Java并发编程快速面试笔记(速成版)


中文 | English

1. 并发基础知识

什么是并发:多个线程交替使用CPU

为什么要使用并发:为了充分利用CPU,程序不需要使用CPU时(例如:等待IO操作完成),就暂时释放,让给其他线程使用,从而使CPU一致处于忙碌状态,提高CPU利用率。

使用多个线程处理会带来哪些并发问题?:

  • 线程安全问题:若多个线程共享同一变量,那么一个线程对变量的写入可能会被另一个线程给覆盖掉。
  • 死锁问题:若多个线程互相竞争资源,那么就可能产生两个线程互相等待对方持有的资源,导致两个线程都无限等待下去。

2. 线程

2.1 线程的基础知识

线程(Thread)是操作系统能够进行CPU调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

一个进程至少要有一个主线程,也可以创建多个子线程,多个线程可以并发运行(交替运行),也可以并行运行(同时被CPU调度)。

多个线程有独立的内存空间,但也共享其进程的堆空间。因此,多个线程操作同一个进程变量时,会产生并发问题(即一个线程的写入覆盖了另一个线程的写入)。

线程具有以下五种状态:


在这里插入图片描述

  • 新建(New) 状态:创建线程,操作系统为线程分配资源。执行thread.start()方法会创建线程
  • 就绪(Runnable/Ready) 状态:线程等待被CPU调度
  • 运行(Running) 状态:线程正在CPU上执行
  • 阻塞(Blocked/Waiting) 状态:等待特定事件完成。例如:等待IO操作完成、等待锁资源、等待睡眠完成等
  • 中止(Terminated/Dead) 状态:线程运行结束,或异常退出,操作系统释放线程资源。

线程状态之间的转换,包括如下:

  • 新建 -> 就绪:线程创建完成后,就会进入就绪队列,等待CPU调度。
  • 就绪 -> 运行:处于就绪状态的线程会排队,等待CPU调度,当轮到它的时候,就会被CPU调度,开始运行。
  • 运行 -> 就绪:当线程的CPU时间片使用完后,就会重新变为就绪状态,开始排队等待CPU调度。此外,若在代码中调用了Thread.yield()方法,也会将运行态转为就绪态。
  • 运行 -> 阻塞:当代码中遇到特定的需要等待的事件时,线程就会由运行态转为阻塞态,直到事件完成。常见的事件有:① Thread.sleep(...):线程睡眠一段时间;② thread.wait():等待所需资源;③ synchronized(obj) / lock.lock():等待获取锁;④ 其他事件:等待IO完成(读取文件等)、等待网络内容发送(accept())、等待其他线程完成(thread.join()futhureTask.get()
  • 阻塞 -> 就绪:当线程等待的事件完成后,线程就会由阻塞状态转为就绪状态,等待CPU调度。
  • 运行 -> 中止:当线程运行结束后,就会进入中止态,释放其资源。常见的中止方式:① 正常运行结束。② 运行报错,且没有被捕获;③ thread.stop()方法被调用。

2.2 Java的Thread类

Java中所谓几种创建线程的方法,最终本质都是创建了一个Thread类,然后运行start方法。

2.2.1 Thread的初始化

初始化Thread类时,我们最多个可以传入四个参数Thread(ThreadGroup group, Runnable target, String name, long stackSize)

  • ThreadGroup group:指定线程所属的线程组。线程组用于将一组线程组织在一起,方便管理和控制。如果不指定线程组,Java 会将线程放入当前线程的线程组中。
  • Runnable target:指定线程要执行的任务。Runnable 接口是一个函数式接口,通常通过实现 run 方法来定义线程要执行的操作。
  • String name:指定线程的名称。线程名称是一个字符串,可以用来标识线程。如果不指定名称,Java 会自动生成一个默认名称,例如 "Thread-0"、"Thread-1" 等。
  • long stackSize:指定线程的栈大小。线程的栈用于存储方法调用和局部变量等信息,栈大小影响着线程的内存消耗和性能。如果不指定栈大小,Java 会使用默认值。

2.2.2 Thread的常用方法

Thread类的常用方法如下:

  • start():用于启动线程,使其进入就绪状态并开始执行 run() 方法中的代码。
  • run():该方法定义了线程的执行逻辑,包含线程要执行的代码。在多线程环境下,当线程处于就绪状态并被调度执行时,会自动调用 run() 方法
  • Thread.sleep(long millis):使当前线程进入睡眠状态指定的毫秒数。等待期间,线程处于阻塞状态。睡眠时间结束后,会进入就绪状态,等待CPU调度。
  • isAlive():检查线程是否处于活跃状态。若线程还没start或已经运行完run方法(抛异常终止也算),则为false。若线程已经开始运行run方法,只要还没运行完,不管在CPU中处于就绪、运行或阻塞,都为true
  • Thread.currentThread():静态方法,用于获取当前正在执行的线程对象
  • Thread.yield():让出当前CPU,让线程处于就绪状态。sleep会让线程先进入阻塞状态,睡眠结束后进入就绪状态。而yield是直接从运行态转为就绪态。注意:使用该方法要谨慎,因为CPU频繁的进行线程切换非常耗资源。
  • setPriority(int priority) 和 getPriority():为线程设置优先级,范围为 1 到 10,越大优先级越高。注意:线程优先级的设置主要影响到 JVM 的线程调度器,而不一定直接影响到操作系统的线程调度器。
  • join():用于等待线程终止。调用该方法的线程将被阻塞,直到被调用的线程执行完成。可以使用重载的 join(long millis) 方法指定最长等待时间,超过指定时间后将不再等待。例如:如果某段代码必须要在A线程执行之后才能执行,那么就在这段代码前调用A.join()

2.2.3 线程的中断(interrupt)

Thread类还为用户提供了两个重要的方法:

  • interrupt():请求中断线程。
  • isInterrupted():判断线程是否处于中断状态。

这个具体的作用是:如果一个threadA想让threadB中止(例如A想让B中止以放弃其持有的资源),那么threadA就可以调用threadB.interrupt()来请求让B中止,调用后,threadB.isInterrupted()就会变为true。此时,threadB中需要去监听自己的isInterrupted()状态,如果为true,就优雅结束。若threadB.run方法中并没有做监听,那就什么都不做,threadB也不会结束。

因此,线程中断就是:一个线程请求另一个线程中止,但另一个线程是否要中止是由该线程自己决定的

为什么不用stop进行中止?因为:stop过于粗暴,直接将线程杀死,该线程可能还要做一些收尾工作,例如释放资源等。因此,不推荐使用stop来中止线程

为什么Thread.sleep一定要处理InterruptedException异常?因为:当某个线程被调用了interrupt 方法后,就是在通知它赶快结束。如果这个时候这个线程还在sleep,那就需要把它叫醒,叫醒它的方式就是JVM给它的sleep方法抛个异常。如果sleep不捕获InterruptedException,那不就叫不醒了么,所以必须处理。

interrupt举例1(线程任务不断循环执行):

```java
public class ThreadInterruptExample {

    public static void main(String[] args) {
        WorkerThread worker = new WorkerThread();
        worker.start(); // 启动后台工作线程
        // ...
        worker.interrupt(); // 发送中断请求
    }

    static class WorkerThread extends Thread {
        @Override
        public void run() {
            while (!isInterrupted()) { // 检查中断状态
                System.out.println("Working...");
            }

            // 在这里可以做一些清理工作,比如释放资源等
            System.out.println("Worker thread exiting.");
        }
    }
}
```

interrupt举例2(主任务无循环,顺序执行):

```java
static class WorkerThread extends Thread {
    @Override
    public void run() {
        try {
            doSomeWork(); // 执行工作任务
        } catch (InterruptedException e) {
            // 捕获 InterruptedException 异常,中断时会执行下面的代码
            System.out.println("Interrupted. Cleaning up...");
        }

        // 在这里可以做一些清理工作,比如释放资源等
        System.out.println("Worker thread exiting.");
    }

    private void doSomeWork() throws InterruptedException {
        // ... 执行一些任务,
        if (Thread.currentThread().isInterrupted()) {
            // 在可以中断的地方,检测是否被中断,若中断,则抛出中断异常
            throw new InterruptedException();
        }
        // ...如果上面没异常,继续执行一些其他任务
        if (Thread.currentThread().isInterrupted()) {
            // 在另一个可以中断的地方继续检测
            throw new InterruptedException();
        }
        // ...
    }
}
```

2.3 创建线程的几种方式

2.3.1 继承Thread类

直接集成Thread类,然后调用其start方法启动。

不推荐使用该方式,原因如下:

  • 不符合面向对象设计:线程任务与线程类没有区分开,耦合在了一起。
  • 单继承限制:继承Thread后就不能再继承其他类了。但你继承Thread类又不是为了扩展它,仅仅是为了重写run方法。
  • 不方便使用线程池:如果你的该线程类后续要使用线程池管理,集成Thread的方式就不方便

若你就是要修改或扩展Thread类,那么应当选择继承的方式

样例:

```java
class MyThread extends Thread {
    public void run() {
        // 线程任务
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start();
    }
}
```

2.3.2 实现Runnable方法

实现Runnable方法,然后传给Thread类即可。

样例1:

```java
class MyRunnable implements Runnable {
    public void run() {
        // 线程任务
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}
```

样例2(使用匿名内部类实现Runnable):

```java
Thread thread1 = new Thread(new Runnable() {
    public void run() {
        // 线程任务
    }
});
thread1.start();
```

2.3.3 FutureTask类+Callable接口

run方法没有返回值。若我们想让线程有返回值,则可以使用“FutureTask类+Callbale接口”的方式。适用场景:先开启一个线程来执行任务,过一会获取它的执行结果

实现上述方式需要三个类/接口:

  • Callable接口:类似Runnable接口,用于定义要执行的任务,不过可以设置返回值。
  • Future/FutureTask:注册Callable接口,用于获取执行结果、判断线程是否执行完等。(Future是一个接口,而FutureTask是Future接口的实现
  • Thread/ThreadPoolExecutor:线程或线程池执行类,将Future提交的任务开启线程来执行。

样例1:

```java
public static void main(String[] args) {
    // 创建一个 Callable
    Callable<Integer> callableTask = new Callable<Integer>() {
        @Override
        public Integer call() {
            return 10; // 返回计算结果
        }
    };

    // 提交 Callable 到线程池,获取 Future 对象
    FutureTask<Integer> futureTask = new FutureTask<>(callableTask);

    //通常使用线程池来执行Future任务
    ExecutorService executor = Executors.newFixedThreadPool(1);
    executor.execute(futureTask);

    // 使用线程也可以
//        Thread thread = new Thread(futureTask);
//        thread.start();

    // 等待计算完成,并获取结果
    try {
        // 若线程还没执行完,则这里会阻塞,等待线程执行结束
        Integer result = futureTask.get();
        System.out.println(result);
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    }
}
```

3. 线程池(ThreadPoolExecutor)

3.1 ThreadPoolExecutor简介

Java服务端开发中,通常不直接new Thread(),而是通过线程池来管理线程,避免频繁创建线程而导致资源耗尽宕机的情况。

ThreadPoolExecutor是Java中用于管理线程池的一个类,它提供了一种灵活的方式来管理线程的生命周期和执行策略。通过ThreadPoolExecutor,你可以控制线程池的大小、任务的执行方式以及拒绝策略等。

除ThreadPoolExecutor外,也有一些其他的线程池类,如:

3.2 ThreadPoolExecutor的初始化

ThreadPoolExecutor包含如下7个初始化参数:

  1. int corePoolSize核心线程数。即线程池中保持活动状态的线程数。即使这些线程是空闲的。
  2. int maximumPoolSize最大线程数,包括核心线程和非核心线程。当工作队列已满且核心线程都在工作时,线程池会创建新的线程,直到达到最大线程数。
  3. long keepAliveTime空闲等待时间。当线程池中的线程数量超过核心线程数时,多余的空闲线程在被终止之前等待新任务的最长时间。超过这个时间,空闲线程会被终止。
  4. TimeUnit unit:keepAliveTime参数的时间单位,可以是秒、毫秒、微秒等
  5. BlockingQueue workQueue阻塞队列。用于保存等待执行的任务的阻塞队列。BlockingQueue是一个接口,可以选择不同的实现方式。
  6. ThreadFactory threadFactory:用于创建新线程的工厂接口,里面包含了一个接口方法Thread newThread(Runnable r);。可以通过自定义ThreadFactory来设置新线程的名称、优先级等属性。一般使用默认的DefaultThreadFactory
  7. RejectedExecutionHandler handler拒绝策略。当线程池无法接受新任务时,就会调用该接口的rejectedExecution(...)方法,来对其进行处理。默认为AbortPolicy策略,即丢弃该任务。

3.3 线程提交线程池的处理流程

当一个线程到来时,会经历如下流程:

  1. 如果线程池中的核心线程数还没有达到设定的corePoolSize,则新到来的任务会被立即分配一个核心线程来处理,而不会进入阻塞队列。
  2. 如果线程池中的核心线程已经饱和(即核心线程数已达到corePoolSize),而且阻塞队列未满,新到来的任务会被放入阻塞队列中等待执行。
  3. 如果线程池中的核心线程已经饱和,且阻塞队列也已满,但是线程池的最大线程数(maximumPoolSize)尚未达到,线程池会创建额外的非核心线程来处理任务。
  4. 如果线程池的最大线程数也已经达到,并且阻塞队列也已满,根据线程池的拒绝策略,可能会执行拒绝策略来处理新到来的任务。默认的拒绝策略是抛出RejectedExecutionException异常,但也可以通过设置自定义的拒绝策略来处理这种情况。

使用以下代码可以验证:

```java
// 创建一个核心线程数为2,最大线程数为4,阻塞队列容量为6的线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 0, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(6));

// 向线程池提交12个任务
for (int i = 1; i <= 12; i++) {
    Thread.sleep(100);

    try {
        executor.submit(() -> {
            while (true) {}
        });
    } catch (RejectedExecutionException e) {
        // 默认的AbortPolicy会抛出RejectedExecutionException异常
        System.out.print("队列已满,拒绝;  ");
    }

    System.out.print(i + ": ");
    System.out.print("队列大小:" + executor.getQueue().size() + ";  ");
    System.out.print("执行中线程数:" + executor.getActiveCount() + ";  ");
    System.out.println();
}

// 关闭线程池
executor.shutdown();
```

输出:

```
1: 队列大小:0;  执行中线程数:1;  
2: 队列大小:0;  执行中线程数:2;    // 1-2任务:核心线程数为2,前两个线程不进入阻塞队列
3: 队列大小:1;  执行中线程数:2;  
4: 队列大小:2;  执行中线程数:2;  
5: 队列大小:3;  执行中线程数:2;  
6: 队列大小:4;  执行中线程数:2;  
7: 队列大小:5;  执行中线程数:2;  
8: 队列大小:6;  执行中线程数:2;   // 3-8任务:由于核心线程数在忙,因此后续任务进入阻塞队里。(虽然最大线程数为4,但阻塞队里未满)
9: 队列大小:6;  执行中线程数:3;  
10: 队列大小:6;  执行中线程数:4;   // 9-10任务:阻塞队里已满,而线程数未达到最大线程数,因此创建新的线程处理。新提交的任务进入阻塞队列。
队列已满,拒绝;  11: 队列大小:6;  执行中线程数:4;   
队列已满,拒绝;  12: 队列大小:6;  执行中线程数:4;  // 11-12任务:阻塞队列和最大线程数都满了,拒绝提交。
```

3.4 常见的阻塞队列与拒绝策略

Java为开发者提供了如下阻塞队列(这些阻塞队列都实现了java.util.concurrent.BlockingQueue接口):

  • ArrayBlockingQueue基于数组实现的阻塞队列。它的容量在创建时必须显式指定
  • LinkedBlockingQueue基于链表实现的阻塞队列。默认初始容量大小为Integer.MAX_VALUE。但如果指定了容量大小,那么中途就不能再修改了。
  • PriorityBlockingQueue基于堆实现的优先阻塞队列(容量与LinkedBlockingQueue一样)。用户的Runnable类需要多实现一个Comparable接口,用于比较优先级。例如:class MyTask implements Runnable, Comparable<Integer>
  • DelayQueue延迟队列。可以让任务延时一段时间后再执行。
  • SynchronousQueue同步队列。只能包含一个元素。与其他阻塞队列不同,它并不存储元素,而是在生产者线程试图将元素放入队列时,必须等待消费者线程来获取元素;反之亦然,在消费者线程试图获取元素时,必须等待生产者线程放入元素。当在ThreadPoolExecutor中使用它的话,那么来个任务要么创建线程,要么被拒绝。

当队列已满,且活跃线程数达到最大线程数时,再进来的任务就被拒绝。开发者可以通过实现RejectedExecutionHandler的方式自定义拒绝策略。Java中提供的常见决策略有:

  • AbortPolicy(默认策略):直接抛出RejectedExecutionException异常
  • CallerRunsPolicy:当线程池无法接受新任务时,让提交任务的线程(调用execute方法的线程)自己去执行该任务。这个策略并不丢弃任务,而是由调用线程来执行被拒绝的任务。
  • DiscardPolicy:当线程池无法接受新任务时,直接丢弃无法处理的任务,不做任何处理(不抛异常,就是单纯的丢弃)。
  • DiscardOldestPolicy:当线程池无法接受新任务时,丢弃队列中等待时间最久的任务,然后尝试重新提交当前任务

3.5 Executors类创建线程池

除了用户直接newThreadPoolExecutor类,用户也可以使用java.util.concurrent.Executors类来创建和管理线程池。

Executors提供了如下创建线程池的常用方法:

  • Executors.newFixedThreadPool(int nThreads):创建一个固定大小的线程池。
  • Executors.newCachedThreadPool():创建一个无脑new线程的线程池。来一个任务只要没有空闲线程,那么就会new一个新的线程。
  • Executors.newSingleThreadExecutor():创建一个只能容纳一个线程的线程池。其他任务全都排队
  • Executors.newScheduledThreadPool(int corePoolSize):创建一个可以定时执行任务的线程池。
  • Executors.newWorkStealingPool(int parallelism):创建一个工作窃取线程池。它会创建一个并行的线程池,线程数量会根据处理器的核心数动态调整,尽可能让所有的CPU都忙碌起来。(不常用,要是都忙起来,那处理用户响应的速度不就慢了)

不推荐使用Executors创建线程池,原因看看各个newXXXPool的源码就知道了:

newFixedThreadPool:

```java
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  // 没有配置队列大小,因此不会出发拒绝策略,有任务会一直入队。可能导致队列长度过大,最终OOM
                                  new LinkedBlockingQueue<Runnable>());  
}
```

newCachedThreadPool:

```java
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,  // 没有核心线程数,最大线程数为无穷。
                              60L, TimeUnit.SECONDS,
                              // 该队列只要任务来,没有空闲线程的话,就会new新线程。因此可能会不断new线程,导致OOM
                              new SynchronousQueue<Runnable>());  
```

其他的与上述两个同理。

4. 锁(Lock)

锁(Lock)是一种同步机制,用于控制多个线程对共享资源的访问。

同步:两个任务必须先后执行。异步:两个任务并行执行。

4.1 常见锁的类型

在编程世界中,有各种各样的锁,不同类型的锁有不同的原理和适用场景。

从不同角度看,锁有不同的类别。

根据作用范围分类,可分为:

  • 全局锁(Global Lock):对整个应用程序或进程中的所有资源起作用。
  • 局部锁(Local Lock):只对特定的资源或代码块起作用。

根据粒度分类,可分为:

  • 粗粒度锁(Coarse-grained Lock):锁住整个数据结构或资源,通常导致并发性能较低。
  • 细粒度锁(Fine-grained Lock):将数据结构或资源分成较小的部分,并对每个部分分别加锁,提高并发性能。

根据控制方式分类,可分为:

  • 悲观锁(Pessimistic Lock):在访问资源之前,先获取锁来排除其他线程对资源的访问,适用于并发写入较多的情况。
  • 乐观锁(Optimistic Lock):乐观的认为在修改数据时不会有其他线程同时修改,只在提交时检查是否有其他线程修改了数据,适用于并发读取较多的情况。

根据获取锁顺序的方式,可分为:

  • 公平锁(Fair Lock):线程按照请求锁的顺序来获取锁。如果有多个线程等待锁,锁会按照先来先服务的原则依次分配给它们。公平锁能够确保所有等待线程都能有机会获取到锁,避免了某些线程长期处于饥饿状态的问题。
  • 非公平锁(Unfair Lock):没有任何排队机制,线程获取锁的顺序是随机的,完全取决于线程调度器的选择。相比于公平锁,它能够更快地执行,因为它允许新来的线程立即尝试获取锁,而不用等待之前的线程。

根据实现方式分类,可分为:

  • 互斥锁(Mutex Lock):最常见的一种锁,同一时刻只允许一个线程访问被保护资源。
  • 读写锁(Read-Write Lock):允许多个线程同时读取共享资源,但在写入时需要独占访问。通常,读锁称为共享锁,写锁称为排他锁
  • 自旋锁(Spin Lock):在获取锁时,线程不会被阻塞,而是会一直尝试获取锁直到成功或达到最大尝试次数。
  • 递归锁(Recursive Lock):允许同一个线程多次获取同一把锁,每次获取都需要相应的释放。也称为可重入锁(Reentrant Lock)

4.2 Java中实现锁机制的不同方式

4.2.1 Sychronized关键字

synchronized 是 Java 中最基本的锁机制,它通过在方法或代码块前添加关键字 synchronized 来实现同步

当一个线程进入了一个被 synchronized 修饰的方法或代码块时,它会尝试获取对象的锁,如果获取成功,则其他线程需要等待直到该线程执行完毕释放锁为止。

Sychronized锁的应用场景:

  • 多线程环境下对共享资源的访问控制:synchronized 可以确保在多线程环境中对共享资源的访问是安全的,避免了竞态条件(Race Condition)。
  • 线程间通信synchronized 可以与 wait()notify() 等方法结合使用,实现线程间的等待和唤醒机制,实现线程间的协作。

Sychronized锁的优点:

  • 简单易用:使用 synchronized 关键字来实现同步非常简单,不需要显式地创建锁对象或手动释放锁。

Sychronized锁的缺点:

  • 粒度较粗:synchronized 通常是对整个方法或代码块进行同步,因此可能会造成性能问题,特别是在高并发情况下。若对方法进行上锁,锁的是整个类对象,其他线程不能调用该对象的其他方法。若对静态方法进行上锁,那么锁的是整个类对象,其他的线程不能调用该类的其他静态方法。
  • 无法中断:一旦一个线程获取了对象的锁,其他线程需要等待该线程释放锁,无法在等待期间中断线程。
  • 非公平性:synchronized 是非公平锁,不能保证等待时间最长的线程最先获得锁。

使用样例1(对方法进行加锁):

```java
// synchronized 修饰方法,当某线程访问该对象的synchronized方法时,
// 其他线程可以访问该对象的非synchronized方法,但不能访问任何synchronized方法
// 即 synchronized 修饰方法的本质是锁的实例对象
public synchronized void increment() {
    count++;
}

public synchronized int getCount() {
    return count;
}
```

使用样例2(对静态方法进行加锁):

```java
// synchronized 修饰静态方法,当某线程访问该类的synchronized静态方法时,
// 其他可以访问“该对象”的任何方法(包括synchronized方法),也可以访问非synchronized静态方法
// 但不能访问该类的任何synchronized静态方法,即 synchronized 修饰静态方法的本质是锁类的class对象(XXXX.class对象)
public static synchronized void increment() {
    count++;
}

public static synchronized int getCount() {
    return count;
}
```

使用样例3(使用代码块锁对象):

```java
// 对obj对象进行上锁,代码块执行结束后,会释放锁
synchronized (obj) {
    count++;
}
```

使用样例3(可重入演示):

```java
synchronized (obj) {
    // do something

    // synchronized是可重入锁,同一个线程获取了还能再获取
    synchronized (obj) { 
        // do something
    }
}
```

4.2.2 ReetrantLock类

ReentrantLock 是 Java 中的一种可重入锁,它提供了与 synchronized 关键字相似的功能,但更加灵活。

ReetrantLock的应用场景:

  • 竞争资源的访问控制:当多个线程需要访问共享资源时,可以使用 ReentrantLock 来确保线程安全,例如数据库连接池中的连接分配。
  • 适用于复杂锁场景:ReetrantLock提供了条件等待、可中断、公平性策略等更多的功能。

ReetrantLock的优点:

  • 条件等待:ReentrantLock 可以与 Condition 结合使用,实现线程之间的条件等待和通知,例如生产者-消费者模式中的队列管理。
  • 可中断的锁:ReentrantLock 提供了可中断的锁获取方式,即线程可以在等待锁的过程中被中断。
  • 公平锁:ReentrantLock 可以支持公平性策略,确保等待时间较长的线程优先获得锁,避免饥饿情况。
  • 超时等待:可以给尝试获取锁的线程一个超时时间,超过该时间获取不到锁就执行其他代码。

ReetrantLock的缺点:

  • 复杂性:相比于 synchronized 关键字,使用 ReentrantLock 代码会更复杂一些。
  • 容易忘记释放锁:需要手动管理锁的获取和释放,容易出现忘记释放锁的情况,导致死锁等问题。
  • 性能开销:ReentrantLock 的性能开销比 synchronized 稍高。

样例1(简单样例):

```java
// 默认非公平锁,若需要公平锁,这里使用`new ReentrantLock(true);`
private final ReentrantLock lock = new ReentrantLock();

public void performTask() {
    lock.lock(); // 获取锁
    try {
        // 执行需要同步的代码块
        System.out.println(Thread.currentThread().getName() + " is performing the task...");
    } finally {
        lock.unlock(); // 释放锁
    }
}
```

样例2(可中断的锁):

```java
Thread thread1 = new Thread(() -> {
    try {
        // 可中断方式获取锁,即当前线程可以被`interrupt()`方法中断
        lock.lockInterruptibly();
        System.out.println(Thread.currentThread().getName() + "获取锁");
        Thread.sleep(100000);
    } catch (InterruptedException e) {
        // 当被中断时,抛出InterruptedException异常
        System.out.println(Thread.currentThread().getName() + "被中断");
    } finally {
        if (lock.isHeldByCurrentThread()) {
            System.out.println(Thread.currentThread().getName() + "释放锁");
            lock.unlock();
        }
    }
});

Thread thread2 = new Thread(() -> {
    lock.lock(); // 非中断方式获取锁
    System.out.println(Thread.currentThread().getName() + "获取锁");
    lock.unlock();
});

thread1.start();
Thread.sleep(100); // 确保线程 1 已经开始获取锁
thread2.start();
System.out.println(thread2.getName() + "启动!");
Thread.sleep(1000); // 主线程等待一段时间后中断线程 1
System.out.println("中断" + thread1.getName());
thread1.interrupt(); // 中断线程 1
```

输出:

```
Thread-0获取锁
Thread-1启动!
中断Thread-0
Thread-0被中断
Thread-0释放锁
Thread-1获取锁
```

样例3(超时等待):

```java
Runnable task = () -> {
    try {
        // 尝试等待 2 秒获取锁。获取不到就执行else的代码
        if (lock.tryLock(2, TimeUnit.SECONDS)) { 
            try {
                System.out.println(Thread.currentThread().getName() + "获取锁.");
                Thread.sleep(100000); // 模拟执行任务
            } finally {
                lock.unlock();
            }
        } else {
            System.out.println(Thread.currentThread().getName() + "等待超时.");
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
};

// 创建多个线程并启动
for (int i = 0; i < 3; i++) {
    Thread.sleep(100);
    new Thread(task).start();
}
```

输出:

```
Thread-0获取锁.
Thread-1等待超时.
Thread-2等待超时.
```

4.2.3 ReadWriteLock接口

读写锁(Read-Write Lock):java.util.concurrent.locks 包中的 ReadWriteLock 接口提供了读写锁的支持。读写锁允许多个线程同时读取共享资源,但在写入时需要独占访问。

ReadWriteLock在Java中是一个接口:

```java
public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}
```

其中最常用的实现为ReentrantReadWriteLock

ReadWriteLock 应用场景:

  • 读多写少的场景:适用于读取频率远远大于写入频率的情况,允许多个线程同时读取共享资源,提高了并发性能。
  • 缓存系统:在缓存系统中,往往有大量的读操作,而写操作相对较少,因此可以使用读写锁来提高并发访问效率。

ReadWriteLock 的优点:

  • 提高并发性能:允许多个线程同时读取共享资源,只有在写操作时才需要排它锁。
  • 降低锁竞争:在读多写少的场景下,读写锁可以降低线程的锁竞争,提高系统的吞吐量。

ReadWriteLock 的缺点:

  • 写锁排他性:写锁是独占的,当有线程持有写锁时,其他线程无法进行读或写操作,可能导致写锁长时间阻塞,降低系统的响应性。

样例1(ReentrantReadWriteLock):

```java
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

// 读线程
new Thread(() -> {
    try {
        readLock.lock();
        System.out.println("线程1获取读锁");
        Thread.sleep(1000);
        readLock.unlock();
        System.out.println("线程1释放读锁");
    } catch (InterruptedException e) { }
}).start();

new Thread(() -> {
    try {
        readLock.lock();  // 只要没有写锁,就可以获得读锁
        System.out.println("线程2获取读锁");
        Thread.sleep(500);
        readLock.unlock();
        System.out.println("线程2释放读锁");
    } catch (InterruptedException e) { }
}).start();

new Thread(() -> {
    try {
        writeLock.lock();  // 写锁必须等所有读锁都释放之后才能获取到
        System.out.println("线程3获取写锁");
        Thread.sleep(2000);
        writeLock.unlock();
        System.out.println("线程3释放写锁");
    } catch (InterruptedException e) { }
}).start();

new Thread(() -> {
    readLock.lock();  // 必须等写锁释放才能获取读锁
    System.out.println("线程4获取读锁");
    readLock.unlock();
    System.out.println("线程4释放读锁");
}).start();
```

输出:

```
线程1获取读锁
线程2获取读锁   // 虽然线程1没释放读锁,因为没有其他写锁,不影响再获取读锁
线程2释放读锁
线程1释放读锁
线程3获取写锁  // 线程3必须等所有的读锁释放后,才能获取写锁
线程3释放写锁
线程4获取读锁  // 线程4必须等写锁释放后,才可以获取读锁
线程4释放读锁
```
Next Post Previous Post
No Comment
Add Comment
comment url