伪共享(False Sharing)
前言
缓存系统的基本单位是 cache lines . cache line 是由大量的两个连续字节组成,一般大小为32-256。而最常见的 cache line 大小为64个字节。
术语 伪共享 (False Sharing)是描述多线程在同时修改同一个 cache line 中的独立变量时,无意识地影响彼此的工作。
在 cache line 中的写竞争是唯一一个在SMP系统上实现一个可伸缩的并行程序的最重要限制因素。
我曾听说 伪共享 被形容为无声的性能杀手,因为在看代码时不明显出现在你眼前。
为了实现性能随线程数线性伸展,我们必须确保没有两个或以上的线程同时写入同一个变量或者 cache line 。
两个线程同时写入同一个变量可以在代码上跟踪。但如果独立变量共享相同的 cache line ,那么我们需要知道内存布局或由工具告诉我们,譬如 Intel VTune 。
在这篇文章中我将会解释Java对象在内存中的布局以及如何利用填充 cache line 来避免伪共享。
上图阐明了伪共享的问题。
在core 2上运行的线程想要更新变量Y的同时,core 1上的线程想要更新变量X。不幸的是这两个变量驻留在同一个 cache line ,每个线程都竞争 cache line 的
所有权来更新变量。
如果core 1获得所有权,那么缓存子系统需要让相应的 cache line 对core 2无效。当core 2获得所有权并进行更新,则core 1被告知 cache
line 的副本无效。这将来回往返并到达 L3 缓存,极大影响性能。
竞争的核心处在不同的插槽互相连接,这个问题将会进一步加剧。
Java内存布局
在Hotspot JVM中,所有的对象头都由两个字组成。
- 第一个字可以称为“mark”,是由24比特的 hash code 和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 中实现填充。
测试
为了显示对性能的影响,让我们利用一些线程修改各自独立的计数器,计数器是原子性的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(不会~T_T)。
所以你应该明白,伪共享是无声的性能杀手。
在先前的文章 伪共享 中,我建议可以通过填充无用的long字段来避免它。
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;
}
}
利用这代码我得到了类似的执行结果。注释掉上面的填充字段 PaddedAtomicLong 可以看到伪共享的影响。
我觉得我们都应该游说Oracle增加内联函数到语言中,以使我们得到可以对齐 cache line 以及填充原子类的权力。
这个以及其他底层的改变将会帮助Java成为一个真正的并发编程语言。
我们经常听到别人说多核时未来。我认为正是在这里,Java需要迎头赶上。
如若侵权,请联系,我会尽快删除。