
一. 线程安全问题
在多线程环境下代码的并发执行,可能导致程序的运行结果与我们的预期结果出现一定偏差,造成这样偏差的原因就称为线程安全问题。
(引入) 多线程并发执行下导致的线程安全问题的案例:
java
public class Demo15 {
private static int count = 0;
//多个线程同时修改(非原子性操作)同一个变量 造成的线程安全问题
public static void main(String[] args) throws InterruptedException {
//多线程代码并发执行出现线程安全问题
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
count++;//对应三个CPU指令
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
count++;
}
System.out.println("t2 线程结束");
});
t1.start();
t2.start();
t1.join();
t2.join();
//一个线程自增 50000 次,两个线程总共自增 100000 次,预期结果 count == 100000 //多线程同时修改 (非原子操作)同一个变量导致线程安全问题
System.out.println("count == " + count);
}
}
main 线程等待t1线程 和 t2 线程各自对count 变量自增50000 次后,打印count的值,程序预期结果为100000。运行程序,得到结果如下:
两个线程并发执行各自对count 自增50000 次后,得到的运行结果 与 预期结果不同,这样出现的问题就叫线程安全问题 (线程不安全)。
案例解析
- 实际上count++ 这一行代码对应着三个CPU指令:
-
- LOad:将内存中count 的值加载到CPU 的寄存器中。
-
- Add:将CPU 相应寄存器的内容进行加一 (+1)操作。
-
- Save:将对应寄存器中的内容保存会内存上。
事实上,上述count++ 实际上对应了3个CPU指令。同时站在操作系统的角度来说,线程的调度是随机的,也就是说在多线程的情况下,对于count++ 这一操作对应的CPU指令并不一定是一口气执行完的 (发生线程调度),中间可能穿插着其他线程对count 的修改操作,从而导致线程不安全。
- Save:将对应寄存器中的内容保存会内存上。
多线程环境下,CPU 在执行count++ 对应的三个指令的过程中,由于存在线程的随即调度,因此可能存在多种调度顺序。下面我们举例说明上述导致线程安全问题代码中可能出现的几种调度顺序 (这里仅仅列举了其中的几种调度顺序)。
- 线程调度下的线程安全情况

按照上述时间轴执行,执行过程如下:
此时两个线程各自对count++,自增两次结果正确。
- 线程调度下的线程不安全情况
按照时间轴执行,执行过程如下:
t1 线程和 t2 线程各自count++, 预计自增两次结果程序只自增一次,出现线程安全问题。
也就是说在如上线程的随机调度下,就可能会导致线程安全问题。分析上述线程调度结果,我们知道两个线程同时自增5w 次后的结果可能会出现偏差 (最终结果不是10W的情况)。
对于上述分析,我们知道出现线程安全的原因是由于多线程的穿插执行。由于多线程的穿插执行使得上述代码运行结果可能出现 <= 10W (多次count++ 只出现自增1 的操作)的情况。
那么是什么原因导致线程安全问题的产生的呢?
二. 如何解决线程安全问题
1. 线程不安全的原因
- [ 根本 ] 操作系统对线程的调度是随机的 (抢占式执行) ,这是线程安全问题的罪魁祸首。(抢占式执行使得线程的调度顺序变得不可控,随机)
- 多个线程同时修改同一个变量 (共享数据)
- 修改操作不是原子的 (可拆分成多步)。当一条操作不是原子性的,在对变量进行操作时,中途其他线程插入 ,导致这个操作被打断 (原线程操作未完成)就可能使得结果出错。
当然除了上述造成线程不安全的原因之外,还存在着因为编译器优化导致的线程安全问题:
- 内存可见性问题 引起的 线程安全问题
- 指令重排序 引起的 线程安全问题
2. 线程安全问题的处理
对于我们来说 线程调度的随机性是操作系统规定死的,那么我们就只能处理 多个线程同时修改同一个变量,且修改操作不是原子的 这样的原因了。
对于上述案例中出现的线程安全问题,我们可以通过加锁的形式将count++ 对应的三条CPU指令打包成一个原子操作。这样就破坏了,修改操作不是原子的这一因素。
2.1 synchronized
synchronized
解决修改操作非原子 引起的 线程安全问题
java
//synchronized使用
synchronized(要加锁的锁对象) {
//要加锁打包成原子操作的代码
}
//
synchronized 关键字
在Java中被称为 监视器锁 (monitor lock)
。
借助synchronized 对要保证线程安全的代码进行加锁打包成原子操作。
java
//使用synchronized 解决案例代码出现的线程安全问题
public class Demo16 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized(locker) {
count++;
}
}
System.out.println("t1 线程结束");
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized(locker) {
count++;
}
}
System.out.println("t2 线程结束");
});
//使用synchronized 对同一个锁对象加锁,使得count++ 打包成原子操作,解决两个线程各自自增 50000 次造成的线程安全问题
t1.start();
t2.start();
//等待t1, t2 线程执行完毕,在执行 main 线程的打印
t1.join();
t2.join();
System.out.println("线程t1, t2 各自增 50000 次后 count == " + count);
}
}
运行程序结果如下:
使用synchronized 将要求线程安全代码打包成原子操作后,程序运行结果 和 预期结果一致,程序得以正确执行。
1. synchronized
的特性
- 互斥 : 进入synchronized ,当多个线程对同一个锁对象加锁时,就会产生互斥效果。争夺到锁对象 的线程就能够继续执行 ,争夺不到锁对象 的线程就会阻塞等待,直到获得锁对象,才能继续向下执行。进入synchronized 修饰的代码块,就会对锁对象加锁;退出synchronized 修饰的代码块,就相当解锁。
java
public class Demo28 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(() -> {
synchronized(locker) {
System.out.println("t1 线程拿到锁");
while(true) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
},"t1");
Thread t2 = new Thread(() -> {
synchronized(locker) {
System.out.println("t2 线程拿到锁");
while(true) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
},"t2");
t1.start();
t2.start();
}
}
运行程序,执行结果如下:

观察运行结果,我们发现由于两个线程中的执行逻辑都是死循环,因此当t1 线程持有锁后,t2 线程会进入BLOCKED 状态,(因竞争同一个锁对象失败,阻塞等待在 synchronized )。
上述特性,我们可以理解为排队上厕所的情形 (当只存在一个厕所 和 多个需要上厕所的线程 争夺厕所的场景,争夺到厕所的线程就加锁,其他争夺失败的线程就阻塞等待厕所 (锁对象))。
- 可重入 :一个线程针对同一锁对象多次加锁,而不会出现死锁。
正常情况下,一个线程针对同一锁对象进行多次加锁操作,会出现阻塞等待现象,而这种阻塞等待获取锁对象的操作将无法完成 (锁对象在第一次加锁处被加锁,要想获取锁就必须继续往下执行完成所有操作才能释放锁,但是要想继续向下执行就必须获取锁),这样就造成了死锁的情况。
java
//以下伪码表示为:一个线程针对同一锁对象多次加锁而造成的死锁局面
Object locker = new Object();
locker.lock();//第一次加锁
//第一把锁加锁代码中间的执行逻辑
locker.lock();//第二次加锁
//由于第一把锁未释放,因此第二次加锁阻塞等待,直到获取锁对象
//第二把锁加锁代码中间的执行逻辑
locker.unlock();//针对第二次加锁,释放加锁对象
locker.unlock();//针对第一次加锁,释放加锁对象

java 针对上述情况做了特殊处理,即synchronized 是可重入锁,可以实现一个线程对同一锁对象多次加锁的情况。
java
//一个线程针对同一把锁 连续加锁的情况 在没有可重入特性下出现的死锁问题
public class Demo19 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
//可重入锁 synchronized Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
//对同一锁对象连续加锁,正常情况会发生死锁,(一个线程中持有锁后又再次争夺锁进入阻塞所引发的死锁)。
//java 中 synchronized针对这一情况引入可重入的特性,(即一个线程中可出现多次针对同一锁对象进行加锁而不阻塞的现象) -- 一个线程针对同一把锁 连续加锁的情况
//可重入锁的关键是 让锁对象内部报错当前加锁线程的信息,和计数器 (记录加锁的次数,从而确定真正的解锁时机)
synchronized(locker) {
synchronized(locker) {
count++;
}
}
}
System.out.println("t1 线程结束");
});
t1.start();
t1.join();
System.out.println("count == " + count);
}
}
程序运行结果如下:
题外话:那么为什么java 中较为主张使用
synchronized + 代码块
这样的方案,而不是采用lock + unlock
函数的方式来使用锁呢?
lock + unlock
的这种写法很容易把unlock
这个操作遗漏 (加锁逻辑中因为某处提前return
,或者抛出异常 从而带走进程而导致unlock
操作无法执行).- Java中采取的
synchronized + 代码块
的形式就能够确保,当你出了代码块后就一定能够释放锁。(无论是代码某处逻辑中提前return
,或抛异常
带来的进程结束)
题外话:Java的可重入的特性很好的解决了 一个线程对同一个锁对象多次加锁而引起的死锁问题,那么如何自己实现一个可重入锁呢?
可重入锁实现的核心,主要在于要求锁对象内部保存线程持有者 和计数器这样的信息。
- 让锁内部存有线程持有者的相关信息,每次对锁对象加锁的时候判断,锁线程持有者是否和当前加锁的线程为同一个。(同一个则计数器自增 (cnt++),继续向下执行;不同则阻塞等待)。
- 解锁的时候计数器减减 (cnt--),当计数器递减为0的时候才真正释放锁。(别的线程才有机会获得锁)。通过计数器来确定合适真正解锁。
2. synchronized 修饰方法
- 修饰普通方法
java
//修饰普通方法相当于对this 对象加锁
public class SynchronizedDemo {
public synchronized void method() {
//要加锁保证线程安全的代码
}
//上述synchronized 修饰方法的效果和 下列一致
// public void method() {
// synchronized(this) {
// //要加锁保证线程安全的代码块
// }
// }
}
- 修饰静态方法
java
//synchronized 修饰静态方法相当于对类对象(SynchronizedDemo.class) 加锁
public class SynchronizedDemo {
public synchronized static void method() {
//要加锁保证线程安全的代码
}
//上述synchronized 修饰方法的效果和 下列一致
// public static void method() {
// synchronized(SynchronizedDemo.class) {
// //要加锁保证线程安全的代码块
// }
// }
}
2.2 内存可见性
内存可见性导致的线程安全问题。
java
import java.util.Scanner;
//内存可见性问题 --> 编译器 & jvm 在不改变原先程序的逻辑下 对你的代码进行调整 使得程序效率提升
public class Demo22 {
//volatile --> (jmm java内存模型)解决内存可见性问题
private static boolean flag = false;
public static void main(String[] args) {
//内存可见性问题导致的线程安全问题
Thread t1 = new Thread(() -> {
while(!flag) {
//编译器优化使得都内存操作变为读寄存器操作,导致程序感知不到 外部flag的变化进而导致的线程安全问题
}
System.out.println("t1 线程执行完毕");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag 的值");
flag = scanner.nextBoolean();
System.out.println("t2 线程执行完毕");
});
t1.start();
t2.start();
System.out.println("main 线程执行完毕");
}
}
观察上述代码,t1 线程会执行一段死循环 (这段死循环内没有任何要执行的逻辑),当我们的t2 线程修改flag 的值为 true 时,我们预期t1 线程会执行结束。那么实际结果和我们预期结果相同吗?
运行程序,执行结果如下:
我们发现t1 线程并没有正确结束,也就是说t1 线程并没有感知到 t2 线程对 flag 的修改。
导致上述代码出现线程安全问题的原因,其实是编译器对我们代码的执行进行优化而造成的原因。
编译器 (编译器 & JVM)优化
:声称在保持原有代码逻辑不变的过程中对代码进行优化,使得程序效率提高。
死循环中的判断代码,其实对应两个CPU指令。Load 指令
是都内存操作,将内存中的值加载到寄存器中读;而Cmp 指令
是存CPU寄存器操作。因此这两个指令的时间开销存在着数量级上的差异。
内存可见性问题:在执行死循环过程中,这两个指令操作会被反复执行,在反复执行的过程中 JVM 就能感知到 LOad 操作反复执行 (站在计算机角度来说,可能经历了沧海山田后依然未发生修改。)的结果 好像都是一样的 ,因此编译器将读内存的操作优化为读寄存器的操作,这样当t2 线程修改外界内存时,t1 线程就无法感知到变量的修改 (读内存操作 被优化为 读寄存器操作)。
1. volatile
Java中对于编译器优化 造成的内存可见性问题,引入volatile 关键字
,通过这个关键字来修饰某个变量,此时编译器针对这个变量的读取操作就不会被优化成读寄存器 (编译器不会优化这个变量的读取操作)。
java
//引入volatile 修饰变量后的代码
import java.util.Scanner;
//内存可见性问题 --> 编译器 & jvm 在不改变原先程序的逻辑下 对你的代码进行调整 使得程序效率提升
public class Demo22 {
//volatile --> (jmm java内存模型)解决内存可见性问题
private volatile static boolean flag = false;
public static void main(String[] args) {
//内存可见性问题导致的线程安全问题
Thread t1 = new Thread(() -> {
while(!flag) {
//编译器优化使得都内存操作变为读寄存器操作,导致程序感知不到 外部flag的变化进而导致的线程安全问题
}
System.out.println("t1 线程执行完毕");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag 的值");
flag = scanner.nextBoolean();
System.out.println("t2 线程执行完毕");
});
t1.start();
t2.start();
System.out.println("main 线程执行完毕");
}
}
运行程序,执行结果如下:
题外话:提到
volatile
,我们很难不想到JMM (Java内存模型)
,那么Java内存模型 (JMM) 中是如何解释上述问题的呢?
每个线程中都有一个自己的工作内存 (work memory) ,同时这些线程共享同一个主内存 (main memory) 。当一个线程循环进行上述读取变量的操作时,就会将主内存 (main memory) 中的数据拷贝回该线程的工作内存 (work memory) 中。后续当另一个线程修改该变量时,也是先修改自己的工作内存 (work memory) ,再拷贝到主内存 (main memory) 中。由于第一个线程仍然在读自己的工作内存 (work memory) ,因此感知不到主内存 (main memory) 的变化。
2.3 指令重排序
指令重排序 引起的 线程安全问题。
同样的指令重排序也是编译器 (编译器 & JVM)优化 的一种体现,编译会在保持代码逻辑不变的前提下,调整代码执行的先后顺序 (对应CPU指令的先后执行顺序),从而达到提升性能的效果。

多线程并发执行中,不同的执行顺序可能会引发线程安全问题。正常情况下,划线代码应该按照 1 -> 2 -> 3 的顺序执行CPU指令 ,但在编译器优化下可能发生指令重排序的操作,从而使得CPU指令按照 1 -> 3 -> 2 这样的顺序执行 。经过调整后的执行顺序在多线程环境下,可能会出现线程安全问题。
同样的指令重排序引起的线程安全问题需要借助关键字 volatile
进行解决。被volatile
修饰的变量的读取和修改操作不会触发指令重排序。
volatile
的功能:
- 确保每次读取操作都是读内存,避免出现内存可见性问题
- 被
volatile
修饰的变量的读取和修改操作不会触发指令重排序