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

时间:2019-04-11 13:42:18

标签: java concurrency thread-safety atomicity

我有一个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字段还允许程序员无需同步即可实现线程安全的不可变对象。线程安全的不可变对象被所有线程视为不可变的,即使数据争用用于在线程之间将引用传递给不可变对象。

并且由于char value[]的{​​{1}}字段确实是String

({final不是int hash,但它的惰性初始化看起来也不错。)

编辑:进行编辑以澄清用于final的值是固定值。它的计算是幂等的,没有副作用。我不介意是否跨线程重复计算,或者BAR是否由于内存缓存/可见性而实际上变成了线程局部的。我担心的是,如果它不是非null,那么它的值是完整的而不是不完整的。

2 个答案:

答案 0 :(得分:2)

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

String是正确实现的不可变类型,您所说的final字段是正确的。但这不是线程安全问题所在。

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

第二个问题是存在记忆危险。由于一个线程对BAR的写操作与另一个线程对BAR的后续读操作之间没有先发生关系,因此不能保证第二个线程将看到初始化值BAR中的。因此,它可能会重复初始化。

请注意,在编写的示例 中,这两个问题不是实际的线程安全问题。您正在执行的初始化是幂等。可以多次初始化BAR的代码的行为没有什么区别,因为您始终将其初始化为对同一String对象的引用。 (单个冗余初始化的代价太小了,不必担心。)

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

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

您声明BAR的想法是为了解决内存危险,而不是竞争条件。


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

  

编辑以澄清用于BAR的值是固定值。它的计算是幂等的,没有副作用。我不介意是否跨线程重复计算,或者BAR是否由于内存缓存/可见性而实际上变成了线程局部的。我担心的是,如果它不是非null,那么它的值是完整的而不是不完整的。

这没有改变我上面所说的。如果值是String,则它是正确实现的不可变对象,并且您将始终看到完整值(与其他任何内容无关)。这就是JLS报价所说的!

(实际上,我掩饰了String使用非final字段来保存延迟计算的哈希码的细节。但是,String::hashCode实现照顾到了。没有线程安全问题。如果需要,请自己检查。)

答案 1 :(得分:1)

您的代码不是线程安全的。看来您可能正在考虑仔细检查锁定模式。正确的模式如下所示:

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. 如果BAR为空,那么我们进入synchronized块,我们必须再次检查BAR是否为空,以便我们可以自动进行检查和赋值。如果我们不进行原子检查,那么BAR可能会被多次初始化。

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

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

最后我想指出一点,因为"Hello World!"已经是一个字符串对象,尝试“延迟加载”它没有多大意义。字符串是在加载类时由JVM创建的,因此在您的方法运行时,甚至在第一次读取BAR时,它们就已经存在。在这种情况下,只有一个字符串,尝试“延迟加载”该字符串是没有好处的。

public class Foo {

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