其他分享
首页 > 其他分享> > 【线程、锁】MESI协议以及带来的问题:伪共享

【线程、锁】MESI协议以及带来的问题:伪共享

作者:互联网

文章目录

1、概述

本文和后续文章将着眼CPU的工作原理阐述伪共享的解决方法和volatile关键字的应用。

2、复习CPU工作原理

2.1、CPU工作原理

要清楚理解本文后续内容,就需要首先重新概述一下JVM的内存工作原理。当然JVM的内存模型是一个可以专门作为另一个专题的较复杂知识点,所以这里我们只描述对下文介绍的伪共享、volatile关键字相关联的一些要点。这里我们不讨论JVM的内存模型,因为本专题之前的内容有过相关讨论(本专题后续还会讨论),也因为JVM内存模型的操作最终会转换成如下图所示的在内存、寄存器、CPU内核中的操作过程。

在这里插入图片描述
如上图所示,当一个JVM线程进入“运行”状态后(这个状态的实际切换由操作系统进行控制),这个线程使用的变量(实际上存储的可能是某个变量实际的值,也可能是某个对象的内存地址)将基于缓存行的概念被换入CPU缓存(既L1、L2缓存)。通常情况下CPU在工作中将优先尝试命中L1、L2缓存中的数据,如果没有命中才会到主存中重新读取最新的数据,这是因为从L1、L2缓存中读取数据的时间远远小于从主存中读取数据的时间,且由于边际效应的原因,往往L1、L2中的数据命中率都很高(参见下表)

在这里插入图片描述
(上表中时间的单位是纳秒。1秒=1000000000纳秒,也就是说1纳米极其短暂,短到光在1纳秒的时间内只能前进30厘米)。

请注意:每一个CPU物理内核都有其独立使用的L1、 L2缓存,一些高级的CPU中又存在可供多核共享的L3缓存,以便MESI的工作过程中能在L3中进行数据可见性读取。另外请注意,当CPU内核对数据进行修改时,通常来说被修改的数据不会立即回存到主存中(但最终会回写到主存中)。

那么当某一个数据(对象)A在多个处于“运行”状态的线程中进行读写共享时(例如ThreadA、ThreadB和ThreadC),就可能出现多种问题

2.2、MESI 协议及 RFO 请求

为了解决这个问题,CPU工程师设计了一套数据状态的记录和更新协议——MESI(中文名:CPU缓存一致性协议)。这个规则实际上由四种数据状态的描述构成,如下图所示:

在这里插入图片描述
(图片摘自网络)其中:

这里请注意一个关键点CPU对于缓存状态的记录是以“缓存行”为单位。举个例子,一个CPU独立使用的一级缓存的大小为32KB,如果按照标准的一个“缓存行”为64byte计算,这个一级缓存最大容纳的“缓存行”为512行。一个缓存行中可能存储了多个变量值(例如一个64byte的缓存行理论上可以存储64 / 8 = 8个long型变量的值),那么只要这8个long型变量的任何一个的值发生了变化,都会导致该“缓存行”的状态发生变化(造成的其中一种后果请参见本文后续2.3小节描述的内容)。

2.3、MESI 协议存在的问题

上述内容就是MESI状态变化的主要过程,请注意这里提到的RFO请求过程放在计算机的整个计算过程中来看,是极为短暂的,但如果放在寄存器工作环境下来看,则是比较耗费时间的(单位在100纳秒以上)。在高并发情况下MESI协议还存在一些问题:

3、伪共享及解决方法

上文2.3小节提到的多个CPU内核抢占同一缓存行上的不相关变量所引起的“活锁”情况,称之为伪共享。在高并发情况下,这种MESI协议引起的“活锁”情况反而降低了整个系统的性能。并且由于CPU和寄存器的工作调配并不是由Java程序员直接负责,所以这种伪共享问题很难发现和排查。

3.1、伪共享示例

请看如下代码片段:

/**
 * 伪共享示例
 */
public class FalseSharing1 {
    /**
     * 因为笔者做测试的电脑是8核CPU。 这里我们不考虑多线程的状态切换因素,只考虑多线程在同一时间的MESI状态强占因素
     */
    private static final int CORE_NUMBER = 4;

    private static VolatileClass[] volatileObjects = new VolatileClass[CORE_NUMBER];

    static {
        // 这里不能使用Arrays.fill工具,原因自己去看
        for (int index = 0; index < CORE_NUMBER; index++) {
            volatileObjects[index] = new VolatileClass();
        }
    }

    public static void main(String[] args) throws Exception {
        /*
         * 测试过程为: 1、首先创建和CORENUIMBER数量一致的线程对象和VolatileClass对象。 2、这些线程各自处理各自的对应的VolatileClass对象,
         * 处理过程很简单,就是进行当前currentValue在二进制下的加法运算,当数值超过 达到2^32时终止 3、记录整个过程的完成时间,并进行比较
         * 
         * 我们主要来看,看似多个没有关系的计算过程在不同代码编辑环境下的时间差异 看整个3次的总时间(你也可以根据自己的实际情况进行调整,次数越多平均时间越准确)
         */
        long totalTimes = 0l;
        int maxTimes = 3;

        for (int times = 0; times < maxTimes; times++) {
            long startTime = System.currentTimeMillis();
            Thread[] testThreads = new Thread[CORE_NUMBER];

            for (int index = 0; index < CORE_NUMBER; index++) {
                testThreads[index] = new Thread(new Handler(volatileObjects, index));
                testThreads[index].start();
            }

            // 等到所有计算线程终止,才继续了
            for (int index = 0; index < CORE_NUMBER; index++) {
                testThreads[index].join();
            }
            long endTime = System.currentTimeMillis();
            totalTimes += (endTime - startTime);

            System.out.println("执行完第" + times + "次");
        }

        System.out.println("time arra = " + (totalTimes / maxTimes));
    }

    @SuppressWarnings("restriction")
    private static class VolatileClass {
        long currentValue = 0l;
    }

    private static class Handler implements Runnable {
        private int index;
        private VolatileClass[] volatileObjects;

        public Handler(VolatileClass[] volatileObjects, int index) {
            this.index = index;
            this.volatileObjects = volatileObjects;
        }

        @Override
        public void run() {
            Long number = 0l;
            while (number++ < 0xFFFFFFFFL) {
                volatileObjects[index].currentValue = number;
            }
        }
    }
}

FalseSharing1例子参见《伪共享(false sharing),并发编程无声的性能杀手》《从Java视角理解伪共享(False Sharing)》,第一篇参考了第二篇,第二篇有L2缓存次数,更牛

以上代码在所描述的工作场景实际上在很多介绍伪共享的文章中都可以找到,笔者只是基于易读的目的出发进行了一些调整:代码中描述了N个线程(例如8个),每个线程持有独立的VolatileClass类的实例(注意,是“独立的”),每一个VolatileClass类的实例示例中只包括了一个长整型变量“currentValue ”,接下来我们让这些线程工作起来,各自对各自持有的currentValue 变量进行累加,直到达到0xFFFFFFFF这个上限值(注意,这里是位运算并不代表32位整形的最小值)。

那么整个代码在运行时就拥有了8个完全独立的currentValue工作在8个独立线程中,但是看似没有关联的8个变量赋值过程,却因为“有没有使用Contended注解”的区别,显示出较大的性能差异。如下表所示:

在这里插入图片描述

注意整个JDK使用的版本是JDK 8.0+,因为在不同的低版本的JDK版本下,体现性能差异的方式是不一样的;另外执行时,需要携带JVM参数“-XX:-RestrictContended”,这样Contended注解才能起作用

3.2、性能差异原因

那么我们基于寄存器对多个currentValue变量在缓存行的存取方式,结合上文提到的MESI协议状态的变化,来解释一下为什么执行结果上会有这样的性能差异:

3.3、特别说明

当笔者写作本篇文章的时候,查阅网络上的一些资料。但是发现其中一些文章对于伪共享的代码示意存在一些描述不完整的问题。当然这些问题都是可以理解的,因为有的文章发表时间都已经是3、4年前的事情了。很多文章中对于不同JDK处理“伪共享”的机制,没有分别进行说明。

上文已经提到有一种处理“伪共享”的方式,叫做“占位”。这种方式很好理解,就是将一个缓存行使用8个长整型变量全部占满(以单缓存行64byte为单位,其中一个对象的头描述战友8byte,所以实际上只需要7个长整型变量就可以全部占满),虽然这些变量中只有一个长整型在使用,但没有关系,因为保证了所有可能存在伪共享风险的变量肯定在不同的缓存行。如下代码示例:

// ......
public final static class VolatileLong {
  public volatile long value = 0L;
  // 这是6个占位长整型变量,暂用缓存行上多余的位置
  // 保证各个VolatileLong类型实例肯定不会在同一缓存行上
  public long p1, p2, p3, p4, p5, p6;
}
// ......

以上代码当然可以达到占位的目的,但实际上只能在JDK 1.7版本之前使用,因为JDK 1.7以及之后的版本会在Java文件的编译期将无用的变量自动忽略掉,这样就导致了设计失效

而JDK1.8以及以后的版本,提供了一个注解“@sun.misc.Contended”来表示一个类的变量需要启用避免“伪共享”的配置。但是该注解默认情况下只用于JDK的原生包,如果需要在自己的代码中使用该注解,就需要在在启动时程序时携带JVM参数“-XX:-RestrictContended”。

jdk1.8默认不开启@sun.misc.Contended注解


参考:
《线程基础:多任务处理(18)——MESI协议以及带来的问题:伪共享》

标签:状态,缓存,index,MESI,线程,寄存器,共享,CPU
来源: https://blog.csdn.net/m0_45406092/article/details/113250887