序言
本书的第三部分主要讲volatile关键字及7种单例设计模式,这块内容大家应该都特别熟悉了。这里快速的过一遍。
相比synchronized关键字,volatile被称为“轻量级锁”,能实现部分synchronized关键字的语义。理解volatile关键字需要从Java的内存模型以及CPU Cache模型。
CPU Cache模型
所有的运算操作都是由CPU的寄存器完成,CPU指令的执行过程需要涉及数据的读取和写入操作,CPU所能访问的数据只能是计算机的主存(RAM),但CPU的处理速度和内存的访问速度之间存在着巨大差距,为了降低CPU整体吞吐量,于是在CPU和主内存之间增加了缓存设计。如下图,CPU和主内存(Main Memory)之间有一级缓存(L1i-L1 instruction和L2i-L2 data)、二级缓存(L2)和三级缓存(L3),访问速度是,
增加这一缓存设计主要是为了避免CPU和主存之间因速度不对等导致的访问效率低下,程序运行过程中,会因运算所需要的数据从主内存复制一份到CPU Cache中,这样CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写入,当运算结束后,再将CPU Cache的最新数据刷新到主内存中,如下图,这样大大提高了CPU的吞吐能力。
CPU缓存一致性问题
由于缓存的出现,极大地提高了CPU的吞吐能力,但是也引入了缓存不一致的问题。为解决多线程情况下的缓存不一致性问题,有以下两种解决方案:
- 通过总线加锁的方式。
- 通过缓存一致性协议。
第一种方式常见于早期的CPU,CPU和其他组件的通信都是通过总线(数据总线、控制总线、地址总线)来进行,如果采用总线加锁的方式,则会阻塞其他CPU对其他组件的访问,从而使得只有一个CPU能够访问这个变量的内存,这种效率低下。
第二种方案中最出名的是Intel的MESI协议(MESI,Modified Exclusive Shared Or Invalid),它保证了每一个缓存中使用的共享变量副本都是一致的。当CPU在操作Cache中的数据时,如果发现该变量是一个共享变量,也就是说在其他的CPU Cache中也存在一个副本。具体操作如下:
1)读取操作,不做任何处理,只是将CPU的数据读取到寄存器。
2)写入操作,发出信号通知其他CPU该变量的Cache Line置为无效状态,其他CPU在进行该变量读取的时候不得不到主存中再次获取。
Java内存模型
Java内存模型(JMM,Java Memory Mode)指定了Java虚拟机如何与计算机的主存(RAM)进行工作。如下图,Java内存模型定义了线程和主内存之间的抽象关系,具体如下:
- 共享变量存储在主内存中,每个线程都可以访问。
- 每个线程都有私有的工作内存或者成为本地内存。
- 工作内存只存储该线程对共享变量的副本。
- 线程不能直接操作主内存,只有先操作工作内存之后才能写入主内存。
- 工作内存和Java内存模型一样是抽象的概念,它并不存在,它涵盖了缓存、寄存器、编译器优化以及硬件等。
并发编程的三个重要特性
- 原子性:事务内所有的操作要么都执行要么都不执行。
- 可见性:当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的值。
- 有序性:程序代码在执行过程中的先后顺序。
JVM如何保证三大特性
在多线程的情况下,如果不能保证三大特性可能会出现错误。JVM采用内存模型的机制来屏蔽各个平台和操作系统之间内存访问的差异,以实现让Java程序在各个平台下达到一致的内存访问效果,比如C语言中的整型变量,在某些平台下占用了两个字节的内容,在某平台下则占用了四个字节的内容,Java在任何平台下int类型就是四个字节,这就是一致内存访问效果。
JMM与原子性:Java内存模型只保证了基本读取和赋值的原子性操作,其他的均不保证,如自增。
JMM与可见性:volatile关键字、synchronized关键字和JUC提供的显式锁这三种方式都可以保证可见性的语义。
JMM与有序性:Java内存模型中,允许编译器和处理器对指令进行重排序,上面的三种方式也都可以保证有序性。另外Java内存模型具备一些天生的有序性规则(Happens-before原则)。
happens-before原则:
- 程序次数规则:在一个线程内,代码按照编写时的次序执行。
- 锁定规则:一个unlock操作要先行发生于对同一个锁的lock操作。
- volatile变量规则:对一个变量的写操作要早于对这个变量的读操作。
- 传递规则:如果操作A先于操作B且操作B先于操作C,则操作A肯定先于操作C,说明happends-before原则具备传递性。
- 线程启动规则:Thread对象的start方法先行发生于对线程的任何动作。
- 线程中断规则:对线程执行interrupt方法肯定优先于捕获到中断信息。
- 线程的终结规则:线程中所有的操作都要先行发生于线程的终止检测。
- 对象的终结规则:一个对象初始化的完成先行发生于finalize方法之前。
volatile的原理和实现机制
volatile可以确保可见性和顺序性,到底如何实现的呢?我们可以通过OpenJDK下的unsafe.cpp源码看到被volatile修饰的变量存在于一个“lock;”的前缀,该前缀实质上相当于一个内存屏障,该内存屏障会为指令的执行提供下面几个保障。
- 确保指令重排序时不会将其后面的代码排到内存屏障之前。
- 确保指令重排序时不会将其前面的代码排到内存屏障之后。
- 确保在执行到内存屏障修饰的指令时前面的代码必须全部执行完成。
- 强制将线程工作内存中值的修改刷新至主内存中。
- 如果是写操作,则导致其他线程工作内存(CPU Cache)中的缓存数据失效。
volatile使用场景
- 开关控制(利用可见性特点)。
- 状态标记(利用顺序性特点)。
- Singleton设计模式的double-check(利用顺序性特点)。
volatile和synchronized区别
- volatile关键字只能用于修饰实例变量或者类变量,不能用于修饰方法以及方法参数和局部变量、常量等。
- synchronized关键字不能用于对变量的修饰,只能用于修饰方法或者语句块。
- volatile修饰的变量可以为null,synchronized关键字同步语句块的monitor对象不能为null。
- synchronized关键字能保证原子性,而volatile无法保证。(原子性)
- synchronized关键字借助于JVM指令的monitor enter和moniter exit使得同步代码串行化,在monitor exit时所有共享资源都会被刷新到主内存中。(可见性)
- volatile使用机器指令(偏硬件)“.lock;”的方式迫使其他线程工作内存中的数据失效,不得不到主内存中进行再次加载。(可见性)
- volatile关键字禁止JVM编译器以及处理器对其进行重排序。(有序性)
- synchronized关键字是以程序的串行化执行来保证有序性。(有序性)
- volatile不会使线程陷入阻塞,synchronized关键字会使线程进入阻塞。
7种单例设计模式
前面有一篇文章也提到了设计模式,这里主要讲的是单例模式。
- 饿汉式:实例instance初始化收集进<clinit>方法中,在多线程情况下不可能被实例化两次。
- 懒汉式:使用实例instance的时候再去创建,但是在多线程下无法保证单例的唯一性。
- 懒汉式+同步方法:在懒汉式基础上给getInstance方法加上synchronized关键字,保证数据同步性。
- Double-Check:定义未被初始化的实例,然后在实例首次初始化时加锁,但是无法保证实例化的有序性。
- Volatile+Double-Check:对未被初始化的实例加上volatile关键字保证有序性。
- Holder方式:实例放到静态内部类Holder之中,实例的创建过程在Java程序编译时期收集至<clinit>方法,目前是使用最广的设计模式之一。
- 枚举方法:枚举类型不允许继承,同样是线程安全且只能被实例化一次。
Holder单例模式代码:
public class HolderSingleton {
public HolderSingleton() {
}
static class Holder {
private static final HolderSingleton instance = new HolderSingleton();
}
public static final HolderSingleton getInstance() {
return Holder.instance;
}
}
枚举单例模式代码:
public enum EnumSingleton {
INSTANCE;
EnumSingleton() {
System.out.println("instance will be initialized immediately");
}
public static EnumSingleton getInstance() {
return INSTANCE;
}
}