如何安全地发布"懒惰生成的有效不可变数组

时间:2014-12-03 18:15:31

标签: java multithreading final

Java的当前内存模型保证如果对象“George”的唯一引用存储到某个其他对象“Joe”的final字段中,并且George和Joe都从未被任何其他线程看到过,所有线程都会看到在商店之前执行的所有乔治操作都已在商店之前执行过。如果在final字段中存储对象的引用是有意义的,那么在此之后永远不会发生变异。

在应该延迟创建可变类型的对象(在拥有对象的构造函数完成执行之后的某个时间)的情况下,是否有任何有效的方法来实现这种语义?考虑包含不可变数组的相当简单的类ArrayThing,但它提供了一个方法(具有相同标称目的的三个版本),以返回指定值之前的所有元素的总和。出于此示例的目的,假设将构建许多实例而不使用该方法,但在使用该方法的实例上,将使用很多实例;因此,在构造ArrayThing的每个实例时预先计算总和是不值得的,但是值得缓存它们。

class ArrayThing {
    final int[] mainArray;

    ArrayThing(int[] initialContents) {
        mainArray = (int[])initialContents.clone();
    }
    public int getElementAt(int index) {
        return mainArray[index];
    }

    int[] makeNewSumsArray() {
        int[] temp = new int[mainArray.length+1];
        int sum=0;
        for (int i=0; i<mainArray.length; i++) {
            temp[i] = sum;
            sum += mainArray[i];
        }
        temp[i] = sum;
        return temp;
    }

    // Unsafe version (a thread could be seen as setting sumOfPrevElements1
    // before it's seen as populating array).

    int[] sumOfPrevElements1;
    public int getSumOfElementsBefore_v1(int index) {
        int[] localElements = sumOfPrevElements1;
        if (localElements == null) {
            localElements = makeNewSumsArray();
            sumOfPrevElements1 = localElements;
        }
        return localElements[index];
    }
    static class Holder {
        public final int[] it;
        public Holder(int[] dat) { it = dat; }
    }

    // Safe version, but slower to read (adds another level of indirection
    // but no thread can possibly see a write to sumOfPreviousElements2
    // before the final field and the underlying array have been written.

    Holder sumOfPrevElements2;
    public int getSumOfElementsBefore_v2(int index) {
        Holder localElements = sumOfPrevElements2;
        if (localElements == null) {
            localElements = new Holder(makeNewSumsArray());
            sumOfPrevElements2 = localElements;
        }
        return localElements.it[index];
    }

    // Safe version, I think; but no penalty on reading speed.
    // Before storing the reference to the new array, however, it
    // creates a temporary object which is almost immediately
    // discarded; that seems rather hokey.

    int[] sumOfPrevElements3;
    public int getSumOfElementsBefore_v3(int index) {
        int[] localElements = sumOfPrevElements3;
        if (localElements == null) {
            localElements = (new Holder(makeNewSumsArray())).it;
            sumOfPrevElements3 = localElements;
        }
        return localElements[index];
    }
}

String#hashCode()方法一样,两个或多个线程可能会看到尚未执行计算,决定执行它并存储结果。由于所有线程最终都会产生相同的结果,因此这不是问题。但是,对于getSumOfElementsBefore_v1(),存在一个不同的问题:Java可能会重新排序程序执行,因此在写入数组的所有元素之前,数组引用将写入sumOfPrevElements1。另一个在那一刻调用getSumOfElementsBefore()的线程可以看到该数组不为null,然后继续读取尚未写入的数组元素。糟糕。

据我所知,getSumOfElementsBefore_v2()解决了这个问题,因为在最终字段Holder#it中存储对数组的引用会在数组元素写入方面建立“发生后”关系。不幸的是,该版本的代码需要创建和维护一个额外的堆对象,并且要求每次访问元素sum数组的尝试都要经过一个额外的间接层。

我认为getSumOfElementsBefore_v3()会更便宜但仍然安全。 JVM保证在引用存储到final字段之前对新对象执行的所有操作将在任何线程可以看到该引用时被所有线程看到。因此,即使其他线程没有直接使用Holder#it,他们使用从该字段复制的引用的事实也会确定他们无法看到引用直到之后在商店实际发生之前所做的一切。

即使后一种方法将开销(相对于不安全方法)限制为创建新数组的时间(而不是为每次读取增加开销),但纯粹为此目的创建新对象仍然看起来相当丑陋写作和阅读最后一个字段。使数组字段volatile实现合法语义,但每次读取字段时都会增加内存系统开销(如果字段已写入另一个线程,则volatile限定符将要求代码注意但是这对于这个应用程序来说太过分了;只需要看到该字段已经被写入的任何线程也看到在存储引用之前由阵列识别的所有写入。 有没有办法实现类似的语义而不必创建和放弃多余的临时对象,或者每次读取字段时都会增加额外的开销?

2 个答案:

答案 0 :(得分:1)

您的第三个版本工作。对存储在final实例字段中的正确构造的对象所做的保证仅适用于该final字段的读取。由于其他线程不读取final变量,因此没有保证。

最值得注意的是,在数组引用存储在final Holder.it变量之前必须完成数组的初始化这一事实并未说明何时将sumOfPrevElements3变量写入(如看到其他线程)。实际上,JVM可能会优化掉整个Holder实例创建,因为它没有副作用,因此生成的代码就像int[]数组的普通不安全发布一样。

要使用final字段发布保证,您必须发布包含Holder字段的final实例,无法解决此问题。


但如果这个额外的实例让你烦恼,你应该考虑使用一个简单的volatile变量。毕竟,您只对<{1}}变量的成本进行假设,换句话说,考虑过早优化。

毕竟,检测另一个线程所做的更改并不一定非常昂贵,例如在x86上它甚至不需要访问主内存,因为它具有缓存一致性。优化器也可能检测到,一旦变量变为非volatile,您就永远不会再写入变量,然后在读取非null引用后,几乎可以为普通字段进行所有优化。< / p>


所以结论一如既往:衡量,不要猜测。只有在找到真正的瓶颈后才开始优化。

答案 1 :(得分:1)

我认为你的第二个和第三个例子确实有效(正如你所说的那样,引用本身可能不会被另一个线程注意到,这可能会重新分配数组。这是一项额外的工作!)。

但这些例子是基于一个错误的前提:volatile字段要求读者“注意到”这一变化是不正确的。实际上,volatilefinal字段执行的操作完全相同。 volatilefinal的读取操作在大多数CPU架构上没有开销。我相信写volatile会产生很少的额外开销。

所以我会在这里使用volatile,而不用担心你所谓的“优化”。速度的差异,如果有的话,将是非常轻微的,我说话就像用总线锁写的额外4个字节,如果有的话。而且你的“优化”代码非常难以阅读。

作为次要链接,最终字段要求您对对象进行唯一引用以使其不可变且线程安全,这是不正确的。规范只要求您阻止对对象的更改。单独引用对象是防止更改的一种方法,当然。但是,已经不可变的对象(例如java.lang.String)可以毫无问题地共享。

总结:Premature Optimization is the Root of All Evil.。松散狡猾的废话,只需编写一个简单的数组更新,并赋值给volatile

volatile int[] sumOfPrevElements;
public int getSumOfElementsBefore(int index) {
    if( sumOfPrevElements != null ) return sumOfPrevElements[index];
    sumOfPrevElements = makeNewSumsArray();
    return sumOfPrevElements[index];
}