指令引用了 内存 该内存不能为read 一直弹窗_深入浅出Java内存模型

3a2136bd5c9382050348b6bbd133ae1a.png

当我们深入到Java并发编程时,一个绕不过去的槛就是Java内存模型(以下简称JMM)。不了解JMM的话,多线程环境下遇到线程安全问题,很可能你就会不知所措。如果在多线程竞争环境下你想设计出高性能,又线程安全的应用,理解JMM更是必不可少的,并且JMM又是JVM中重要的一部分。所以学习并掌握了JMM,对于开发人员在日常工作中来说是一件收益很大的事情。

为什么需要JMM?

这个说来就话长了,简单来说主要是为了弥补由底层硬件架构升级、编译指令优化引发的可见性、有序性问题,而定义出的一套规范内存模型。

可见性问题

如果一个共享变量被一个线程修改了,能够马上被其它线程看到,我们称之为可见。

随着硬件升级,多核cpu出现,每个cpu都自带独立寄存器。线程运行时为了加速,会把一些访问的变量缓存在cpu的寄存器上。当多个线程访问同一个共享变量a时,不同的线程运行在不同的cpu上面,同时也就缓存了一份a在不同的寄存器上。这个时候给变量进行修改,每个线程只看到自己修改的a,这样就出现了同一时刻,每个线程看到的a是不一样的,也就是不可见。这是缓存带来的不可见性问题。

075e91c99b5c7c5a8ce6dd9c5debc382.png
多核CPU缓存

运行下面一段代码:

static 

得出结果:

a 

为什么a不是200000呢?因为t1线程和t2线程都缓存了a,两者都是修改了自己的副本再刷到主内存中,这样在主内存中两者出现相互覆盖的场景,a的次数就会被算少。

有序性问题

这个主要是处理器重排序优化引发的问题。

编译器重排序,比如下面一段代码

public 

对于a、b、c的三者初始化,虽然代码上规定了初始化顺序是: a -> b -> c。但因为调整a、b 、c的处理顺序并不会影响最终的处理结果(遵守了As-If-Serial语义)。在一个CPU周期内,计算机会并行执行这几个初始化的指令代码去进行提速,也就是a、b、c初始化顺序是随机的,相当于a、b、c的初始化指令被重新排了一遍顺序。

在单线程环境下,只要重排序遵守As-If-Serial语义是一定不会有线程安全问题的,但是在多线程环境下就不一定。看下面

定义Instance类

class 

多线程去调用getInstance方法

private 

这时候如果判断instance不为null, instance.i的值就一定是10了吗? 不一定,因为此时instance可能还没有完成初始化工作,new Instance()并不是一个指令就可以完成的原子操作,整个过程可以分成下面三步。

mmeory 

仔细观察你会发现,第二和第三步其实是没有逻辑上的依赖的,处理器可以重排序这两个操作。所以就会出现instance不为null,但还没有初始化完成的情况,此时instance.i的值为0,并不是理想当然的10。

所以我们看到了由硬件缓存和编译指令重排序带来提速的同时,也带来了可见性和有序性的问题。一方面我们是不能全部禁止这些优化,如果没有这些优化,程序的性能将会大打拆扣,但又不能放任这种优化带来的问题,所以这时JMM出来解决这个问题。

JMM做的事情是在不影响程序结果的情况尽量放开限制,让底层尽可能的优化程序,但同时也提供了限制优化的方法,让开发人员按需去使用。简单来说JMM在两个方面提供了限制方法:一、禁用缓存 二、禁止重排序。具体来说这些方法有volatile、锁(synchronized)、final等关键字以及六项happen-before规则。

volatile

提到volatile人们最先想到的就是volatile变量的可见性

volatile 

上面代码线程A调用write方法之后,线程B调用read方法马上就能看到之前的修改,根本不需要加锁操作。简单来说voatile的可见性是通过禁用缓存来实现的,关于volatitle的可见性实现原理可以参考以下文章,这里就不再详细描述了。

林林:剖析volatile、synchronized实现原理​zhuanlan.zhihu.com
34c8682664187a393ebf464653dedb2a.png

这里额外说明的是volatile除了禁用缓存之外,还有禁止重排序的功能

多线程环境下运行以下代码

volatile 

调用read方法,当 b == 1时, c会是多少呢? 按照之前重排序的规则 c = 2 和 b = 1是没有先后逻辑依赖关系,所以调用write方法时理应可以进行重排序处理,此时c的值可能是2,也可能是0。这种情况在Jdk1.5之前确实是这样,但在Jdk1.5之后对volatile语义在重排序方面进行了增强,此时的c一定是2。具体规则如下:

bba79441e98b45225d947bb228664bc6.png
volatile重排序规则

从表中我们看出。

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。
  • 当第一个操作是volatile写,第二个操作是volaitle读时,不能重排序。

应用上面的规则,我们得出

void 

除此之外当对volatile变量进行写操作时,还会把线程中的本地缓存变量刷新到主内存中,当读取volatile变量时会把本地缓存变量置为失效,读取主内存中最新值。所以write方法中当执行 b = 1 时, 会把 c = 2 刷新到主内存中。read方法中b==1之后,会把主内存中最新的c load出来,所以c 一定能读到最新值。

JMM中又是如何强制把本地缓存的变量刷新到主内存和禁止重排序的?通过插入内存屏障式。

  • 在每个volatile写操作的前面插入一个StoreStore屏障,保证前面任意的读写操作不能重排到volatile后面。
  • 在每个volatile写操作后面插入一个StroeLoad屏障,禁止当前的volatile写与后面的volatile读/写进行重排序。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障,禁止后面的所有普通读操作与当前的volatile重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障,禁止后面的所有普通写操作和当前的volatile读重排序。

所以合理的利用volatile特性可以在无锁的情况下,并且不阻塞线程,实现线程间安全通信。

Java中锁是通过让临界区互斥执行的方式,来保证线程间的通信安全。如果从内存的角度来看,则Java中所有锁的获取和释放都可以用下面的图来概括。

34c286372713334176efbba930de0672.png


是不是觉得和volatile通信的方式很像?因为从内存语义的本质上来说两者就是一样的。

volatile内存语义:

  • 线程A写一个volatile变量,实际上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)的信息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(之前对共享变量所做修改的)的信息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质是线程A通过主内存向线程B发送消息。

锁的内存语义:

  • 线程A释放一个锁,实际上是线程A向接下来将要获取这个锁的某个线程发出了(其对共享变量所做修改的)的信息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(释放锁之前对共享变量所做修改的)的信息。
  • 线程A释放锁,随后线程B获取锁,这个过程实质是线程A通过主内存向线程B发送消息。

所以我们知道了Java中volatile、锁最终都是通过读写主内存实现线程间安全通信,解决缓存 带来的不一致性问题。

final域内存语义

对于final域,编译器和处理器要遵守两个重排序规则。

1)在构造函数内对一个final域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

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

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现实际包含了2个方面,一个是针对编译器的,一个是针对处理器的

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

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

这两条规则保证了:在对象引用被任意线程可见之前,对象的final域一定是被正确的初始化过了,而普通域则不具有这个保障。

用一段代码说明

public 

线程A执行writer, 线程B执行reader,如果object不为null,则object.j的值一定是10(final域),因为重排序而object.i可能是0也可能是1(普通域)。

上面针对的是final域的基础类型,如果是引用类型呢?

引用类型在原有基础上加多一条限制:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。

public 

上面代码中1与3不能重排序,2与3不能重排序,这样就保证了obj不null时,intArray一定是被正确初始过了。至于4、6是不是可见这个是不保证的,因为没有同步原语操作。

对象引用在构造函数中逃逸

前面提到了在对象引用被任意线程可见之前,对象的final域一定是被正确的初始化过。这实际上是有个前提条件的,就是对象引用不能构造函数中逃逸。因为逃逸会可能让对象引用在正确初始化之前对其它线程可见关于对象逃逸分析可参考下面文章

林林:JVM之逃逸分析​zhuanlan.zhihu.com
34c8682664187a393ebf464653dedb2a.png


举个例子:

public 

因为逃逸和重排序,导致了对象中的final域还没有初始完,对象引用就已经对其它线程可见了,所以构造函数中一定要避免对象引用逃逸。

Happens-before

happens-before是JMM最核心的概念。对Java程序员来说,理解happnes-before是理解JMM的关键。JMM的设计示意图如下

697a64b22a2793752ad44d7de51a5919.png
JMM设计示意图

JMM的happens-before规则对于程序员来说提供了足够强的内存可见性保证,但在不影响运行结果的情况下,JMM又放开了限制,让编译器和处理器去尽情优化。

happens-before定义

happens-before的概念最开始由Leslie Lamport在一篇叫Time, Clocks, and the Ordering of Events in a Distributed System的论文中提出

https://medium.com/coinmonks/time-and-clocks-and-ordering-of-events-in-a-distributed-system-cdd3f6075e73​medium.com

在论文中,happens-before的语义是一种因果关系。在现实世界里,如果A事件是导致B事件的起因,那么A事件一定先于(happens-before)B事件发生的,这个就是happens-before语义的现实理解。对Java来说,happens-before的语义本质上是一种可见性,A happens-before 意味着A事件对B事件是可见的,无论A事件和B事件是否发生在同一个线程里。例如A事件发生在线程1上,B事件发生在线程2上, happens-before规则保证线程2上也能看到A事件的发生。

在JSR-133的JMM规范中定义了如下happens-before规则。

1)程序顺序规则:一个线程中的前面的操作,happens-before于该线程中的任意后续操作。这个很好理解,同一个线程的任意操作,对自己来说当然是可见的。

2)监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。

锁的内存语义,已经说明白了。

34c286372713334176efbba930de0672.png

3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。参考上面volatile特性和volatile实现原理。

4)传递性:如果A happens-before B, 且 B happens-before C, 那么A happens-before C。

volatile 

5)start()规则:如果线程A执行ThreadB.start() (后启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B的任意操作。

public 

6)join()规则:如果线程A执行操作ThreadB.join()返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作返回。

public 

总的来说JMM定义的happens-before规则还是相对容易理解。

总结

JMM涉及到东西相对来说是比较多一点,总的来说可理解成:为了弥补由底层硬件架构升级、编译指令优化引发的可见性、有序性问题,而定义出的一套规范内存模型。

可见性问题的源头是由缓存引起的,有序性问题是编译器、处理器指令优化重排序引起。缓存和指令优化重排序都是提速应用的重要手段,不能全部禁止。

JMM做的事情是在不影响程序结果的情况尽量放开限制,让底层尽可能的优化程序,但同时也提供了限制优化的方法,让开发人员按需去使用。简单来说JMM在两个方面提供了限制方法:一、禁用缓存 二、禁止重排序。具体来说这些方法有volatile、锁(synchronized)、final等关键字以及六项happen-before规则。

另外文中大部分观点和知识都源自于方腾飞、魏鹏、程晓明编写的《java并发编程艺术》一书。本人在此基础上做了一些提炼并查阅一些资料作为补充并提出自己的观点,如有偏差欢迎指出。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值