本文对Java锁机制进行深入研究,探究Java锁的底层实现原理。本文参考文章Java并发编程 系列文章。
Java锁机制在多线程环境下对共享资源进行同步处理,能够保证原子性、可见性和有序性。
原子性
Java代码被解释成机器指令时往往被拆分为多条微指令。原子性用来保障这些微指令被独立且不可分割的执行。但在多线程环境中,多个线程的微指令可能交叉执行。
保证原子性,最常用的方式是Java锁,Lock或synchronized。除了锁之外,还可以使用CAS(Compare and Swap)方式,乐观锁的实现原理来实现。CAS在某些场景下不一定有效,不能判断另一个线程先修改了某值,然后再改回原值的情况。
可见性
理解可见性,需要先了解JVM的内存模型。Java的变量存放在主存,每个线程具有自己的工作内存,线程对变量操作不能直接操作主存,只能将变量拷贝到自己的工作内存,处理完成后再拷贝回主存。同时,线程不能访问其他线程的工作内存。
JVM内存模型决定了,线程对变量的修改,其他线程可能看到,也可能看不到。Java中可以通过锁(Lock && synchronized)或volatile来保证原子性。
有序性
为了提高性能,编译器和处理器可能会对指令做重排序。重排序分为3种:
- 编译器优化的重排序
在不影响单线程程序语义的情况下,重新安排语句的执行顺序。
- 指令级并行的重排序
现代处理器采用指令级并行技术,并行执行多条指令。在不存在数据依赖的情况下,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序
处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
Java中可以通过锁(Lock && synchronized)或volatile来保证有序性。
synchronized
作用:1.确保线程互斥访问同步代码(原子性);2. 保证共享对象修改的及时可见性(可见性);3.有效解决重排序问题(有序性)。
synchronized有3种用法:修饰普通方法,修饰静态方法,修饰代码块。
-同步代码块:
3
反编译结果:
JVM中使用monitorenter和monitorexit来实现synchronized。
- monitorenter
每个对象有一个monitor,monitor被占用时处于锁定状态,线程使用monitorenter来试图获取objectref关联的monitor的拥有权。当该monitor的entry count为0时,thread拥有该monitor并设置entry count为1;当monitor已经被thread拥有,该thread重入monitor时,增加entry count的数量;当其他thread拥有monitor,当前thread会被block直到monitor的entry count为0。
- monitorexit
执行monitorexit指令的thread必须是monitor的owner。该指令减少monitor上的entry count,当entry count为0时,thread退出monitor,不再是monitor的owner。
synchronized的语义底层是通过一个monitor的对象来完成,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中(这是才获取monitor)才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
-同步方法:
3
反编译结果:
相对于普通方法,synchronized方法的常量池中多了ACC_SYNCHRONIZED标识符,JVM通过该标识符来实现方法同步。方法调用时,调用指令检查方法是否设置该标识符,若被设置,执行线程先获得monitor,获取成功后才能执行方法体,执行完之后再释放monitor。在方法执行期间,任何其他线程都无法获得同一个monitor对象。
方法的同步是通过隐式的方式来实现,无需通过字节码来完成。
总结:普通方法的synchronized,锁定的是this对象的monitor;静态方法的synchronzied,锁定的是class的monitor;代码块的synchronized锁定的monitor取决于后面括号中的对象,可以是this对象,也可以是class类。
synchronized的底层优化
synchronized通过对象内部的monitor来实现。monitor依赖于底层OS的mutex lock实现。因此,获取monitor需要从用户线程切换到内核线程,再切换回用户线程。操作系统中线程间的切换需要从用户态切换到核心态,成本高,需要相对较长的时间,这就是为什么synchronized效率低的原因。JDK对synchronized做各种优化来减轻重量级锁的开销。为了减少获取锁和释放锁带来的性能消耗,引入轻量级锁和偏向锁。
Java锁的状态分为4种:无锁状态,偏向锁,轻量级锁,重量级锁
随着锁的竞争,Java锁可以由偏向锁升级为轻量级锁,再由轻量级锁升级为重量级锁。锁的转化是单方向的,不能反向。JDK 1.6默认开启偏向锁和轻量级锁,也可以使用-XX:-UseBiasedLocking来禁用偏向锁。
锁的状态保存在对象的头文件中,下面以32位JDK为例,分别展现不同锁级别的锁状态。轻量级锁为00, 记录线程栈中lock record的地址; 无锁和偏向锁为01,偏向锁记录当前线程的ThreadId; 重量级锁是10,记录互斥量的指针。
- 重量级锁
这种依赖OS mutex lock实现的锁被称为重量级锁,需要线程切换。
- 轻量级锁
轻量级锁的本意是在没有多线程竞争的前提下,减少重量级锁使用产生的性能消耗。
适用场景:线程交替执行同步块的情况。当存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
加锁过程:
- 代码进入同步块的时候,若同步对象锁状态为无锁状态(锁状态为01,是否为偏向锁为0), 虚拟机先在当前线程的栈中创建锁记录(Lock Record)的空间来存储锁对象的Mark Word的拷贝。线程堆栈与对象头的状态如下:
- 拷贝对象头中的Mark Word到当前线程的锁记录Lock Record中。
- 拷贝成功后,虚拟机使用CAS操作来尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record中的owner指针指向对象的mark word。
- 若更新成功,线程拥有该对象的锁,将对象的锁标志位设为“00”,表示对象处于轻量级锁状态。
- 若更新失败,虚拟机检查对象的Mark Word是否指向当前线程的栈,若是,说明当前线程拥有这个对象的锁,直接进入同步块继续执行。否则,说明多个线程竞争锁,轻量级锁升级为重量级锁,锁状态变为10,Mark Word存储指向重量级锁(互斥量)的指针,后面等待锁的线程进入阻塞状态。当前线程便尝试使用自旋锁来获取重量级锁,自旋锁为了不让线程阻塞而采取循环来获取锁的过程。
解锁过程:
- 通过CAS操作尝试将当前线程中的Displaced Mark Word替换成对象当前的Mark Word;
- 若替换成功,则同步过程完成;若替换失败,说明有其他线程尝试过获取该锁(锁已经膨胀),那就要在释放锁的同时唤醒被挂起的线程。
轻量级锁是在没有锁竞争的情况下来减轻重量级锁的性能开销,通过多次CAS操作来获取对象的锁。
- 偏向锁
偏向锁是为了在无多线程竞争情况下尽量减少不必要的轻量级锁执行路径。轻量级锁的获取和释放通过多次CAS原子指令实现,而偏向锁只需在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能开销必须小于节省下来的CAS原子指令的性能消耗) 。
轻量级锁在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
偏向锁的获取:
- 查看对象Mark Word中的锁状态位是否为01,偏向锁标识是否为1,来确定是否为可偏向状态;
- 若为可偏向状态,看ThreadId是否为当前线程,若是,则当前线程获取偏向锁,直接执行同步代码,退出;否则执行步骤3来获取偏向锁;
- 通过CAS来竞争锁,竞争成功,将Mark Word的ThreadId设置为当前线程ID,执行同步代码,退出;竞争失败,继续执行;
- 竞争失败表示有竞争,当到达全局安全点(safepoint)时获取偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行同步代码。
偏向锁的释放:
偏向锁在遇到其他线程尝试竞争时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销需要等待全局安全点(此时没有字节码正在执行),先暂停拥有偏向锁的线程并判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(01)或轻量级锁(00)的状态。
下面是锁之间的对比:
锁优化
Java中还提供了其他锁优化的方式。
-适应性自旋(Adaptive Spinning)
线程获取轻量级锁的过程中执行CAS操作失败时,通过自旋来获取重量级锁。
自旋消耗CPU,通过自适应性自旋(自旋成功,下次自旋次数会更多,自旋失败,下次自旋次数会减少)来调节自旋次数。自旋锁假设其他线程会快速释放锁,通过自旋等待来减少重走获得锁的路径的开销。
-锁粗化(Lock Coarsening)
将对同一个对象的多次加锁、解锁操作合并为一次。
StringBuffer.append()中为同步操作,使用synchronized来加锁,这里将多次加锁合并为一次。
3
-锁消除(Lock Elimination)
删除不必要的加锁操作,对线程安全的代码,去除锁。
根据代码逃逸技术,若判断一段代码中堆上的数据不会逃逸出当前线程,认为这段代码是线程安全的,不必要加锁。
3
上面的代码,虽然Stringbuffer.append()函数为同步操作,但StringBuffer sb为局部变量,并不会从方法中逃逸出去,其过程是线程安全的,锁可以消除。
Java Thread
Java Thread状态: New, Runnable, Running, Blocked, Dead
创建Thread对象后,在执行start()方法前为New状态;执行start()方法后进入Runnable状态,等待CPU资源;获取CPU资源后进入Running状态,执行run()方法;由于某种原因(I/O操作)让出CPU,自身进入Blocked状态;线程执行完或出现异常进入Dead状态。
sleep()方法用来暂时让出CPU资源,但不释放锁,线程进入Blocked状态,等待时间结束变成Runnable状态。yield()方法将thread的状态由running变为runnable。join()方法是父线程等待子线程执行完成后再执行,用来将异步执行的线程合并为同步线程。
join()方法通过wait方法来将线程阻塞,若当前线程还在执行,将当前线程阻塞直到join的线程执行完成,当前线程才能执行。但从下面的源码看出,join方法只调用了wait方法,并没有调用相应的notify方法。原因:Thread.start()方法提供处理,当join的线程执行完成后,会自动调用主线程继续往下执行。
5
volitale
synchronized能够保证可见性、有序性和原子性,但为重量级操作,对系统性能影响较大。
volatile可以理解为一种比较轻量级的锁,提供可见性和有序性,但不具有原子性。 可以通过atomicInteger或synchronized来保证原子性。
为了实现volatile的可见性和happen-before的语义,JVM底层通过内存栅栏/内存屏障来完成,为一组处理器指令,用于实现对内存操作的顺序限制。
下面是一些内存栅栏:
LoadLoad栅栏
执行顺序load1->loadload->load2,确保load2及其后续指令加载数据之前能访问到load1的数据。
StoreStore栅栏
执行顺序Store1->StoreStore->Store2, 确保Store2以及后续Store指令执行前,Store1操作的数据对其它处理器可见。
LoadStore栅栏
load1->loadStore->store2, 确保store2和后续指令执行前,可以访问到load1加载的数据。
StoreLoad栅栏
store1->storeload->load2, 确保load2及其后续load执行读取之前,store1的数据对其他处理器可见。
为了实现volatile的内存语义,编译器在生成字节码时在指令序列中插入内存屏障来禁止重排序。volatile写操作之前插入StoreStore,之后插入StoreLoad,确保写入立即刷新到主存; volatile读操作后面插入LoadLoad及LoadStore,确保从主存读取的最新数据能够被线程看到。
在防止指令重排序达到的效果如下:
在单例模式的双重检查时,将instance声明为volatile,保证微指令执行的有序性,防止其他线程获得未初始化的对象。原因是对象创建分为3个阶段,有可能在优化处理时调整顺序,将2和3反序,导致获取的instance指向未初始化的内存地址。
memory = allocate(); // 1. 分配内存空间
initInstance(memory); // 2. 初始化对象
instance = memory; // 3. 设置instance指向分配的内存地址