转自慕课网《高薪之路–Java面试题精选集》
与 JVM 一样,并发与多线程也是 Java 程序员进阶必备的知识,也几乎是中高级岗位程序员的必考题目,具有相当的深度和区分度。同样不仅用于考察候选人能否回答正确,而且还用于考察候选人对问题的理解深度 — 这类考察深度的面试题通常直接关系着候选人的定级,而定级又通常和薪资挂钩。并发也是一个很庞大的主题,专栏将多线程与并发的面试高频题精简为三方面的内容:
1、锁与并发;
2、线程与线程池;
3、Java 并发进阶问题;
这三方面的内容分别对应接下来的三个章节,各部分内容相对来说比较独立,下面分别展开讲解。
锁与并发是息息相关,锁是并发控制的基础,并发则是 Java 支撑大规模高性能网站的基础。因此 Java 的锁 / 并发也一直处于发展当中,从最开始的关键字 synchronized 到后面 synchronized 语义的优化扩充、再到 API 层面的锁、CAS 的出现等等。总体来讲,是一个不断进化的知识点。
问题 1:什么是线程死锁?死锁产生的条件是什么?如何避免死锁?
参考答案:
1、什么是线程死锁?
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
2、死锁产生的条件是什么?
(1)、互斥条件:该资源任意一个时刻只由一个线程占用;
(2)、请求与保持条件:一个线程 / 进程因请求资源而阻塞时,对已获得的资源保持不放;
(3)、不剥夺条件:线程 / 进程已获得的资源在末使用完之前不能被其他线程 / 进程强行剥夺,只有自己使用完
毕后才释放资源;
(4)、循环等待条件:若干线程 / 进程之间形成一种头尾相接的循环等待资源关系。
3、如何避免线程死锁?
针对死锁产生的条件进行一一拆解:
(1)、破坏互斥条件:无法破坏,因为使用锁的本意就是想让它们互斥的(临界资源需要互斥访问);
(2)、破坏请求与保持条件:一次性申请所有的资源;
(3)、破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源;
(4)、破坏循环等待条件:按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件(最常用)。
点评:
死锁示意图如下,线程 A 持有 resource1,线程 B 持有 resource2,它们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
一个死锁的用例:
//资源1
private static final Object resource1 = new Object();
//资源2
private static final Object resource2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {// 先获得resource1
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "死锁测试线程1").start();
new Thread(() -> {
synchronized (resource2) {// 先获得resource2
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "死锁测试线程2").start();
输出
Thread[死锁测试线程1,5,main]get resource1
Thread[死锁测试线程2,5,main]get resource2
Thread[死锁测试线程1,5,main]waiting get resource2
Thread[死锁测试线程2,5,main]waiting get resource1
线程 A 通过 synchronized (resource1) 获得 resource1 的锁,休眠 5000 毫秒;与此同时,线程 B 通过 synchronized (resource2) 获得 resource2。休眠结束后,线程 A 和线程 B 都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
为了正常运行,针对上面的示例作如下的修改(调整第二个 Thread 的资源顺序):
new Thread(() -> {
synchronized (resource1) { // 先获得resource1
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "死锁测试线程2").start();
输出
Thread[死锁测试线程1,5,main]get resource1
Thread[死锁测试线程1,5,main]waiting get resource2
Thread[死锁测试线程1,5,main]get resource2
Thread[死锁测试线程2,5,main]get resource2
Thread[死锁测试线程2,5,main]waiting get resource1
Thread[死锁测试线程2,5,main]get resource1
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。
问题 2:"说说 synchronized 和 java.util.concurrent.locks.Lock 的异同?"
参考答案:
1、相同点:Lock 能完成 synchronized 所实现的所有功能;
2、不同点:Lock 有比 synchronized 更精确的线程语义和更好的性能,而且不强制性的要求一定要获得锁。synchronized 会自动释放锁,而 Lock 则要求手工释放。更具体地来说,有以下差异:
(1)、含义不同
Synchronized 是关键字,属于 JVM 层面,底层是通过 monitorenter 和 monitorexit 完成,依赖于 monitor 对象来完成;
Lock 是 java.util.concurrent.locks.lock 包下的,是 JDK1.5 以后引入的新 API 层面的锁;
(2)、使用方法不同
Synchronized 不需要用户手动释放锁,代码完成之后系统自动让线程释放锁;ReentrantLock 需要用户手动释放锁,没有手动释放可能导致死锁;
(3)、等待是否可以中断
Synchronized 不可中断,除非抛出异常或者正常运行完成;ReentrantLock 可以中断。一种是通过 tryLock (long timeout, TimeUnit unit),另一种是 lockInterruptibly () 放代码块中,调用 interrupt () 方法进行中断;
(4)、加锁是否公平
Synchronized 是非公平锁;ReentrantLock 默认非公平锁,可以在构造方法传入 boolean 值,true 代表公平锁,false 代表非公平锁;
问题 3:AtomicInteger 怎么实现原子操作的?AtomicInteger 有哪些缺点?
参考答案:
AtomicInteger 内部使用 CAS 原子语义来处理加减等操作,CAS 全称 Compare And Swap(比较与交换),通过判断内存某个位置的值是否与预期值相等,如果相等则进行值更新。CAS 是内部是通过 Unsafe 类实现,而 Unsafe 类的方法都是 native 的,在 JNI 里是借助于一个 CPU 指令完成的,属于原子操作。
AtmoicInteger 缺点有:
(1)、循环开销大。如果 CAS 失败,会一直尝试。如果 CAS 长时间不成功,会给 CPU 带来很大的开销;
(2)、只能保证单个共享变量的原子操作,对于多个共享变量,CAS 无法保证;
(3)、存在 ABA 问题
点评:
而第三个缺点中提到的 ABA 问题,其实是一类问题,常见于数据更新的场景(如数据库更新、缓存更新)。就 AtomicInteger 而言,CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。
解决方案:ABA 问题一般通过版本号的机制来解决。每次变量更新的时候,版本号加 1,这样只要变量被某一个线程修改过,该版本号就会发生递增操作。ABA 场景下的变化过程就从 “A-B-A” 变成了 “1A-2B-3A”。
JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet () 中。compareAndSet () 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
锁与并发的内容比较多,特别是锁的概念比较多,比如悲观锁 / 乐观锁、公平锁 / 非公平锁、共享锁 / 排他锁等等,限于篇幅这里没有太展开,大家下来还可以参见扩展阅读部分(特别是扩展面试题目部分)进行补充学习,特别是 ReadWriteLock/ReentrantReadWriteLock 的源码,需要重点关注。
Java 中的锁
Java CAS 原理剖析
聊聊并发(二)——Java SE1.6 中的 Synchronized
深入理解读写锁 —ReadWriteLock 源码分析
Java 多线程(十)之 ReentrantReadWriteLock 深入分析
扩展面试题目_锁与并发面试题
如何将一个线程不安全的类改写为线程安全的类?