ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

【Java 多线程】线程安全问题:原因与解决方案全解析

【Java 多线程】线程安全问题:原因与解决方案全解析

写过多线程代码的同学,大概率都遇到过这种"诡异"现象:

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++; // 自增 5w 次 }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) count++; // 自增 5w 次 }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("count: " + count); // 预期 10w,实际常常不到! }

两个线程各自增 5 万次,count 理论上应该是10 万,可实际跑出来的结果却经常小于 10 万,甚至每次都不一样。这就是典型的线程安全问题。

目录

一、什么是线程安全

二、线程安全问题的根源

三、原因:原子性 / 可见性 / 有序性

3.1 原子性(Atomicity)——操作被打断

3.2 可见性(Visibility)

3.3 有序性(Ordering)——指令被重排序

四、解决方案总览

五、synchronized:保证原子性

6.1 两个核心特性

6.2 用法示例

七、volatile:保证可见性

八、总结


一、什么是线程安全

想给"线程安全"下一个严谨的定义很难,但可以这样通俗理解:

如果多线程环境下代码运行的结果,符合我们在单线程环境下的预期,那么这个程序就是线程安全的。

二、线程安全问题的根源

线程调度是随机的

Java 的线程是抢占式调度:哪个线程能在什么时候占用 CPU,由操作系统调度器决定,程序员无法精确预知。随机调度让同一个程序在多线程环境下的执行顺序存在无数种可能。

于是带来一条铁律:

程序员必须保证:在任意一种执行顺序下,代码都能正常工作。只要有一种交叉顺序会让结果出错,这个程序就不安全。

三、原因:原子性 / 可见性 / 有序性

随机调度本身只是"诱因",它最终是通过破坏下面三个特性来制造问题的。

3.1 原子性(Atomicity)——操作被打断

原子性指:一个操作要么完整执行,要么完全不执行,中间不能被别的线程插入。

关键事实:一条 Java 语句不一定是一条指令,也不一定是原子的。比如开头那个 count++,其实在 CPU 层面被拆成了三步:

  1. 从内存把 count 读到 CPU 寄存器;
  2. 在寄存器里执行 +1;
  3. 把结果写回内存。

如果线程 A 刚做完第 1 步,线程 B 也把旧值读走并 +1 写回,然后 A 再用自己的旧值 +1 写回——两次++只产生了+1的效果,值就丢了。这正是 count 到不了 10 万的原因。

再举个生活化的例子:火车站售票,A 客户端查到还剩 1 张票,正要去 update 数据库;B 客户端也查到还剩 1 张,于是又卖了一次——同一张票被卖了两次。这也是原子性被破坏。

一句话:原子性 = 操作不被打断。正因为它会被打断,抢占式调度才有机会"插一脚"。

3.2 可见性(Visibility)

可见性指:一个线程对共享变量值的修改,能否及时被其他线程看到。

这就涉及Java 内存模型(JMM):

  • 所有共享变量存在主内存(Main Memory);
  • 每个线程有自己的工作内存(Working Memory),本质是 CPU 寄存器和高速缓存;
  • 线程读共享变量时,会先从主内存拷贝到工作内存再读;
  • 线程修改共享变量时,会先改工作内存的副本,再同步回主内存。

问题来了:因为每个线程都持有一份"副本",线程 1 改了工作内存里的值,主内存和其他线程的工作内存不一定能及时同步。于是线程 2 读到的还是旧值。

(为什么 JMM 要这么"麻烦"地拷来拷去?因为 CPU 访问寄存器/缓存的速度比访问内存快几千上万倍,先缓存能大幅提速;而之所以不全部用寄存器,原因就一个字——贵。)

一句话:可见性 = 修改能被别人看到。它解决的是"你改了,我看不到"的问题。

3.3 有序性(Ordering)——指令被重排序

有序性指:代码的执行顺序应该符合逻辑预期。

但 JVM 和 CPU 会做指令重排序(Instruction Reordering)优化。例如这段代码:

  1. 去前台取下 U 盘;
  2. 去教室写 10 分钟作业;
  3. 去前台取下快递。

单线程下,编译器发现按1 → 3 → 2执行也能得到一样的结果,还能少跑一趟前台,于是就这么优化了。这在单线程下没问题,因为编译器保证逻辑不发生变化。

但在多线程下就麻烦了:代码执行复杂度高,编译器很难在编译阶段预测所有线程的交叉效果,激进的重排序很容易让优化后的逻辑和原先不等价,从而引发状态错乱。

一句话:有序性 = 执行顺序符合预期。它解决的是"代码被重排后,逻辑悄悄变了"的问题。

四、解决方案总览

解决线程安全,宏观上有三条思路:

  1. 使用没有共享资源的模型—— 各线程各干各的,互不碰同一份数据;
  2. 共享资源只读、不写—— 只读不写就没有竞争;或用不可变对象;
  3. 直面线程安全(重点)—— 通过加锁/关键字把三个特性补回来:
  • 保证原子性→ synchronized
  • 保证可见性→ volatile
  • 保证顺序性→ 依赖同步机制提供的"内存屏障"

五、synchronized:保证原子性

synchronized 也叫监视器锁(monitor lock),是解决原子性的主力手段。

6.1 两个核心特性

1)互斥(mutex)

  • 某个线程进入某个对象的 synchronized 代码块,相当于给这个对象加锁;
  • 其他线程若也想进入同一个对象的 synchronized 块,就会阻塞等待;
  • 进入 = 加锁,退出 = 解锁。

用生活化的比喻:一段代码像一间"房间",线程是进房间的人。没有任何机制时,A 进去 B 也能进去,隐私(操作)就被打断——这就是没原子性。给房间加把锁,A 进去就把门锁上,其他人只能排队,原子性就有了。

锁的底层:synchronized 用的锁存在Java 对象头里,可以粗略理解成每个对象都带一块"有人/无人"的标记(类似厕所门)。底层基于操作系统的mutex lock实现,解锁后由 OS 从等待队列唤醒新线程(不遵循先来后到,是重新竞争)。

package thread; public class Demo14 { private static int count= 0; private static Object locker = new Object(); public static void main(String[] args) throws InterruptedException { //创建两个线程,分别对同一个变量进行5W次的++操作 //最终主线程打印结果 Thread t1=new Thread(()->{ for(int i=0;i<50000;i++){ synchronized (locker){ count++; } } }); Thread t2=new Thread(()->{ for(int i=0;i<50000;i++){ synchronized (locker){ count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("count="+count); } }

2)可重入(reentrant)

  • synchronized 同步块对同一条线程是可重入的,不会出现"自己把自己锁死"。
  • 为什么强调这点?如果锁不可重入:一个线程没释放锁又去加锁,第二次加锁就会阻塞等待,可释放第一个锁的也是它自己——它已经"躺平"了,就永远解不开,于是死锁。
  • Java 的 synchronized 是可重入锁,内部有"线程持有者 + 计数器":加锁时若发现占着锁的正是自己,仍可继续获取并让计数器 +1;解锁时计数器减到 0 才真正释放。

6.2 用法示例

① 修饰代码块:锁指定对象(任意对象)

public class SynchronizedDemo { private Object locker = new Object(); public void method() { synchronized (locker) { // 临界区 } } }

② 修饰代码块:锁当前对象

public void method() { synchronized (this) { // 临界区 } }

③ 修饰普通方法:锁 SynchronizedDemo 的实例对象

public synchronized void method() { }

④ 修饰静态方法:锁 SynchronizedDemo 的类对象

public synchronized static void method() { }

思考:synchronized 锁的是什么?两个线程竞争的是同一把锁才会产生阻塞;如果各自拿的是不同的锁,那就没有竞争,该不安全还是不安全。

七、volatile:保证可见性

volatile 的核心作用就是保证内存可见性。

它是怎么做到的呢?针对 volatile 修饰的变量,JMM 强制:

  • 写入时:改变线程工作内存中volatile变量副本的值,将改变后的副本的值从工作内存刷新到主内存。
  • 读取时:从主内存中读取volatile变量的最新值到线程的工作内存中,从工作内存中读取volatile变量的副本。

也就是说,volatile 让读写强制走主内存。代价是速度慢了(毕竟缓存快、内存慢),但数据更准确了。

典型应用——用标志位让线程优雅停止:

package thread; public class VolatileDemo { static class Counter { public volatile boolean flag = false; } public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { while (!counter.flag) { // 空循环 } System.out.println("循环结束! t1感知到flag变成true"); }); t1.start(); Thread.sleep(2000); System.out.println("主线程:修改 flag = true"); counter.flag = true; } }

如果不加 volatile,t1 读的是自己工作内存里的旧值,即使主线程把 flag 改成 true,t1 也感知不到,循环永远不结束(可见性问题)。加 volatile,t1 能立刻感知变化,循环正常结束。

volatile不保证原子性

volatile和synchronized有着本质的区别.synchronized能够保证原⼦性,volatile保证的是内存可见性。

代码示例
这个是最初的演示线程安全的代码.
• 给 increase 方法去掉 synchronized
• 给 count 加上 volatile 关键字.

public class VolatileCountDemo { static class Counter { volatile public int count = 0; void increase() { count++; } } public static void main(String[] args) throws InterruptedException { final Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.count); } }

此时可以看到, 最终 count 的值仍然无法保证是 100000。

八、总结

  • 什么是线程安全:多线程运行结果 = 单线程预期结果。
  • 总根源:抢占式随机调度,让执行顺序有无数种可能。
  • 三个原因:
    • 原子性:count++ 等操作被拆成多步,会被打断 → 值丢失、重复操作;
    • 可见性:JMM 工作内存副本不同步,改了别人看不到;
    • 有序性:指令重排序在多线程下可能改变逻辑。
  • 三条宏观思路:① 无共享资源;② 共享资源只读/不可变;③ 直面线程安全。
  • 两种主力手段:
    • synchronized:补原子性,互斥 + 可重入;
    • volatile:补可见性,强制读写主内存,但不保证原子性。
返回列表