今天学习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空指针异常了。
注意:本文归作者所有,未经作者允许,不得转载