Java并发编程快速面试笔记(速成版)
文章目录
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中处于就绪、运行或阻塞,都为trueThread.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个初始化参数:
- int corePoolSize:核心线程数。即线程池中保持活动状态的线程数。即使这些线程是空闲的。
- int maximumPoolSize:最大线程数,包括核心线程和非核心线程。当工作队列已满且核心线程都在工作时,线程池会创建新的线程,直到达到最大线程数。
- long keepAliveTime:空闲等待时间。当线程池中的线程数量超过核心线程数时,多余的空闲线程在被终止之前等待新任务的最长时间。超过这个时间,空闲线程会被终止。
- TimeUnit unit:keepAliveTime参数的时间单位,可以是秒、毫秒、微秒等
- BlockingQueue
workQueue :阻塞队列。用于保存等待执行的任务的阻塞队列。BlockingQueue
是一个接口,可以选择不同的实现方式。 - ThreadFactory threadFactory:用于创建新线程的工厂接口,里面包含了一个接口方法
Thread newThread(Runnable r);
。可以通过自定义ThreadFactory来设置新线程的名称、优先级等属性。一般使用默认的DefaultThreadFactory
- RejectedExecutionHandler handler:拒绝策略。当线程池无法接受新任务时,就会调用该接口的
rejectedExecution(...)
方法,来对其进行处理。默认为AbortPolicy
策略,即丢弃该任务。
3.3 线程提交线程池的处理流程
当一个线程到来时,会经历如下流程:
- 如果线程池中的核心线程数还没有达到设定的corePoolSize,则新到来的任务会被立即分配一个核心线程来处理,而不会进入阻塞队列。
- 如果线程池中的核心线程已经饱和(即核心线程数已达到corePoolSize),而且阻塞队列未满,新到来的任务会被放入阻塞队列中等待执行。
- 如果线程池中的核心线程已经饱和,且阻塞队列也已满,但是线程池的最大线程数(maximumPoolSize)尚未达到,线程池会创建额外的非核心线程来处理任务。
- 如果线程池的最大线程数也已经达到,并且阻塞队列也已满,根据线程池的拒绝策略,可能会执行拒绝策略来处理新到来的任务。默认的拒绝策略是抛出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释放读锁 ```