通过引用访问延迟初始化的非易失性字符串线程安全吗?

Is access by reference to a lazily initialized non-volatile String thread-safe?

我有一个 String 字段,它被初始化为 null 但随后可能被多个线程访问。该值将在第一次访问时延迟初始化为幂等计算值。

这个字段是否需要 volatile 才能线程安全?

这是一个例子。

public class Foo {
    private final String source;
    private String BAR = null;

    public Foo(String source) {
        this.source = source;
    }

    private final String getBar() {
        String bar = this.BAR;
        if (bar == null) {
            bar = calculateHashDigest(source); // e.g. an sha256 hash
            this.BAR = bar;
        }
        return bar;
    }

    public static void main(String[] args) {
        Foo foo = new Foo("Hello World!");
        new Thread(() -> System.out.println(foo.getBar())).start();
        new Thread(() -> System.out.println(foo.getBar())).start();
    }
}

我在示例中使用了 System.out.println(),但我并不担心调用互锁时会发生什么。 (虽然我很确定这也是线程安全的。)

BAR 需要 volatile 吗?

我认为答案是,不需要volatile它是线程安全的,主要是因为这个 excerpt from JLS 17.5:

final fields also allow programmers to implement thread-safe immutable objects without synchronization. A thread-safe immutable object is seen as immutable by all threads, even if a data race is used to pass references to the immutable object between threads.

并且由于 Stringchar value[] 字段确实是 final.

int hash 不是 final 但它的延迟初始化看起来也不错。)

Edit:编辑以阐明 BAR 的值是 fixed 值。它的计算是幂等的,没有副作用。我不介意计算是否跨线程重复,或者如果 BAR 由于内存缓存/可见性而成为有效的线程本地。我担心的是,如果它是非空的,那么它的值是完整的而不是部分的。

您的代码(技术上)不是线程安全的。

String 确实是一个正确实现的不可变类型,而且您所说的有关其 final 字段的内容是正确的。但这不是线程安全问题所在。

第一个问题是 BAR 的惰性初始化中存在竞争条件。如果两个线程同时调用 getBar(),它们都会将 BAR 视为 null,然后都尝试对其进行初始化。

第二个问题是内存问题。由于一个线程对 BAR 的写入与另一个线程对 BAR 的后续读取之间没有 happens-before 关系,因此无法保证第二个线程会看到BAR 的初始值。因此,它可能会重复初始化。

请注意,在示例中所写,这两个问题不是实际的线程安全问题。您正在执行的初始化是 idempotent。这对您可能多次初始化 BAR 的代码的行为没有影响,因为您总是将它初始化为对同一个 String 对象的引用。 (单次冗余初始化的开销太小不用担心。)

但是,如果 BAR 是对可变对象的引用,或者如果初始化开销很大,那么这就是一个真正的线程安全问题。

正如@Ravindra 所说,简单的解决方案是将 getBar 声明为 synchronized。这解决了这两个问题。

您声明 BAR 的想法解决了内存危险,但没有解决竞争条件。


您在问题中添加了以下内容:

Edit to clarify the value intended for BAR is a fixed value. Its calculation is idempotent and has no side-effects. I don't mind if the calculation is repeated across threads, or if BAR becomes effectively a thread-local due to memory-caching / visibility. My concern is, if it's non-null then it's value is complete and not somehow partial.

这并没有改变我上面所说的。如果值是 String,那么它是一个正确实现的不可变对象,您将 总是 看到完整的值 irrespective还要别的吗。这就是 JLS 报价所说的!

(实际上,我掩盖了 String 使用非 final 字段来保存延迟计算的哈希码的细节。但是,String::hashCode实现会处理这个问题。它没有线程安全问题。如果你愿意,自己检查一下。)

您的代码不是线程安全的。看来您可能正在考虑双重检查锁定模式。正确的模式是这样的:

public class Foo {

    private static volatile String BAR = null;

    private static String getBar() {
        String bar = BAR;
        if (bar == null) {
          synchronized( Foo.class )
            if( bar == null ) {
              bar = "Hello World!";
              BAR = bar;
            }
        }
        return bar;
    }
    // ...

这里有两件事。

  1. 如果BAR已经初始化,则不会进入synchronized块。 volatile 在这里是必需的,因为需要一些同步并且 BAR 的读取将与对易失性 BAR.

  2. 的写入同步
  3. 如果 BAR 为 null 那么我们进入 synchronized 块,我们必须再次检查 BAR 是否仍然为 null 因此我们可以进行检查和分配原子。如果我们不进行原子检查,那么 BAR 有可能会被多次初始化。

您引用了 Java 规范。关于 final 关键字。虽然 String 是不可变的并且在内部使用 final 关键字,但这不会影响您的字段 BAR。字符串很好,但是你的字段仍然是共享内存位置,如果你希望它是线程安全的,则需要同步。

另一位发帖人也提到了实习字符串。他们说在这个特定实例中只有一个 "Hello World!" 对象是正确的,因为 JVM 规范保证字符串是驻留的。这是一种奇怪的线程安全形式,不适用于其他对象,因此只有在您确定它可以正常工作时才使用它。您自己创建的大多数对象将无法使用您现在拥有的代码。

最后我想我要指出的是,因为 "Hello World!" 已经是一个字符串对象,所以尝试 "lazy load" 它没有多大意义。字符串是在您的 class 被加载时由 JVM 创建的,因此它们在您的方法是 运行 时已经存在,甚至在 BAR 第一次被读取时就已经存在。在这种情况下,只有一个字符串,尝试 "lazy load" 字符串没有优势。

public class Foo {

    //  probably better, simpler
    private static final String BAR = "Hello World!";