当前位置: 首页 > 后端技术 > Java

Java里面为什么搞了双重检查锁,写完这篇文章终于真相大白了

时间:2023-04-02 00:36:54 Java

为什么在Java中使用双重检查锁?写完这篇文章,真相终于大白。对象被初始化。此时程序员可能会求助于惰性初始化。但是获得线程安全的延迟初始化需要一些技巧,否则很容易出现问题。例如,下面是一个非线程安全惰性初始化对象的示例代码:COPYpublicclassUnsafeLazyInitialization{privatestaticInstanceinstance;publicstaticInstancegetInstance(){if(instance==null)//1:一个线程执行instance=newInstance();//2:线程B执行returninstance;}}在UnsafeLazyInitialization中,假设线程A执行代码1,线程B执行代码2,此时线程A可能会看到instance引用的对象还没有初始化(参见下面的“问题根源”)原因)。对于UnsafeLazyInitialization,我们可以通过同步getInstance()来实现线程安全的惰性初始化。示例代码如下:COPYpublicclassSafeLazyInitialization{privatestaticInstanceinstance;publicsynchronizedstaticInstancegetInstance(){if(instance==null)instance=newInstance();返回实例;}}由于getInstance()是同步的,synchronized会产生性能开销。如果getInstance()被多个线程频繁调用,会导致程序执行性能下降。相反,如果getInstance()不被多线程频繁调用,那么这种惰性初始化方案将提供令人满意的性能。在早期的JVM中,synchronized(甚至是无竞争的synchronized)都有如此巨大的性能开销。因此,人们想出了一个“聪明”的把戏:双重检查锁定(double-checkedlocking)。人们希望通过双重检查锁定来减少同步的开销。下面是使用双重检查锁定进行惰性初始化的示例代码:COPYpublicclassDoubleCheckedLocking{//1privatestaticInstanceinstance;//2publicstaticInstancegetInstance(){//3if(instance==null){//4:首先检查同步(DoubleCheckedLocking.class){//5:Lockif(instance==null)//6:第二个检查实例=newInstance();//7:问题的根源就在这里}//8}//9returninstance;//10}//11}//12如上面代码所示,如??果第一次检查实例不为null,那么就不需要进行后面的加锁和初始化操作。因此,可以大大降低synchronized带来的性能开销。从表面上看,上面的代码似乎两全其美:当多个线程同时尝试创建一个对象时,使用锁来确保只有一个线程可以创建该对象。创建对象后,执行getInstance()会直接返回创建的对象,不需要获取锁。双重检查锁定可能看起来很完美,但这是一个糟糕的优化!当线程执行到第四行代码,读到实例不为null时,可能实例引用的对象还没有初始化。问题的根源前面双重检查锁定示例代码的第7行(instance=newSingleton();)创建了一个对象。这行代码可以分解为如下三行伪代码:COPYmemory=allocate();//1:分配对象ctorInstance(memory)的内存空间;//2:初始化对象instance=memory;//3:设置实例指向上面三行伪代码中的Between2and3新分配的内存地址可能会被重新排序(在一些JIT编译器上,这种重新排序确实发生了,参见“乱序写入”部分).2和3之间重新排序后的执行顺序如下:COPYmemory=allocate();//1:分配对象instance=memory的内存空间;//3:setinstance指向刚刚分配的内存地址//注意此时Object还没有初始化!ctor实例(内存);//2:初始化对象根据《The Java Language Specification, Java SE 7 Edition》(以下简称java语言规范),所有线程在执行java程序时都必须遵守线程内语义。线程内语义保证重新排序不会改变单个线程内程序执行的结果。换句话说,线程内语义允许在不改变单线程程序的执行结果的情况下在单个线程内进行那些重新排序。虽然上述三行伪代码中的第2、3行被重新排序,但这种重新排序不会违反线程内语义。这种重新排序可以在不改变单线程程序执行结果的情况下,提高程序的执行性能。为了更好的理解线程内语义,请看下面的示意图(假设一个线程A在构造对象后立即访问这个对象):如上图所示,只要保证2在前面of4,即使2和3线程间重新排序也不会违反线程内语义。接下来我们看一下多线程并发执行时的情况。请看下面的示意图:由于在单线程中必须遵守线程内语义,所以可以保证A线程的程序执行结果不会被改变。但是当线程A和B按上面显示的顺序执行时,线程B会看到一个尚未初始化的对象。注:本文用红色虚线箭头标示错误的读操作,绿色虚线箭头标示正确的读操作。回到本文的主题,如果在DoubleCheckedLocking示例代码(instance=newSingleton();)的第7行发生重排序,另一个并发执行的线程B可能会在第4行判断instance不为null,然后线程B会访问instance引用的对象,但是这个对象此时可能还没有被线程A初始化!下面是这个场景的具体执行顺序:时间线程A线程Bt1A1:分配对象的内存空间t2A3:设置实例指向内存空间t3B1:判断实例是否为空t4B2:因为实例是notnull,线程B会访问实例引用对象t5A2:初始化对象t6A4:访问实例引用的对象这里虽然A2和A3重新排序了,但是java内存模型的线程内语义会保证A2会在A4之前执行。所以线程A的线程内语义没有改变。但是A2和A3的重新排序会导致线程B判断B1处的instance不为空,然后线程B会去访问instance引用的对象。此时,线程B会访问一个未初始化的对象。知道问题的根源后,我们可以想出两种方法来实现线程安全的惰性初始化:不允许2和3重排序;允许2和3重新排序,但不允许其他线程“看到”这个重新排序的排序。volatile解决方案对于之前基于双重检查锁实现惰性初始化的方案(参考DoubleCheckedLocking示例代码),我们只需要做一个小的修改(将实例声明为volatile)就可以实现线程安全的惰性初始化。请看下面的示例代码:COPYpublicclassSafeDoubleCheckedLocking{privatevolatilestaticInstanceinstance;publicstaticInstancegetInstance(){if(instance==null){synchronized(SafeDoubleCheckedLocking.class){if(instance==null)instance=newInstance();//instanceisvolatile,现在没问题}}returninstance;}}请注意,此解决方案需要JDK5或更高版本(因为从JDK5开始使用JSR-133内存模型规范,此规范增强了volatile语义)。当对对象的引用被声明为volatile时,在多线程环境下将禁止“问题根源”的三行伪代码中2和3之间的重新排序。上面的示例代码将按照以下顺序执行:该方案通过禁止上图中2和3之间的重新排序,从本质上保证了线程安全的惰性初始化。基于类初始化的解决方案JVM会在类初始化阶段(即Class被加载后,被线程使用前)进行类初始化。在类初始化期间,JVM获取一个锁。这个锁可以同步多个线程对同一个类的初始化。基于这个特性,可以实现另一种线程安全的惰性初始化方案(这种方案被称为InitializationOnDemandHolderidiom):}publicstaticInstancegetInstance(){returnInstanceHolder.instance;//这样会导致InstanceHolder类被初始化}}假设两个线程并发执行getInstance(),下面是执行示意图:这种方案的本质是:让“问题的根源”2和3用三行伪代码重新排序,但不允许非构造线程(此处为线程B)“看到”此重新排序。初始化一个类,包括对类进行静态初始化和初始化类中声明的静态字段。根据Java语言规范,类或接口类型T在第一次出现以下任何情况时立即初始化:T是一个类,并且创建了类型T的实例;T是一个类,调用了A静态方法;分配了在T中声明的静态字段;使用了在T中声明的静态字段,并且该字段不是常量字段;T是顶级类(参见第7.6节),执行嵌套在T中的断言语句。在InstanceFactory示例代码中,执行getInstance()的第一个线程将导致初始化InstanceHolder类(案例4)。由于java语言是多线程的,多个线程可能同时尝试初始化同一个类或接口(例如,多个线程可能同时调用getInstance()来初始化InstanceHolder类)。因此,在java中初始化一个类或接口时,需要仔细的进行同步处理。Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。C到LC的映射由JVM的具体实现自由实现。JVM在类初始化时会获取这个初始化锁,每个线程至少获取一次锁,以保证类已经初始化(其实Java语言规范允许JVM的具体实现在这里做一些优化,见稍后说明)。流程分析对于类或接口的初始化,java语言规范制定了一个精致复杂的类初始化流程。java中初始化一个类或接口的过程如下(这里对类初始化过程的描述省略了与本文无关的部分;同时为了更好的说明类进程中的同步处理机制初始化,作者人为初始化类处理过程分为五个阶段):第一阶段第一阶段:通过在Class对象上同步来控制类或接口的初始化(即获取Class的初始化锁)目的)。获取锁的线程会等待,直到当前线程可以获取初始化锁。假设Class对象当前没有初始化(此时初始化状态state标记为state=noInitialization),两个线程A和B同时尝试初始化Class对象。下面是对应的示意图:下面是这个示意图的说明:时间线程A线程Bt1A1:尝试获取Class对象的初始化锁。这里假设线程A已经获取到初始化锁B1:尝试获取Class对象的初始化锁,由于线程A已经获取到锁,线程B将等待获取初始化锁t2A2:线程A看到线程还没有已初始化(因为状态为read==noInitialization),线程设置state=initializingt3A3:线程A释放初始化锁Phase2Phase2:线程A执行类初始化,同时线程B等待初始化锁对应的条件:下面是这个图的说明:时间线程A线程Bt1A1:执行类的静态初始化和初始化类中声明的静态字段B1:获取初始化锁t2B2:读取状态==initializingt3B3:释放初始化lockt4B4:Waitinconditionofinitializationlock第三阶段第三阶段:线程A设置state=initialized,然后唤醒所有在该条??件下等待的线程:下面是这个图的说明:timethreadAt1A1:获取初始化锁t2A2:setsstate=initializedt3A3:wakeswaitinginthecondition所有线程t4A4:释放初始化锁t5A5:线程A初始化过程完成theclass:下面是这个图的说明:时间线程Bt1B1:获取初始化锁t2B2:ReadTostate==initializedt3B3:释放初始化锁t4B4:线程B的类初始化过程完成线程A在中执行类初始化第二阶段A1,释放第三阶段A4中的初始化锁;第四阶段B1的线程B获取了同样的初始化锁,直到第四阶段B4之后才开始访问这个类。根据java内存模型规范的锁规则,这里会存在如下happens-before关系:这种happens-before关系会保证:线程A在类初始化时执行写操作(执行的静态初始化类和初始化类静态字段中的声明),线程B必须看到它。Thefifthstage第五阶段:线程C执行类的初始化过程:下面是这个图的说明:时间线程Bt1C1:获取初始化锁t2C2:读取状态==初始化t3C3:释放初始化锁t4C4:线程CclassinitializationProcessingcompletes在第三阶段之后,类已经被初始化。因此,线程C在第五阶段的类初始化处理过程比较简单(前面线程A和B的类初始化处理过程经历了两次锁获取-锁释放,而线程C的类初始化处理只需要经历一次锁获取-锁释放)。线程A在第二阶段A1执行类的初始化,在第三阶段A4释放锁;线程C在第五阶段的C1获取相同的锁,直到第五阶段的C4之后才开始访问该类。根据java内存模型规范的锁规则,会存在如下happens-before关系:这种happens-before关系会保证:线程A在执行类初始化时,必须能够看到写操作。注1:本文中的条件和状态标签是虚构的。Java语言规范不强制使用条件和状态标记。JVM的具体实现只需要实现类似的功能即可。注2:Java语言规范允许Java的具体实现优化类的初始化过程(这里优化第五阶段)。详见Java语言规范第12.4.2章。通过对比基于volatile双重检查锁的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更加简洁。但是基于volatiledouble-checklocking的方案还有一个额外的好处:除了静态字段的惰性初始化外,还可以实现实例字段的惰性初始化。总结延迟初始化减少了初始化类或创建实例的开销,但增加了访问延迟初始化的字段的开销。大多数情况下,正常初始化优于惰性初始化。如果确实需要对实例字段使用线程安全的惰性初始化,请使用上面介绍的基于volatile的惰性初始化方案;如果你真的需要对静态字段使用线程安全的惰性初始化,请使用上面描述的基于类的初始化方案。如果本文对您有帮助,欢迎关注点赞`,您的支持是我坚持创作的动力。转载请注明出处!