<返回更多

深入理解并发编程艺术之JVM内存模型

2023-10-27  微信公众号  码农本农
加入收藏

JAVA内存模型由来

我们知道不同的计算机硬件和操作系统的,所遵循的规范以及计算机内存模型是有区别的,也就意味着我们开发的程序放在某个计算机硬件和操作系统上运行是正常的,而在另一个计算机硬件和操作系统上运行就存在安全问题。

《Java 虚拟机规范》中曾试图定义一种“Java 内存模型”来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,但是定义 Java 内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让 Java 的并发内存访 问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。为了获得更好的执行效能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。经过长时间的验证和修补,直至 JDK 5(实现了 JSR-133)发布后,Java 内存模型才终于成熟、完善起来了。

讲到这里java内存模型的概念是什么呢?

Java 内存模型(Java Memory Model 简称 JMM)是一种抽象的概念,并不真实存在,它主要目的是围绕原子性、可见性和有序性这几种并发问题定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

主内存主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。

工作内存主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

根据JVM虚拟机规范主内存与工作内存的数据存储类型以及操作方式,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

java内存模型中的问题

我们java开发中三个并发问题原子性问题,可见性问题,指令重排问题

原子性问题

原子性就是指该操作是不可再分的。不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。

可见性问题

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

指令重排问题

指令重排序:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能,但是指令重排会遵循as-if-serial语义。as-if-serial语义的意思是:不管怎么重排序,程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义,为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。何为依赖关系:譬如指令1把地址A中的值加10,指令2 把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排——(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证 处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为hAppens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。happens-before原则后面再介绍。

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

深入理解并发编程艺术之JVM内存模型

java内存模型如何解决问题

为了解决这种线程安全问题,针对一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下八种操作来完成。

数据同步八大原子操作

(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态

(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用

(4)load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中

(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎

(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量

(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作

(8)write(写入):作用于工作内存的变量,它把 store 操作从工作内存中的一个变量的值传送到主内存的变量中

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从 工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作 必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令 的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。

除此 之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

1.不允许 read 和 load、store和 write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。

2.不允许一个线程丢弃它最近的assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

3.不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。

4.一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use、store 操作之前,必须先执行 assign 和 load 操作。

5.一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但lock操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。

6.如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。

7.如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

8.对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

8大原子操作long和double类型

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性, 但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有 被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否 要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”。

如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试,在目前主流平台下商 用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位x86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。

从JDK9起,HotSpot增加了一个实验性的参数-XX:+AlwaysAtomicAccesses来约束虚拟机对所有数据类型进行原子性的访问。而针对double类型,由于现代中央处理器中一般都包含专门用于处理浮点数据的浮点运算器,用来专门处理单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也证实了这一点。笔者的看法是,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

volatile关键字

除了8大原子操作Java内存模型还对volatile关键字定义了特殊规则:

假定 T 表示 一个线程,V 和 W 分别表示两个 volatile 型变量,那么在进行 read、load、use、assign、store 和 write 操作 时需要满足如下规则:

1.只有当线程 T 对变量 V 执行的前一个动作是 load 的时候,线程 T 才能对变量 V 执行 use 动作;并且, 只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 才能对变量 V 执行 load 动作。线程 T 对变量 V 的 use 动作可以认为是和线程 T 对变量 V 的 load、read 动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次使用 V 前都必须先从主内存刷新最新的值,用于保证能看见其 他线程对变量 V 所做的修改。

2.只有当线程 T 对变量 V 执行的前一个动作是 assign 的时候,线程 T 才能对变量 V 执行 store 动作;并 且,只有当线程 T 对变量 V 执行的后一个动作是 store 的时候,线程 T 才能对变量 V 执行 assign 动作。线程 T 对变量 V 的 assign 动作可以认为是和线程 T 对变量 V 的 store、write 动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以 看到自己对变量V所做的修改。

3.假定动作A是线程T对变量V实施的use或assign 动作,假定动作F 是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store 动作,假定动作Q是和动作G相应的对变量W的read或 write动作。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序 相同。

volatile关键字可以解决指令重排和可见性问题但是解决不了原字性问题,synchronized可以解决原子性问题,可见性问题,指令重排问题,后面讲会详细说一下volatile关键字和synchronized。

这8种内存访问操作以及上述规则的限定以及jvm内存模型对volatile 的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。基于理解难度和严谨性考虑,最新的JSR-133文档中,已经放弃了采用这8种操作去定义Java内存模型的访问协议,缩减为4种(仅是描述方式改变了,Java 内存模型并没有改变)。

先行发生原则

这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。这种定义相当严谨,但也是极为烦琐,实践起来更是无比麻烦。如果在代码中必须考虑以上定义才能确定并发访问下是否安全,那就太麻烦了,好在后来这种定义的一个等效判断原则——先行发生原则的提出解决这中困惑, 如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变 得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为Java语言中有一 个“先行发生”(Happens-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。现在就来看看“先行发生”原则指的是什么。先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们可以举个例子来说明一下。如代码清单12-8所示的这三条伪代码。

// 以下操作在线程A中执行 
i = 1; 
// 以下操作在线程B中执行 
j = i; 
// 以下操作在线程C中执行 
i = 2;

假设线程A中的操作“i=1”先行发生于线程B的操作“j=i”,那我们就可以确定在线程B的操作执行后,变量j的值一定是等于1,得出这个结论的依据有两个:一是根据先行发生原则,“i=1”的结果可以被观察到;二是线程C还没登场,线程A操作结束之后没有其他线程会修改变量i的值。现在再来考虑 线程C,我们依然保持线程A和B之间的先行发生关系,而C出现在线程A和B的操作之间,但是C与B没 有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量i的影响可能 会被线程B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已 经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

·程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行 发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循 环等结构。

·管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这 里必须强调的是“同一个锁”,而“后面”是指时间上的先后。

·volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先后。

·线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

·线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检 测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止 执行。

·线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程 的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。

·对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。

·传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行发生于操作C的结论。

Java语言无须任何同步手段保障就能成立的先行发生规则有且只有上面这些,下面演示一下如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全。读者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。演示例子如以下代码所示。

private int value = 0; 
pubilc void setValue(int value){ 
this.value = value; 
}
public int getValue(){ 
return value; 
}

代码中显示的是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时 间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则:

1.由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;

2.由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;

3.由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;

4.后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。

5.因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。

那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:

1.要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;

2.要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来 实现先行发生关系。

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”。那如果一个操作“先行发生”,是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的。

一个典型的例子就是多次提到的“指令重排序”,如下代码所示:

int i = 1; 
int j = 2;

代码所示的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这一点。

上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

关键词:JVM      点击(10)
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多JVM相关>>>