Java 并发编程艺术 第三章 Java 内存模型

零散笔记 1 - 锁释放和获取的内存语义

  1. 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
  2. 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被 Monitor 保护的临界区代码必须从主内存中读取共享变量。

对比锁释放-获取的内存语义与 Volatile 写-读的内存语义可以看出:

  1. 锁释放与 Volatile 写有相同的内存语义。
  2. 锁获取与 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 在类的初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁还确保这个类已经被初始化过了)。
  1. 线程A和B 同时竞争锁,假设 A 成功。因为 A 成功,B 将一直等待获取锁。
  2. A 看到对象还未被初始化,因为获取到 state == noInitialization,线程设置 state = initializing
  3. 线程 A 释放锁。
步骤2:线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待(假设这里 A 线程执行了上面的三行伪代码,虽然 2 和 3 重排序了,但是其他线程看不到)。
  1. 线程 A 执行类的初始化
  2. 线程 B 获取到锁
  3. 读取到 state == initializing
  4. 释放初始化锁
  5. 在初始化锁的 condition 上等待
步骤3:线程 A 初始化完毕,设置 state = initialized,然后唤醒在 condition 中等待的所有线程
  1. 线程 A 获取到初始化锁
  2. 设置 state = initialized
  3. 唤醒在 condition 中等待的所有线程
  4. 释放初始化锁
  5. 线程 A 的初始化处理过程完成
步骤4:线程 B 结束类的初始化处理
  1. 线程 B 获取到锁
  2. 读取到 state == initialized
  3. 释放初始化锁
  4. 线程 B 的类初始化处理过程完成
步骤5:线程 C 执行类的初始化处理
  1. 线程 C 获取初始化锁
  2. 读取到 state == initialized
  3. 释放初始化锁
  4. 线程 C 的类初始化处理过程完成

你可能感兴趣的:(Java 并发编程艺术 第三章 Java 内存模型)