关于多线程:双重检查锁原来是这样演变来的你了解吗

63次阅读

共计 3720 个字符,预计需要花费 10 分钟才能阅读完成。

在看 Nacos 的源代码时,发现多处都应用了“双重查看锁”的机制,算是十分好的实际案例。这篇文章就着案例来剖析一下双重查看锁的应用以及劣势所在,目标就是让你的代码格调更加高一个档次。

同时,基于单例模式,解说一下双重查看锁的演变过程。

Nacos 中的双重查看锁

在 Nacos 的 InstancesChangeNotifier 类中,有这样一个办法:

private final Map<String, ConcurrentHashSet<EventListener>> listenerMap = new ConcurrentHashMap<String, ConcurrentHashSet<EventListener>>();

private final Object lock = new Object();

public void registerListener(String groupName, String serviceName, String clusters, EventListener listener) {String key = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, groupName), clusters);
    ConcurrentHashSet<EventListener> eventListeners = listenerMap.get(key);
    if (eventListeners == null) {synchronized (lock) {eventListeners = listenerMap.get(key);
            if (eventListeners == null) {eventListeners = new ConcurrentHashSet<EventListener>();
                listenerMap.put(key, eventListeners);
            }
        }
    }
    eventListeners.add(listener);
}

该办法的次要性能就是对监听器事件进行注册。其中注册的事件都存在成员变量 listenerMap 当中。listenerMap 的数据结构是 key 为 String,value 为 ConcurrentHashSet 的 Map。也就是说,一个 key 对应一个汇合。

针对这种数据结构,在多线程的状况下,Nacos 解决流程如下:

  • 通过 key 获取 value 值;
  • 判断 value 是否为 null;
  • 如果 value 值不为 null,则间接将值增加到 Set 当中;
  • 如果为 null,就须要创立一个 ConcurrentHashSet,在多线程时,有可能会创立多个,因而要应用锁。
  • 通过 synchronized 锁定一个 Object 对象;
  • 在锁内再获取一次 value 值,如果仍然是 null,则进行创立。
  • 进行后续操作。

上述过程,在锁定前和锁定之后,做了两次判断,因而称作”双重查看锁“。应用锁的目标就是防止创立多个 ConcurrentHashSet。

Nacos 中的实例略微简单一下,上面以单例模式中的双重查看锁的演变过程。

未加锁的单例

这里间接演示单例模式的懒汉模式实现:

public class Singleton {
    
    private static Singleton instance;
    
    private Singleton() {}
    
    public Singleton getInstance() {if (instance == null) {instance = new Singleton();
        }
        return instance;
    }    
}

这是一个最简略的单例模式,在单线程下运行良好。但在多线程下会呈现显著的问题,可能会创立多个实例。

以两个线程为例:

能够看到,当两个线程同时执行时,是有可能会创立多个实例的,这很显著不合乎单例的要求。

加锁单例

针对上述代码的问题,很直观的想到是进行加锁解决,实现代码如下:

public class Singleton {
    
    private static Singleton instance;
    
    private Singleton() {}
    
    public synchronized Singleton getInstance() {if (instance == null) {instance = new Singleton();
        }
        return instance;
    }
}

与第一个示例惟一的区别是在办法上增加了 synchronized 关键字。这时,当多个线程进入该办法时,须要先取得锁能力进行执行。

通过在办法上增加 synchronized 关键字,看似完满的解决了多线程的问题,但却带了性能问题。

咱们晓得应用锁会导致额定的性能开销,对于下面的单例模式,只有第一次创立时须要锁(避免创立多个实例),但查问时是不须要锁的。

如果针对办法进行加锁,每次查问也要承当加锁的性能损耗。

双重查看锁

针对下面的问题,就有了双重查看锁,示例如下:

public class Singleton {
    
    private static Singleton instance;
    
    private Singleton() {}
    
    public Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

第一,将锁的范畴放大的办法内;

第二,锁之前先判断一下是不是 null,如果不为 null,阐明曾经实例化了,间接返回,没必要进行创立;

第三,如果为 null,进行加锁,而后再次判断是否为 null。为什么要再次判断?因为一个线程判断为 null 之后,另外一个线程可能曾经创立了对象,所以在锁定之后,须要再次核实一下,真的为 null,则进行对象创立。

改良之后,既保证了线程的安全性,又防止了锁导致的性能损失。问题到此结束了吗?并没有,持续往下看。

JVM 的指令重排

在某些 JVM 当中,编译器为了性能问题,会进行指令重排。在上述代码中 new Singleton() 并不是原子操作,有可能会被编译器进行重排操作。

创建对象可形象为三步:

memory = allocate();    //1:调配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance = memory;     //3:设置 instance 指向刚调配的内存地址 

下面操作中,操作 2 依赖于操作 1,但操作 3 并不依赖于操作 2。因而,JVM 是能够进行指令重排优化的,可能会呈现如下的执行程序:

memory = allocate();    //1:调配对象的内存空间 
instance = memory;     //3:instance 指向刚调配的内存地址,此时对象还未初始化
ctorInstance(memory);  //2:初始化对象 

指令重排之后,将操作 3 的赋值操作放在了后面,那就会呈现一个问题:当线程 A 执行完步骤赋值操作,但还未执行对象初始化。此时,线程 B 进来了,在第一层判断时发现 Instance 曾经有值了(实际上还未初始化),间接返回对应的值。那么,程序在应用这个未初始化的值时,便会呈现谬误。

针对此问题,可在 instance 上增加 volatile 关键字,使得 instance 在读、写操作前后都会插入内存屏障,防止重排序。

最终,单例模式实现如下:

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

至此,一个欠缺的单例模式实现了。此时,你是否有一个疑难,为什么 Nacos 中的双重查看锁没有应用 volatile 关键字呢?

答案很简略:下面单例模式如果呈现指令重排,会导致单例实例被应用。那么,再看 Nacos 的代码,因为创立 ConcurrentHashSet 并不会影响到查问,而真正影响查问的是 listenerMap.put 办法,而 ConcurrentHashSet 自身是线程平安的。因而,也就不会呈现线程平安问题,不必应用 volatile 关键字了。

小结

浏览源码最有意思的一个中央就是能够看到很多经典常识的实际,如果可能深刻思考,拓展一下,会取得意想不到的播种。

再回顾一下本文的重点:

  • 浏览 Nacos 源码,发现双重查看锁的应用;
  • 未加锁单例模式应用,会创立多个对象;
  • 办法上加锁,导致性能降落;
  • 代码内部分加锁,双重判断,既满足线程平安,又满足性能需求;
  • 单例模式特例:创建对象分多步,会呈现指令重排景象,采纳 volatile 进行防止指令重排;

最初,想学习更多相似干货,关注一下吧,继续输入。

博主简介:《SpringBoot 技术底细》技术图书作者,热爱钻研技术,写技术干货文章。

公众号:「程序新视界」,博主的公众号,欢送关注~

技术交换:请分割博主微信号:zhuan2quan

正文完
 0