Fork me on GitHub

【多线程与高并发原理篇:3_java内存模型】

1. 概述

Java 内存模型即 Java Memory Model,简称 JMM。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有一个私有的工作内存,工作内存中存储了该线程以读/写共享变量的副本。工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

Java内存模型是跟cpu缓存模型是类似的,基于cpu缓存模型来建立的Java内存模型,只不过Java内存模型是标准化的,屏蔽掉底层不同的计算机的区别。

2. Java内存模型带来的问题

Java内存模型规定了线程对主内存的操作具备原子性,包括以下8个操作:
lock:主内存,标识变量为线程独占;
unlock:主内存,解锁线程独占变量;
read:主内存,读取内存到线程缓存(工作内存);
load:工作内存,read后的值放入线程本地变量副本;
use:工作内存,传值给执行引擎;
assign:工作内存,执行引擎结果赋值给线程本地变量;
store:工作内存,存值到主内存给write备用;
write:主内存,写变量值。

假设如下程序,两个未加同步控制的线程去同时对i自增,会出现什么结果呢?

public class Test {
    private int i = 0;
    public void increment() {
        i++;
        System.out.println("i=" + i);
    }

    public static void main(String[] args) {
        Test t = new Test();
        new Thread(() -> t.increment()).start();
        new Thread(() -> t.increment()).start();
    }
}

通过运行会出现下面三种情况

i=1
i=1

或者

i=1
i=2

或者

i=2
i=2

下面通过图来解释第一种情况

A、B两个线程都有自己的工作内存,A自从执行read操作,从主内存读取i=0,随后load操作载入自己的工作内存,接着执行use操作,对i进行自增,然后从新赋值操作assign,此时线程A的工作内存i=1,随后store操作进行存储,最后写回到主内存,最终i=1。

B线程也进行如此操作,read->load->use->assign->store->write,最终也得出i=1。

出现第二种,关键在于B线程read操作是从A线程刷新到主内存后才去取值的。执行顺序是:线程A自增->线程A打印i最终值->线程B自增->线程B打印i最终值,如下图

出现第三种,是线程A自增后把i=1刷新到主内存,在执行打印之前,线程B优先从主内存获取i=1,进行read->load->use->assign->store->write,将i=1自增为i=2,随后线程A执行打印操作,执行顺序是:线程A自增->线程B自增->线程A打印i最终值->线程B打印i最终值,如下图

3. 可见性、有序性、原子性

虽然java内存模型JMM提供为每个线程提供了每个工作内存,存放共享变量的变量副本,但是如果线程没有作可见性的控制,从上述过程中可以看出,多线程下对共享变量的修改,其结果依然是不可预知的。

3.1 可见性

volatile关键词,在程序级别,保证对一个共享变量的修改对另外线程立马可见。上述程序对i加入volatile关键字,可以保证能始终得到第二种结果。

下面用程序来演示:

Class VolatileExample {
	int  a = 0;
	volatile boolean flg = false;
	
	public void writer() {
		a = 1;
		flg = true;
	}
	
	public void reader() {
		if (flg) {
			int i = a;
			......
		}
	}
}

图解如下:

上述过程概括为两句话:
当写一个volatile修饰的变量时,JMM会把线程对应的本地内存中的共享变量值刷新的主内存;
当读一个volatile修饰的变量时,JMM会把该线程对应的本地内存置为无效,从主内存读取最新的共享变量的值。
上述过程解释了volatile的可见性问题。

3.2 有序性

对于一些代码,编译器或者处理器,为了提高代码执行效率,会将指令重排序,就是说比如下面的代码:

flg = false;
//线程1:
parpare(); // 准备资源
flg = true;

//线程2:
while(!flg) {
	Thread.sleep(1000);
}
execute();// 基于准备好的资源执行操作

重排序之后,让flag = true先执行了,会导致线程2直接跳过while等待,执行某段代码,结果prepare()方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常。

volatile通过内存屏障,保证volatile修饰的变量,与其前后定义的值,不发生指令重排。JMM定义了如下四种内存屏障StoreStore、StoreLoad、LoadLoad、LoadStore;

对于volatile写,在前面插入StoreStore,禁止上面的普通读与下面的volatile写重排序;后面插入StoreLoad,禁止上面的volatile写与下面的普通读重排序,如下图:

对于volatile读,在后面插入LoadLoad,禁止上面的volatile读与下面的普通读重排序;下面再插入LoadStore,禁止上面的volatile读与下面的普通写重排序,如下图:

happens-before原则

为了保证多线程之间在某些情况下一定不能发生指令重排,java内存模型规定了8条原则。

  1. 程序次序规则 :一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;

  2. 管程锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;

  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;

  4. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;

  5. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

  7. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

  8. 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

3.3 原子性

一般情况下,volatile修饰的变量是不能保证原子性的,例如i++是复合操作,先读取,再修改变量的值,是不具备原子性的

4. volatile作用

通过上面的描述,可以得出volatile的作用主要有两点:

  • 保证线程可见性
  • 禁止指令重排序

5. HotSpot层面实现

通过hsdis工具查看java汇编文件,首先下载hsdis-amd64.dll到 \jdk1.8\jre\bin ,然后设置VM参数,-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

最终执行时会在volatile变量前加如下信息

lock addl $0x0,(%rsp)  

如下图:

6. 底层CPU硬件层面实现

上述过程中,JVM虚拟机会向CPU发送lock前置指令,将这个变量所在的缓存行数据写回主内存,如果其他CPU缓存的值是旧值,就会有问题,在多CPU(这里指多个核)下,每个CPU都会通过嗅探总线上传播的数据是否与自己的缓存一致,通过缓存一致性协议,最终保证多个CPU内部缓存数据的一致性,下面通过图来说明。

虚拟机的lock前缀指令,在底层硬件是通过缓存一致性协议来完成的,不同的CPU缓存一致性协议不一样, 有MSI、MESI、MOSI、Synapse、Firefly及Dragon,英特尔CPU的缓存一致性协议是通过MESI来完成的。

为了实现MESI协议,需要解释两个专业术语:flush处理器缓存refresh处理器缓存

flush处理器缓存,他的意思就是把自己更新的值刷新到高速缓存里去(或者是主内存),因为必须要刷到高速缓存(或者是主内存)里,才有可能在后续通过一些特殊的机制让其他的处理器从自己的高速缓存(或者是主内存)里读取到更新的值。除了flush以外,他还会发送一个消息到总线(bus),通知其他处理器,某个变量的值被他给修改了。

refresh处理器缓存,他的意思就是说,处理器中的线程在读取一个变量的值的时候,如果发现其他处理器的线程更新了变量的值,必须从其他处理器的高速缓存(或者是主内存)里,读取这个最新的值,更新到自己的高速缓存中。所以说,为了保证可见性,在底层是通过MESI协议、flush处理器缓存和refresh处理器缓存,这一整套机制来保障的。

flush和refresh,这两个操作,flush是强制刷新数据到高速缓存(主内存),不要仅仅停留在写缓冲器里面;refresh,是从总线嗅探发现某个变量被修改,必须强制从其他处理器的高速缓存(或者主内存)加载变量的最新值到自己的高速缓存里去。

7. 总结

本篇主要讲述了Java内存模型的作用,屏蔽了底层实现的细节,同时带来了一系列问题,导致线程之间的三大问题,即有序性、可见性、原子性,volatile关键字修饰的变量在多线程之间的作用,以及初步分析了底层是如何实现的,如果要深入分析,这个得具体看MESI协议规范,以及不同硬件底层的实现逻辑,比如英特尔的操作手册,后面有时间再接着深入。

posted @ 2022-04-23 07:12  小猪爸爸  阅读(436)  评论(2编辑  收藏  举报