当前位置: 首页 > 编程笔记 >

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

潘俊楚
2023-03-14
本文向大家介绍Java双重检查加锁单例模式的详解,包括了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语义进行了加强。还有一个问题就是明确什么是不可变对象,如果对不可变对象含义不确定,请不要使用,另外当前是不可变对象不能保证将来此类一直是不可变对象(代码总是在不断修改),慎用!

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

总结

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

 类似资料:
  • 本文向大家介绍双重检查加锁单例 线程安全-Java版相关面试题,主要包含被问及双重检查加锁单例 线程安全-Java版时的应答技巧和注意事项,需要的朋友参考一下 双重检验加锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 ,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线

  • 这是我为单例模式的自定义类。在这段代码中,我使用双重检查锁定,如下所示。当我在一些源代码上读到许多帖子时,他们说双重检查很有用,因为它可以防止两个并发线程同时运行,从而产生两个不同的对象。 我还是不太懂上面的代码。当实例为null时,如果两个线程一起运行同一行代码,问题是什么? 当它出现时。两个线程都将看到对象为null。然后两者同步。然后,他们再次检查,仍然看到它为空。并创建两个不同的对象。哎呀

  • 问题内容: 我偶然遇到了一篇文章,该文章最近讨论了Java中的双重检查锁定模式及其陷阱,现在我想知道我多年来使用的那种模式的变体是否会遇到任何问题。 我看过许多关于该主题的文章和文章,并了解了对部分构造的对象的引用所带来的潜在问题,据我所知,我认为我的实现不受这些问题的影响。以下模式是否有问题? 而且,如果没有,人们为什么不使用它?在围绕此问题进行的任何讨论中,我从未见过推荐它的方法。 问题答案:

  • 我使用Java大约一个月了,在编程方面仍然是一个一般的业余爱好者,所以如果我有什么不对的地方,请随时纠正我。也许我会提供一些多余的细节,但我现在是如此困惑,我无法决定什么是重要的。 所以,我一直在开发多线程客户机-服务器应用程序。所有线程都在使用同一个对象,其中存储了某些配置值和共享记录器;此对象在server-thread中初始化,然后作为参数传递给client-thread类构造函数。首先,假

  • Java 实现单例模式有方法有双重检测锁,代码如下: 我理解的 synchronized 关键字实现了可见性、原子性和有序性,临界区中的代码可以重排序,但是不能重排序到临界区外面,synchronized 实现的可见性是临界区中代码执行结束之后,里面的共享变量会刷新到主内存中,那么如果 new Singleton() 方法被拆成了三个操作,并且经过重排序之后的顺序是这样的话: 分配内存 将实例引用

  • 本文向大家介绍java 单例模式的实例详解,包括了java 单例模式的实例详解的使用技巧和注意事项,需要的朋友参考一下 java 单例模式的实例详解 概念:    java中单例模式是一种常见的设计模式,单例模式分三种:懒汉式单例、饿汉式单例、登记式单例三种。    单例模式有一下特点:   1、单例类只能有一个实例。   2、单例类必须自己自己创建自己的唯一实例。   3、单例类必须给所有其他对