Java 并发编程的底层实现原理 —— 《Java 并发编程的艺术》

  |   0 评论   |   0 浏览

Java 代码在编译后会变成 Java 字节码,字节码被类加载器加载到 JVM 里,JVM执行字节码,最终需要转化为汇编指令在 CPU 上执行。Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 指令。

volatile的应用

volatile 是轻量级的 synchronized,如果 synchronized 的使用和 执行成本更低,因为它不会引起线程上下文切换和调度

volatile 的定义与实现原理

Java语言规范第3版中对 volatile的定义如下: Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁要更加方便。如果一个字段被声明称 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。

与 volatile 实现原理相关的 CPU 术语与说明。

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

volatile 是如何来确保可见性的呢?
在 X86 处理器下通过工具获取 JIT 编译器生成的汇编指令来查看对 volatile 进行操作时,CPU 会做什么

Java 代码如下:

instance = new Singleton(); //instance 是 volatile 变量

转换为汇编代码,如下。

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

Lock 前缀的指令在多核处理器下 会发生两件事情。

  • 1)将当前处理器缓存行的数据写回到系统内存。

  • 2) 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部
缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据 写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操 作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一 致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状 态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

下面来具体讲解 volatile 的两条实现原则。

1)Lock 前缀指令会引起处理器缓存回写到内存。 Lock前缀指令导致在执行指令期间,声 言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以 独占任何共享内存[2]。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕 竟锁总线开销的比较大。在8.1.4节有详细说明锁定操作对处理器缓存的影响,对于Intel486和 Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果 访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区 域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁 定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据

2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel 64处 理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致 性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统 内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的 缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理 器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理 器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充

synchronized 的实现原理与应用

随着 Java SE 1.6 对 synchronized 进行各种优化之后,有些情况下它就没那么重了。下面详细讲解 Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

synchronized 实现同步的基础:Java 中的每一个对象都可以作为锁。

  • 对于普通同步方法,锁是当前实例对象。

  • 对于静态同步方法,锁是当前类的 Class 对象。

  • 对于同步方法块,锁是 Synchronized 括号里配置的对象。

那么锁到底存在哪里呢?锁里面会存储什么信息呢?

Java 对象头

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

Java 对象头的长度

长度内容说明
32/64bitMark Word存储对象的hashCode 或 锁信息等
32/64bitClass Metadata Address存储对象类型数据的指针
32/64bitArray length数组的长度(如果当前对象是数组)

Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。32位 JVM 的 Mark Word 的默认存储结构如表

Java 对象头的存储结构

锁状态25bit4bit1bit 是否是偏向锁2bit 锁标志位
无锁状态对象的hashCode对象分代年龄001

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。可能编程存储一下4种数据。

image.png

image.png

锁的升级与对比

Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 "偏向锁"和"轻量级锁",
在 Java SE 1.6 中,锁一共有 4 中状态,级别从低到高依次是: 无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁

(1) 偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正 在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈 会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他 线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

image.png

(2)关闭偏向锁

偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟: -XX : BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁: -XX UseBiasedLocking= false ,那么程序默认会进入轻量级锁状态。

2 轻量级锁

(1)轻量级锁加锁
线程在执行同步块之前,JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word
(2)轻量级锁解锁
轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁

image.png

因为自旋会消耗 CPU ,为了避免午用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会恢复到轻量级锁状态。

锁的优缺点对比

image.png

原子操作的实现原理

1.术语定义

原子操作意为"不可被中断的一个或一系列操作"。
image.png

2.处理器如何实现原子操作

处理器使用语句对缓存加锁总线加锁的方式来实现多处理器之间的原子操作。

(1)使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。想要保证读改写共享变量的操作是原子的,就必须保证 CPU1 读改写共享变量的时候, CPU2 不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决这个问题的。总线索就是使用处理器提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

(2)使用缓存锁保证原子性

第二个机制是通过缓存锁定来保证原子性。总线索顶的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

频繁使用的内存会缓存在处理器的 L1、L2、和 L3 高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存 行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声 言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子 性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处 理器回写已被锁定的缓存行的数据时,会使缓存行无效

但是又两种情况下处理器不会使用缓存锁定。

  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。

  • 有些处理器不支持缓存锁定。对于 Intel 486 和 Pentium 处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

###3.Java 如何实现原子操作

Java 可以通过循环CAS 的方式来实现原子操作。

JVM 中的 CAS 操作正是利用了处理器提供的 CMPXCHG 指令实现的。

CAS 实现原子操作的三大问题

  • ABA 问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化 则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它 的值没有发生变化,但是实际上却变化了。ABA 问题的解决思路就是使用版本号.在变量前面追加上版本号。从Java 1.5 开始, JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。

  • 循环时间开销大自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升

  • 只能保证一个共享变量的原子操作。对多个共享变量操作时,循环 CAS就无法保证操作的原子性,这个时候就可以用锁。从Java 1.5开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里进行 CAS 操作

###(3)使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁。


标题:Java 并发编程的底层实现原理 —— 《Java 并发编程的艺术》
作者:zh847707713
地址:http://lovehao.cn/articles/2020/03/05/1583388378892.html