synchronized 详细介绍 及底层实现
synchronized 关键字有三种用法:
- 修饰实例的方法,给当前类的实例加锁,在运行同步代码块时先要得到当前实例的锁
- 修饰静态的方法,修饰静态方法就是给当前class类对象加锁,在执行同步代码块之前要先得到这个class的锁
- 修饰代码块,给局部变量加锁,也可以是实例也可以是类,对给定对象加锁,进入同步代码库前要获得给定对象的锁
底层如何实现:
在java1.6之前,只要用synchronized给对象加锁就是重量级锁,重量级锁是依赖操作系统的MutexLock(互斥锁)来实现的,要将用户态切换到内核态,转换状态会消耗很多的时间,造成的开销很大,所以叫重量级锁,在1.6之后,java对synchronized进行了大量的优化,锁不是一上来就是重量级锁,而是由各种状态进行一步步升级,由偏向锁->轻量锁->自旋锁->自适应自旋锁->重量级锁,一步步升级以减少不必要的开销与上升效率。
锁对象:
锁是加在对象上的,被加了锁的对象我们叫锁对象,任何一个对象都能被叫为锁对象,虚拟机是如何知道一个对象是否加锁了呢,jvm里是通过对象头来判断的。
对象在堆中的存储结构有三部分:
对象头 |
实例变量 |
填充数据 |
对象头:
长度 | 内容 | 说明 |
32/64 bits | MarkWord | hashcode,GC年龄代,锁信息 |
32/64 bits | Class Metadata Address | 类型指针指向对象的类原数据,jvm通过它来知道对象属于哪个类 |
32/64 bits | ArrayLength | 如果对象是数组,这里是数组的长度 |
有关对象的锁信息是在Mark Word里。
由于默认情况下,对象的锁是偏向锁所以默认的MarkWord结构如下
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
由于锁是可升级的,所以这个结构是可变化的
偏向锁
一开始默认所有对象的锁标志位是01,锁状状态是0,代表所有对象一出生默认的就是可偏向锁,并且锁的偏向锁并没有生效,
但是当线程执行到临界区时,所谓的临界区就是同步代码块,只让一个线程进行执行操作,这时会用CAS(compare and
Swap)操作,将线程id插到对象头的MarkWord中,同时修改偏向锁的状态位,这时候就将第一个对象的偏向锁成功的给了一个线程。
偏向锁是jdk1.6引入的锁优化,他的作用就是,适合单个线程对一个锁对象的利用的情况,
一个锁偏向于第一个第一个获取他的线程,如果以后没有第二个别的锁来与他竞争锁,再次运行这样一段同样的代码块,同样
的锁对象,线程并不需要进行加锁和解锁。会进行以下步骤:
- 先判断线程id和MarkWord中的线程id是否相同,如果相同,说明已经获得了偏向锁,直接执行同步代码块;
- 如果不一致,就看锁的是否是偏向锁的标志位是否是1
- 如果是0,代表这个对象是个新的(处)利用cas操作将MarkWord的线程id改成自己的id,线程其实就是第一次获取了对象的锁。
- 如果是1,此时又偏向的不是自己(线程id不同),说明发生了竞争,此时要根据另外的这个线程,进行重新偏向或者撤销偏向,但大部分情况下撤销偏向升级成轻量级锁。
偏向锁的设计就是因为大部分情况下一个同步代码块由同一个线程进行访问,如果一个线程获得了锁就没必要每次执行完再次
解锁,可以节约很多开销,
当出现第二个线程竞争锁,偏向锁就没必要存在了,这时候就要上升为轻量级锁。这就是锁膨胀,要想上升为轻量级锁还要进
行锁撤销。
锁撤销:
1. 在一个安全点停止所有拥有锁的线程
2. 遍历线程栈,如果有锁的记录的话,修复MarkWord 将锁状态置为0,也就是无锁状态
3. 唤醒当前线程,将锁升级为轻量级锁。
偏向锁的批量再偏向(Bulk Rebias)机制
偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。
那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗? 答案是可以, JVM 提供了批量再偏向机制(Bulk Rebias)机制
该机制的主要工作原理如下:
引入一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性
从前文描述的对象头结构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。
除了对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值
每当遇到一个全局安全点时, 如果要对 class C 进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一
个新的 epoch_new
然后扫描所有持有 class C 实例的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_new 的值赋给被
锁定的对象中。
退出安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相
等, 如果不相等, 则说明该对象的偏向锁已经无效了, 可以尝试对此对象重新进行偏向操作。
所以对于本身不符合大多数情况,由两个及以上执行同一个代码块的情况,可以在一开始就将默认为偏向锁这个功能关闭
轻量锁:
轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合
如何将锁升级为轻量级锁:
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量锁的标志位为00
自旋锁:
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。
注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。
所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。
经验表明,大部分同步代码块执行的时间都是很短很短的,也正是基于这个原因,才有了轻量级锁这么个东西。
自旋适应性锁:
所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。
其大概原理是这样的:
假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源。
轻量级锁也叫非阻塞同步,乐观锁,因为他没有将等待的锁的线程挂起阻塞,而是继续运行它,因为他希望短时间类会得到锁,挂起阻塞会带来没必要的开销。
重量级锁:
重量级锁也就是通常说synchronized的对象锁,synchronized基本就是由他实现的。
锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
但是monitor监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。
MarkWord的结构
monitor是由数据结构objectmonitor实现的。
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
ObjectMonitor的数据结构:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
Synchronized代码块底层实现原理:
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位
置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所
对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为
1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor ,重入时计数器的值也会加
1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit
指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确
保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是
正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动
产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以
看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令,也就是说有两个monitorexit,一个用来执行
正常情况下执行完毕的解锁,一个是在执行过程中抛出异常时也能解锁
Synchronized代码块底层实现原理:
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法
表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令
将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的
是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行
线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无
法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
总结:
synchronized用法就是作用于实例方法就是作用于实例对象,作用于静态方法就是作用于类对象,作用于代码块,作用于任何对象,然后底层是利用markword里的锁信息与线程进行交互,加锁和取锁,与一系列复杂的过程,锁是有轻升级到重,方向是单一的,各自锁的实现原理都不同,适用于不同的场合,一步步升级。
参考资料: