Java 内存模型

141 阅读7分钟
原文链接: zhuanlan.zhihu.com

一、Java内存模型

硬件处理

电脑硬件,我们知道有用于计算的cpu、辅助运算的内存、以及硬盘还有进行数据传输的数据总线。在程序执行中很多都是内存计算,cpu为了更快的进行计算会有高速缓存,最后同步至主内存,大概的交互如下图



为了使处理器内部的运算单元能够被充分的利用,处理器可能会对输入代码进行乱序执行优化,然后将计算后的结果进行重组,保证该结果和顺序执行的结果是一致的(单位时间内,一个core只能执行一个线程,所以结果的一致仅限一个线程内)。

Java内存模型

Java内存模型是语言级别的模型,它的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取数变量这样的底层细节。

在内存里,java内存模型规定了所有的变量都存储在主内存(物理内存)中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行。不同的线程无法访问别线程的工作内存里的内容。下图展示了逻辑上 线程、主内存、工作内存的三者交互关系。



内存交互操作

Java内存模型定义的8个操作指令来进行内存之间的交互

read 读取主内存的值,并传输至工作内存

load 将read的变量值存放到工作内存

read 好比快递运输车,工作内存好比站点,运输车将快递运输到站点,站点必须得卸货 load

use 将工作内存的变量值,传递给执行引擎

assign 执行引擎对变量进行赋值

use 好比站点进行快递员分配,站点说我把快递分给你了快递员A。快递员A接收到快递 assign 开始派送。

store 工作内存将变量传输到主内存

write 主内存将工作内存传递过来的变量进行存储

storewrite 就好理解了,快递员A将快递送到你家门口(store),然后你得签收(write)

lock 用作主内存变量,它把一个变量在内存里标识为一个线程独占状态

unlock 用作主内存变量,它对被锁定的变量进行解锁

下图展示下工作内存和主内存间的指令操作交互



指令规则

  • read 和 load、store和write必须成对出现
  • assign操作,工作内存变量改变后必须刷回主内存
  • 同一时间只能运行一个线程对变量进行lock,当前线程lock可重入,unlock次数必须等于lock的次数,该变量才能解锁
  • 对一个变量lock后,会清空该线程工作内存变量的值,重新执行load或者assign操作初始化工作内存中变量的值。
  • unlock前,必须将变量同步到主内存(store/write操作)

二、重排序

从java源码到最终实际执行的指令序列,会经历下面3种重排序



从源码到最终执行的指令序列的示意图

重排序的现象
a=1,b=a 这一组 b依赖a,不会重排序;
a=1,b=2 这一组 a和b没有关系,那么就有可能被重排序执行 b=2,a=1

  • 编译器优化的重排序
    编译器在不改变单线程程序语义的前提下,可以重新安排语句执行顺序
  • 指令级并行的重排序
    现代处理器采用了指令级并行技术将多条指令重叠执行。在不存在数据依赖的时候,处理器可以改变指令执行顺序
  • 内存系统重排序
    处理器使用高速缓存,使得多运算单元加载和存储主内存操作看上去可能在乱序执行

重排序的代码示例,文章底部的参考文章里有示例,这里就不罗列了。

三、内存屏障

JMM(java 内存模型) 在不改变程序执行结果的前提下,尽可能的支持处理器的重排序。通过禁止特定特定类型的编译器重排序和处理器重排序,为开发者提供一致的内存可见性保证,如 volatilefinal

Java编译器在生成指令的时候会在适当位置插入内存屏障来进制特定类型的处理器排序。

内存屏障说的通俗一点就是一个栏杆,在两个指令之间插入栏杆,后面的指令就不能越过栏杆先执行。

JMM定义的内存屏障指令分为4类

  • LoadLoad
    指令示例 Load1 LoadLoad Load2
    确保Load1数据装载一定先于Load2及后续所有Load指令
  • LoadStore
    指令示例 Load1 LoadStore Store2
    确保Load1数据装载一定先于Store2及后续所有Store指令
  • StoreStore
    指令示例 Store1 StoreStore Store2
    确保Store1主内存落地(从工作内存刷入主存,其它线程可见)一定先于Store2及后续所有Store指令
  • StoreLoad
    指令示例 Store1 StoreLoad Load2
    确保Store1主内存落地(从工作内存刷入主存,其它线程可见)一定先于Load2及后续所有Load指令

处理器对重排序的支持



从上面可以看到不同的处理器架构对重排序的支持也是不一样(其它处理器架构暂不罗列),所以不同的平台JMM的内存屏障施加也略有不同,具体来说,比如 X86 对Load1Load2不支持重排序,那么你就没有必要施加 LoadLoad 屏障。

四、volatile的内存语义

volatile我们都知道是java的关键字用来保证数据可见性,防止指令重排的效果。包括JUC里AQS Lock的底层实现也是基于volatitle来实现。

volatile写的内存语义

当写一个volatile变量的时候,JMM会把该线程对应的本地内存变量值刷新到主内存

volatile读的内存语义

当读一个volatile变量的时候,JMM会把线程本次内存置为无效。线程接下来将从主内存中读取共享变量(也就是重新从主内存获取值,更新运行内存中的本地变量)

上面两个语义,保证了volatile变量写入对线程的可见性

volatile内存屏障插入规则



volatile内存屏障策略

代码简单示例

class X {
    int a, b;
    volatile int v, u;

    void f() {
        int i, j;

        i = a;// load a  普通load
        j = b;// load b  普通load
        i = v;// load v  volatile load
        // LoadLoad
        j = u;// load u  volatile load
        // LoadStore
        a = i;// store a 普通store  
        b = j;// store b 普通store
        // StoreStore
        v = i;// store v volatile store
        // StoreStore
        u = j;// store u volatile store
        // StoreLoad
        i = u;// load u  volatile load
        // 两个屏障 LoadLoad 和 LoadStore
        j = b;// load b  普通load
        a = i;// store a 普通store
    }
}

上述代码可以套用volatile屏障规则对应。

当然不同的处理器架构重排序的支持也是不一样,比如X86 只有当 store1 load2 的时候会进行重排序,那么就会省略掉很多类型的内存屏障。

五、final的内存语义

final在Java中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。

被final修饰的变量不能被修改,方法不能被重写,类不能被继承。

我们暂时把final修饰的称作域,对于final域,编译器和处理器要遵守两个重排序规则

写规则

  • 在构造函数内对一个final域的写入,与随后把这个被构造的对象的引用赋值给一个引用变量,这两个操作不可重排序

JMM禁止编译器把final域的写重排序到构造函数之外

编译器会在final域写入的后面插入 StoreStore 屏障,禁止处理器把final域的写重排序到构造函数之外。

该规则可以保证在对象引用为任意线程可见之前,对象的final域已经被正确初始化,而普通域无法保障。

读规则

  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障。

该规则保证在读一个对象的final域之前,一定会先读包含这个域的对象引用