java多线程面试系列——线程的锁

在Android开发的面试中,Java多线程的问题是绕不开的。这个系列主要介绍面试过程中涉及到的多线程的知识点,以及相关的面试题。这是本系列的第四篇,介绍Java中多线程的锁,及其对应的常见的面试题。

什么是锁

线程的锁是一种同步机制,用于保证多个线程安全地访问共享资源。锁有很多种,像乐观锁、公平锁、读写锁等等,我们刚开始学锁的时候可能会一脸懵逼。但是对于锁的实现思路,锁就只有乐观锁和悲观锁两种;而是否公平、是否可重入、是否可中断只是锁的特性。就像猫只有公母两种,但也有比如胖瘦美丑等特征。

乐观锁和悲观锁

乐观锁和悲观锁是指对线程操作共享资源时不同的心态。乐观锁是假设共享资源被访问时不会出问题,只有修改资源时会验证检查。而悲观锁则是假设共享资源被访问时一定会出问题,因此只会让共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。简单的说,乐观锁是把所有线程的线程看成"好人",而悲观锁是把所有线程看成"坏人"。

在java中,synchronizedReentrantLock等独占锁就是悲观锁思想的实现;而原子变量类(比如AtomicIntegerLongAdder)则是使用了乐观锁的一种实现方式 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的缺点有哪些

  1. ABA 问题。当变量值由 A 变为 B 再变为 A时,CAS 是不可感知的,但实际上变量已经发生了变化;解决办法是在每次获取时加版本号,并且每次更新对版本号 +1
  2. 循环时间长开销大。CAS多次失败会多次重试,造成大量的时间消耗和性能浪费
  3. 只能保证一个共享变量的原子操作。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的锁
}

死锁出现的条件:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资
    源 X;
  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是
    循环等待。

如何解决死锁:

只要破坏上面四个条件之一就可以了,详情可以看什么是死锁?死锁如何解决?-CSDN博客

注意:Lock在异常情况下,不会主动释放锁。因此需要在 finally 主动调用 unLock,如果没有执行unLock,则会发生死锁

活锁

活锁指的是,两个线程都是处于活跃状态(Runnable),但是两个线程分别相互谦让任务,导致程序无法继续向前运行。就像两个人一直互相让路,最后都无法通过。

解决活锁的方案很简单,就是谦让时尝试等待一个随机的时间就可以了

参考

相关推荐
2401_8574396913 分钟前
SpringBoot框架在资产管理中的应用
java·spring boot·后端
怀旧66615 分钟前
spring boot 项目配置https服务
java·spring boot·后端·学习·个人开发·1024程序员节
李老头探索17 分钟前
Java面试之Java中实现多线程有几种方法
java·开发语言·面试
weixin_4493108421 分钟前
高效集成:聚水潭采购数据同步到MySQL
android·数据库·mysql
芒果披萨22 分钟前
Filter和Listener
java·filter
qq_49244844627 分钟前
Java实现App自动化(Appium Demo)
java
阿华的代码王国35 分钟前
【SpringMVC】——Cookie和Session机制
java·后端·spring·cookie·session·会话
Zender Han36 分钟前
Flutter自定义矩形进度条实现详解
android·flutter·ios
找了一圈尾巴1 小时前
前后端交互通用排序策略
java·交互
白乐天_n3 小时前
adb:Android调试桥
android·adb