在 Java 中,如何确保布尔标志的安全、一致并发使用,同时最大限度地减少时间性能影响?更新争用条件原子布尔运算同步更新:基准测试

2022-09-03 02:12:17

在我的场景中,我的对象基本上是原始数组包装器,它们在发生写入访问时设置布尔“脏”标志。DirtyArray

public class DirtyArray {
    private byte[] data;

    public DirtyArray(byte[] data) {
        this.data = data;
    }

    private boolean dirty = false;

    public void setValue(int index, byte value) {
        dirty = true;
        data[index] = value;
    }

    public boolean isDirty() {
        return dirty;
    }
}

肮脏的标志只会从 到 。falsetrue

我需要使此安全并发使用:有一个或多个线程可能会修改数组()。有一个或多个线程在 GCed 之前捕获,并且如果它已被修改,则应将其写入磁盘 ()。setValueDirtyArrayisDirty

现在,如果我理解正确,像上面那样这样做是不安全的:实际上,从线程的角度来看,商店可以在商店之前重新排序。因此,查看并不能保证没有修改。isDirtydata[index]=valuedirty=trueisDirty()==falsedata

这是正确的吗?

假设是的,那么制作标志应该可以解决这个问题。但是,在下面的基准测试中,我看到这样做时速度会降低约50x-100x。dirtyvolatile

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void touchAll()
{
    for (int i = 0; i < numEntities; i++)
        bytes.setValue(i, ( byte ) 2);
}

使用AtomicBoolean和Java 9中引入的内存排序get/set变体,我有这个变体:

public class DirtyArray {
    private byte[] data;

    public DirtyArray(byte[] data) {
        this.data = data;
    }

    private AtomicBoolean dirty = new AtomicBoolean();

    public void setValue(int index, byte value) {
        if (!dirty.getPlain())
            dirty.setRelease(true);
        data[index] = value;
    }

    public boolean isDirty() {
        return dirty.getAcquire();
    }
}

其性能(在上面的基准测试中)与原始非易失性版本相同。

这样做安全吗?也就是说,它是否保证何时修改我会看到?(在我的理解中,它应该是,但只是因为只有从到,永远不会回来。dataisDirty()==truedirtyfalsetrue

是否有其他变体来实现此保证,甚至可能允许重置为 ,理想情况下不会对性能产生负面影响?dirtyfalse


更新

我同意到目前为止对答案的一般评估,即保证已更改的数组和标志之间一致性的唯一方法是同步和。Pak Uula指出的竞争条件才是真正的问题,而不是让肮脏的旗帜可见。所以基本上我上面问的问题是错误的问题......datadirtysetValueisDirty

有关更多上下文:这是关于在 https://github.com/imglib 中存储透明缓存图像的像素。它用于非常紧密的循环中,从同步中接受打击并不是一个真正的选择。典型的使用场景是:

  • 多个线程修改映像(由许多线程支持)。DirtyArrays
  • 检查发生在另一个线程上,该线程在垃圾回收之前捕获(在 的持有者上),如果它很脏,则将其写入磁盘。isDirty()DirtyArrayPhantomReferenceDirtyArray

我现在的观点是,这应该比单个呼叫更粗糙。有一些“自然”的同步点会发生,因为线程通过从中获取它们(当然会掩盖细节)在s之间切换,线程在线程池中并从共享队列中获取作业,或者线程以其他方式等待彼此。在这些同步点上,早期(按程序顺序)的效果必须变得可见。因此,我倾向于只使用普通的不同步版本,并依靠较粗糙级别的同步。setValue()DirtyArrayConcurrentHashMapsetValue()

唯一让我有点头疼的是清理是由垃圾回收触发的,我必须确保(持有者)在粗略级别同步点之前没有被收集。但我认为我可以通过保持强大的引用并在必要时添加可访问性围栏来确保这一点。DirtyArray


答案 1

AtomicBoolean(或任何其他原子族)不保证与不同变量的同步。所以没有。代码不保证当数据被修改时,你会得到isDirty()==true。您唯一可以保证的是,所有线程始终看到相同的 isDirty() 值。事实上,列出的选项都没有做出保证。

进行保证的唯一方法是在 set 方法内的整个代码块上具有独占锁:if 语句和赋值。这可以通过同步关键字(在方法上或在代码块中)或使用java.util.concurrency


答案 2

有关使用 的解决方案的几条注意事项。AtomicBoolean

您在寻找什么样的线程安全性?我不明白为什么你这么在乎标志的读写顺序。此标志的获取/设置操作可能在时间上完美排序,并且仍然会导致比赛。dirty

争用条件

在你的两个例子中,我看到和之间的竞争条件:setValueisDirty

  1. 线程 1 调用 ,在设置 之前更新标志并抢占线程 2。setValuedirtydata[index] = value
  2. 线程 2 调用 ,它返回 ,但数组尚未更新,因为线程 1 被抢占。isDirtytruedata
  3. 如果线程 2 继续对脏数组执行操作,例如将其复制到另一个位置,则在线程 1 使用 恢复执行后,副本将与内存中的数组不一致。data[index] = value

改变操作顺序可以解决这场竞赛,但会引入另一场竞赛。

    public void setValue(int index, byte value) {
        data[index] = value;
        dirty = true;
    }

请考虑以下顺序:

  1. 线程 1 在索引 0 处设置值。标志成为 .dirtytrue
  2. 线程 1 在索引 1 处开始设置值。 被执行,并且线程 1 在 之前被抢占。data[1] = valuedirty = true
  3. 线程 2 检查该标志并将阵列同步到磁盘。
  4. 线程 1 恢复执行,并设置脏标志。

比赛是数组与外部副本一致,但标记为脏。这种竞争可能会导致过多的复制操作。

原子布尔运算

你对原子布尔值的使用是非原子的。线程可能只是在条件语句和语句之间被抢占。您使用的构造的原子等效项是 。thenifdirty.compareAndSet(false, true);

实际上,您不需要这样。 就足够了,因为您在更新任何值时不按格式设置脏标志。dirty.set(true)

但可悲的是,即使没有从竞争条件中拯救出来。线程可以在设置脏标志和更新数据之间被抢占。上述争用条件不会对标志更新的原子性产生影响。AtomicBoolean.set

同步

从一开始,Java就为这种情况提供了一个模式:.synchronized

public class SyncronizedDirtyArray {
    private byte[] data;

    public SyncronizedDirtyArray (byte[] data) {
        this.data = data;
    }

    private boolean dirty = false;

    public synchronized void setValue(int index, byte value) {
        dirty = true;
        data[index] = value;
    }

    public synchronized boolean isDirty() {
        return dirty;
    }
}

synchronized确保没有线程可以干扰 和 。dirty = true;data[index] = value;

与非同步解决方案相比,它可能会更慢,但可以避免争用条件。尝试使用此解决方案并设置基准测试。

更新:基准测试

我制作了自己的基准测试,非常简单,但很有启发性。

我用不同的同步基元阻止了DirtyArray的几个变体。

  • DirtyArrayNoSync:没有任何同步的普通实现
  • DirtyArraySync:使用synchronized
  • DirtyArrayLock:使用ReentrantLock
  • DirtyArrayVolatile:使用volatile boolean
  • DirtyArrayAtomic:从主题启动器实现
  • DirtyArrayAtomicSet:使用和AtomicBoolean.setAtomicBoolean.get

benchmar 将调用数组中具有 10000 万个元素的每个元素。输出的持续时间(以毫秒为单位)。setValue

配置: OpenJDK 11.0.6, Core i7, Windows 10 Home

org.example.sync.DirtyArrayNoSync: 112
org.example.sync.DirtyArraySync: 2222
org.example.sync.DirtyArrayLock: 16752
org.example.sync.DirtyArrayVolatile: 7555
org.example.sync.DirtyArrayAtomic: 7591
org.example.sync.DirtyArrayAtomicSet: 3066

是的,同步使它慢20倍,但它是第二快的解决方案。所有其他的,即使有 ,也比较慢。AtomicBoolean

更新 2.

jdk-14.0.1 的基准测试显示了几乎相同的结果:

org.example.sync.DirtyArrayNoSync: 102
org.example.sync.DirtyArraySync: 2323
org.example.sync.DirtyArrayLock: 16801
org.example.sync.DirtyArrayVolatile: 7942
org.example.sync.DirtyArrayAtomic: 7984
org.example.sync.DirtyArrayAtomicSet: 3320