伪共享(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中,所有的对象头都由两个字组成。

  1. 第一个字可以称为“mark”,是由24比特的 hash code 和8比特的标识(例如锁状态,它可以转换被锁对象)组成。
  2. 第二个字是指向对象的类的引用。数组会有一个额外的字来描述数组长度。

每个对象对齐到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需要迎头赶上。

 

 

 

如若侵权,请联系,我会尽快删除。

 

posted @ 2013-03-09 23:42  盖文  阅读(733)  评论(3编辑  收藏  举报