持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情
面试官:你知道Java中有哪些锁吗?
锁的分类
乐观锁、悲观锁
乐观锁
以乐观的心态操作资源,认为数据不会总是更改,不会真正的加锁,在获取不到锁资源时,可以再次让CPU调度,重新尝试获取锁资源。
Java中提供的CAS操作,就是乐观锁的一种实现。Atomic原子性类中,就是基于CAS乐观锁实现的。
悲观锁
悲观锁则认为数据总是频繁更改,每次操作资源时都会加锁保证互斥。获取不到锁资源时,会将当前线程挂起(进入BLOCKED、WAITING),线程挂起会涉及到用户态和内核态的切换,而这种切换是比较消耗资源的。
- 用户态:JVM可以自行执行的指令,不需要借助操作系统执行。
- 内核态:JVM不可以自行执行,需要操作系统才可以执行。
Java中提供的synchronized,ReentrantLock,ReentrantReadWriteLock都是悲观锁。
可重入锁、不可重入锁
重入
当前线程获取到A资源的锁,在获取之后尝试再次获取A资源的锁是可以直接拿到的。
Java中提供的synchronized,ReentrantLock,ReentrantReadWriteLock都是可重入锁。
不可重入
当前线程获取到A资源的锁,在获取之后尝试再次获取A资源的锁,无法获取到的,因为A锁被当前线程占用着,需要等待自己释放锁再获取锁。
互斥锁、共享锁
互斥锁
同一时间点,只会有一个线程持有者当前互斥锁。
Java中提供的synchronized、ReentrantLock是互斥锁。
共享锁
同一时间点,当前共享锁可以被多个线程同时持有。
Java中提供的ReentrantReadWriteLock,有互斥锁也有共享锁。
公平锁、非公平锁
公平锁
线程A获取到了锁资源,此时线程B也来获取锁但是没有拿到,那么线程B去排队,然后线程C来了,锁被A持有,同时线程B在排队。那么线程C直接排到B的后面,等待B拿到锁资源或者是B取消后,才可以尝试去竞争锁资源。
非公平锁
线程A获取到了锁资源,此时线程B也来获取锁但没有拿到,线程B去排队,然后线程C来了,先尝试竞争一波。出现两种情况:
- 拿到锁资源:开心,插队成功。
- 没有拿到锁资源:依然要排到B的后面,等待B拿到锁资源或者是B取消后,才可以尝试去竞争锁资源。
Java中提供的synchronized只能是非公平锁。
Java中提供的ReentrantLock,ReentrantReadWriteLock可以实现公平锁和非公平锁。
面试官:既然聊到了synchronized,那么说说你对synchronized的理解
synchronized
在Java1.5版本中,synchronized性能不如SDK里面的Lock,但1.6版本之后,synchronized做了很多优化,将性能追了上来。
使用场景
- Synchronized修饰普通同步方法:锁对象当前实例对象;
- Synchronized修饰静态同步方法:锁对象是当前的类Class对象;
- Synchronized修饰同步代码块:锁对象是Synchronized后面括号里配置的对象,这个对象可以是某个对象(object),也可以是某个类(Object.class);
注意事项
面试官:synchronized使用时有什么要注意的地方吗?
- 使用synchronized修饰非静态方法或者使用synchronized修饰代码块,指定的为实例对象时,同一个类的不同对象拥有自己的锁,因此不会相互阻塞。
- 使用synchronized修饰类和对象时,由于类对象和实例对象分别拥有自己的监视器锁,因此不会相互阻塞。
- 使用使用synchronized修饰实例对象时,如果一个线程正在访问实例对象的一个synchronized方法时,其它线程不仅不能访问该synchronized方法,该对象的其它synchronized方法也不能访问,因为一个对象只有一个监视器锁对象,但是其它线程可以访问该对象的非synchronized方法。
- 线程A访问实例对象的非static synchronized方法时,线程B也可以同时访问实例对象的static synchronized方法,因为前者获取的是实例对象的监视器锁,而后者获取的是类对象的监视器锁。
实现原理
面试官:那么synchronized的实现你有了解过吗?
synchronized是基于对象实现的。在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现,Synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步。
Synchronized修饰代码块
Synchronized代码块同步在需要同步的代码块开始的位置插入monitorenter指令,在同步结束的位置或者异常出现的位置插入monitorexit指令;JVM要保证monitorenter和monitorexit都是成对出现的,任何对象都有一个monitor与之对应,当这个对象的monitor被持有以后,它将处于锁定状态。
同步方法块在进入代码块时插入了monitorenter语句,在退出代码块时插入了monitorexit语句,为了保证不论是正常执行完毕还是异常跳出代码块都能执行monitorexit语句,因此会出现两句monitorexit语句。
Synchronized修饰方法
Synchronized方法同步不再是通过插入monitorenter和monitorexit指令实现,而是由方法调用指令来读取运行时常量池中的ACC_SYNCHRONIZED标志隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。
对象的锁信息
Java对象在堆中存储的除了我们的实例数据之外,还有对象头和对象填充部分。
对象头包含MarkWord和ClassPoint,其中MarkWord中标记着四种锁的信息:无锁、偏向锁、轻量级锁、重量级锁。
Synchronized锁升级
面试官:既然说到这里,就再说说synchronized锁升级的过程吧?
锁的4种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)。
偏向锁的引入
大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的升级
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
轻量级锁的引入
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
轻量级锁什么时候升级为重量级锁
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
面试官:刚刚说了锁升级,那么锁能否降级?
为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
ReentrantLock
面试官:嗯,小伙子了解的还不少嘛,刚刚你也说到了ReentrantLock,那么它和Synchronized有什么区别吗?
ReentrantLock和Synchronized的区别
单词不一样(面试官:说点有用的)。
- ReentrantLock是个类,synchronized是关键字,当然都是在JVM层面实现互斥锁的方式。
- 实现原理是不一样,ReentrantLock基于AQS实现的,synchronized是基于ObjectMonitor。
- ReentrantLock支持公平锁和非公平锁,可以指定等待锁资源的时间。
- 如果竞争比较激烈,推荐ReentrantLock去实现,不存在锁升级概念。而synchronized是存在锁升级概念的,如果升级到重量级锁,是不存在锁降级的。
AQS
面试官:刚刚提到了AQS,能详细说说吗?
AQS就是AbstractQueuedSynchronizer类,其实就是JUC包下的一个基类,JUC下的很多内容都是基于AQS实现了部分功能,比如ReentrantLock、ThreadPoolExecutor、阻塞队列、CountDownLatch、Semaphore、CyclicBarrier等等都是基于AQS实现。
首先AQS中提供了一个由volatile修饰,并且采用CAS方式修改的int类型的state变量。还维护了一个当前获取锁的线程。
其次AQS中维护了一个双向链表(等待队列),有head,有tail,并且每个节点都是Node对象。
当有线程调用ReentrantLock的lock()方法时,会将state从0改为1。修改成功则表示加锁成功,会将自己设为当前获取锁的线程。如果此时线程2也来获取锁,也会尝试将state从0改为1,此时state的值是1,所以操作失败,这时再去判断自己是不是当前获取锁的线程,很明显也不是,那么就将自己放入等待队列中,等待持有锁的线程释放资源。
释放锁的过程非常的简单,就是将AQS内的state变量的值递减1,如果state值为0,则彻底释放锁,会将当前获取锁的线程也设置为null!此时将等待队列的头节点唤醒重新尝试加锁。
死锁
面试官:那你了解死锁吗?说说看
死锁是指一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
简单来说就是,线程1要操作资源A且获取了资源A的锁,同时也要操作资源B需要获取资源B的锁(获取不到就等待且不会释放当前持有资源A的锁),此时线程2正在操作资源B并没有释放资源B的锁,恰好线程2也要操作资源A,也在获取资源A的锁(获取不到就等待且不会释放当前持有资源B的锁)。那么就会出现死锁的情况。
并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
死锁发生的条件
- 互斥,共享资源A和B只能被一个线程占用;
- 占有且等待,线程1已经取得共享资源A,在等待共享资源B的时候,不释放共享资源A;
- 不可抢占,其他线程不能强行抢占线程1占有的资源;
- 循环等待,线程1等待线程2占有的资源,线程2等待线程1占有的资源,就是循环等待。
只有以上这四个条件都发生时才会出现死锁。
避免死锁的发生
我们破坏其中一个条件,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。
-
对于“占有且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
-
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
-
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
面试官:很好小伙子,今天的面试就先到这里,你可以回去等消息了!