1. 引言
并发编程是 Java 开发者进阶的必经之路,而理解 Java 内存模型(Java Memory Model,JMM)则是掌握并发安全的基石。很多初学者在编写多线程程序时,会遇到「明明改了变量,另一个线程却看不到」「程序运行结果不稳定」等诡异问题,这些往往都与 JMM 的三大特性——可见性、原子性、有序性——有关。
本文将从 JMM 的核心概念出发,依次讲解 volatile 能解决什么、不能解决什么,synchronized 的锁升级机制,以及 happens-before 规则,最后通过实战案例帮助你把这些知识串起来。
2. 从 JMM 说起:可见性、原子性、有序性
Java 内存模型规定了多线程环境下共享变量的读写规则,其核心是解决三个问题:可见性、原子性、有序性。
2.1 可见性(Visibility)
可见性指的是:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。
在 JMM 中,每个线程都有自己的工作内存(可以理解为 CPU 缓存),线程对变量的读写都发生在工作内存中,再同步回主内存。如果缺少同步机制,一个线程的修改可能不会及时刷新到主内存,其他线程读到的就是旧值。
publicclassVisibilityDemo{privatestaticbooleanflag=false;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadreader=newThread(()->{while(!flag){// 空转等待}System.out.println("flag 变为 true,退出循环");});reader.start();Thread.sleep(100);flag=true;// 主线程修改 flag}}上面的代码中,reader线程可能永远无法退出循环,因为主线程对flag的修改对reader线程不可见。
2.2 原子性(Atomicity)
原子性指的是:一个操作要么全部执行成功,要么全部不执行,中间不能被其他线程打断。
Java 中,对基本类型的赋值和读取(如int x = 1)是原子的,但i++这种「读-改-写」复合操作并不是原子的,它实际上包含三步:读取 i、计算 i+1、写回 i。在多线程环境下,这会导致结果丢失更新。
publicclassAtomicityDemo{privatestaticintcount=0;publicstaticvoidmain(String[]args)throwsInterruptedException{Thread[]threads=newThread[10];for(inti=0;i<10;i++){threads[i]=newThread(()->{for(intj=0;j<10000;j++){count++;}});threads[i].start();}for(Threadt:threads){t.join();}System.out.println("count = "+count);// 结果通常小于 100000}}2.3 有序性(Ordering)
有序性指的是:程序执行的顺序按照代码的先后顺序执行。但为了提升性能,编译器和 CPU 可能会对指令进行重排序(reordering),只要重排序后的结果与单线程执行结果一致即可。在多线程环境下,重排序可能导致意想不到的问题。
publicclassOrderingDemo{privatestaticintx=0,y=0;privatestaticinta=0,b=0;publicstaticvoidmain(String[]args)throwsInterruptedException{for(inti=0;i<100000;i++){x=0;y=0;a=0;b=0;Threadt1=newThread(()->{a=1;x=b;});Threadt2=newThread(()->{b=1;y=a;});t1.start();t2.start();t1.join();t2.join();if(x==0&&y==0){System.out.println("第 "+i+" 次出现重排序");}}}}3. volatile:能解决什么,不能解决什么
volatile 是 Java 提供的最轻量的同步机制,它主要解决可见性和有序性问题。
3.1 volatile 能解决什么
保证可见性:volatile 变量的写操作会立即刷新到主内存,读操作会从主内存读取,从而保证其他线程能看到最新值。
保证有序性:volatile 通过内存屏障禁止指令重排序,保证 volatile 变量读写前后的指令顺序。
publicclassVolatileDemo{privatestaticvolatilebooleanflag=false;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadreader=newThread(()->{while(!flag){// 空转等待}System.out.println("flag 变为 true,退出循环");});reader.start();Thread.sleep(100);flag=true;// volatile 保证 reader 线程能看到}}3.2 volatile 不能解决什么
不能保证原子性:volatile 无法解决i++这类复合操作的原子性问题,因为「读-改-写」三步之间仍可能被其他线程打断。
publicclassVolatileNotAtomicDemo{privatestaticvolatileintcount=0;publicstaticvoidmain(String[]args)throwsInterruptedException{Thread[]threads=newThread[10];for(inti=0;i<10;i++){threads[i]=newThread(()->{for(intj=0;j<10000;j++){count++;}});threads[i].start();}for(Threadt:threads){t.join();}System.out.println("count = "+count);// 仍然小于 100000}}适用场景总结:
- 适合:状态标志位、单次发布不可变对象、作为「轻量级锁」使用。
- 不适合:需要保证原子性的复合操作、多个变量之间的约束关系。
4. synchronized:锁升级与原理
synchronized 是 Java 内置的同步机制,既能保证原子性,也能保证可见性和有序性。它的底层实现经历了从重量级锁到轻量级锁的演进,这就是所谓的「锁升级」。
4.1 synchronized 的基本用法
publicclassSynchronizedDemo{privateintcount=0;// 同步方法publicsynchronizedvoidincrement(){count++;}// 同步代码块publicvoidincrementBlock(){synchronized(this){count++;}}}4.2 锁升级过程
Java 6 之后,synchronized 引入了偏向锁、轻量级锁、重量级锁的升级机制,目的是在竞争不激烈时减少锁开销。
- 偏向锁:只有一个线程访问同步块时,锁会偏向该线程,减少不必要的 CAS 操作。
- 轻量级锁:当有第二个线程竞争时,偏向锁升级为轻量级锁,通过自旋等待获取锁。
- 重量级锁:当自旋超过阈值或竞争激烈时,升级为重量级锁,依赖操作系统互斥量,线程会阻塞。
4.3 synchronized 与 volatile 的对比
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证(禁止重排序) | 保证 |
| 性能开销 | 较小 | 较大(有锁升级优化) |
| 适用场景 | 状态标志、单次发布 | 复合操作、临界区 |
5. happens-before 规则与实战案例
happens-before 是 JMM 定义的一组规则,用来判断两个操作之间是否存在「先行发生」关系。如果 A happens-before B,那么 A 的执行结果对 B 可见,且 A 的指令顺序在 B 之前。
5.1 核心 happens-before 规则
- 程序顺序规则:同一个线程中,前面的操作 happens-before 后面的操作。
- 监视器锁规则:对一个锁的解锁 happens-before 后续对这个锁的加锁。
- volatile 变量规则:对一个 volatile 变量的写 happens-before 后续对这个变量的读。
- 传递性规则:如果 A happens-before B,B happens-before C,则 A happens-before C。
5.2 实战案例:双重检查锁单例
经典的线程安全单例模式,结合 volatile 和 synchronized:
publicclassSingleton{privatestaticvolatileSingletoninstance;privateSingleton(){}publicstaticSingletongetInstance(){if(instance==null){// 第一次检查synchronized(Singleton.class){if(instance==null){// 第二次检查instance=newSingleton();}}}returninstance;}}这里 volatile 的作用是防止new Singleton()的指令重排序。new操作实际上包含三步:分配内存、初始化对象、将引用指向内存。如果不加 volatile,编译器可能将后两步重排序,导致其他线程拿到一个未初始化完成的对象。
5.3 实战案例:生产者-消费者模型
利用 volatile 作为状态标志,配合 synchronized 保证临界区安全:
publicclassProducerConsumerDemo{privatestaticvolatilebooleanrunning=true;privatestaticfinalObjectlock=newObject();privatestaticintbuffer=0;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadproducer=newThread(()->{while(running){synchronized(lock){buffer++;System.out.println("生产,buffer = "+buffer);}try{Thread.sleep(50);}catch(InterruptedExceptione){}}});Threadconsumer=newThread(()->{while(running){synchronized(lock){if(buffer>0){buffer--;System.out.println("消费,buffer = "+buffer);}}try{Thread.sleep(80);}catch(InterruptedExceptione){}}});producer.start();consumer.start();Thread.sleep(1000);running=false;// volatile 保证两个线程都能看到停止信号producer.join();consumer.join();}}6. 总结
本文从 JMM 的三大特性出发,梳理了并发编程的核心知识:
- 可见性、原子性、有序性是并发安全的三个维度,理解它们是排查并发问题的前提。
- volatile解决可见性和有序性,但不解决原子性,适合状态标志等轻量场景。
- synchronized通过锁升级机制在保证原子性的同时兼顾性能,是临界区保护的默认选择。
- happens-before规则是判断操作间可见性的理论依据,双重检查锁单例是它的经典应用。
在实际开发中,建议优先使用java.util.concurrent包下的工具类(如AtomicInteger、ConcurrentHashMap),它们封装了底层的并发细节,更不容易出错。理解 JMM 的底层原理,能帮助你在遇到疑难并发问题时快速定位根因。