【大厂面试07期】说一说你对synchronized锁的理解?
synchronized锁的原理也是大厂面试中经常会涉及的问题,本文主要通过对以下问题进行分析讲解,来帮助大家理解synchronized锁的原理。
1.synchronized锁是什么?锁的对象是什么?
2.偏向锁,轻量级锁,重量级锁的执行流程是怎样的?
3.为什么说是轻量级,重量级锁是不公平的?
4.重量级锁为什么需要自旋操作?
5.什么时候会发生锁升级,锁降级?
6.偏向锁,轻量锁,重量锁的适用场景,优缺点是什么?
1.synchronized锁是什么?锁的对象是什么?
synchronized的英文意思就是同步的意思,就是可以让synchronized修饰的方法,代码块,每次只能有一个线程在执行,以此来实现数据的安全。
一般可以修饰同步代码块、实例方法、静态方法,加锁对象分别为同步代码块块括号内的对象、实例对象、类。
在实现原理上,
- synchronized修饰同步代码块,javac在编译时,在synchronized同步块的进入的指令前和退出的指令后,会分别生成对应的monitorenter和monitorexit指令进行对应,代表尝试获取锁和释放锁。
(为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。) - synchronized修饰方法,javac为方法的flags属性添加了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。
public class SyncTest {
private Object lockObject = new Object();
public void syncBlock(){
//修饰代码块,加锁对象为lockObject
synchronized (lockObject){
System.out.println("hello block");
}
}
//修饰实例方法,加锁对象为当前的实例对象
public synchronized void syncMethod(){
System.out.println("hello method");
}
//修饰静态方法,加锁对象为当前的类
public static synchronized void staticSyncMethod(){
System.out.println("hello method");
}
}
2.偏向锁,轻量级锁,重量级锁的执行流程是怎样的?
在JVM中,一个Java对象其实由对象头+实例数据+对齐填充三部分组成,而对象头主要包含Mark Word+指向对象所属的类的指针组成(如果是数组对象,还会包含长度)。像下图一样:
Mark Word:存储对象自身的运行时数据,例如hashCode,GC分代年龄,锁状态标志,线程持有的锁等等。在32位系统占4字节,在64位系统中占8字节,所以它能存储的数据量是有限的,所以主要通过设立是否偏向锁的标志位和锁标志位用于区分其他位数存储的数据是什么,具体请看下图:
锁信息都是存在锁对象的Mark Word中的,当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record
的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针。
这是网上找到的一个流程图,可以先看流程图,结合着文字来了解执行流程
偏向锁
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。
简单的来说,就是主要锁处于偏向锁状态时,会在Mark Word中存当前持有偏向锁的线程ID,如果获取锁的线程ID与它一致就说明是同一个线程,可以直接执行,不用像轻量级锁那样执行CAS操作来加锁和解锁。
偏向锁的加锁过程:
场景一:当锁对象第一次被线程获得锁的时候
线程发现是匿名偏向状态(也就是锁对象的Mark Word没有存储线程ID),则会用CAS指令,将mark word
中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。
场景二:当获取偏向锁的线程再次进入同步块时
发现锁对象存储的线程ID就是当前线程的ID,会往当前线程的栈中添加一条Displaced Mark Word
为空的Lock Record
中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized
关键字带来的性能开销基本可以忽略。
场景二:当没有获得锁的线程进入同步块时
当没有获得锁的线程进入同步块时,发现当前是偏向锁状态,并且存储的是其他线程ID(也就是其他线程正在持有偏向锁),则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint
中去查看偏向的线程是否还存活
- 如果线程存活且还在同步块中执行,
则将锁升级为轻量级锁,原偏向的线程继续拥有锁,只不过持有的是轻量级锁,继续执行代码块,执行完之后按照轻量级锁的解锁方式进行解锁,而其他线程则进行自旋,尝试获得轻量级锁。 - 如果偏向的线程已经不存活或者不在同步块中,
则将对象头的mark word
改为无锁状态(unlocked)
由此可见,偏向锁升级的时机为:当一个线程获得了偏向锁,在执行时,只要有另一个线程尝试获得偏向锁,并且当前持有偏向锁的线程还在同步块中执行,则该偏向锁就会升级成轻量级锁。
偏向锁的解锁过程
因此偏向锁的解锁很简单,其仅仅将线程的栈中的最近一条lock record
的obj
字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改锁对象Mark Word中的thread id,简单的说就是锁对象处于偏向锁时,Mark Word中的thread id 可能是正在执行同步块的线程的id,也可能是上次执行完已经释放偏向锁的thread id,主要是为了上次持有偏向锁的这个线程在下次执行同步块时,判断Mark Word中的thread id相同就可以直接执行,而不用通过CAS操作去将自己的thread id设置到锁对象Mark Word中。
这是偏向锁执行的大概流程:
轻量级锁
重量级锁依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的,而在大部分时候可能并没有多线程竞争,只是这段时间是线程A执行同步块,另外一段时间是线程B来执行同步块,仅仅是多线程交替执行,并不是同时执行,也没有竞争,如果采用重量级锁效率比较低。以及在重量级锁中,没有获得锁的线程会阻塞,获得锁之后线程会被唤醒,阻塞和唤醒的操作是比较耗时间的,如果同步块的代码执行比较快,等待锁的线程可以进行先进行自旋操作(就是不释放CPU,执行一些空指令或者是几次for循环),等待获取锁,这样效率比较高。所以轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再升级为重量级锁。
轻量级锁的加锁过程
JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。
然后线程尝试用CAS操作将锁的Mark Word替换为自己线程栈中拷贝的锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。
自旋:不断尝试去获取锁,一般用循环来实现。
自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。
JDK采用了适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。
轻量级锁的释放流程
在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
轻量级锁的加锁解锁流程图:
重量级锁
当多个线程同时请求某个重量级锁时,重量级锁会设置几种状态用来区分请求的线程:
Contention List:所有请求锁的线程将被首先放置到该竞争队列,我也不知道为什么网上的文章都叫它队列,其实这个队列是先进后出的,更像是栈,就是当Entry List为空时,Owner线程会直接从Contention List的队列尾部(后加入的线程中)取一个线程,让它成为OnDeck线程去竞争锁。(主要是刚来获取重量级锁的线程是回进行自旋操作来获取锁,获取不到才会进从Contention List,所以OnDeck线程主要与刚进来还在自旋,还没有进入到Contention List的线程竞争)
Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List,主要是为了减少对Contention List的并发访问,因为既会添加新线程到队尾,也会从队尾取线程。
Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set。
OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck。
Owner:获得锁的线程称为Owner
。
!Owner:释放锁的线程
重量级锁执行流程:
流程图如下:
步骤1是线程在进入Contention List时阻塞等待之前,程会先尝试自旋使用CAS操作获取锁,如果获取不到就进入Contention List队列的尾部。
步骤2是Owner线程在解锁时,如果Entry List为空,那么会先将Contention List中队列尾部的部分线程移动到Entry List
步骤3是Owner线程在解锁时,如果Entry List不为空,从Entry List中取一个线程,让它成为OnDeck线程,Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁,JVM中这种选择行为称为 “竞争切换”。(主要是与还没有进入到Contention
List,还在自旋获取重量级锁的线程竞争)
步骤4就是OnDeck线程获取到锁,成为Owner线程进行执行。
步骤5就是Owner线程调用锁对象的wait()方法进行等待,会移动到Wait Set中,并且会释放CPU资源,也同时释放锁,
步骤6.就是当其他线程调用锁对象的notify()方法,之前调用wait方法等待的这个线程才会从Wait Set移动到Entry List,等待获取锁。
3.为什么说是轻量级,重量级锁是不公平的?
偏向锁由于不涉及到多个线程竞争,所以谈不上公平不公平,轻量级锁获取锁的方式是多个线程进行自旋操作,然后使用用CAS操作将锁的Mark Word替换为指向自己线程栈中拷贝的锁记录的指针,所以谁能获得锁就看运气,不看先后顺序。重量级锁不公平主要在于刚进入到重量级的锁的线程不会直接进入Contention List队列,而是自旋去获取锁,所以后进来的线程也有一定的几率先获得到锁,所以是不公平的。
4.重量级锁为什么需要自旋操作?
因为那些处于ContetionList、EntryList、WaitSet中的线程均处于阻塞状态,阻塞操作由操作系统完成(在Linxu下通过pthread_mutex_lock函数)。线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。如果同步块中代码比较少,执行比较快的话,后进来的线程先自旋获取锁,先执行,而不进入阻塞状态,减少额外的开销,可以提高系统吞吐量。
5.什么时候会发生锁升级,锁降级?
偏向锁升级为轻量级锁:
就是有不同的线程竞争锁时。具体来看就是当一个线程发现当前锁状态是偏向锁,然后锁对象存储的Thread id是其他线程的id,并且去Thread id对应的线程栈查询到的lock record的obj字段不为null(代表当前持有偏向锁的线程还在执行同步块)。那么该偏向锁就会升级成轻量级锁。
轻量级锁升级为重量级锁:
就是在轻量级锁中,没有获取到锁的线程进行自旋,自旋到一定次数还没有获取到锁就会进行锁升级,因为自旋也是占用CPU的,长时间自旋也是很耗性能的。
锁降级
因为如果没有多线程竞争,还是使用重量级锁会造成额外的开销,所以当JVM进入SafePoint安全点(可以简单的认为安全点就是所有用户线程都停止的,只有JVM垃圾回收线程可以执行)的时候,会检查是否有闲置的Monitor,然后试图进行降级。
6.偏向锁,轻量锁,重量锁的适用场景,优缺点是什么?
篇幅有限,下面是各种锁的优缺点,来自《并发编程的艺术》:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |
参考链接:
https://github.com/farmerjohngit/myblog/issues/12
http://redspider.group:4000/article/02/9.html
https://blog.csdn.net/bohu83/article/details/51141836
https://blog.csdn.net/Dev_Hugh/article/details/106577862