程序猿大牛:分享JAVA并发机制的底层实现原理详解(附电子书籍)
先来熟悉一些术语
-
内存屏障:是一组处理器指令,用于实现对内存操作的顺序限制。
-
缓存行:缓存中可以分配的最小存储单元。
-
原子操作:不可中断的一个或一系列操作。
-
缓存行填充:当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存航到适当的缓存(L1,L2,L3的或所有)。
-
缓存命中:如果进行高速缓存航填充操作的内存位置仍然是下次处理器访问的地址是,处理器从缓存中读取操作数,而不是从内存。
-
写命中:当处理器将操作数写回到一个内存缓存的区域是,首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存。
-
写缺失:一个有效的缓存行被写入到不存在的内存区域。
volatile的应用
volatile是轻量级的synchronized,它只是用来保证共享变量的可见性,不能保证操纵的原子性。
volatile如何实现内存可见性?
-
深入的说,通过加入内存屏障和禁止重排序优化实现的。
-
对volatile变量执行写操作时,会在写操作后加入一条store屏障指令。
-
对volatile变量执行读操作时,会在读操作前加入一条load屏障指令。
volatile保证共享变量可见性
有volatile修饰的变量进行写操作的时候会多出一行汇编代码,该行代码会有一个lock指令。
volatile的两条实现原则:
①: Lock前缀指令会引起处理器缓存会写到内存(使处理器独占任何共享内存)。
②:一个处理器的缓存回写会导致其他处理器的缓存无效。
synchronized的实现原理和应用
synchronized实现同步的基础:
①:对于普通方法,锁是当前实例对象。
②:对于静态同步方法,锁是class对象。
③:同步方法块,锁是synchronized后面括号里的对象。
JVM规范中的实现原理
JVM基于进入和退出Monitor对象实现方法同步和代码块的同步。
Mark Word标记位
synchronized用到的锁是放在JAVA对象头里面的,其中有个Mark Word来存储对象的hashcode、分代年龄和锁标记位,其中锁标记位会产生变化,对应的不同的标记,我们的锁有3种:轻量级锁、重量级锁、偏向锁。
同步原理
代码块同步是使用monitorenter和monitorexit指令实现,monitorenter指令是在编译后插入同步代码块的开始位置,而monitorexit插入到方法结束处和异常处。JVM要保证每个monitorenter必须有monitorexit对应。
Java对象头
长度 | 内容 | 说明 |
32/64bit | Mark Word | 存储对象的hashCode或锁信息 |
32/64bit | Class Metadata Address | 存储对象类型数据的指针 |
32/64bit | Array Length | 数组长度(如果当前对象是数组) |
无锁状态的Mark Word
锁状态 | 25 bits | 4 bits | 1 bit 是否是偏向锁 | 2 bits 锁标志位 |
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
有锁状态的Mark Word
锁状态 | 25 bits | 4 bit | 1 bit | 2 bits |
23 | 2 bits | 是否是偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
重量级锁 | 指向向互斥量的指针 | 10 | ||
GC标志 | 空 | 11 | ||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
锁的升级与对比
锁一共有四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
①:偏向锁
引入原因:大多数情况下,锁仅有某一线程多次获得,为了使获得锁的代价更低而引入偏向锁。
偏向锁的设置:当某一线程访问同步块时,会在对象头和栈帧中的琐记录里存储锁偏向的线程ID,以后该线程在进入该同步块的时候,不需要再次使用CAS原子操作进行加锁和解锁,只需要简单的测试一下对象头中的Mark Word是否存在指向当前线程的偏向锁。如果测试成功,则表示获得锁,否则检测是否设置有偏向锁,如果没有,则使用CAS竞争锁,否则偏向锁指向该线程。
偏向锁的关闭:在6和7中是默认采用的,可以通过JVM参数关闭:UseBiaseLocking=false,此时程序进入轻量级锁的状态。
②:轻量级锁
加锁:线程执行同步块之前,会在线程私有的栈帧中开辟用于存储锁记录的空间,称为Displaced Mark Word。然后线程尝试将对象Mark Word的替换为指向Displaced Mark Word记录的指针,如果成功,那么当前线程获得锁,如果失败,那么使用自旋获得锁。
何为自旋?
轻量级锁解锁: 使用原子的CAS操作将Displaced Mark Word 替换回对象头,如果成功,表示没有竞争发生,否则,说明当前锁存在竞争(从上图可以看出,竞争锁的线程一直在尝试修改Mark Word,这肯定存在竞争),锁就会膨胀成重量级的锁。
因为自旋会消耗CPU,为了避免太多无用的自旋,一旦锁膨胀成重量级的锁,便不会再恢复到轻量级的锁的状态。当锁处于这个状态下,其他线程试图获取锁时就被阻塞住。当锁释放时再唤醒这些线程。此时醒来的线程就会进行一轮新的竞争。
三种锁的比较:
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法之间存在纳秒级的差距 | 线程间存在锁的竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高程序的响应速度 | 自旋消耗CPU | 追求响应时间,同步块执行速度快 |
重量级锁 | 线程竞争不使用自旋,不消耗CPU | 线程阻塞,响应时间慢 | 追求吞吐量,同步块执行速度较长 |
原子操作的实现原理
处理器实现原子操作的机制:
-
第一机制:总线锁(声言Lock信号)
-
第二机制共享缓存锁(修改内存地址,缓存一致性机制:阻止同时修改由2个以上的处理器缓存的内存区域数据)。
JAVA实现原子操作的机制
-
第一个是循环CAS:JVM中的CAS操纵是利用了处理器提供的CMPXCHG指令实现的。自旋CAS的基本思路是循环进行CAS操作,直到CAS操作成功了为止。
使用锁机制实现原子操作
-
锁机制保证只有获得锁的线程才能够操作锁定的内存区域。注意:除了偏向锁,JVM实现锁的方式都用了循环CAS操作(使用循环CAS获取锁,使用循环CAS释放锁)。
极简同步技巧
寄存器的效应
-
计算机必须将数据从主存储器中读到寄存器中,对寄存器操作,然后将数据存放存储器;
-
当操作系统将某thread分配给CPU时,他会把thread特有的信息加载到CPU的寄存器中;
-
在分配不同的thread给CPU之前,它会将寄存器的信息存下来。所以thread间决不会共享保存在寄存器的数据;
-
使用volatile关键字能够保证比那里不会保持在寄存器中,能够保证变量是真正地分享与thread之间。
重排语句的效应
-
synchronized块能够防止语句的重排,VM不能将语句从synchronized块移动到synchronized块之外。
在这里给大家提供一个交流,讨论的平台,JAVA架构师群671017482