复查锁是这样进化来的,你懂吗?转载本文请联系程序新视界公众号。在查看Nacos的源码时,发现很多地方都使用了“双重检查锁”机制,这是一个很好的实践案例。本文通过一个案例来分析一下双检锁的使用和优势。目的是让你的代码风格更上一层楼。同时基于单例模式,讲解双重校验锁的演进过程。Nacos中的双校验锁在Nacos的InstancesChangeNotifier类中,有这样一个方法:privatefinalMap>listenerMap=newConcurrentHashMap>();privatefinalObjectlock=newObject();publicvoidregisterListener(StringgroupName,StringserviceName,Stringclusters,EventListenerlistener){Stringkey=ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName,groupName),clusters);ConcurrentHashSeteventListeners=listenerMap.get(key);if(eventListeners==null){同步(锁定){eventListeners=listenerMap.get(key);if(eventListeners==null){eventListeners=newConcurrentHashSet();listenerMap.put(key,eventListeners);}}}eventListeners.add(listener);}该方法的主要作用是注册监听事件。注册的事件都存在于成员变量listenerMap中。listenerMap的数据结构是一个Map,key为String,value为ConcurrentHashSet。换句话说,一个键对应一个集合。对于这个数据结构,在多线程的情况下,Nacos的处理流程如下:通过key获取value;判断该值是否为空;如果value不为null,直接将value添加到Set中;如果为null,则需要创建一个ConcurrentHashSet。在多线程中,可能会创建多个线程,所以要用到锁。通过synchronized锁定一个Object对象;再次获取锁中的值,如果仍然为null,则创建它。跟进。上述过程,在加锁前和加锁后,进行了两次判断,所以称为“双重检查锁”。使用锁的目的是为了避免创建多个ConcurrentHashSet。Nacos中的例子稍微复杂一些。下面是双检锁在单例模式下的演化过程。Unlockedsingleton这里直接演示单例模式的惰性模式实现:publicclassSingleton{privatestaticSingletoninstance;privateSingleton(){}publicSingletongetInstance(){if(instance==null){instance=newSingleton();}returninstance;}}这个是最简单的单例模式,在单线程下效果很好。但是在多线程下会有明显的问题,可能会创建多个实例。以两个线程为例:可以看出,当两个线程同时执行时,是可以创建多个实例的,这显然不符合单例的要求。对于上面代码的问题,很直观的想到加锁。实现代码如下:publicclassSingleton{privatestaticSingletoninstance;privateSingleton(){}publicsynchronizedSingletongetInstance(){if(instance==null){instance=newSingleton();}returninstance;}}与第一个例子唯一不同的是增加了方法上的同步关键字。这时,当多个线程进入这个方法时,需要先获取锁,然后再执行。通过在方法中加入synchronized关键字,看似完美解决了多线程的问题,却带来了性能问题。我们知道使用锁会带来额外的性能开销。对于上面的单例模式,只有第一次创建需要加锁(防止创建多个实例),查询不需要加锁。如果方法被加锁,每个查询也要承担加锁的性能损失。Doublechecklock针对上面的问题,有一个doublechecklock。示例如下:}returninstance;}}首先,方法内缩小锁的范围;第二,加锁前,检查是否为null,如果不为null,说明已经实例化了,直接返回,不需要创建;第三,如果为null,就加锁,然后再判断是否为null。为什么需要重新判断?因为一个线程判断为null后,另一个线程可能已经创建了该对象,所以加锁后需要再次检查,如果真的为null,则创建对象。改进后,既保证了线程的安全,又避免了锁带来的性能损失。这是问题的结尾吗?不,继续读下去。JVM指令重排在某些JVM中,编译器会为了性能问题进行指令重排。上面代码中,newSingleton()不是原子操作,可能会被编译器重新排列。创建对象可以抽象为三步:memory=allocate();//1:分配对象的内存空间ctorInstance(memory);//2:初始化对象instance=memory;//3:设置实例指向刚刚分配的内存地址在上面的操作中,操作2依赖于操作1,而操作3不依赖于操作2。因此,JVM可以进行指令重排优化,可能会出现如下执行顺序:memory=allocate();//1:分配对象的内存空间instance=memory;//3:instance指向刚刚分配的内存地址,此时对象还没有初始化ctorInstance(memory);//2:重新整理初始化对象指令后,将操作3的赋值操作放在前面,这时就会出现一个问题:当线程A完成步骤赋值操作时,但是还没有进行对象初始化。这时候线程B进来了,在第一级判断的时候发现Instance已经有值了(实际上是没有初始化),直接返回对应的值。那么,当程序使用这个未初始化的值时,就会出错。为了解决这个问题,可以给实例加上volatile关键字,这样实例就会在读写操作前后插入内存屏障,避免重排序。最后,单例模式的实现如下:);}}}returninstance;}}至此,一个完整的单例模式就实现了。说到这里,大家有没有疑问,为什么Nacos中的double-checklock不使用volatile关键字呢?答案很简单:如果上面的单例模式有指令重排,就会使用单例实例。然后看Nacos的代码,ConcurrentHashSet的创建不影响查询,但是listenerMap.put方法真正影响查询,而且ConcurrentHashSet本身是线程安全的。因此不会有线程安全问题,也不需要使用volatile关键字。总结阅读源码最有趣的事情之一就是可以看到很多经典知识的实践。如果能够深入思考并加以拓展,将会获得意想不到的收获。复习本文重点:阅读Nacos源码,找到复查锁的使用;使用未锁定的单例模式将创建多个对象;锁定方法,导致性能下降;代码中的局部锁,双重判断,既满足线程安全,又满足性能要求;单例模式的特例:分多步创建一个对象,会发生指令重排,所以使用volatile来避免指令重排。