我曾经写过一篇文章叫《上帝是如何把宙斯挤下神坛的》,那么上帝在成为唯一的神以后是怎么处理来自凡人的祈祷和愿望呢?忙疯了的上帝又是如何做到面对这么多凡人的时候不出错的?
需求故事
- 1.作为一个基督徒ChristianA,可以向god1祈祷“ChristianA want rich”,这样god1的祈祷清单里面就会有“ChristianA want rich”
- 2.作为一个基督徒ChristianB,可以向god2祈祷“ChristianB want strong”,这样god2的祈祷清单里就会有“ChristianA want rich”,“ChristianB want strong”,而且god1和god2的实例相同
- 3.作为一个1000个Christian,可以同时向上帝祈祷,要求这1000个祈祷的上帝实例相同。
Story1
作为一个基督徒ChristianA,可以向god1祈祷“ChristianA want rich”,这样god1的祈祷清单里面就会有“ChristianA want rich”
Story1 Test Case
@Test
public void testTheCrazyGod(){
//story 1
GOD god1 = GOD.getInstance();
god1.recievePray("ChristianA","ChristianA want rich");
assertEquals("ChristianA want rich", god1.getPray("ChristianA"));
}
Story1 Implementation
public class GOD {
private Map<String, String> prayMap = new HashMap();
private GOD() {
}
public static GOD getInstance() {
return new GOD();
}
public void recievePray(String prayer, String prayMessage) {
prayMap.put(prayer, prayMessage);
}
public String getPray(String prayer) {
return prayMap.get(prayer);
}
}
Story2
作为一个基督徒ChristianB,可以向god2祈祷“ChristianB want strong”,这样god2的祈祷清单里就会有“ChristianA want rich”,“ChristianB want strong”,而且god1和god2的实例相同
Story2 Test Case
在设计Story2的test case时,重要的是如何测试两个god的实例相同,通过Object.toString(), 可以获得实例的ID,所以验证方法就是判断两个对象的实例ID是否相同
@Test
public void testTheCrazyGod(){
//story 1
GOD god1 = GOD.getInstance();
String god1Instanceid = god1.toString();
god1.recievePray("ChristianA","ChristianA want rich");
assertEquals("ChristianA want rich", god1.getPray("ChristianA"));
//story 2
GOD god2 = GOD.getInstance();
String god2Instanceid = god2.toString();
god2.recievePray("ChristianB","ChristianB want Strong");
assertEquals("ChristianA want rich", god1.getPray("ChristianA"));
assertEquals("ChristianB want Strong", god2.getPray("ChristianB"));
assertEquals(god1Instanceid,god2Instanceid);
}
Story2 Implementation
public class GOD {
private static GOD singleGod;
private Map<String, String> prayMap = new HashMap();
private GOD() {
}
public static GOD getInstance() {
if (singleGod == null) {
singleGod = new GOD();
}
return singleGod;
}
public void recievePray(String prayer, String prayMessage) {
prayMap.put(prayer, prayMessage);
}
public String getPray(String prayer) {
return prayMap.get(prayer);
}
}
这就是一个最简单的Singleton模式的实现了,但是这个实现有个最大的问题,那就是不是线程安全的,如果两个线程同时进入了getInstance方法,那么就可能会创建多个上帝的实例
Story3
作为一个1000个Christian,可以同时向上帝祈祷,要求这1000个祈祷的上帝实例相同。
Story3 Test Case
public void testSafetyGodStory3() {
Set<String> stringSet = new HashSet<String>();
//在测试中创建一个内部类用来跑多线程
class Christian implements Runnable {
@Override
public void run() {
GOD god = GOD.getInstance();
stringSet.add(god.toString());//记录每个线程获得的实例ID
}
}
//创建一个当使用线程才创建的线程池
ExecutorService executorService = Executors.newCachedThreadPool();
for (int c = 0; c < 1000; c++) {
//在线程池里创建1000个线程Christian并执行
executorService.execute(new Christian());
}
//如果之前提交的线程任务完成就关闭线程池
executorService.shutdown();
Iterator<String> iterator = stringSet.iterator();
assertTrue(iterator.hasNext());
String firstInstanceId = iterator.next();
while (iterator.hasNext()) {
String instanceId = iterator.next();
assertEquals(firstInstanceId, instanceId);
}
}
Story3 Implementation
Singleton的线程安全问题解决方法一共有5种
既然这个story讲到并发和线程安全问题,那么就在这部分干脆深入讲讲JVM在涉及线程调度时候的内存模型,
用一个singleton模式来彻底理解java的多线程并发和线程安全
JVM内存模型和线程
先说说现代计算机线程机制设计的初衷吧,本质原因其实是运算功能和读写功能速度差异导致的,所以聪明的人类使用运筹学的原理来压榨计算机的运算功能,就是让CPU在等待读写操作的时候不要闲着。
人类里面顶尖聪明的人想出的线程机制其实不仅仅是提高了计算机的功效,我们普通人更应该从这些顶尖聪明的人的想法里进行学习:在日常工作生活中,当我们遭遇了因为无法抗拒的因素导致的等待时,比如等待飞机,我们是不是可以新开启一个线程在等待的同时进行其他的运算呢?从我个人的经验来说,这种启动新线程的思维方法是一个技能,而且这个技能是用的越多就越熟练效率越高的技能。
Java内存模型和线程关系图
每个新创建的线程都会分布一个独立的工作内存,而这些工作内存是和主内存打交道的,所以在多线程并发的情况下最重要和最核心的问题就是缓存一致性问题,也就是在Story3里面定义的线程安全问题。所以我们先要了解一下Java Memory Model的8个基本操作是什么:
- lock:只能用在主内存变量上,他把一个主内存变量标记成一条线程独占的状态
- unlock:只能用在主内存变量上,一个线程释放主内存变量后其他线程才能够锁定
- read:把一个主内存变量从主内存读到线程的工作内存,以便load使用
- load:把read操作的变量值放入工作内存的变量副本中
- use:线程从工作内存的变量副本获得变量值进行运算
- assign:线程把预算结果赋值给工作内存的变量
- store:把工作内存的变量值传输给主内存,以便write使用
- write:把从store操作中的变量值写入主内存
从上面的8个操作我们可以看到read和load,store和write必须是一起出现的,JMM不允许这4个指令单独出现。但是(这个但是很重要),虽然要求两个操作是一起的,却可以在两个操作之间插入其他操作,举例来说就是可以是这样的执行顺序:read A,read B,load A,load B
有关java内存模型和线程的基础操作介绍完了,下面我们用5个不同的Singleton线程安全实现来具体分析一下吧:
线程安全方法1:Eager方法
public class GOD {
private static final GOD singleGod = new GOD();
private Map<String, String> prayMap = new HashMap();
private GOD() {
try {
Thread.sleep(5);//模拟创建对象时间较长,可以启动另外一个线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static GOD getInstance() {
return singleGod;
}
}
这个实现方式其实和上面提到的java内存模型就没什么关系了,因为被定义成static final的singleGod会在jvm启动调用ClassLoader来加载GOD类的时候就会完成实例的准备,所以称之为Eager模式,这样做的缺点是开销比较大,在没有调用到getInstance的时候JVM就已经创建好了singleGod的实例了。
这里刚好涉及到了类加载的问题,那么让我们再深入一点,看看一个类的生命周期究竟是什么样的,这样也可以帮助我们更好的理解final,static这些修饰符是怎么工作的。
Java Class生命周期图
这里先要明确一个概念,我们在平常说到类加载其实是包含了上图中的“加载”,“链接”,“初始化”3个步骤的。而上图是官方定义一个类各个阶段的命名。
- loading,在loading阶段JVM需要完成3件事:
+ 根据类名读类的二进制字节流,
+ 把字节流转化成存储结构,
+ 在内存中生成一个代表这个类的java.lang.Class的对象。
在我们的Test Case中,当第一次调用到GOD.getInstance()时,JVM就会启动loading动作去读GOD.class的二进制流
- linking-Verification,在verification阶段JVM需要进行4个验证:
+ 文件格式验证,确定字节流是符合Class规范的
+ 元数据验证,确定描述信息是符合java规范的
+ 字节码验证,确定程序语意是合法的
+ 符号引用验证,确保Resolution能够正常执行
在我们的Test Case中,JVM读完了GOD.class的二进制字节流就会启动Verification动作
- linking-Preparation,为类变量分配内存并设置类变量初始值的过程,所谓的类变量就是被Static,通常情况下初始值都是变量的默认零值,但是如果类变量被final修饰过,那么就会执行赋值命令
在我们的Test Case中,singleGod就是一个类变量,并在preparation阶段初始化成new GOD()
private static final GOD singleGod = new GOD();
而没有final修饰的类变量的区别就是赋值命令不会在Preparation阶段执行,而是在Initialization阶段执行,这时候singleGod还是null
- linking-Resolution,JVM把常量池内的符号引用替换为直接引用的过程
+ 符号引用(SymbolicReference),用符号来描述引用目标,引用的目标不一定加载到JVM中
+ 直接引用(DirectReference),引用的目标对象已经加载到了JVM中
在我们的Test Case中, Resolution阶段就是发现GOD需要Map,然后会把Map的全名给GOD的ClassLoader去加载Map,然后把Map从符号引用变为直接引用
private Map<String, String> prayMap = new HashMap();
- Initialization,这个阶段主要就是执行类构造器<clinit>()的过程,这里有几个有意思的特点需要注意:
+ **\<clinit\>()是类构造器,不同于实例构造器,这点尤为重要**
+ 由于\<clinit\>()就是把所有static修饰的变量和语句块进行顺序执行,所以语句块的顺序很重要(就好比是JavaScript这种解释性语言的执行方式)
+ 如果有父类,那么父类的\<clinit\>()会先执行父类的\<clinit\>(),所以父类的静态变量赋值优先于子类的静态变量赋值,**这也是静态变量不能被子类改写的根本原因**
+ 如果没有static修饰的变量和代码,那么编译器可以不生成\<clinit\>()方法
- Using,当初始化完成之后,java虚拟机就可以执行Class的业务逻辑指令,通过堆中java.lang.Class对象的入口地址,调用方法区的方法逻辑,最后将方法的运算结果通过方法返回地址存放到方法区或堆中。
- Unloading,在类使用完之后,如果满足下面3个条件全部满足的情况,类就会被卸载:
+ 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
+ 加载该类的ClassLoader已经被回收
+ 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
线程安全方法2:Lazy方法
public class GOD {
private static GOD singleGod;
private GOD() {
try {
Thread.sleep(5);//模拟创建对象时间较长,可以启动另外一个线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static synchronized GOD getInstance() {
if (singleGod == null) {
singleGod = new GOD();
}
return singleGod;
}
}
所谓Lazy就是等到真正调用到getInstance的时候再去创建实例,这是和Eager方法相对的。
采用 synchronized 修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。每个对象都有一个 monitor (锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。
这里既然谈到了那么我们就研究的再深入一点,看看JVM是怎么实现synchronized的吧。
Synchronized的实现
1.先说说synchronized的历史
synchronized在JDK5之前一直被称为重量级锁,是一个较为鸡肋的设计,而在JDK6对synchronized内在机制进行了大量显著的优化,加入了CAS,轻量级锁和偏向锁的功能,性能上提升很多,所以如果仅仅是为了实现互斥,那么可以优先考虑synchronized。
2.CAS Compare and Swap,
这事一个用于在硬件层面上提供原子性操作。在 Intel 处理器中,CAS通过汇编指令cmpxchg实现。比较是否和给定的数值一致,如果一致则修改,不一致则不修改。那么这个硬件特性就很适合用在锁上面了。用一个更具体的例子来说,通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。
3.java中synchronized可以用在2个地方:
- 用在方法上,锁的是当前实例对象,实现方法是编译时,方法的常量池中多了ACC_SYNCHRONIZED标示符,当线程调用方法时,会检查ACC_SYNCHRONIZED是否设置,如果设置了,那么线程会获取对应monitor,获取成功了才能执行方法体。
- 如果方法是普通方法,那么monitor是对象实例上的锁,
如果方法是静态方法,那么monitor是类上的锁
- 用在代码块上,锁的是括号里的对象,实现方式是在代码编译的时候给代码块前后增加monitorenter和monitorexit
- 线程执行monitorenter的详细过程:[1]如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者.[2]如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.[3]如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
- 执行monitorexit的线程必须是对应monitor的所有者,指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
4.这个坑是越挖越深了,那我们来看看monitor吧。
当多线程同时访问一段同步代码时,新请求的线程会首先加入到Entry Set集合中,通过竞争(compete)方式,同一时间只有一个线程可以竞争成功并获取监视器,进入The Owner。获取监视器的线程调用wait()后就会释放监视器,并进入Wait Set集合中等待满足条件时被唤醒。下面这个图可以帮助更容易的理解这个过程
5.Java SE1.6里锁的四种状态
Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以锁有四种状态:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:
- 偏向锁,Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程,所以CAS指令大大提高了锁的效率。偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
- 轻量级锁
- 轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争
锁的优缺点对比:
锁 优点 缺点 场景 偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。 轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。 重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行速度较长。
有关java中synchronized的实现原理就先讲到这里了,我们知道在java中另外一个用来实现线程安全的关键字就是volatile了,下面这个线程安全的实现就是使用了volatile来达到的
线程安全方法3:DCL(double check lock)
public class GOD {
private volatile static GOD singleGod ;
private GOD() {
try {
Thread.sleep(5);//模拟创建对象时间较长,可以启动另外一个线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static GOD getInstance() {
if(singleGod==null){
synchronized (GOD.class){
if(singleGod==null){
singleGod=new GOD();
}
}
}
return singleGod;
}
}
这个实现里面先用volatile来声明了singleGod,这样就是对其他线程可见的了。然后在getInstance方法里面使用DCL(Double Check Lock)机制来进行双重锁检查:
- 一次是在同步块外,同步块外的检查是为了节省时间,如果实例已经存在就不需要进入同步块了。
- 一次是在同步块内,为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的检查,如果在同步块内不进行二次检验的话就会生成多个实例了。
以上就是一个使用DCL来实现线程安全singleton的标准方法了,使用了volatile,还使用了synchronized,还有DCL。我们已经知道了synchronized的实现原理是在代码块编译的时候前后加锁判断,那么这时候问题来了,既然已经又了代码块的锁,为什么还要使用volatile呢,所以下面我们就深入看看volatile的实现和一个更有意思的东西:重排序
Volatile的实现和重排序
1.Volatile基础作用
Java 语言规范中指出:为了获得最佳速度,允许线程保存共享成员变量的私有拷贝,而且只当线程进入或者离开同步代码块时才将私有拷贝与共享内存中的原始值进行比较。
而Volatile 修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。
这样当多个线程同时与某个对象交互时,就必须注意到要让线程及时的得到共享成员变量的变化。而 volatile 关键字就是提示 JVM:对于这个成员变量,不能保存它的私有拷贝,而应直接与共享成员变量交互。volatile 是一种稍弱的同步机制,在访问 volatile 变量时不会执行加锁操作,也就不会执行线程阻塞,因此 volatilei 变量是一种比 synchronized 关键字更轻量级的同步机制。2.Volatile实现原理
那么Volatile是如何来保证可见性的呢?在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。
java代码:instance = new Singleton();//instance是volatile变量 汇编代码: 0x01a3de1d: movb $0x0,0x1104800(%esi); 0x01a3de24: lock addl $0x0,(%esp);
有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多线程下会引发了两件事情,让我们再回顾一下java内存模型来看看这两件事情的含义是什么
- Lock指令会将当前线程工作内存的数据会写回(Store-Write)到主内存,lock指令执行期间会锁定缓存,阻止其他线程同时修改被锁定的区域数据
- 这个写回主内存的操作会引起在其他线程工作内存缓存了该内存地址的数据无效,CPU有嗅探技术,其他线程通过嗅探发现缓存区域的主内存被修改了,那么就会无效缓存区域,并在下次读取的时候强制从主内存读取(load read)
3.有了Volatile为什么还要Synchronized?
这时候有个问题来了:那么在我们的线程安全方法3的实现里面,对singleGod加volatile应该就可以实现在写singleGod的时候让其他的线程缓存无效了,为什么还要加上synchronized呢?(在网上看了很多文章都是讲为什么synchronized了以后还要加volatile的,没有一个提出上面这个问题,所以这个问题绝对是本文原创)
这个问题的答案是:volatile声明的singleGod仅仅保证了在执行singleGod = new GOD();
时是使用了lock指令锁定了singleGod,但是不会保证只有一个线程进入if (singleGod == null)
, 所以会有多个线程先后使用lock指令来给singleGod赋值。这里也能看出Volatile和Synchronized的重要区别是Volatile是针对变量的,Synchronized是真对方法和代码块的。
4.有了Synchronized为什么还要Volatile?
看到网上有人对这个问题的解释是Volatile可以让变量对所有线程可见,其实想想Synchronized的原理都发现是不对的,这个块都上锁了,那么这块代码自然是对线程可见的啊。所以加Volatile是有别的原因的,这个原因就是防止JVM对指令的重排序,那么什么是指令重排序呢?
5.什么是指令重排序?
让我们再看看这行代码
singleGod=new GOD();
这行代码其实做了3件事情:
- 给singleGod分配内存
- 调用GOD的构造函数来初始化成员变量
- 把singleGod指向分配的内存空间(执行完这步singleGod就是非null了)
JVM在编译的时候会对上面三个指令进行重排序优化,而优化后的顺序是不能保证的。因为在单线程条件下1-3-2这种顺序也是没有任何问题的。
但是我们想象一下多线程下的情况:
- 线程A执行顺序是1-3-2,这时候执行完了1-3,singleGod已经是非null了,这时候还没有执行2就被线程B抢占了
- 线程B抢进来一看,singleGod已经不是null,那么就不需要执行
singleGod=new GOD();
了,但这样B得到的是一个没有调用构造函数初始化的对象,仅仅有分配好的空内存空间而加入了volatile就会保证如果线程A变量的写操作没有完成,线程B的工作内存缓存是被设置成无效的,线程B如果要读变量,必须从主内存读取,也就是不论执行顺序是1-2-3,还是1-3-2,线程B都没法插队。所以volatile定义的变量是遵循了Happen-Before规则的。那我们再多走一步,聊聊什么是happen-before吧
6.什么是Happen-Before呢
Java语言中有一个“先行发生”(Happen-Before)的规则,它是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其意思就是说,在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个原则特别重要,它是判断数据是否存在竞争、线程是否安全的主要依据。
举例来说,存在3个线程:线程A中执行如下操作:i=1 线程B中执行如下操作:j=i 线程C中执行如下操作:i=2
假设线程A中的操作”i=1“ Happen-Before线程B中的操作“j=i”,那么就可以保证在线程B的操作执行后,变量j的值一定为1,即线程B观察到了线程A中操作“i=1”所产生的影响;现在,我们依然保持线程A和线程B之间的Happen-Before关系,同时线程C出现在了线程A和线程B的操作之间,但是C与B并没有Happen-Before关系,那么j的值就不确定了,线程C对变量i的影响可能会被线程B观察到,也可能不会,这时线程B就存在读取到不是最新数据的风险,不具备线程安全性。
Java内存模型中的有八条可保证Happen-Before的规则,他们无需任何同步器协助就已经存在,如果编译器判断指令不存在Happen-Before规则,那么就会随机的进行重排序,volatile就是其中一条对一个volatile变量的写操作happen—before后面对该变量的读操作
,所以在我们DCL实现里面,没有用volatile修饰的singleGod的3个指令就是被重排序了。至于其余的7条,如果有兴趣可以自己找来看看,这里就不再增加阅读负担了。
在方法3的实现里面我们深入讲了一下volatile的作用和实现原理,但是用DCL这种方式做线程安全的Lazy模式也还是有些复杂了,有没有更简单的方式呢?
线程安全方法4:static nested class
public class GOD {
private static class GODHolder{
private static final GOD singleGod = new GOD();
}
private GOD() {
try {
Thread.sleep(5);//模拟创建对象时间较长,可以启动另外一个线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static synchronized GOD getInstance() {
return GODHolder.singleGod;
}
}
这个实现是不是很简单,也是《Effective Java》上面推荐的,看起来和Eager方法一样是利用了final static的方式在类加载阶段就完成了实例的创建,但是它却是Lazy模式的。
因为只有当调用到GOD.getInstance时才会去调用GODHolder,这时候才会启动ClassLoader去加载GODHolder,那么在这个加载的过程中就会创建好singleGod,所以这个创建和写入内存的过程是根本不担心多线程的。那么为什么JVM可以做到这样呢?原因是java在编译GOD时,会把GOD编译成GOD.class
和GOD$GODHolder.class
两个二进制文件,那么在Christian调用到GOD.getInstance实际的执行顺序如下:
- 找到
GOD.class
进行GOD的加载,连接和初始化- 进入GOD.getInstance方法
- 因为需要GODHolder,所以找到
GOD$GODHolder.class
进行加载,连接和初始化。在连接时会创建一个GOD,赋值给singleGod- GODHolder返回已经初始化的singleGod
线程安全方法5:enum
public enum GOD {
INSTANCE;
}
这个实现是不是更简单,而且也是可以通过多线程测试的,因为enum默认就是线程安全的。那enum又是怎么实现的呢?
Java enum的实现原理
其实看enum的实现方法很简单,就是使用javap来看一下class文件的字节码就好了,从下面的字节码文件里我们可以看到其实enum是通过继承java.lang.Enum来实现的一个final类,而且定义的INSTANCE是final static的,而且会在static{}区块对INSTANCE进行初始化。因此enum实现的Singleton模式是Eager的,会在JVM加载enum的时候就初始化好,那么自然是线程安全的了。所以enum并没有在JVM底层数据结构上有任何改变,而是通过对关键字的封装可以让程序员更方便的定义一些final类来使用,并自动添加了values和valueOf方法
javap -c dp.singleton.GOD4Enum
Compiled from "GOD4Enum.java"
public final class dp.singleton.GOD4Enum extends java.lang.Enum<dp.singleton.GOD4Enum> {
public static final dp.singleton.GOD4Enum INSTANCE;
public static dp.singleton.GOD4Enum[] values();
Code:
0: getstatic #1 // Field $VALUES:[Ldp/singleton/GOD4Enum;
3: invokevirtual #2 // Method "[Ldp/singleton/GOD4Enum;".clone:()Ljava/lang/Object;
6: checkcast #3 // class "[Ldp/singleton/GOD4Enum;"
9: areturn
public static dp.singleton.GOD4Enum valueOf(java.lang.String);
Code:
0: ldc #4 // class dp/singleton/GOD4Enum
2: aload_0
3: invokestatic #5 // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
6: checkcast #4 // class dp/singleton/GOD4Enum
9: areturn
static {};
Code:
0: new #4 // class dp/singleton/GOD4Enum
3: dup
4: ldc #7 // String INSTANCE
6: iconst_0
7: invokespecial #8 // Method "<init>":(Ljava/lang/String;I)V
10: putstatic #9 // Field INSTANCE:Ldp/singleton/GOD4Enum;
13: iconst_1
14: anewarray #4 // class dp/singleton/GOD4Enum
17: dup
18: iconst_0
19: getstatic #9 // Field INSTANCE:Ldp/singleton/GOD4Enum;
22: aastore
23: putstatic #1 // Field $VALUES:[Ldp/singleton/GOD4Enum;
26: return
}
和坚思辨
本来singleton模式是一个非常简单的模式,但是在考虑了多线程安全以后我们确能够提供5种不同的实现方式:
- Eager方法:利用static final关键字
- Lazy方法:利用synchronized关键字
- DCL方法:利用volatile关键字
- Nested Class方法:利用static nested class来实现
- enum方法:利用enum方法
以上5种方法不存在绝对的优劣之分,需要根据场景来进行合适的选择。
但是在介绍5个方法的时候我觉得更有价值的是也顺带了解了一下每种实现方法背后的本质是什么,而且通过这种打破砂锅问道底的方法能够让我们感受到Java这些机制创造者们的智慧和严谨:
- Java内存模型是什么样子的,多线程是如何在这个模型里面工作的
- 通过深入了解static final关键字知道了Java的类加载过程是什么,从一个类被加载,使用,到最后卸载每一步都干了什么
- 通过深入了解synchronized关键字知道了synchronized是怎么工作的,还有java1.6以后的3种锁是怎么工作的
- 通过深入了解volatile关键知道了volatile是怎么工作的,什么是指令重排序,什么是Happen-Before原则
- 通过查看Nested Class文件明白了为什么这个方法可以做到lazy的线程安全
- 通过查看enum的class字节码,我们知道了enum的底层实现是什么,又为什么可以做到线程安全