JAVA的线程安全问题
一.线程安全的概念
线程安全是指当多个线程同时访问某个对象、方法或变量时,系统仍然能保持正确的行为和数据一致性,无需调用者进行额外的同步协调。它是多线程编程的基石,用于防止因并发操作导致的数据混乱、计算结果错误或程序崩溃等问题,想准确给出⼀个线程安全的确切定义是复杂的。
但我们可以这样认为:
如果多线程环境下代码运⾏的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的
二.观察线程不安全
示例代码:
public class Demo1 { //这里定义一个int类型的变量 private static int count = 0; public static void main(String[] args) throws InterruptedException { //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { count++; } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + count); } }当上述代码执行后,得到的结果并不是预期中的10万,而是一个5万多的值,多执行几次该代码,每次得到的结果都不同,这便是采用多线程编程引发的线程安全问题
三.导致线程安全问题出现的原因
那么为什么会导致出现线程安全问题呢,主要可分为以下几种情况
1.线程调度是随机的(主要原因)
这是线程安全问题的罪魁祸首,随机调度使⼀个程序在多线程环境下,执行顺序会存在很多的变数. 程序猿必须保证在任意执行顺序下,代码都能正常工作.但是这是操作系统的底层逻辑,我们无法去改变
2.两个或多个线程针对同一个变量进行修改操作
当两个或多个线程针对同一个变量进行修改操作时,如果没有适当的同步机制,就必然会引发数据竞争,导致程序行为出现不确定性错误。
就针对上述线程不安全的例子来说,t1线程和t2线程同时对count变量进行累加修改操作,但由于count++这个操作它并不是"原子性"的。它看起来是一步,但在计算机底层实际分为三步:
(1)读取当前count值到寄存器(load)
(2)对寄存器中的值进行修改(add)
(3)将寄存器中的值重新写回内存中(save)
但由于操作系统的调度是随机的,可能会导致以下的情况发生,假设初始值 count = 5。
| 时间点 | 线程t1的操作 | 线程t2的操作 | 内存中count的值 |
|---|---|---|---|
| 1 | 读取 count=5 | 5 | |
| 2 | 计算 5+1=6 | 读取 count=5 | 5 |
| 3 | 计算 5+1=6 | 5 | |
| 4 | 写回 6 | 6 | |
| 5 | 写回 6 | 6 |
最终结果:两个线程都执行了一次自增,但最终 count = 6,而不是正确的 7。线程B的写入覆盖了线程A的写入,导致一次更新“丢失”。
3.原子性
原子性 是并发编程中的一个核心概念,指一个或多个操作要么全部成功执行,要么完全不执行,中间不会被打断,也不会被其他线程看到中间状态。
简单来说就好比银行转账,从账户A扣款100元,向账户B加款100元。原子性要求这两个操作必须一起完成。如果只扣了A的钱但没加到B上,就是原子性被破坏,会导致数据不一致。或者说上面的count++操作,count++ 在底层需要三步(读→改→写)。原子性则是要求这三步像一步一样执行,其他线程看不到中间过程。
4.内存可见性
内存可见性指在多线程环境中,当一个线程修改了共享变量后,其他线程能够立即、可靠地看到最新值的能力。这是并发编程中除原子性外的另一核心挑战,因为现代计算机的多级缓存架构和编译器的指令重排序优化可能导致一个线程的修改滞留在本地缓存,而其他线程仍读取到旧值或处于不一致中间状态的数据。
5.解决线程安全问题的方法
为了解决线程不安全问题,在这里引入一个新的概念"锁"
上述示例代码的优化版本:
public class Demo2 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Object locker = new Object(); //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized (locker) { count++; } } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { synchronized (locker) { count++; } } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + count); } }这时代码运行的结果就是预期中的10万了
四.什么是"锁"
1.锁的基本概念
锁 是并发编程中实现线程同步的核心机制,用于控制多个线程对共享资源的访问顺序,确保同一时间只有一个(或固定数量)的线程能执行特定代码段或访问特定数据。
2.锁的核心作用
(1)实现互斥:保证临界区代码一次只能被一个线程执行
类比说明
| 场景 | 锁的类比 | 说明 |
|---|---|---|
| 单间厕所 | 门锁 | 一人进去锁门,其他人等待 |
| 会议室 | 钥匙/门禁卡 | 只有持卡人才能进入开会 |
| 游戏手柄 | 手柄本身 | 只有拿着手柄的人能操作角色 |
(2)保证原子性:将非原子操作包装成原子操作
就好像前面的示例代码,再加锁之前,因为count++操作不是原子性的,在线程调度过程中会产生线程安全问题,但对于修改后的代码来说,线程的运行逻辑就会改变,还是假设初始值 count = 5
| 时间点 | 线程t1的操作 | 线程t2的操作 | 内存中count的值 |
|---|---|---|---|
| 1 | 获取到锁(lock) | 5 | |
| 2 | 读取 count=5(load) | 5 | |
| 3 | 计算 5+1=6(add) | 获取锁,发现已经被占用,开始阻塞等待(lock) | 5 |
| 4 | t2加锁失败,放弃cpu,进入阻塞状态,操作系统又调度给t1(或者其他线程) | 5 | |
| 5 | 写回6 (save) | 6 | |
| 6 | 释放锁(unlock) | 6 | |
| 7 | 获取到锁,阻塞结束,读取 count=6(load) | 6 | |
| 8 | 计算 6+1=7(add) | 6 | |
| 9 | 写回7 (save) | 7 | |
| 10 | 释放锁(unlock) | 7 |
修改后的代码在执行时,t2执行lock开始尝试获取锁,但由于此时锁已经被线程t1获取了,所以此时t2线程获取锁失败,会进入阻塞等待状态,直到t1线程释放锁,t2线程获取到锁,t2线程才会继续执行
3.synchronized关键字
3.1核心本质
synchronized 通过在对象头中设置标记来实现基于监视器(Monitor)的锁机制,确保同一时间只有一个线程能执行特定代码。
3.2基本形式
synchronized (obj) { //一系列代码逻辑 }进入大括号即为加锁,出了大括号即为解锁,小括号里填的是一个Object类型的对象
注意:
(1)synchronized关键字并不关心()里填的是什么对象,只关心当两个或多个线程()里填的是一个对象时,这些线程才会产生"锁竞争"或者说"锁排斥"
(2)加锁并不意味着"禁止线程调度",而是禁止其他线程插队,也就是说当线程1在执行锁内的逻辑时,是可以被调度走的,但是其他线程想要申请这个锁时,会进行阻塞等待
(3)与其它编程语言不同,synchronized关键字更稳健,不管在锁内的代码逻辑中出现return或者throw,都会确保执行unlock即释放锁,避免因为忘记释放锁引起的线程安全问题
3.3三种使用方式
(1)直接修饰代码块:明确指定锁哪个对象,如前面的示例代码
(2)把synchronized加到实例方法上,此时就是给this加锁
class Count { public int count = 0; synchronized public void add() { count++; } } public class Demo2 { public static void main(String[] args) throws InterruptedException { Count c = new Count(); //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c.add(); } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { c.add(); } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + c.count); } }(3)synchronized还可以修饰一个静态方法
public class Demo3 { private static int count = 0; synchronized private static void add() { count++; } public static void main(String[] args) throws InterruptedException { //线程t1对count变量进行累加 Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { add(); } }); //线程t2也对count变量进行累加 Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { add(); } }); t1.start();t2.start(); t1.join();t2.join(); //打印最后累加完count的结果 System.out.println("count为:" + count); } }3.4可"重入"锁
synchronized 同步块对同⼀条线程来说是可重入的,不会出现自己把自己锁死的问题
理解"把自己锁死" ⼀个线程没有释放锁,然后又尝试再次加锁. // 第一次加锁,加锁成功 lock(); // 第二次加锁,锁已经被占用,阻塞等待,按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待.直到第一次的锁被释放,才能获取到第二个锁.但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作.这时候就会死锁
但是:Java 中的synchronized是可重入锁,因此没有上⾯的问题.
五.死锁问题
1.基本概念
死锁是多线程编程中最严重的问题之一,指两个或多个线程永久阻塞,互相等待对方持有的资源,导致程序无法继续执行
示例代码:
public class Demo4 { private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { throw new RuntimeException(e); } } public static void main(String[] args) { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); sleep(1000); synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); sleep(1000); synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); } } }); t1.start();t2.start(); } }死锁过程分析:
| 时间点 | 线程t1的操作 | 线程t2的操作 | 锁状态 |
|---|---|---|---|
| t0 | 启动 | 启动 | locker1=空闲, locker2=空闲 |
| t1 | 获取 locker1 | 获取 locker2 | locker1=t1持有, locker2=t2持有 |
| t2 | 休眠1秒 | 休眠1秒 | 双方各持有一把锁 |
| t3 | 醒来,尝试获取 locker2(但被t2持有) → 阻塞等待 | 醒来,尝试获取 locker1(但被t1持有) → 阻塞等待 | 互相等待对方释放锁 |
| t4 | 无限期等待locker2 | 无限期等待locker1 | 死锁形成 |
2.死锁产生的四个必要条件
| 条件 | 在代码中的体现 |
|---|---|
| 1. 互斥 | synchronized 确保每个锁一次只能被一个线程持有 |
| 2. 持有并等待 | t1持有locker1等待locker2,t2持有locker2等待locker1 |
| 3. 不可剥夺 | Java锁不能被强制抢占,只能主动释放 |
| 4. 循环等待 | t1 → 等待 t2 的locker2,t2 → 等待 t1 的locker1 |
3.解决死锁的方法
死锁产生有四个必要条件,打破其中任意一个,便可以解除死锁
(1)"锁"是互斥的(这是锁的基本特点),对于synchronized关键字来说是解决不了的
(2)"锁"不可被抢占,这对于synchronized关键字来说也是解决不了的
(3)持有并等待,即t1线程在持有locker1这个锁的前提下,还想要获取locker2这个锁,解决办法是在编写代码时尽量避免出现锁的嵌套
(4)打破循环等待,就需要约定加锁的顺序,即把锁进行编号,约定任何一个线程在需要加多把锁时,都按照锁编号从小到大的顺序来加锁
//修改后的代码,锁编号解决死锁问题 public class Demo4 { private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { throw new RuntimeException(e); } } public static void main(String[] args) { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); sleep(1000); synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); } } }); Thread t2 = new Thread(() -> { synchronized (locker1) { System.out.println("t1线程获取到locker1这个锁"); sleep(1000); synchronized (locker2) { System.out.println("t1线程获取到locker2这个锁"); } } }); t1.start();t2.start(); } } 六.内存可见性问题
1.内存可见性导致的线程安全问题
实例代码:

2.原因分析
通过上述代码可以发现当我们在t2线程中修改了flag的值,按预期来说t1线程应该检测到flag非0,从而结束线程,但结果是t1线程并没有结束,这便是内存可见性导致的线程安全问题
内存可见性问题,本质上是因为编译器优化引起的,在JAVA的程序员中,有的大佬代码写的非常简洁明了,更加高效,但是有的小白的代码逻辑比较低效,为了更高效的运行,大佬们便想了个办法,就是在编译器中加入"优化机制",即小白写出了一串代码逻辑,在编译器编译的时候就会自动分析这一串代码,在保持代码逻辑不变的前提下,自动修改代码内容,让代码变得更高效
那么上述代码为什么会出现问题,从ti线程的while循环语句,站在cpu的角度来看可分为两个步骤,(1)从内存中读取flag的值储存到寄存器中(load),(2)比较寄存器中的值和0是否相同,若相同便继续执行,不相同使用跳转语句跳转到某个位置,在执行时因为第一步读内存操作的开销比第二步比较的开销大的多,在执行了多次以后(输入操作之前的时间足够让这个循环执行上万次),编译器就发现flag每次读到的值好像都是一样的,且编译器也没有发现flag在哪里有修改(虽然t2线程里有修改flag的值的操作,但是编译器无法判断另一个线程的执行时机),于是编译器做了一个大胆的决定,就把load操作优化掉了,以后只从寄存器/缓存中读取flag的值,此时t2线程修改flag的值,t1线程就感知不到了。
从JMM的角度来说,在JAVA的进程中。每个线程都会有一份工作内存(work Memory),同时这些线程还共享一份主内存(main Memory),而当一个线程进行修改和读取操作时候
修改:先把数据从主内存拷贝到工作内存,对工作内存进行操作,再写回主内存
读取:先把数据从主内存拷贝到工作内存,再从工作内存中读取
内存可见性:
t1线程while循环判定的是工作内存里的值
t2线程修改的是主内存里的值,由于t1工作内存是主内存数据的副本,导致修改主内存,不会影响t1工作内存里的值
3.volatile 关键字
为了解决上述问题JAVA就引入了volatile 关键字
3.1 基本应用
volatile 是 Java 提供的一种轻量级的同步机制,用于确保变量的可见性和禁止指令重排序,但它不保证原子性。它常用来修饰某个变量,此时编译器就知道这个变量"易变",后续在针对这个变量的读写操作时就不会涉及优化操作了
修改后的代码:

3.2 volatile 不保证原子性
volatile 和 synchronized 有着本质的区别.synchronized能够保证原⼦性,volatile保证的是内存可⻅ 性,因此在前面的问题中使用volatile是无法解决线程安全问题的,volatile适用于内存可见性导致的线程安全问题
volatile vs synchronized
| 特性 | volatile | synchronized |
|---|---|---|
| 保证原子性 | ❌ 不保证(除了单次读/写) | ✅ 完全保证 |
| 保证可见性 | ✅ 完全保证 | ✅ 完全保证 |
| 保证有序性 | ✅ 禁止重排序 | ✅ 保证串行执行 |
| 适用场景 | 状态标志、一次性发布 | 复合操作、临界区 |
| 性能开销 | 小 | 较大 |
| 阻塞行为 | 不会阻塞 | 会阻塞线程 |