【Java SE】多线程(二):线程安全、synchronized、volatile与wait/notify详解
一、线程不安全
1.1 线程不安全的直观现象
多线程的优势是提升效率,但多个线程同时操作共享数据时,极易出现数据错乱的问题,这就是线程不安全。
计数器案例:
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// 线程1:自增5万次
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) count++;
});
// 线程2:自增5万次
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) count++;
});
t1.start();
t2.start();
t1.join();
t2.join();
// 预期10万,实际永远小于10万
System.out.println("count: " + count);
}
运行结果永远小于预期的10万,这就是典型的线程不安全问题。
1.2 线程不安全的原因
操作系统对线程的调度是随机的,这也是线程不安全的罪魁祸首。
(1)原子性缺失:操作被拆分打断
原子性指一段操作不可分割,要么全部执行,要么全部不执行。
count++看似一行代码,实则对应3个CPU的指令:
- load:把内存中的值加载到CPU寄存器中
- add:把寄存器的内容+1
- save:把寄存器中的内容保存回内存中
执行这三个指令时不一定能一次执行完,很有可能1和2执行完,调度走;过了很久,再调度回来执行3。
如果两个线程对于同一个count进行操作,极大概率t1还没来得及保存新结果(1),t2就已经加载并修改,保存了数据(0 -> 1),此时再调度t1,t1接下来该保存新数据(1),t1的修改覆盖了t2的修改,对于这两次自增,count的结果是1。这样多次覆盖,就会导致count最终的结果小于100000
(2)可见性缺失:数据更新互相看不见
Java内存模型(JMM)规定线程有独立工作内存,共享数据存主内存。
- 线程修改共享变量时,先改工作内存副本,再同步到主内存。
- 线程A修改了变量,但未及时同步到主内存,线程B读取的还是旧值,导致逻辑错误。
(3)有序性缺失:指令被乱序优化
为提升效率,编译器和CPU会对指令重排序(不影响单线程结果),但多线程下会打乱逻辑:
- 典型场景:双重检查锁单例模式中,
instance = new Singleton()可能被重排序,导致线程获取未初始化的对象,引发空指针。
二、synchronized
synchronized是Java内置的互斥锁,能同时保证原子性、可见性、有序性,是解决线程安全最常用的关键字。
- 进入 synchronized 修饰的代码块, 相当于加锁
- 退出 synchronized 修饰的代码块, 相当于解锁
加锁操作不是把线程锁死在CPU上,不让这个线程被调度走,而是禁止其他线程重新加这个锁,避免其他线程的操作,在当前线程的执行过程中插队。
2.1 三大特性
(1)互斥性(原子性)
synchronized用的锁是存在Java对象里的,可以粗略的理解为每个对象在内存中存储时,都有一块内存表示当前锁定的状态(类似于厕所的有人/无人)
- 如果是无人状态,就可以使用,使用时设置为“有人”状态
- 如果是有人状态,其他人无法使用,只能等待。
理解阻塞等待:
针对每一把锁,操作系统内部都维护了一个等待队列,当这个锁被某个线程占有时,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直到之前的线程解锁后,由操作系统唤醒一个新线程,再来获取这个锁。
- 上个一个线程解锁后,下一个线程不是立即就能获取,而是靠操作系统来“唤醒”,这也是操作系统线程调度的一部分工作
- 假设A B C三个线程,线程A先获得锁,然后B尝试获取,C再尝试获取。此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来,B不一定立即获得锁,而是重新与C竞争。
- 同一时刻,只有一个线程能获取同一把锁,执行临界区代码。
- 其他线程尝试获取锁时,会进入阻塞等待状态,直到锁被释放。
(2)可见性
- 线程进入
synchronized代码块时,清空工作内存,从主内存加载最新数据。 - 线程退出代码块时,强制将修改后的数据刷新回主内存,其他线程能立即看到最新值。
(3)可重入性
理解“把自己锁死”
一个线程没有释放锁,又尝试重新加锁
例如第一次加锁,成功上锁;第二次加同一把锁,锁已经被占用,就会阻塞等待。
按照之前的锁的设定,第二次加锁,阻塞等待,直到第一次的锁释放;而释放第一个锁也是由该线程完成,这样就陷入了死循环,把自己锁死了。
这样的锁称为“不可重入锁”
Java的synchronized引入了可重入的概念,同一线程可重复获取同一把锁,不会自己锁死自己。
底层通过线程持有者+计数器实现:加锁时计数器+1,解锁时计数器-1,计数器为0时真正释放锁。
2.2 三种使用方式
(1)修饰代码块(锁自定义对象)
// 锁任意对象
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock) { // 加锁
count++;
} // 解锁
}
});
}
对于锁来说任意对象都可以,一般情况下,我们会专门定义一个Object类给锁使用。
(2)修饰实例方法(锁当前对象)
// 锁当前实例对象
public synchronized void increment() {
count++;
}
(3)修饰静态方法(锁类对象,全局唯一)
// 锁当前类的Class对象,所有实例共享一把锁
public synchronized static void increment() {
count++;
}
2.3 修复计数器案例
给count++加synchronized锁,保证原子性:
private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock) { count++; }
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (lock) { count++; }
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count: " + count); // 输出10万
}
2.4 死锁
死锁是指两个或多个线程,各自拿着对方需要的锁,又互相等待对方释放锁,谁都不放手、谁都走不了,程序永久阻塞卡死。
构成死锁的必要条件
- 锁是互斥的。一个线程拿到锁后,另一个线程想要拿锁必须阻塞等待
- 锁不可剥夺。线程1拿到锁,线程2也想获取这个锁,必须阻塞等待,而不能直接抢占
- 请求和保持。一个线程拿到锁A,不释放锁1的前提下,获取锁B
- 循环等待。 多个线程的等待过程构成了循环,例如A等B释放,B也在等A释放
如何避免死锁
前两个条件是锁的基本特性,想要避免死锁的出现就要破坏掉3或4.
- 统一锁的获取顺序
所有线程都按固定顺序拿锁,例如约定从序号小的锁开始获取,如果锁已经被获取,就要阻塞等待 - 放弃请求与保持
要么一次性把需要的锁全部拿到,一把都不拿不到就不执行。 - 设置超时
尝试拿锁等待一段时间,拿不到就放弃,不永久阻塞。 - 减少嵌套加锁
2.5 Java标准库中的线程安全类
Java标准库中很多是线程不安全的,这些类可能会涉及多线程修改共享数据,又没有加锁措施。
ArrayListLinkedListHashMapTreeMapHashSetTreeSetStringBuilder
线程安全类:
-
StringBuffer是线程安全的,正是因为其中的方法都有synchronized。
但是由于synchronized的限制,代码中可能出现锁的竞争导致阻塞,会使代码的效率大打折扣 -
String:虽然没有加锁,但是不涉及修改,仍然是线程安全的
三、volatile
3.1 内存可见性问题
以下面代码为例:t2改变flag的值来影响t1的执行,当输入1时,理论上t1内while条件不成立,应该跳出循环,线程结束。然而实际上输入1后t1线程还在继续
public class demo18 {
private static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag==0){
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(()->{
//修改flag
Scanner scan = new Scanner(System.in);
System.out.println("输入flag的值:");
flag = scan.nextInt();
});
t1.start();
t2.start();
}
}
很明显,这也是由于线程安全导致的bug。一个线程在读取,一个线程在修改,修改的值没有被另一个线程读取到,这就是“内存可见性问题”。
这涉及到编译器优化:
我们写的代码,都会通过javac 把.java文件编译成.class字节码文件,由jvm执行。编译器可以保持代码逻辑不变的情况下,对代码进行优化,提升效率。
而在多线程场景,编译器很有可能判断错误,导致优化前后的逻辑不完全相同。
分析编译器如何误判:
t1中是空循环,对于CPU主要就是两个操作:load (加载flag的值),cmp(条件跳转)
- 对于
cmp:cpu的寄存器操作,速度快很多 - 对于
load:需要到内存中访问,时间可能是cmp的几千倍。
每轮循环执行速度非常快,短时间内就可以执行很多次,每次读取flag的值都是不变的。经过多次循环,JVM认为这个读取操作可以被优化(正是因为load在循环中时间消耗是cmp的几千倍),因此把读内存操作改成了读寄存器操作。
而用户输入值可能要经过好几秒,与上述的操作时间完全不是一个量级。等到用户真的输入flag的值,t1已经感知不到了(编译器优化使得t1的读操作不是真正的读内存)
如果在循环中加入一些语句
while(flag==0){
sleep(1);
}
加入sleep后,使循环的速度大大大大幅度下降,此时load时间占比对于整个循环小了很多,JVM认为这个优化没有必要,每次都是读内存操作。因此t2的修改可以被t1感知到,结果正确。
3.2 volatile的作用
volatile是轻量级并发关键字,不保证原子性,仅保证可见性和禁止指令重排序,适合解决“一个线程写、多个线程读”的场景。
(1)保证可见性:数据更新立即同步
- 写
volatile变量:修改后立即刷新到主内存。 - 读
volatile变量:强制从主内存读取最新值,不读工作内存缓存。
解决线程感知不到变量更新的问题:
public class demo18 {
private volatile static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag==0){
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(()->{
//修改flag
Scanner scan = new Scanner(System.in);
System.out.println("输入flag的值:");
flag = scan.nextInt();//输入后t1就可以感知到,跳出循环,线程结束
});
t1.start();
t2.start();
}
}
(2)禁止指令重排序:避免逻辑错乱
volatile变量前后会加内存屏障,禁止编译器和CPU对其前后指令重排序。- 应用:双重检查锁(DCL)单例模式,防止指令重排序导致的空指针。
volatile适合无复合操作(如count++)、仅需可见性/有序性的场景;复合操作必须用synchronized或AtomicInteger。
四、wait 等待 / notify 通知
多线程不仅要“互斥”,还要“协作”——比如生产者生产完数据,通知消费者消费;消费者无数据时等待。wait()、notify()、notifyAll()是实现线程等待-唤醒的核心方法,定义在Object类中。
4.1 方法详解
(1)wait():让线程等待并释放锁
- 作用:当前线程进入阻塞等待状态,释放持有的锁,允许其他线程获取锁执行任务。
- 重载:
wait()(无限等待)、wait(long timeout)(超时等待,毫秒)。 - 必须在
synchronized代码块/方法中调用,否则抛IllegalMonitorStateException。
(2)notify():随机唤醒一个等待线程
- 作用:随机唤醒一个在当前对象锁上等待的线程。
- 唤醒后不立即释放锁,需当前线程退出
synchronized代码块后,被唤醒线程才能竞争锁。
(3)notifyAll():唤醒所有等待线程
- 作用:唤醒所有在当前对象锁上等待的线程。
- 所有线程被唤醒后竞争同一把锁,同一时刻只有一个线程能执行。
注意:
- 调用wait(),notify()以及这两个方法所在的synchronized代码块内必须是同一对象才能生效
- 要确保先wait 再notify,才会有作用。如果先notify再wait,不会对notify所在的线程有影响,但是没啥用
4.2 wait()与sleep()的区别
| 特性 | wait() | sleep() |
|---|---|---|
| 所属类 | Object类 | Thread类 |
| 锁行为 | 释放锁 | 不释放锁 |
| 唤醒方式 | 需notify()/notifyAll()唤醒 | 超时自动唤醒 |
| 使用场景 | 线程协作(等待-唤醒) | 线程休眠(暂停执行) |
如果sleep()在synchronized代码块内,就会出现“抱着锁睡”的情况,休眠期间其他线程也不能拿到这把锁。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)