第三章 Java内存模型之基础

 2019-12-10 16:13  阅读(1045)
文章分类:Java Core

平时我们很少会注意Java内存模型,对于一些概念很多都是背诵,不是甚解,纳闷这一章,将把这个透明层给扯开,让他再也遮不住我们眼睛。

首先两个关键问题

1)线程之间如何通信
2)线程之间是如何同步

线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型中,线程之间共享程序的公共状态,通过读-写内存中的公共状态进行隐式通信。
在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发送消息来进行显示通信。

同步是指程序中用于控制不同线程间发生相对顺序的机制,在共享内存并发模型里,同步是显示进行的。程序员必须显示指定某个方法或某段代码需要在线程之间互相执行。在消息传递的并发模型里,由于消息的发送必须在消息的接受之前,因此同步是隐式进行的。

那么Java的并发采用的共享内存模型,Java线程之间的通信是隐式进行的。整个通信的过程对程序员是完全透明的。如果我们不理解这个隐式进行的线程之间的这种通信,那么遇到各种奇怪的内存问题也就不奇怪了。

Java内存模型的抽象结构

首先,我们要了解一下内存可见性问题发生的位置。在Java中,所有实例域和数组元素都存储在堆内存中(重点),堆内存在线程之间共享。而局部变量,方法定义参数和异常处理的参数不会在线程之间共享的,那是线程私有的,所以他们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(JMM)控制。JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来说,JMM定义了线程和主内存之间的抽象关系。线程之间的共享变量存储在主内存中(Main Memory)每个线程都有一个私有的本地存储(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。就是我们在理解中可以想象有一个本地存储这个东西,方便我们去理解和想象,但是实际是缓存、写缓冲区、寄存器等形式。

如下图一般,我们就能很好地理解这个概念了。

20191210001664\_1.png

从图中我们可以发现:如果线程A要与线程B通信的话,需要下面两个过程:
1)线程A把本地内存中A中更新的共享变量刷新到主内存中
2)线程B到主内存中去读取线程A已经更新过的共享变量

我们会发现,共享内存之间的通信确实是隐性的。

当然我们在举个小栗子,更直观的去理解。

20191210001664\_2.png

相信大家都看懂了吧。线程A向线程B发送消息,必须通过主内存,JMM通过控制主内存和每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

从源代码到执行序列的重排序

相信大家都听过重排序这个名词,就是在执行程序的时候,为了提高性能,编译器和处理器常常会对指令做重排序。

重排序分为三种:
1)编译器优化的重排序。编译器在不改变单线程程序语句的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据的依赖性,处理器可能改变语句对应的机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用了缓存和读/写缓冲区,这使得加载和存储操作看上去是乱写执行。

从Java源代码到最终执行的指令序列,会分别经历下面3种重排序。

20191210001664\_3.png

其中1属于编译器重排序,2和3属于处理器重排序。这些排序都有可能造成多线程程序出现内存可见性问题。
对于编译器JMM的编译器重排序规则可以禁止特定类型的编译器重排序。
对于处理器则需要通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,他确保在不同的编译器和不同的处理器上,通过禁止特定类型的编译器重排序和处理器重排序,为我们提供一致的内存可见性保证。

接下来讲点有趣的而且很有用的东西。比上面的好玩很多。

并发编程模型的分类

背景:
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,避免由于处理器停顿下来等待内存写入数据而产生的延迟(cpu比内存块太多了,所以CPU会经常等待内存写入数据)。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,减少对内存总线的占用。我们发现写缓冲区的优点很给力啊,但是···它有一个很严重的问题,因为每个处理器都有自己的写缓冲区,而且只对它所在的处理器可见。这一特性会对内存操作的执行顺序带来重要的影响。处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。

不太懂不要紧,来一口小栗子马上懂

20191210001664\_4.png

线程A、B分别进行A1、A2和B1、B2的操作,如果是一个线程执行这几个操作,是不会有问题的,但是两个线程,分别进行操作,因为重排序造成执行顺序的不确定性,我们很有可能得到x=y=0 的结果。

其中步骤如下图,首先同时A1、B1 写缓冲区操作之后,直接读取共享变量(A2、B2)。最后才会把脏数据刷新。这种顺序就会得到x=y=0 的结果。

20191210001664\_5.png

这种顺序发生的关键在于写缓冲区仅对自己的处理器可见,他会导致处理器执行内存操作的顺序可能会与内存实际的操作顺序不一样。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

重点就是现代处理器允许读-写操作进行重排序。

20191210001664\_6.png image.png

总结上图的结论 :常见的处理下允许Store-Load重排序,这个不影响结果。但是不允许数据依赖的操作进行重排序。

为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。大家了解一下就好了。不必太多纠结。

20191210001664\_7.png image.png

最后,介绍一个和我们息息相关的透明知识点。

happens-before

从JDK5开始,Java采用JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以在一个线程里也可以是在不同的线程之间。

happens-before规则:

1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
意思就是当在单线程中,程序中的逻辑上前一个操作必须happens-before后一个操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

需要注意的是:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!!! happens-before只是要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)

还是很绕对吧,就是这样的如果

public void add(){
        int a = 1;//A
        int b= 2;//B
        int c = a+b;//C
        }

A happens-before B ,A happens-before C,B happens-before C。
因为A happens-before B,所以A操作产生的结果一定要对B操作可见,但是B操作和A没关系,所以可以重排序。同样A happens-before C,所以A操作产生的结果一定要对C操作可见,如果重排序了结果会错误,所以不能重排序。

再来体会一遍:如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!happens-before只是要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 第三章 Java内存模型之基础

相关推荐