前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >简述Java内存模型

简述Java内存模型

作者头像
胖虎
发布2020-12-08 14:46:02
4090
发布2020-12-08 14:46:02
举报
文章被收录于专栏:晏霖
本章节主要介绍Java内存模型的设计原理,让我们更清晰的认识数据在内存中的表现,使我们能够可以更好的使用他们,也能让我们在开发中避开很多问题。学知识并非翻翻书那么简单,况且即使学会了,不使用也会忘记的。所以我希望如果本章节出现读者第一次接触的名词以及很难理解的概念时,尽量多读几遍,把这些概念联想出模型来记忆,必要时反复翻阅。

2.3.1什么是Java内存模型

在介绍Java内存模型(JMM)前,我要打消读者一个错误的认知,那就是JMM与JVM到底是什么关系,现在告诉大家,Java虚拟机模型(JVM)与Java内存模型(JMM)没有本质上的联系。为什么这么说,我来解释一下:想必我的读者大部分都是Java开发工程师,成为一名Java开发工程师必备的两点,就是要了解Java的语法,以及使用Java API,拥有这两点你就可以编写Java代码,编写后的代码需要在Java虚拟机上运行,其实上面我已经把JDK的组成说了出来。JDK(Java Development Kit)就是由Java程序设计语言、Java API类库、Java虚拟机这三部分组成的,是Java程序开发的最小环境(如图2-6所示)。也就是说想要开发Java程序,必备的就是JDK。我们还可以继续把Java API类库分成Java SE API子集和Java虚拟机两部分统称JRE(Java Runtime Environment),JRE是Java程序运行的标准环境。所以说Java虚拟机模型(JVM)是将Java文件编译成class文件并运行class文件的软件,而Java内存模型(JMM)主要定义了线程与内存之间的细节,现在看来两者并没有直接的关系。

图 2-6 Java技术体系

介绍了Java组成的基本知识后,就让我们聊一聊什么是JMM。Java能摆脱硬件的束缚,可以“一次编写,到处运行”,这不仅是因为虚拟机的功劳,也是因为提供了相对安全的内存管理和访问机制,让Java程序在不同平台下都能达到一致的内存访问效果,这种可以屏蔽各种硬件和操作系统的内存访问差异,我们称是Java内存模型。

在并发编程中存在一个最重要的问题就是,线程之间是如何通信的。在Java并发中,线程通信采用共享内存模型机制的,在共享内存模型中,线程间通过读、写内存中公共的状态进行隐式通信。采用内存共享的优点是,数据的共享使线程间的数据不用传送,而是直接访问内存,也加快了程序的效率,当然有利也有弊,共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。

在Java运行程序中有一些内存是线程共享的,例如Java中几乎所有的对象都存储在堆内存中,还有一些静态的常量,这些数据我们统称为共享变量,共享变量存储在主内存中。还有一些变量是每个线程独有的,存在在本地内存中,例如局部变量,方法内定义的参数还有异常处理器参数,这些数据不会在线程之间共享,我们所以不会存在可见性问题,也不受JMM影响,本地内存是JMM抽象的概念,实际并不存在,实际情况与主内存之间还存在来高速缓存、写缓冲区等。如图2-7是抽象出来的JMM结构示意图。

图2-7 JMM抽象结构图

JMM就是通过控制主内存与其他线程的本地内存之间通信,来保证共享变量的内存可见性。

2.3.2重排序

我们在Java编辑器,例如Idea里编写的代码,在执行时,程序真的能像我们所写的顺序执行吗?其实并非如此。在任何系统中处理器常常对指令进行重排序,而达到提高性能。Java的重排序分为三种,如图2-8源码到最终指令顺序图。

1.编译器优化的重排序:编译器在不改变单线程程序语义前提下,可对语句的执行进行重排序。

2.指令级并行的重排序:处理器的指令级并行技术将多条指令重叠执行,如果不存在数据的依赖性将会改变语句对应机器指令的执行顺序。

3.内存系统的重排序,因为使用了读写缓存区,使得看起来并不是顺序执行的。

图2-8 源码到最终指令顺序图

上述3种重排序中,1属于编译器重排序,2、3属于处理器重排序。重排序可能会导致内存可见性问题。JMM的编译器重排序会禁止类型类型的编译器重排序。JMM的处理器重排序则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,内存屏障用来禁止特定类型的处理器重排序。JMM把内存屏障分为4类,如表2-3所示。

表2-3 内存屏障指令分类

屏障类型

指令示例

说明

LoadLoad Barriers

Load1; LoadLoad; Load2

确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。

StoreStore Barriers

Store1; StoreStore; Store2

确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。

LoadStore Barriers

Load1; LoadStore; Store2

确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。

StoreLoad Barriers

Store1; StoreLoad; Load2

确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

看来JMM重排序规则在程序的背后帮助我们做了太多的事情,要不是他禁止了一些类型的重排序,我们程序在多线程的运行结果就没办法控制了,即使这种操作会降低一些效率,但是安全比效率更重要。

演示重排序的代码理论上可以用两个存放在内存中的变量,用两个方法控制读和写就能实现,但是运行结果存在不确定性,所以这里笔者也是写了一个伪代码来说明。

代码清单2-6 Reorder.java

代码语言:javascript
复制
public class Reorder {
    int a = 0; // int a
    volatile boolean flag = false; //共享变量 boolean b

    public void writer() {
        flag = true;
        a = 1;
    }

    public void read() {
        if (flag) {
            int i = a * a;
        }
    }
}

上面代码对于单线程执行视角看来,重排序不存在任何问题,重排序不改变执行结果。

但是我们现在模拟多线程执行如上代码,在并发的某一时刻,赋值有可能发生顺序上的变化,正如代码所示,flag赋值先于a的赋值,那么此时flag的值已经被刷入主内存中,对读线程是可见的,此时另一个线程刚好进入if里给i进行赋值,结果i的值一定是1吗?答案是不一定的,重排序后的结果是不确定的,因此上面代码是其中的一种情况而已。

2.3.3 happens-before

上一节我们知道了重排序,如果重排序是随意性的,任凭处理器按照自己的优化指令进行重排序,那我们程序岂不是到处都是bug?当然不是了,任何事物都有一定的规则。JMM为程序所有的操作定义了一个偏序关系(偏序关系是数学中的序列理论,详细可自行查阅。我简单的说,集合中xyz 和zyx,对于x和z的顺序是无所谓的,不要求每个元素之间都能比较大小,不保证所有元素的互相可比较性,例如我们更喜欢吃草莓而不是葡萄,更喜欢喜悦而不喜欢悲伤,但是我们不必在葡萄和悲伤作出选择。)这种偏序关系称为happen-before。

happen-before是JMM最核心的概念。JMM模型对于程序员来说最重要就是帮助我们提供一个简单使用、易于理解的强内存模型。对于编译器和处理器来说要尽量对其束缚性越小越好,因为束缚性越强,损耗的性能就会越多,介于这两点,在设计JMM模型时就要去平衡这两点,因此提出了happen-before规则。

在JSR-133 中使用happen-before关系来保证内存可见性,说的是在JMM中一个操作的执行结果要对另一个操作可见,这两个操作就存在happen-before关系,反过来,如果两个操作不存在happen-before关系,那么JVM可以对他们的顺序任意排序。有了happen-before后,所有的并发代码再也不担心重排序导致的问题了。

happen-before的规则如下。

n 程序次序规则:在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen-before(时间上)后执行的操作。

n 监视器锁定规则:在一个监视器上解锁操作必须在同一个监视器加锁之前操作。

n volatile变量规则:对一个volatile变量的写操作必须对该变量的读操作之前执行。线程启动规则:Thread对象的start()方法happen-before此线程的每一个动作。

n 线程终止规则:线程的所有操作都happen-before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。线程中断规则:对线程interrupt()方法的调用happen-before发生于被中断线程的代码检测到中断时事件的发生。

n 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen-before它的finalize()方法的开始。

n 传递性:如果操作A happen-before操作B,操作B happen-before操作C,那么可以得出A happen-before操作C。

规则都是很官方的,下面我用自己的话对照上面的规则分别解释happen-before:

第一条

单线程情况下,happen-before原则告诉你,你放心的认为代码写在前面的就是先执行就可以了,不会有任何问题的。

第二条

对于一个解锁操作后的变量 随后后要对这个锁加锁。

第三条

volatile关键字修饰的变量的写先行发生与对这个变量的读

第四条、第五条、第六条我们一起解释

例:a = 1;

thread1.start();

a =1 先行发生 thread.start()前,thread1启动后,才可以读取到a的值为1的。线程终止前的所有操作先行发生于终止方法的返回。这就保障了一个线程结束后,其他线程一定能感知到线程所做的所有变更。

第七条

对象被垃圾回收调用finalize时,对象的构造一定已经先行发生。

第八条

传递性,只是具有简单的传递性。

2.3.4 as-if-serial

happen-before的规则是规定了两个操作之间的关系,我们指的这两个操作并非在代码编辑器里面的书写顺序,书写顺序决定不了实际操作顺序。在编译器或处理器重排序后的执行结果如果与happen-before关系执行的结果一直,我们才会认为这样的重排序是正确的,也就是无论编译器和处理器用什么方式进行重排序,只要不影响happen-before的结果都是被允许的,我们把对于编译器和处理器这样的约束原则称为as-if-serial,as-if-serial翻译中文的意思可以是:犹如连续的,我们可以理解为:高速编译器和处理器,无论这么排序,都不能影响程序的执行结果。

因此as-if-serial的语义本质上或者在宏观上和happen-before是一回事。

编译器和处理器可以对程序中很多操作进行随意排序,只要不影响程序执行结果,那么你知道什么样的操作不会随意排序吗?只有一种关系的数据关系,就是有数据依赖关系。

我们举两个个简单的例子来说明数据依赖关系:

double pi = 3.14; //A圆周率

double r = 1.0; //B 半径

double area = pi * r * r; //C 面积

如上述代码所描述的,圆的面积计算与A、B两个操作的顺序是无关的,但是C与A、B两个操作是有关系的。

图2-9 无依赖关系执行顺序

从图上,我们可以看出,A和C、B和C是存在税局依赖关系的,A、B是不存在数据依赖关系的,因此圆的面积示例代码中存在4个happen-before关系。

1. A happen-before B。

2. B happen-before A。

3. B happen-before C。

4. A happen-before C。

到这里,我们对于什么样的操作会存在数据依赖关系已经有了简单的认识,这里做了一个分类,一下3种类型就是存在数据依赖关系的操作,如表2-4所示。

表2-4 3种数据依赖关系类型

类别

代码示例

说明

写后读

a=2;b=a;

写这个变量后再读这个变量

写后写

a=1;a=2;

写这个变量后再写这个变量

读后写

a=b;b=1;

读这个变量后再写这个变量

以上3种操作,如果编译器和处理器重排序后不按照happen-before规则,程序的执行结果就会不同,因此存在数据依赖关系的操作,编译器和处理器在重排序时不会改变其执行顺序。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-11-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 晏霖 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档