Java 代码在编译后变成 Java 字节码,字节码被类加载器加载到 JVM里执行,最终转换为汇编指令在CPU上执行,Java 中所所使用的并发机制依赖于 JVM 的实现和 CPU 指令;Java 中的大部分并发容器和框架依赖于 volatile 和原子操作的实现原理,了解这些原理对我们进行并发编程会有所帮助。
本文将依次介绍:CPU术语、Java对象头、锁的升级与对比、volatile 实现原理、synchronized 实现原理、原子操作的实现原理。
CPU术语
术语 | 英文 | 描述 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓存行 | cache line | CPU 高速缓存中可以分配的最小存储单位。 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3 的或所有) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数回写到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
比较并交换 | Compare and Swap | CAS 操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值。 |
CPU 流水线 | CPU pipeline | CPU 流水线工作的方式就像工业生产的装配流水线,在CPU中由5-6个不同功能的电路单元组成一条处理器流水线,然后将一条X86指令分成5-6步后,再由这些电路单元分别指向,这样就能实现在一个CPU时钟周期内完成一条指令,因此提高CPU的运算速度。 |
内存顺序冲突 | Memory order violation | 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线 |
Java对象头
如果对象是数组类型,则虚拟机用3个字宽(Word)存储悐头,如果对象是非数组类型,则用2个字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如表2-2所示。
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如表2-3所示。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Work可能变化为存储以下4种数据,如表2-4所示。
在64位虚拟机下,Mark Word是64bit大小的, 其存储结构如图2-5所示。
锁的升级与对比
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6 中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。这种策略是为了提高获得锁和释放锁的效率。
偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
-
偏向锁的获取
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成 1(表示当前是偏向锁);如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。 -
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈帧会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
-
关闭偏向锁
偏向锁在 Java 6 和 Java 7 里默认是启用的,但是它在应用程序启动几秒之后才激活,如果有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
轻量级锁
-
轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于
存储锁记录的空间
,并将对象头中的 Mark Word 复制到锁记录中,官方成为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程尝试使用
自旋
来获得锁。 -
轻量级锁解锁
轻量锁解锁时,会使用 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会
膨胀
成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
锁的对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁,竞争的线程会尝试使用自旋来获得锁,自旋会消耗CPU | 最求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步快执行时间较长 |
volatile 实现原理
Java 语言规范第 3 版中对 volatile 的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个共享变量。
在某些情况下 volatile 比锁要更加方便。如果一个字段被声明成 volatile,Java 线程内存模型能够确保所有线程看到这个变量的值是一致的。volatile 是如何保证可见性呢?有 volatile修饰的共享变量进行写操作的时候,JVM会向处理器发送一条
Lock 前缀的指令
。Lock 前缀的指令在多核处理器下会发生两件事情(volatile 的两条实现原则):
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现
缓存一致性协议
,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
synchronized 实现原理
在并发编程中我们称 sychronized 为重量级锁,Java中的每一个对象都可以作为锁是 synchronized 实现同步的基础。具体表现为以下 3 种形式:
1)对于普通同步方法,锁是当前示例对象。
2)对于静态同步方法,锁是当前类的 Class 对象。
3)对于同步方法块,锁是 synchronized 括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁到底存在哪里,锁里存储了什么信息呢?
JVM 是基于进入(monitorenter)和退出(monitorexit) Monitor 对象来实现方法同步和代码块同步的。monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束和异常处,JVM 会保证每个 monitorenter 都有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,会尝试获取对象对应的 monitor 的所有权,即尝试获得对象的锁。
This figure shows the monitor as three rectangles. In the center, a large rectangle contains a single thread, the monitor’s owner. On the left, a small rectangle contains the entry set. On the right, another small rectangle contains the wait set. Active threads are shown as dark gray circles. Suspended threads are shown as light gray circles.
原子操作的实现原理
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。处理器一般基于对缓存加锁或总线加锁来实现多个处理器之间的原子操作。
-
使用总线锁保证原子性
所谓总线锁就是使用处理器提供的一个 LOCK# 信号,当一个处理器在总线桑输出次信号时,其他处理器的请求将被阻塞,那么该处理器就可以独占共享内存。 -
使用缓存锁保证原子性
因为总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定来代替总线锁定来进行优化。
Java中实现原子操作的方式
在Java中可以通过
锁
和
循环CAS
的方式实现原子操作。从 Java 1.5 开始,JDK 的并发包里提供了一些类来支持原子操作,如 AtomicBoolean、AtomicInteger、AtomicLong等。这些原子包装类还提供了有用的工具方法,比如以原子方式将当前值自增 1 和自减 1。
CAS 实现原子操作的三大问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。
-
ABA 问题
ABA 问题的解决思路是使用版本号,A->B->A 变成 1A->2B-3A。从 JDK 1.5 开始,JDK的 atomic 包里提供了一个 AtomicStampedReference 类来解决ABA问题。这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的新值。 -
循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果 JVM 支持处理器的 pause 指令,那么效率会有一定的提升。 -
只能保证一个共享变量的原子操作
对于一个共享变量执行操作时,我们可以使用循环 CAS 的方式保证原子性,但是对于多个共享变量操作时,循环 CAS 就无法保证操作的原子性了。从 Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作了。
使用锁机制实现原子操作,锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM 内部实现了很多中锁机制,有偏向锁、轻量级锁和互斥锁。除了偏向锁,JVM 实现锁的方式都是用了循环 CAS 的方式,即当一个线程想进入同步快的时候,使用循环 CAS 的方式来获取锁,当它退出同步快的时候使用循环 CAS 释放锁。
参考资料:
《Java 并发编程的艺术》