懒汉式单例模式双重锁两次判空的个人理解

今天学习dubbo3源码时看到单例模式的两次非空判断,很是疑惑,于是便学习研究了下,理解起来也很简单,无非是出于效率和安全的考虑.

懒汉式单例模式类

public class Singleton {

        // 使用volatile禁止指令重排序
        private static volatile Singleton sin = null;

        public static int i = 0;// 标识有几个线程获取到了锁
        public static int j = 0;// 标识系统中到底生成了几个实例

        // 将构造器的修饰符设置为"private",可以防止在外部进行new实例对象
        private Singleton() {
        };

        // 获取实例对象的方法,公共的方法。
        public static Singleton getInstance() {
            // 第一次判空。
            if (sin == null) {
                // 加锁
                synchronized (Singleton.class) {
                    i++;
                    // 第二次判空。
                    if (sin == null) {
                        sin = new Singleton();
                        j++;
                    }
                }
            }
            return sin;
        }
}

多线程并发调用单例模式的测试类

public class ThreadTest implements Runnable  {

    /**
     * 实例化一个倒计数器,初始倒计数为10; 其实内部是将AQS的同步状态变量state设置为了10,
     * 说明此时有10个线程获取到了共享锁
     */
    static final CountDownLatch latch = new CountDownLatch(10);
    static final ThreadTest demo = new ThreadTest();

    @Override
    public void run() {
        try {
            // 实例对象生成
            Singleton.getInstance();
            // 输出当前线程的名称
            System.out.println(Thread.currentThread().getName());
        } catch (Exception e) {
            e.printStackTrace();
        }
        finally {
            // 计数器进行减一,说明有一个线程已经成功释放了共享锁,当计数器减到0时,会唤醒阻塞的线程
            latch.countDown();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建一个长度为10的定长线程池
        ExecutorService exec = Executors.newFixedThreadPool(10);
        for (int i=0; i<10; i++){
            // 提交任务给线程池去执行
            exec.submit(demo);
        }

        /**
         * 等待检查,阻塞main主线程, 只有当CountDownLatch倒计数器为0时,
         * 也就是获得共享锁的线程全部释放了共享锁后, 才会唤醒阻塞的main主线程
         */
        latch.await();

        // 在开启的10个线程中几个线程获取到了锁
        System.out.println("共有 ( " + Singleton.i + " ) 个线程获取到对象锁");
        // 最终生成了几个Singleton实例
        System.out.println("最终生成了( " + Singleton.j + " )个Singleton实例对象");

        // 关闭线程池
        exec.shutdown();
    }
}

main方法运行结果:

pool-1-thread-1
pool-1-thread-7
pool-1-thread-5
pool-1-thread-3
pool-1-thread-6
pool-1-thread-2
pool-1-thread-4
pool-1-thread-9
pool-1-thread-8
pool-1-thread-10
共有 ( 2 ) 个线程获取到对象锁
最终生成了( 1 )个Singleton实例对象

从运行结果可以看出,如果不进行第二次判空的话,那么在竟锁池(锁池)中如果还有活跃的线程在等待获取的锁的话,在锁释放后就会再次竞争获取锁,获取的锁的线程进入"就绪状态",当cpu分配其"时间片"后进行线程的调度,从而线程进入"运行中状态",并会去执行同步的代码块,如果在没加如二次判空的话,就会导致系统中存在多个实例,而在进行判空后,即使你获取到了锁,但在执行同步代码块时也会直接跳过。

实例:

public class Singleton {
    private volatile static Singleton singleton;
    private Singleton (){}
    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

1.当A与B同时调用getSingleton时,判断第一个if都为空,这时A拿到锁,进行第二层if判断,条件成立new了一个对象; B在外层等待,A创建完成,释放锁,B拿到锁,进行第二层if判断,条件不成立,结束释放锁。C调用getSingleton时第一层判断不成立,直接拿到singleton对象返回,避免进入锁,减少性能开销。

2.当两个线程同时掉这个方法的时候,如果一个判空可能会创建两个对象,比如当A线程还没创建完对象的时候,B线程也能看进入if分支里面,所以使用synchronized加锁排队;如果每次都排队的话,效率就低下了,所以需要外层的判空。总结:synchronized保证线程安全,外层判空保证效率。

3.第一次判断是为了验证是否创建对象,第二次判断是为了避免重复创建单例,因为可能会存在多个线程通过了第一次判断在等待锁,来创建新的实例对象。

扩展:

竟锁池(锁池)的概念:https://blog.csdn.net/qq_22498277/article/details/82184419

volatile 修饰符的作用:

代码中 private static `volatile` Singleton sin = null;  volatile修饰符的作用是什么呢?

volatile修饰变量只是为了禁止指令重排序,因为在 sin = new Singleton(); 创建对象时,底层会分为四个指令执行:(下面是正确的指令执行顺序)
①、如果类没有被加载过,则进行类的加载
②、在堆中开辟内存空间 adr,用于存放创建的对象
③、执行构造方法实例化对象
④、将堆中开辟的内存地址 adr 赋值给被volatile修饰的引用变量 sin

如果sin引用变量不使用volatile修饰的话,则可能由于编译器和处理器对指令进行了重排序,导致第④步在第③步之前执行,此时sin引用变量不为null了,但是sin这个引用变量所指向的堆中内存地址中的对象是还没被实例化的,实例对象还是null的;那么在第一次判空时就不为null了,然后去使用时就会报NPE空指针异常了。

已有 0 条评论

    感谢参与互动!