吊打面试官之ReentrantLock 和 Sychronized的区别?

基本回答

这个问题还不简单? 我上我也行!

  • 无非就是ReentrantLock 是在JUC包中的,是需要new的一个对象, 而Sychronized是Java内置关键字
  • 从使用上看,ReentrantLock实现起来比较复杂,还需要手动加锁解锁

但是这还不够,看起来实在没什么意思。既然涉及JUC包下的并发工具实现,那我们就必须提到小学二年级就学过的AQS,作为Java多线程并发工具类的基石,是不可逾越的一座大山。谈到并发,不谈谈AQS,感觉就没那味(T_T)。

浅谈AQS

说到AQS,感觉这是一个很抽象的概念,不知道从何说起,迷迷糊糊地记得这是一个抽象队列同步器。好像有一个队列 ,抽象了出队,入队,加锁之类的操作,什么支持非公平锁和公平锁。可以不用想的这么复杂。

我们从故事最开始的地方说起。

我们为了提高Java程序的运行效率,最大化资源利用,所以有了多线程。但这是有代价的,好处有了,问题接踵而至。

如何保证在多线程环境下,安全地访问共享资源,保证程序最终运行结果的正确性?

解决方法是维护一个int 类型的 state变量,初始化为0。

如果有线程访问这个共享资源,state变量就加1, 停止访问就减1,当state变量值为0时,代表当前共享资源没有线程访问。这也就是ReentrantLock的基本实现原理。

那么问题来了,既然state变量表示加锁解锁,为什么不用boolean类型呢?

有没有一种可能,一个线程抢到的锁不只一把,要是同步代码块里还有其它的锁怎么办?你把state标记成true了不就死锁了吗? 所以锁的数量不唯一,这就是可重入锁实现的原理。ReentrantLock的名字也叫重入锁。

还有一种情况,同步代码块不一定只有一个线程才能访问吧,如果我只是想限定访问同步代码块的线程数量呢?

这就是Semaphore的实现原理。

其它的并发工具类基本都是基于这个思想实现,无非就是根据自己的需要修改一下,像coundownlatch 的初始的state变量就不为0了,来一个线程state就减1,当state等于0时就唤醒所有线程了。

如何修改state变量呢?

通过CAS+自旋的方式修改,如果失败次数多了就暂时阻塞线程,等资源空闲再唤醒。

失败的线程过多了怎么办?

这时候就需要维护一个队列将抢锁失败的线程封装成一个个node节点放进一个队列中(先进先出的双向链表),这样暂时管理一下等待的线程。注意这时并没有立即挂起线程,不涉及用户态到内核态的切换。

具体实现

具体实现有个tryAcquire

plain
复制代码
protected boolean tryAcquire(long arg) { throw new UnsupportedOperationException(); }

只返回true和false,表示是否取到共享资源(CAS实现),不然就去做其它操作。具体实现由各个并发工具类实现。

还有个acquire

plain
复制代码
public final void acquire(long arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

它封装了排队、阻塞、唤醒等通用逻辑,子类只需关注tryAcquire的实现。

这里用了final修饰,表示不能有子类重写,此时AQS就算是接管了

plain
复制代码
final boolean acquireQueued(final Node node, long arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

这是AQS核心的锁获取排队逻辑(了解即可),用于在锁竞争失败后将线程加入CLH队列,并通过自旋或挂起等待锁释放。

关键点1

p == head && tryAcquire(arg)

当前节点的前驱是头节点(即当前线程是队列中的第一个等待线程

用tryAcquire再次尝试获取锁(此时锁可能已被前驱节点释放)。

成功后就setHead(node); 将当前节点设置为头节点

关键点2

shouldParkAfterFailedAcquire:检查当前线程是否应该挂起(避免无意义自旋浪费CPU)。

这些源码看得头痛?那就记住一句话吧:AQS尽量保证在用户态管理同步状态,仅在必要时依赖操作系统挂起线程

为什么AQS要实现等待队列呢?

这里就涉及到了公平锁和非公平锁的实现。

直接重写tryAcquire就能定义公平或非公平锁。

并且线程队列插入删除都是O(1)的时间复杂度。

区别

叽里咕噜的说什么呢?ReentrantLock和Sychronize的区别到底是什么?(╯▔皿▔)╯

  1. 从AQS的角度上靠,ReentrantLock基于AQS实现,有tryLock 去抢锁,对应AQS的tryAcquire,加锁失败后能做其它操作,而sychronized不彳亍。

  2. ReentrantLock基于AQS实现,支持公平锁和非公平锁,而sychronized不支持。

  3. ReentrantLock因为基于AQS实现,没有直接使用操作系统底层mutex原语来线程多线程对共享资源的访问,而是通过tryAcquire先通过CAS操作 ,而sychronized在加入锁升级机制(轻量级锁阶段也是用的CAS操作)之前就直接那样干了。

  1. ReentrantLock抢锁失败的线程会放在AQS等待队列中,而使用Sychronized抢锁会放在锁池中。

  2. ReentrantLock处理主动放弃锁的线程(调用await方法),可以放在Condition等待队列中,实现更精细化的处理,而sychronized处理主动放弃的线程会放在等待池中。

简单说说sychronized锁监视器机制。

这是在sychronized升级为重量级锁的时候,对象头的mark word指针指向锁监视器monitor

cpp
复制代码
class ObjectMonitor{ // 当前持有锁的线程(指向 JavaThread) volatile Thread* _owner; // 递归锁计数(重入次数) volatile int _recursions; // 等待获取锁的线程队列(Entry Set)——等待进入 synchronized 块 ObjectWaiter* _EntryList; // 因 wait() 而阻塞的线程队列(Wait Set) ObjectWaiter* _WaitSet; .......其它字段 }

锁池用来管理抢锁失败的线程(blocking)

此时线程是时刻准备抢锁的,只是目前没抢到(T_T)

等待池用来管理调用wait方法陷入等待状态线程

此时是主动放弃锁的线程,目前要等待其他线程唤醒(waiting状态)

是为了等某个资源到位了才重出江湖,被notifiy了才去执行任务,再放到锁池中,准备抢锁,这是线程通信问题

这就是从AQS的角度上解释ReentrantLock和Sychronized的区别,看到这里相信应该可以吊打面试官了吧o( ̄▽ ̄)ブ

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
不想上班
下载 APP