单例模式可能是大家经常接触和使用的一个设计模式,你可能会这么写
public class UnsafeLazyInitiallization { private static UnsafeLazyInitiallization instance; private UnsafeLazyInitiallization() { } public static UnsafeLazyInitiallization getInstance(){ if(instance==null){ //1:A线程执行 instance=new UnsafeLazyInitiallization(); //2:B线程执行 } return instance; } }
上面代码大家应该都知道,所谓的线程不安全的懒汉单例写法。在UnsafeLazyInitiallization类中,假设A线程执行代码1的同时,B线程执行代码2,此时,线程A可能看到instance引用的对象还没有初始化。
你可能会说,线程不安全,我可以对getInstance()方法做同步处理保证安全啊,比如下面这样的写法
public class SafeLazyInitiallization { private static SafeLazyInitiallization instance; private SafeLazyInitiallization() { } public synchronized static SafeLazyInitiallization getInstance(){ if(instance==null){ instance=new SafeLazyInitiallization(); } return instance; } }
这样的写法是保证了线程安全,但是由于getInstance()方法做了同步处理,synchronized将导致性能开销。如getInstance()方法被多个线程频繁调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个方案将能够提供令人满意的性能。
那么,有没有更优雅的方案呢?前人的智慧是伟大的,在早期的JVM中,synchronized存在巨大的性能开销,因此,人们想出了一个“聪明”的技巧——双重检查锁定。人们通过双重检查锁定来降低同步的开销。下面来让我们看看
public class DoubleCheckedLocking { //1 private static DoubleCheckedLocking instance; //2 private DoubleCheckedLocking() { } public static DoubleCheckedLocking getInstance() { //3 if (instance == null) { //4:第一次检查 synchronized (DoubleCheckedLocking.class) { //5:加锁 if (instance == null) //6:第二次检查 instance = new DoubleCheckedLocking(); //7:问题的根源出在这里 } //8 } //9 return instance; //10 } //11 }
如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。双重检查锁定看起来似乎很完美,但这是一个错误的优化!为什么呢?在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。在第7行创建了一个对象,这行代码可以分解为如下的3行伪代码
memory=allocate(); //1:分配对象的内存空间 ctorInstance(memory); //2:初始化对象 instance=memory; //3:设置instance指向刚分配的内存地址
上面3行代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的,如果不了解重排序,后文JMM会详细解释)。2和3之间重排序之后的执行时序如下
memory=allocate(); //1:分配对象的内存空间 instance=memory; //3:设置instance指向刚分配的内存地址,注意此时对象还没有被初始化 ctorInstance(memory); //2:初始化对象
回到示例代码第7行,如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化。在知晓问题发生的根源之后,我们可以想出两个办法解决
下面就介绍这两个解决方案的具体实现
基于volatile的解决方案
对于前面的基于双重检查锁定的方案,只需要做一点小的修改,就可以实现线程安全的延迟初始化。请看下面的示例代码
public class SafeDoubleCheckedLocking { private volatile static SafeDoubleCheckedLocking instance; private SafeDoubleCheckedLocking() { } public static SafeDoubleCheckedLocking getInstance() { if (instance == null) { synchronized (SafeDoubleCheckedLocking.class) { if (instance == null) instance = new SafeDoubleCheckedLocking();//instance为volatile,现在没问题了 } } return instance; } }
当声明对象的引用为volatile后,前面伪代码谈到的2和3之间的重排序,在多线程环境中将会被禁止。
基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取多个线程对同一个类的初始化。基于这个特性,实现的示例代码如下
public class InstanceFactory { private InstanceFactory() { } private static class InstanceHolder { public static InstanceFactory instance = new InstanceFactory(); } public static InstanceFactory getInstance() { return InstanceHolder.instance; //这里将导致InstanceHolder类被初始化 } }
这个方案的本质是允许前面伪代码谈到的2和3重排序,但不允许其他线程“看到”这个重排序。在InstanceFactory示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化。由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能会在同一时刻调用getInstance()方法来初始化IInstanceHolder类)。Java语言规定,对于每一个类和接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。
也许你还存在疑问,前面谈的重排序是什么鬼?为什么volatile在某方面就能禁止重排序?现在引出本文的另一个话题JMM(Java Memory Model——Java内存模型)。什么是JMM呢?JMM是一个抽象概念,它并不存在。Java虚拟机规范中试图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台的内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。
Java线程之间的通信由JMM来控制,JMM决定一个线程共享变量的写入何时对另一个线程可见。JMM保证如果程序是正确同步的,那么程序的执行将具有顺序一致性。从抽象的角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量(实例域、静态域和数据元素)存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本(局部变量、方法定义参数和异常处理参数是不会在线程之间共享,它们存储在线程的本地内存中)。从物理角度上看,主内存仅仅是虚拟机内存的一部分,与物理硬件的主内存名字一样,两者可以互相类比;而本地内存,可与处理器高速缓存类比。Java内存模型的抽象示意图如图所示
这里先介绍几个基础概念:8种操作指令、内存屏障、顺序一致性模型、as-if-serial、happens-before 、数据依赖性、 重排序。
8种操作指令
关于主内存与本地内存之间具体的交互协议,即一个变量如何从主内存拷贝到本地内存、如何从本地内存同步回主内存之类的实现细节,JMM中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每种操作都是原子的、不可再分的(对于double和long类型的遍历来说,load、store、read和write操作在某些平台上允许有例外):
如果要把一个变量从主内存模型复制到本地内存,那就要顺序的执行read和load操作,如果要把变量从本地内存同步回主内存,就要顺序的执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a read b、load b、load a。
内存屏障
内存屏障是一组处理器指令(前面的8个操作指令),用于实现对内存操作的顺序限制。包括LoadLoad, LoadStore, StoreLoad, StoreStore共4种内存屏障。内存屏障存在的意义是什么呢?它是在Java编译器生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行,内存屏障是与相应的内存重排序相对应的。JMM把内存屏障指令分为4类
StoreLoad Barriers是一个“全能型 ”的屏障,它同时具有其他3个屏障的效果。现在的多数处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖性分3种类型:写后读、写后写、读后写。这3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器可能对操作进行重排序。而它们进行重排序时,会遵守数据依赖性,不会改变数据依赖关系的两个操作的执行顺序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
顺序一致性内存模型
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。它有两个特性:
从顺序一致性模型中,我们可以知道程序所有操作完全按照程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区外,那样就破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。像前面单例示例的类初始化解决方案就是采用了这个思想。
as-if-serial
as-if-serial的意思是不管怎么重排序,(单线程)程序的执行结果不能改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
happens-before
happens-before是JMM最核心的概念。从JDK5开始,Java使用新的JSR-133内存模型,JSR-133 使用happens-before的概念阐述操作之间的内存可见性,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
happens-before规则如下:
happens-before与JMM的关系如下图所示
as-if-serial语义和happens-before本质上一样,参考顺序一致性内存模型的理论,在不改变程序执行结果的前提下,给编译器和处理器以最大的自由度,提高并行度。
重排序
终于谈到我们反复提及的重排序了,重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。重排序分3种类型。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
从JMM设计者的角度来说,在设计JMM时,需要考虑两个关键因素:
JMM设计就需要在这两者之间作出协调。JMM对程序采取了不同的策略:
介绍完了这几个基本概念,我们不难推断出JMM是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的:
谈完了JMM,那么Java相关类库是如何实现的呢?这里就谈谈J.U.C( java.util.concurrent),先来张J.U.C的思维导图
不难看出,J.U.C由atomic、locks、tools、collections、executor这五部分组成。它们的实现基于volatile的读写和CAS所具有的volatile读和写。AQS(AbstractQueuedSynchronizer,队列同步器)、非阻塞数据结构和原子变量类,这些J.U.C中的基础类都是使用了这种模式实现的,而J.U.C中的高层类又依赖于这些基础类来实现的。从整体上看,J.U.C的实现示意图如下
也许你对volatile和CAS的底层实现原理不是很了解,这里先这里先简单介绍下它们的底层实现
volatile
Java语言规范第三版对volatile的定义为:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致性的更新,线程应该确保通过排他锁单独获得这个变量。如果一个字段被声明为volatile,Java内存模型确保这个所有线程看到这个值的变量是一致的。而volatile是如何来保证可见性的呢?如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存(Lock指令会在声言该信号期间锁总线/缓存,这样就独占了系统内存)。但是,就算是写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线(注意处理器不直接跟系统内存交互,而是通过总线)上传播的数据来检查自己缓存的值是不是过期了,当处理器发现直接缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
CAS
CAS其实应用挺广泛的,我们常常听到的悲观锁乐观锁的概念,乐观锁(无锁)指的就是CAS。这里只是简单说下在并发的应用,所谓的乐观并发策略,通俗的说,就是先进性操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就采取其他的补偿措施(最常见的补偿措施就是不断重试,治到成功为止,这里其实也就是自旋CAS的概念),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种操作也被称为非阻塞同步。而CAS这种乐观并发策略操作和冲突检测这两个步骤具备的原子性,是靠什么保证的呢?硬件,硬件保证了一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。
也许你会存在疑问,为什么这种无锁的方案一般会比直接加锁效率更高呢?这里其实涉及到线程的实现和线程的状态转换。实现线程主要有三种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。而Java的线程实现则依赖于平台使用的线程模型。至于状态转换,Java定义了6种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这6种状态分别是:新建、运行、无限期等待、限期等待、阻塞、结束。 Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于简单的同步块(被synchronized修饰的方法),状态转换消耗的时间可能比用户代码执行的时间还要长。所以出现了这种优化方案,在操作系统阻塞线程之间引入一段自旋过程或一直自旋直到成功为止。避免频繁的切入到核心态之中。
但是这种方案其实也并不完美,在这里就说下CAS实现原子操作的三大问题
谈完了这两个概念,下面我们就来逐个分析这五部分的具体源码实现
atomic
atomic包的原子操作类提供了一种简单、性能高效、线程安全操作一个变量的方式。atomic包里一共13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用、原子更新属性。atomic包里的类基本使用Unsafe实现的包装类。
下面通过一个简单的CAS方式实现计数器(一个线程安全的计数器方法safeCount和一个非线程安全的计数器方法count)的示例来说下
public class CASTest { public static void main(String[] args){ final Counter cas=new Counter(); List ts=new ArrayList(600); long start=System.currentTimeMillis(); for(int j=0;j<100;j++){ Thread t=new Thread(new Runnable() { @Override public void run() { for(int i=0;i<10000;i++){ cas.count(); cas.safeCount(); } } }); ts.add(t); } for(Thread t:ts){ t.start(); } for(Thread t:ts){ try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(cas.i); System.out.println(cas.atomicI.get()); System.out.println(System.currentTimeMillis()-start); } } public class Counter { public AtomicInteger atomicI=new AtomicInteger(0); public int i=0; /** * 使用CAS实现线程安全计数器 */ public void safeCount(){ for(;;){ int i=atomicI.get(); boolean suc=atomicI.compareAndSet(i,++i); if(suc){ break; } } } /** * 非线程安全计数器 */ public void count(){ i++; } }
safeCount()方法的代码块其实是getandIncrement()方法的实现,源码for循环体第一步优先取得atomicI里存储的数值,第二步对atomicI的当前数值进行加1操作,关键的第三步调用compareAndSet()方法来进行原子更新操作,该方法先检查当前数值是否等于current,等于意味着atomicI的值没有被其他线程修改过,则将atomicI的当前数值更新成next的值,如果不等compareAndSet()方法会返回false,程序则进入for循环重新进行compareAndSet()方法操作进行不断尝试直到成功为止。在这里我们跟踪下compareAndSet()方法如下
public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
从上面源码我们发现是使用Unsafe实现的,其实atomic里的类基本都是使用Unsafe实现的。我们再回到这个本地方法调用,这个本地方法在openjdk中依次调用c++代码为unsafe.cpp、atomic.app和atomic_windows_x86.inline.hpp。关于本地方法实现的源代码这里就不贴出来了,其实大体上是程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身就会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。