关于锁的最常见的十道面试题

发布时间:2024年01月19日

面试题一:什么是乐观锁?乐观锁底层是如何实现的?

乐观锁是一种并发控制的策略。在操作数据的时候,线程读取数据的时候不会进行加锁,先去查询原值,操作的时候比较原来的值,看一下是都被其他线程修改,如果没有修改则写回,否则就重新执行读取流程

悲观锁(底层是synchronized和ReentrantLock)就是考虑事情比较悲观,认为在访问共享资源的时候发生冲突的概率比较高,所以每次访问前线程都需要加锁

乐观锁底层是通过CAS机制实现的,CAS机制包含三个组件:内存地址V、预期值A和新值B

CAS的操作过程如下:

  1. 比较内存地址V中的值是否与预期值A相等
  2. 如果相等,将内存地址V的值改成新值B
  3. 如果不相等,表示预期值A和实际值不匹配,操作失败

但是CAS并不完美,如果数据值一直发生改变,那么CAS会一直自旋,CPU会有巨大开销,而且CAS会有一个经典问题ABA问题

面试题二:什么是ABA问题?如何解决ABA问题?

?我们捋顺一下这张流程图:

  1. 线程一读取了数据A
  2. 线程二读取了数据A
  3. 线程二通过CAS比较,发现数据A是没错的,修改数据A为B
  4. 线程三读取数据B
  5. 线程三通过CAS比较,发现数据B是没错的,修改数据B为A
  6. 线程一通过CAS比较,发现数据A是没错的,修改数据A为B

这个过程中任何线程都没有做错什么,但是值被改变了,线程一却没有办法发现,其实这样得情况出现对结果是没有任何影响的,但是我们要做到规范,所以如何防止ABA问题呢?

加标志位:搞一个自增的字段,操作一次就自增一次

例如:我们需要操作数据库,根据CAS的原则我们本来只需要查询原本值就可以,现在我们一同查出他们的标志位版本字段version

只查询原本值不能防止ABA

update table set value = newValue where value = #{oldValue}

加上标志位version

update table set value = newValue ,version = version + 1 where value = #{oldValue} and vision = #{vision} 
// 判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样

面试题三:ReentrantLock底层是如何实现的?

ReentrantLock是Java中的一个可重入锁,它的底层主要依赖于CAS和AQS(AbstractQueuedSynchronizer)队列

CAS:CAS是一种无锁算法,有三个操作数:内存值V,旧的预期值A,要修改的新值B。只有在A==V的时候,会将V值改成B,否则什么也不做。这个操作是一个原子操作,,被广泛应用于Java的底层实现

AQS队列:AQS是一个用于构建锁和同步容器的框架。AQS使用的是一个和FIFO队列,表示排队等待锁的线程

ReentrantLock的流程如下:

ReentrantLock先通过CAS尝试获取锁,如果此时锁已经被暂用,该线程加入到AQS队列中并wait

当前驱线程的锁被释放,在对手的线程就会被notify,然后继续CAS尝试获取锁

面试题四:手写一个死锁代码?产生死锁的因素有哪些?

死锁指的是两个或者多个线程在执行过程中因为争夺资源而造成的一种相互等待的现象,若无外力干预,他们就将无法推进下去。下面是一段产生死锁的代码:

public class DeadlockDemo {
    private static Object resource1 = new Object(); // 资源 1
    private static Object resource2 = new Object(); // 资源 2

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + " get resource1");
                try {
                    Thread.sleep(1000);
                } 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) {
                System.out.println(Thread.currentThread() + " get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + " waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + " get resource1");
                }
            }
        }, "线程 2").start();
    }
}

运行结果:

在这个例子中,线程一首先获取资源一的锁,然后进入休眠,这是为了让线程二有足够的时间锁住资源二,然后线程一和线程二都会尝试获取对方手中的资源,从而导致死锁

产生死锁的因素包括:

  1. 互斥条件:指一个资源每次只能被一个线程使用,即在一段时间内某资源仅为一个线程所占有。如若有其他线程请求该资源,只能等待
  2. 请求和保持条件:指运算单元已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它运算单元占有,此时请求运算单元阻塞,但又对自己已获得的其它资源保持不放
  3. 不可剥夺条件:指运算单元以获得的资源,在未使用完啊之前,不能被剥夺
  4. 环路等待条件:指在发生死锁时,必然存在一个进程—资源的环形链,即线程程集合{P0,P1,P2,…,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

面试题五:如何排查死锁?如何解决死锁?

排查死锁的工具主要有几种:

使用JDK工具:JDK提供了一些工具,如jps和jstack,可以用来查看程序中的内存使用情况,线程堆栈信息等

使用可视化工具:JDK还提供了一些可视化工具,如jconsole和VisualVM,可以用来查看程序的内存使用情况、线程堆栈信息等

如:在JDK文件中bin文件夹中的jvisualvm.exe

解决死锁的方法主要是有以下几种:

  • 破坏死锁的四个条件之一:死锁的四个必要条件是互斥条件、请求和保持条件、不剥夺条件和环路等待条件。只要破坏四个条件中的一个条件就可以避免死锁的发生
  • 正确的顺序获得锁:如果获取多个锁,我们就要考虑不同线程获取锁的顺序。如果所有线程都按照相同的顺序获取锁,就可以避免死锁
  • 超时放弃:当线程获取锁超时了则放弃,这样就避免了出现死锁获取的情况

面试题六:Java 中乐观锁的实现类有哪些?悲观锁的实现类有哪些?

在Java中,乐观锁和悲观锁的实现主要依赖于特定的类和机制:

乐观锁的实现类:

  • java.util.concurrent.atomic包下的原子类,?如AtomicInteger、AtomicLong、AtomicReference等,它们提供了基于CAS(Compare And Swap)的读写操作和并发环境下的内存可见性
  • ?AtomicStampedReference和AtomicMarkableReference类,它们可以解决CAS操作中的ABA问题

悲观锁的实现类:

  • synchronized:Java中最常见的实现悲观锁的方式就是使用synchronized
  • ReentrantLock:Java中ReentrantLock也是一种悲观锁的实现机制

面试题七:Java 中除了乐观锁和悲观锁外还有哪些锁?

除了乐观锁和悲伤锁之外,其他常用的锁策略还有以下几个:

  1. 公平锁:公平锁是指多个线程按照申请锁的顺序来获取锁
  2. 非公平锁:非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,获取锁是随机的
  3. 独占锁:独占锁是指任何时候都只有一个线程能执行资源操作
  4. 共享锁:共享锁是可以同时被多个线程读取的,但是只能被一个线程修改。比如Java中的eentrantReadWriteLock就是共享锁的实现方式,他只允许一个线程进行写操作,允许多个线程读操作
  5. 可重入锁:可重入锁指的是该线程获取了该锁的时候,可以无限次的进入到锁锁住的代码
  6. 自旋锁:自旋锁指的是尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

Java中所有所有锁默认都是非公平锁,因为非公平锁效率高,但是很小几率非公平锁会产生线程饥饿问题(某个线程竞争了多次,依然没有获取到锁资源)

面试题八:AtomicInteger 是线程安全的吗?它属于哪种锁类型?它存在 ABA 问题吗?如何解决这些问题?

AtomicInteger是线程安全的,它是通过CAS算法和自旋锁来实现线程安全的

AtomicInteger属于乐观锁类型,乐观锁的概念是不加锁去完成某一项操作,如果冲突就重试,直到成功为止

AtomicInteger存在ABA问题,就是线程一获取到 AtomicInteger 的 value 为 A, 在准备做修改之前,线程二对 AtomicInteger 的 value 做了两次操作,一次是将值修改为 B,然后又将值修改为原来的 A. 此时线程一进行 CAS 操作,发现内存中的值依旧是 A, 更新成功

解决AtomicInteger的ABA问题,可以使用AtomicStampedReference,AtomicStampedReference内部维护了一个Pair对象,存储了value值和一个版本号,每次更新除了value还会更新版本号,这样就可以避免ABA问题

import java.util.concurrent.atomic.AtomicStampedReference;

public class AtomicStampedReferenceExample {
    public static void main(String[] args) {
        //创建一个初始值为100,初始版本号为0的AtomicStampedReference对象
        AtomicStampedReference<Integer> atomicStampedRef = new AtomicStampedReference<>(100, 0);
        //获取当前值和版本号
        int currentValue = atomicStampedRef.getReference();
        int currentStamp = atomicStampedRef.getStamp();
        System.out.println("当前版本号:" + currentStamp);
        System.out.println("当前值:" + currentValue);

        //尝试将值从100更新为200,版本号加1
        int newStamp = currentStamp + 1;
        boolean success = atomicStampedRef.compareAndSet(currentValue, 200, currentStamp, newStamp);
        System.out.println("更新结果: " + success);

        //获取更新后的值和版本号
        int updatedValue = atomicStampedRef.getReference();
        int updatedStamp = atomicStampedRef.getStamp();
        System.out.println("更新后的值: " + updatedValue + ",更新后的版本号:" + updatedStamp);
    }
}

面试题九:什么是Semaphore?它有什么用?它的底层是如何实现的?

Semaphore也被称为信号量,是一种同步工具,可以用来先致并发访问的数量,或者作为一种许可机制来管理资源的使用

Semaphore就好比停车场的保安,可以控制车位的使用资源。比如来了五辆车,只有两个车位,门卫可以先放两辆车进去,等有车为空出来,再让后面的车进入,实现代码如下:

import java.time.LocalTime;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.LinkedBlockingQueue;

public class SemaphoreDemo {
    public static void main(String[] args) {
        //同时只允许两个线程访问
        Semaphore semaphore = new Semaphore(2);
        ThreadPoolExecutor semaphoreThread = new ThreadPoolExecutor(10, 50, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
        for (int i = 0; i < 5; i++) {
            semaphoreThread.execute(() -> {
                try {
                    //堵塞获取许可
                    semaphore.acquire();
                    System.out.println("Thread:" + Thread.currentThread().getName() + " " + LocalTime.now());
                    TimeUnit.SECONDS.sleep(2);
                    // 释放许可
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        semaphoreThread.shutdown();
    }
}

运行结果:

Semaphore是基于AQS(AbstractQueueSynchronized)来实现的,它使用一个同步队列来管理等待许可的线程。Semaphore内部维护了一个计算器,记录着可用的许可数量。每次线程调用acquire()方法的时候,Semaphore会将计数器减一,如果计数器小于等于零,则线程会被加入到同步队列中阻塞等待。当线程调用release()方法释放许可时,Semaphore会将计数器加一,并唤醒一个等待的线程

面试题十:CountDownLatch 和 CyclicBarrier 有什么区别?

CountDownLatch和CyclicBarrier都是Java中并发编程的同步辅助类,他们都用于多线程之间协调,但是它们的使用场景和工作方式有所不同:

  • CountDownLatch:CountDownLatch有一个重要的用途是允许一个或者多个线程等待其他线程完成操作。它的计数器只可以使用一次,即它的计数器在初始化后就不能被再修改,只能被减少。当计数器减少到零的时候,所有等待线程都会被唤醒
  • CyclicBarrier:CyclicBarrier的一个重要用途就是使一组线程在所有线程都到达某个点(这个点被称为屏障点)的时候才继续执行。与CountDownLatch不同,CyclicBarrier的计数器可以被重置,因此它可以被多次使用。当计数值增加到初始化值的时候,所有线程都会被唤醒

为了让大家更能理解它们的区别,我用简洁的语言复述以下:

  • CountDownLatch:它是一个同步工具类,用于使一个线程等待其他线程完成各自的工作。例如,假设我们有一个任务A,它要等待其他4个任务完成后才能开始执行,那么我们就可以使用CountDownLatch来实现这种需求。
import java.util.concurrent.CountDownLatch;

public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(4);
        for (int i = 1; i <= 4; i++) {
            final int taskNum = i;
            new Thread(() -> {
                System.out.println("Task " + taskNum + " finished");
                latch.countDown();
            }).start();
        }
        latch.await();
        System.out.println("All tasks finished, start task A");
    }
}
  • CyclicBarrier:它是一个同步工具类,它允许一组线程互相等待,直到所有线程都到达某个屏障点(barrier point)。例如,假设我们有一个任务,需要等待其他4个任务全部到达一个点后才能开始执行,那么我们就可以使用CyclicBarrier来实现这种需求。
import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo {
    public static void main(String[] args) {
        CyclicBarrier barrier = new CyclicBarrier(4, () -> System.out.println("All tasks arrived at the barrier, start task A"));
        for (int i = 1; i <= 4; i++) {
            final int taskNum = i;
            new Thread(() -> {
                try {
                    System.out.println("Task " + taskNum + " arrived at the barrier");
                    barrier.await();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

总的来说,CountDownLatch是一次性的,CyclicBarrier是可以重复使用的。CountDownLatch的计数器只能做减法,CyclicBarrier的计数器可以重置为初始值。

文章来源:https://blog.csdn.net/loss_rose777/article/details/135698142
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。