JVM系列(三)之指令重排序与硬件层面的一致性

2021/03/25 JVM 共 3981 字,约 12 分钟

本篇将使用一个简单的Java demo来介绍类加载细节、介绍指令重排序、讲解简单的计算机硬件结构以及硬件层面的一致性实现

类加载验证

类加载过程回忆

  • 类加载的过程一共有三个大步骤,其中第二个步骤又被分为三个小步骤,通过这一系列的操作就可以实现一个类的成功加载
  • loading
    • 在这一阶段,会将静静躺在硬盘上面的class文件load到内存中交给JVM
  • linking
    1. verification
      • 验证阶段,在这个阶段会对class文件进行验证,确认其是否符合class文件标准,例如class文件的魔数检查就是在这一步,如果魔数不是cafe babe那么直接就会被JVM否认,无法进入下一步
    2. preparation
      • 在这一阶段,会将class中存在的静态变量赋予默认值,加了final修饰的静态变量直接赋初始值
    3. resolution
      • 在这一阶段,会将常量池中原本的符号引用全部转换成为真真正正指向内存中对象的内存引用
  • initialization
    • 在这一阶段才会给静态变量赋予真正的初始值

一个demo

image

image

  • 这是这段代码与它的执行结果,现在我们对这段代码做非常简单的改动,将 static count 和 static T 的位置进行调换,调换完毕之后我们再次执行

    image

    image

  • 我们可以发现,仅仅是位置发送变化,执行结果却完全不同,这里我们要稍微解释一下

  • 在第一个版本中,我们的count在T的前面,执行preparation的时候,count先于T进行默认值赋值,也就是说,首先会给count赋予0,然后给T赋予null;接下来在initialization阶段也会先给count赋予2,然后再new T,此时一旦创建T对象,那么就会执行count++代码,这时count就变成了3

  • 在第二个版本中,我们的T在count的前面,执行preparation的时候,T会优先赋值为null,然后会给count赋予0;接下来的initialization阶段会先给T初始化,T一旦初始化就执行count++代码,此时count变为1,然后就轮到count初始化,这样count就会被赋予2,这就是打印结果为2的原因

Double-Check Singleton和指令重排序

双重检查单例与volatile

  • 面试题中有一道典中典,那就是双重检查单例的INSTANCE实例究竟要不要加上volatile,其实在历史上这个问题争执了很久,但是最后的结果是加上volatile的一方成为了最终胜利者
  • INSTANCE实例加上volatile不是为了保证线程之间的可见性,而是为了保证cpu不会执行指令重排序

指令重排序简单介绍

  • 众所周知,一行Java的代码在cpu的层次上不一定是一条指令,我们以 new Object(); 为实例

    image

    image

    • 我们可以看到,首先cpu的第一步就是在内存空间中申请一个地址空间用于放置Object对象,此时这个Object对象中的成员变量,除了被final修饰或者被static修饰的,全部被赋予默认值,也就是说,int被赋予0,double被赋予0.0,object被赋予null
    • 接下来会调用invokespecial方法,此时就会调用构造方法,将object对象构造出来,并且将里面的成员变量赋予初始值
    • 最后就会将这个object对象的引用指向这个对象,这里没有使用引用,所以直接就被pop出去了
  • 如果发生了指令重排序,那么pop指令完全有可能在invokespecial指令之前执行!

半初始化问题

  • 我们现在有一个双重检查单例需要实例化,假如第一个线程过来试图访问单例对象,但是发现单例并没有实例化,于是就会进入实例化阶段,非常不幸的是,初始化的过程中发生了指令重排序,并且pop方法在invokespecial方法之前执行,那么就会在这个实例刚刚得到一片内存地址,对象内所有成员变量都还是默认值的时候就被引用指向了。更更不幸的是,此时有另外一个线程过来,也访问了这个单例对象,发现这个对象的值并不为null,于是就将这个对象拿去使用了,可是我们都知道,此时这个对象还没有被初始化,成员变量的值都还是默认值。于是拿去使用这个对象的线程的结果就可想而知了
  • 以上就是著名的半初始化问题
  • 有人就会想,为啥要有指令重排序这个特点,如果没有指令重排序就不会有这么恶心的问题了
  • 这个问题将会在后面详细解释

简单计算机组成结构

cpu的存储结构

image

  • 我们现在的计算机存储结构就是一个金字塔型,速度越快,容量越小
  • 当我们的内容过大,读取速度要求不高的时候,文件可以放在硬盘上;如果我们对这个文件的读写速度要求变高的时候,那么就会被放在主存上;如果我们对这个文件的访问更加频繁的化,那么它的优先级还会被提高
  • 当我们的cpu找一个变量的时候,首先会从寄存器中找,找不到就去一级缓存(L1)中找,如果找不到就回去二级缓存(L2)中找,还找不到会继续往下找。假如说在硬盘上找到了,那么这个变量首先会被放到主存中,接着被放到三级缓存中,然后是二级缓存,一级缓存,最后被放到寄存器中使用

各级缓存之间的效率比较

image

假如我们以寄存器中的速度为基准的化,那么一级缓存比寄存器慢3-4倍,二级换粗是10倍三级缓存是40-45倍,主存那就是160倍往上了,对于我们来说速度非常快的主存,在cpu面前慢的和蜗牛一样

硬件一致性保证

image

  • 我们在前面提到过,cpu想要操作一个数据,首先会从将之从主存读到L3高速缓存,然后读到L2,读到L1高速缓存,最后放到寄存器中操作。但是L3是所有cpu共享的高速缓存,但是从L2开始就是每一个cpu自己独享一个缓存空间,那么我从主存中读取一个x加入到自己的cpu寄存器中并且对它进行了修改,那么其他cpu该怎么办呢?以下有解决的方案

    1. 总线锁

      当我们的cpu想要读取一个变量的时候都是通过总线和主存相互连接的,当我们一个cpu想要读取一个数的时候就可以将这条总线锁住,等到操作完毕就可以释放总线,这样就可以保证数据一致性

      总线锁的缺陷很大,当我们想要对x修改的时候,别的cpu可能想要对非常远的其他位置的y进行修改,但是因为总线被锁住了,另外一个cpu就只能等待第一个cpu将x修改完毕并且释放了总线锁之后才可以利用总线

      早期的cpu就是使用的总线锁,但是无疑,总线锁效率非常低下

    2. 缓存一致性协议

      总线锁的效率极其低下,很多情况下不会使用,现代的计算机最常用的就是各种各样的一致性协议,不同的cpu厂商的协议也不一样,因为大家很多都使用的intel,所以这里我们聊聊intel的一致性协议MESI

      Intel给读取的变量做了4个标记,分别是

      • modified:这个变量我当前这个cpu修改过了
      • exclusive:这个变量当前只有我这个cpu在使用
      • shared:这个变量我在读取的时候,其它cpu也在读取
      • invalid:这个变量我在使用的时候,被其他变量修改了

      一个cpu在对一个变量修改之后,这个cpu会将之标记为modified,但是在其他cpu哪里就会是invalid

      当我一个cpu想要使用一个变量的时候,这个变量的标记变为了invalid,那么我们就可以重新去主存中读取一遍,缓存一致性协议也可以被称为总线锁

    3. 缓存锁+总线锁

      现代cpu虽然拥有了缓存一致性协议,但是仍然无法完全消灭总线锁,因为当一个读取的变量或对象大于一个缓存行的时候,还是需要总线锁,所以现代cpu实现硬件一致性的方式是通过缓存锁+总线锁的方式

缓存行(cache line)与伪共享

  • 当我们把一个int变量读到cpu自己的缓存里面的时候,并不是说只读取这个int的4个字节,而是一次性将这个int变量所在的缓存行一起读进去,这样可以提高效率,一般来说,现在的缓存行大小为64个字节

  • 这里就会出现一个非常有意思的现象

    image

    我第一个cpu想要对x进行修改,于是将x的这个缓存行直接读进去;第二个cpu想要对y进行修改,于是直接将y所在的整个缓存行读到自己的缓存里面;非常巧合的是,x和y处在同一个缓存行里,如果这个时候cpu对x修改完毕,那么就会通知其他cpu这个缓存行invalid了,需要你们重新读取一下;另外一个想要修改y的cpu接收到了消息就会将这个缓存行重新读取一遍,这个问题就是伪共享

  • 伪共享定义

    位于同一个缓存行的不同两个对象被被不同的两个cpu锁定,从而导致相互影响的现象

  • 伪共享的解决

    image

    这里我们直接看 disruptor 的源码,它的指针定义在前面加上了7个long类型做填充,后面也加上了7个long类型做填充,这就保证了这个指针的前面和后面都不会和其他的变量处于同一缓存行,这就可以极大提示速度了

CPU指令重排序与合并读写

指令重排之读合并

  • 我们都知道,一个cpu的速度是主存速度的160倍以上当我们的一个cpu调用了一条去主存里面读取数据的时候cpu会等待,但是这种等待非常浪费效率,我们的cpu就会继续执行后面的指令,只要这个指令和第一条指令没有关联,就会优先执行,一直到一开始去主存中读取数据的指令执行完毕后继续执行,这样的情况最后表现出来的现象就是cpu的指令最后执行的时候是乱序的
  • cpu为了提高效率,会在一条指令执行(去内存中读数据,效率慢100倍起步)的同时去执行下一条指令,前提是下一条指令的执行与上一条指令没有依赖关系。
  • 在读取变量的时候执行其他指令很好理解,这就是合并读

指令重排之写合并

  • 我们有一个cpu对一个变量进行了修改,那么他就会将这个变量写回去,在写回去的过程中,后续的指令也对这个变量进行了修改,那么cpu就会将这个修改过程合并,将这个修改的内容合并写到一个写合并缓存(write combining store buffers)最后在一起写回去,究其根本,还是因为cpu再往其他缓存区里写回数据的速度太慢了,以至于后续的指令都已经重新修改了
  • 写合并缓存区位于寄存器和L1缓存之间,这个写合并缓存区非常珍贵,一般来说只有4个字节

文档信息

Search

    Table of Contents