在使用volatile关键字的时候,需要额外关注一下伪共享的问题。
先说一下cpu缓存的模型:
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