Java并发机制的底层实现原理
1.1概述
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的的并发机制依赖于JVM的实现和CPU的指令
1.2Volatile应用
1.2.1volatile的定义与实现原理
在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 “可见行”,可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
volatile的定义与实现原理
Java语言规范中对volatile的定义如下: Java允许线程访问共享变量,为了确保共享变量能够被准确和一致地更新,线程应该确保通过排它锁单独获得这个变量
Java提供了volatile,在某些情况下比锁要更加方便,如果一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的
CPU术语的定义 -内存屏障: Memory barriers 是一组处理器指令,用于实现对内存操作的顺序限制。
volatile是如何来保证可见性的呢?
使用X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么事。
instance = new Singleton(); // instance是volatile变量
转换为汇编代码,如下
xxx.... lock addl s0x0, (%esp)
我们可以发现,被volatile修饰的共享变量进行写
操作的时候会多出lock xxx
的汇编代码。通过查看IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发两件事情
1、将当前处理器缓存行的数据写回到系统内存
2、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内存缓存(L1, L2或其他)后才进行操作,但操作完不知道何时会写到缓。
如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存,但是,就算是写回到内存,如果其他处理器缓存的值还是旧值,在执行计算操作时就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议
,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被改变,就会将当前处理器的缓存行设置为无效状态。当处理器对这个数据进行修改操作的时候,会重新从系统内存把数据读到处理器缓存中
。
volatile的两条实现原则
Lock前缀会引起处理器缓存回写到内存
它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性协议机制来确保修改的原子性,此操作被称为 "缓存锁定"
,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效
IA-32处理器等使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性,再多核处理器系统中进行操作的时候,处理器能够嗅探其他处理器访问系统内存和他们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器缓存的数据在总线上保持一致。
例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效
,在下次访问相同内存地址时,强制执行缓存行填充。
1.3synchronized的实现原理与应用
在多线程并发编程中synchronized一直是元老级角色,很多人会称呼它为重量级锁。但是随着JavaSE1.6对synchronized进行了各种优化以后,有些情况下它就并不那么重要了,本文详细介绍JavaSE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
先看下利用synchronized实现同步的基础: Java中的每一个对象都可以作为锁
,具体的表现为以下三种形式。
- 对于普通方法,锁是
当前实例对象
。 - 对于静态同步方法,锁是
当前类的Class对象
。 - 对于同步方法块,锁是
Synchronized括号里配置的对象
。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁,那么锁到底存在哪里呢?锁里面会存储什么信息呢?
从JVM规范中可以看到synchronized在JVM里的实现原理,JVM基于进入和退出Monitor(监视器,即锁)对象来实现方法同步和代码块同步
,但两者的实现细节不一样。代码块同步是使用monitorenter
和monitorexit
指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明,但是,方法的同步同样可以使用这两个指令来实现。
monitorenter指令是编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束出和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
任何对象都有一个monitor(锁)与之关联
,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter(加锁)指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁
Java对象头
synchronized用的锁是存在Java对象头
里的。
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字款存储对象头。在32位虚拟机中,一字宽等于4字节,即32bit。如下图所示。
对象头
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/32bit | Array length | 数组的长度(如果当前对象是数组) |
Java对象头的Mark Word里默认存储对象的HashCode
、GC分代年龄
和锁标志
。32位的JVM的Mark Word的默认
存储结构如表2-3所示。
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的HashCode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word存储的数据会随着锁标志位的变化而变化,Mark Word可能变化为存储以下4种数据
锁的升级与对比
1、偏向锁
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和 “轻量级锁”, 在1.6中,锁一共有四种状态,级别从低到高分别是: 无锁状态
、偏向锁状态
、轻量级锁状态
和重量级锁
状态。这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级
,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得
。为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录(栈帧的锁记录参考)里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单的测试一下对象头的Mark Word里是否存储着执行当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要在测试一下Mark Word偏向锁的标识是否设置为1(1表示当前是偏向锁):如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。如果没有设置,则使用CAS竞争锁
2、轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark word复制到锁记录中,官方称为DIsplaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,失败表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
解锁时,会使用CAS操作将Displaced Mark Word替换回对象头,如果成功,则表示没有竞争发生。如果失败,则表示当前锁存在竞争,锁就会膨胀为重量级锁。
1.4原子操作的实现原理
Java中可以通过锁和循环CAS的方式来实现原子操作。
CAS操作实现原子操作的三大问题。
1、ABA问题
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是一个值原来是A,变成了B,又编程了A,那么使用CAS进行检查时会发现它的值没有改变,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面加上版本号。每次变量更新的时候将版本号加1
,那么A ——> B ——> A 就会变为 1A ——>2B——3A,从1.5开始,JDK的Atomic包里提供了一个类AtmoicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用 ①是首先检查当前引用是否等于预期引用
,②检查当前标志是否等于预期标志
。如果全部相等,则以原子方式将改引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(
V expectedReference, //预期引用
V newReference, //更新后的引用
int expectedStamp, //预期标志
int newStamp //更新后的标志
)
2、循环时间开销大
自旋
CAS如果长时间不成功,会给CPU带来非常大的执行开销
3、只能保证一个共享变量的原子操作
锁机制实现原子操作。
锁机制保证了只有获得锁的线程才能操作锁定的内存区域。JVM内部实现了很多锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS
,即当一个线程想进入同步块的时候使用循环CAS的方式来获得锁,当它退出同步块的时候使用循环CAS释放锁。