第一章讲述了Java线程间通信方式是通过共享内存变量的方式进行显式的同步、隐式的通信,那么在多线程共享内存时,如何保证它们的访问顺序(如保证线程A对共享变量a的写入在线程B的读取之前执行)? 这就是JMM需要完成的任务了。
Java内存模型(JMM)是一个语言级的内存模型,它描述和规定了在不同平台上编译器和处理器的重排序规则,进而提供了一种统一的内存可见性保证。
在Java中,所有的实例域、静态域和数组元素都在堆上开辟内存存储,在线程之间进行共享,多线程对于共享变量的访问,如果不进行正确的同步会存在线程安全问题;java虚拟机栈(局部变量表等)、程序计数器等是线程私有变量,不存在线程安全问题。
如下图:每个线程有一个本地内存,存储了主内存中共享变量的副本,本地内存包括:缓存、写缓冲区、寄存器等。
JMM决定一个线程对共享变量的写入何时对另一个线程可见。
顺序一致性模型是一个理论参考模型,它提供了极强的内存可见性保证:
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的手段。
在顺序一致性模型中,不论程序是否对共享变量进行同步访问,都不会存在线程安全性问题。
由于顺序一致性模型的效率太低(对主内存访问的互斥性、内存的访问速度远远低于处理器时钟频率),所以每个处理器都会有自己的寄存器和写缓冲区,而且编译器和处理器都会对指令进行重排序以提高处理效率。
在这种情况下,程序对内存操作顺序对于程序员是不可见的,因此Java语言规范定义了JMM通过以下两种手段为程序员提供内存可见性保证:
JMM会在生成指令序列的适当位置插入内存屏障来禁止特定类型的处理器重排序。
内存屏障:
JMM提供了先行发生(Happens-Before)
规则,来描述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在Happens-Before关系。
先行发生
于该线程中后续的所有操作;先行发生
于后续对该锁的获取;先行发生
于所有后续对该变量的读;先行发生
B,B先行发生
C,那么A先行发生
C。Happens-Before规则是JMM提供的最主要的内存可见性概念,而禁止特定的编译器重排序和在字节码中插入内存屏障禁止特定的处理器重排序是JMM实现内存可见性的具体方法。
不管编译器和处理器怎么重排序,单线程程序的执行结果会保持一致,此为串行化(as-if-serial)语义。
串行化语义规定了编译器和处理器不能对存在数据依赖的操作进行重排序。
Java中有三个同步原语,它们确保了共享变量在多线程之间的内存可见性:
如果一个共享变量被volatile修饰,JMM保证所有线程看到的该变量的值都是一致的。
该关键字保证了被修饰的共享变量在多线程之间的:
对于volatile变量的符合操作,可以通过配合自旋CAS将其封装为原子操作,详见后续的原子操作类解析
对于一个Volatile变量的写操作Happens-Before于后续任意对该变量的读取。
JMM通过在volatile变量的读写操作前后插入相应的内存屏障来禁止特定的处理器重排序:
具体的屏障插入我也不甚熟悉,暂且如此记录,待以后了解。
volatile变量在汇编代码时插入Lock指令:
缓存锁定
来原子地更新内存;锁是Java并发编程中最重要的同步机制,其不仅保证了共享变量在多线程间的可见性,而且保证了临界区(对共享变量的访问)操作在多线程间的互斥性和原子性。
同时,一个锁的释放相当于该线程通过主内存向获取同一个锁的线程发送了一个消息(隐式通信)。
对一个锁的释放Happens-Before于后续任意对该锁的获取。
相当于Volatile变量的写的内存语义
;相当于Volatile变量的读取的内存语义
。Java的Synchronized同步原语,主要利用Java对象头和ObjectMonitor(对象监视器)实现,对象头记录锁标识和锁类型(偏向锁、轻量级锁、互斥锁即重量级锁),对象监视器记录拥有锁的线程和在该锁上的线程等待队列以及阻塞队列。具体请看深入理解Java并发之synchronized实现原理。
关键字Final正如其义:最终的。
被其修饰的变量是不能被修改的,当然,如果该变量是引用类型,那么其引用的对象可以修改;且final域只能在声明时或构造函数里对其进行初始化;
final修饰的方法是不可被重写的;
final修饰的类是不可被继承的,如String类。
Final关键字保证了被其修饰的域在被其他线程访问时已经是完成初始化的了,即其他线程不会因为重排序而读取到未完成初始化的final域。前提是保证构造函数中不会发生this
逸出。
写final域的重排序规则:
读final域的重排序规则:
初次读取对象引用于初次读取该对象包含的final域,这两个操作之间存在间接依赖关系。
编译器和大多数处理器都会遵循间接依赖,而不会对这两个操作进行重排序。
为了防止有的处理器对此进行重排序,JMM在读final域的前面插入一个LL屏障。
早期的JVM在性能上存在一些待优化的地方,因此会需要推迟一些高开销的对象初始化操作,并且只有在当使用这些对象时才进行初始化。如下例:
public class UnSafeInit {
private static Resource resource;
public static Resource getInstance(){
//当resource为null时进行初始化
if (resource == null){
resource = new Resource();
}
return resource;
}
public static class Resource {
//将构造器私有化,避免外部函数调用构造器初始化单例对象
private Resource(){
}
}
}
该例代码在单线程程序中没有问题,是一个良好的延时初始化操作,但是在多线程环境运行时,由于多个线程同时访问,会存在多个线程之间看到的共享变量resource不一致的情况,所以可能出现,多个线程持有的resource对象不一致。
由于上例未对共享变量的访问进行同步,导致多线程访问出现线程安全问题。所以要对方法进行同步:
public static synchronized Resource getInstance(){
//当resource为null时进行初始化
if (resource == null){
resource = new Resource();
}
return resource;
}
然而上例代码在每次被调用时都会进行加锁解锁的操作,引起线程阻塞和上线文切换,导致了额外的性能开销。为了减少这种开销,出现了下例的双重检查锁定方式:
//进入方法时不加锁
public static Resource getInstance(){
//当检测到resource不为null时也不用加锁,大幅降低锁带来的性能开销
if (resource == null){
//当检测到resource为null时进入同步块
synchronized (UnSafeInit.class){
//再次检查resource为null时才进行初始化
if (resource == null){
resource = new Resource();
}
}
}
return resource;
}
这种方式看似很美好,即保证了只有一个线程完成了Resource的初始化操作,又降低了同步带来的性能开销;然而美好的事物往往在美丽的背后隐藏着危险:线程A获得了锁进行了Resource的初始化,线程B看到了Resource不为null,然而由于重排序的存在,线程B获得的Resource对象可能还没有完成初始化,即线程B看到对象处于无效或错误状态。
对于DCL导致的问题,归根结底还是对象的引用在构造器中逸出
了,只需要做一点小小的改动,比如禁用构造期间编译器重排序,使对象在被真正完成初始化后才赋值给引用。
private static volatile Resource resource;
//进入方法时不加锁
public static Resource getInstance(){
//当检测到resource不为null时不用加锁,大幅降低锁带来的性能开销
if (resource == null){
//当检测到resource为null时进入同步块
synchronized (UnSafeInit.class){
//再次检查resource为null时才进行初始化
if (resource == null){
resource = new Resource();
}
}
}
return resource;
}
此方法中,利用volatile
关键字的Happens-Before
规则,使所有线程都看到完成初始化后的对象引用。
public class SafeInit {
private static Resource resource = new Resource();
public static Resource getInstance(){
return resource;
}
}
在构造器中采用了特殊的方式来处理静态域,并提供了额外的线程安全保障。静态构造器是由JVM在类的初始化阶段执行,即在类被加载后被线程使用之前。由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类被正确加载,因此在静态初始化期间,内存写入操作对所有线程所见。
但是这种提前初始化的方式,没有起到延迟加载的作用,下面的方法利用这种无需显示同步的方法和占位类构造了线程安全的延迟初始化方法:
public class SafeInit {
private static class ResourceHolder{
public static Resource resource = new Resource();
}
public static Resource getInstance(){
return ResourceHolder.resource;
}
JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需显示同步。
当任何一个线程第一次调用getResource方法时,才会使ResourceHolder被加载和初始化,此时静态初始化器将执行Resource的初始化操作。
详见 Java 单例模式的两种高效写法