零散笔记 1 - 锁释放和获取的内存语义
- 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被 Monitor 保护的临界区代码必须从主内存中读取共享变量。
对比锁释放-获取的内存语义与 Volatile 写-读的内存语义可以看出:
- 锁释放与 Volatile 写有相同的内存语义。
- 锁获取与 Volatile 读有相同的内存语义。
零散笔记 2 - 双重检查锁定与延迟初始化
Java 程序中有时需要推迟加载一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。例如说:懒加载的单例模式。
实现如下:
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if(null == instance) { // 1: 线程 A 执行
instance = new Instance(); // 2: 线程 B 执行
}
return instance;
}
}
在上面程序在多线程环境下执行时,假设 A 线程执行代码 1 的同时,B 线程执行代码 2。此时,线程 A
可能会看到 instance 引用的对象还没有完成初始化。
对于 UnsafeLazyInitialization 类,我们可以对 getInstance() 方法做同步处理来实现线程安全的延迟初始化。
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if(null == instance) {
instance = new Instance();
}
return instance;
}
}
由于对 getInstance() 方法做了同步处理,synchronized 将导致性能开销。如果 getInstance() 方法被多个线程频繁的调用,将会导致程序执行性能的下降。
在早期的 JVM 中,synchronized(甚至是无竞争的 synchronized)存在巨大的性能开销(轻量级锁竞争使用自旋 CAS,重量级锁竞争直接阻塞线程)。因此又有了更进一步的优化:双重检查锁定(Double-Checked Locking)。想通过双重检查锁定来降低同步的开销。
public class DoubleCheckedLocking {
private static Instance instance;
public synchronized static Instance getInstance() {
if(null == instance) { // 1: 第一次检查
synchronized(DoubleCheckedLocking.class) { // 2: 加锁
if(null == instance) // 3: 第二次检查
instance = new Instance(); // 4: 问题的根源出在这里
}
}
return instance;
}
}
如上面代码所示,如果第一次检查 instance 不为 null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低 synchronized 带来的性能开销。但问题是,在执行到步骤1,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化。
Double-Checked Locking 非安全的原因
前面 DoubleCheckedLocking 类中的代码,执行到步骤4(instance = new Instance();)创建了一个对象。这一行代码可以分解为下面三行伪代码。
memory = allocate(); // 1: 分配对象的内存空间
storInstance(memory); // 2: 初始化对象
instance = memory; // 3: 设置 instance 指向刚分配的内存地址
上面 3 行伪代码中,2 和 3 之间可能会被重排序。重排序之后执行时序如下。
memory = allocate(); // 1: 分配对象的内存空间
instance = memory; // 3: 设置 instance 指向刚分配的内存地址
// 注意,此时对象还没有被正确的初始化!
storInstance(memory); // 2: 初始化对象
所以,当指令被重排序只有 B 线程将看到一个还没有被初始化的对象。
解决方案 1 - 基于 volatile
public class DoubleCheckedLocking {
private volatile static Instance instance;
public synchronized static Instance getInstance() {
if(null == instance) {
synchronized(DoubleCheckedLocking.class) {
if(null == instance)
instance = new Instance();
}
}
return instance;
}
}
在声明对象引用为 volatile 后,三行伪代码中的 2 和 3 之间的重排序,在多线程的环境下将被禁止(原因是因为 volatile 关键字的内存屏障)。
解决方案 2 - 基于类的初始化
JVM 在类的初始化阶段(即在 Class 被加载后,切被线程使用之前),会执行初始化。在执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。基于这个特性,可以实现另一种线程安全的延迟初始化方案。
public class InstanceFactory {
private static class InstanceHolder {
public static Instacne instance = new Instance();
}
pubilc static Instance getInstance() {
return InstanceHolder.instance;
}
}
这个方案的实质是:允许前面三行伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程 B)“看到” 这个重排序。
具体执行步骤如下(这是个抽象的步骤,因为 JVM 规范中并没有指定必须使用某种方式实现,只需要实现类似的功能即可)。
Java语言规范中定义触发类初始化的五种情况:
- T 是一个类,而且一个 T 类型的实例被创建。
- T 是一个类,且 T 中声明的一个静态方法被调用。
- T 中声明的一个静态字段被赋值。
- T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
- T 是一个顶级类,而且一个断言语句嵌套在 T 内部被执行。
零散笔记 3 - 一个类初始化时的抽象步骤
步骤1:通过在 Class 对象上的同步,来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁(Java 语言规范规定,JVM 在类的初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁还确保这个类已经被初始化过了)。
- 线程A和B 同时竞争锁,假设 A 成功。因为 A 成功,B 将一直等待获取锁。
- A 看到对象还未被初始化,因为获取到 state == noInitialization,线程设置 state = initializing
- 线程 A 释放锁。
步骤2:线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待(假设这里 A 线程执行了上面的三行伪代码,虽然 2 和 3 重排序了,但是其他线程看不到)。
- 线程 A 执行类的初始化
- 线程 B 获取到锁
- 读取到 state == initializing
- 释放初始化锁
- 在初始化锁的 condition 上等待
步骤3:线程 A 初始化完毕,设置 state = initialized,然后唤醒在 condition 中等待的所有线程
- 线程 A 获取到初始化锁
- 设置 state = initialized
- 唤醒在 condition 中等待的所有线程
- 释放初始化锁
- 线程 A 的初始化处理过程完成
步骤4:线程 B 结束类的初始化处理
- 线程 B 获取到锁
- 读取到 state == initialized
- 释放初始化锁
- 线程 B 的类初始化处理过程完成
步骤5:线程 C 执行类的初始化处理
- 线程 C 获取初始化锁
- 读取到 state == initialized
- 释放初始化锁
- 线程 C 的类初始化处理过程完成