Java内存模型
JMM示意图
Java内存模型,Java Memory Model(简称JMM),控制了Java线程之间的通信。线程之间的共享变量存储在主内存中,每个线程都有一个自己私有的本地内存,本地内存中存储了该变量以读/写共享变量的副本。如图:
![img](https://i-blog.csdnimg.cn/blog_migrate/7a6acdbec86451e61f5f07bbf1c1b450.jpeg)
Java线程通信
Java并发采用的是共享内存模型,线程之间共享程序的公共状态,并通过写-读主内存中的公共状态来隐式进行通信。
![img](https://i-blog.csdnimg.cn/blog_migrate/74328b1102e3bcb3487dfbf066e96672.jpeg)
如果线程 A 和线程 B 通信,要两个步骤:
1、线程 A 需要将本地内存 A 中的共享变量副本刷新到主内存去
2、线程 B 去主内存读取线程 A 之前已更新过的共享变量
并发编程的问题
- 原子性
- 可见性
- 有序性
原子性问题
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
- 可以通过 synchronized和Lock实现原子性,能够保证任一时刻只有一个线程访问该代码块。
- 对任意单个volatile变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
可见性问题
可见性指的是当一个线程修改了某个共享变量的值,其他线程何时能够得知这个修改的值。
volatile关键字保证可见性,volatile 写-读的内存语义
- 当写一个 volatile 变量时,JMM 会立即把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
synchronized和Lock锁也可以保证可见性,锁释放和获取的内存语义
- 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
有序性问题
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的。但对于多线程环境可能出现乱序现象,因为在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,重排后的指令与原指令的顺序未必一致。
- 可以通过volatile关键字来保证一定的有序性,因为实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
- 可以通过synchronized和Lock来保证有序性,可以保证每个时刻是有一个线程执行同步代码块。
重排序
类别
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三类:
1、编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2、指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
![img](https://i-blog.csdnimg.cn/blog_migrate/65c6b7ef5136a53d07d2664b3f63fe7a.jpeg)
上面的这些重排序都可能导致多线程程序出现原子性、可见性、有序性问题。JMM确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,提供一致的内存可见性保证。
as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
影响
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
对于存在数据依赖关系的两个操作,如果进行重排序,程序的执行结果将会被改变。所以编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
控制依赖性
前序操作是条件语句(if, while…),则后续操作和前序之间就产生了控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
内存屏障指令
为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
![img](https://i-blog.csdnimg.cn/blog_migrate/aef4dab512e76f98d928db8a133664f0.jpeg)
happens-before
JSR-133 内存模型使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器的解锁,happens-before 于随后对这个监视器的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
- 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
- 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
- 对象终结规则:对象的构造函数执行,结束先于finalize()方法。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行,happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
happens-before 与 JMM 的关系如下图所示:
![img](https://i-blog.csdnimg.cn/blog_migrate/abf86385f191bc47fc60e0dfc8e96e4f.jpeg)
如上图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。
Volatile
写-读内存定义
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
内存语义实现
下面是 JMM 针对编译器制定的 volatile 重排序规则表:
![img](https://i-blog.csdnimg.cn/blog_migrate/b3a6df27165d799c8e1fb101cee37c0f.jpeg)
总的来说,就是
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
下面是基于保守策略的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。