好理解的Java内存虚假共享(False Sharing)性能损耗以及解决方案

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://linuxstyle.blog.csdn.net/article/details/89714205

虚假共享(False Sharing)也有人翻译为伪共享

参考 https://en.wikipedia.org/wiki/False_sharing

在计算机科学中,虚假共享是一种性能降低的使用模式,它可能出现在具有由高速缓存机制管理的最小资源块大小的分布式一致高速缓存的系统中。当系统参与者将定期尝试访问,将永远不会被另一方改变数据,但这些数据共享与数据的高速缓存块被修改,缓存协议可能迫使一位与会者尽管缺乏逻辑必然性的整个单元重载。高速缓存系统不知道该块内的活动,并迫使第一个参与者承担真正共享资源访问所需的高速缓存系统开销。

到目前为止,该术语最常见的用法是在现代多处理器 CPU高速缓存中,其中存储器被缓存在两个字大小的一些小功率的行中(例如,64个对齐的,连续的字节)。如果两个处理器对可存储在一行中的同一存储器地址区域中的独立数据进行操作,则系统中的高速缓存一致性机制可能会在每次数据写入时强制整个线路穿过总线或互连,从而除了浪费系统带宽之外还会导致内存停顿 。虚假共享是自动同步的缓存协议的固有工件,也可以存在于分布式文件系统或数据库等环境中,但目前的流行仅限于RAM缓存。

示例

struct foo {
    int x;
    int y; 
};

static struct foo f;

/* The two following functions are running concurrently: */

int sum_a(void)
{
    int s = 0;
    for (int i = 0; i < 1000000; ++i)
        s += f.x;
    return s;
}

void inc_b(void)
{
    for (int i = 0; i < 1000000; ++i)
        ++f.y;
}

在这里,sum_a可能需要不断地从主存储器(而不是从缓存)重新读取x,即使inc_b并发修改y是无关紧要的。

如果你还是不能理解虚假共享不要紧看下面的例子

理解虚假分享

为了更好地理解这一点,我们假设一个假设的情况:

有三位画家。每个人都有他自己的木板,他们在上面绘画,每个板有三个部门,分别是1区,2区和3区。

画家只能画出这三个部门中的一个。当画家描绘他的木板的一个部分时,另外两个板也必须改变以反映第一个画家所做的事情。

这里的木板类似于缓存块,画家类似于并发线程,绘画类似于写入活动。

请记住,此更新在逻辑上是不必要的,因为每个画家使用的分区不与其他画家使用的分区相交。可以做的是在所有画家完成绘画之后,最后可以更新木板。但这不是我们的计算机架构的工作方式。这是因为管理高速缓存机制的组件不知道实际更新了高速缓存块的哪个分区。它标记整个块为脏。强制内存更新以维持缓存一致性。与高速缓存块中的写入活动相比,这是非常昂贵的计算。

只有当写入进程和两个并行线程具有交叉缓存块时才会出现此问题。现在解决此问题的唯一方法是确保两个并行线程具有不同的缓存块。

 

参考:虚假分享

要实现线程数量的线性可伸缩性,我们必须确保没有两个线程同时写入同一个变量或缓存行。可以在代码级别跟踪写入同一变量的两个线程。为了能够知道自变量是否共享相同的缓存行,我们需要知道内存布局,或者我们可以使用工具告诉我们。英特尔VTune就是这样一个分析工具。下面,将解释如何为Java对象布置内存以及如何填充缓存行以避免错误共享。

上图演示虚假共享的问题。

在核心1(Core1)上运行的线程想要更​​新变量X,而核心2(Core2)上的线程想要更​​新变量Y。

不幸的是,这两个变量位于同一缓存行中。每个线程都将竞争对缓存行的所有权,以便可以更新。如果核心1获得所有权,那么缓存子系统将需要使核心2的相应缓存行置为无效。当Core 2获得所有权并执行其更新时,将告知核心1使其缓存行的副本无效。这将通过L3缓存来回乒乓,会极大的影响性能。如果竞争核心在不同的套接字上并且还必须跨越套接字互连,那么将进一步加剧性能问题。

 

Java内存布局

对于基于Hotspot的JVM比如现在的OpenJDK和OracleJDK,所有对象都有一个2个字的header。首先是“标记(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个长度的任何字段之间填充缓存行。为了显示性能影响,让我们花几个线程来更新自己独立的计数器。这些计数器将长期波动可以看到它们的比较数据。

package linuxstyle.blog.csdn.net;

public final class FalseSharing implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    public final static int NUM_THREADS = 4; // change
    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];

    static {
        for (int i = 0; i < longs.length; i++) {
            longs[i] = new VolatileLong();
        }
    }

    private final int arrayIndex;

    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 long p1, p2, p3, p4, p5, p6; // comment out
        public volatile long value = 0L;
    }
}

输出如下:

结果

运行上面的代码,同时增加线程数并添加/删除缓存行填充,得到如下图所示的结果。这是测量4核测试运行的持续时间。 

通过增加完成测试所需的执行时间可以清楚地看出错误共享的影响。如果没有缓存行争用,我们就可以通过线程实现近似线性扩展。

这不是一个完美的测试,因为我们无法确定VolatileLongs将在内存中的位置。它们是独立的对象。但是经验表明,同时分配的对象往往位于同一位置。

 

需要注意的是上面的解决办法是有争议的参考:知道你的Java对象内存布局

理论上,理论和实践是相同的

是几年前的一篇优秀文章,它告诉大家Java应该如何布局你的对象,总结一下:

  • 对象在内存中对齐8个字节(如果A%K == 0,则地址A为K对齐)
  • 所有字段都是类型对齐的(long / double是8对齐,整数/ float 4,short / char 2)
  • 字段按其大小的顺序打包,除了最后的引用
  • 类字段永远不会混合,所以如果B扩展A,B类的对象将首先在A的字段中布局在内存中,然后是B的
  • 子类字段以4字节对齐开始
  • 如果类的第一个字段是long / double并且类起始点(在标题之后,或者在super之后)不是8对齐,则可以交换较小的字段以填充4字节间隙。

JVM不仅仅按照你告诉它的顺序依次对你的字段进行plok的原因也在文章中讨论,总结如下:

  • 未对齐访问是不好的,因此JVM可以避免错误的布局(对内存的未对齐访问会导致各种不良副作用,包括在某些体系结构上崩溃您的进程)
  • 字段的朴素布局会浪费内存,JVM重新排序字段以改善对象的整体大小
  • JVM实现要求类型具有一致的布局,因此需要子类规则

那么......很好的明确规则,可能会出错?

https://gist.github.com/nitsanw/5594570#file-gistfile1-java

首先,规则不是JLS的一部分,它们只是实现细节。如果您阅读Martin Thompson关于虚假共享的文章,  您会注意到T先生有一个错误共享的解决方案,该解决方案适用于JDK 6,但不再适用于JDK 7.以下是两个版本。

下面是避免在JDK 6/7上进行错误共享:

// No false sharing on 6, but happens on 7
public final static class VolatileLong
{
    public volatile long value = 0L;
    public long p1, p2, p3, p4, p5, p6;
}
// No false sharing on 6 or 7
public static class PaddedAtomicLong extends AtomicLong
{
    public volatile long p1, p2, p3, p4, p5, p6 = 7L;
}

事实证明,JVM改变了它对6到7之间的字段进行排序的方式,这足以打破这个咒语。公平地说,没有上面规定的规则要求字段顺序与它们被定义的顺序相关联,但是......它需要担心并且它可以让你绊倒。

正如上述规则在我的脑海中仍然是新鲜的,LMAX 开源的Disruptor发布了Coalescing Ring Buffer。我仔细阅读了代码并发现以下内容:

public final class CoalescingRingBuffer<K, V> implements CoalescingBuffer<K, V> {

  private volatile long nextWrite = 1; // <-- producer access (my comment)
  private volatile long lastCleaned = 0; // <-- producer access (my comment)
  private volatile long rejectionCount = 0;
  private final K[] keys;
  private final AtomicReferenceArray<V> values;

  private final K nonCollapsibleKey = (K) new Object();
  private final int mask;
  private final int capacity;

  private volatile long nextRead = 1; // <-- consumer access (my comment)
  private volatile long lastRead = 0; // <-- consumer access (my comment)
  ...
}

在介绍CoalescingRingBuffer的博客文章中找到了Nick Zeeb,  并提出了担忧,即生产者/消费者访问的字段可能会遭受错误的共享,Nick的回复:

试图对字段进行排序,以便最大限度地减少错误共享的风险。Java 7可以重新排序字段。使用Martin Thompson的PaddedAtomicLong进行了性能测试,但没有在Java 7上获得性能提升。

尼克很聪明,并不是在这里引用这些用来来批评他。引用他来表明这是令人困惑的东西(所以在某种程度上,我引用他来安慰自己与其他同样困惑的专业人士的公司)。我们怎么知道?这是我和尼克交谈后想到的一种方式:

public class FalseSharingTest {
  @Test
  public void test() throws NoSuchFieldException, SecurityException{
    long nextWriteOffset = UnsafeAccess.unsafe.objectFieldOffset(
		    CoalescingRingBuffer.class.getDeclaredField("nextWrite"));
    long lastReadOffset = UnsafeAccess.unsafe.objectFieldOffset(
		    CoalescingRingBuffer.class.getDeclaredField("lastRead"));
    assertTrue(Math.abs(nextWriteOffset - lastReadOffset) >= 64);
  }
}

使用Unsafe我可以从对象引用中获取字段偏移量,如果2个字段小于高速缓存行,则它们可能遭受错误共享(取决于内存中的结束位置)。当然,这是验证事物的一种hackish方式,但它可以成为您构建的一部分。

热门

大约在同一时间LMAX发布了CoalescingRingBuffer,Gil Tene(Azul的CTO)发布了HdrHistogram。现在Gil非常认真,非常聪明,并且比大多数人更了解JVM(这是他的InfoQ演讲,观看它)所以我很想看看他的代码。你知道什么,一堆热门领域:

public abstract class AbstractHistogram implements Serializable {
  // "Cold" accessed fields. Not used in the recording code path:
  long highestTrackableValue;
  int numberOfSignificantValueDigits;

  int bucketCount;
  int subBucketCount;
  int countsArrayLength;

  HistogramData histogramData;

  // Bunch "Hot" accessed fields (used in the the value recording code path) here, near the end, so
  // that they will have a good chance of ending up in the same cache line as the counts array reference
  // field that subclass implementations will add.
  int subBucketHalfCountMagnitude;
  int subBucketHalfCount;
  long subBucketMask;
  
  ...
}

Gil在这里做的很好,他试图让相关领域在内存中挤在一起,这将提高他们在同一缓存行上结束的可能性,从而为CPU节省潜在的缓存。可悲的是,JVM还有其他计划...... 

所以这里有另一个工具可以帮助你理解你的内存布局,以便添加到你的工具带中:Java Object Layout  我偶然碰到了它,而不是一直想着内存布局。

注意histogramData如何跳转到botton并且subBucketMask被移到顶部,打破了我们的热门束。解决方案是丑陋但有效的,将所有字段移动到另一个毫无意义的父类:

abstract class AbstractHistogramColdFields implements Serializable {
  // "Cold" accessed fields. Not used in the recording code path:
  long highestTrackableValue;
  int numberOfSignificantValueDigits;

  int bucketCount;
  int subBucketCount;
  int countsArrayLength;

  HistogramData histogramData;
}
public abstract class AbstractHistogram extends AbstractHistogramColdFields {
  // Bunch "Hot" accessed fields (used in the the value recording code path) here, near the end, so
  // that they will have a good chance of ending up in the same cache line as the counts array reference
  // field that subclass implementations will add.
  int subBucketHalfCountMagnitude;
  int subBucketHalfCount;
  long subBucketMask;
  ...
}

优秀的JOL现已在OpenJDK下发布。它甚至比以前更好,并支持许多时髦的功能。

http://openjdk.java.net/projects/code-tools/jol/

代码工具:jol

JOL(Java Object Layout)是分析JVM中对象布局方案的微型工具箱。这些工具大量使用Unsafe,JVMTI和Serviceability Agent(SA)来解码实际的 对象布局,占用空间和引用。这使得JOL比依赖堆转储,规范假设等的其他工具更准确。

 

参考:

展开阅读全文

false sharing及使用vtune验证

06-01

多核开发中常见的一个问题是false sharing(失效共享),这个问题让我们用一个全新的角度来看待多核程序的编写,这个角度就是硬件的角度。rnrn rnrnIntel Core 2 Duo处理器平台上, L2 cache是由两个core共享的,而L1 data cache是分开的,由两个core分别存取。cache line的大小是64 Bytes。当不同的线程同时读写不同的,看起来更不相关的2个变量时,由于这2个变量实际保存在同一条cache line上,从而会暗地里造成cache line的访问冲突而导致潜在的性能损失。例如这段代码:rnrnrnunsigned char VectorA[10];rnunsigned char VectorB[10];rnrn rnrnUINT MyThreadProcA( LPVOID pParam )rn rn unsigned long long myCounter = 100000000;rn while(--myCounter) rn rn for (int i=0; i<10; ++i)rn rn ++VectorA[i];rn rn rn return 0; // thread completed successfullyrnrnrn rnrnUINT MyThreadProcB( LPVOID pParam )rn rn unsigned long long myCounter = 100000000;rn while(--myCounter) rn rn for (int i=0; i<10; ++i)rn rn ++VectorB[i];rn rn rn return 0; // thread completed successfullyrnrnrn rnrn尽管MyThreadProc[A/B] 是两个不同的线程,访问的也是两个不同的变量,但是,false sharing却真真实实的发生了。当MyThreadProcA更新VectorA[i]的时候,对应的Core A上的cache line同时被更新,变为modified状态,而这个cache line中又保存了VectorB[i]的一份copy,因此,另一个Core B中的cacheline就会变为失效状态(invalid),CPU会不得不通过cache protocol(cache的同步协议)去通知Core B上的cache line同时更新VectorB的数据,这样,尽管MyThreadProcA没有修改VectorB,却会导致MyThreadProcB线程访问VectorB时cache miss增加!而我们知道,cache的访问速度是普通内存的10倍,cache miss增大自然会造成明显的性能下降!rnrn rnrn在Core2平台上,可以使用EXT_SNOOP.ALL_AGENTS.HITM 事件来评测false sharing的影响,它监测的是总线(内存总线)传输的情况,如果HITM事件发生,则表明总线上响应端的cache正处于修改状态,这恰恰反映了false sharing问题的根源。rnrnrnvtune的手册对于EXT_SNOOP.ALL_AGENTS.HITM 的解释的:rnrnThis event counts the snoop responses to bus transactions. Responses can be counted separately by type and by bus agent. With the 'THIS_AGENT' mask the event counts snoop responses from this processor to bus transactions sent by this processor. With the 'ALL_AGENTS' mask the event counts all snoop responses seen on the bus. rnrn rnrn先看看看看上面这段代码的测量结果吧!rnrn[img=http://p.blog.csdn.net/images/p_blog_csdn_net/softarts/EntryImages/20090601/false_sharing_1.jpg][/img]rn rnrn采用sampling测量,EXT_SNOOP.ALL_AGENTS.HITM 发生次数1175次,CPU_CLK 为6373,INST_RETIRED为3796rnrn rnrnfalse sharing的解决也很简单,只要把共享的数据放到不同的cache line中即可,例如,将代码改为:rnrn rnrnunsigned char VectorA[100];rnunsigned char VectorB[100];rnrn rnrn这样,使用的仍然是VectorA[0~9]和VectorB[0~9],VectorA[10~99]则充当了一个pad占位符,把同一条cache line(64bytes)占满。rnrn rnrn解决false sharing问题后的测量数据为:rnrn rn[img=http://p.blog.csdn.net/images/p_blog_csdn_net/softarts/EntryImages/20090601/2false_sharing.jpg][/img]rn rnrnEXT_SNOOP.ALL_AGENTS.HITM 显著降到179次,CPU_CLK 降为1847,由于指令个数没有太大变化,INST_RETIRED为3370。通过程序中内嵌计时函数的方法也能得到接近的结果。rnrn rnrn总结,解决false sharing问题的方法:rnrn1. 增大数组元素的间隔使得由不同线程存取的元素位于不同的cache line上rn2. 在每个线程中创建全局数组各个元素的本地拷贝,然后结束后再写回全局数组rnrnfalse sharing是多核程序开发的常见问题,需要引起程序员们的重视。rnrnrn本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/softarts/archive/2009/06/01/4232467.aspx 论坛

没有更多推荐了,返回首页