java进阶专题(十一) 探究JMM

前言

​ 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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值