面试官提问
● 公平锁与非公平锁的区别是什么?
● 什么是可重入锁?
● 什么是死锁,怎样避免死锁?
● ReentrantLock与Syschronized实现原理是什么?两者有什么区别?
● 请说明ReentrantLock获取锁与释放锁的流程。
● 请说明Syschronized锁升级的过程。
● 锁性能优化方法是什么?
● 介绍一下AbstractQueuedSynchronizer(AQS)。
1 公平锁与非公平锁
公平锁是指多个线程竞争锁时直接进入队列排队,根据申请锁的顺序获得锁,先到先得。而非公平锁则是多个线程竞争锁时,首先尝试直接抢锁,失败后再进入等待队列。
使用公平锁,先到先得,线程获取锁时不会出现饥饿现象。使用非公平锁,整体的吞吐效率比较高。
ReentrantLock默认是非公平锁,在构造方法中传入参数true则为公平锁;Synchronized是非公平锁。
2 可重入锁
可重入锁是指一个线程可以多次获取同一把锁,其实现原理是,为每个锁关联一个计数器,线程首次获取锁时,计数器置为1,再次获取该锁时,计数器加1;线程每退出同步块一次,计数器就减1。计数器为0则代表锁被当前线程释放。
Synchronized和ReentrantLock都是可重入锁。
3 ReentrantLock锁
ReentrantLock锁的特点是可重入,支持公平锁和非公平锁两种方式。
阅读ReentrantLock代码可知,它主要利用CAS+AQS队列来实现。以非公平锁为例,当线程竞争锁时首先使用CAS抢占锁,成功则返回,失败则进入AQS队列并且挂起线程;当锁被释放时,唤醒AQS中的某个线程,从被挂起处再次尝试获取锁(当AQS队列头节点的下一个节点不为空时,直接唤醒该节点;否则从队尾向前遍历,找到最后一个不为空的节点并唤醒),获取锁失败则再次进入队尾。图1-13详细描述了ReentrantLock非公平锁的获取与释放流程。
下面通过源码来分析ReentrantLock的实现。非公平锁首先使用CAS检测锁是否空闲并抢占锁,当多个线程同时抢占同一把锁时,CAS操作保证只有一个线程执行成功。
final void lock() {//state为0则计数器设为1,表示抢占锁成功if (compareAndSetState(0, 1))setExclusiveOwnerThread(Thread.currentThread());elseacquire(1);
}
假设3个线程T1、T2和T3同时竞争锁,线程T1执行CAS成功,线程T2和T3则会进入acquire方法:
public final void acquire(int arg) {if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();}
接下来分别阅读tryAcquire、addWaiter和acquireQueued的实现代码。
进入tryAcquire方法,若锁空闲(state = 0),则当前线程通过CAS直接抢锁,抢锁成功则返回true;抢锁失败则判断持有锁的线程是否为自己,如果是自己的话就记录重入锁的次数,并返回获取锁成功,否则返回获取锁失败。
protected final boolean tryAcquire(int acquires) {return nonfairTryAcquire(acquires);}final boolean nonfairTryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState();//锁处于空闲状态,没有被任何线程持有if (c == 0) {//忽略AQS队列中的等待线程,当前线程直接通过CAS抢锁体现了非公平性if (compareAndSetState(0, acquires)) {//抢锁成功,设置独占线程为当前线程setExclusiveOwnerThread(current);return true;}}//检查持有锁的线程是否为当前线程else if (current == getExclusiveOwnerThread()) {//可重入锁,记录重入次数int nextc = c + acquires;if (nextc < 0) // overflowthrow new Error("Maximum lock count exceeded");setState(nextc);return true;}//获取锁失败return false;}
若tryAcquie获取锁失败,则执行addWaiter方法,线程加入AQS队列尾部,具体代码如下:
private Node addWaiter(Node mode) {//初始化节点,模式设置为独占Node node = new Node(Thread.currentThread(), mode);Node pred = tail;//tail不为null,说明队列已被初始化if (pred != null) {node.prev = pred;//通过CAS将Node对象加入AQS队列,成为尾节点if (compareAndSetTail(pred, node)) {pred.next = node;return node;}}//队列未初始化或者CAS操作失败则进入enq函数enq(node);return node;}
T2和T3线程抢锁失败,假设它们同时加入AQS队列,由于队列尚未初始化(tail ==null),因此至少有一个线程进入enq()方法,代码如下:
这段代码通过自旋和CAS来实现非阻塞的原子操作,保证线程安全。假设T2和T3线程同时执行enq方法,**第一轮循环,CAS操作确保只有一个线程创建head节点;**第二轮循环,AQS队列完成初始化,tail非空,T2和T3线程都进入else逻辑,通过CAS操作将当前节点加入队尾。若T2线程执行compareAndSetTail成功,则T3线程需要在下一次循环时入队,最终AQS队列如图1-14所示。
T2和T3线程进入队列后执行acquireQueued()方法,AQS队列头节点的后继节点可以再次尝试获取锁,获取锁失败后被挂起,代码如下:
如果T1线程一直持有锁,那么T2和T3线程最终会进入shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,代码如下:
最终T2和T3线程在状态为Node.SIGNAL的前驱节点后挂起,保证前驱节点获取锁后能唤醒自己。AQS队列中节点的状态及说明如表1-1所示。
锁的释放过程比较简单,代码如下:
public void unlock() {sync.release(1);}public final boolean release(int arg) {if (tryRelease(arg)) {//释放锁成功Node h = head;//唤醒AQS队列中的某个节点(一般是头节点)if (h != null && h.waitStatus != 0)unparkSuccessor(h);return true;}return false;}
核心方法是tryRelease和unparkSuccessor,先看一下tryRelease的执行过程,代码如下:
protected final boolean tryRelease(int releases) {//重入锁,每重入一次则state加1,每释放锁一次则state 减1int c = getState() - releases;// 若当前线程不是持有锁的线程则抛出异常if (Thread.currentThread() != getExclusiveOwnerThread())throw new IllegalMonitorStateException();boolean free = false;//state 减为 0,代表释放锁成功if (c == 0) {free = true;setExclusiveOwnerThread(null);}setState(c);return free;}
释放锁成功后会唤起AQS队列中被挂起的线程,代码如下:
private void unparkSuccessor(Node node) {int ws = node.waitStatus;if (ws < 0)compareAndSetWaitStatus(node, ws, 0);Node s = node.next;if (s == null || s.waitStatus > 0) {// 如果节点为null或者处于取消状态// 那就从后往前遍历寻找距离头节点最近的非取消节点s = null;for (Node t = tail; t != null && t != node; t = t.prev)if (t.waitStatus <= 0)s = t;}// 唤醒线程if (s != null)LockSupport.unpark(s.thread);
}
被唤醒的线程也不能保证抢锁成功,失败后依然会放置在队尾,这里也体现了锁的“非公平”性。
4 Syschronized锁
在HotSpot虚拟机中,对象内存布局主要分为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding),如图1-15所示。
当线程访问同步块时,首先需要获得锁并把相关信息存储在对象头中,对象头由以下两部分组成:
● Mark Word:存储自身运行时数据,例如HashCode、GC年龄、锁相关信息等内容。MarkWord信息结构如表1-2所示。
● Klass Pointer:Class对象的类型指针,指向的位置是对象对应的Class对象(对应的元数据对象)的内存地址。
总体上来说,锁升级过程如图1-16所示。
1)偏向锁
线程获取偏向锁的流程如下:
● 检查Mark Word中的线程id。
● 若线程id为空,则通过CAS设置为当前线程id:成功则获取锁,失败则撤销偏向锁。
● 若线程id不为空且为当前线程,则获取锁成功,否则撤销偏向锁
持有偏向锁的线程每次进入这个锁相关的同步块时,只需判断Mark Word中记录的线程id是否为自己。在没有竞争时,一个线程反复申请获得同一把锁,使用偏向锁效率极高。
2)轻量级锁
多个线程竞争偏向锁导致锁升级为轻量级锁,获取轻量级锁的流程如下:
● 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间LockRecord,并将对象头中的Mark Word复制到Lock Record。
● 利用CAS操作将对象头中的Mark Word更新为指向Lock Record的指针,若操作成功则竞争到锁,锁标志位变为00,表示当前为轻量级锁状 态。
● CAS执行失败且自旋一定次数后仍未成功,则说明该锁已被其他线程抢占,这时轻量级锁会膨胀为重量级锁,锁标志位变成为10。
使用轻量级锁提升性能的前提**:多个线程交替执行同步块,锁在整个生命周期内基本不会存在竞争或者出现锁竞争的概率很低,减少了使用重量级锁产生的性能消耗。**
轻量级锁与偏向锁的比较:轻量级锁每次申请、释放都至少需要一次CAS操作,但偏向锁只有在初始化时需要一次CAS操作,后续当前线程可以几乎零成本地直接获得锁(仅需比较线程id是否为自己)。
3)自旋锁
如果持有锁的线程能在很短时间内释放锁,那么竞争锁的线程就没有必要阻塞挂起,它们只需要自旋等待持有锁的线程释放锁,然后再尝试获取锁,这样就能减少传统的重量级锁因使用操作系统互斥量而产生的性能开销。因此,在轻量级锁膨胀为重量级锁之前,一般会尝试通过自旋的方式获取锁。假如当前持有锁的线程一直不释放锁,那么自旋就是在无意义地浪费CPU时间,当自旋多次始终无法获取锁时,轻量级锁会膨胀为重量级锁。
4)重量级锁
没有竞争到锁的线程会被挂起,只有在持有锁的线程退出同步块之后才会唤醒这些线程。唤醒操作涉及操作系统调度,开销很大。