本文介绍了synchronized关键字实现锁的底层原理以及JDK对于synchronized做出的锁升级优化!
synchronized 块是Java 提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
线程的执行代码在进入synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后, 其他线程必须等待该线程释放锁后才能获取该锁。
另外,由于Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized 的使用就会导致上下文切换。
synchronized锁,具有三种表现形式:
synchronized作为锁是和Java是对象头中的Mark Word密不可分的。Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。关于Java对象的构成,可以看这篇文章:Java中的对象内存布局、压缩指针、对象大小计算以及对象访问定位的详解。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。32位JVM在不同状态下mark word的组成如下表:
状态 | Mark Word(32 bit) | ||||
无锁 | hash:25 | age:4 | biased_lock:1 | lock:2 | |
偏向锁 | thread Id:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 |
轻量级锁 | Lock record address:30(指向栈中锁记录的指针) | lock:2 | |||
重量级锁 | Monitor address:30 (指向监视器对象/monitor的指针) | lock:2 | |||
GC标记 | 30,空,只在Mark Sweep GC中用到,其他时刻无效 | lock:2 |
状态 | Mark Word(64 bit) | |||||
无锁 | unused:25 | hash:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |
偏向锁 | thread Id:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 |
轻量级锁 | Lock record address:62(指向栈中锁记录的指针) | lock:2 | ||||
重量级锁 | Monitor address:62 (指向监视器对象/monitor的指针) | lock:2 | ||||
GC标记 | 62,空,只在Mark Sweep GC中用到,其他时刻无效 | lock:2 |
下面来解释各种存放的数据的含义:
unused:就是表示没有使用的区域,在64位虚拟机的对象头中会出现。
hash:对象的hashcode,如果对象没有重写hashcode()方法,那么通过System.identityHashCode()方法获取。采用延迟加载技术,不会主动计算,但是一旦生成了hashcode,JVM会将其记录在markword中;
age:4位的Java对象GC年龄。对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15。最大值也是15。
biased_lock:1位的偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
lock:2位的锁状态标记位,其中无锁和偏向锁的锁标志位都是01,只是在前面的1 bit的biased_lock区分了这是无锁状态还是偏向锁状态。lock和biased_lock共同表示对象处于什么状态:
biased_lock 偏向锁标识位 | lock 锁标识位 | 状态 |
0 | 01 | 未锁定 |
1 | 01 | 偏向锁 |
无 | 00 | 轻量级锁 |
无 | 10 | 重量级锁 |
无 | 11 | GC标记 |
thread Id:持有偏向锁的线程ID。
epoch:的偏向锁的时间戳。
Lock record address:轻量级锁状态下,指向栈中锁记录的指针。
Monitor address:重量级锁状态下,指向对象监视器Monitor的指针。
从上面的内容可以看出来,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较),也可能是对象头里的Monitor指针和Monitor对象(重量级锁)。
JDK1.6及之后synchronized锁可以分为偏向锁、轻量级锁、重量级锁,并且前两种锁和Monitor是没关系的,这当然得益于JDK1.6对synchronized的优化,因此,人们常说的synchronized底层是使用Monitor来实现的这句话是不准确的。
什么是Monitor?
- Monitor是一种用来实现同步的工具,又称为对象监视器/管程。
- 与每个java对象相关联,即每个java对象都有一个Monitor与之对应
- Monitor是实现Sychronized(内置锁)的基础,是shcronized作为重量级锁使用的对象(这源于JDK1.6的优化)。
在重量级锁阶段,线程在获取锁的时候,实际上就是获得一个监视器对象 (monitor) ,monitor可以认为是一个同步对象,所有的Java对象是天生携带一个monitor。因此任何对象都可以作为锁。多个线程访问同步代码块时,相当于去争抢对象监视器对象、修改对象中的锁标识。更多Monitor详解在下面重量级锁那部分!
案例:
public class SyncBlock {
static int i;
public static void main(String[] args) {
synchronized (SyncBlock.class) {
i++;
}
}
}
使用javap -v反编译后得到字节码如下:
如果没有synchronized块,而是普通块:
public class NoSyncBlock {
static int i;
public static void main(String[] args) {
{
i++;
}
}
}
那么就不会产生monitor相关字节码:
从上面的字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
当执行monitorenter指令时,当前线程将试图获取锁对象对应的Monitor的持有权,当锁对象的 Monitor的计数器为0,那线程可以成功取得 Monitor,并将计数器值设置为 1,取锁成功。
如果当前线程已经拥有锁对象的 Monitor的持有权,那它可以重入这个 Monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。
倘若其他线程已经拥有Monitor的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将尝试释放锁并设置Monitor的计数器值减一直到为0 ,其他线程将有机会持有 Monitor。
值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器表,这个异常处理器表声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
当然在JDK1.6及之后的JDK版本中,对上面这些步骤进行了优化升级,得到了更好的性能,上面的步骤只有重量级锁时才会使用到。
public class SyncMethod {
static int i;
public static void main(String[] args) {}
synchronized void sync() {
i++;
}
}
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:
从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。在JDK1.6之前的同步块和同步方法两个同步方式实际都是通过获取Monitor和释放Monitor来实现同步的,但在JDK1.6时,规则改变了,下面来看看JDK1.6的锁升级!
在JDK1.6之前的同步块和同步方法两个同步方式实际都是通过获取Monitor和释放Monitor来实现同步的,其实wait、notiy和notifyAll等方法也是依赖于Monitor对象的内部方法来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常。
在JDK1.6之前,synchronized属于重量级锁,效率低下,因为Monitor是依赖于底层的操作系统的互斥原语mutex来实现,JDK1.6之前实际上加锁时会调用Monitor的enter方法,解锁时会调用Monitor的exit方法,由于java的线程是映射到操作系统的原生线程之上的,如果要操作Monitor对象,都需要操作系统来帮忙完成,这会导致线程在“用户态和内核态”两个态之间来回切换,这个状态之间的转换需要相对比较长的时间,对性能有较大影响。
庆幸的是在JDK1.6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后,为了减少获得锁和释放锁所带来的性能消耗,为了减少这种重量级锁的使用,引入了轻量级锁和偏向锁,这两个锁可以不依赖Monitor的操作。
JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在JDK 1.6中,锁对象一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁、重量级锁的加锁、解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。
偏向锁的释放采用只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争,然后其他线程使用CAS替换掉原来的线程的ThreadID。
偏向锁释放失败,则进入偏向锁的撤销(升级)。
偏向锁在JDK1.6之后是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
相比于重量级锁,轻量级锁可以减少传统的重量级锁使用Monitor而导致操作系统Mute互斥量产生的性能消耗。因为监视器锁Monitor的是依赖于底层的操作系统的Mutex来实现的,操作Monitor会导致线程在用户态和核心态的转换,这个成本非常高,状态之间的转换需要相对比较长的时间。
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
在代码进入同步块的时候,首先获取锁对象的Mark Word。
判断此锁对象是否是无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),如果是,则虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,并存储锁对象无锁状态的Mark Word的拷贝,我们称Lock Record中存储对象mark word的字段叫Displaced Mark Word,然后执行步骤(3);如果不是,则说明是加锁状态,则执行步骤(4)。
然后,虚拟机将使用CAS操作尝试将对象头中的Mark Word更新为指向Lock Record即线程栈中锁记录的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,继续向下执行同步代码;如果CAS失败了则会执行方法(4)。
继续检查对象的Mark Word的是否指向当前线程栈,如果是说明当前线程已经拥有了这个对象的锁,说明是重入,执行重入逻辑,继续向下执行同步代码;否则说明这个锁对象已经被其他线程抢占了,执行步骤(5);
如果不是重入,对于抢锁失败的线程,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的循环第(3)步重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果最终还是失败则锁会升级至重量级锁。锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量/monitor)的指针,此状态下,后面等待锁的线程也要进入阻塞状态。
执行完同步代码块代码,退出同步代码块,使用CAS开始轻量级锁解锁,线程会使用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果对象的Mark Word仍然指向着线程的锁记录,就么可能就替换成功,整个同步过程就算完成了,成功释放锁;如果不满足,则释放锁,唤醒被挂起阻塞的线程,开始重量级锁的竞争。
注:当超过自旋阈值,竞争的线程就会把锁对象Mark Word指向重量级锁,导致Mark Word中的值发生了变化,当原持有轻量级锁的线程执行完毕,尝试通过CAS释放锁时,因为Mark Word已经指向重锁,不再是指向当前线程Lock Record的指针,于是解锁失败,这时原持有轻量级锁的线程就会知道锁已经升级为重量级锁。
synchronized(obj){
synchronized(obj){
synchronized(obj){
}
}
}
假设锁的状态是轻量级锁,则线程栈中会包含3个指向当前锁对象的Lock Record。其中栈中最高位的Lock Record为第一次获取锁时分配的,其中Displaced Mark word为锁对象加锁前的Mark Word,而之后的锁重入,则会在线程栈中新分配一个Displaced Mark word为null的Lock Record,用来重入计数。如下图:
每次重入锁时会将Lock Record放在最后,每次释放锁的时候则会从低到高遍历栈的Lock Record删除对应的Lock Record。 这就是轻量级锁的实现逻辑,相对于偏向锁来说,逻辑会稍微简单一些。
自旋锁是在JDK1.4.2的时候引入的。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。轻量级锁的竞争就是采用的自旋锁机制。
注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西。
自旋锁的一些问题:
如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在原地等待空消耗cpu,这会让人很难受。
本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。
基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。
默认情况下,自旋的次数为10次,用户可以通过-XX:PreBlockSpin来进行更改。
所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。
其大概原理是这样的:
假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。
轻量级锁也被称为非阻塞同步、乐观锁,因为这个过程并没有把线程阻塞挂起,而是让线程空循环等待,串行执行。
在JDK1.6之前,Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的,synchronized属于重量级锁,效率低下,。因为Monitor是依赖于底层的操作系统的互斥原语mutex来实现,JDK1.6之前实际上加锁时会调用Monitor的enter方法,解锁时会调用Monitor的exit方法,由于java的线程是映射到操作系统的原生线程之上的,如果要操作Monitor对象,都需要操作系统来帮忙完成,这会导致线程在“用户态和内核态”两个态之间来回切换,这个状态之间的转换需要相对比较长的时间,对性能有较大影响。
庆幸的是在JDK1.6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后,为了减少获得锁和释放锁所带来的性能消耗,为了减少这种重量级锁的使用,引入了轻量级锁和偏向锁,这两个锁可以不依赖Monitor的操作。
当轻量级锁升级为重量级锁之后,它的Markword部分数据大体如下,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。
每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
在Hospot JDK(大部分人使用的JDK)中,monitor对象的具体实现的代码是没有开源的,因为Hospot将底层的调用C++的native方法的具体实现屏蔽了,而monitor对象正是由C++来实现的,但是这部分源码在openjdk上开源了,我们可以在openjdk中查看源码。
在HotSpot虚拟机中,最终采用ObjectMonitor类实现monitor。ObjectMonitor所在目录为:https://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/d975dfffada6/src/share/vm/runtime。在下面可以找到如下两个文件:
其中.hpp是c++的头文件,主要定义一些变量,其具体的实现是以cpp中,下面一起来看看objectMonitor.hpp的源码
首先是ObjectWaiter,ObjectWaiter 顾名思义对象等待者,其实就是对等待锁的线程的封装。
class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
enum Sorted { PREPEND, APPEND, SORTED } ;
// ObjectWaiter 类似一个双向链表
ObjectWaiter * volatile _next; // 上一个 ObjectWaiter
ObjectWaiter * volatile _prev; // 下一个 ObjectWaiter
Thread* _thread; // 线程
jlong _notifier_tid;
ParkEvent * _event;
volatile int _notified ;
volatile TStates TState ;
Sorted _Sorted ; // List placement disposition
bool _active ; // Contention monitoring is enabled
public:
ObjectWaiter(Thread* thread);
void wait_reenter_begin(ObjectMonitor *mon);
void wait_reenter_end(ObjectMonitor *mon);
};
接下来就是ObjectMonitor,来看看初始化的方法的源码:
ObjectMonitor() {
//成员变量简单的初始化
_header = NULL; //markOop对象头,重量级锁储存锁对象头信息的地方
_count = 0;
_waiters = 0, //等待线程数
_recursions = 0; //锁的重入次数,作用于可重入锁
_object = NULL; //监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
_owner = NULL; //指向持有ObjectMonitor对象的线程地址
_WaitSet = NULL; //处于wait状态的线程,会被包装成ObjectWaiter,加入到_WaitSet集合(调用wait方法)
_WaitSetLock = 0 ; // 保护等待队列,作用于自旋锁。
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //阻塞在EntryList上的最近可达的的线程列表
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block阻塞状态的线程,会被包装成ObjectWaiter,加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;// 监视器前一个拥有者线程的ID
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner则指向持有ObjectMonitor对象的线程。
对于重量级锁,获取锁的过程实际上就是获取monitor的过程,释放锁的过程实际上就是释放monitor的过程。monitor的竞争获取是在ObjectMonitor的enter方法中,而释放则是在exit方法中。
总结:
当多个线程同时访问一段同步代码时,没有获得monitor的对象会进入_EntryList 集合,所谓的获取monitor简单所就是把monitor中的owner变量设置为当前线程同时monitor中的_recursions加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,_recursions减一,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并_recursions减一,以便其他线程进入获取monitor。如下图所示:
其实Java中的wait、notiy和notifyAll等方法也是依赖于ObjectMonitor对象的内部方法来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常。
升级重量级锁时,锁对象的markword存储的是对应ObjectMonitor的指针,但是依然持有 原始的对象头分代年龄 hash 是否偏向的信息。它是储存在ObjectMonitor类中有一个markOop类型的_header成员变量中,而这个值,就是在锁膨胀的过程中复制过来的。
偏向锁:
优点:加锁和解锁不需要额外消耗,和执行非同步方法相比,仅存在纳秒级的差距
缺点:如果线程间存在竞争,会带来额外开销(偏向锁的撤销)
适用场景: 适用于只有一个线程访问同步块的场景
轻量锁:
优点: 竞争的线程不会造成阻塞,提高了程序的响应速度
缺点: 如果始终得不到锁,使用自旋会消耗CPU
适用场景: 追求相应实践,同步块执行速度非常快
重量锁:
优点: 线程竞争不使用自旋,不会消耗CPU
缺点: 线程阻塞,响应时间缓慢
适用场景: 追求吞吐量,同步块执行速度较慢
总体过程如下:
当关闭了偏向锁的设置,那么就会走右边的流程;反之则走左边的流程。
参考
《openJDK8》
《Java并发编程之美》
《Java并发编程的艺术》
《深入理解Java虚拟机》
《实战高并发编程》