java并发:java内存模型与多线程之volatile
java内存模型
Java作为平台无关性语言,JSL(java语言规范)定义了一个统一的内存管理模型JMM(Java Memory Model),JMM屏蔽了底层平台内存管理细节。
JMM的核心目标是确保多线程环境下的 原子性、可见性 和 有序性。
(1)原子性保证操作不可分割;
(2)可见性确保一个线程对共享变量的修改能被其他线程及时看到;
(3)有序性则通过禁止指令重排序来维护代码逻辑顺序。
JMM规定了JVM有主内存(Main Memory)和工作内存(Working Memory)。


主内存
即java堆内存,存放程序中所有的类实例、静态数据等变量,是多个线程共享的。
工作内存
用于存放线程从主内存中拷贝过来的变量以及访问方法所取得的局部变量,是每个线程私有的,其他线程不能访问。
Note:
线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。
于是每个线程对变量的操作都是先从主内存将其拷贝到工作内存,再对其进行操作,多个线程之间不能直接互相传递数据。
示例
在java中,执行下面这个语句:
执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中,而不是直接将数值10写入主存当中。
happens-before 原则

补充:
传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
join规则:如果线程A执⾏操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
如果两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执⾏。如果重排序之后的执⾏结果,与按happens-before关系来执⾏的结果⼀致,那么JMM也允许这样的重排序。
总之,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可⻅的,不管它们在不在⼀个线程。
内存不可见问题
可见性指的是在一个线程中修改变量的值以后,在其他线程中能够看到这个值。
Java 内存模型是一个抽象的概念,在实际实现中线程的工作内存是什么呢?
为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。
Java 内存模型的工作内存对应下图中的 Ll 或者 L2 缓存或者 CPU 的寄存器。

案例
假如线程 A 和线程 B 同时处理一个共享变量 , 会出现什么情况?
情景假设:
线程 A 和线程 B 使用不同CPU执行,并且当前两级Cache都为空
执行过程:
- 线程A根据需要获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中 X 的值(假如为 0),并把 X 的值缓存到两级缓存
- 线程A修改 X 的值为 1,然后将其写入两级 Cache,并刷新到主内存
- 线程B获取 X 的值,一级缓存没有命中,二级缓存命中了,返回X=1
- 线程B修改 X 的值为 2,将其存放到线程 2 所在的一级 Cache 和共享二级 Cache 中,最后更新主内存中X的值为2
- 线程A根据需要获取共享变量X的值,一级缓存命中,其中X=l
到这里问题就出现了,线程 B 已经把 X 的值修改为了 2,线程 A 获取的还是 1;这就是共享变量的内存不可见问题,此处体现为线程 B 写入的值对线程 A 不可见。
据此可知,在java内存模型中,存在缓存一致性问题问题,所以在多线程环境中必须解决可见性问题。
对此Java提供了解决方式:使用关键字 volatile
关键字volatile
volatile是一个特殊的修饰符,只有成员变量才能使用它,具体用法如下:
public volatile static int count=0;//在声明的时候带上volatile关键字即可
与Synchronized及ReentrantLock等提供的互斥相比,Synchronized保证了Synchronized同步块中变量的可见性,而volatile则是保证了所修饰变量的可见性。
volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义。
在功能上,锁⽐ volatile更强⼤;在性能上,volatile更有优势。
详解
关键字volatile,从表面意思上是说这个变量是易变的,不稳定的。
这个关键字的作用是告诉编译器,凡是被该关键字声明的变量都是易变的、不稳定的;所以不要试图对该变量使用缓存等优化机制,而应当每次都从它的内存地址中去读值。
Note:
volatile只提供了内存可见性,而没有提供原子性。
同一个变量多个线程间的可见性与多个线程中操作互斥是两件事情,操作互斥提供了操作整体的原子性,所以说如果用这个关键字做高并发的安全机制的话是不可靠的。
问题:什么时候使用volatile关键字?
根据volatile的特点,其最好用于对内存可见性要求高,而对原子性要求低的地方。
更具体一点来说,其适用于写入不依赖变量的当前值场景。
如果依赖当前值,则是“获取、计算、写入”的三步操作,要求这三步操作是原子性的,而volatile不保证原子性。
指令重排序与内存屏障
为了优化性能,编译器和处理器可能会对指令进行重排序,但 JMM 要求这种重排序不能改变程序的执行结果。
编译器重排序:在不改变单线程语义前提下,调整指令顺序优化执行效率
处理器重排序:CPU采用流水线、多发射等技术导致指令实际执行顺序改变(如StoreLoad重排序)
示例 —— 重排序引发的问题
多线程环境下,由于StoreStore重排序,flag=true可能先于x=1执行,导致线程2看到不一致状态。
// 线程1 x = 1; // Store flag = true; // Store // 线程2 while (!flag); // Load System.out.println(x); // 可能输出0(未看到x=1)
内存屏障(Memory Barrier)是处理器提供的特殊指令,用于控制指令顺序和内存可见性,主要解决两类问题:
A、可见性问题
强制将工作内存的修改刷新到主内存,或强制从主内存加载最新值
B、有序性问题
禁止特定类型的指令重排序
硬件层⾯,内存屏障分两种:读屏障(Load Barrier)和写屏障 (Store Barrier)。
JMM中的四种内存屏障

volatile除了保证变量的可见性,还禁止指令重排序
JMM要求:
写操作(volatileVar = value)
插入StoreStore屏障:确保非volatile写不会重排序到volatile写之后
插入StoreLoad屏障:确保写结果对其他处理器立即可见(代价最高但最严格)
读操作(tmp = volatileVar)
插入LoadLoad屏障:确保后续读操作不会重排序到当前读之前
插入LoadStore屏障:确保后续写操作不会重排序到当前读之前
示意图


补充:

硬件实现差异
不同处理器对内存屏障的支持程度不同:
x86:天然保证LoadLoad、LoadStore、StoreStore顺序,仅需MFENCE实现StoreLoad屏障;ARM/POWER:弱内存模型,需显式插入所有四种屏障(通过DMB指令)
JVM的应对:HotSpot在生成机器码时,会根据目标平台转换内存屏障
实战案例:双重检查锁定(DCL)
public class Singleton { private static volatile Singleton instance; // 必须volatile public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 关键:防止重排序 } } } return instance; } }
解读:
instance = new Singleton();可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc 2 ctorInstanc(memory) //初始化对象 3 s=memory //设置s指向刚分配的地址
无volatile时的风险:
上述三个步骤可能会被重排序为 1 => 3 => 2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc 3 s=memory //设置s指向刚分配的地址 2 ctorInstanc(memory) //初始化对象
此时对象构造可能重排序为:1. 分配内存 → 2. 写入引用 → 3. 初始化对象;其他线程可能看到未初始化的对象(半初始化状态)。
volatile的作用:
通过StoreLoad屏障禁止步骤2和3的重排序。
总结
Java 内存模型(JMM)抽象了线程与主内存之间的关系,规定了共享变量的访问规则,从而简化了多线程编程并增强了程序的可移植性。

浙公网安备 33010602011771号