Java并发-线程基础与synchronized关键字
在现代程序开发中,并发处理已经成为必备技能。特别是在分布式系统大行其道的今天,从前端处理到服务调用、缓存处理、数据库处理、文件处理、消息处理等等,都离不开并发编程的知识。
本文是Java并发系列的开篇,我们从最基础的线程概念开始,逐步深入到synchronized
关键字的使用。希望通过这个系列,能帮大家建立起完整的并发编程知识体系。
# 一、线程基础知识
我们先从概念上理解一下进程和线程的关系:
- 进程:是线程的容器,是程序在操作系统中的运行实例
- 线程:是程序执行的最小单位,一个进程可以包含多个线程
线程相比进程有个很大的优势:线程间的切换和调度成本远远小于进程。这也是为什么在并发编程中我们更多使用线程的原因。
在深入学习之前,我们先了解一下线程的生命周期,这对后面的理解很重要:
- NEW(新建):线程对象被创建,但还没有调用
start()
方法- RUNNABLE(可运行):调用
start()
方法后,线程处于就绪状态,等待CPU调度- BLOCKED(阻塞):线程被阻塞于锁,等待获取
synchronized
锁- WAITING(等待):线程调用
wait()
、join()
或park()
方法后进入无限期等待状态- TIMED_WAITING(超时等待):调用带超时参数的
sleep()
、wait()
、join()
方法- TERMINATED(终止):线程执行完毕或因异常而终止
这些状态之间的转换构成了线程的完整生命周期。刚接触可能觉得复杂,先有个整体印象,随着学习深入会逐渐清晰。
# 二、线程基本操作
# 1、创建并启动线程
Java中创建线程主要有两种方式:继承Thread
类或实现Runnable
接口。我们来看具体的实现:
public class HelloThread extends Thread{
@Override
public void run() {
System.out.println("hello");
}
}
//和
public class HelloRunnable implements Runnable{
@Override
public void run() {
System.out.println("hello runnable");
}
}
有了线程的主体,来看看如何新建并启动线程:
new HelloThread().start();
//和
new Thread(new HelloRunnable()).start();
从使用方式上可以看出:
- 继承
Thread
类:可以直接创建实例并调用start()
方法 - 实现
Runnable
接口:需要将其作为参数传递给Thread
构造器
实际开发中更推荐使用Runnable
接口,因为Java是单继承的,实现接口更加灵活。
# 2、线程终止
关于线程的生命周期,有几个重要概念需要理解:
- JVM会等待所有非守护线程结束后才会退出
- 守护线程(通过
thread.setDaemon(true)
设置)会随着主线程一起结束 - 如果有用户线程在长时间运行,JVM就不会退出
Thread.stop()
方法已经被废弃,因为它会强制终止线程,可能导致数据不一致。正确的做法是通过标志位来优雅地终止线程:
class HelloThread extends Thread{
private boolean isStop;
@Override
public void run() {
try {
sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (!isStop) {
System.out.println("hello thread");
}
}
public void stopMe() {
isStop = true;
}
}
//main中的代码
HelloThread thread = new HelloThread();
thread.start();
thread.stopMe();
# 3、线程中断机制
除了使用标志位,JDK还提供了更优雅的线程中断机制。线程中断的工作原理是:给目标线程发送一个中断信号,告诉它"有人希望你停止执行",但具体如何响应这个信号,完全取决于线程自己的实现逻辑。
Thread类提供了三个相关方法:
interrupt()
:实例方法,用来给目标线程发送中断信号isInterrupted()
:实例方法,检查线程是否收到中断信号interrupted()
:静态方法,检查当前线程是否收到中断信号,并清除中断状态
先来一个简单的例子,演示一下前两个方法:
class HelloThread extends Thread {
@Override
public void run() {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("stop!");
break;
}
}
}
}
//main中执行
HelloThread thread = new HelloThread();
thread.start();
thread.interrupt();
这个例子展示了中断机制的基本用法:主线程调用子线程的interrupt()
方法发送中断信号,子线程通过isInterrupted()
检查中断状态,收到信号后主动退出。
需要注意的是,线程完全可以选择忽略中断信号,但这样做不太规范,建议总是对中断信号进行适当的处理。
中断机制在不同的阻塞场景下表现不同,需要特别注意:
1. 可中断的阻塞方法
当线程正在执行wait()
、sleep()
、join()
等方法时收到中断信号,这些方法会:
- 清除线程的中断状态
- 抛出
InterruptedException
异常
2. 不可中断的阻塞
当线程被阻塞在synchronized
关键字或Lock.lock()
上时,中断信号不会让线程抛出异常,只会设置中断标志位。这种情况下需要在获取锁后主动检查中断状态:
while (!Thread.currentThread().isInterrupted()) {
synchronized (lock) {
// 业务逻辑
}
}
不过好在Java并发包中的工具类提供了更好的支持,比如Lock.lockInterruptibly()
方法就能响应中断。关于这些高级工具我们会在后续文章中详细介绍。
# 4、等待和通知机制
在多线程协作中,经常需要一个线程等待某个条件满足后再继续执行。Java提供了wait()
和notify()
方法来实现这种线程间通信。
工作机制很简单:
- 线程A调用
obj.wait()
后会释放锁并进入等待状态 - 线程B调用
obj.notify()
会唤醒一个在该对象上等待的线程 - 被唤醒的线程需要重新获取锁才能继续执行
来看一个具体的例子:
public class Demo {
final static Object object = new Object();
public static class T1 extends Thread {
public void run() {
synchronized (object) {
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static class T2 extends Thread {
public void run() {
synchronized (object) {
object.notify();
}
}
}
}
使用这段代码时,可以先启动T1线程(会进入等待状态),然后启动T2线程(唤醒T1),最后两个线程都正常结束。
重要的使用约束:
wait()
和notify()
必须在synchronized
块中调用- 调用前必须先获得对象的监视器锁
notify()
随机唤醒一个等待线程,notifyAll()
唤醒所有等待线程
这种机制为线程间协作提供了基础支撑。
# 5、join和yield方法
这两个方法提供了不同的线程协作方式:
join方法
当线程A调用线程B的join()
方法时,线程A会阻塞等待,直到线程B执行完毕。这相当于"插队"机制,确保某个线程优先完成。
Thread t1 = new Thread(() -> System.out.println("任务1"));
Thread t2 = new Thread(() -> System.out.println("任务2"));
t1.start();
t1.join(); // 等待t1完成
t2.start(); // 确保任务1先执行完
yield方法
yield()
方法让当前线程主动让出CPU时间片,给相同优先级的其他线程执行机会。如果没有相同优先级的就绪线程,则可能什么都不做。这是一种"谦让"机制,不保证一定会切换线程。
# 三、synchronized
关键字
前面我们了解了线程的基本概念和操作,现在该面对多线程编程中最核心的问题了:线程安全。
当多个线程同时访问共享资源时,就可能出现数据不一致的问题。为了更好地理解这个概念,我们先了解几个重要术语:
- 临界区:同时只能被一个线程访问的共享资源区域
- 竞态条件:多线程并发访问共享数据,最终结果取决于线程执行的时序
让我们通过一个实际例子来看看线程安全问题:
public class Demo {
static int num = 0;
public static class T1 extends Thread {
public void run() {
num++;
}
}
public static void main(String[] args) {
int step = 0;
while (true) {
num = 0;
test();
System.out.println(++step);
if (num != 5) {
System.out.println("num: " + num);
break;
}
}
}
public static void test() {
T1 t1 = new T1();
T1 t2 = new T1();
T1 t3 = new T1();
T1 t4 = new T1();
T1 t5 = new T1();
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
try {
t1.join();
t2.join();
t3.join();
t4.join();
t5.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
这个例子创建了5个线程,每个线程对共享变量num
执行自增操作,期望的结果是5。但是,如果你多次运行这个程序,会发现偶尔会出现num
小于5的情况。
为什么会这样?原因在于num++
并不是原子操作,它实际上包含三个步骤:
- 读取
num
的当前值 - 将值加1
- 将结果写回
num
当多个线程同时执行这三个步骤时,就可能出现数据竞争,导致最终结果不正确。
# 1、synchronized基本原理
Java提供了synchronized
关键字来解决线程安全问题。synchronized
提供了互斥锁机制,确保同一时刻只有一个线程能够执行被保护的代码块,从而实现原子性。
synchronized
的基本结构包含两部分:
- 锁对象:用作同步监视器的对象引用
- 同步代码块:需要被保护的临界区代码
现在我们来修复上面的线程安全问题:
public static class T1 extends Thread {
public void run() {
synchronized(this)
num++;
}
}
在这个修复后的版本中:
- 锁对象是
this
,即当前的线程实例 - 同步代码块是
num++
操作
但这里有个问题:每个线程实例都有自己的锁,所以实际上这种写法并不能解决线程安全问题!正确的做法应该是使用共享的锁对象:
public static class T1 extends Thread {
private static final Object LOCK = new Object();
public void run() {
synchronized(LOCK) {
num++;
}
}
}
synchronized
的工作原理是:线程执行到同步块时会尝试获取锁对象的监视器,获取成功后执行临界区代码,执行完毕后释放锁。其他线程必须等待锁释放才能获得执行机会。
# 2、synchronized的使用方式
synchronized
有多种使用方式,适用于不同的场景:
private static final Object object = new Object();
public static class T1 extends Thread {
public void run() {
synchronized (object) {
num++;
}
}
}
// 方式2:修饰实例方法
public static class T1 extends Thread {
public synchronized void run() {
num++;
}
}
// 方式3:修饰静态方法
public static class T1 extends Thread {
public static synchronized void increment() {
num++;
}
public void run() {
increment();
}
}
不同使用方式的锁对象:
- 同步代码块:锁对象是括号内指定的对象
- 实例方法:锁对象是当前实例(
this
) - 静态方法:锁对象是当前类的Class对象
需要注意的是,只有使用相同锁对象的同步代码才能互斥。如果锁对象不同,就无法实现同步效果。
# 3、synchronized使用注意事项
1. 避免使用可变的锁对象
// 错误示例
synchronized (new Object()) { // 每次都是新对象,无法实现同步
// ...
}
2. 避免使用可能被缓存的对象作为锁 Integer
、String
、Boolean
等对象在JVM中可能被重用(如字符串常量池、整数缓存),这意味着你的锁对象可能被其他不相关的代码使用,导致意外的同步行为:
// 存在风险的写法
String lockStr = "LOCK"; // 可能被其他代码复用
synchronized (lockStr) {
// ...
}
// 推荐做法
private static final Object LOCK = new Object();
synchronized (LOCK) {
// ...
}
# 4、synchronized的可重入特性
synchronized
锁是可重入的,这是一个非常重要的特性。来看一个典型的场景:
class Widget {
public synchronized void doSomething() {
...
}
}
class LoggingWidget extends Widget {
@Override
public synchronized void doSomething() {
System.out.println("logging do something");
super.doSomething();
}
}
当调用LoggingWidget.doSomething()
时会发生什么?
- 线程首先获得
LoggingWidget
实例的锁 - 在方法内部调用
super.doSomething()
时,需要再次获得同一个对象的锁
如果锁不是可重入的,这里就会发生死锁:线程在等待自己已经持有的锁。
可重入性的含义:同一个线程可以多次获得它已经持有的锁。Java中的synchronized
锁维护了一个获取计数器和拥有者线程的标识:
- 当线程第一次获得锁时,计数器为1
- 同一线程再次获得锁时,计数器递增
- 每次释放锁时,计数器递减
- 计数器为0时,锁才真正释放
这种机制是基于线程的,而不是基于方法调用的,确保了同一线程的递归调用和方法间调用不会产生死锁。
# 四、总结
本文介绍了Java并发编程的基础知识:
线程基础:
- 理解线程生命周期的6个状态
- 掌握线程创建的两种方式:继承
Thread
类和实现Runnable
接口 - 学会优雅地终止线程:标志位法和中断机制
- 了解线程间协作:
wait()
/notify()
、join()
/yield()
synchronized关键字:
- 解决多线程访问共享资源的安全问题
- 提供互斥锁机制,确保代码块的原子性执行
- 支持多种使用方式:同步代码块、同步方法
- 具备可重入特性,避免死锁问题
这些知识为后续学习更高级的并发工具(如Lock
、Executor
、并发容器等)打下了坚实基础。在实际开发中,正确使用synchronized
已经可以解决大部分的线程安全问题。
祝你变得更强!