本文主要介绍Sync锁的升级过程以及介绍锁信息,其中的CAS,MarkWord,偏向锁,轻量级锁…等知识点的讲解,不讲解锁的其他优化,其他锁优化会在下一篇进行讲解。
1.什么是Synchronized关键字?
synchronized关键字用来解决多线程之间访问资源的同步问题,它可以保证被它修饰的方法或者代码块在任何时刻只有一个线程能够执行。
在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock(互斥锁) 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要比较长的时间,时间成本较高,所以早期的 synchronized 效率比较低。
但是在 Jdk1. 6 之后Java 官方从 JVM 层面对synchronized 做了较大的优化,引入自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
2.Sync锁升级过程?
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。这里的锁只能升级而不能降级,这种策略也是为了提高获得锁和释放锁的效率。
2.1 什么是MarkWord?
我们都知道对象通常由三部分组成,对齐填充字节,实例数据,以及对象头。而MarkWord就是对象头的一部分。
对象头:(64位虚拟机)
1.根据对象类型来划分内存大小,如果对象是数组类型,则虚拟机用160bit也就是20个字节。来存储对象头,其中64bit存放MarkWord + 64bit存放类元数据的指针 + 32bit存放数组的长度。
2.如果对象是非数组类型,则用128bit,也就是16个字节,来存储对象头。其中64bit存放MarkWord + 64bit存放类元数据的指针。
1.MarkWord:
主要就是存放对象的分代年龄,HashCode,锁的信息。
2.类元数据的指针:
虚拟机通过这个指针来确定该对象是属于哪个类的,而类的元信息就在方法区中,所以是指向方法区的,当调用该对象的方法的时候,就是根据对象头的这个指针,来调用到方法。
就是说对象头分为数组类型和非数组类型,主要区别就只是多了一个数组长度的空间。
MarkWord内存图:
2.2 偏向锁
偏向锁的“偏”就是偏心的偏,它的意思就是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步,偏向锁会就把整个同步都消除掉。
偏向锁的目的其实就是,想要在没有竞争且只有一个线程使用锁的情况下,减少轻量级锁所带来的性能消耗。因为轻量级锁每次获取、释放锁都至少需要执行一次CAS原子操作,而偏向锁只有初始化时需要执行一次CAS操作。所以在没有线程竞争的情况下,它的效率是较高的。
偏向锁加锁原理:
当JVM启用了偏向锁模式,新创建对象的Mark Word中的Thread Id为0,说明此时处于可偏向但未偏向任何线程的状态,也叫做匿名偏向状态(anonymously biased)。
- 第一个线程来访问同步块时,先检测Mark Word中的锁标志位是否为01,是的话说明是偏向锁,再判断偏向锁标志位是否为1,如果不是,则是其他锁状态。然后如果锁标志位为0,则走轻量级锁逻辑。如果锁标志位为1,则检查Mark Word中的Thread Id是否是当前线程ID。
- 如果是,则表明当前线程已经获得对象锁,接着执行同步代码。以后该线程进入同步块时,都不需要进行CAS操作,只会往当前线程的栈中添加一条锁记录,用来统计重入的次数。
- 如果对象头Mark Word中Thread Id不是当前线程ID,则进行CAS操作,企图将当前线程ID替换进Mark Word中。如果当前对象锁状态处于匿名偏向锁状态,则会替换成功(将Mark Word中的Thread id由0改成当前线程ID,然后在当前线程的栈中将线程ID存入Lock Record),获取到锁,执行同步代码块;
- 如果对象锁已经被其他线程占用,则会替换失败,开始进行偏向锁撤销,这也是偏向锁的特点,一旦出现线程竞争,就会进行偏向锁撤销;
CAS是什么:CAS是一种无锁算法,CAS通常有3个操作数,内存值V,预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
偏向锁的CAS:
内存中的值:就是MarkWord中的线程id值,可能是当前持有偏向锁的线程id,或者为0表示未被线程占用。
预期值:假设对象头中的线程id为空,也就是为0。
要修改的新值:也就是将MarkWord中的线程id改为当前要获取锁的线程id。
偏向锁撤销原理:
- 偏向锁的撤销,是需要等待全局安全点的(这个时间点上没有正在执行的工作线程,下面有解释)。首先它会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程状态。(遍历当前JVM的所有线程,如果能找到,则说明偏向的线程还存活)
- 如果线程还存活,则检查线程是否在执行同步代码,如果是,则升级为轻量级锁,当前持有偏向锁的线程获得锁,唤醒暂停的线程,从安全点继续执行代码,执行完代码再走轻量级锁的释放锁逻辑。
- 如果持有偏向锁的线程未存活,或者持有偏向锁的线程已经执行完同步代码块中的代码,则进行校验是否允许重偏向(判断epoch是否过期,epoch是一个时间戳),如果不允许重偏向,则撤销偏向锁,将Mark Word设置为无锁状态(无锁不可偏向的状态),然后升级为轻量级锁,进行CAS操作获得锁,就是竞争线程去持有轻量级锁。
- 如果允许重偏向,设置为匿名偏向锁状态,通过CAS操作将偏向锁指向新的线程(在对象头和线程栈帧的锁记录中存储当前线程ID)
- 唤醒安全点挂起的线程,继续执行代码。
全局安全点:
从线程的角度,安全点是代码执行中的一些特殊位置,当线程执行到这些特殊的位置,如果此时在GC,那么在这个地方线程会暂停,直到GC结束。
GC的时候要挂起所有活动的线程,因此线程挂起,会选择在到达安全点的时候挂起。
安全点这个特殊的位置保存了线程上下文的全部信息。在进入安全点的时候打印日志信息能看出线程此刻都在干嘛。
等待所有用户线程进入安全点后并阻塞,然后做一些全局性操作的行为,例如:GC行为,偏向锁撤销等。
2.3 轻量级锁:
在偏向锁失败后,虚拟机并不会立即将锁升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少使用重量级锁所产生的性能消耗,因为使用轻量级锁时候,不需要去申请互斥量。
轻量级锁之所以能够提升程序同步性能的依据是,“整个同步周期内都是不存在竞争的”。因为交替执行的线程获取锁,并不会发生竞争。如果没有竞争,轻量级锁会使用 CAS 操作来避免使用互斥操作产生的开销。但如果存在锁竞争,除了互斥量的开销外,还会有额外的CAS操作,因此在有锁竞争的情况下,轻量级锁会比传统的重量级锁要更慢!而且如果锁竞争的激烈,轻量级将会很快膨胀为重量级锁!
轻量级锁加锁原理:
- 如果当一个未获得锁的线程进入同步代码块的时候如果该对象是无锁状态,即标志位是00,或者锁升级为轻量级锁,那么就走轻量级锁的逻辑,首先它会在当前线程的栈帧中建立一个lock record 锁记录空间,将markword拷贝到锁记录中
- 然后执行CAS操作,操作成功则把markword更新为指向当前线程锁记录的指针,就表示该线程拥有了对象锁。
- 如果CAS操作失败了,会执行自旋锁逻辑,自旋锁失败达到一定次数后才升级为重量级锁,就会将MarkWord修改为重量级锁的信息。
轻量级锁的CAS:
预期值:是空值 或者 自己的锁记录指针
要修改的新值:则是将指针指向自己的锁记录 或者 markword的原始信息
内存中的值:锁当前真实状态,可能被占用,可能为空
轻量级锁释放锁原理:
- 判断对象头中的MarkWork中的锁记录指针是否仍然指向自己,是的话就会使用原子的CAS操作将锁记录中的Mark Word信息替换回到对象头。
- 如果成功,则表示没有竞争发生。
- 如果不是或者cas操作失败,直接释放掉这个锁,然后唤醒被挂起的线程(为什么不是呢,因为锁被升级为重量级锁,MarkWord被改变了,注意中有解释)
注意:当超过自旋的阈值,来竞争的线程就会把锁对象Mark Word指向重量级锁Monitor,从而导致Mark Word中的值发生了变化,当原持有轻量级锁的线程执行完毕,尝试通过CAS释放锁时,因为Mark Word已经指向重量级锁,不再是指向当前线程Lock Record的指针,于是解锁失败,这时原持有轻量级锁的线程就会知道锁已经升级为重量级锁,持有轻量级锁的线程就会直接释放锁,然后通知被挂起的线程。就是说新来的线程自旋失败了,将锁升级了,但是没拿到锁。被挂起了。
2.4 自旋锁:
轻量级锁失败后,虚拟机为了避免线程在操作系统的层面上被挂起,还会进行一项称为自旋锁的优化手段。
我们知道互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态非常耗费时间)。
一般线程持有锁的时间都不会太长,所以为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以我们可以让后面来请求锁的线程等待一会而不被挂起,只需要让线程去执行一个空循环(自旋)不断尝试获取锁,那么这个就叫做自旋锁。
自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的。JDK1.6之后,就改为默认开启了。
注意:自旋等待不能完全替代阻塞,因为它还是要占用cpu的时间。如果锁被占用的时间短,倒没什么问题,但如果占用时间长,效果就很差,这样就会造成cpu资源浪费!所以自旋等待的时间必须要有一个限度。如果自旋的次数超过了指定的次数依然没有获得到锁,就会挂起线程。避免造成cpu资源的浪费。
最后附上一张锁升级过程的流程图: