前言
JMM即java内存模型,JMM研究的就是多线程下Java代码的执行顺序,共享变量的读写。它定义了Java虚拟机在计算机内存中的工作方式。从抽象角度看,JMM定义了线程和主存之间的抽象关系:线程之前的共享变量存储在主内存中,每个线程有个私有的本地内存,本地内存中存储了该线程读写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。
先抛出两个问题:
你写的代码一定是实际运行的代码吗?
代码的编写顺序,一定是实际执行的顺序吗?
参考文献:
Java Language Specification Chapter 17. Threads and Locks
JSR-133: JavaTM Memory Model and Thread Specification
Doug Lea’ s JSR-133 cookbook
书籍:《Java Concurrency in Practice》
并发测试框架:jcstress
猜猜一下代码在多线程的情况下,会发生什么样的情况?
boolean stop;
@Actor
public void a1() {
while(!stop){
}
}
@Signal
void a2() {
stop = true;
}
int balance = 10;
@Actor
public void deposit() {
balance += 5;
}
@Actor
public void withdraw() {
balance -= 5;
}
@Arbiter
public void query(I_Result r) {
r.r1 = balance;
}
int a;
int b;
@Actor
public void actor1(II_Result r) {
b = 1;
r.r2 = a;
}
@Actor
public void actor2(II_Result r) {
a = 2;
r.r1 = b;
}
为了方便测试,改造下代码:
package com.study.demo6;
import java.util.concurrent.TimeUnit;
public class WhileTest {
static boolean stop;
public static void a1() {
while (true) {
boolean b = stop;
if (b) {
break;
}
}
}
public static void main(String[] args) {
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println("stop>>>>>>>true!");
}).start();
a1();
}
}
运行结果:
发现main主线程中,调用了啊a1()方法,子线程1秒后,对stop修改了true,按正常逻辑,死循环应该会break终止了,但是实际上运行,我们发现,一直在循环中,并未终止!
提示:
先用 -XX:+PrintCompilation 来查看即时编译情况(% 的含义 On-Stack-Replacement(OSR))
再尝试用 -Xint 强制解释执行
代码演示
package com.study.demo6;
import java.util.Arrays;
import java.util.List;
public class AddSubTest {
static int balance = 10;
private static void add(){
balance+=5;
}
private static void sub(){
balance-=5;
}
public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = Arrays.asList(new Thread(AddSubTest::add), new Thread(AddSubTest::sub));
threadList.forEach(Thread::start);
for (Thread thread : threadList) {
thread.join();
}
System.out.println(balance);
}
}
这回用一下ASM 工具,可以看到源码第10 行的 balance += 5 的字节码如下
LINENUMBER 8 L0
GETSTATIC TestAddSub.balance : I
ICONST_5
IADD
PUTSTATIC TestAddSub.balance : I
而第13 行的 balance -= 5 字节码如下
LINENUMBER 12 L0
GETSTATIC TestAddSub.balance : I
ICONST_5
ISUB
PUTSTATIC TestAddSub.balance : I
换成伪代后
static int balance = 10;
private static void add(){
//balance+=5;
int b = balance;
b += 5;
balance = b;
}
private static void sub(){
//balance-=5;
int c = balance;
c -= 5;
balance = c;
}
可能出现的执行顺序如下:
case1: 线程1和2串行
int b = balance; // 线程1
b += 5; // 线程1
balance = b; // 线程1
int c = balance; // 线程2
c -= 5; // 线程2
balance = c; // 线程2
case2:线程1和线程2同时拿到10,线程1执行完,线程2再执行完
int c = balance; // 线程2
int b = balance; // 线程1
b += 5; // 线程1
balance = b; // 线程1
c -= 5; // 线程2
balance = c; // 线程2
case3:线程1和线程2同时拿到10,线程2执行完,线程1再执行完
int b = balance; // 线程1
int c = balance; // 线程2
c -= 5; // 线程2
balance = c; // 线程2
b += 5; // 线程1
balance = b; // 线程1
代码演示:
package com.study.demo6;
public class FourthResultTest {
int a;
int b;
private void actor1(IIResult r){
b=1;
r.r2 = a;
}
private void actor2(IIResult r){
a=2;
r.r1 = b;
}
}
可能出现的结果
case1:
b = 1; // 线程1
r.r2 = a; // 线程1
a = 2; // 线程2
r.r1 = b; // 线程2
// 结果 r11, r20
case2:
a = 2; // 线程2
r.r1 = b; // 线程2
b = 1; // 线程1
r.r2 = a; // 线程1
// 结果 r10, r22
case3:
a = 2; // 线程2
b = 1; // 线程1
r.r2 = a; // 线程1
r.r1 = b; // 线程2
// 结果 r11, r22
case4:这种结果是不是超乎你的预期了?这是因为可能是编译器调整了指令执行顺序
r.r2 = a; // 线程1
a = 2; // 线程2
r.r1 = b; // 线程2
b = 1; // 线程1
// 结果 r10, r20
如果让一个线程总是占用CPU 是不合理的,所有任务调度器会让线程分时使用CPU
编译器以及硬件层面都会做层层优化,提升性能
Compiler/JIT 优化
Processor 流水线优化
Cache 优化
case1:
//优化前
x=1
y=“universe”
x=2
//优化后
y=“universe”
x=2
case2:
//优化前
for(i=0;i<max;i++){
z += a[i]
}
//优化后
t = z
for(i=0;i<max;i++){
t += a[i]
}
z = t
case3:
//优化前
if(x>=0){
y = 1;
// …
}
//优化后
y = 1;
if(x>=0){
// …
}
流水线在CPU 的一个时钟周期内会执行多个指令的不同部分
非流水线操作
假设有三条指令
—|---|—|
1 2 3
每条指令执行花费300ps 时间,最后将结果存入寄存器需要20ps
一秒能运行的指令数为
流水线操作
仔细分析就会发现,可以把每个指令细分为三个阶段
A|B|C| // 1
A|B|C| // 2
A|B|C| // 3
增加一些寄存器,缓存每一阶段的结果,这样就可以在执行 指令1-C 阶段时,同时执行 指令2-B 以及 指令3-A
一秒能运行的指令数为
在按序执行中,一旦遇到指令依赖的情况,流水线就会停滞
如果采用乱序执行,就可以跳到下一个非依赖指令并发布它。这样,执行单元就可以总是处于工作状态,把
时间浪费减到最少
MESI (CPU缓存一致性)协议 引入缓存的副作用在于同一份数据可能保存了副本,一致性该如何保证呢?
Modified - 要向其它CPU 发送cache line 无效消息,并等待ack
Exclusive - 独占、即将要执行修改
Shared - 共享、一般读取时的初始状态
Invalid - 一旦发现数据无效,需要重新加载数据
就上文所说的第四种可能:r1 和r2 有没有可能同时为0
r.r1 = b; // 线程2 与 a = 2 重排
r.r2 = a; // 线程1 与 a = 1 重排
b = 1; // 线程1
a = 2; // 线程2
下面从缓存的角度分析,注意假定指令没有重排
b = 1; // 线程1 - 写入 CPU-0 的 store buffer
a = 2; // 线程2 - 写入 CPU-1 的 store buffer
r.r1 = b; // 线程2 - 马上执行
r.r2 = a; // 线程1 - 马上执行
// 线程1 - 将 store buffer 中的 b = 1 写入 cache, 晚了
// 线程2 - 将 store buffer 中的 a = 2 写入 cache, 晚了
以上介绍了多线程读写共享变量可能发生的哪些问题?但对于程序员而言,我们不应当关注究竟是编译器优化、Processor 优化、缓存优化。否则,就好像打开了潘多拉魔盒!
A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. A high level, informal overview of the memory model shows it to be a set of rules for when writes by one thread are visible to another thread.
多线程下,共享变量的读写顺序是头等大事,内存模型就是多线程下对共享变量的一组读写规则
共享变量值是否在线程间同步
代码可能的执行顺序
需要关注的操作就有两种Load、Store
Load 就是从缓存读取到寄存器中,如果一级缓存中没有,就会层层读取二级、三级缓存,最后才是Memory
Store 就是从寄存器运算结果写入缓存,不会直接写入Memory,当Cache line 将被eject 时,会
writeback 到Memory
在多线程下,没有关系依赖的代码,在操作共享变量时(至少有一个线程写),并不能保证按编写顺序(Program Order)执行,这称为发生了竞态条件(Race Conditon)。
例如
有共享变量 x,线程 1 执行
r.r1 = y;
r.r2 = x;
线程 2 执行
x = 1;
y = 1;
最终的结果可能是 r11 而 r20
竞态条件是为了更好的 data race free。
若要保证多线程下,每个线程的执行顺序(Synchronization Order)按编写顺序(Program Order)执行,那么必须使用 Synchronization Actions 来保证,这些 SA 有
lock,unlock
volatile 方式读写变量
VarHandle 方式读写变量
Synchronization Order 也称之为 Total Order
例如
用 volatile 修饰共享变量 y,线程 1 执行
r.r1 = y;
r.r2 = x;
线程 2 执行
x = 1;
y = 1;
最终的结果就不可能是 r11 而 r20
错误的认识,线程 1 执行
synchronized(LOCK) {
r1 = x; //1 处
r2 = x; //2 处
}
线程 2 执行
synchronized(LOCK) {
x = 1
}
并不是说 //1 与 //2 处之间不能切换到线程 2,只是即使切换到了线程 2,因为线程 2 不能拿到 LOCK 锁导致被阻塞,执行权又会轮到线程 1
用例1
int x;
volatile int y;
之后采用
x = 10; //1 处
y = 20; //2 处
此时 //1 处代码绝不会重排到 //2 处之后(只写了 volatile 变量)
用例 2
int x;
volatile int y;
执行下面的测试用例
@Actor
public void a1(II_Result r) {
y = 1; //1 处
r.r2 = x; //2 处
}
@Actor
public void a2(II_Result r) {
x = 1; //3 处
r.r1 = y; //4 处
}
//1 //2 处的顺序可以保证(只写了 volatile 变量),但 //3 //4 处的顺序却不能保证(只读了 volatile 变量),仍会出现 r1r20 的问题
有时会很迷惑人,例如下面的例子
用例3
@Actor
public void a1(II_Result r) {
r.r2 = x; //1 处
y = 1; //2 处
}
@Actor
public void a2(II_Result r) {
r.r1 = y; //3 处
x = 1; //4 处
}
这回 //1 //2 (只写了 volatile 变量)//3 //4 处(只读了 volatile 变量)的顺序均能保证了,绝不会出现r1r21 的情况
此外将用例 2 中两个变量均用 volatile 修饰就不会出现 r1r20 的问题,因此也把全部都用 volatile 修饰称为total order,部分变量用 volatile 修饰称为 partial order