Java面试系列之并发编程专题-Synchronized灵魂拷问
作者:
修罗debug
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
金三银四跳槽季即将来临,想必有些猿友已经蠢蠢欲动在做相关的准备了!在接下来的日子里,笔者将坚持写作、分享Java工程师在面试求职期间的方方面面,包括简历制作、面试场景复现、面试题解答、谈薪技巧 以及 项目的实战!今天我们先拿Java里面的并发编程之Synchronized来开刀!
以下内容来自 程序员实战基地 fightjava.com 一位网友最近的面试场景,笔者尝试着将其复现,话不多说,咱们直接开撸!
1. 面试官:Synchronized有用过吗?谈谈你对它的理解
(1)画外音:面试官主要是想了解你有没有Java并发编程方面的经验,可以讲讲它的概念和部分原理!
(2)回答:Synchronized是Java的关键词,JVM实现的一种可以实现并发产生的多个线程互斥同步访问共享资源的 方式,也可以说是一种 “同步互斥锁”,在实际代码中可用于修饰代码块、方法、静态方法以及类;适用于单体应用系统架构
2.面试官:嗯,说一说它的原理?
(1)画外音:这么快就问原理,看来是动真格的了,不是随便问问而已!
(2)回答: 通过查看被Synchronized 修饰过的代码块编译后的字节码,会发现编译器会在 被Synchronized修饰过的代码块 的前、后生成两个字节码指令:monitorenter、monitorexit;
这两个字节码指令的含义:当JVM执行到monitorenter指令时,首先会尝试着先获取对象(共享资源)的锁,如果该对象没有被锁定、又或者当前线程已经拥有了这个对象的锁时,则锁的计数器count加1,即执行 +1 操作;当JVM执行monitorexit指令时,则将锁的计数器count减一,即执行 -1 操作;
当计数器count为0时 ,该对象的锁就被释放了!!
如果当前线程获取该对象的锁失败了,则进入堵塞等待状态,直到该对象的锁被另外一个线程释放为止;即Java中的Synchronize底层其实是通过对象(共享资源)头、尾设置标记,从而实现锁的获取和释放。
3.面试官:你刚才提到获取对象的锁,说一说“锁”到底是什么,如何确定对象的锁?
(1)画外音:这是笔者自行想象、扩充的内容!
(2)回答: “锁” 可以理解为monitorenter和monitorexit字节码指令之间的一个 Reference类型的参数,即要锁定Lock和解锁UnLock的对象
众所周知,使用Synchronized可以修饰不同的对象,因此,对应的对象的锁可以这么确定:
A.如果Synchronized 明确指定了“锁”的对象,比如Synchronized变量、 Synchronized(this) 等,说明加、解锁的即为该变量、当前对象;
B.若 Synchronized 修饰的方法为非静态方法,表示此方法对应的对象为“锁”对象; 若 Synchronized 修饰的方法为静态方法,则表示此方法对应的类对象为“锁”对象;
注意:当一个对象被锁住时,对象里面所有用Synchronized 修饰的方法都将产生堵塞, 而对象里非Synchronized 修饰的方法可正常被调用,不受锁的影响;
4.面试官:什么叫可重入锁,为什么说Synchronized是可重入锁?
(1)画外音:这面试官脑袋瓜转得可真快!
(2)回答:通俗地讲,“可重入”指的是:当 当前线程获取到了当前对象的锁之后,如果后续的操作仍然需要获取获取该对象的锁时,可以不用再次重新获取,即可以直接操作该对象(共享资源);
可重入性是锁的一个基本要求,是为了解决自己锁死自己的情况,比如一个类的同步方法调用另一个同步方法时,假如Synchronized不支持重入,进入method2方法时当前线程已经获得锁,而在method2方法里面执行method1时当前线程又要去尝试获取锁,这时如果不支持重入,它就要等待释放,把自己阻塞,导致很有可能自己锁死自己!
对Synchronized来说,可重入性是显而易见的,刚才提到,在执行monitorenter指令时,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,就把锁的计数器+1,其实本质上就是通过这种方式实现了可重入性(而不是已拥有了锁则不能继续获取)。
5.面试官:说一说JVM底层对Java的原生锁做了哪些优化?
(1)画外音:这有点难,特意上网参考了下,原来是关于锁竞争和升级的……
(2)回答:在Java 6以前前,Monitor的实现完全依赖底层操作系统的互斥锁来实现,也就是上面在问题2中所阐述的获取、释放锁的逻辑;由于Java的线程与操作系统的原生线程有映射关系,如果要将一个线程进行阻塞或唤起 都需要操作系统的协助,这就需要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代JDK中做了大量的优化;
一种优化是使用自旋锁,即在线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。
而现代JDK中还提供了三种不同的Monitor实现,也就是三种不同的锁:偏向锁、轻量级锁、重量级锁
这三种锁使得JDK得以优化Synchronized的运行,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这就是锁的升级、降级。
当没有竞争出现时,默认使用偏向锁,JVM会利用CAS操作,在对象头上的MarkWord部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁,因为在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
如果有另一线程试图锁定某个被偏向锁锁过的对象,JVM就会自动撤销偏向锁,切换到轻量级锁实现;轻量级锁依赖CAS操作MarkWord来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁;
6.面试官:嗯,不错,Synchronized是公平锁还是非公平锁,为什么?
(1)画外音:这个倒不难……
(2)回答:非公平;非公平主要表现在获取锁的行为上:并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象;
7.面试官: 为什么说Synchronized是悲观锁?
(1)画外音:面试官的用意应该是想让候选人谈谈对悲观锁的理解(下面的回答有点官方)
(2)回答:因为Synchronized的并发策略是悲观的:即不管是否会产生竞争,任何的数据操作都必须要加锁,包括“从用户态切换到核心态”、“维护锁计数器”和“检查被阻塞的线程是否需要被唤醒”等操作;
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略,即先进行操作,如果没有其他线程征用数据,那操作就成功了;
如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施,这种乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。
8.面试官: 那你了解乐观锁吗,它的实现原理又是什么,能讲讲吗?
(1)画外音:应该是聊聊CAS机制……
(2)回答:乐观锁,顾名思义表示系统总是认为当前的并发情况是乐观的,而不需要通过加各种锁进行控制;
乐观锁的实现原理是CAS机制(Compare And Swap,比较并交换),一种在JUC中广泛使用的算法;它涉及到三个操作数:内存值V、预期值A、新值B,当且仅当预期值A和内存值V相等时才将内存值V修改为新值B;
其底层实现逻辑:首先检查某块内存的值是否跟之前我读取的是一样的,如果不一样则表示期间此内存值已经被别的线程更改过,舍弃本次操作,否则说明期间没有其他线程对此内存值操作,可以把新值设置给此块内存,即间接意味着获取锁成功!
CAS具有原子性,它的原子性是由CPU硬件指令实现保证的,即通过JNI调用Native方法,从而调用由C++编写的硬件级别指令,JDK中提供了Unsafe类来执行这些操作(查看JUC很多类的底层源码会发现 Unsafe.compareAndSwapxxx() 的调用无处不在,很牛逼!!!)
9.面试官:那乐观锁就一定是好的吗?
(1)画外音:废话,世间一切事物哪有什么是一定的,此处应该是想让候选人提到自旋消耗性能以及ABA的问题……
(2)回答:乐观锁可以避免 悲观锁独占对象这一现象 的出现,同时也提高了并发性能,但它也有一些缺点:
A. 乐观锁只能保证一个共享变量的原子操作:如果多一个或几个变量,乐观锁将变得力不从心,但互斥锁能轻易解决,不管对象数量多少及对象颗粒度大小;
B. 长时间自旋可能导致开销大。假如CAS长时间不成功而一直自旋,会给CPU带来很大的开销;
C. ABA问题:CAS的核心思想是通过比对内存值与预期值是否一样而判断内存值是否被改过,但这个判断逻辑不够严谨;
假如内存值原来是A,后来被一线程改为B,最后又被改回了A,则CAS认为此内存值并没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程值的情景的运算结果影响很大。
10.面试官:刚提到ABA的问题,那有什么办法解决吗?
(1)画外音:打个广告~这个可以看看debug的“分布式锁实战视频教程”,乐观锁那里就用的 version来实现的
(2)回答:解决的思路是引入版本号,每次变量更新时都把版本号加1,同时如果条件允许,还需要额外建立数据更新历史表,并同时维护好版本号version 和 数据变更记录的映射关系!
11.面试官:跟Synchronized相比,可重入锁ReentrantLock的实现原理有什么不同?
(1)画外音:打个广告~这个可以看看debug的“分布式锁实战视频教程”,乐观锁那里就用的 version来实现的
(2)回答:其实,几乎所有锁的实现原理都是为了达到同个目的:让所有的线程都能看到某种标记,同一时刻只能有一个线程获取到锁;
Synchronized通过在对象头中设置标记MarkWord实现了这一目的,是一种JVM原生的锁实现方式;
而ReentrantLock以及所有的基于Lock接口的实现类,则是通过一个volitile关键字修饰的int类型变量,并保证每个线程都能拥有对该int变量的可见性和原子性,其本质是基于所谓的AQS框架;
12.面试官:你刚刚提到了AQS,那你说说AQS的实现原理?
(1)画外音:还真是不依不饶啊……
(2)回答:AQS,即 AbstractQueuedSynchronizer 抽象队列同步器,是一个用来构建锁和同步器的类,JUC Lock包下的锁(常用的有ReentrantLock、ReadWriteLock),以及其他的像Semaphore、CountDownLatch,甚至是早期的FutureTask等,都是基于AQS来构建的;
A.AQS在内部定义了一个变量:volatile int state,用于表示同步状态:当线程调用lock方法时,如果state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将state=1;如果state=1,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
B.AQS内部是通过Node实体类来表示一个双向链表结构的同步队列,完成线程获取锁的排队工作,当有线程获取锁失败后,就被添加到队列末尾。
Node类是对要访问同步代码的线程的封装,包含了线程本身及其状态waitStatus(它有五种不同的取值,分别表示是否被阻塞、是否等待唤醒、是否已经被取消等),每个Node结点关联其prev结点和next结点(指针),方便线程释放锁后快速唤醒下一个在等待的线程,是一个FIFO的过程;
Node类有两个常量,SHARED和EXCLUSIVE,分别代表共享模式和独占模式,所谓共享模式是一个锁允许多条线程同时操作(信号量Semaphore就是基于AQS的共享模式实现的),独占模式指的是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待(如ReentranLock);
C.AQS通过内部类Condition Object构建等待队列(可有多个),当Condition调用wait()方法后,线程将会加入等待队列中,而当Condition调用signal()方法后,线程将从等待队列转移动同步队列中竞争锁。
D.AQS和Condition各自维护了不同的队列,在使用Lock和Condition的时候,其实就是两个队列的互相移动。
13.面试官:请对比下Synchronized 和 ReentrantLock的异同?
(1)画外音:笔者自行扩充的
(2)回答:ReentrantLock是Lock的实现类,是一个互斥的同步锁;
A.从功能角度上看,ReentrantLock比Synchronized的同步操作更精细(因为可以像普通对象一样使用),甚至实现了Synchronized没有的高级功能,如:
等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,对处理执行时间非常长的同步块很有用。
带超时的获取锁尝试:在指定的时间范围内获取锁,如果时间到了仍然无法获取则返回,可以判断是否有线程在排队等待获取锁。
可以响应中断请求:与Synchronized不同,当获取到锁的线程被中断时,能够响应中断,中断异常将会被抛出,同时锁会被释放。
可以实现公平锁:从锁的释放角度上看,Synchronized在JVM层面上实现的,不但可以通过一些监控工具监控Synchronized的锁定,而且在代码执行出现异常时,JVM会自动释放锁定;但是使用Lock则不行,Lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中。
B.从性能角度上看,Synchronized早期实现比较低效,对比ReentrantLock,大多数场景性能都相差较大,但是在Java6中对其进行了非常多的改进,在竞争不激烈时,Synchronized的性能要优于ReetrantLock;在高竞争情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态。
14.面试官:上面提到ReentrantLock也是一种可重入锁,那它的底层又是如何实现的?
(1)画外音:这……还是得聊聊AQS?(后面偷瞄了一会源码)
(2)回答:ReentrantLock内部自定义了同步器Sync(Sync既实现了AQS,又实现了AOS,而AOS提供了一种持有互斥锁的方式),其实就是加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程ID和当前请求的线程ID是否一样,一样就可重入了。
15.面试官:除了Synchronized 和 ReentrantLock,你还接触过JUC下中的哪些并发工具?
(1)画外音:总算人性化一点了,可以自圆其说了!
(2)回答:通常所说的并发包JUC其实就是java.util.concurrent包及其子包下集合了Java并发的各种基础工具类,具体主要包括几个方面:
A.提供了CountDownLatch、CyclicBarrier、Semaphore等,比Synchronized更加高级,可以实现更加丰富多线程操作的同步结构;
B.提供了ConcurrentHashMap、有序的ConcunrrentSkipListMap,或者通过类似快照机制实现线程安全的动态数组CopyOnWriteArrayList等,各种线程安全的容器;
C.提供了ArrayBlockingQueue、SynchorousQueue或针对特定场景的PriorityBlockingQueue等,各种并发队列的实现;
D.强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等。
16.面试官:简单说一说ReadWriteLock 和StampedLock 吧?
(1)画外音:问得还比较多……
(2)回答:虽然ReentrantLock和Synchronized简单实用,但是行为上有一定的局限性,要么不占,要么独占;在实际应用场景中,有时候不需要大量竞争的写操作,而是以并发读为主,为了进一步优化并发操作的粒度,Java提供了读写锁;
读写锁基于的原理是:多个读操作不需要互斥,如果读锁试图锁定时,写锁却被某个线程持有时,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到脏数据;
ReadWriteLock代表了一对锁,它在数据量大 且 并发读多、写少的时候,能够比纯同步版本凸显出优势;
读写锁看起来比Synchronized的粒度似乎细一些,但在实际应用中,其表现也并不尽人意,主要还是因为相对比较大的开销;
所以,JDK在后期引入了StampedLock,在提供类似读写锁的同时,还支持优化读模式,优化读是基于这样的假设:大多数情况下读操作并不会和写操作冲突,其逻辑是先试着修改,然后通过validate方法确认是否进入了写模式,如果没有进入,就成功避免了开销;如果进入,则尝试获取读锁。
17.面试官:如何让Java的线程彼此同步?你了解过哪些同步器?
(1)画外音:应该是想聊JUC 同步器的三个成员 : CountDownLatch、 CyclicBarrier
和 Semaphore,我不玩了……
面试官:看出了我的不悦,诡异一笑,好吧,关于Java的锁和同步咱们聊得也比较多了,咱们换个话题吧!!!
下一回合,咱们聊聊 Java的线程池!!!
总结: