深入理解Java中的双重检查锁DCL

882 阅读10分钟

深入理解 Java 中的双重检查锁 DCL

本文梳理了 DCL 的多种实现,并尽量只从 "先行发生" happens-before 角度考虑某些 DCL 实现为什么失效,同时列举了常见的 DCL 变形,如 final 实现,ThreadLocal 实现,Atomic 实现等。涉及大量知识,看完本文定让你大呼过瘾。

正确理解 happens-before 操作

虽然很多书上提到了 happens-before 可以在单一线程中理解为顺序操作,这也很符合直觉。但是这并不是 happens-before 的本意,happens-before 只能保证两次操作间的写 -> 读是否可见。如果理解为程序执行具备了先后顺序,就会产生很多错误理解。同时不要理解为全局有序,不要理解为代码执行具有顺序一致性,而是需要从两次操作的角度考虑有序性。

请看一下示例,自己判断一下打印结果的几种可能。

class Obj {
  int x;
  int y;
  // thread1: 
  void foo1() {
    x = 1;
    y = 2;
  }
  // thread2:
  void foo2() {
    System.out.println(y);
    System.out.println(x);
  }
}
​

两个线程分别执行 foo1 和 foo2 方法,可能打印出的结果有:(0, 0), (1, 0), (1, 2), (2, 0)

这里如果按照实现先后的 happens-before 理解的话,(2, 0)是绝对不可能出现的,因为执行 x 的赋值语句 happens-before y 的赋值语句,当 y 为 2 的时候,x 显然已经执行了,所以 x 不可能为 0

实际上,即使使用 volatile 修饰 x,(2, 0)仍可能出现。

使用 JCStress 测试如下:

@JCStressTest
@Outcome(id = {"2, 0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "also ok")
@Outcome(id = {"2, 1", "0, 0", "0, 1"}, expect = Expect.ACCEPTABLE, desc = "ok")
@State
public class HBTest {
    volatile int x;
    int y;
​
    @Actor
    public void foo1() {
        x = 1;
        y = 2;
    }
​
    @Actor
    public void foo2(II_Result r) {
        r.r1 = y;
        r.r2 = x;
    }
}

image-20240607145145920.png 如上图所示,出现了结果(2, 0)。

总结 JLS 中规定的 happens-before

这里使用 x, y 表示操作,hb 表示 happens-before 关系。

线程相关:

  1. 同一线程中 hb(x, y)
  2. 线程 start() happens-before 于线程中的操作
  3. 线程 t 中所有操作,happens-before 于其他线程的 t.join 操作

并发支持相关:

  1. volatile 写操作 happens-before 于读操作
  2. x 和 y 在同一个同步块(synchronized)中时,hb(x, y)
  3. 同一个 monitor 的解锁操作 happens-before 之后的读操作

对象相关:

  1. 构造器结束 happens-before 于对象的 finalize
  2. 默认初始化 happens-before 于对象的其他操作

传递性:

hb(x, y), hb(y, z) => hb(x, z)

除此之外,还对 final 语义进行了规范。简单来说,如果对象的属性 x 被 final 修饰,在初始化后(排除使用反射的情况)会对 final 字段进行 freeze 操作。对于任意线程,如果引用不为空,obj.x 必为初始值。注意,静态字面量常量在类加载的准备阶段结束后值就已经确定了。

hb 提供了一种思考代码执行的思路,多数情况下,可以简化思考,让我们不需要考虑指令重排、可见性等底层问题,之后的分析,我们将依赖于 hb,在其无法解决问题时,再来考虑更底层的具体实现。

DCL 最优写法

双重检查锁 DCL(double-checked lock) 是实现懒计算或者延迟初始化的常用方法,在 JDK1.5 之前,volatile 不能保证对象属性的可见性,1.5 之后通过在 volatile 操作前后添加内存屏障,保证了对象的可见性。

final class SingetonDemo {
  private Singleton singleton;
​
  public Singleton getSingleton() {
    if (singleton == null) { // 1
      singleton = new Singleton(); // 2
    }
    return singleton; // 3
  }
}

以上代码是实现单例对象的常见错误实现,通常我们认为调用 new Helper()方法可能返回未完全初始化的对象。实际上方法 getSingleton 还可能返回 null, 因为不能保证 2 处执行方法返回值 happens-before 于 3 处对于 singleton 的读操作。

final class LazyCalc
  private FieldType field;
  FieldType getField() {
    if (field == null) {
      synchronized (this) {
        if (field == null)
          field = computeFieldValue(); // 3
      }
    }
    return field; // 4
  }
}
​

如果不使用 volatile 修饰字段,getField 返回结果可能为 null。代码 2 保证了 hb,原因在于 synchronized 保证了内部的 hb,因为处于同步块中,其他线程对于 field 的写对于当前线程中的读操作可见。代码 4 无法读到 field 的最新值,因为其不在同步块中,而在同一线程中也不存在写操作。

安全的方法是使用 volatile 修饰字段和 synchronized 保证每次一个线程进行计算或者创建对象。

private volatile FieldType field;
FieldType getField() {
    FieldType result = field; // 1
    if (result == null) {
        synchronized(this) {
            result = field; // 2
            if (result == null)
                field = result = computeFieldValue(); // 3
        }
    }
    return result; // 4
}

这是一个最优实现,使用了 result 局部变量,清楚地表示了对 field 的访问,1, 2 处代表对字段的读操作,3 代表写操作,4 读取局部变量。相比于不使用局部变量,减少了一次 volatile 读操作。

使用 final 语义实现 DCL

根据之前描述的 final 语义规范,对象不为空是,读取到的 final 字段必为初始化值,可以使用 final 语义实现 DCL。

record SingletonHolder(Singleton s) {}
​
public class Foo {
  private SingletonHolder holder = null;
  public Singleton getInstance() {
    SingletonHolder h = holder; // 1
    if (h == null) {
      synchronized (this) {
        h = holder; // 2
        if (h == null) {
          holder = h = new SingletonHolder(new Singleton());
        }
      }
    }
    return h.s();
  }
}

注意以上代码 1 处读取操作不保证 hb 于其他线程的写操作,但是 2 处位于同步块中确保读取到了 holder 的最新值,因此可以保证线程安全。

其他延迟初始化的实现

1. Initialize-on-Demand

JVM 规范要求: All implementations must initialize each class or interface on its first active use.

根据虚拟机保证对于静态变量初始化实现了安全发布,同时类加载遵循懒加载的原则,可以保证使用静态包装类实现懒加载。这个方法的优点是简单,方便实现,代码不易出错,不要求 JDK 版本,缺点是无法延迟初始化对象的非静态字段。

public class HolderFactory {
  public static Singleton get() {
    return Holder.instance;
  }
​
  private static class Holder {
    public static final Singleton instance = new Singleton();
  }
}

2. 使用 AtomicReference 包装引用

Lombok 中 lazy@getter 使用的这种方法,原理和普通的 DCL 类似,区别是使用 Atomic 类实现可见性。

class AtomicRefDcl {
    @Getter(lazy = true)
    private final String[] s = calculate();
​
    String[] calculate() {
        return new String[0];
    }
}

编译后的类如下:

class AtomicRefDcl {
    private final AtomicReference<Object> s = new AtomicReference();
​
    AtomicRefDcl() {
    }
​
    String[] calculate() {
        return new String[0];
    }
​
    public String[] getS() {
        Object $value = this.s.get();
        if ($value == null) {
            synchronized(this.s) {
                $value = this.s.get();
                if ($value == null) {
                    String[] actualValue = this.calculate();
                    $value = actualValue == null ? this.s : actualValue;
                    this.s.set($value);
                }
            }
        }
​
        return (String[])($value == this.s ? null : $value);
    }
}

Lombok 支持缓存结果为空的情况,使用 AtomicReference 自己作为标识区分,这是处理空指针的一种技巧。

3. 枚举类

使用枚举类,类似于第一种方法,可以实现类的延迟初始化。

public enum enumClazz{
   INSTANCE
   enumClazz(){
     //do something
   }
}

4. 对于非 double、long 的基本类型,无需使用 voatile

以 int 为例,不像对象可以发生引用溢出,对于方法 int 的返回值不可能为未完全发布值。

无锁

对于上述第 4 条,如果对于临界区数据不竞争不激烈的场景,可以使用无锁实现。使用无锁后,即使多线程同时执行,也可保证结果的正确性,缺点是可能进行了多次重复计算。

String 虽然是一个不变类,但是其却不止一种状态,只不过对于外部使用者来说可以视为一种。String 中的 hash 字段缓存 hash 值,在未计算时为 0。

class String {
  @Stable
  private final byte[] value;
​
  private final byte coder;
  /** Cache the hash code for the string */
  private int hash; // Default to 0
​
  private boolean hashIsZero; // Default to false;
​
  public int hashCode() {
    // The hash or hashIsZero fields are subject to a benign data race,
    // making it crucial to ensure that any observable result of the
    // calculation in this method stays correct under any possible read of
    // these fields. Necessary restrictions to allow this to be correct
    // without explicit memory fences or similar concurrency primitives is
    // that we can ever only write to one of these two fields for a given
    // String instance, and that the computation is idempotent and derived
    // from immutable state
    int h = hash;
    if (h == 0 && !hashIsZero) {
      h = isLatin1() ? StringLatin1.hashCode(value)
        : StringUTF16.hashCode(value);
      if (h == 0) {
        hashIsZero = true;
      } else {
        hash = h;
      }
    }
    return h;
  }
}

实际上,对于不可变类,其内部的不可变状态(未计算状态 -> 已计算状态)也可以在运行时计算,无需互斥同步也可以保证结果的正确性。如 guava 中 ImmutableListMultimap 的 inverse 实现:

@ElementTypesAreNonnullByDefault
public class ImmutableListMultimap<K, V> extends ImmutableMultimap<K, V>
  implements ListMultimap<K, V> {
  @LazyInit @RetainedWith @CheckForNull private transient ImmutableListMultimap<V, K> inverse;
​
  @Override
  public ImmutableListMultimap<V, K> inverse() {
    ImmutableListMultimap<V, K> result = inverse;
    return (result == null) ? (inverse = invert()) : result;
  }
​
  private ImmutableListMultimap<V, K> invert() {
    Builder<V, K> builder = builder();
    for (Entry<K, V> entry : entries()) {
      builder.put(entry.getValue(), entry.getKey());
    }
    ImmutableListMultimap<V, K> invertedMultimap = builder.build();
    invertedMultimap.inverse = this; // 避免重复计算
    return invertedMultimap;
  }
}

推荐看看@LazyInit 注解的说明文档, 可以将这个注解用于自己的代码中。

安全发布

安全发布指的是对象创建的初始化结果对于之后的所有读操作具有可见性。JMM 并未规定这个 happens-before 规则,需要自己实现。总结本文使用的规则和例子,常见的安全发布方法有:

  1. 使用 volatile 或者 Atomic 类
  2. 使用同步块读取初始化结果
  3. 使用 final 语义
  4. 使用静态初始化

ThreadLocal 实现懒加载

class Foo {
  /** If perThreadInstance.get() returns a non-null value, this thread
    has done synchronization needed to see initialization
    of helper */
  private final ThreadLocal perThreadInstance = new ThreadLocal();
  private Helper helper = null;
  public Helper getHelper() {
    if (perThreadInstance.get() == null) createHelper();
    return helper; // 1
  }
  private final void createHelper() {
    synchronized(this) {
      if (helper == null)
        helper = new Helper();
    }
    // Any non-null value would do as the argument here
    perThreadInstance.set(perThreadInstance);
  }
}

这种方法也可以保证 singleton 的可见性,threadLocal 是线程安全的,当线程读到 threadLocal 不为空时,必执行过至少一次同步块,若为其他线程创建单例对象,根据同步块间 hb 原则,当本线程进入同步块可以保证可见性;若为当前线程创建单例对象,根据线程内的 hb 可以保证可见性。考虑代码 1 处的读操作,虽然其读操作在同步块外,由于读取前本线程内必然进行了一次 threadLocal:: set 操作,也就是说必然执行过一次同步块,保证了读取的 helper 必然为最新值。

同时,这也是为数不多的使用非静态 threadLocal 的例子,看来 threadLocal 使用弱引用 key 还是有必要的。对于 threadLocal 的分析请见我后续分析。

Immuatables 框架中的实现

@Value.Immutable
abstract class Point{
    abstract double x();
    abstract double y();
​
    @Value.Lazy
    double r() {
        return Math.sqrt(x() * x() + y() * y());
    }
}

编译后生成后的不可变实现类如下:

@ParametersAreNonnullByDefault
@CheckReturnValue
@Generated(from = "Point", generator = "Immutables")
@Immutable
final class ImmutablePoint extends Point {
  private final double x;
  private final double y;
  private transient volatile long lazyInitBitmap;
  private static final long R_LAZY_INIT_BIT = 1L;
  private transient double r;
  
  double r() {
    if ((this.lazyInitBitmap & 1L) == 0L) {
      synchronized(this) {
        if ((this.lazyInitBitmap & 1L) == 0L) {
          this.r = super.r();
          this.lazyInitBitmap |= 1L;
        }
      }
    }
​
    return this.r; // 1
  }
  // 略去其他
}

其使用了 lazyInitBitmap 字段表示字段是否已经初始化,实际上还是基于 volatile 的 DCL,优点是如果存在多个需要懒计算的字段,只需要使用一个 long 标识是否计算过。

这里 1 处对于 r 的读操作必然读到最新值,因为之前不存在非同步块的读操作,且对于 r 的写操作在对标识字段 lazyInitBitmap 之前,标识字段代码后加入了写屏障,保证了之后的读取 r 为最新值。也可以通过 happens-before 推导得出 r 的写对于读可见。

总结与启示

  1. 在 Java1.5 发布之前,常见的 DCL 是不安全的,而且很多改良方法都无法保证线程安全。
  2. 我们应该使用推荐的最佳实践。Effective Java 和 Concurency Programming in Java 都不推荐使用 DCL。推荐使用成熟的机制,成熟的类库,成熟的框架。
  3. 并发编程很难,很难理解。包括 Doug Lea 也是这么说的。本人的体会是越学习,越感到不懂的内容有很多。
  4. 代码很难测试,有的问题很难出现,只能在不同的机器上测出。

阅读参考

  1. Double-checked locking
  2. The "Double-Checked Locking is Broken" Declaration
  3. Correct and Efficient Synchronization of Java ™ Technologybased Threads
  4. LCK10-J. Use a correct form of the double-checked locking idiom
  5. The Java Memory Model
  6. JSR 133 (Java Memory Model) FAQ
  7. Safe Publication and Safe Initialization in Java

附:以下是测试非安全 DCL 返回 null 代码,在我的笔记本上没有跑出为空指针的结果,感兴趣的读者可以在自己的机器上试试。

@JCStressTest
@Outcome(id = {"false, false"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = {"false, true"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "return null")
@Outcome(id = {"true, false"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "return null")
@Outcome(id = {"true, true"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "return null")
@State
public class LazyInitTest {
    private static class Singleton {}
    private Singleton singleton;

    Singleton getSingleton() {
        if (singleton == null) {
            synchronized (this) {
                if (singleton == null)
                    singleton = new Singleton(); // 3
            }
        }
        return singleton; // 4
    }

    @Actor
    void f1(ZZ_Result r) {
        r.r1 = getSingleton() == null;
    }

    @Actor
    void f2(ZZ_Result r) {
        r.r2 = getSingleton() == null;
    }
}