Java双重检查加锁单例模式的详解

什么是DCL

DCL(Double-checked locking)被设计成支持延迟加载,当一个对象直到真正需要时才实例化:

class SomeClass {
 private Resource resource = null;
 public Resource getResource() {
 if (resource == null)
  resource = new Resource();
 return resource;
 }
}

为什么需要推迟初始化?可能创建对象是一个昂贵的操作,有时在已知的运行中可能根本就不会去调用它,这种情况下能避免创建一个不需要的对象。延迟初始化能让程序启动更快。但是在多线程环境下,可能会被初始化两次,所以需要把getResource()方法声明为synchronized。不幸的是,synchronized方法比非synchronized方法慢100倍左右,延迟初始化的初衷是为了提高效率,但是加上synchronized后,提高了启动速度,却大幅下降了执行时速度,这看起来并不是一桩好买卖。DCL看起来是最好的:

class SomeClass {
 private Resource resource = null;
 public Resource getResource() {
 if (resource == null) {
  synchronized(this) {
  if (resource == null)
   resource = new Resource();
  }
 }
 return resource;
 }
}

延迟了初始化,又避免了竞态条件。看起来是一个聪明的优化--但它却不能保证正常工作。为提高计算机系统性能,编译器、处理器、缓存会对程序指令和数据进行重排序,而对象初始化操作并不是一个原子操作(可能会被重排序);因此可能存在这种情况:一个线程正在构造对象过程中,另一个线程检查时看见了resource的引用为非null。对象被非安全发布(逸出)。

根据Java内存模型,synchronized的语义不仅仅是在同一个信号上的互斥(mutex),也包含线程和主存之间数据交互的同步,它确保在多处理器、多线程下对内存能有可预见的一致性视图。获取或释放锁会触发一次内存屏障(memory barrier)--强迫线程本地内存和主存同步。当一个线程退出一个synchronized block时,触发一次写屏障(write barrier )--在释放锁前必须把所有在这个同步块里修改过的变量值刷新到主存;同样,进入一个synchronized block时,触发一次读屏障(read barrier)--让本地内存失效,必须从主存中重新获取在这个同步块中将要引用的所有变量的值。正确使用同步能保证一个线程能以可预见的方式看到另一个线程的结果,线程对同步块的操作就像是原子的。“正确使用”的含义是:必须是在同一个锁上同步。

DCL是怎么失效的

了解了JMM后,再来看看DCL是怎么失效的。DCL依赖于一个非同步的resource字段,看起来无害,实则不然。假如线程A进入了synchronized block,正在执行resource = new Resource();此时线程B进入 getResource()。考虑到对象初始化在内存上的影响:为new对象分配内存;调用构造方法,初始化对象的成员变量;把新创建好对象的引用赋值给SomeClass的resource字段。然而线程B没有进入synchronized block,却可能以不同于线程A执行的顺序看到上述内存操作。B看到的可能是如下顺序(指令重排序):分配内存,把对象引用赋值给SomeClass的resource字段,调用构造器。当内存已经分配好,A线程把SomeClass的resource字段设值完成后,线程B进入检查发现resource不是null,跳过synchronized block返回一个未构造完成的对象!显而易见,结果不是预期的也不是想要的。

下面代码是一个试图修复DCL的加强版,遗憾的是它仍然不能保证正常工作。

// (Still) Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
 private Helper helper = null;
 public Helper getHelper() {
 if (helper == null) {
  Helper h;
  synchronized (this) {
  h = helper;
  if (h == null)
   synchronized (this) {
   h = new Helper();
   } // release inner synchronization lock
  helper = h;
  }
 }
 return helper;
 }
 // other functions and members...
}

这段代码把Helper对象的构造放在一个内部的同步块,又用了一个局部变量h来先接收初始化完成后的引用,直觉就是当这个内部的同步块退出时,应该会触发一次内存屏障,能阻止对初始化Helper对象和给Foo的helper字段赋值的两个操作重排序。不幸的是,直觉是完全错误的,对同步规则理解得不对。对于monitorexit规则(即,释放同步),监视器被释放之前必须执行monitorexit之前的动作。然而,没有规定说monitorexit后的操作,不能在监视器释放前执行。编译器把赋值语句helper = h;移动到内部同步块之前是完全合理合法的,在这种情况下,我们又重新回到了以前。许多处理器提供执行这种单向内存屏障指令。改变语义要求释放锁是一个完整的内存屏障会有性能损失。然而即使初始化时有一个完整的内存屏障,也不能保证,在一些系统上,保证线程能看到helper的属性字段的值为非null也需要同样的内存屏障。因为处理器有自己的本地缓存拷贝,某些处理器在执行缓存一致性指令前,即使其他的处理器使用内存屏障强制把最新值写入主存,该处理器读到的还是本地缓存拷贝的旧值。

关于重排序(reorder)有3种来源:编译器、处理器、内存系统。承诺“write-once, run-anywhere concurrent applications in Java” 的Java是接受处理器和内存系统为优化而重排序的,所以DCL单例模式没有完美的解决方案,在多线程下编程要异常小心。下面讨论多线程环境下单例模式的实现。

多线程环境下单例的实现

第一种,同步方法(synchronized)

优点:所有情况下都能正常工作,延迟初始化;

缺点:同步严重损耗了性能,因为只有第一次实例化时才需要同步。

不推荐,绝大部分情况是没必要延迟初始化的,不如采用急切实例化(eager initialization)

// Correct multithreaded version
class Foo {
 private Helper helper = null;
 public synchronized Helper getHelper() {
 if (helper == null)
  helper = new Helper();
 return helper;
 }
 // other functions and members...
}

第二种,使用IODH(Initialization On Demand Holder)

利用static块做初始化,如下定义一个私有的静态类去做初始化,或者直接在静态块代码中去做初始化,能保证对象被正确构造前对所有线程不可见。

class Foo {
 private static class HelperSingleton {
 public static Helper singleton = new Helper();
 }
 public Helper getHelper() {
 return HelperSingleton.singleton;
 }
 // other functions and members...
}

第三种,急切实例化(eager initialization)

class Foo {
 public static final Helper singleton = new Helper();
 // other functions and members...
}
class Foo {
 private static final Helper singleton = new Helper();
 public Helper getHelper() {
 return singleton;
 }
 // other functions and members...
}

第四种,枚举单例

public enum SingletonClass {
 INSTANCE;
 // other functions...
}

上面4种方式在所有情况下都能保证正常工作

第五种,只对32位基本类型的值有效

缺陷:对64位的long和double及引用对象无效,因为64位的基本类型的赋值操作不是原子的。利用场景有限。

// Lazy initialization 32-bit primitives
// Thread-safe if computeHashCode is idempotent
class Foo {
 private int cachedHashCode = 0;
 public int hashCode() {
 int h = cachedHashCode;
 if (h == 0) {
  h = computeHashCode();
  cachedHashCode = h;
 }
 return h;
 }
 // other functions and members...
}

第六种,DCL加上volatile语义

旧内存模型(在JDK1.5发行之前)下失效,只能在JDK1.5后使用。

另外不推荐次方法,多核处理器下线程每次写volatile字段都会把工作内存及时刷新到主存,每次读都会从主存获取数据,因为要和主存交换数据,volatile的频繁读写会占用数据总线资源。

// Works with acquire/release semantics for volatile
// Broken under current semantics for volatile
class Foo {
 private volatile Helper helper = null;
 public Helper getHelper() {
 Helper h = helper;
 if (helper == null) {// First check (no locking)
  synchronized (this) {
  h = helper;
  if (helper == null)
   helper = h = new Helper();
  }
 }
 return helper;
 }
}

第七种,不可变对象的单例

对于不可变对象(immutable object)本身是线程安全的,不需要同步,单例实现起来最简单。比如Helper是一个不可变类型,只用用final修饰singleton字段就行:

class Foo {
 private final Helper singleton = new Helper();
 public Helper getHelper() {
 return singleton;
 }
 // other functions and members...
}

缺陷:旧内存模型(在JDK1.5发行之前)下失效,只能在JDK1.5后使用,因为新内存模型对final和volatile语义进行了加强。还有一个问题就是明确什么是不可变对象,如果对不可变对象含义不确定,请不要使用,另外当前是不可变对象不能保证将来此类一直是不可变对象(代码总是在不断修改),慎用!

需要使用单例时,慎用延迟初始化,优先考虑急切实例化(简单优雅,不易出错)

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对我们的支持。如果你想了解更多相关内容请查看下面相关链接

(0)

相关推荐

  • Java多线程实战之单例模式与多线程的实例详解

    1.立即加载/饿汉模式 // 立即加载/饿汉模式 public class MyObject { private static final MyObject myObject = new MyObject(); private MyObject() { } public static MyObject getInstance() { return myObject; } } 立即加载/饿汉模式是在类创建的同时已经创建好一个静态的对象供系统使用,不存在线程安全问题 2.延迟加载/懒汉模式 // 延

  • JAVA多线程并发下的单例模式应用

    单例模式应该是设计模式中比较简单的一个,也是非常常见的,但是在多线程并发的环境下使用却是不那么简单了,今天给大家分享一个我在开发过程中遇到的单例模式的应用. 首先我们先来看一下单例模式的定义: 一个类有且仅有一个实例,并且自行实例化向整个系统提供. 单例模式的要素: 1.私有的静态的实例对象 2.私有的构造函数(保证在该类外部,无法通过new的方式来创建对象实例) 3.公有的.静态的.访问该实例对象的方法 单例模式分为懒汉形和饿汉式 懒汉式: 应用刚启动的时候,并不创建实例,当外部调用该类的实例

  • java 单例模式(饿汉模式与懒汉模式)

    java 单例模式 饿汉式单例 对于饿汉模式,我们可这样理解:该单例类非常饿,迫切需要吃东西,所以它在类加载的时候就立即创建对象. 懒汉式单例类 对于懒汉模式,我们可以这样理解:该单例类非常懒,只有在自身需要的时候才会行动,从来不知道及早做好准备.它在需要对象的时候,才判断是否已有对象,如果没有就立即创建一个对象,然后返回,如果已有对象就不再创建,立即返回. 单例设计模式常用于JDBC链接数据库 注意: 1 我们常用的是第一种饿汉式,因为: (1)既然采用了单例设计模式,就是为了使用单例类的对象

  • Java单例模式下的MongoDB数据库操作工具类

    本文实例讲述了Java单例模式下的MongoDB数据库操作工具类.分享给大家供大家参考,具体如下: 我经常对MongoDB进行一些基础操作,将这些常用操作合并到一个工具类中,方便自己开发使用. 没用Spring Data.Morphia等框架是为了减少学习.维护成本,另外自己直接JDBC方式的话可以更灵活,为自己以后的积累留一个脚印. JAVA驱动版本: <!-- MongoDB驱动 --> <dependency> <groupId>org.mongodb</g

  • 9种Java单例模式详解(推荐)

    单例模式的特点 一个类只允许产生一个实例化对象. 单例类构造方法私有化,不允许外部创建对象. 单例类向外提供静态方法,调用方法返回内部创建的实例化对象.  懒汉式(线程不安全) 其主要表现在单例类在外部需要创建实例化对象时再进行实例化,进而达到Lazy Loading 的效果. 通过静态方法 getSingleton() 和private 权限构造方法为创建一个实例化对象提供唯一的途径. 不足:未考虑到多线程的情况下可能会存在多个访问者同时访问,发生构造出多个对象的问题,所以在多线程下不可用这种

  • 详解java中的6种单例写法及优缺点

    在java中,单例有很多种写法,面试时,手写代码环节,除了写算法题,有时候也会让手写单例模式,这里记录一下单例的几种写法和优缺点. 1.初级写法 2.加锁 3.饿汉式 4.懒汉式 5.双锁检验 6.内部类 1.初级写法 package com.java4all.test6; /** * Author: yunqing * Date: 2018/8/13 * Description:单例模式 -- 初级 */ public class Singleton { private static Sing

  • java使用静态关键字实现单例模式

    本文为大家分享了使用静态关键字实现单例模式的具体代码,供大家参考,具体内容如下 单例模式:只能获得某个类的唯一一个实例 单例模式,不管什么时间点得到的对象都是同一个对象 看下面代码: /** * 单例模式 * @author xiongda * @date 2018年4月15日 */ public class SingletonMode { private static SingletonMode single =null; public int number = 1; //将构造方法定义为私有

  • Java静态内部类实现单例过程

    这篇文章主要介绍了Java静态内部类实现单例过程,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 枚举实现单例 线程安全,调用效率高,不能延时加载,可以天然的防止反射和反序列化调用 public enum SingletonFactory { //枚举元素本身就是单例 INSTANCE; //添加自己需要的操作 public SingletonObject getInstance(){ return new SingletonObject();

  • Java双重检查加锁单例模式的详解

    什么是DCL DCL(Double-checked locking)被设计成支持延迟加载,当一个对象直到真正需要时才实例化: class SomeClass { private Resource resource = null; public Resource getResource() { if (resource == null) resource = new Resource(); return resource; } } 为什么需要推迟初始化?可能创建对象是一个昂贵的操作,有时在已知的运

  • Java中的双重检查(Double-Check)详解

    在 Effecitve Java 一书的第 48 条中提到了双重检查模式,并指出这种模式在 Java 中通常并不适用.该模式的结构如下所示: public Resource getResource() { if (resource == null) { synchronized(this){ if (resource==null) { resource = new Resource(); } } } return resource; } 该模式是对下面的代码改进: public synchron

  • Java设计模式之单例模式示例详解

    目录 0.概述 1.饿汉式 1.1 饿汉式单例实现 1.2 破坏单例的几种情况 1.3 预防单例的破坏 2.枚举饿汉式 2.1 枚举单例实现 2.2 破坏单例 3.懒汉式 4.双检锁懒汉式 5.内部类懒汉式 6.JDK中单例的体现 0.概述 为什么要使用单例模式? 在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池.缓存.对话框.注册表.日志对象.充当打印机.显卡等设备驱动程序的对象.事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常.

  • java 多线程与并发之volatile详解分析

    目录 CPU.内存.缓存的关系 CPU缓存 什么是CPU缓存 为什么要有多级CPU Cache Java内存模型(Java Memory Model,JMM) JMM导致的并发安全问题 可见性 原子性 有序性 volatile volatile特性 volatile 的实现原理 总结 CPU.内存.缓存的关系 要理解JMM,要先从计算机底层开始,下面是一份大佬的研究报告 计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的!如果我们计算一次a+b所需要的的时间: CPU读取内存获得a,1

  • Java基础面试题之volatile详解

    目录 1.volatile保证可见性 1.1.什么是JMM模型? 1.2.volatile保证可见性的代码验证 1.2.1.无可见性代码验证 1.2.1.volatile保证可见性验证 2.volatile不保证原子性 2.1 什么是原子性? 2.2 不保证原子性的代码验证 2.3 volatile不保证原子性的解决方法 2.3.1 方法1:使用synchronized 2.3.2 方法1:使用JUC包下的AtomicInteger 3.volatile禁止指令重排 3.1 什么是指令重排? 3

  • Java多线程之哲学家就餐问题详解

    一.题目 教材提供一个哲学家就餐问题的解决方案的框架.本问题要求通过pthreads 互斥锁来实现这个解决方案. 哲学家 首先创建 5 个哲学家,每个用数字 0~4 来标识.每个哲学家作为一个单独的 线程运行. 可使用 Pthreads 创建线程.哲学家在思考和吃饭之间交替.为了模拟这两种活动,请让线程休眠 1 到 3 秒钟.当哲学家想要吃饭时,他调用函数: pickup_forks(int philosopher _number) 其中,philosopher _number 为想吃饭哲学家的

  • Java内存模型之happens-before概念详解

    简介 happens-before是JMM的核心概念.理解happens-before是了解JMM的关键. 1.设计意图 JMM的设计需要考虑两个方面,分别是程序员角度和编译器.处理器角度: 程序员角度,希望内存模型易于理解.易于编程.希望是一个强内存模型. 编译器和处理器角度,希望减少对它们的束缚,以至于编译器和处理器可以做更多的性能优化.希望是一个弱内存模型. ​因此JSR-133专家组设计JMM的核心目标就两个: 为程序员提供足够强的内存模型对编译器和处理器的限制尽可能少 ​下面通过一段代

  • java synchronized的用法及原理详解

    目录 为什么要用synchronized 使用方式 字节码语义 对象锁(monitor) 锁升级过程 为什么要用synchronized 相信大家对于这个问题一定都有自己的答案,这里我还是要啰嗦一下,我们来看下面这段车站售票的代码: /** * 车站开两个窗口同时售票 */ public class TicketDemo { public static void main(String[] args) { TrainStation station = new TrainStation(); //

  • Java中注解与原理分析详解

    目录 一.注解基础 二.注解原理 三.常用注解 1.JDK注解 2.Lombok注解 四.自定义注解 1.同步控制 2.类型引擎 一.注解基础 注解即标注与解析,在Java的代码工程中,注解的使用几乎是无处不在,甚至多到被忽视: 无论是在JDK源码或者框架组件,都在使用注解能力完成各种识别和解析动作:在对系统功能封装时,也会依赖注解能力简化各种逻辑的重复实现: 基础接口 在Annotation的源码注释中有说明:所有的注解类型都需要继承该公共接口,本质上看注解是接口,但是代码并没有显式声明继承关

  • Go 语言单例模式示例详解

    目录 简单单例模式 加锁的单例模式 双check 的单例模式 sync.Once 的单例模式 简单单例模式 单例模式是创建类型的模式,它是为了保证执行期间内只有一个实例.使用 Golang 指针可以很容易的实现单例模式,通过指针保持相同的引用. package singleton type singleton struct{} var instance = &singleton{} func getSingleton() *singleton { return instance } 可以看到整个

随机推荐