暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

volatile 会影响性能吗

阿东编程之路 2022-10-04
810


我们之前在深入浅出volatile关键字中对 volatile 已经有所介绍,使用 volatile 主要有两个作用,一是保证可见性,二是防止指令重排,算是一个轻量级的锁但不能保证原子性(相关证明可以看下深入浅出volatile关键字);那使用 volatile 到底对性能有影响吗?今天我们就这个问题来研究下。
由于计算机硬件的高速发展,CPU(中央处理器)的处理速度越来越快,和主内存之间的速度差异也越来越大;程序进行指令或数据处理时需要将指令或数据从主内存加载到 CPU 的寄存器,而由于这样的速度差异存在,导致CPU大部分时间都在等待主内存的存取,所以为了提高 CPU 的利用率,在 CPU主内存 之间加入高速缓存。
目前计算机主要有两种存储器 SRAM 和 DRAM
  • SRAM 是静态存储器,只要处于通电状态,内部数据就可以保存,一旦断电数据就会丢失。一般被集成在CPU里用做高速缓存。
  • DRAM 是动态存储器,其中的存储单元使用电容保存电荷的方式开存储数据,电容会不断漏电,所以需要定时刷新充电才能保持数据不丢。一般被用作主内存。

CPU 高速缓存
高速缓存到现在已经发展到了三级:
  • L1 高速缓存:每个 CPU 核心都有一个属于自己的 L1 高速缓存,通常内置在CPU核心旁边,能存储 32 kb 的数据;存取需要 4 个CPU时钟周期。
  • L2 高速缓存:L2 同样也是每个核心都有的,离内核远一些,但是存储空间更大,能存储 256kb 的数据;存取需要 11  个CPU时钟周期。
  • L3 高速缓存:L3 一般是多个核心共享的高速缓存,能存储数个 MB 的数据;存取需要 39 个CPU时钟周期。
时钟周期是计算机中最小的时间单位,纳秒级的,具体时间取决于 CPU 的主频。
当 CPU 读取数据时,会先从 L1 缓存读取,命中则直接返回;如果未命中则去 L2 缓存读取,如果还未命中则去 L3 缓存读取,L3 缓存也没有的话就到主内存读取,并将读取的数据依此写入多级高速缓存中。

再提一句,SRAM 的存取速度要比 DRAM 快很多,主内存的存取需要 107 个时钟周期。
这时候你肯定会问,既然高速缓存比主内存快很多,为什么不干脆把高速缓存设置大点呢?
  • 性能上其实存储的数据小,寻址的速度更快,也就能更好的利用 SRAM 的性能;打个比方,高速缓存就像我们衣服上的口袋,主内存就像我们的背包,如果口袋特别大装的东西很多,我们就要花上不少时间去寻找口袋的目的就是快速存取
  • 成本上:就一句话,SRAM 贵!

而有了高速缓存,肯定就需要保证不同 CPU 内核高速缓存之间数据的一致性,不同厂商的一致性协议各不相同:MSI、MESI、MOSI。我们简单聊下使用最广泛的 MESI 协议:
  • MESI 协议其实就是:当有 CPU 对某「缓存行」进行修改时,总线会嗅探到修改,并通知其他 CPU 核心将本地的高速缓存设置为失效状态,下次读缓存时,如果发现该缓存行已经失效,会直接从主内存读取数据;但是这个修改需要等待其他 CPU 将缓存设置为失效状态再写回主内存,性能比较差所以引入了 Store Buffer(写缓冲),有修改操作时会先写到 Store Buffer 然后直接返回,等待后续所有 CPU 中设置该缓存失效完成后将修改写回主内存,从而保证不同 CPU 核高速缓存之间数据的一致性。总之是种最终一致性的实现。

有了这些理论基础后,回到我们最开始的问题,volatile 会影响性能吗?
我们来分析下 volatile 是如何保证可见性的:
有了一致性协议,数据写完后,会存在一定的延迟后才写到主内存,并发问题之一的可见性是无法保证的;而 volatile 可以保证线程立即可见:如果使用 volatile, 则会在读写时生成一个 lock 前缀,会将等待其他高速缓存中的缓存行设置为失效,接着将 Store Buffer 中的数据立刻写回主内存读也直接从主内存直接读取而不是高速缓存
如果使用了 volatile 关键字的变量,根据上面的分析则相当于禁用了高速缓存,再往上又分析了高速缓存对性能提升有着极大的作用,所以禁用性能肯定会大打折扣,我们来看代码验证下:
先看下不加 volatile 的变量的存取速度:
    public class VolatileDemo {


    private static class NoVolatile {
    long a;
    }
    static NoVolatile[] array = new NoVolatile[2];
    static {
    array[0] = new NoVolatile();
    array[1] = new NoVolatile();
    }


    public static void main(String[] args) {
    int times = 100000000;
    Stopwatch write = Stopwatch.createStarted();
    for(int i = 0; i < times; i++) {
    array[0].a = i;
    }
    System.out.println(String.format("不使用 volatile,写%s次,耗时%sms",
    times, write.elapsed(TimeUnit.MILLISECONDS)));
    Stopwatch read = Stopwatch.createStarted();
    for(int i = 0; i < times; i++) {
    long temp = array[0].a;
    }
    System.out.println(String.format("不使用 volatile,读%s次,耗时%sms",
    times, read.elapsed(TimeUnit.MILLISECONDS)));
    }
    }
    结果:
      不使用 volatile,写100000000次,耗时128ms 
      不使用 volatile,读100000000次,耗时44ms

      再看下加 volatile 的变量存取速度:
        public class VolatileDemo {


        private static class HasVolatile {
        volatile long a;
        }
        static HasVolatile[] array = new HasVolatile[2];
        static {
        array[0] = new HasVolatile();
        array[1] = new HasVolatile();
        }


        public static void main(String[] args) {
        int times = 100000000;
        Stopwatch write = Stopwatch.createStarted();
        for(int i = 0; i < times; i++) {
        array[0].a = i;
        }
        System.out.println(String.format("使用 volatile,写%s次,耗时%sms",
        times, write.elapsed(TimeUnit.MILLISECONDS)));
        Stopwatch read = Stopwatch.createStarted();
        for(int i = 0; i < times; i++) {
        long temp = array[0].a;
        }
        System.out.println(String.format("使用 volatile,读%s次,耗时%sms",
        times, read.elapsed(TimeUnit.MILLISECONDS)));
        }
        }
        结果:
          使用 volatile,写100000000次,耗时743ms 
          使用 volatile,读100000000次,耗时155ms

          可以看得出来,加了 volatile 后存取速度下降的很厉害。所以结论是 volatile 会影响性能。
          其实 volatile 还不只“禁用高速缓存”这个原因造成性能差,还有个叫做缓存行伪共享的问题会降低写入性能

          在分析之前先介绍下缓存行和伪共享问题。

          缓存行(cache line)
          高速缓存由多个缓存行组成,CPU 向内存取数据会取一小块固定大小的数据到 CPU 高速缓存,这一小块数据就被称作缓存行;缓存行是 CPU 中可分配的和操作的最小存储单元,具体大小和 CPU 架构有关,64 位操作系统下基本上都是 64 字节;缓存行事实上也是一种优化,只要是数据结构在内存中连续的(比如数组)进行顺序读取性能都会很好。
          伪共享问题
          假设有 a,b 两个变量(long 型处于连续的内存中,有两个并发线程 ThreadA,ThreadB 分别属于内核 1 和内核 2;此时 ThreadA 线程想读取 a,由于缓存行的特性会读取 64 字节的数据,而两个 long 型是 16 字节,所以会把 a,b 两个变量都读进内核 1 的高速缓存;然后 ThreadB 这时想要修改变量 b 的值,根据上文我们介绍的 MESI 缓存一致性协议,会将修改操作写进写缓冲(store buffer),总线会监听到缓存行的变化将别的核心高速缓存中的该缓存行设置为失效状态,当所有内核中该缓存行都为失效状态后会通知到 store buffer 最终写入主内存。后面线程 ThreadA 想要再次读取变量 a 时,发现 a 所在的缓存行已经为失效状态,就需要从主内存重新读取了。
          两个不相关的变量处于同一个缓存行,每次修改哪个变量都会导致所有内核中缓存行失效,这个问题就叫做伪共享。
          而像这种普通变量的伪共享问题主要影响的是读性能写性能的影响由于有写缓冲的优化倒不是特别明显。

          那 volatile 修饰的变量呢?
          我们上面说了加了 volatile 相关与禁用了高速缓存,读就会直接从主内存读取,所以伪共享问题对 volatile 变量的「读」没有太大影响;但是「写」的性能影响就挺大了,因为写的时候相当于不使用写缓冲,等待所有内核都设置失效状态后写入主内存才结束
          解决伪共享问题也有通用的方案,就是利用「空间换时间的思想」增加变量之间的地址间隔,使得不同变量处于不同的缓存行上,就不会相互影响了。
          我们来验证下
          未进行数据填充:
            public class Demo {


            private static class Node {
            volatile long l;
            }
            // 使用数组让两元素尽量处于同一缓存行中
            static Node[] array = new Node[2];


            static {
            array[0] = new Node();
            array[1] = new Node();
            }


            public static void main(String[] args) throws Exception {


            CountDownLatch count = new CountDownLatch(2);
            Stopwatch stopwatch = Stopwatch.createStarted();
            // 两个线程分别写入 1 亿次
            Thread write0 = new Thread(() -> {
            for (long i = 0; i < 100000000L; i++) {
            array[0].l = i;
            }
            count.countDown();
            });
            Thread write1 = new Thread(() -> {
            for (long i = 0; i < 100000000L; i++) {
            array[1].l = i;
            }
            count.countDown();
            });
            write0.start();
            write1.start();
            count.await();
            System.out.println(String.format("耗时={%s}ms",
            stopwatch.elapsed(TimeUnit.MILLISECONDS)));
            }
            }
            结果:
              耗时={1951}ms

              进行对齐填充后,保证一个 Node 对象至少大于 64 字节:
                public class ContentDemo {


                private static class Pad {
                long l1, l2, l3, l4, l5, l6, l7;
                }
                private static class Node extends Pad {
                volatile long l;
                }
                static Node[] array = new Node[2];
                static {
                array[0] = new Node();
                array[1] = new Node();
                }


                public static void main(String[] args) throws Exception {


                CountDownLatch count = new CountDownLatch(2);
                Stopwatch stopwatch = Stopwatch.createStarted();
                        // 两个线程分别写入 1 亿次
                Thread write0 = new Thread(() -> {
                for (long i = 0; i < 100000000L; i++) {
                array[0].l = i;
                }
                count.countDown();
                        });
                Thread write1 = new Thread(() -> {
                for (long i = 0; i < 100000000L; i++) {
                array[1].l = i;
                }
                count.countDown();
                });
                write0.start();
                write1.start();
                count.await();
                System.out.println(String.format("耗时={%s}ms",
                stopwatch.elapsed(TimeUnit.MILLISECONDS)));
                }
                }
                结果:
                  耗时={985}ms
                  可以看得出来优化的效果很明显。
                  在高性能内存队列 Disruptor 中也是该解决方案:

                  JDK 本身也提供了 @Contented 注解来对变量进行对齐填充,解决伪共享问题,和上面声明多个空变量的方式进行间隔效果一样,在 JUC 包下的 ConcurrentHashMap、Striped64 等源码工具类中也有应用, 感兴趣的话可以去了解下。
                  当然 volatile 肯定比加锁性能强很多,对于想解决重排序和可见性问题可以使用 volatile;当我们不得已使用 volatile 且想对性能进一步提升时建议以上述「空间换时间」的方式解决。

                  总结
                  本文通过 CPU 高速缓存的架构和原理以及 MESI 缓存一致性协议分析了 volatile 为什么会影响性能, 还有伪共享问题对不加 volatile 和加 volatile 造成性能影响的区别以及解决方案。




                  https://tech.meituan.com/2016/11/18/disruptor.html
                  文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论