并发三大特性
可见性
当一个线程修改了共享变量的值,其他线程能够看到修改的值。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的。
如何保证可见性
- 通过 volatile 关键字保证可见性
- 通过内存屏障保证可见性
- 通过 synchronized 关键字保证可见性
- 通过 Lock 保证可见性
-
可见性问题深入分析
通过一段代码来理解可见性问题:
public class VisibilityTest {
private boolean flag = true;
public static void main(String[] args) throws InterruptedException {
VisibilityTest test = new VisibilityTest();
// 线程threadA模拟数据加载场景
Thread threadA = new Thread(() -> test.load(), "threadA");
threadA.start();
// 让threadA执行一会儿
Thread.sleep(1000);
// 线程threadB通过flag控制threadA的执行时间
Thread threadB = new Thread(() -> test.refresh(), "threadB");
threadB.start();
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
public void refresh() {
flag = false;
System.out.println(Thread.currentThread().getName() + "修改flag");
}
public void load() {
System.out.println(Thread.currentThread().getName() + "开始执行.....");
int i = 0;
while (flag) {
i++;
//TODO 业务逻辑
}
System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i);
}
}
造成的问题是,即使 threadB 已经更改了变量 flag,但是因为 threadA 在更改之前已经读取了 flag 的变量副本并且线程之间是不可见的。解决办法是将 flag 使用关键字 volatile 修饰。
有序性
即程序执行的顺序按照代码的先后顺序执行。JVM 存在指令重排,所以存在有序性问题。
如何保证有序性 通过 volatile 关键字保证有序性
- 通过内存屏障保证有序性
- 通过 synchronized关键字保证有序性
-
原子性
一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作(64位处理器)。不采取任何的原子性保障措施的自增操作并不是原子性的。
如何保证原子性 通过 synchronized 关键字保证原子性
- 通过 Lock 保证原子性
- 通过 CAS 保证原子性
思考:在 32 位的机器上对 long 型变量进行加减操作是否存在并发隐患?
Java内存模型(JMM)
JMM定义
Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM 描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM 是围绕**原子性**、**有序性**、**可见性**展开的。<br />![](https://cdn.nlark.com/yuque/0/2022/png/22484004/1656467393039-0faf3ef6-416a-4c28-871d-a42ec6f34138.png#crop=0&crop=0&crop=1&crop=1&height=403&id=k5mXd&originHeight=538&originWidth=1191&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=&width=893)<br />
JMM 与硬件内存架构的关系
Java 内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在 CPU 缓存中和 CPU 内部的寄存器中。如下图所示,Java 内存模型和计算机硬件内存架构是一个交叉关系:<br />![](https://cdn.nlark.com/yuque/0/2022/png/22484004/1656467393344-c95b7c06-ed4f-4f5d-b40e-1baa62c19b76.png#crop=0&crop=0&crop=1&crop=1&height=288&id=KENni&originHeight=384&originWidth=817&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=&width=613)
内存交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
- load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。
- write(写入):作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。
Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许 read 和 load、store 和 write 操作之一单独出现
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
JMM 的内存可见性保证
按程序类型,Java 程序的内存可见性保证可以分为下列3类:
- 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知。 JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。
未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行,比如正确同步的多线程程序在临界区内的重排序。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
- 顺序一致性模型保证对所有的内存读/写操作都具有原子性,而 JMM 不保证对64位的 long 型和 double 型变量的写操作具有原子性(32位处理器)。
JVM 在32位处理器上运行时,可能会把一个64位 long/double 型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。从 JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位 long/double 型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在 JSR-133中都必须具有原子性
volatile
volatile 特性
- 可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
- 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性(基于这点,我们通过会认为 volatile 不具备原子性)。volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
64位的 long 型和 double 型变量,只要它是 volatile 变量,对该变量的读/写就具有原子性。
- 有序性:对 volatile 修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。
在 JSR-133之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量重排序。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。
volatile写-读的内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
volatile 可见性实现原理
JMM 内存交互层面实现
volatile 修饰的变量的 read、load、use 操作和 assign、store、write 必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证 volatile 变量操作对多线程的可见性。
硬件层面实现
通过 lock 前缀指令,会锁定变量缓存行区域并写回主内存,这个操作称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。一个处理器的缓存回写到内存会导致其他处理器的缓存无效。
volatile 在 hotspot 的实现
字节码解释器实现
JVM 中的字节码解释器(bytecodeInterpreter),用 C++实现了 JVM 指令,其优点是实现相对简单且容易理解,缺点是执行慢。
bytecodeInterpreter.cpp
模板解释器实现
模板解释器(templateInterpreter):对每个指令都写了一段对应的汇编代码,启动时将每个指令与对应汇编代码入口绑定,可以说是效率做到了极致。
void TemplateTable::volatile_barrier(Assembler::Membar_mask_bits order_constraint) {
// Helper function to insert a is-volatile test and memory barrier
if (os::is_MP()) { // Not needed on single CPU
__ membar(order_constraint);
}
}
// 负责执行putfield或putstatic指令
void TemplateTable::putfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
// ...
// Check for volatile store
__ testl(rdx, rdx);
__ jcc(Assembler::zero, notVolatile);
putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad |
Assembler::StoreStore));
__ jmp(Done);
__ bind(notVolatile);
putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
__ bind(Done);
}
// Serializes memory and blows flags
void membar(Membar_mask_bits order_constraint) {
// We only have to handle StoreLoad
// x86平台只需要处理StoreLoad
if (order_constraint & StoreLoad) {
int offset = -VM_Version::L1_line_size();
if (offset < -128) {
offset = -128;
}
// 下面这两句插入了一条lock前缀指令: lock addl $0, $0(%rsp)
lock(); // lock前缀指令
addl(Address(rsp, offset), 0); // addl $0, $0(%rsp)
}
}
在linux系统x86中的实现
inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
if (os::is_MP()) {
// always use locked addl since mfence is sometimes expensive
#ifdef AMD64
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
__asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
}
}
x86处理器中利用 lock 实现类似内存屏障的效果。
lock前缀指令的作用
- 确保后续指令执行的原子性。在 Pentium 及之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel 使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低 lock 前缀指令的执行开销。
- lock 前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
- lock 前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将 store buffer 中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新 store buffer 的操作会导致其他 cache 中的副本失效。
汇编层面 volatile 的实现
添加下面的 jvm 参数查看之前可见性 Demo 的汇编指令 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
验证了可见性使用了 lock 前缀指令
从硬件层面分析Lock前缀指令
《64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf》中有如下描述:
The 32-bit IA-32 processors support locked atomic operations on locations in system memory. These operations are typically used to manage shared data structures (such as semaphores, segment descriptors, system segments, or page tables) in which two or more processors may try simultaneously to modify the same field or flag. The processor uses three interdependent mechanisms for carrying out locked atomic operations:
• Guaranteed atomic operations
• Bus locking, using the LOCK# signal and the LOCK instruction prefix
• Cache coherency protocols that ensure that atomic operations can be carried out on cached data structures (cache lock); this mechanism is present in the Pentium 4, Intel Xeon, and P6 family processors
32位的 IA-32处理器支持对系统内存中的位置进行锁定的原子操作。这些操作通常用于管理共享的数据结构(如信号量、段描述符、系统段或页表),在这些结构中,两个或多个处理器可能同时试图修改相同的字段或标志。处理器使用三种相互依赖的机制来执行锁定的原子操作:
- 有保证的原子操作
- 总线锁定,使用 LOCK#信号和 LOCK 指令前缀
- 缓存一致性协议,确保原子操作可以在缓存的数据结构上执行(缓存锁);这种机制出现在 Pentium 4、Intel Xeon 和 P6系列处理器中
CPU缓存架构剖析
有序性问题深入分析
思考:下面的 Java 程序中 x 和 y 的最终结果是什么?
public class ReOrderTest {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException{
int i = 0;
while (true) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
/**
* x,y:
*/
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
shortWait(20000);
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x == 0 && y == 0){
break;
}
}
}
public static void shortWait(long interval){
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while(start + interval >= end);
}
}
指令重排序
Java 语言规范规定 JVM 线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义:JVM 能根据处理器特性(CPU 多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合 CPU 的执行特性,最大限度的发挥机器性能。
在编译器与 CPU 处理器中都能执行指令重排优化操作
volatile 重排序规则
volatile 禁止重排序场景
- 第二个操作是 volatile 写,不管第一个操作是什么都不会重排序
- 第一个操作是 volatile 读,不管第二个操作是什么都不会重排序
- 第一个操作是 volatile 写,第二个操作是 volatile 读,也不会发生重排序
JMM 内存屏障插入策略
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
JSR133规范
x86处理器不会对读-读、读-写和写-写操作做重排序, 会省略掉这3种操作类型对应的内存屏障。仅会对写-读操作做重排序,所以 volatile 写-读操作只需要在 volatile 写后插入 StoreLoad 屏障
JVM层面的内存屏障
在 JSR 规范中定义了4种内存屏障:
屏障 | 指令 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 |
LoadStore | Load1; LoadStore; Store2 | 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。 |
StoreStore | Store1; StoreStore; Store2 | 在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 |
StoreLoad | Store1; StoreLoad; Load2 | 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能 |
由于x86只有 store load 可能会重排序,所以只有 JSR 的 StoreLoad 屏障对应它的 mfence 或 lock 前缀指令,其他屏障对应空操作
硬件层内存屏障
硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel 的提法)来提供一致性的能力。拿 X86平台来说,有几种主要的内存屏障:
- lfence,是一种Load Barrier 读屏障
- sfence, 是一种 Store Barrier 写屏障
- mfence, 是一种全能型的屏障,具备 lfence 和 sfence 的能力
- Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。它后面可以跟 ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG 等指令。
内存屏障有两个能力:
- 阻止屏障两边的指令重排序
刷新处理器缓存/冲刷处理器缓存
对 Load Barrier 来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据;对 Store Barrier 来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
Lock 前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在 Lock 锁住总线的时候,其他 CPU 的读写请求都会被阻塞,直到锁释放。
不同硬件实现内存屏障的方式不同,Java 内存模型屏蔽了这种底层硬件平台的差异,由 JVM 来为不同的平台生成相应的机器码。