java的双重检查锁定详解

     双重检查锁定模式(也被称为"双重检查加锁优化","锁暗示"(Lock hint)) 是一种软件设计模式用来减少并发系统中竞争和同步的开销。有时候会在单例模式里遇到这个双重检查锁定,这个技术是单例模式的一种具体的实现,通过它来进行单例模式里的延迟初始化,保证在经过延迟初始化之后还是只有一个单例类的实例被创建。

    java程序中可能需要推迟一些高开销的对象初始化操作,只有在使用这些对象时才进行初始化,所以有时候采用延迟初始化来降低初始化类和创建对象的开销,而双重检查锁定是常见的延迟初始化技术(本文主要介绍该技术但是不推荐使用它,后面会进行分析)。

    以下是非线程安全的延迟初始化案例:

    public class UnsafeLazyInitialization {

        private static Instance instance ;

        public static Instance getInstace(){

            if(instance == null)              //1.A线程执行

            instance =new Instance(); //2.B线程执行

        return instance;

        }

    }

    至于为什么是线程不安全,在线程A执行代码1的时候线程B执行代码2,线程A可以看见instance引用的对象还没有完成初始化。可以对getInstance方法做同步处理来实现线程安全的延迟初始化(加关键字synchronized),但是处理之后如果被频繁调用就会导致执行性能下降。如果不会被频繁使用就会提供一个较好的性能。

    但是这样还是会出现性能的损耗,所以就会出现了本文主题:通过双重检查锁定来降低同步的开销。

    双重检查锁定模式首先验证锁定条件(第一次检查),只有通过锁定条件验证才真正的进行加锁逻辑并再次验证条件(第二次检查)。一下是使用双重检查锁定来实现延迟初始化案例:

    public class UnsafeLazyInitialization {                           //1

        private static Instance instance ;                               //2

        public static Instance getInstace(){                           //3

            if(instance == null){                                               //4第一次检查

                synchorized(DoubleCheckedLocking.Class){  //5加锁

                    if(instance==null)                                          //6第二次检查

                    instance =new Instance();                            //7

                }

            }

         return instance;

        }

    }

    如上所示,如果第一次检查instance不为null就不需要执行下面的加锁和初始化操作,可以降低synchronized带来的消耗,多个线程试图在同一时间创建对象时会通过加锁来保证只有一个线程能创建对象,在对象创建好之后执行getInstance()将不需要获取锁,直接返回已经创建好的对象。

    虽然解决了一些问题但是却是一个错误的优化,当执行到第四行代码,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。这就是问题所在,在创建一个对象的时候会被分解为三部分进行:

        memory = allocate(); //1 分配对象的内存空间

        ctorInstance(memory); //2 初始化对象

        instance=memory; //3 设置instance指向刚分配的内存地址

    但是在执行这三个步骤的时候可能会出现2和3被重排序,先进行instance的指向工作在进行初始化。根据java语言规范所有线程在执行java程序时必须遵守intra-thread semantics,该规则会保证重排序不会改变单线程内的程序执行结果,也就是说允许单线程内不会改变单线程执行结果的重排序。虽然上面这种重排序不违反intra-thread semantics,但是在多线程的情况下就会导致其他线程看到一个没有被初始化的对象,如果发生重排序另一个并发执行的线程就可能在判断引用的时候判断不为null,线程接下来将访问所引用的对象,但这个对象并没有被初始化。虽然重排序的规则能保证单线程的初始化对象排在访问该对象之前,但是无法确保在多线程下重排序还是安全的,又可能会访问到一个还未初始化的对象。

    在出现这种情况之后,可以想出两个办法来解决这个问题:

        1.不允许2和3重排序

        2.允许2和3重排序,但不允许其他线程看见这个重排序

    一、基于volatile的解决方案

    对于上面的双重检查锁定来实现延迟初始化的方案,只需要用volatile修饰instance就可以实现线程安全的延迟初始化。从JDK5之后就可以使用这个方法了,当声明之后就会禁止在多线程的情况下对对象的初始化对象以及设置instance指向刚分配的内存地址两个步骤的重排序。也就是说通过禁止重排序的方法来保证线程安全的延迟初始化。

    二、基于类初始化的解决方案

    在java类的初始化阶段会进行类的初始化,即在Class被加载后和被线程使用之前,在执行类的初始化时,JVM会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化,通过这个锁可以实现线程安全的延迟初始化方案。

    public class InstanceFactory{

        public static class InstanceHolder{

            public static Instance instance =new Instance();

        }

        public static Instance getInstance(){

            return InstanceHolder.instance;

        }

    }

    通过上面这段代码会导致多个线程在调用方法时同时试图获取Class对象的初始化锁,当一个线程获得这个锁后将执行InstanceHolder的初始化,即new Instance的三个步骤。这三个步骤依然可能会出现重排序,但其他线程无法看到这个过程。在首次执行getInstance()时会导致类的初始化,在java初始化时需要要做细致的同步处理,对于每一个类或者接口C,都有一个唯一的初始化锁LC与之对应,JVM在类的初始化期间会获取这个初始化锁并且每个线程至少获取一次锁来保证这个类已经被初始化过了。

    在一个线程获得初始化锁之后会执行new Instance(),三个步骤也可能发生重排序,但是这个过程其他线程看不到,因为其他线程没有获得锁只是一直在等待导致不能看到重排序,但是根据java内存模型规范的锁规则,会存在happens-before关系,将保证线程A执行类的初始化时的写入操作即执行类的静态初始化和初始化类中声明的静态字段,线程B一定能看到。所以B会得到正常初始化的对象。

    对比两个方案,会发现基于类的初始化的方案的实现代码更简单,基于volatile的双重检查锁定方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例化字段实现延迟初始化,降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销,正常的初始化要优于延迟初始化,如果有必要对实例字段使用线程安全的延迟初始化,尽量使用基于volatile的延迟初始化方案,如果需要对静态字段使用线程安全的延迟初始化,请使用介绍的基于类初始化的方案。

猜你喜欢

转载自blog.csdn.net/ZytheMoon/article/details/89817308
今日推荐