天天看点

Java笔记:Java内存模型

本文是在翻阅多篇有关java内存模型相关的文章之后,基于自己的理解,为了加深记忆,写的一篇java内存模型的总结。好记性不如烂笔头,共勉。

为什么要提出Java内存模型

由于在java多线程编程中,方法区和堆空间(实例对象,数组对象,静态对象)都是线程共享的,而程序计数器、java方法栈和本地方法栈才是线程私有的。因为这些共享变量的存在,在并发编程领域需要解决的核心问题就是它们的可见性、有序性和原子性问题。如果不能解决这些问题,在并发编程中会造成诡异的bug。那么可见性、有序性和原子性都是怎么造成的呢?

  • 可见性:主要是为了平衡CPU处理速度和内存读写速度之间差异性,为了能够减少CPU等待IO读写的时间,所以需要增加CPU缓存,这样就引入了可见性问题。一个线程对共享变量进行了改变,另外一个线程也能看到,称之为可见性。
  • 原子性:为了更好的利用CPU,引入多线程切换,这样会造成原子性问题。一个或者多个操作不被中断执行的特性,称为原子性。
  • 有序性:为了更好的利用缓存,编译器或者处理器对指令会有重排序的优化,这样就引入了有序性问题。

基于以上的问题,如果能禁用缓存或者禁止编译器重排序,一切就迎刃而解了。但是,这样的话,程序的运行效率就堪忧了。所以对于开发程序员而言,需要有一个按需禁用缓存或者禁止编译器重排序的规范,这样就能平衡运行效率和线程安全了。因此,java在1.5版本引入了java内存模型。

什么是java内存模型

就我的理解而言,java内存模型是一种规范,在必要的情况下,让一个线程对共享变量的修改对其他线程可见。根据免费电子书-深入理解java内存模型对java内存模型的抽象,可以这么理解:

Java笔记:Java内存模型

每个线程对共享变量都有一个抽象的本地内存副本(其实本地内存并不真实存在),java内存模型需要做的是就是通过刷新缓存、编译器优化等操作让线程之间共享变量可见。

既然java内存模型是一种规范,那么我们需要了解的就是锁、final、volatile三种方法的使用,以及8大happens-before原则。

happens-before原则

happens-before原则不是指一条指令必须先于另一条指令发生,而是指一条指令的结果需要对后面一条指令可见。as-if-serial原则。举个例子就是:指令1happens-before指令2,如果指令1和指令2不存在数据依赖关系,也就是指令1的结果对指令2没有影响,这种情况编译器仍可以进行重排序;否则,不允许。以下是规范的八大原则:

  • 控制流顺序执行原则:一条语句A在书写上先于另一条语句B,那么A happens-before B;
  • 锁的原则:对同一个锁的解锁happens-before后续对同一个锁的加锁操作;
  • volatile原则:对volatile变量的写操作happens-before后续对这个变量的读操作;
  • 传递性:A happens-before B,B happens-before C,那么A happens-before C;
  • 线程的start原则:在线程A中调用线程B的start()方法,那么线程B能看到线程A中start之前的的所有操作。换句话说就是,线程A调用B.start之前的所有操作 happens-before B中的任何语句;
  • 线程的join原则:如果在线程A中调用线程B的join()方法,那么线程B中的任意操作 happens-before join()的返回;
  • 线程的interrupt原则:线程的interrupt()方法的调用 happens-before 被中断线程的代码检测到中断事件的发生;
  • 对象销毁原则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

happens-before的底层实现

通过在指令的前后插入内存屏障来保证可见性。总共有四种内存屏障:

内存屏障 指令示例 含义
读读屏障 Load1;LoadLoad;Load2 确保对指令Load1的读要先于对指令2以及后续所有读指令的读
读写屏障 Load1;LoadStore;Store2 确保对Load1的读要先于对指令2以及后续所有写指令的写
写写屏障 Store1;StoreStore;Store2 确保对Store1的写(刷新到内存)要先于指令2以及后续所有写指令的写
写读屏障 Store1;StoreLoad;Load2 确保对Store1的写在所有处理器中都可见(刷新到内存),要先于后续的读操作;并且会使StoreLoad之前所有的内存访问指令都执行完成后,才会执行后续的 内存访问指令

根据上面的锁的原则,对同一个锁的解锁操作会先行发生于对同一个锁的加锁操作,在JVM进行逃逸分析之后,如果确定锁是被不同的线程所持有,那么在解锁的时候会强制刷新缓存,如果只是由同一个线程持有,那么会移除加锁和解锁操作。

加锁和解锁的内存语义

解锁的时候,线程会将本地内存强制刷新到主内存当中,而加锁的时候,缓存中的共享变量会置为无效,线程需要从主内存中重新获取共享变量的值。如图:

Java笔记:Java内存模型

volatile

volatile是用来保证变量的可见性和有序性的,但不保证原子性。对于volatile变量的读操作,会在之后插入读写屏障和读读屏障,就是确保在volatile读之后,后续所有内存操作才允许执行;对于volatile变量的写操作,会在其之前插入读写屏障,在其后插入写读屏障,确保该变量对后续内存操作可见,以及也不会重排序到读取之前。

volatile提供了一种不保证原子性的变量可见性和有序性的机制,有时候可以替代锁。适合于读多写少的场景。如果频繁写的话,会频繁强制刷新内存,这个对性能也是很有影响的。

final

被final标记的变量,生而不可变,天生就是线程安全的,但是1.5之后java内存模型对其也做了重排的限制。

由于1.5之前编译器和处理器对final字段的优化太过努力了,以致于都优化出错了。有这么一个场景就是,编译器重排序指令的时候,将对象中的final字段重排序到了构造函数的外面,以致于可能其他线程对其的读取有种final字段可变的错觉。

所以,java内存模型对final字段的读写有以下重排的限制:

  • 对于final字段的写,在其后面插入写写屏障,就保证了final字段不会重排到构造函数外面,其他线程读到的都会是初始化完成了的final字段值;
  • 对于final字段的读,初次读对象引用要在初次读对象中的final字段之前,编译器会在final字段读的前面插入读读屏障。这个是处理器层面上的重排序限制,而编译器层面上,由于这两个存在数据依赖关系,不会进行重排序。

还有值得注意的是,在对象还没有初始化完全的时候,不要逸出,例如:

public class Escape{
	private final int i;
	static Escape obj;
	public Escape(int i){
		this.i = i;
		obj = this;//这样就逸出了
	}
}
           

而且,这种情况,在多线程环境下,读取到的i的值也可能不对。

参考资料

极客时间-深入拆解java虚拟机第13篇

极客时间-java并发编程实战第2篇

免费电子书-深入理解java内存模型