【八股战神篇】Java多线程高频面试题(JUC)
目录
专栏简介
一 如何创建线程?
延伸
1. 创建 Java 线程的本质
二 说说线程的生命周期?
延伸
1.描述一下线程的生命周期图
2.线程的优先级对线程执行有何影响?
3.如何确保三个线程按照特定顺序执行?
三 并发和并行的区别?
延伸
1.并发和并行的实际应用
四 同步和异步的区别?
延伸
1.同步与异步的实际应用场景
2.同步与异步的技术实现
五 线程池时需要考虑哪些核心参数?
延伸
1.线程池创建的两种方式
2.描述线程池的工作流程及其任务调度策略。
3.线程池常用的阻塞队列总结**
六 线程池四大拒绝策略?
延伸
1.内置拒绝策略源码解析
2.如何合理设置线程池的大小以提高系统的并发性能?请给出建议。
七 ThreadLocal 类的作用是什么?请解释其在多线程环境下的工作原理和适用场景。
延伸
1.ThreadLocal优缺点
2.内存泄漏问题及解决
3.ThreadLocal 类底层是如何实现的?请解释其数据结构
八 什么是线程死锁?
延伸
1.线程死锁的示例
九 如何预防和避免线程死锁?
延伸
1.如何检测死锁
十 synchronized 底层原理了解吗?
延伸
1.如何使用 synchronized?
2. synchronized 的优缺点
3.synchronized 关键字锁定的对象是什么?请解释其含义。
十一 synchronized和ReentrantLock的区别?
延伸
1.锁的状态与升级过程
十二 什么是乐观锁?
拓展:
1.实现方式
2.CAS 算法的主要问题
十三 什么是线程上下文切换?
延伸
1.线程上下文切换的影响
2.如何减少线程上下文切换的影响?
专栏简介
八股战神篇专栏是基于各平台共上千篇面经,上万道面试题,进行综合排序提炼出排序前百的高频面试题,并对这些高频八股进行关联分析,将每个高频面试题可能进行延伸的问题进行分析排序选出高频延伸八股题。面试官都是以点破面从一个面试题不断深入,目的是测试你的理解程度。本专栏将解决你的痛点,助你从容面对。本专栏已更新Java基础高频面试题、Java集合高频面试题、MySQL高频面试题、JUC Java并发高频面试题、JVM高频面试题,后续会继续更新Redis、操作系统、计算机网络、设计模式、场景题等,计划在七月前更新完毕(赶在大家高频面试前)点此链接订阅专栏“八股战神篇”。
一 如何创建线程?
Java 提供了多种方式来创建和管理线程,最常见的方式一共有四种,接下来我会分别进行讲述。
-
继承
Thread
类:创建一个子类继承Thread
类,并重写其run()
方法。 -
实现
Runnable
接口:实现Runnable
接口并重写其run()
方法,然后将其传递给Thread
对象。 -
使用
Callable
和Future
:实现Callable
接口并重写call()
方法,可以有返回值,通过FutureTask
或线程池获取结果。 -
使用线程池(
ExecutorService
):通过ExecutorService
提交任务,由线程池管理线程的创建和调度。
详解
第一种通过继承 Thread 类并重写其 run() 方法来创建线程。在run() 方法中定义线程需要执行的任务逻辑,然后
创建该类的实例,调用 start() 方法启动线程,start() 方法会自动调用 run() 方法中的代码逻辑。这种方式简单直观,但由于 Java 不支持多重继承,因此限制了类的扩展性。
代码示例
class MyThread extends Thread {@Overridepublic void run() {System.out.println("Thread is running: " + Thread.currentThread().getName());}
}
public class ThreadExample {public static void main(String[] args) {MyThread thread = new MyThread();thread.start(); // 启动线程}
}
第二种是实现 Runnable 接口并将其传递给 Thread 构造器来创建线程。Runnable 是一个函数式接口,其中的 run() 方法定义了任务逻辑。这种方式更加灵活,因为它不占用类的继承关系,同时可以更好地支持资源共享,可以让多个线程共享同一个 Runnable 实例。这种方式适用于需要解耦任务逻辑与线程管理的场景。
代码示例
class MyRunnable implements Runnable {@Overridepublic void run() {System.out.println("Runnable is running: " + Thread.currentThread().getName());}
}
public class RunnableExample {public static void main(String[] args) {Thread thread = new Thread(new MyRunnable());thread.start(); // 启动线程}
}
第三种是通过实现 Callable 接口来创建有返回值的线程。Callable 接口类似于 Runnable,但它可以返回结果并抛出异常。Callable 的 call() 方法需要通过 FutureTask 包装后传递给 Thread 构造器。通过 Future 对象可以获取线程执行的结果或捕获异常。这种方式适用于需要获取线程执行结果或处理复杂任务的场景。
代码示例
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<Integer> {@Overridepublic Integer call() throws Exception {System.out.println("Callable is running: " + Thread.currentThread().getName());return 42; // 返回值}
}
public class CallableExample {public static void main(String[] args) {FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());Thread thread = new Thread(futureTask);thread.start();
try {// 获取结果System.out.println("Result: " + futureTask.get());} catch (InterruptedException | ExecutionException e) {e.printStackTrace();}}
}
第四种是通过 Executor 框架创建线程池来管理线程。Executor 框架提供了更高级的线程管理功能,例如线程复用、任务调度等。通过 submit() 或 execute() 方法提交任务,避免频繁创建和销毁线程的开销。它作为最常被使用的方式,广泛用于需要高效管理大量线程的场景。
代码示例
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {public static void main(String[] args) {ExecutorService executor = Executors.newFixedThreadPool(3);
Runnable task = () -> {System.out.println("ThreadPool task is running: " + Thread.currentThread().getName());};
for (int i = 0; i < 5; i++) {executor.submit(task); // 提交任务到线程池}
executor.shutdown(); // 关闭线程池}
}
对比不同创建线程的方法
方法 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
Thread | 简单任务 | 代码简单,易于理解 | 耦合任务逻辑与线程,无法多继承 |
Runnable | 解耦任务逻辑与线程 | 灵活,支持多继承 | 无法直接返回结果 |
Callable | 需要返回值或抛出异常的任务 | 支持返回结果和异常 | 代码相对复杂,需配合 Future |
线程池(Executor ) | 高并发或需要线程复用的场景 | 高效,便于资源管理 | 增加了线程池管理的复杂性 |
延伸
1. 创建 Java 线程的本质
在 Java 中,线程的启动本质上是通过调用 Thread 类的 start() 方法完成的。start() 方法会通知 JVM 启动一个新的线程,并在这个新线程中执行 run() 方法中的代码逻辑。因此,无论你使用哪种方式创建线程,最终都会归结到调用 new Thread().start() 来真正启动线程。
换句话说,new Thread().start() 是启动线程的唯一入口,而其他方式(如继承 Thread、实现 Runnable 或 Callable、使用 Executor 框架)都是对 Thread 类的封装或扩展,目的是为了提供更灵活的编程模型。
二 说说线程的生命周期?
在 Java 中,线程的生命周期有以下七种状态,每个线程在其生命周期中的任意时刻只可能处于这七种状态之一。接下来我会详细说一下各状态:
第一个是创建(NEW),线程对象通过new Thread()
创建,但尚未调用start()
方法启动。此时线程尚未分配系统资源,仅完成对象初始化。
第二个是就绪(RUNNABLE),当调用线程的 start() 方法后,线程进入就绪状态线程(包含子状态READY)后等待CPU调度。
第三个运行(RUNNING),线程获取CPU时间片,执行run()
方法中的代码。可通过sleep()
、wait()
或I/O操作主动让出CPU。
第四个是阻塞状态(BLOCKED),如果线程试图进入一个同步代码块或方法时,发现所需的锁被其他线程占用,则会进入阻塞状态。线程在此状态下等待锁的释放,获得锁后会重新回到可运行状态。
第五个是等待状态(WAITING),当线程调用不带超时参数的等待方法(例如 Object.wait()、Thread.join() 或 LockSupport.park())时,它将进入等待状态。在这种状态下,线程会无限期地等待,直到其它线程通过 notify()、notifyAll() 或中断操作将其唤醒。
第六个是超时等待状态(TIMED_WAITING),如果线程调用了带有超时参数的等待方法,如 Thread.sleep(long millis)、wait(long timeout) 或 join(long millis),则会进入超时等待状态。与等待状态不同,超时等待状态会在指定时间到期后自动返回到可运行状态。
第七个是终止状态(TERMINATED),当线程的 run() 方法执行完毕,或者因未捕获异常而提前结束时,线程就进入了终止状态。此时,该线程的生命周期结束,不能再被启动或复用。
延伸
1.描述一下线程的生命周期图
线程在创建后首先进入 NEW(新建)状态,调用 start() 方法后进入 READY(就绪)状态,等待 CPU 时间片分配;当线程获得时间片后进入 RUNNING(运行)状态并执行任务。若线程调用 wait() 方法,则进入 WAITING(等待)状态,需依赖其他线程的通知才能恢复运行;而通过 sleep(long millis) 或 wait(long millis) 方法,线程会进入 TIMED_WAITING(超时等待)状态,并在超时结束后返回可运行状态。如果线程试图获取 synchronized 锁但被占用,则进入 BLOCKED(阻塞)状态,直到锁可用。最后,当线程执行完 run() 方法或因异常退出时,进入 TERMINATED(终止)状态,生命周期结束。
2.线程的优先级对线程执行有何影响?
在Java中,线程的优先级用来提示线程调度器应该优先执行哪个线程。线程优先级是一个整数,范围为1
(最低优先级)到10
(最高优先级),默认值为5
。
优先级对线程执行的影响:
-
高优先级线程会比低优先级线程更有可能被CPU优先调度执行。
-
线程优先级只是一个建议,最终执行顺序和频率由操作系统的线程调度器决定。
-
不能完全依赖优先级:因为调度策略因操作系统而异,可能导致不同平台上表现不同。
3.如何确保三个线程按照特定顺序执行?
在 Java 中,可以通过使用同步机制(如 wait/notify
或 Lock/Condition
)或者并发工具类(如 CountDownLatch
或 Semaphore
)来确保三个线程按照特定顺序执行。我将给出4个方法来保证线程的顺序执行。
1. 使用 join()
join()
方法会让当前线程等待另一个线程执行完成。其核心原理是主线程调用线程对象的 join()
方法时,会进入阻塞状态,直到目标线程结束。
代码示例
Thread t1 = new Thread(() -> System.out.println("线程1执行"));
Thread t2 = new Thread(() -> System.out.println("线程2执行"));
Thread t3 = new Thread(() -> System.out.println("线程3执行"));
t1.start();
t1.join(); // 等待 t1 完成
t2.start();
t2.join(); // 等待 t2 完成
t3.start();
t3.join(); // 等待 t3 完成
优点: 简单直接。
缺点: 线程之间的耦合度较高,不能灵活实现更复杂的顺序控制。
2. 使用 wait/notify
可以通过 wait/notify
机制,使用共享的锁和标志变量来控制线程执行顺序。
代码示例
public class WaitNotifyExample {private static final Object lock = new Object();private static int flag = 1;
public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (lock) {while (flag != 1) {try {lock.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程1执行");flag = 2;lock.notifyAll();}});
Thread t2 = new Thread(() -> {synchronized (lock) {while (flag != 2) {try {lock.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程2执行");flag = 3;lock.notifyAll();}});
Thread t3 = new Thread(() -> {synchronized (lock) {while (flag != 3) {try {lock.wait();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println("线程3执行");lock.notifyAll();}});
t1.start();t2.start();t3.start();}
优点: 可以精确控制线程的执行顺序。
缺点: 编程较复杂,代码容易出错。
3. 使用 CountDownLatch
CountDownLatch
是一个并发工具类,可以通过设置初始计数值实现线程间的协调。
代码示例
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {public static void main(String[] args) throws InterruptedException {CountDownLatch latch1 = new CountDownLatch(1);CountDownLatch latch2 = new CountDownLatch(1);
Thread t1 = new Thread(() -> {System.out.println("线程1执行");latch1.countDown(); // 减少 latch1 的计数});
Thread t2 = new Thread(() -> {try {latch1.await(); // 等待 latch1 减为 0System.out.println("线程2执行");latch2.countDown(); // 减少 latch2 的计数} catch (InterruptedException e) {e.printStackTrace();}});
Thread t3 = new Thread(() -> {try {latch2.await(); // 等待 latch2 减为 0System.out.println("线程3执行");} catch (InterruptedException e) {e.printStackTrace();}});
t1.start();t2.start();t3.start();}
}
优点: 使用方便,代码更清晰。
缺点: CountDownLatch 是一次性的,不能重复使用。
4. 使用 Semaphore
Semaphore
是另一种常用的并发工具类,可以通过限制线程的许可证数量来控制线程的执行顺序。
import java.util.concurrent.Semaphore;
public class SemaphoreExample {public static void main(String[] args) {Semaphore semaphore1 = new Semaphore(1);Semaphore semaphore2 = new Semaphore(0);Semaphore semaphore3 = new Semaphore(0);
Thread t1 = new Thread(() -> {try {semaphore1.acquire();System.out.println("线程1执行");semaphore2.release(); // 释放 semaphore2 的许可证} catch (InterruptedException e) {e.printStackTrace();}});
Thread t2 = new Thread(() -> {try {semaphore2.acquire();System.out.println("线程2执行");semaphore3.release(); // 释放 semaphore3 的许可证} catch (InterruptedException e) {e.printStackTrace();}});
Thread t3 = new Thread(() -> {try {semaphore3.acquire();System.out.println("线程3执行");} catch (InterruptedException e) {e.printStackTrace();}});
t1.start();t2.start();t3.start();}
}
优点: 可灵活控制线程的执行流程。
缺点: 对于复杂顺序控制,代码可能较冗长。
三 并发和并行的区别?
并发和并行是多线程编程中的两个核心概念,它们描述了任务执行的不同方式。虽然这两个术语经常被混用,但它们有着本质的区别。接下来我会详细讲述并发和并行的定义以及它们之间的区别。
并发是指在同一时间段内,多个任务能够交替进行。也就是说,虽然 CPU 资源有限,系统会通过时间分片(Time-Slicing)技术,快速切换不同的任务执行。并发并不一定要求在同一时刻有多个任务同时执行,而是通过线程调度、上下文切换等手段,实现多个任务的“假并行”。
并行是指在同一时刻,多个任务可以真正地同时执行。并行通常要求有多个计算资源(如多个处理器核心或者多台计算机),每个任务都由不同的计算资源同时处理。
最后说一下它们之间的四点区别,
第一是执行方式的不同,并发是任务交替执行,强调的是任务调度的时间分片;而并行是任务同时执行,强调的是多核资源的利用。
第二是对硬件的要求不同,并发可以在单核 CPU 上实现,通过时间片轮转完成任务切换;而并行需要多核 CPU 的支持,每个核心独立处理一个任务。
第三是适用场景的不同,并发适合 I/O 密集型任务,因为这类任务通常需要等待外部资源,如磁盘、网络等,CPU 可以在这段时间切换到其他任务;而并行适合计算密集型任务,因为这类任务需要大量 CPU 计算,多核并行可以显著加速处理速度。
第四是核心目标的不同,并发的核心目标是提高系统的响应能力和资源利用率;而并行的核心目标是提高系统的吞吐量和计算效率。
详解
并发与并行的异同
概念 | 并发 | 并行 |
---|---|---|
定义 | 多个任务在同一时间段内交替执行 | 多个任务在同一时刻同时执行 |
硬件要求 | 单核处理器上通过时间切片实现 | 多核处理器或多台计算机,任务在多个处理单元上并行执行 |
资源 | 共享资源、任务切换,操作系统进行调度 | 多个处理单元各自独立工作,任务分配到不同的处理单元 |
示例 | 单核 CPU 上,浏览器同时处理多个任务(交替执行) | 多核 CPU 上,分配任务到不同核心进行并行处理 |
目标 | 提高任务响应性,解决资源竞争问题 | 提高处理能力,减少计算时间 |
延伸
1.并发和并行的实际应用
最后,在实际开发中,并发和并行往往是结合使用的。例如:
(1)在 Java 中,ExecutorService 提供了线程池机制,可以通过合理配置线程池大小来实现并发任务调度。
(2)对于计算密集型任务,可以使用 ForkJoinPool 或并行流(parallelStream)来充分利用多核 CPU 的并行能力。
(3)在分布式系统中,通过将任务分解为多个子任务并发执行,并在多个节点上并行处理,可以进一步提升系统的整体性能。
四 同步和异步的区别?
同步和异步是编程中两种常见的任务执行模式,接下来我会详细讲述同步和异步的定义以及它们之间的区别。
同步指的是任务按照顺序依次执行的方式。在这种模式下,调用者会阻塞等待任务完成并返回结果后,才会继续执行后续的操作。
异步指的是任务无需等待立即返回,调用方可以继续执行其他操作,而任务的结果会在稍后通过如回调函数、事件通知或 Future 对象等机制传递给调用方。
同步(Synchronous)和异步(Asynchronous)是描述程序中任务执行方式的两个重要概念,它们的主要区别如下:
-
执行方式:
-
同步:任务按顺序执行,当前任务完成后才能执行下一个任务。
-
异步:任务可以并行执行,不需要等待当前任务完成即可开始下一个任务。
-
-
阻塞与非阻塞:
-
同步:通常是阻塞的,任务会等待结果返回。
-
异步:通常是非阻塞的,任务会继续执行,不等待结果,完成后通过回调或通知处理结果。
-
-
适用场景:
-
同步:适用于依赖任务顺序的场景。
-
异步:适用于需要高并发或任务独立的场景。
-
同步与异步的核心区别
属性 | 同步(Synchronous) | 异步(Asynchronous) |
---|---|---|
执行方式 | 按顺序执行,任务完成后继续下一步 | 并行执行,任务可独立完成 |
是否阻塞 | 阻塞当前线程 | 非阻塞,任务继续执行 |
效率 | 低(等待任务完成耗时) | 高(可以同时处理多任务) |
复杂性 | 逻辑简单 | 需要回调或通知机制处理结果 |
适用场景 | 小规模、任务依赖顺序 | 高并发、大量 I/O 操作场景 |
延伸
1.同步与异步的实际应用场景
同步应用场景
-
数据库操作:查询用户信息并立即返回结果。
-
单线程操作:例如计算密集型任务,逻辑简单且需要严格按顺序执行。
示例:同步的订单处理
public void processOrder() {validateOrder(); // 验证订单saveOrder(); // 保存订单sendConfirmation(); // 发送确认邮件
}
异步应用场景
-
Web服务器:处理高并发 HTTP 请求,避免阻塞线程。
-
文件上传与下载:文件传输时,允许用户继续使用其他功能。
-
消息队列:使用消息中间件(如 RabbitMQ、Kafka)处理任务。
示例:异步的订单处理
public void processOrderAsync() {CompletableFuture.runAsync(() -> validateOrder());CompletableFuture.runAsync(() -> saveOrder());CompletableFuture.runAsync(() -> sendConfirmation());
}
2.同步与异步的技术实现
同步技术实现
-
使用阻塞 I/O:例如传统的
InputStream
和OutputStream
。 -
单线程顺序执行逻辑,方法返回即表示完成。
异步技术实现
-
非阻塞 I/O:使用
NIO
或AsynchronousFileChannel
。 -
多线程:通过线程池实现任务异步执行。
-
事件驱动:使用回调函数、CompletableFuture 或 Reactive Streams。
五 线程池时需要考虑哪些核心参数?
线程池是 Java 并发编程中的重要工具,它通过复用线程来减少线程创建和销毁的开销,从而提高系统的性能和稳定性。在 Java 中,线程池的核心实现类是 ThreadPoolExecutor,它提供了七个重要的参数来配置线程池的行为。接下来我会详细讲述这七大参数的定义、作用以及它们如何影响线程池的工作机制。
-
核心线程数(corePoolSize)
-
指线程池中始终保持存活的线程数量。即使线程空闲,它们也不会被销毁。核心线程用于处理常规任务负载。
-
-
最大线程数(maximumPoolSize)
-
指线程池中允许的最大线程数量。当任务量大于核心线程的处理能力时,线程池会创建更多的线程,直到达到最大线程数。
-
-
任务队列(workQueue)
-
用于存放等待执行的任务。常见的队列类型有有界队列(
ArrayBlockingQueue
)、无界队列(LinkedBlockingQueue
)等。任务队列可以影响线程池的行为。
-
-
线程存活时间(keepAliveTime)
-
指非核心线程在空闲状态下能存活的最长时间。当线程空闲时间超过该值时,会被销毁(如果当前线程数大于核心线程数)。
-
-
存活时间单位(timeUnit)
-
配合
keepAliveTime
使用,指定时间的单位(如秒、毫秒等)。
-
-
线程工厂(threadFactory)
-
用于创建线程,可以自定义线程的名称、优先级等,便于调试和监控。
-
-
拒绝策略(handler)
-
当线程池和任务队列都满时,决定如何处理新提交的任务。常见策略包括丢弃任务、抛出异常、调用者执行等。
-
这些参数决定了线程池的行为,必须根据实际场景合理配置。
延伸
1.线程池创建的两种方式
(1)通过 ThreadPoolExecutor 手动创建线程池
ThreadPoolExecutor 是线程池的核心实现类,它允许开发者通过构造函数灵活地配置线程池的参数。以下是 ThreadPoolExecutor 的构造函数:
public ThreadPoolExecutor(int corePoolSize, // 核心线程数int maximumPoolSize, // 最大线程数long keepAliveTime, // 空闲线程存活时间TimeUnit unit, // 存活时间单位BlockingQueue<Runnable> workQueue, // 任务队列ThreadFactory threadFactory, // 线程工厂RejectedExecutionHandler handler // 拒绝策略
);
优点: 配置灵活,可以根据业务需求精确控制线程池的行为。 易于调试和排查问题,因为所有参数都是显式设置的。
示例代码:
// 创建一个自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(2, // 核心线程数4, // 最大线程数60, // 空闲线程存活时间TimeUnit.SECONDS, // 时间单位new LinkedBlockingQueue<>(10), // 任务队列Executors.defaultThreadFactory(), // 线程工厂new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
executor.submit(() -> System.out.println("任务执行"));
(2)通过 Executors 工具类创建线程池(不建议使用)
Executors 是一个工具类,提供了几种常用的线程池创建方法,例如:
newFixedThreadPool(int nThreads):创建固定大小的线程池。
newCachedThreadPool():创建一个根据需要创建新线程的线程池。
newSingleThreadExecutor():创建只有一个线程的线程池。
newScheduledThreadPool(int corePoolSize):创建支持定时及周期性任务执行的线程池。
优点:使用简单,适合快速开发和测试场景。
示例代码:
// 使用 Executors 创建固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> System.out.println("任务执行"));
实例分析:自定义线程池
创建一个自定义线程池,合理配置参数:
import java.util.concurrent.*;
public class CustomThreadPoolExample {public static void main(String[] args) {ExecutorService threadPool = new ThreadPoolExecutor(2, // 核心线程数4, // 最大线程数60L, // 空闲线程存活时间TimeUnit.SECONDS, // 存活时间单位new ArrayBlockingQueue<>(2), // 有界任务队列Executors.defaultThreadFactory(), // 默认线程工厂new ThreadPoolExecutor.AbortPolicy() // 拒绝策略);
for (int i = 0; i < 10; i++) {final int task = i;threadPool.execute(() -> {System.out.println("执行任务: " + task + " by " + Thread.currentThread().getName());try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}});}
threadPool.shutdown();}
}
2.描述线程池的工作流程及其任务调度策略。
线程池的工作流程主要分为以下几个阶段:
-
任务提交:
-
通过
execute()
或submit()
方法提交任务到线程池。
-
-
线程分配与任务队列:
-
如果当前运行的线程数小于核心线程数(
corePoolSize
),创建一个新的线程来执行任务。 -
如果核心线程数已满,则将任务加入队列等待。
-
如果队列也满了,并且线程数小于最大线程数(
maximumPoolSize
),则创建一个非核心线程来执行任务。
-
-
拒绝任务:
-
当线程数达到最大线程数并且队列也满时,线程池会根据拒绝策略决定如何处理任务,例如丢弃任务、抛出异常或回退到调用线程。
-
-
任务执行与线程复用:
-
线程从任务队列中获取任务执行,执行完成后不会被销毁,而是继续等待新的任务。
-
-
线程回收:
-
如果线程数超过核心线程数(非核心线程)并且这些线程在
keepAliveTime
内没有执行任务,会被回收以释放资源。
-
3.线程池常用的阻塞队列总结**
队列类型 | 是否有界 | 特点 | 适用场景 |
---|---|---|---|
ArrayBlockingQueue | 有界 | 固定容量,基于数组 | 对任务数量有限制的场景 |
LinkedBlockingQueue | 无界/有界 | 基于链表,默认无界 | 任务量较小且处理速度快的场景 |
SynchronousQueue | 无存储 | 直接移交任务,不存储 | 高性能、快速响应的系统 |
PriorityBlockingQueue | 无界 | 按优先级排序 | 需要优先级调度的场景 |
DelayQueue | 无界 | 延迟执行任务 | 定时任务、缓存过期等场景 |
LinkedTransferQueue | 无界 | 高效的移交操作 | 高吞吐量、高并发场景 |
六 线程池四大拒绝策略?
线程池是 Java 并发编程中用于管理线程的重要工具,而拒绝策略则是线程池在资源耗尽时处理新任务的一种机制。当线程池中的线程数达到最大值且任务队列已满时,线程池会根据配置的拒绝策略来决定如何处理无法接受的新任务。接下来我会详细讲述线程池的四大拒绝策略及其特点。
首先是 AbortPolicy(中止策略),它线程池的默认拒绝策略。当线程池无法接受新任务时,它会直接抛出 RejectedExecutionException 异常,终止任务的提交。这种策略适用于对任务执行有严格要求的场景,例如不允许任务丢失的情况。
然后是 CallerRunsPolicy(调用者运行策略),它会将被拒绝的任务回退给提交任务的线程执行。也就是说,任务不会被丢弃,而是由调用线程(通常是主线程)直接运行该任务。这种策略可以减缓任务提交的速度,从而缓解线程池的压力,但可能会导致调用线程阻塞。在这种情况下,主线程会承担部分任务的执行工作。
接下来是 DiscardPolicy(丢弃策略),它会直接丢弃无法处理的任务,并且不会抛出任何异常。这种策略适用于对任务执行要求不高的场景,例如允许部分任务丢失的情况。在这种情况下,被拒绝的任务会被静默丢弃,调用方不会收到任何通知。
最后是 DiscardOldestPolicy(丢弃最旧任务策略),它会丢弃任务队列中最旧的任务(即等待时间最长的任务),然后尝试重新提交当前任务。这种策略可以确保较新的任务有机会被执行,但可能会导致某些任务被重复提交或丢失。在这种情况下,队列中最旧的任务会被移除,为新任务腾出空间。
延伸
1.内置拒绝策略源码解析
1.1 AbortPolicy
public static class AbortPolicy implements RejectedExecutionHandler {public AbortPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {throw new RejectedExecutionException("Task " + r.toString() +" rejected from " +e.toString());}
}
-
抛出
RejectedExecutionException
,终止任务提交流程。 -
优点:明确通知调用方任务被拒绝。
-
缺点:需要调用方显式捕获异常,否则可能导致程序崩溃。
1.2 CallerRunsPolicy
public static class CallerRunsPolicy implements RejectedExecutionHandler {public CallerRunsPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {if (!e.isShutdown()) {r.run();}}
}
-
调用线程执行任务,如果线程池已经关闭,则任务不会被执行。
-
优点:避免任务丢失,并减少线程池的负载。
-
缺点:可能拖慢调用线程的执行速度,影响其他任务。
1.3 DiscardPolicy
public static class DiscardPolicy implements RejectedExecutionHandler {public DiscardPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {// 直接丢弃任务}
}
-
任务直接被丢弃,没有任何通知。
-
优点:简单高效,不影响线程池运行。
-
缺点:任务丢失无法感知,不适合关键性任务。
1.4 DiscardOldestPolicy
public static class DiscardOldestPolicy implements RejectedExecutionHandler {public DiscardOldestPolicy() { }
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {if (!e.isShutdown()) {e.getQueue().poll(); // 移除最早的任务e.execute(r); // 尝试执行新任务}}
}
-
移除队列中最早的任务,然后重新尝试提交新任务。
-
优点:优先处理最新任务,适合实时性要求高的场景。
-
缺点:可能丢弃尚未完成的重要任务。
2.如何合理设置线程池的大小以提高系统的并发性能?请给出建议。
在设置线程池大小时,合理的线程池大小是提升系统并发性能的关键。设置线程池大小的目标是使得系统在高并发情况下能够充分利用 CPU 资源,同时避免线程过多导致的上下文切换和资源竞争。以下是一些设置线程池大小的建议:
-
基于 CPU 核数设置线程池大小:
-
通常情况下,对于 CPU 密集型任务,线程池的大小可以设置为 CPU 核数 或 CPU 核数 + 1。这样可以确保线程池的线程数与 CPU 核数匹配,避免过多线程导致的上下文切换和资源浪费。
-
对于 I/O 密集型任务,由于线程在等待 I/O 操作时处于阻塞状态,线程池可以设置为 CPU 核数的多倍,例如 CPU 核数 * 2 或 CPU 核数 * 4,这样可以充分利用 CPU 资源。
-
-
使用
Runtime.getRuntime().availableProcessors()
获取 CPU 核数:-
在 Java 中,可以使用
Runtime.getRuntime().availableProcessors()
方法来获取当前系统的 CPU 核数,并以此作为设置线程池大小的参考。
-
-
考虑任务的性质:
-
对于 I/O 密集型任务,线程池中的线程数可以相对较大,因为线程在 I/O 阻塞时会释放 CPU 资源,其他线程可以继续工作。
-
对于 CPU 密集型任务,线程池中的线程数不应过多,避免超出 CPU 核心数,避免线程上下文切换的开销。
-
-
使用合适的
ThreadPoolExecutor
参数:-
corePoolSize
:核心线程数,线程池会始终保持这个数量的线程,即使这些线程处于空闲状态。 -
maximumPoolSize
:最大线程数,线程池允许的最大线程数。合理设置此值可以防止线程池创建过多线程。 -
keepAliveTime
:线程空闲时的最大存活时间。合理设置可以避免在没有任务时占用系统资源。
-
-
根据任务的等待时间和执行时间调整线程池大小:
-
如果任务执行时间较短,任务数量较大,且每个任务都需要等待 I/O 或网络操作,线程池应该设置得更大,以便在一个线程阻塞时,可以由其他线程来继续执行任务。
-
如果任务执行时间较长,线程池的大小可以适当设置较小,以减少线程切换和资源消耗。
-
七 ThreadLocal 类的作用是什么?请解释其在多线程环境下的工作原理和适用场景。
ThreadLocal
是 Java 提供的一个工具类,用来为每个线程创建独立的本地变量副本。通过 ThreadLocal
,每个线程都可以访问到自己单独的变量副本,互不干扰,从而实现线程隔离。
工作原理:
-
每个线程都持有一个
ThreadLocalMap
,这个ThreadLocalMap
中的键是ThreadLocal
对象,值是线程本地变量的副本。 -
当线程访问
ThreadLocal
的get()
或set()
方法时,实际操作的是当前线程的ThreadLocalMap
,从而保证了线程隔离。
适用场景:
-
ThreadLocal适用于需要在 同一线程内共享数据,但不同线程之间相互隔离的场景,例如:
-
数据库连接(
Connection
)管理:为每个线程分配一个独立的Connection
,避免多个线程共享一个Connection
导致线程安全问题。 -
用户会话信息:在 Web 应用中,每个线程处理一个用户的请求,
ThreadLocal
可以用来存储用户的会话信息。
-
延伸
1.ThreadLocal优缺点
优点 | 缺点 |
---|---|
线程隔离:确保每个线程有独立的变量副本。 | 内存泄漏风险:未及时清理可能导致内存泄漏。 |
易于使用:简化了线程本地数据的管理。 | 资源管理复杂:需要手动清理线程局部变量。 |
无需加锁:线程本地变量无需显式同步。 | 调试困难:多线程环境下,变量状态可能难以跟踪。 |
2.内存泄漏问题及解决
问题原因:
-
ThreadLocalMap
的键是弱引用(ThreadLocal
),但值是强引用。 -
如果
ThreadLocal
对象被垃圾回收,ThreadLocalMap
的键会变成null
,但值无法被回收,导致内存泄漏。
解决方案:
-
使用完
ThreadLocal
后,显式调用remove()
方法清理数据。
代码示例:
public class ThreadLocalLeakExample {private static ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "Initial Value");
public static void main(String[] args) {threadLocal.set("Custom Value");System.out.println(threadLocal.get());// 显式清理,避免内存泄漏threadLocal.remove();}
}
3.ThreadLocal 类底层是如何实现的?请解释其数据结构
ThreadLocal 是一个用来为每个线程提供独立变量副本的类。它的底层实现通过每个线程单独维护一个 ThreadLocalMap
来实现数据隔离。当我们调用 ThreadLocal
的 set
方法时,数据会存储到当前线程的 ThreadLocalMap
中;而调用 get
方法时,会从该线程的 ThreadLocalMap
中获取对应的值。这样,线程之间就可以互相隔离,互不干扰。
ThreadLocal 的核心数据结构是 ThreadLocalMap,它是 ThreadLocal 类的一个静态内部类,每个 Thread 对象中都持有一个 ThreadLocalMap 类型的成员变量 threadLocals。ThreadLocalMap 类似于普通的哈希表(HashMap),但它使用开放寻址法(而非链表法)来解决哈希冲突,并且其 Entry 对象是一个弱引用(WeakReference)的子类,其中键(Key)是 ThreadLocal 对象的弱引用,值(Value)是用户存储的线程局部变量。
工作机制
ThreadLocal
的核心方法如下:
1.set(T value)
:
-
将当前线程的
ThreadLocalMap
中以当前ThreadLocal
对象为键存储值。 -
如果ThreadLocalMap为空,则初始化一个新的ThreadLocalMap并关联到当前线程。
核心代码:
public void set(T value) {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);elsecreateMap(t, value); }
2.get()
:
-
从当前线程的
ThreadLocalMap
中获取以当前ThreadLocal
为键的值。 -
如果ThreadLocalMap为空或没有对应值,会返回null或初始化默认值。
核心代码:
public T get() {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {@SuppressWarnings("unchecked")T result = (T)e.value;return result;}}return setInitialValue(); }
3.remove()
:
-
从当前线程的
ThreadLocalMap
中移除以当前ThreadLocal
为键的数据,防止内存泄漏。
八 什么是线程死锁?
线程死锁是多线程编程中一个常见的问题,它会导致程序陷入一种无法继续执行的状态。接下来我会详细讲述线程死锁的定义和产生原因。
首先说一下什么是线程死锁,它是指两个或多个线程在执行过程中,因为争夺资源而相互等待对方释放资源,从而导致所有相关线程都无法继续执行的情况。例如,线程 A 持有资源 1 并等待资源 2,而线程 B 持有资源 2 并等待资源 1,这样两个线程就会陷入互相等待的状态,形成死锁。
接下来说一下线程死锁的产生条件,线程死锁的产生需要同时满足以下四个必要条件:
第一个是互斥条件,资源只能被一个线程占用,其他线程必须等待资源释放后才能使用。
第二个是占有且等待,线程已经占有了某些资源,并且正在等待获取其他被占用的资源。
第三个是不可剥夺条件,线程持有的资源不能被强制剥夺,只有线程自己可以释放资源。
第四个是循环等待条件,存在一组线程形成循环等待,每个线程都在等待下一个线程所占有的资源。
延伸
1.线程死锁的示例
public class DeadlockExample {private static final Object resource1 = new Object();private static final Object resource2 = new Object();
public static void main(String[] args) {Thread threadA = new Thread(() -> {synchronized (resource1) {System.out.println("Thread A: Holding resource 1...");try { Thread.sleep(100); } catch (InterruptedException e) {}System.out.println("Thread A: Waiting for resource 2...");synchronized (resource2) {System.out.println("Thread A: Holding resource 1 and 2.");}}});
Thread threadB = new Thread(() -> {synchronized (resource2) {System.out.println("Thread B: Holding resource 2...");try { Thread.sleep(100); } catch (InterruptedException e) {}System.out.println("Thread B: Waiting for resource 1...");synchronized (resource1) {System.out.println("Thread B: Holding resource 1 and 2.");}}});
threadA.start();threadB.start();}
}
在这个例子中,线程 A 和线程 B 分别持有不同的资源,并试图获取对方持有的资源,最终导致死锁。
九 如何预防和避免线程死锁?
线程死锁是多线程编程中一个常见的问题,它会导致程序陷入一种无法继续执行的状态。接下来我会详细讲述死锁发生的必要条件以及避免线程死锁的方法。
首先说一下线程死锁的产生条件,线程死锁的产生需要同时满足以下四个必要条件:
第一个是互斥条件,资源只能被一个线程占用,其他线程必须等待资源释放后才能使用。
第二个是占有且等待,线程已经占有了某些资源,并且正在等待获取其他被占用的资源。
第三个是不可剥夺条件,线程持有的资源不能被强制剥夺,只有线程自己可以释放资源。
第四个是循环等待条件,存在一组线程形成循环等待,每个线程都在等待下一个线程所占有的资源。
其次,如果我们想要避免死锁的发生,只要破坏其中任何一个条件。
第一种是破坏互斥条件,我们可以尽量减少对共享资源的独占性访问。 使用无锁数据结构来替代传统的同步机制,比如:ConcurrentHashMap、AtomicInteger 等。对于只读资源,可以通过复制或缓存的方式避免竞争。
第二种是破坏占有且等待条件,我们可以要求线程在开始执行前一次性获取所有需要的资源。如果无法获取所有资源,则释放已占有的资源并稍后重试。这种方法被称为“一次性申请所有资源”,但需要注意的是,它可能会增加资源的竞争压力。
第三种是破坏不可剥夺条件,我们可以允许系统强制剥夺线程占有的资源。这种方法通常用于操作系统层面,但在 Java 中并不常见,因为强制剥夺资源可能会导致数据不一致或复杂的恢复逻辑。
第四种是破坏循环等待条件,我们可以为资源分配一个全局的顺序编号,并要求线程按照固定的顺序申请资源。
这种方法可以有效避免循环等待,从而防止死锁的发生。
延伸
1.如何检测死锁
Java 提供了一些内置工具和 API,可以用来检测死锁。
(1) jstack 命令
jstack 是 JDK 自带的一个命令行工具,用于生成 Java 进程的线程快照。通过分析线程快照,可以发现死锁。
使用方法:
jstack <pid>
其中 <pid> 是目标 Java 进程的进程 ID。
输出示例:
如果存在死锁,jstack 会明确指出死锁的线程及其持有的锁信息。例如:
Found one Java-level deadlock:
=============================
"Thread-1":waiting to lock monitor 0x00007f8c00001234 (object 0x000000076b5c1234, a java.lang.Object),which is held by "Thread-0"
"Thread-0":waiting to lock monitor 0x00007f8c00005678 (object 0x000000076b5c5678, a java.lang.Object),which is held by "Thread-1"
优点:简单易用,适合快速定位死锁问题。可以查看线程的调用栈,帮助分析死锁的根本原因。
(2) ThreadMXBean API
ThreadMXBean 是 Java 提供的一个管理接口,可以通过编程方式检测死锁。
代码示例:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class DeadlockDetector {public static void main(String[] args) {ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null) {System.out.println("检测到死锁!");for (long threadId : deadlockedThreads) {ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);System.out.println("死锁线程:" + threadInfo.getThreadName());}} else {System.out.println("未检测到死锁。");}}
}
优点:可以集成到应用程序中,实现自动化的死锁检测。提供了更细粒度的线程信息,便于进一步分析。
十 synchronized 底层原理了解吗?
synchronized 用于保证多线程环境下的数据一致性。接下来我会详细讲述 synchronized 的定义和底层实现。
首先说一下什么是synchronized ,它是一种内置的锁机制,它可以作用于方法或代码块,用于控制多个线程对共享资源的访问。当一个线程进入 synchronized 保护的代码区域时,它会尝试获取锁;如果锁已被其他线程占用,则当前线程会被阻塞,直到锁被释放。锁的持有者在退出同步代码块或方法时会自动释放锁,从而允许其他线程继续执行。
接下来说一下 synchronized 的底层是如何实现的,它的依赖于 JVM 的监视器锁(Monitor)机制,每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取锁,会判断 monitor 的进入数是否为 0 ,如果为 0 则该线程进入monitor,然后将进入数设置为 1,该线程即为monitor的所有者;如果不为 0,说明已有线程占有该monitor,那么线程就会进入并处于阻塞状态,直到monitor的进入数为 0,才会重新尝试获取monitor的所有权。
退出同步代码块时,线程会执行 monitorexit,该线程必须是 objectref 所对应的 monitor 的所有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。
延伸
1.如何使用 synchronized?
(1)修饰实例方法
当 synchronized 修饰实例方法时,锁的对象是当前实例(即 this)。也就是说,同一时间只有一个线程可以访问该实例的同步方法。
使用场景:适用于需要对某个对象的实例方法进行同步控制的场景。
示例代码:
public class Counter {private int count = 0;
// synchronized 修饰实例方法public synchronized void increment() {count++;System.out.println(Thread.currentThread().getName() + " incremented count to: " + count);}
public synchronized int getCount() {return count;}
}
public class Main {public static void main(String[] args) {Counter counter = new Counter();
Runnable task = () -> {for (int i = 0; i < 5; i++) {counter.increment();}};
Thread t1 = new Thread(task, "Thread-1");Thread t2 = new Thread(task, "Thread-2");
t1.start();t2.start();}
}
特点:锁住的是当前实例对象(this)。不同实例之间的同步方法互不影响。
(2)修饰静态方法
当 synchronized 修饰静态方法时,锁的对象是类的 Class 对象(即 Counter.class)。这意味着所有线程在访问该类的静态同步方法时都会被同步控制。
使用场景:适用于需要对整个类的所有实例共享的静态资源进行同步控制的场景。
示例代码:
public class Counter {private static int count = 0;
// synchronized 修饰静态方法public static synchronized void increment() {count++;System.out.println(Thread.currentThread().getName() + " incremented count to: " + count);}
public static synchronized int getCount() {return count;}
}
public class Main {public static void main(String[] args) {Runnable task = () -> {for (int i = 0; i < 5; i++) {Counter.increment();}};
Thread t1 = new Thread(task, "Thread-1");Thread t2 = new Thread(task, "Thread-2");
t1.start();t2.start();}
}
特点:锁住的是类的 Class 对象(Counter.class)。所有线程在访问该类的静态同步方法时都会被阻塞。
(3)修饰代码块
当 synchronized 修饰代码块时,可以显式指定锁的对象。这种方式更加灵活,允许开发者选择具体的锁对象。
使用场景:适用于只需要对部分代码进行同步控制的场景。
示例代码:
public class Counter {private int count = 0;private final Object lock = new Object(); // 自定义锁对象
public void increment() {// synchronized 修饰代码块synchronized (lock) {count++;System.out.println(Thread.currentThread().getName() + " incremented count to: " + count);}}
public int getCount() {synchronized (lock) {return count;}}
}
public class Main {public static void main(String[] args) {Counter counter = new Counter();
Runnable task = () -> {for (int i = 0; i < 5; i++) {counter.increment();}};
Thread t1 = new Thread(task, "Thread-1");Thread t2 = new Thread(task, "Thread-2");
t1.start();t2.start();}
}
特点:可以显式指定锁对象(如 this 或其他自定义对象)。更加灵活,适合局部同步需求。
(4)总结对比
修饰方式 | 锁对象 | 适用场景 |
---|---|---|
修饰实例方法 | 当前实例对象(this) | 同步控制单个实例的方法调用。 |
修饰静态方法 | 类的 Class 对象(Counter.class) | 同步控制所有实例共享的静态资源。 |
修饰代码块 | 显式指定的锁对象(如 this 或自定义对象) | 灵活控制特定代码段的同步,减少锁的范围,提高性能。 |
2. synchronized 的优缺点
优点:简单易用,无需手动管理锁的获取和释放。底层经过多次优化,性能在大多数场景下已经足够高效。
缺点:在高并发场景下,重量级锁可能会导致性能瓶颈。不支持锁的中断或超时等高级功能(相比之下,ReentrantLock 提供了更灵活的锁机制)。
3.synchronized 关键字锁定的对象是什么?请解释其含义。
在 Java 中,synchronized
关键字是用于实现线程同步的,它通过锁定对象来控制多个线程对共享资源的访问。锁定的对象决定了线程同步的粒度,也决定了哪些线程可以进入临界区。
锁定对象的核心概念
-
锁定的对象是同步代码块或方法中指定的对象。
-
只有持有该对象锁的线程可以执行同步代码,其它线程必须等待,直到锁被释放。
-
锁的范围:
-
静态方法锁住的是类的 Class 对象。
-
非静态方法锁住的是当前实例对象。
-
同步代码块锁住的是指定的对象。
十一 synchronized和ReentrantLock的区别?
synchronized 和 ReentrantLock 是 Java 中实现线程同步的两种主要方式,它们都能保证多线程环境下的数据一致性,接下来我会详细讲述两者在基本概念、功能特性、性能表现以及锁的释放与异常处理上的区别。
第一个是基本概念上的区别,synchronized 是 Java 的内置关键字,它是隐式的,通过 JVM 提供的监视器锁机制实现同步,使用简单,无需手动管理锁的获取和释放;而 ReentrantLock 是 java.util.concurrent.locks 包中的一个类,它是显式的,提供了更灵活的锁机制,需要开发者手动调用 lock() 和 unlock() 方法来控制锁的生命周期。
第二个是功能特性上的区别,ReentrantLock 提供了比 synchronized 更丰富的功能,比如:ReentrantLock 支持在等待锁的过程中响应中断,而 synchronized 不支持中断;还有ReentrantLock 提供了 tryLock() 方法,允许线程尝试获取锁并在指定时间内返回结果,而 synchronized 必须一直等待锁释放。
第三个是性能上的区别,synchronized 和 ReentrantLock 在不同场景下各有优势。
对于低竞争场景,由于synchronized 经过多次优化(如偏向锁、轻量级锁),一般与 ReentrantLock 相当甚至更好。
对于高竞争场景,ReentrantLock 提供了更多的灵活性(如公平锁、可中断锁等),更适合复杂需求。
第四个是锁的释放与异常处理上的区别,synchronized 在退出同步代码块时会自动释放锁,即使发生异常也不会导致死锁;而ReentrantLock 需要开发者手动调用 unlock() 方法释放锁,因此必须在 finally 块中确保锁的释放,否则可能导致死锁。
延伸
1.锁的状态与升级过程
(1)锁的状态
在Java中,synchronized关键字和ReentrantLock等锁机制都涉及锁的状态管理。锁的状态通常可以分为以下几种:
无锁状态(Unlocked):当一个对象或资源没有被任何线程持有锁时,它处于无锁状态。此时,多个线程可以自由访问该资源。
偏向锁(Biased Locking):偏向锁是一种优化机制,用于减少无竞争情况下的同步开销。当一个线程第一次获取锁时,JVM会将锁标记为偏向该线程,并记录线程ID。如果后续该线程再次尝试获取锁,无需进行额外的同步操作,直接判断线程ID是否匹配即可。偏向锁适用于只有一个线程访问同步块的场景。
轻量级锁(Lightweight Locking):当有第二个线程尝试获取已经被偏向的锁时,偏向锁会升级为轻量级锁。轻量级锁通过CAS(Compare-And-Swap)操作来尝试获取锁。如果CAS操作成功,则线程获取锁;如果失败,则进入自旋等待状态,尝试多次获取锁。
重量级锁(Heavyweight Locking):当多个线程竞争锁且自旋等待无法快速获取锁时,轻量级锁会升级为重量级锁。重量级锁会将未获取锁的线程挂起(进入阻塞状态),并由操作系统调度。这种方式会带来较大的性能开销,因为线程的挂起和唤醒需要上下文切换。
(2)锁的升级过程
锁的升级过程是一个从低开销到高开销的逐步演化过程,目的是在不同竞争程度下选择最优的锁实现。以下是锁升级的具体流程:
初始状态:无锁,对象刚创建时,没有任何线程竞争锁,处于无锁状态。
偏向锁,第一个线程尝试获取锁时,JVM会将锁标记为偏向锁,并记录线程ID。后续该线程再次尝试获取锁时,只需检查线程ID是否匹配,无需额外操作。
轻量级锁,当第二个线程尝试获取锁时,偏向锁失效,升级为轻量级锁。轻量级锁通过CAS操作尝试获取锁。如果CAS操作失败,线程会进入自旋状态,反复尝试获取锁。
重量级锁,如果自旋一定次数后仍然无法获取锁,或者系统检测到锁竞争激烈,轻量级锁会升级为重量级锁。重量级锁会将未获取锁的线程挂起,避免CPU资源浪费。
(3)锁升级的意义
锁升级的核心目的是在不同的竞争场景下平衡性能和资源消耗:
偏向锁:适合单线程频繁访问的场景,减少同步开销。
轻量级锁:适合少量线程竞争的场景,利用CAS和自旋提高效率。
重量级锁:适合高竞争场景,避免线程长时间占用CPU资源。
(4)锁降级
需要注意的是,锁的升级是单向的,即从无锁 → 偏向锁 → 轻量级锁 → 重量级锁。一旦锁升级为重量级锁,就不会再降级为轻量级锁或偏向锁。
十二 什么是乐观锁?
乐观锁是一种并发控制机制,接下来我会详细讲述乐观锁的定义、实现方式、特点以及它的适用场景。
首先说一下什么是乐观锁,它是一种基于“无锁”思想的并发控制机制。它假设多线程操作之间很少发生冲突,因此在读取数据时不会加锁,而是通过某种机制(如版本号或时间戳)来检测数据是否被其他线程修改过。如果检测到数据未被修改,则提交更新;如果检测到数据已被修改,则根据策略进行处理(如重试或抛出异常)。
接下来说一下乐观锁的实现方式,乐观锁的实现通常依赖于以下两种机制:
一种是版本号机制:为数据添加一个版本号字段,每次更新时递增版本号,并在更新时验证版本号是否匹配。
另一种是CAS 操作:使用比较并交换(Compare-And-Swap)指令,直接在硬件层面实现无锁操作。CAS 操作包含内存位置(V)、预期值(A)和新值(B)这三个参数。只有当内存位置的值等于预期值时,才会将内存位置的值更新为新值。
然后再说一下乐观锁的特点,一共有三个。
第一个是无锁设计,乐观锁不依赖传统的锁机制,减少了线程阻塞和上下文切换的开销。
第二个是性能好,在低冲突场景下,乐观锁的性能优于悲观锁,因为它避免了锁的竞争。
第三个是支持冲突检测,乐观锁通过版本号或 CAS 操作检测冲突,但需要开发者显式处理冲突(如重试或回滚),这可能会增加代码的复杂性。
最后说一下乐观锁的适用场景,一般有三种场景比较适合。
第一种是读多写少的场景,例如缓存系统、统计计数器等,读操作远多于写操作,冲突概率较低。
第二种是在分布式环境中,乐观锁可以通过版本号或时间戳实现跨节点的数据一致性。
第三种是高并发环境,在高并发场景下,乐观锁可以减少锁的竞争,从而提高系统的吞吐量。
需要注意的是,乐观锁并不适合写操作频繁或冲突概率较高的场景,因为频繁的冲突会导致大量的重试操作,反而降低性能。
拓展:
1.实现方式
版本号机制 :每本书都有一个版本号,每次借阅时版本号递增。如果读者 A 想借书时发现版本号变了,说明书已被借走。
CAS 操作 :系统直接检查书的状态(内存位置),只有当状态符合预期(未被借走)时,才会完成借阅操作。
特点 :
无锁设计 :图书馆没有物理锁,减少了等待时间。
性能好 :在借阅人数少的情况下,系统运行非常高效。
冲突检测 :如果书被借走,系统会提示读者重试,但需要开发者处理这种情况。
适用场景 :
读多写少 :图书馆里大多数时候是读者查阅书籍,而不是借阅。
分布式环境 :多个图书馆分馆可以通过版本号保持书籍状态一致。
高并发场景 :在高峰期,系统能快速处理大量查询请求。
2.CAS 算法的主要问题
(1)ABA 问题
问题描述:
在 CAS 操作中,如果一个变量的值从 A 变为 B,然后又变回 A,那么 CAS 操作会认为该变量没有被修改过,从而成功执行更新操作。但实际上,变量的值可能已经被其他线程修改过。
示例:
假设有两个线程操作同一个变量 value:
初始值:value = A
线程1读取 value 的值为 A。
线程2将 value 修改为 B,然后再改回 A。
线程1尝试使用 CAS 更新 value,发现当前值仍然是 A,于是成功更新。
尽管最终值看起来没有变化,但中间的状态已经被修改过,这可能导致逻辑错误。
解决方案:
引入版本号:使用带有版本号的原子类(如 AtomicStampedReference),在每次修改时增加版本号,确保可以检测到中间状态的变化。
使用其他数据结构:例如使用不可变对象或链表等数据结构,避免直接修改共享变量。
(2)循环时间长开销大
问题描述:
当多个线程竞争同一个资源时,CAS 操作可能会失败多次。在这种情况下,线程通常会进入自旋(即不断重试 CAS 操作),直到成功为止。这种自旋操作会消耗大量的 CPU 资源,尤其是在高并发场景下。
示例:
假设多个线程同时尝试对一个共享变量进行 CAS 操作:
AtomicInteger atomicInt = new AtomicInteger(0);
// 线程1和线程2都尝试将值从0改为1
while (!atomicInt.compareAndSet(0, 1)) {// 自旋等待
}
如果竞争激烈,线程可能需要多次尝试才能成功,导致 CPU 使用率飙升。
解决方案:
限制自旋次数:设置最大重试次数,超过后切换到阻塞模式(如使用 Lock)。
使用更高级的同步机制:例如 ReentrantLock 或 Semaphore,它们可以在竞争激烈时挂起线程,减少 CPU 开销。
(3)只能保证一个共享变量的原子操作
问题描述:
CAS 操作只能针对单个变量进行原子操作。如果需要对多个变量进行原子操作,CAS 本身无法直接支持。
示例:
假设有两个共享变量 x 和 y,需要同时更新它们的值:
AtomicInteger x = new AtomicInteger(0);
AtomicInteger y = new AtomicInteger(0);
// 需要同时更新 x 和 y
x.incrementAndGet();
y.incrementAndGet();
上述代码中,x 和 y 的更新并不是原子性的,可能存在线程安全问题。
解决方案:
使用锁:通过显式锁(如 ReentrantLock)或内置锁(synchronized)来保证多个变量的原子性。
封装成对象:将多个变量封装到一个对象中,使用 AtomicReference 对整个对象进行 CAS 操作。
事务性内存:某些高级并发框架(如 STM)提供了对多个变量的原子性支持。
(4)其他潜在问题
除了上述三个主要问题外,CAS 还可能存在以下问题:
性能瓶颈:在低竞争场景下,CAS 性能很高;但在高竞争场景下,由于频繁的自旋和重试,性能会显著下降。可以结合锁或其他同步机制,根据竞争程度动态选择合适的同步策略。
缺乏公平性:CAS 是一种非公平的同步机制,先到的线程可能因为竞争失败而被后来的线程抢占资源。可以使用公平锁(如 ReentrantLock 的公平模式)来保证线程调度的公平性。
复杂性增加:使用 CAS 时,开发者需要手动处理重试逻辑、版本号等问题,增加了代码的复杂性。尽量使用更高层的并发工具(如 ConcurrentHashMap、CopyOnWriteArrayList 等),减少手动实现 CAS 的需求。
十三 什么是线程上下文切换?
线程上下文切换是多线程编程中的一个概念,它直接影响程序的性能和效率。接下来我会详细讲述线程上下文切换的定义、发生时机、过程和影响。
首先讲一下什么是线程上下文切换,它是指当 CPU 从一个线程切换到另一个线程时,操作系统需要保存当前线程的执行状态,并加载下一个线程的执行状态,以便它们能够正确地继续运行。执行状态主要包括:寄存器状态、程序计数器(PC)、栈信息、线程的优先级等。
接下来讲一下发生时机,通常有四种情况会发生线程上下文切换。
第一种是时间片耗尽,操作系统为每个线程分配了一个时间片,当线程的时间片用完后,操作系统会强制切换到其他线程,这是为了保证多个线程能够公平地共享 CPU 资源。
第二种是线程主动让出 CPU,当线程调用了某些方法,如 Thread.sleep()、Object.wait() 或 LockSupport.park()等,会使线程主动让出 CPU,导致上下文切换。
第三种是调用了阻塞类型的系统中断,比如:线程执行 I/O 操作时,由于 I/O 操作通常需要等待外部资源,线程会被挂起,会触发上下文切换。
第四种是被终止或结束运行。
然后再讲一下线程上下文切换的过程,分为四步。
第一步是保存当前线程的上下文,将当前线程的寄存器状态、程序计数器、栈信息等保存到内存中。
第二步是根据线程调度算法,如:时间片轮转、优先级调度等,选择下一个要运行的线程。
第三步是加载下一个线程的上下文,从内存中恢复所选线程的寄存器状态、程序计数器和栈信息。
第四步是 CPU 开始执行被加载的线程的代码。
最后讲一下线程上下文切换所带来的影响。线程上下文切换虽然能够实现多任务并发执行,但它也会带来 CPU 时间消耗、缓存失效以及资源竞争等问题。为了减少线程上下文切换带来的性能损失,可以采取减少线程数量、使用无锁数据结构等方式进行优化。
延伸
1.线程上下文切换的影响
然后,线程上下文切换虽然能够实现多任务并发执行,但它也会带来一定的开销:
CPU 时间消耗 :保存和恢复上下文需要额外的 CPU 周期,这会降低系统的整体性能。
缓存失效 :每次切换线程时,CPU 缓存中的数据可能会失效,导致下一线程需要重新加载数据,进一步增加延迟。
资源竞争 :如果线程数量过多,频繁的上下文切换会导致系统资源的竞争加剧,从而降低吞吐量。
2.如何减少线程上下文切换的影响?
最后,为了减少线程上下文切换带来的性能损失,可以采取以下优化措施:
减少线程数量 :合理控制线程池的大小,避免创建过多的线程。
使用无锁数据结构 :通过 CAS(Compare-And-Swap)等无锁操作减少线程间的锁竞争。
协程或异步编程 :使用轻量级的协程或异步模型(如 Java 的 CompletableFuture
或 Kotlin 的协程)替代传统的线程模型,减少上下文切换的频率。
优化线程调度策略 :根据任务的特点选择合适的线程调度算法,例如优先处理高优先级任务。