我有一个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,那么它的值是完整的而不是不完整的。
答案 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;
}
// ...
这里有两件事。
如果BAR
已经初始化,则不会输入synchronized
块。这里需要volatile
,因为需要进行一些同步,并且BAR
的读取将与对易失性BAR
的写入进行同步。
如果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!";