【问题标题】:Avoiding volatile reads in thread-safe memoizing Supplier避免线程安全记忆供应商中的易失性读取
【发布时间】:2017-12-30 06:59:04
【问题描述】:

我想创建一个给定Supplier 的记忆版本,这样多个线程可以同时使用它,同时保证原始供应商的get() 最多被调用一次,并且所有线程都看到相同的结果.双重检查锁定似乎很合适。

class CachingSupplier<T> implements Supplier<T> {
    private T result = null;

    private boolean initialized = false;

    private volatile Supplier<? extends T> delegate;

    CachingSupplier(Supplier<? extends T> delegate) {
        this.delegate = Objects.requireNonNull(delegate);
    }

    @Override
    public T get() {
        if (!this.initialized && this.delegate != null) {
            synchronized (this) {
                Supplier<? extends T> supplier = this.delegate;
                if (supplier != null) {
                    this.result = supplier.get();
                    this.initialized = true;
                    this.delegate = null;
                }
            }
        }
        return this.result;
    }
}

我的理解是,在这种情况下,delegate 必须是volatile,否则synchronized 块中的代码可能会重新排序:写入delegate 可能发生在写入result 之前,可能在完全初始化之前将result 暴露给其他线程。对吗?

因此,通常这将需要在每次调用时在 synchronized 块之外对 delegate 进行易失性读取,每个竞争线程最多只进入 synchronized 块一次,而 result 未初始化,然后再也不会.

但是一旦result 被初始化,是否有可能通过首先检查非易失性标志initialized 和short- 在随后的调用中避免delegate 的非同步易失性读取的成本,但可以忽略不计电路?或者这对我来说完全没有正常的双重检查锁定?或者它是否以某种方式对性能的伤害大于它的帮助?还是真的坏了?

【问题讨论】:

  • 是的。您可以删除supplier 检查并仅检查initialized。然后在同步块中将supplier 设置为空,因为之后它将不再使用。这将与 Guava 的 Suppliers.memoize 相同,只是允许对委托进行 GC。
  • @BenManes 我看到了源代码,但他们的initialized 标志是volatile,所以他们仍然在每次调用时进行易失性读取。我很好奇,一旦result 被初始化,是否也可以避免这种情况。我认为我的方法有问题,我只是看不到它,因为我对 Java 的内存模型了解不够。
  • A Supplier 可以返回 null 结果,因此如果不检查 volatile 标志,就无法确定您是否进行了激烈的读取,这将充当内存屏障确保一致性。但是 volatile 读取在 x86 上大部分是免费的,因此即使您不安全地放松它,您也不会在 JMH 基准测试中看到收益。
  • @BenManes 是的,我想说明返回null 的供应商,这就是我捎带原始供应商的原因。供应商是易变的,而不是结果,我正在检查供应商是否为空,确保它在构造函数中不为空,并在同步块的末尾将其清空(使其有资格作为 GC小红利)。非易失性initialized 字段用于避免随后对原始供应商进行易失性读取,因为我承认这可能是微不足道的收益。我只是想知道它是否真的有效。

标签: java multithreading concurrency thread-safety double-checked-locking


【解决方案1】:

不要实现双重检查锁定,使用现有的工具为您工作:

class CachingSupplier<T> implements Supplier<T> {
    private final Supplier<? extends T> delegate;
    private final ConcurrentHashMap<Supplier<? extends T>,T> map=new ConcurrentHashMap<>();

    CachingSupplier(Supplier<? extends T> delegate) {
        this.delegate = Objects.requireNonNull(delegate);;
    }

    @Override
    public T get() {
        return map.computeIfAbsent(delegate, Supplier::get);
    }
}

请注意,在将其发布到其他线程之前, 简单地进行一次急切的首次评估并将供应商替换为不断返回的供应商更为简单且足够。或者只是使用volatile 变量并接受如果多个线程遇到尚未评估的供应商可能会有一些并发评估。


以下实现仅用于信息(学术)目的,强烈建议使用上述更简单的实现。

您可以改用不可变对象的发布保证:

class CachingSupplier<T> implements Supplier<T> {
    private Supplier<? extends T> delegate;
    private boolean initialized;

    CachingSupplier(Supplier<? extends T> delegate) {
        Objects.requireNonNull(delegate);
        this.delegate = () -> {
            synchronized(this) {
                if(!initialized) {
                    T value = delegate.get();
                    this.delegate = () -> value;
                    initialized = true;
                    return value;
                }
                return this.delegate.get();
            }
        };
    }

    @Override
    public T get() {
        return this.delegate.get();
    }
}

这里,initializedsynchronized(this) 保护下写入和读取,但在第一次评估时,delegate 被替换为新的 Supplier,它总是返回评估值而无需任何检查。

由于新供应商是不可变的,因此它是安全的,即使被从未执行过synchronized 块的线程读取。


正如 igaz 正确指出的那样,如果 CachingSupplier 实例本身未安全发布,则上述类无法避免数据竞争。一个完全不受数据竞争影响的实现,即使发布不正确,但在普通访问情况下仍然可以在没有内存障碍的情况下工作,则涉及更多:

class CachingSupplier<T> implements Supplier<T> {
    private final List<Supplier<? extends T>> delegate;
    private boolean initialized;

    CachingSupplier(Supplier<? extends T> delegate) {
        Objects.requireNonNull(delegate);
        this.delegate = Arrays.asList(() -> {
            synchronized(this) {
                if(!initialized) {
                    T value = delegate.get();
                    setSupplier(() -> value);
                    initialized = true;
                    return value;
                }
                return getSupplier().get();
            }
        });
    }
    private void setSupplier(Supplier<? extends T> s) {
        delegate.set(0, s);
    }
    private Supplier<? extends T> getSupplier() {
        return delegate.get(0);
    }

    @Override
    public T get() {
        return getSupplier().get();
    }
}

我认为这更加强调了第一个解决方案的美感……

【讨论】:

  • 这应该加盖Holger's self mutating lambda或类似的东西,就像“双重检查锁定”...
  • @Eugene:替换委托也可以在没有 lambda 表达式的情况下工作,这种模式的一个例子是反射实现代码,从 Java 1.3 iirc 开始。我想,已经有一个名字了,但我手头没有。
  • 这段代码不是线程安全的,例如 T get 会抛出 NPE
  • @igaz:好吧,如果原始供应商抛出一个 NPE,这个CachingSupplier 将抛出一个 NPE。除此之外,我看不出 NPE 是如何发生的,而这段代码将对此负责。
  • 我不是指供应商抛出 NPE - 另一个线程读取可以看到 CachingSupplier#delegate 为空(因为 #delegate 不是最终的);只有当 CachingSupplier 自己安全发布时,才能保证另一个线程看到非空委托
【解决方案2】:

它已损坏,即它不是多线程安全的。 根据 JMM,简单地“看到”一个共享内存值(在您的示例中,读取器线程可能会将 #initialized 视为 true),这不是发生前的关系,因此读取器线程可以:

load initialized //evaluates true
load result //evaluates null

以上是允许的执行。

没有办法避免同步操作的“成本”(例如,易失性写入的易失性读取),同时避免数据争用(以及由此导致的代码损坏)。句号。

概念上的困难在于打破常识推断,即线程要看到初始化为 true -> 必须有一个 prior 写入 true 才能初始化;虽然很难接受,但这个推论是不正确的

正如 Ben Manes 指出的那样,易失性读取只是 x-86 上的普通负载

【讨论】:

  • 考虑到 Java 1.5 中对 JMM 的更改,不要对 initializedresult 的写入与对 delegate 的写入有发生前的关系,因为 delegate 是 @ 987654326@?
  • 在您的代码中,阅读器线程不需要阅读#delegate;看看我的回复中的法律执行情况。
  • 线程是否有可能将initialized 视为trueresult 视为null,因为对同步块中这些变量的写入仍然可以相互重新排序,即使他们都必须在写信给delegate之前来?
  • 我认为考虑“重新排序”本身并没有什么用处; “异常”行为可能是由于推测执行或分支预测或硬件架构师提出的任何其他加速我们的程序的行为。 JMM 使您不必了解硬件级别发生的事情。您的问题的具体答案是 reader 线程可能会看到已初始化(true); result(null) 因为在读取初始化的读取线程(线程 B)和写入初始化的写入线程(线程 A)之间没有发生之前的关系(true)
猜你喜欢
  • 2010-11-18
  • 1970-01-01
  • 2013-07-06
  • 2013-10-24
  • 2013-12-30
  • 2018-01-18
  • 2019-04-13
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多