Zhu.Yang

朱阳的个人博客(公众号:think123)

0%

一篇文章讲懂Java内存模型

Java运行时数据区域

在Java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(Java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响
Java运行时数据区

多线程下编码问题

在Java多线程编程中,线程之间的通信机制是通过读写共享内存(堆内存)中的公共数据来隐式进行通信的,而同步是指程序用于控制不同线程之间操作发生相对顺序的机制,同步是显式进行的,需要程序员 显式指定某个方法或某段代码需要在线程之间互斥执行。由于线程之间的通信是隐式的,所以不了解这之间的通信机制,那么可能会遇到各种奇怪的内存可见性问题,导致在多线程编码中得到很多你不想要的结果。

在处理器级别,由于现在计算机基本上都是多CPU的,存储器模型也定义了一定的条件,用于知道当前处理器对其他处理器的存储器写入是可见的。某些处理器表现出强大的内存模型,其中所有处理器始终看到任何给定内存位置的完全相同的值。其他处理器表现出较弱的内存模型,其中需要特殊指令(称为内存屏障,memory barriers,intel称之为memory fence)来刷新或使本地处理器高速缓存无效,以便查看其他处理器进行的写入或使该处理器的写入对其他处理器可见。但是即使在一些最强大的内存模型上,通常也需要内存屏障。处理器大多数使用较弱的内存模型,因为这样可以容许更大的扩展性。

编译器重新排序代码时,写入对另一个线程可见的问题更加复杂。例如,编译器可能会决定稍后在程序中移动写操作更有效;只要此代码动作不会改变程序的语义,就可以自由地执行此操作。如果编译器推迟操作,则另一个线程在执行之前不会看到它;这反映了缓存的效果。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型

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

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

java代码到最终执行的指令会分别经历下面三种排序
指令重排序

什么是Java内存模型

Java虚拟机规范中试图定义一种Java内存模型(JMM,Java Memory Model)来屏蔽各种硬件和操作系统的内存访问差异,以实现Java程序在各种平台下都能达到一致的访问效果。

上述的编译器重排序,处理器重排序(包括指令集重排序和内存系统重排序)这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

上面这么多问题都影响我们多线程编码,因此Java为了保证一次编译到处运行,让线程之间的通信由Java内存模型控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
Java线程工作内存和主存通信

as-if-serial语义

as-if-serial语义指的是不管怎么重排序(编译器和处理器为了提高并行度),单线程的执行结果不能被改变。编译器,runtime和处理器都必须准守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。

数据依赖关系分为以下几种:
| 名称 |代码示例 | 说明 |
|—|—|—|
| 写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
| 写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
| 读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量 |

编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。 仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

处理器排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操做重排序。
为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 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会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

原子性、可见性、与有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们现在看看这三个特性。

原子性

通俗的将就是一个操作不能被其他操作打断,要么成功,要么失败。Java中基本数据类型的访问读写是具备原子性的(例外就是long和double的非原子性协定,但是几乎不会发生)

可见性

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

有序性

有序性指的是代码在本线程内部观察都是有序;如果在另一个线程中观察一个线程,所有的操作都是无序的。
前半句指的是as-if-serial语义,后半句指的是”指令重排序”现象和”本地内存和主内存同步延迟”现象。

如果每一次多线程编程都要去考虑重排序,那么是非常辛苦的,而且也违背了java的初衷,因为考虑系统底层的实现总是非常辛苦的。从jdk5开始java使用新的jsr-133内存模型,其中通过happen-before(先行发生)的概念来阐述操作之间的内存可见性。 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。这就意味着如果说操作A先行发生于操作B,其实就是说在发生操作B之前,从操作A产生的影响能被B观察到,”影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

Java中天然的happen-before(先行发生)原则

  • 在一个线程内,按照控制流顺序,书写在前面的操作happen-before书写在后面的操作
  • 对同一个monitor的unlock操作都happens-before同一个monitor的lock
  • 对volatile变量的写操作都happens-before同一个变量的读操作
  • 线程的start()都happens-before线程的任何一个操作
  • 线程中的所有操作happens-before任何其他线程从该线程上的join()成功返回。
  • 线程interrrupt()方法的调用happends-before被中断线程的代码检测到中断事件的发生
  • 如果A happens-before B,且B happens-before C,那么A happens-before C.

Java中线程同步的关键字

volatile

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的volatile重排序规则表:
volatile排序规则
从上表我们可以看出:举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。

  2. 在每个volatile写操作的后面插入一个StoreLoad屏障。

  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。

  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。因为volatile可以保证可见性以及重排序优化。

synchronized

只有一个线程能持有监视器,这代表只有一个线程可以进入同步代码块,其他线程只有在这个线程退出了同步代码块之后才能进入,同时在退出同步代码块的时候JMM会把该线程对应的本地内存中的共享变量刷新到主内存中,所以这个线程的写操作对其他线程是可见的。当我们进入同步代码块的时候,我们需要获取监视器,在获取监视器的同时会使得当前处理器本地的缓存无效,而是会从主存中同步数据,然后我们就可以看到前一个线程的所有写入了。

1
2
3
synchronized(new Object()) {
dosomething();
}

如果用这段代码来设置内存屏障是不起作用的,因为编译器知道不会有其他线程来获取同一个而监视器,所以编译器会完全给你移除
需要注意的是两个监视器在同一个监视器锁上同步才能触发happens-before,如果线程A在对象X上同步时可见的所有内容在对象Y上同步后不一定就对线程B可见。所以获取和释放必须匹配。

synchronized关键字可以保证原子性、可见性、与有序性,因为它如此强大的功能,许多人将它大量用在线程同步中,通常会伴随着越大的性能影响。

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

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

也就是说使用final字段可以保证初始化安全性,它允许在不同步的情况下自由访问和共享不可变对象。

总结

多线程编码中影响多线程安全的主要有内存、处理器重排序、编译器重排序等硬件和软件方面问题,而JMM则屏蔽了底层细节,对其进行了抽象处理,保证了单线程的as-if-serial语义以及多线程下合理使用Java提供的关键字或者开发包来保证线程安全。

参考文档

http://www.infoq.com/cn/articles/java-memory-model-1

<<深入理解Java虚拟机>>

欢迎关注我的其它发布渠道