开场
上海漕河泾某栋写字楼里,因为一个Reentrantlock,引发了一场求职者和面试官的battle。
面试官:你先自我介绍一下。
安琪拉:面试官你好,我叫安琪拉,草丛三婊,最强中单,草地摩托车车手,第21套广播体操推广者,火球拥有者、不焚者,安琪拉,这是我的简历,请过目。
面试官:看你简历上写熟悉多线程编程,用过ReentrantLock吗?
安琪拉:用过的。
面试官:ReentrantLock 为什么叫 ReentrantLock?
安琪拉:【心想:这什么鬼问题?】
ReentrantLock 可以拆成二部分,
- 在英文当中,前缀 re- 有"再"、"重新"、"重复"的意思。例如:review(复习)restart(重新开始),reentrant是可重入的意思
- Lock就是锁
加在一块就是可重入锁。
你看我简历上不是写了英语过四级嘛,这种问题就不要拿来考我,OK?
面试官:那可重入是什么意思?
安琪拉:跟我这咬文嚼字呢。。
面试官:可重入,是个动词,指的是谁可重入呢?
面试官:指的是线程,当一个线程获取锁之后,这个线程可以再次获取锁,可重入从字面上就是可重新进入,进哪里?当然是临界区。
面试官:什么是临界区?
安琪拉:临界区指的是一个访问共用资源的程序片段,这个共用资源又有个特性。它不希望同时被多个线程访问。当有线程进入程序片段(临界区)的时候,其他线程必须等待,清楚了吧。。
正题
面试官:【心想,看样子来之前背了八股文,不过问题不大,我早有准备(提前从安琪拉的博客上找了些题)】
synchronized 用过的吧?
安琪拉:用过。
面试官:那你说说同样是锁,ReentrantLock 和 synchronized 的区别?嘿嘿
安琪拉:可以从几个方面来说:
- 锁分类
- synchronized 是非公平锁
- ReentrantLock 可自定义锁是公平还是非公平(具体原理后面解释)
- 灵活性
- synchronized 使用形式非常固定,就是单纯的加锁
- ReentrantLock 可以尝试获取锁(tryLock)、带超时时间的获取锁、可响应中断(lockInterruptibly)
- 可重入(线程可重复多次加锁)
- synchronized、ReentrantLock 都是可重入的。
- 使用方式
- synchronized 修饰方式加锁,自动释放锁(没有显示的unlock操作)
- ReentrantLock 显示加锁,显示释放锁(有lock、unlock操作)。
- 实现原理
- synchronized 基于monitor(监视器)模式
- ReentrantLock 基于 AQS
面试官:你刚才说使用方式上的差异,能写段代码看下吗?简单写写就好,加锁解锁过程。
安琪拉:笔递给我一下,给我张A4纸。
先说下synchronized,三种作用范围如下, 以及可重入的示例:
public class SynchronizedTest {
private static final Object monitor = new Object();
private static int money = 0;
// 1.作用于静态方法
public synchronized static void add(int income) {
money += income;
}
// 2.作用于非静态方法
public synchronized void achieveMoney(int income) {
money += income;
}
public void achieveCoin(int income) {
// 3.作用域代码快
synchronized (monitor) {
money += income;
}
}
// 4.可重入
public void achieve(int income) {
for (int i = 0; i < 10; i++) {
synchronized (monitor) {
money += income;
}
}
}
}
面试官:ReentrantLock 的用法呢?也一起写一写。
安琪拉:
public class ReentrantLockTest {
private static final ReentrantLock lock = new ReentrantLock();
public void add(int income) {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
//doSomeThing();
}finally {
lock.unlock();
}
}
}
}
上面代码是带时间的尝试获取锁,获取成功,才会执行后续逻辑。
安琪拉:对了,能帮我倒杯水吗?讲了这么久有点渴。
面试官:ReentrantLock 有什么特点,使用场景是怎样的?
安琪拉:ReentrantLock 有很多特点,很适合按照特定场景来用,
可尝试获取锁
比如我有个定时任务,每间隔5秒钟执行一次暴打凯的任务。但是为了防止前一次暴打凯任务还没结束,后一次任务又触发了,怕凯受不住,我就在每次任务执行前先尝试获取一下锁,调用
lock.tryLock()
,如果获取锁失败,直接跳过这次任务,等下一次任务执行。公平锁
比如亚瑟、凯、孙悟空都希望能跟安琪拉一起中路蹲草,但是中路草丛有限,挤不下这么多人,只能选一个,那就大家按照先来先到原则,从ReentrantLock获取到锁的才允许蹲草,获取不到,需要等在后面,ReentrantLock是公平的。
如果是非公平锁的话,可能草丛已经有亚瑟跟凯排队了,孙悟空从野区来直接排在他俩前面了。
带条件等待
使用synchronized 结合Object上的 wait和 notify方法可以实现线程间的等待通知机制。但是ReentrantLock结合Condition接口同样可以实现这个功能。而且相比synchronized 使用起来更清晰。
我们来写个代码:
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
lock.lock();
//启动子线程
new Thread(new SubThread()).start();
System.out.println("主线程等待通知");
try {
//主线程进入等待队列,释放锁
condition.await();
} finally {
lock.unlock();
}
System.out.println("主线程恢复运行");
}
static class SubThread implements Runnable {
@Override
public void run() {
//获取锁
lock.lock();
try {
//唤醒条件等待队列
condition.signal();
System.out.println("子线程通知");
} finally {
lock.unlock();
}
}
}
程序输出:
面试官:既然你这里讲到带条件的等待,那你通过Reentrantlock 写一个生产者消费者模型,纸笔给你
安琪拉:
ReentrantLock lock = new ReentrantLock();
/**
* 库存
*/
LinkedList<Integer> list = new LinkedList<>();
/**
* 容量
*/
private int capacity;
public Factory(int capacity) {
this.capacity = capacity;
}
Condition notFull = lock.newCondition();//队列满时的等待条件
Condition notEmpty = lock.newCondition();//队列空时的等待条件
public void produce(int ele) throws InterruptedException {
lock.lock();
try {
//这个地方容易写成if,注意应该是while,在多线程编程里面死循环很常见
while (list.size() == capacity) {
//库存满了,等库存空余(不满)的条件
notFull.await();
}
System.out.println("生产元素:" + ele);
list.add(ele);
//生产了新商品,通知消费
notEmpty.signal();
} finally {
lock.unlock();
}
}
public void consume() throws InterruptedException {
lock.lock();
try {
//同上
while (list.size() == 0) {
//库存空了,等不为空条件出现
notEmpty.await();
}
System.out.println("消费元素:" + list.poll());
notFull.signal();
} finally {
lock.unlock();
}
}
//测试验证
public static void main(String[] args) {
Factory factory = new Factory(2);
for (int i = 0; i < 10; i++) {
int data = i;
new Thread(() -> {
try {
factory.produce(data);
} catch (InterruptedException e) {
}
}).start();
}
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
factory.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
面试官:那你跟我讲讲ReentrantLock 如何区分公平还是非公平的?
安琪拉:直接上代码吧
//默认: 非公平锁,
public ReentrantLock() {
sync = new NonfairSync();
}
//公平锁, fair 传入true
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
非公平锁的吞吐量比公平锁高很多,所以默认使用非公平锁。
面试官:那你跟我说说获取锁的流程?
安琪拉:先说非公平锁的获取流程。
final void lock() {
//直接尝试获取锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
非公平锁直接上来先尝试CAS 抢占一把,如果没有线程占锁,直接获取,否则进入 acquire(1)
流程。
acquire(1)
是公平锁的处理流程,这些都是AQS 提供的能力。
面试官:等你半天了,终于说到我想听的了,对于AQS, 讲讲你的理解,顺着这样的思路来讲:Why - What - How。为什么会有这个东西?这个东西是什么?怎么做到的?
尤其是和synchronized作一下对比,可能觉得已经有synchronized锁了,还需要AQS吗?
安琪拉:
Why: synchronized 只解决了有和没有的问题,但是锁的场景和多样性方面还很欠缺,例如: 带超时时间的获取锁、获取锁非阻塞(尝试获取锁)、带等待条件的请求锁。其实这些你通过synchronized 加手动封装也能实现,但是需要些功力而且还容易出错,所以Doug Lea就写了功能更加丰富的AQS以及一些一系列多线程组件,方便大家按需扩展。AQS 是JDK1.5 引入的,那个时候synchronized 还没做优化,没有偏向锁和轻量级锁,所以AQS 的 CAS(Reentrantlock) 自旋就很有必要了,毕竟有时间竞争并不激烈。
What: AQS是AbstractQueuedSynchronizer 的缩写,抽象队列同步器,我们很多同步工具ReentrantLock、Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier 都是基于AQS 实现的。
How: AQS实现机制是什么呢?大家可以先不往下看,如果让你来实现一个多线程控制访问共享资源的工具,你会如何写?考虑这么几个问题:
- 当有多个线程竞争的时候,运行线程排队等待获取资源,如何做?
- 当某个线程使用完资源,如何通知正在排队等待的资源?
- synchronized获取锁是阻塞的,也就是线程获取锁的时候一定会进入等待,但是如果希望实现一个线程过来访问,发现已经有其他线程持有锁了,直接返回,不希望产生锁竞争,怎么实现?
AQS 基本的原理是它提供了一套共享资源的访问的规范,通过CLH(一个双向链表)的方式把线程等待管理起来。
CLH
它底层采用的是状态标志位(state变量)+FIFO队列的方式来记录获取锁、释放锁、竞争锁等一系列锁操作;
对于AQS而言,其中的state变量可以看做是锁,队列采用的是先进先出的双向链表,state共享状态变量表示锁状态,内部使用CAS对state进行原子操作修改来完成锁状态变更(锁的持有和释放)。
面试官:那你给我讲讲AQS中锁获取、释放的流程?
安琪拉:AQS东西太对了,您看要不这样子,到二面面试官的时候,我再深入介绍AQS。
面试官:这样子啊。那今天先到这吧,你回去等我们通知。
安琪拉:嗯,通知我的时候记得不要打电话,不方便,发微信就好了。