使用volatile关键字时的伪共享问题

在使用volatile关键字的时候,需要额外关注一下伪共享的问题。

先说一下cpu缓存的模型:

企业微信截图_16375725381706.png

cpu和主内存中间存在缓存,访问缓存的速度比内存快很多。cpu读取数据时,会先尝试在缓存里获取,缓存不能命中时才去读内存;操作一个数据时,会先在缓存里修改,再将结果同步到主内存中。

缓存读取的单位是缓存行,也就是说cpu每次从缓存里读取数据都是读取一个缓存行。

我们知道volatile关键字可以用来保证变量的可见性,它实现这个功能靠的是缓存一致性协议:一旦cpu修改了某个缓存行的数据,对于其他cpu而言,这个缓存行就失效了,只能从内存中重新加载。

缓存行的大小一般是64字节,可以存8个long类型变量。

现在假设这样一种情况,我们有一组volatile修饰的long变量,被多个线程同时操作。这些变量在内存上是连续的,存储在同一个缓存行上。由于缓存一致性协议,只要其中有任意一个变量被修改,都会使整个缓存行失效,其他cpu要读取时只能重新访问内存,从而造成比较大的开销,这个问题就是“伪共享(fase sharing)”。

解决伪共享的一种有效的方式就是填充缓存行。如果我们能保证每一个volatile修饰的变量都能独占缓存行,就不会因为其他变量被修改而使缓存失效,这是一种以空间换时间的策略。

下面这段代码摘自Martin Thompson的博客,可以证明填充缓存行的效果:

public final class FalseSharing
    implements Runnable
{
    public final static int NUM_THREADS = 4; // change
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    private final int arrayIndex;
  
    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];
    static
    {
        for (int i = 0; i < longs.length; i++)
        {
            longs[i] = new VolatileLong();
        }
    }
  
    public FalseSharing(final int arrayIndex)
    {
        this.arrayIndex = arrayIndex;
    }
  
    public static void main(final String[] args) throws Exception
    {
        final long start = System.nanoTime();
        runTest();
        System.out.println("duration = " + (System.nanoTime() - start));
    }
  
    private static void runTest() throws InterruptedException
    {
        Thread[] threads = new Thread[NUM_THREADS];
  
        for (int i = 0; i < threads.length; i++)
        {
            threads[i] = new Thread(new FalseSharing(i));
        }
  
        for (Thread t : threads)
        {
            t.start();
        }
  
        for (Thread t : threads)
        {
            t.join();
        }
    }
  
    public void run()
    {
        long i = ITERATIONS + 1;
        while (0 != --i)
        {
            longs[arrayIndex].value = i;
        }
    }
  
    public final static class VolatileLong
    {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5, p6; // comment out
    }
}

复制代码

这段代码中同时开启4个线程,对5千万个volatile修饰的变量循环执行读写操作。

注意61行的代码,声明了6个没有作用的长整型变量,目的就是增加每两个volatile变量之间的间隙,尽可能保证两个volatile变量不会出现在同一个缓存行上。

我们可以将61行注释掉,再看看输出的运行时间。

不注释的时间是16544754400,注释后的时间是43853611600,填充缓存行后执行快了3倍。

值得一提的是,上面这种填充方式在jdk1.7以后有可能并不管用。有文章指出jdk1.7会在编译期间优化那些没有作用的变量,导致上述代码失去效果。(不过似乎这也和虚拟机有关,我在jdk1.8,hotspot的环境下并没有复现这个问题)

网上我看到有两种绕开这个问题的方式。

第一种是通过添加一个操作填充变量的方法,骗过编译器的优化机制:

public final static class VolatileLong
{
    public volatile long value = 0L;
    public long p1, p2, p3, p4, p5, p6;
    public long sum() {
        return p1 + p2 + p3 + p4 + p5 + p6;
    }
}
复制代码

第二种则是利用继承,将填充量放在子类里,也可以绕开优化(Disruptor框架中使用了这样的方式):

public static class VolatileLong
{
    public volatile long value = 0L;
}
public final static class PaddingLong extends VolatileLong {
    public long p1, p2, p3, p4, p5, p6;
}
复制代码

另外,java8中提供了一个@Contended注解,可以用于对齐缓存行,解决伪共享。使用时需要在jvm指令上添加:-XX:-RestrictContended

おすすめ

転載: juejin.im/post/7034717377858109471