吊打面试官之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的区别到底是什么?(╯▔皿▔)╯
-
从AQS的角度上靠,ReentrantLock基于AQS实现,有tryLock 去抢锁,对应AQS的tryAcquire,加锁失败后能做其它操作,而sychronized不彳亍。
-
ReentrantLock基于AQS实现,支持公平锁和非公平锁,而sychronized不支持。
-
ReentrantLock因为基于AQS实现,没有直接使用操作系统底层mutex原语来线程多线程对共享资源的访问,而是通过tryAcquire先通过CAS操作 ,而sychronized在加入锁升级机制(轻量级锁阶段也是用的CAS操作)之前就直接那样干了。
-
ReentrantLock抢锁失败的线程会放在AQS等待队列中,而使用Sychronized抢锁会放在锁池中。
-
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( ̄▽ ̄)ブ
