在Java中模拟内存屏障以消除volatile读取

8

假设我有一个字段,被并发访问,读取次数很多且很少写入。

public Object myRef = new Object();

假设有一个线程T1每分钟将myRef设置为另一个值,而其他N个线程将连续且并发地读取myRef数十亿次。我只需要确保所有线程最终都能看到myRef的值。
一个简单的解决方案是使用AtomicReference或者像下面这样简单的volatile:
public volatile Object myRef = new Object();

然而,据我所知,volatile读操作确实会带来性能开销。我知道这种开销很小,这更像是我想知道的东西而不是我真正需要的东西。因此,让我们不要关注性能,假设这是一个纯理论问题。
因此,问题归结为:是否有一种安全地绕过仅偶尔写入的引用的volatile读取方式,通过在写入站点执行某些操作? 经过一些阅读,看起来内存屏障可能就是我需要的东西。因此,如果存在这样的结构体,我的问题将得到解决:
  • 写入
  • 调用屏障(同步)
  • 一切都同步了,所有线程都将看到新值。(在读取站点没有永久成本的情况下,它可以是旧的,或者产生一次缓存同步的成本,但在那之后,一切都恢复到常规字段获取直到下一次写入)。
Java中是否有这样的结构体,或者是否有通用的方法? 这时我不禁想,如果这样的东西存在,那么维护这些原子包的聪明人们已经把它纳入进去了。(比例失调的读取与写入可能不是值得关注的情况?)那么,也许我的想法有问题,这样的结构体根本不可能存在?我见过一些代码示例将“volatile”用于类似的目的,利用它的先行发生契约。有一个单独的sync字段,例如:
public Object myRef = new Object();
public volatile int sync = 0;

在编写线程/网站时:

myRef = new Object();
sync += 1 //volatile write to emulate barrier

我不确定这是否有效,有些人认为这仅适用于x86架构。在阅读JMS中的相关部分后,我认为只有当需要查看myRef新值的线程进行了volatile读操作与volatile写操作配对时,才能保证其有效。(因此不能消除volatile读取)。

回到我的最初问题:这是否可能?在Java中可能吗?在Java 9 VarHandles中的某个新API中可能吗?


1
在我看来,你已经进入了需要编写和运行一些模拟工作负载的实际基准测试领域。 - NPE
1
JMM指出,如果您的写入线程执行sync += 1;,并且您的读取线程读取sync值,它们也会看到myRef的更新。因为您只需要使读者最终看到更新,所以可以利用这一点,在读者线程的每1000次迭代(或类似情况)中仅读取同步。但您也可以使用volatile进行类似的技巧 - 只需在读者中缓存myRef字段1000次迭代,然后再次使用volatile读取... - Petr Janeček
@PetrJaneček 但是他不需要同步访问线程之间共享的计数器变量吗?那不会成为瓶颈吗?我认为这样做甚至会更加昂贵。 - Ravindra Ranwala
@RavindraRanwala 每个读者都会有自己的计数器,如果您的意思是计数到1000次左右。如果您指的是sync字段,那么读者在每次迭代时不会触及sync字段,他们会在想要检查是否有更新时机会主动这样做。话虽如此,一个更简单的解决方案是将myRef缓存1000轮,然后重新读取它... - Petr Janeček
@PetrJaneček 谢谢,我已经考虑过它作为可能的解决方案。但我想知道是否可以使用通用、稳定的实现来实现这一点。 - sydnal
4个回答

2
基本上,您希望具有volatile的语义但没有运行时成本。
我认为这是不可能的。
问题在于volatile的运行时成本是由实现编写器和读取器代码中的内存屏障所产生的指令引起的。如果你通过摆脱读取器的内存屏障来“优化”读取器,那么当实际写入值时,就不能保证读取器会看到“很少写入”的新值了。
顺便说一下,sun.misc.Unsafe类的某些版本提供了显式的loadFencestoreFencefullFence方法,但我认为使用它们不会比使用volatile带来任何性能收益。
假设地说...
你想让多处理器系统中的一个处理器能够告诉所有其他处理器:

"嘿!无论你正在做什么,请使地址XYZ上的内存缓存失效,并立即执行。"

不幸的是,现代ISA不支持此功能。
在实践中,每个处理器都控制着自己的缓存。

我明白了,你回答中的假设部分正是我想要的。谢谢。 - sydnal

0
不太确定这是否正确,但我可能会使用队列来解决这个问题。 创建一个类,包装一个ArrayBlockingQueue属性。该类具有update方法和read方法。update方法将新值发布到队列中并删除除最后一个值以外的所有值。read方法返回队列上peek操作的结果,即读取但不删除。查看队列前面的元素的线程可以毫无障碍地进行。更新队列的线程也能干净利落地进行。

0

X86提供TSO;你可以免费获得[LoadLoad][LoadStore][StoreStore]屏障。

volatile读取需要释放语义。

r1=Y
[LoadLoad]
[LoadStore]
...

正如您所看到的,这已经由X86免费提供。

在您的情况下,大多数调用都是读取操作,并且缓存行已经在本地缓存中。

编译器级别的优化需要付出代价,但在硬件级别上,易失性读取与常规读取一样昂贵。

另一方面,易失性写入更加昂贵,因为它需要[StoreLoad]来保证顺序一致性(在JVM中,这是使用lock addl %(rsp),0或MFENCE完成的)。由于在您的情况下很少进行写入操作,因此这不是问题。

我会谨慎对待这个级别的优化,因为很容易使代码比实际需要的更复杂。最好通过一些基准测试(例如使用JMH)指导您的开发工作,并在真实硬件上进行测试。还可能存在其他隐藏的恶意程序,例如虚假共享。


0
  • 你可以使用ReentrantReadWriteLock,它专为少量写入和大量读取的场景而设计。
  • 你可以使用StampedLock,它专为少量写入和大量读取的情况设计,但也可以尝试乐观地进行读取。例如:

    private StampedLock lock = new StampedLock();
    
    public void modify() {            // 写方法
        long stamp = lock.writeLock();
        try {
          modifyStateHere();
        } finally {
          lock.unlockWrite(stamp);
        }
    } 
    
    public Object read() {            // 读方法
      long stamp = lock.tryOptimisticRead();
      Object result = doRead();       // 尝试不加锁读取,该方法应该很快
      if (!lock.validate(stamp)) {    // 乐观读取失败
        stamp = lock.readLock();      // 获取读锁并重复读取
        try {
          result = doRead();
        } finally {
          lock.unlockRead(stamp);
        }
      }
      return result;
    }
    
  • 使你的状态不可变,只允许通过克隆现有对象并仅通过构造函数修改必要属性来进行受控修改。一旦构造出新状态,你就将其分配给被许多读线程读取的引用。这样,读线程不会产生任何开销


如果你想要点踩,请说明原因,这样作者和社区就可以学习。 - diginoise
我的情况下无法使它变为不可变。如果使用StampedLock的情况比简单的volatile读取方式更节省成本,我会感到非常惊讶。然而我会尝试,谢谢。 - sydnal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接