资讯动态

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

发布时间:2026/10/26 1:58:18 来源:尧图企业网站定制
写过多线程代码的同学大概率都遇到过这种诡异现象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 可见性Visibility3.3 有序性Ordering——指令被重排序四、解决方案总览五、synchronized保证原子性6.1 两个核心特性6.2 用法示例七、volatile保证可见性八、总结一、什么是线程安全想给线程安全下一个严谨的定义很难但可以这样通俗理解如果多线程环境下代码运行的结果符合我们在单线程环境下的预期那么这个程序就是线程安全的。二、线程安全问题的根源线程调度是随机的Java 的线程是抢占式调度哪个线程能在什么时候占用 CPU由操作系统调度器决定程序员无法精确预知。随机调度让同一个程序在多线程环境下的执行顺序存在无数种可能。于是带来一条铁律程序员必须保证在任意一种执行顺序下代码都能正常工作。只要有一种交叉顺序会让结果出错这个程序就不安全。三、原因原子性 / 可见性 / 有序性随机调度本身只是诱因它最终是通过破坏下面三个特性来制造问题的。3.1 原子性Atomicity——操作被打断原子性指一个操作要么完整执行要么完全不执行中间不能被别的线程插入。关键事实一条 Java 语句不一定是一条指令也不一定是原子的。比如开头那个 count其实在 CPU 层面被拆成了三步从内存把 count 读到 CPU 寄存器在寄存器里执行 1把结果写回内存。如果线程 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优化。例如这段代码去前台取下 U 盘去教室写 10 分钟作业去前台取下快递。单线程下编译器发现按1 → 3 → 2执行也能得到一样的结果还能少跑一趟前台于是就这么优化了。这在单线程下没问题因为编译器保证逻辑不发生变化。但在多线程下就麻烦了代码执行复杂度高编译器很难在编译阶段预测所有线程的交叉效果激进的重排序很容易让优化后的逻辑和原先不等价从而引发状态错乱。一句话有序性 执行顺序符合预期。它解决的是代码被重排后逻辑悄悄变了的问题。四、解决方案总览解决线程安全宏观上有三条思路使用没有共享资源的模型—— 各线程各干各的互不碰同一份数据共享资源只读、不写—— 只读不写就没有竞争或用不可变对象直面线程安全重点—— 通过加锁/关键字把三个特性补回来保证原子性→ 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 t1new Thread(()-{ for(int i0;i50000;i){ synchronized (locker){ count; } } }); Thread t2new Thread(()-{ for(int i0;i50000;i){ synchronized (locker){ count; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(countcount); } }2可重入reentrantsynchronized 同步块对同一条线程是可重入的不会出现自己把自己锁死。为什么强调这点如果锁不可重入一个线程没释放锁又去加锁第二次加锁就会阻塞等待可释放第一个锁的也是它自己——它已经躺平了就永远解不开于是死锁。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; } }如果不加 volatilet1 读的是自己工作内存里的旧值即使主线程把 flag 改成 truet1 也感知不到循环永远不结束可见性问题。加 volatilet1 能立刻感知变化循环正常结束。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补可见性强制读写主内存但不保证原子性。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑