《Java并发编程的艺术》第三章·附一——双重检查锁定与延迟初始化

在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本文将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。


双重检查锁定的由来?
在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的实例代码:
这里写图片描述
如上如所示,假设A线程执行代码1的同时,B线程执行代码2.此时,线程A可能会看到instance引用的对象还没有完成初始化。
对于上图代码来说,我们可以改造getInstance()方法做同步处理来实现线程安全的延迟初始化:
这里写图片描述
如上图所示,虽然对getInstance()方法做了同步处理,但如果多个线程频繁调用该方法,则会导致性能的下降。因此,人们想出一个”聪明“的技巧:双重检查锁定。即通过双重检查锁定来降低同步的开销:
这里写图片描述
虽然看起来上图代码即能保证线程安全同时也可以降低同步的开销,但这是一个错误的优化:在线程执行到第4行时,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。


问题的根源?
前面的实例代码第7行,可以分解为以下3行伪代码:
这里写图片描述
上面3行伪代码之间,2和3可能发生重排序。在单线程中并不会出现问题(即使2和3发生了重排序,但2依旧在4之前,2依旧在4之前,由JMM intra-thread semantics:保证重排序不会改变单线程内的程序执行结果。):
这里写图片描述
但如果在多线程中,就会出现问题,时序图如下:
这里写图片描述
如上图所示,如果线程A中,2和3发生重排序,则在线程B中判断instance不为空,并访问对象时,该对象是未完成初始化的。
为了解决上述问题,我们可以用以下方法来实现线程安全的延迟初始化:
- 不允许2和3重排序。(基于volatile的解决方案)
- 允许2和3重排序,但不允许其他线程”看到“这个重排序。(基于类初始化的解决方案)


基于volatile的解决方案
对于上述问题,可以把instance声明为volatile型,就可以实现线程安全的延迟初始化。
这里写图片描述
若instance声明为volatile类型,则会禁止伪代码中2和3的重排序,因为volatile写的内存语义保证,如果第二个操作为volatile写时,无论第一个操作是什么,都不允许进行重排序。
优化后的执行时序为:
这里写图片描述


基于类初始化的解决方案
JVM在类的初始化阶段,会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案(Initialization On Demand Holder idiom)。
这里写图片描述
假设两个线程并发执行getInstance()方法,则执行示意图如下:
这里写图片描述
在上图代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化。在Java语言规范中规定:对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。(JVM具体实现中可能会做一些优化)
Java初始化一个类或接口的处理过程如下(此处省略与本文无关的类初始化处理过程的说明,并人为的分为5个阶段):
第1阶段:通过在Class对象上的同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设Class对象当前还没被初始化(假设初始化状态state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象,对应的示意图及时序表如下:
这里写图片描述
这里写图片描述
第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
对应的示意图及时序表如下:
这里写图片描述
这里写图片描述
第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
对应的示意图及时序表如下:
这里写图片描述
这里写图片描述
第4阶段:线程B结束类的初始化处理。
对应的示意图及时序表如下:
这里写图片描述
这里写图片描述
【备注】:线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化所;线程B在第4阶段的B1获取同一个初始化锁,并在第4阶段的B4之后才开始访问这个类,根据Java内存模型规范的锁规则,存在happens-before关系。happens-before规则保证,线程A执行类的初始化时的写入操作,对线程B可见。
第5阶段:线程C执行类的初始化的处理。
对应的示意图及时序表如下:
这里写图片描述
这里写图片描述
在第3阶段后,类已经完成了初始化。因此线程C在第5阶段的类初始化过程相对简单一些。其目的是确保该类已经被初始化完毕。
【备注】:这里的condition和state标记是虚拟出来的。Java语言规范并没有硬性规定一定要使用condition和state标记。JVM的具体实现只要实现类似功能即可。


通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用基于volatile的延迟初始化方案,如果确实需要对静态字段使用线程安全的延迟初始化,请使用基于类初始化的方案。


基于类的延迟初始化为什么只能对静态字段进行延迟初始化?
类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。虽然JVM初始化类的时机并不确定,具体由JVM去控制,但限定了以下情况会立即进行类初始化:
1.创建类的实例
2.访问类的静态变量。(除常量【被final修辞的静态变量】外:常量一种特殊的变量,因为编译器把他们当作值(value)而不是域(field)来对待。如果你的代码中用到了常变量,编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。这是一种很有用的优化,但是如果你需要改变final域的值那么每一块用到那个域的代码都需要重新编译。)
3.访问类的静态方法。
4.反射。
5.当初始化一个类时,发现其父类还未初始化,则先出发父类的初始化。
6.虚拟机启动时,定义了main()方法的那个类先初始化。
基于类的延迟初始化方案属于第2种情况。在类加载过程中,JVM在准备阶段为类的静态变量在方法区中分配内存并将其初始化为默认值。但不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。所以基于类的延迟初始化方案并不能对实例字段实现延迟初始化。

【备注】:后续会出《深入理解JVM》系列博客,到时会详细讲解类加载过程,有兴趣的童鞋可以持续关注。
【备注】:本文图片均摘自《Java并发编程的艺术》·方腾飞,若本文有错或不恰当的描述,请各位不吝斧正。谢谢!

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值