CodeQL 文档

双重检查锁定对象初始化中的竞争条件

ID: java/unsafe-double-checked-locking-init-order
Kind: problem
Security severity: 
Severity: warning
Precision: high
Tags:
   - reliability
   - correctness
   - concurrency
   - external/cwe/cwe-609
Query suites:
   - java-security-and-quality.qls

点击查看 CodeQL 仓库中的查询

双重检查锁定是一种常见的模式,用于延迟初始化由多个线程访问的字段。但是,根据底层运行时的内存模型,正确实现它可能很困难,因为编译器、运行时或 CPU 执行的重新排序可能会将未初始化或半初始化的对象暴露给其他线程。从版本 5 开始,Java 改进了其内存模型,以支持双重检查锁定,前提是底层字段标记为 volatile 并且所有初始化都在 volatile 写入之前完成。

建议

首先,应考虑执行延迟初始化的 getter 是否对性能至关重要。如果不是,则更简单的解决方案是完全避免双重检查锁定,只需将整个 getter 标记为 synchronized。这更容易正确实现,并且可以防止难以发现的并发错误。

如果使用双重检查锁定,则重要的是底层字段必须为 volatile 并且对该字段的更新必须是同步区域中发生的最后一件事,也就是说,必须在分配字段之前完成所有初始化。此外,Java 版本必须为 5 或更高版本。读取 volatile 字段会有轻微的开销,因此使用局部变量来减少 volatile 读取次数也很有用。

示例

以下代码将 f 延迟初始化为 new MyObject()

private Object lock = new Object();
private MyObject f = null;

public MyObject getMyObject() {
  if (f == null) {
    synchronized(lock) {
      if (f == null) {
        f = new MyObject(); // BAD
      }
    }
  }
  return f;
}

这段代码不是线程安全的,因为另一个线程可能会在构造函数完成评估之前看到对 f 的赋值,例如,如果编译器内联了内存分配和构造函数,并重新排序了对 f 的赋值,使其发生在内存分配之后。

另一个即使使用 volatile 也不是线程安全的示例是,如果在对 f 赋值之后发生了额外的初始化,因为在这种情况下,其他线程可能会在构造对象完全初始化之前访问它,即使编译器或运行时没有进行任何重新排序。

private Object lock = new Object();
private volatile MyObject f = null;

public MyObject getMyObject() {
  if (f == null) {
    synchronized(lock) {
      if (f == null) {
        f = new MyObject();
        f.init(); // BAD
      }
    }
  }
  return f;
}

上面的代码应该被改写为既使用 volatile 又在 f 被更新之前完成所有初始化。此外,可以使用局部变量来避免不必要地多次读取字段。

private Object lock = new Object();
private volatile MyObject f = null;

public MyObject getMyObject() {
  MyObject result = f;
  if (result == null) {
    synchronized(lock) {
      result = f;
      if (result == null) {
        result = new MyObject();
        result.init();
        f = result; // GOOD
      }
    }
  }
  return result;
}

最后,如果构造的对象是不可变的(即,对象将所有字段声明为 final),并且双重检查的字段只在同步块之外读取一次,那么就可以在没有 volatile 的情况下正确地使用双重检查锁定。

鉴于 MyImmutableObject 中的所有字段都被声明为 final,那么以下示例可以防止将未初始化的字段暴露给另一个线程。但是,由于对 f 有两次读取而没有同步,因此这些读取可能会被重新排序,这意味着此方法可能返回 null

private Object lock = new Object();
private MyImmutableObject f = null;

public MyImmutableObject getMyImmutableObject() {
  if (f == null) {
    synchronized(lock) {
      if (f == null) {
        f = new MyImmutableObject();
      }
    }
  }
  return f; // BAD
}

在这种情况下,使用局部变量来最小化字段读取次数不再是性能提升,而是一个确保正确性的关键细节。

参考资料

  • ©GitHub, Inc.
  • 条款
  • 隐私