【问题标题】:Is access by reference to a lazily initialized non-volatile String thread-safe?通过引用延迟初始化的非易失性字符串进行访问是线程安全的吗?
【发布时间】:2019-04-11 13:42:17
【问题描述】:

我有一个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 字段还允许程序员在不同步的情况下实现线程安全的不可变对象。线程安全的不可变对象被所有线程视为不可变对象,即使使用数据竞争在线程之间传递对不可变对象的引用也是如此。

因为Stringchar value[] 字段确实是final

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

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

【问题讨论】:

  • 呃,字段BAR不是final,所以final-s上的子句不适用。 (尽管 getBar() 将始终将该字段设置为相同的值;无论有多少线程同时运行它)
  • 如果固定值实际上是这样的字符串,您可以只使用return "Hello World!"; 或类似的东西,您正在浪费时间检查null 的字段。或者直接分配BAR,而不是从null 值开始。如果BAR 更复杂,那么延迟加载可能会有优势,但在这种情况下 幂等 或固定值无关紧要,仍然必须考虑线程安全。
  • 你现在拥有的是线程安全的,只要calculateHashDigest() 是线程安全的并且只取决于它的输入(当然没有副作用),正如你所说,你不在乎BAR 立即可见(它最终会变得可见,但不能说何时可见)。

标签: java concurrency thread-safety atomic


【解决方案1】:

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

String 确实是一个正确实现的不可变类型,你所说的关于它的 final 字段的事情是正确的。但这不是线程安全问题的所在。

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

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

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

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

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

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


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

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

这并没有改变我上面所说的。如果值为String,那么它是一个正确实现的不可变对象,您将总是看到一个完整的值不管其他任何东西。这就是 JLS 的名言!

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

【讨论】:

  • 您提出了非常好的观点,应该保留以供参考,但我添加了一个编辑以澄清 BAR 确实是一个固定值,就像在示例中一样。我想确保这是安全的,你提到它是安全的。重新计算的成本是微不足道的,因为大部分线程数是 1。
  • @antak 如果BAR 是这样的常量字符串,那么你就可以了。如果不是,则“固定值”无关紧要。 Java 有内存可见性要求,如果您没有正确使用该语言,甚至可能在线程之间看不到常量。
  • 值得注意的是,变量赋值long 除外保证是原子的。如果将“BAR”的类型更改为长代码,即使赋值是幂等的,代码也不安全。
  • 除非 BAR 值是...,否则您仍然有线程安全问题......:该问题专门询问字符串。我认为您提出的观点虽然适用于更一般的情况,但与问题无关。此外,您对“线程安全问题”一词的使用让我担心我可能会遗漏一些东西,因为它们似乎不是问题中提出的设计的问题。
【解决方案2】:

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

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 的读取将与对 volatile BAR 的写入同步。

  2. 1234563如果我们不进行原子检查,那么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!";

【讨论】:

  • 谢谢。我添加了一个编辑以澄清我不需要对 BAR 的初始化操作最多一次保证。
  • 如果对象是这样的字符串文字以外的任何东西,那么最多一次无关紧要。如果您不遵循语言的线程安全规范,您的对象可能根本不可见。我开始觉得您需要创建一个更好的示例,Java 中的String 具有特殊的线程安全属性,导致人们给您各种答案,并且您不断以奇怪的方式改变问题,看起来您的真正的问题是非常不同的。 @antak
  • 拜托,我专门询问了字符串,这就是我在标题中的内容,因为这是我所关心的。你有一个关于这个例子不好的观点,我试图澄清你对这个问题的评论的回复。我还更新了示例,因此字符串不会显示为文字。
  • 但这没有任何意义。如果字符串文字可以,为什么要故意创建这样的新字符串?我真的认为你需要给我们更多的信息。如果您要返回字符串文字,那么无论您做什么,它都可能是安全的,但几乎所有其他事情都需要适当的同步。而且您提供的示例不需要延迟初始化。 “懒惰”部分没有任何用处,它们只是死代码。所以这部分也很奇怪。
  • 这不是字面意思,但我看到我原来的例子是如何不足的,让这个问题很难回答。关于字符串实习+1的好点。那是我不打算放在示例中的内容,因此该示例甚至无法评估字符串的线程安全不可变对象性。 字符串很好,:这确实是我想知道的。只要我没有得到字符串本身的任何部分可见性,分配给BAR 的(非)可见性就可以了。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-03-16
  • 2011-11-17
  • 2014-07-11
  • 2015-07-27
  • 1970-01-01
  • 2020-02-09
相关资源
最近更新 更多