在 Java 并发编程中,线程安全是绕不开的核心问题。当多个线程同时执行代码,运行结果和单线程预期不一致,就发生线程不安全。本文结合底层原理,梳理线程不安全的根源,以及对应的解决手段synchronized, volatile, wait, notify, 设计模式等。
1.什么是线程不安全
- 如果多线程环境下程序运行结果, 和单线程环境下预期结果不一致, 就代表线程不安全
经典示例:两个线程同时对同一个变量做 50000 次自增,预期最终结果 100000,实际运行结果往往小于 100000。
我们用代码举例试试看:
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++; } }); Thread t2=new Thread(()->{ for (int i = 0; i < 50000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("count: "+count); }运行结果:
很明显的看到两次运行的结果不同, 且都小于10000
注: count++ 看上去是一行java代码,底层是分为三个步骤:
- 从主内存读取到数据到CPU寄存器
- 在寄存器完成+1 运行
- 将计算结果写回主内存
线程抢占调度会打断这三步,多个线程同时读写同一个变量,就出现数据错乱。
2.线程不安全的四大根本原因
2.1 线程抢占式调度(根本前提)
操作系统对线程是抢占式调度,线程什么时候让出 CPU、什么时候被调度执行,是随机、不可预测的。 多个线程执行顺序千变万化,如果代码没有做并发保护,在某些调度时序下就会出错。
2.2 操作不具有原子性
原子性: 一段操作, 要么全部执行完毕, 要么完全不执行, 中间不能被其他线程打断
很多 Java 语句不是原子操作,例如 count++ 是 3 条 CPU 指令。线程执行一半被切走,其他线程介入修改数据,就产生错误。
类比:卖票系统,线程 A 读到还有 1 张票,还没完成票数更新,线程 B 同时读到还有 1 张票,两个线程都完成卖票,同一张票卖给两个人。
2.3 内存可见性问题
2.3.1 java内存模型JMM:
- 主内存: 存放共享变量(对应物理内存)
- 工作内存: 每个线程独有, 本质是CPU寄存器 高速缓存
2.3.2 线程修改共享变量流程:
- 将主内存变量拷贝副本到自己的工作内存
- 在工作内存修改副本
- 再把副本刷新回主内存
可见性问题: 一个线程修改了工作内存的副本, 没有及时刷新到主内存, 其他线程读取到主内存拿到的值还是旧值, 感知不到修改.
比如子线程循环读取 flag,主线程修改 flag,子线程永远看不到 flag 的变化,循环无法退出。
2.4指令重排序
为提升执行效率,编译器、CPU 会对指令做重排序优化,在保证单线程逻辑不变前提下,调整代码执行顺序。 单线程下重排序没有问题, 但多线程环境,重排序会打乱多线程之间依赖逻辑,引发 bug。
例如:单例模式
instance = new Singleton(),底层分为分配内存、初始化对象、赋值引用三步,重排序可能出现先赋值引用,再初始化对象,其他线程拿到半初始化的对象。
总结: 触发线程安全的必要条件:多线程 + 共享变量 + 对变量进行修改。只读共享变量不会线程不安全。
3.线程不安全的解决方案
解决线程安全就是解决上面的四个问题:保证原子性, 保证可见性, 抑制不合理重排序, 同时规避抢占调度带来的执行顺序混乱.
3.1 方案一: synchronized 同步锁(监视器锁 MonitorLock)
synchronized 可以同时保证: 原子性, 可见性, 抑制指令重排序
核心是互斥: 同一时刻, 只有一个线程能拿到锁执行同步代码块, 其他线程阻塞等待锁释放
synchronized 三种用法
1. 修饰代码块, 指定锁对象
Object locker=new Object(); synchronized (locker){ count++; }2.修饰普通方法: 锁当前对象 this
public synchronized void add(){ count++; }3. 修饰静态方法: 锁类对象 (类的Class对象)
public static synchronized void add(){ count++; }特性:
- 互斥:竞争同一把锁才会阻塞, 不同锁之间互不影响
- 可重入: 同一个线程可以重复获取同一把锁, 不会自己把自己锁死, 内部维护线程持有者+计数器
修改上面自增不安全的代码, 加上synchronized 就可以得到正确的10000结果:
private static int count=0; private static Object locker=new Object(); public static void main(String[] args) throws InterruptedException{ 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); }运行结果:
3.2 方案二: volatile关键字
volatile两大能力:
- 保证内存可见性:读写 volatile 变量强制和主内存交互,不使用线程本地缓存副本;写操作立刻刷新到主内存,读操作每次从主内存读取最新值。
- 禁止指令重排序。
⚠️ 重点:volatile 不能保证原子性!
适合场景:一个线程写,多个线程读,解决可见性。不适合场景:多线程同时写同一个变量(比如 count++,加 volatile 依旧线程不安全)。
例: 解决标志位可见性问题
static class Counter{ public volatile int flag=0; }面试高频: 双重校验锁DCL懒汉单例, instance 必须加volatile, 防止指令重排序导致拿到半初始化对象.
class Singleton{ //静态共享变量,volatile是关键 private static volatile Singleton instance=null; //私有构造方法: 防止外部new对象 private Singleton(){} //对外获取实例的静态方法 public static Singleton getInstance(){ //第一层判断: 外层if,减少抢锁开销 if (instance==null){ //类锁,多个线程竞争这一把锁 synchronized (Singleton.class){ //第二次判断: 内层if,防止多线程等待锁之后重复创建对象 if (instance==null){ instance=new Singleton(); } } } return instance; } }3.3 方案三: wait/notify 做线程之间的协调
synchronized解决竞;wait() / notify() / notifyAll()解决线程之间的执行顺序协调。
wait():释放锁,线程进入等待队列,等待被唤醒;必须写在 synchronized 同步块内。notify():唤醒该对象等待队列中随机一个等待线程;不会立刻释放锁,退出同步块才释放锁。notifyAll():唤醒该对象等待队列全部线程,唤醒后全部线程重新竞争锁。
注: wait 建议放在while 循环判断条件, 防止虚假唤醒. 典型使用场景: 阻塞队列, 生产者-消费者类型.
3.4 方案四: 规避共享可变数据(无锁思路)
- 不共享数据:每个线程使用局部变量(栈上变量,线程私有,不存在多线程竞争)。
- 共享只读数据:变量只读取,不修改,没有写操作,不会线程不安全,例如
String不可变类。
3.5 方案五: 使用线程安全的类
Java 标准库区分线程安全 / 不安全集合:
- ❌线程不安全:
ArrayList、HashMap、HashSet、StringBuilder - ✅线程安全:
StringBuffer、Vector、HashTable(不推荐,性能差);ConcurrentHashMap(并发推荐)
BlockingQueue阻塞队列,天生线程安全,生产者消费者模型首选。
3.6 方案六: 线程池
- 复用线程,减少频繁创建销毁线程开销;同时管理多线程任务,配合阻塞队列做任务管理。
4.面试高频对比
4.1 synchronized vs volatile
| synchronized | volatile | |
|---|---|---|
| 原子性 | ✅保证 | ❌不保证 |
| 可见性 | ✅保证 | ✅保证 |
| 重排序 | ✅禁止 | ✅禁止 |
| 作用 | 修饰代码块 / 方法,加锁互斥 | 修饰变量 |
| 使用场景 | 多线程读写共享变量 | 一个线程写、多线程读,解决可见性 |
4.2 wait() vs sleep()
wait()是 Object 方法;sleep()是 Thread 静态方法。wait()必须在synchronized中调用,会释放锁;sleep()不需要锁,不会释放锁。wait()用于线程间通信,等待其他线程 notify 唤醒;sleep()单纯休眠一段时间,时间到自动恢复。
5.总结
- 优先避免问题:尽量不使用共享可变对象;使用局部变量;使用不可变对象只读。
- 保证原子性:多线程修改共享变量,使用
synchronized锁,保证操作不可被打断。 - 保证可见性:变量用
volatile修饰,或者使用锁(synchronized 自带可见性)。 - 禁止指令重排序:
volatile、synchronized都可以限制重排序。 - 协调线程执行顺序:
wait‑notify、阻塞队列,做生产者消费者模型。
注意:锁会带来性能损耗,不要无脑到处加锁,只把真正存在竞争的临界区代码放进同步块,缩小锁粒度。