没有同步或volatile关键字的延迟初始化

时间:2012-06-15 13:45:22

标签: java multithreading concurrency

前几天Howard Lewis Ship发布了一篇名为"Things I Learned at Hacker Bed and Breakfast"的博客文章,其中一个要点是:

  

通过延迟分配一次的Java实例字段   初始化不必同步或不稳定(只需要   因为你可以接受跨线程的竞争条件来分配给   领域);这是来自Rich Hickey

从表面上看,这似乎与关于线程内存更改可见性的公认智慧不一致,如果在Java Concurrency in Practice一书或Java语言规范中有所涉及,那么我就错过了它。但这是HLS在Brian Goetz出席的活动中从Rich Hickey那里获得的东西,所以看起来肯定会有一些东西。有人可以解释一下这句话背后的逻辑吗?

4 个答案:

答案 0 :(得分:9)

这句话听起来有点神秘。但是,我想HLS是指你懒惰地初始化一个实例字段并且不关心多个线程是否多次执行这个初始化的情况。 例如,我可以指向hashCode()类的String方法:

private int hashCode;

public int hashCode() {
    int hash = hashCode;
    if (hash == 0) {
        if (count == 0) {
            return 0;
        }
        final int end = count + offset;
        final char[] chars = value;
        for (int i = offset; i < end; ++i) {
            hash = 31*hash + chars[i];
        }
        hashCode = hash;
    }
    return hash;
}

正如您所看到的,hashCode字段(包含计算的字符串哈希的缓存值)的访问权限未同步,并且该字段未声明为volatile。任何调用hashCode()方法的线程仍然会收到相同的值,但hashCode字段可能被不同的线程多次写入。

这种技术的可用性有限。恕我直言,它主要用于示例中的情况:一个缓存的原始/不可变对象,它是从其他最终/不可变字段计算出来的,但它在构造函数中的计算是一种过度杀伤。

答案 1 :(得分:5)

修改

槽糕。当我读到它时,它在技术上是不正确的,但在实践中还是有一些警告。只有最终字段可以安全地初始化一次并在多个线程中访问而不进行同步。

延迟初始化的线程可能会以多种方式遭受同步问题。例如,您可以使用构造函数竞争条件,其中类的引用已导出,而类本身未完全初始化。

我认为这在很大程度上取决于你是否有原始字段或对象。可以多次初始化的原始字段,如果您不介意多个线程进行初始化,则可以正常工作。但是,以这种方式进行HashMap样式初始化可能会有问题。某些体系结构上的long值甚至可以在多个操作中存储不同的单词,因此可能会输出一半的值,尽管我怀疑long永远不会跨越内存页面,因此它永远不会发生。

我认为这在很大程度上取决于应用程序是否具有任何内存障碍 - 任何synchronized阻止或访问volatile字段。魔鬼肯定在这里的细节,并且执行延迟初始化的代码可以在一个架构上使用一组代码而不是在不同的线程模型中或在很少同步的应用程序中正常工作。


作为比较,这里有一个关于最终字段的好文章:

  

http://www.javamex.com/tutorials/synchronization_final.shtml

     

从Java 5开始,final关键字的一个特定用法是并发工具中非常重要且经常被忽视的武器。从本质上讲,final可用于确保在构造对象时,访问该对象的另一个线程不会在部分构造的状态中看到该对象,否则就会发生。这是因为当用作对象变量的属性时,final作为其定义的一部分具有以下重要特征:

现在,即使该字段标记为final,如果它是一个类,您也可以修改类中的字段。这是一个不同的问题,您仍然需要同步。

答案 2 :(得分:5)

在某些情况下,这种方法很好。

  • 可以尝试不止一次设置该字段。
  • 如果个别线程看到不同的值,那就没关系。

通常在创建未更改的对象时,例如从磁盘加载属性,在短时间内拥有多个副本不是问题。

private static Properties prop = null;

public static Properties getProperties() {
    if (prop == null) {
        prop = new Properties();
        try {
            prop.load(new FileReader("my.properties"));
        } catch (IOException e) {
            throw new AssertionError(e);
        }
    }
    return prop;
}

在短期内,这比使用锁定效率低,但从长远来看,它可能更有效。 (虽然属性拥有自己的锁,但你明白了;)

恕我直言,这不是一个适用于所有情况的解决方案。

也许重点是在某些情况下你可以使用更宽松的内存一致性技术。

答案 3 :(得分:3)

我认为这句话是不真实的。另一个线程可以看到部分初始化的对象,因此即使构造函数尚未完成运行,该引用也可以对另一个线程可见。 Java Concurrency in Practice,第3.5.1节:

中对此进行了介绍
public class Holder {

    private int n;

    public Holder (int n ) { this.n = n; }

    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }

}

此类不是线程安全的。

如果可见对象是 immutable ,那么我没关系,因为final字段的语义意味着在构造函数完成运行之前你不会看到它们(见第3.5.2节)。