在Android开发的面试中,Java多线程的问题是绕不开的。这个系列主要介绍面试过程中涉及到的多线程的知识点,以及相关的面试题。这是本系列的第四篇,介绍Java中多线程的锁,及其对应的常见的面试题。
什么是锁
线程的锁是一种同步机制,用于保证多个线程安全地访问共享资源。锁有很多种,像乐观锁、公平锁、读写锁等等,我们刚开始学锁的时候可能会一脸懵逼。但是对于锁的实现思路,锁就只有乐观锁和悲观锁两种;而是否公平、是否可重入、是否可中断只是锁的特性。就像猫只有公母两种,但也有比如胖瘦美丑等特征。
乐观锁和悲观锁
乐观锁和悲观锁是指对线程操作共享资源时不同的心态。乐观锁是假设共享资源被访问时不会出问题,只有修改资源时会验证检查。而悲观锁则是假设共享资源被访问时一定会出问题,因此只会让共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。简单的说,乐观锁是把所有线程的线程看成"好人",而悲观锁是把所有线程看成"坏人"。
在java中,synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现;而原子变量类(比如AtomicInteger
、LongAdder
)则是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁
CAS
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS的原理也很简单,就是要写入的新值与预期值进行比较,如果不同,则失败重试,如果相同,则进行更新。在java中,就只有通过CAS实现的原子类是乐观锁,其他的都是悲观锁 。图片来源:一、CAS 详解 - 《Java 并发编程教程》 - 极客文档 (geekdaxue.co)
注意:CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。
面试题:什么时候用乐观锁(或者说CAS)比较好
乐观锁适用于读多写少的场景 。高并发的场景下,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。而乐观锁不存在锁竞争造成线程阻塞,也不会有死锁的问题。如果读多写少,乐观锁性能会更好。如果读少写多,则会频繁失败和重试,这样同样会影响性能,导致 CPU 飙升。
面试题:CAS的缺点有哪些
- ABA 问题。当变量值由 A 变为 B 再变为 A时,CAS 是不可感知的,但实际上变量已经发生了变化;解决办法是在每次获取时加版本号,并且每次更新对版本号 +1
- 循环时间长开销大。CAS多次失败会多次重试,造成大量的时间消耗和性能浪费
- 只能保证一个共享变量的原子操作。CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。1.5以后,但是可以通过 AtomicReference 来间接对多个变量进行原子操作
悲观锁
悲观锁还分为两种,一种是共享锁,一种是互斥锁。
共享锁
共享锁是指可以让多个线程一起读取共享变量,但是对写操作阻塞,即当线程读操作时,允许其他线程读操作,但是不能写操作。
在java中,共享锁只有 ReentrantReadWriteLock (读写锁)中的读锁。
互斥锁
大部分的锁都是互斥锁,像 synchronized、ReentrantLock 等等。其中 synchronized 比较特殊,在1.5以后为了提升 synchronized 的性能,它有了一段锁的升级过程:偏向锁 ---> 轻量级锁 ---> 重量级锁
当线程A初次执行到 synchronized 代码块的时候,锁对象变成偏向锁 ,这时会通过CAS修改对象头里的锁标志位,同时持有锁的线程 ID 也保存到对象头里。需要注意,当线程A执行完同步代码块后,它并不会主动释放偏向锁。当线程A第二次执行synchronized代码块时,如果线程 ID 相同,由于之前没有释放锁,就不需要重新加锁。之所以不主动释放锁,是因为加锁解锁都非常耗时。
当线程B加入竞争锁时,这时偏向锁就升级为轻量级锁(自旋锁) 。如果线程B没有抢到锁的线程将会自旋,即不停地循环判断锁是否能够被成功获取。判断方式是通过对象头的标志位,如果释放了锁,线程B就会通过 CAS 修改对象头里的锁标志位的方式来抢占锁。
当循环次数太多时(默认允许循环10次),该线程会将轻量级锁升级为重量级锁。线程获取该重量级锁时,会阻塞当前线程,而不是循环判断是否能获取锁
注意:synchronized 的锁只能按照 偏向锁、轻量级锁、重量级锁的顺序逐渐升级,不允许降级。
锁的特性
是否可重入
当一个线程得到一个对象后,再次请求该对象锁时是可以再次得到该对象的锁的。 具体概念就是:自己可以再次获取自己的内部锁。 Java里面内置锁(synchronized)和Lock(ReentrantLock)都是可重入的。
csharp
public class SynchronizedTest {
public void method1() {
synchronized (SynchronizedTest.class) {
System.out.println("方法1获得ReentrantTest的锁运行了");
method2();
}
}
public void method2() {
synchronized (SynchronizedTest.class) {
System.out.println("方法1里面调用的方法2重入锁,也正常运行了");
}
}
public static void main(String[] args) {
new SynchronizedTest().method1();
}
}
上面便是 synchronized 的重入锁特性,即调用method1()方法时,已经获得了锁,此时内部调用method2()方法时,由于本身已经具有该锁,所以可以再次获取。
是否公平
如果多个线程申请一把公平锁 ,那么当锁释放的时候,先申请的先得到,非常公平。显然如果是非公平锁,后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的。
ReentrantLock可以是一种公平锁,也可以是非公平锁。我们可以通过在构造方法中传入 true 设置为公平锁,传入false 设置为非公平锁。synchronized 是非公平锁。以下是使用公平锁实现的效果:
csharp
public class LockFairTest implements Runnable{
//创建公平锁
private static ReentrantLock lock=new ReentrantLock(true);
public void run() {
while(true){
lock.lock();
try{
System.out.println(Thread.currentThread().getName()+"获得锁");
}finally{
lock.unlock();
}
}
}
public static void main(String[] args) {
LockFairTest lft=new LockFairTest();
Thread th1=new Thread(lft);
Thread th2=new Thread(lft);
th1.start();
th2.start();
}
}
下面是截取的部分执行结果,分析结果可看出两个线程是交替执行的,几乎不会出现同一个线程连续执行多次。
输出结果:
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
Thread-1获得锁
Thread-0获得锁
是否可中断
ReentrantLock 的 lockInterruptibly 方法可以获取中断等待的锁。而 synchronized 关键字的锁是不能中断的。
锁造成的问题
死锁
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。示例如下:
java
public void methodA() {
synchronized(lockA) { // 获得lockA的锁
synchronized(lockB) { // 获得lockB的锁
} // 释放lockB的锁
} // 释放lockA的锁
}
public void methodB() {
synchronized(lockB) { // 获得lockB的锁
synchronized(lockA) { // 获得lockA的锁
} // 释放lockA的锁
} // 释放lockB的锁
}
死锁出现的条件:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资
源 X; - 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是
循环等待。
如何解决死锁:
只要破坏上面四个条件之一就可以了,详情可以看什么是死锁?死锁如何解决?-CSDN博客
注意:Lock在异常情况下,不会主动释放锁。因此需要在 finally 主动调用 unLock,如果没有执行unLock,则会发生死锁
活锁
活锁指的是,两个线程都是处于活跃状态(Runnable),但是两个线程分别相互谦让任务,导致程序无法继续向前运行。就像两个人一直互相让路,最后都无法通过。
解决活锁的方案很简单,就是谦让时尝试等待一个随机的时间就可以了
参考
- 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其Java实现! - 知乎 (zhihu.com)
- 乐观锁和悲观锁详解 | JavaGuide
- 【建议收藏】106道Android核心面试题及答案汇总(总结最全面的面试题)
- 面试官问我什么是JMM - 知乎 (zhihu.com)
- Java 并发编程实战 (geekbang.org)
- final保证可见性和this引用逃逸 - 知乎 (zhihu.com)
- Synchronized的底层实现原理(原理解析,面试必备)_synchronized底层实现原理-CSDN博客
- 线程间到底共享了哪些进程资源 - 知乎 (zhihu.com)
- stackoverflow.com/questions/1...
- spotcodereviews.com/articles/co...
- Linux内核同步机制之(三):memory barrier (wowotech.net)
- 万字长文!一文彻底搞懂Java多线程 - 掘金 (juejin.cn)
- 线程同步问题的产生及解决方案