翻译自:http://mechanical-sympathy.blogspot.com/2011/07/false-sharing.html
伪共享
内存在缓存系统里是以缓存行为单位存储的。缓存行是大小为2的整数幂的连续字节,典型是32-256字节。最普遍的缓存行大小是64字节。伪共享是 个术语,用于当多个线程不知不觉地相互影响对方的性能,在修改使用相同缓存行的依赖变量时。缓存行上的写冲突是并行执行线程在SMP(对称多处理器 )系统上获得伸缩性的单一最大制约因素。我已听到伪共享被描述为无声的性能杀手,因为在查看代码时,它非常不明显。
为了达到按线程数量的线性伸缩性,我们必须确保没有两个线程写同一个变量或同一个缓存行。两个线程写同一个变量可以在代码层面跟踪到。为了知道独立 变量是否共享同一个缓存行,我们需要知道内存布局,或者让工具告诉我们。 Intel VTune是这样的一个分析工具。在这篇文章,我将解释Java对象的内存布局和如何通过填充缓存行来避免伪共享。
上图展示了伪共享的问题。线程A允许在Core 1,想更新变量X,运行在Core 2的线程想更新变量Y。不幸的是这两个热门变量属于同一个缓存行。每个线程将竞争缓存行的所有权,这样它们可以更新它。如果Core 1获得所有权,缓存子系统需要作废Core 2对应的缓存行。当Core 2获得所有权并执行它的更新,Core 1 将被告诉去作废它的缓存行拷贝。这将来来回回往返让L3缓存极大影响性能。如果竞争的Core处于不同的sockets,那么需要额外的跨Socket互 连,这个问题将更加恶化。
Java 内存布局
对于HotSpot JVM,所有对象都有一个2-字(world)的头。第一个是“标记(mark)”字,有24比特位用于哈希码和8比特位用于标记,例如锁状态,或交换用 于锁对象。第二个字是指向对象所属类的引用。数组有额外的字用于数组的大小。每个对象对齐到8字节的粒度边界,为了性能。因而为了更高效填充,对象的字段 将被重排序,从声明的顺序到下面的基于字节大小的顺序:
- doubles (8) and longs (8)
- ints (4) and floats (4)
- shorts (2) and chars (2)
- booleans (1) and bytes (1)
- references (4/8)
- <repeat for sub-class fields>
(译注:上面小括号里的数字表示占用的字节数。)
有了这些知识,我们可以用7个long在任意字段之间填充缓存行。在Disruptor里,我们在RingBuffer游标和BatchEventProcessor序号器周围填充缓存行。
为了展示性能影响,让我们用一些线程各自更新自己独立的计数器。这些计数器将是volatile
修饰的long
,这样将可以看到他们的进展。
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核 Nehalem 上的测试持续时间。
伪共享的影响是清晰可见的,通过完成测试需要的时间的增加。没有缓存行竞争,我们可以达到接近按线程线性的扩展。
这不是一个完美等测试,因为我们不能确定VolatileLongs将布局到内存的哪里。他们是独立的变量。然而,经验显示同时分配的对象倾向于同地协作(co-located)。
现在你知道,伪共享是无声的性能杀手了。
另一个测试例子
这是来自作者的另一篇文章:http://mechanical-sympathy.blogspot.com/2011/08/false-sharing-java-7.html,由于相关,就放一起了。
看起来Java 7变得更聪明了,会消除或重排序未使用的字段,这重新引入伪共享。作者认为下面的代码是最可靠的:
import java.util.concurrent.atomic.AtomicLong;
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 PaddedAtomicLong[] longs = new PaddedAtomicLong[NUM_THREADS];
static
{
for (int i = 0; i < longs.length; i++)
{
longs[i] = new PaddedAtomicLong();
}
}
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].set(i);
}
}
public static long sumPaddingToPreventOptimisation(final int index)
{
PaddedAtomicLong v = longs[index];
return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
}
public static class PaddedAtomicLong extends AtomicLong
{
public volatile long p1, p2, p3, p4, p5, p6 = 7L;
}
}