彻底搞清楚Java并发 (二) 底层实现

Java代码 -> Java字节码 -> 汇编指令(汇编指令是cpu指令的集合)

Volatile

Java语言提供了Volatile,在某些情况下比上锁要更加方便,如果一个成员变量被声明为Volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的

术语 英文单词 术语描述
内存屏障 memory barriers 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行 cache line CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行的时候,会加载整个缓存行,现代CPU需要执行几百次CPU指令
原子操作 atomic operations 不可中断的一个或一系列操作
缓存行填充 chache line fill 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个告诉缓存行到适当的缓存(L1, L2, L3或所有)
缓存命中 cache hit 如果进行高速缓存行填充操作的内存位置仍然是下一次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取
写命中 write hit 当处理器将操作数写回到一个内存缓存的区域的时候,它会首先检查这个缓存的内存地址是不是在缓存行中,如果存在一个有效的缓存行,则处理怄气将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中
写缺失 write misses the cache 一个有效的缓存行被写入不存在的内存区域

JIT

JIT 是 just in time 的缩写, 也就是即时编译编译器。使用即时编译器技术,能够加速 Java 程序的执行速度。下面,就对该编译器技术做个简单的讲解。

首先,我们大家都知道,通常通过 javac 将程序源代码编译,转换成 java 字节码,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多。为了提高执行速度,引入了 JIT 技术。

在运行时 JIT 会把翻译过的机器码保存起来,以备下次使用,因此从理论上来说,采用该 JIT 技术可以接近以前纯编译技术。下面我们看看,JIT 的工作过程。

彻底搞清楚Java并发 (二) 底层实现_第1张图片
JIT

Hot Spot编译(class字节码直接编译为机器指令)

当 JVM 执行代码时,它并不立即开始编译代码。这主要有两个原因:

首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。

当然,如果一段代码频繁的调用方法,或是一个循环,也就是这段代码被多次执行,那么编译就非常值得了。因此,编译器具有的这种权衡能力会首先执行解释后的代码,然后再去分辨哪些方法会被频繁调用来保证其本身的编译。其实说简单点,就是 JIT 在起作用,我们知道,对于 Java 代码,刚开始都是被编译器编译成字节码文件,然后字节码文件会被交由 JVM 解释执行,所以可以说 Java 本身是一种半编译半解释执行的语言Hot Spot VM 采用了 JIT compile 技术,将运行频率很高的字节码直接编译为机器指令执行以提高性能,所以当字节码被 JIT 编译为机器码的时候,要说它是编译执行的也可以。也就是说,运行时,部分代码可能由 JIT 翻译为目标机器指令(以 method 为翻译单位,还会保存起来,第二次执行就不用翻译了)直接执行。

第二个原因是最优化,当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。

我们将在后面讲解这些优化策略,这里,先举一个简单的例子:我们知道 equals() 这个方法存在于每一个 Java Object 中(因为是从 Object class 继承而来)而且经常被覆写。当解释器遇到 b = obj1.equals(obj2) 这样一句代码,它则会查询 obj1 的类型从而得知到底运行哪一个 equals() 方法。而这个动态查询的过程从某种程度上说是很耗时的。

JVM 注意到每次运行代码时,obj1 都是 java.lang.String 这种类型,那么 JVM 生成的被编译后的代码则是直接调用 String.equals() 方法。这样代码的执行将变得非常快,因为不仅它是被编译过的,而且它会跳过查找该调用哪个方法的步骤。如果下次执行代码时,obj1 不再是 String 类型了,JVM 将不得不再生成新的字节码。尽管如此,之后执行的过程中,还是会变的更快,因为同样会跳过查找该调用哪个方法的步骤。这种优化只会在代码被运行和观察一段时间之后发生。这也就是为什么 JIT 编译器不会直接编译代码而是选择等待然后再去编译某些代码片段的第二个原因。

寄存器和主存

其中一个最重要的优化策略是编译器可以决定何时从主存取值,何时向寄存器存值。考虑下面这段代码:

清单 1. 主存 or 寄存器测试代码
public class RegisterTest {
    private int sum;
     
    public void calculateSum(int n) {
        for (int i = 0; i < n; ++i) {
            sum += i;
        }
    }
}

在某些时刻,sum 变量居于主存之中,但是从主存中检索值是开销很大的操作,需要多次循环才可以完成操作。正如上面的例子,如果循环的每一次都是从主存取值,性能是非常低的。相反,编译器加载一个寄存器给 sum 并赋予其初始值,利用寄存器里的值来执行循环,并将最终的结果从寄存器返回给主存。这样的优化策略则是非常高效的。但是线程的同步对于这种操作来说是至关重要的,因为一个线程无法得知另一个线程所使用的寄存器里变量的值,线程同步可以很好的解决这一问题。

排序的第二个主要问题是,一个线程写了一个变量,然后很快读取,有可能从读缓冲中获得比缓存子系统中最新值要旧的值。为了克服这一点,并且确保最新值可见,线程不能从本地读缓冲中读取值。可以使用屏障指令,防止下一个读操作在另一线程的写操作之前发生。

CPU缓存和缓存一致性

单核CPU cache结构

彻底搞清楚Java并发 (二) 底层实现_第2张图片
单核CPU cache

在单核CPU结构中,L1分成了指令(L1P)和数据(L1D)两部分,而L2则是指令和数据共存。

彻底搞清楚Java并发 (二) 底层实现_第3张图片
多核CPU cache

多核的CPU cache结构如上图所示,这个时候多个核心之间共享L3高速缓存,多核有自己私有的L1和L2缓存

彻底搞清楚Java并发 (二) 底层实现_第4张图片
缓存行的结构

补充

为了保证缓存的一致性,缓存控制器跟踪每一个缓存行的状态,这些状态的数量是有限的。Intel使用MESIF协议,AMD使用 MOESI。在MESIF协议下,缓存行处于以下5个状态中的1个。

被修改(Modified):表明缓存行已经过期,在接下来的场景中要写回主内存。当写回主内存后状态将转变为排它( Exclusive )。

独享(Exclusive) 表明缓存行被当前核心单独持有,并且与主内存中一致。当被写入时,状态将转变为修改(Modified)。要进入这个状态,需要发送一个 Request-For-Ownership (RFO)消息,这包含一个读操作再加上广播通知其他拷贝失效。

共享(Shared):表明缓存行是一个与主内存一致的拷贝。

失效(Invalid):表明是一个无效的缓存行。

向前( Forward ):一个特殊的共享状态。用来表示在NUMA体系中响应其他缓存的特定缓存。

为了从一个状态转变为另一个状态,在缓存之间,需要发送一系列的消息使状态改变生效。对于上一代(或之前)的 Nehalem核心的Intel CPU和 Opteron核心的AMD CPU,插槽之间确保缓存一致性的流量需要通过内存总线共享,这极大地限制了可扩展性。如今,内存控制器的流量使用一个单独的总线来传输。例如,Intel的QPI和AMD的HyperTransport就用于插槽间的缓存一致性通讯。、

缓存控制器作为L3缓存段的一个模块连接到插槽上的环行总线网络。每一个核心,L3缓存段,QPI控制器,内存控制器和集成图形子系统都连接到这个环行总线上。环由四个独立的通道构成,用于:在每个时钟内完成请求、嗅探、确认和传输32-bytes的数据。L3缓存包含所有L1和L2缓存中的缓存行,这有助于帮助核心在嗅探变化时快速确认改变的行。用于L3缓存段的缓存控制器记录了哪个核心可能改变自己的缓存行。

如果一个核心想要读取一些数据,并且这些数据在缓存中并不处于共享、独占或者被修改状态;那么它就需要在环形总线上做一个读操作。它要么从主内存中读取(缓存没命中),要么从L3缓存读取(如果没过期或者被其他核心嗅探到改变)。在任何情况下,一致性协议都能保证,读操作永远不会从缓存子系统返回一份过期拷贝。

在Java中对一个volatile变量进行写操作,除了永远不会在寄存器中分配之外,还会伴随一个完全的屏障指令。在x86架构上,屏障指令在读缓冲排空之前,会显著影响放置屏障的线程的运行。

instanse = new Singleton();//instanse被volatile修饰

转变为汇编代码

0x01a3deld: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);

有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,也就是有lock前缀的指令,并且会产生两个效果

  • 将当前处理器缓存行的数据写回到主存中
  • 这个写回操作会使得其他CPU里缓存了该内存地址的数据无效

为了提高速度,处理器设计的时候不直接和内存进行通信,而是先把数据读取到内部缓存(L1,L2)等,但是操作完不知道何时才能写回主存。这个观点我之前一直不是很理解为什么,之前学到的JMM的大概意思是,数据都在主存中,线程创建自己的工作内存,并且把数据都在自己的工作内存中进行处理,这样其实工作内存就是映射到硬件层面的真实三级缓存,JMM规定的线程模型中的工作内存,其实就是会出现这种问题,在对工作内存中变量修改的之后的最新值,因为不能够决定是否能够立刻写回主存,所以会造成数据的不可见性

汇编指令的lock前缀能够做到将这个变量的缓存行数据写回主存,但是这个时候,别的线程仍然可能使用自己工作内存(高速缓存)中的旧值,这个时候虽然使用了lock前缀,但是同样解决不了问题,在多处理器下,为了保证各个处理器的缓存是不是一致的,就会实现缓存一致性协议

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效,当处理器对这个数据进行修改的时候,会重新从内存中读取最新的值,并且读取到处理器的缓存行里

volatile的两条实现原则

  • Lock前缀指令会引起处理器缓存写回内存, 在早期的CPU中,Lock前缀指令导致在执行指令期间,处理器会声言LOCK#信号,在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何内存,但是这样的话开销太大了,现在的CPU设计的Lock前缀指令不会锁总线,而是锁住缓存,如果访问的内存区域已经缓存至CPU中,则不会声言LOCK#信号,它会锁定这块内存区域对应的缓存行,并写回到主存中,并且将会采用缓存一致性去保证别的CPU读取的是最新的值
  • 一个处理器的缓存写回内存之后,将会导致其余处理器的缓存失效,在多核心的处理器系统中进行操作的时候,IA-32和Intel 64处理器能够嗅探其他处理器访问系统内存和它们内部的缓存,处理器使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致,例如如果通过嗅探一个处理器来检测其他处理器打算写内存的地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址的时候,强制执行缓存行填充

synchronized

jdk 1.6对synchronized进行了各种优化之后,有些情况下它就不是那么重量了

  • 对于普通同步方法,锁的是当前实例对象
  • 对于静态同步方法,锁的是当前类的Class对象
  • 对于同步代码块,锁的是synchronized括号中的对象

JVM保证了每个对象都有一个Monitor与之关联,并且通过Monitor对象来实现方法同步和代码块同步,但是两者的实现细节不一样,代码块的同步是monitorenter和monitorexit(2个)指令实现的,而方法同步是另一种实现方式,但是方法同步同样可以使用这种方式去实现,线程执行到monitorenter的时候将会去获取对象所对应的monitor,也就是说尝试获取该对象的锁

jdk1.6为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁"和"轻量级锁",在jdk 1.6中锁一共有4种状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以升级但是不能够降级

Java对象头

synchronized使用的锁是存在Java对象头中的,如果对象是数组类型,虚拟机使用3个字宽(Word)存储对象头,如果对象是非数组类型,则使用2个字宽存储对象头,在32位的虚拟机中,1个字宽等于4个字节,即32bit

例如,在32bit的JVM中如果对象处于未被锁定的状态下,那么对象头的32bit中的25bit用于存储对象的哈希码,4bit用于存储对象分代年龄,2bit用于存储锁的标志位,1bit固定为0

存储内容 标志位 状态
对象的哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

偏向锁

大多数情况下,锁总是很大程度上被同一条线程获取,为了让线程获得锁的代价更低而引入了偏向锁,当一个线程访问同步代码块并获取锁的时候,会在对象头和栈帧中的锁记录里存储锁偏向线程的ID,以后该线程在进入和退出同步块的时候就不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,则说明该线程已经获取了锁;如果测试失败,需要再测试一下Mark Word中的偏向锁是否被指为1(表示当前是偏向锁);如果没设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

偏向锁的撤销

偏向锁使用一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁

  • 偏向锁的撤销需要等待全局安全点(在这个时间点上没有正在执行的字节码)

    它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活跃状态,则将对象头置为无锁状态;如果线程仍然活着,拥有偏向锁的会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Work要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

你可能感兴趣的:(彻底搞清楚Java并发 (二) 底层实现)