多线程(1)
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
简述线程,程序、进程的基本概念。以及他们之间关系是什么?
一、基本概念
1. 程序(Program)
程序是静态的 计算机指令和数据的集合,通常以可执行文件的形式存储在磁盘或其他存储介质中(如Windows的.exe、Linux的.out)。它是一组预先编写好的代码,规定了计算机需要执行的操作逻辑,但未运行时仅占用存储空间,不占用系统资源(如CPU、内存)。例如,用户编写的C/C++代码经编译后生成的二进制文件即为程序。
2. 进程(Process)
进程是程序的一次动态执行过程,是操作系统进行资源分配和调度的基本单位。当程序被加载到内存并启动运行时,操作系统会为其分配独立的内存空间(如代码段、数据段、堆、栈)、文件句柄、CPU时间片等资源,并为其创建一个进程控制块(PCB)来记录其运行状态(如就绪、运行、阻塞)。每个进程都是独立的,拥有自己的地址空间,进程间互不干扰(除非通过特定机制通信)。例如,打开一个Word文档会启动一个Word进程,播放音乐会启动一个音乐播放器进程。
3. 线程(Thread)
线程是进程内的最小执行单元,是进程中的一个"子任务"。一个进程可以包含多个线程(多线程),这些线程共享进程的资源(如内存空间、文件句柄),但拥有自己独立的栈空间和程序计数器(PC),用于记录当前执行的位置。线程的创建、切换和销毁开销远小于进程(无需复制整个地址空间),因此多线程技术可显著提升程序的并发执行效率。例如,浏览器的一个进程中可能包含渲染页面、下载资源、响应用户点击等多个线程。
二、三者关系
1. 程序与进程
- 程序是静态的代码文件 ,进程是程序的动态运行实例。
- 程序是进程的"模板",进程是程序的"执行过程"。没有程序则无法创建进程,但一个程序可以被多次执行,生成多个独立的进程(如同时打开两个Word窗口,对应两个Word进程)。
2. 进程与线程
- 进程是资源分配的基本单位 (操作系统为进程分配内存、文件等资源),线程是CPU调度的基本单位(操作系统调度线程执行具体的指令)。
- 一个进程至少包含一个线程(主线程),也可以包含多个线程(多线程)。
- 同一进程内的线程共享进程的资源(如内存、文件句柄),因此线程间通信(IPC)更高效(如直接访问共享变量);但线程间需协调对共享资源的访问(如通过互斥锁、信号量避免竞态条件)。
- 不同进程的资源相互独立,进程间通信(IPC)需通过操作系统提供的机制(如管道、消息队列、共享内存)。
3. 总结
程序是静态的代码集合,进程是程序的动态运行实体,而线程是进程内的细粒度执行单元。三者的关系可概括为:
程序 → 进程(静态→动态)→ 线程(进程内的并发执行单元)。
多线程技术通过在一个进程内创建多个线程,实现了任务的并发执行,同时避免了多进程的高开销,是现代高性能程序(如服务器、图形界面应用)的核心技术之一。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
线程有哪些基本状态?
一、Java线程的基本状态及生命周期
Java线程在生命周期中共有6种基本状态,这些状态由java.lang.Thread.State枚举定义,状态之间的转换由线程的行为(如方法调用、系统调度)触发。以下是各状态的详细说明及转换关系:
1. 新建状态(NEW)
- 定义 :线程被创建但尚未调用
start()方法,此时线程对象已实例化,但未进入线程调度队列。 - 触发条件 :通过
new Thread()创建线程实例后,未调用start()。
2. 可运行状态(RUNNABLE)
- 定义 :线程已获得CPU执行资格,可能正在执行(
RUNNING)或等待CPU调度(READY)。- RUNNING:线程正在CPU上执行。
- READY:线程已准备好运行,等待操作系统分配CPU时间片。
- 触发条件 :
- 调用
start()后进入此状态。 - 从
BLOCKED、WAITING、TIMED_WAITING状态恢复时,若竞争到CPU则进入RUNNING,否则进入READY。
- 调用
3. 阻塞状态(BLOCKED)
- 定义:线程因尝试获取已被占用的锁而暂时无法执行,处于等待锁的状态。
- 触发条件 :
- 执行
synchronized同步代码块/方法时,目标锁被其他线程占用。 - 调用
Lock接口的lock()方法时未获取到锁(非公平锁场景)。
- 执行
4. 等待状态(WAITING)
- 定义:线程无限期等待其他线程显式唤醒,否则永远不参与CPU调度。
- 触发条件 :
- 调用
Object.wait()、Thread.join()、LockSupport.park()方法。
- 调用
- 唤醒方式 :
- 其他线程调用
Object.notify()/notifyAll()、LockSupport.unpark(Thread)。
- 其他线程调用
5. 超时等待状态(TIMED_WAITING)
- 定义 :线程等待指定时间后自动唤醒(即使未被显式唤醒),是
WAITING的限时版本。 - 触发条件 :
- 调用带超时参数的方法,如
Thread.sleep(long)、Object.wait(long)、LockSupport.parkNanos()、LockSupport.parkUntil()。
- 调用带超时参数的方法,如
6. 终止状态(TERMINATED)
- 定义 :线程执行完毕或因异常退出
run()方法,生命周期结束。 - 触发条件 :
run()方法正常执行完毕。run()方法抛出未捕获的异常。
总结
Java线程状态转换是并发编程的基础,理解各状态的触发条件和转换逻辑有助于编写高效、无死锁的代码。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
如何理解内存泄漏问题?有哪些情况会导致内存泄露?如何解决?
一、内存泄漏的核心定义与本质
内存泄漏(Memory Leak)是指 程序中已不再被使用的对象(无用对象),因被存活对象的强引用链持续持有,导致垃圾回收器(GC)无法回收其内存空间 的现象。
关键背景:Java的垃圾回收机制
Java通过GC自动管理内存,其核心规则是"可达性分析":从一组称为"GC Roots"的根对象(如栈帧中的局部变量、静态变量、JNI引用等)出发,所有能被这些根直接或间接引用的对象都被标记为"存活对象",无法被回收;反之,未被引用的对象会被标记为"可回收对象",在GC时被清理。
因此,内存泄漏的本质是:无用对象(本应被回收)因被GC Roots的引用链间接持有,无法被GC识别为"可回收对象" ,导致内存被持续占用,最终可能引发OutOfMemoryError(内存溢出)。
二、内存泄漏的典型触发场景与原理
场景1:静态集合类持有强引用
问题描述 :静态变量的生命周期与JVM进程一致(全局存活),若静态集合(如static Map)未主动清理其中的对象,即使这些对象在业务逻辑中已不再使用,仍会被静态集合的强引用链持续持有,无法被GC回收。
示例代码:
java
public class StaticCache {
private static final Map<String, Object> CACHE = new HashMap<>(); // 静态集合,全局存活
public void addToCache(String key, Object value) {
CACHE.put(key, value); // 对象被静态集合持有
}
// 未提供清理方法!
}
原理 :CACHE作为静态变量,其引用链指向JVM的方法区(元空间),因此其中的所有value对象都会被标记为"存活",即使业务逻辑中已不再需要它们。
场景2:未关闭的资源(IO、数据库连接等)
问题描述 :Java中部分资源(如FileInputStream、Connection、Socket)内部可能持有堆外内存(如操作系统文件句柄)或系统资源。若未显式关闭这些资源,即使对象本身被回收,其关联的堆外资源也无法释放,且可能导致资源耗尽(如文件句柄泄漏)。
示例代码:
java
public void readFile() throws IOException {
FileInputStream fis = new FileInputStream("data.txt"); // 打开文件流
// ...读取文件内容...
// 未调用fis.close()!
}
// fis变量超出作用域后被回收,但文件流的底层句柄未被释放,导致资源泄漏
原理 :FileInputStream的close()方法负责释放操作系统的文件句柄。若未调用close(),句柄会一直被占用,最终导致"Too many open files"错误。
场景3:监听器/回调未注销
问题描述 :当对象注册监听器(如Swing的事件监听器、Spring的ApplicationListener)或回调函数时,监听器通常会被长生命周期对象(如单例、容器)持有。若未主动注销监听器,即使原对象已不再使用,监听器仍会被长生命周期对象引用,导致原对象无法被回收。
示例代码:
java
public class EventManager {
private List<EventListener> listeners = new ArrayList<>(); // 长生命周期列表
public void register(EventListener listener) {
listeners.add(listener); // 注册监听器
}
// 未提供unregister方法!
}
public class MyListener implements EventListener {
private LargeObject data; // 大对象
public MyListener() {
this.data = new LargeObject(1024 * 1024); // 分配大内存
}
}
// 使用场景:
EventManager manager = new EventManager();
MyListener listener = new MyListener();
manager.register(listener);
listener = null; // 原监听器变量置空,但manager.listeners仍持有MyListener实例
原理 :EventManager的listeners列表是长生命周期对象,持有MyListener的强引用。即使listener变量被置空,MyListener实例仍被manager引用,无法被GC回收,连带其持有的LargeObject也无法释放。
场景4:非静态内部类隐式持有外部类引用
问题描述:Java的非静态内部类(如普通内部类、匿名内部类)会隐式持有外部类的强引用。若内部类的实例被长生命周期对象(如静态集合)持有,外部类实例也会被间接持有,导致外部类无法被回收(即使外部类已无其他引用)。
示例代码:
java
public class Outer {
private byte[] largeData = new byte[1024 * 1024]; // 外部类的大对象
class Inner { // 非静态内部类,隐式持有Outer.this引用
public void printData() {
System.out.println(largeData.length); // 访问外部类成员
}
}
}
// 使用场景:
List<Inner> innerList = new ArrayList<>();
Outer outer = new Outer();
innerList.add(outer.new Inner()); // Inner实例被静态列表持有
outer = null; // 外部类变量置空,但innerList中的Inner实例仍持有Outer.this引用
原理 :Inner实例的引用链为 innerList → Inner实例 → Outer实例。即使outer变量被置空,Inner实例仍通过隐式引用持有Outer实例,导致Outer及其largeData无法被回收。
场景5:缓存未清理或策略不当
问题描述 :缓存(如HashMap、Guava Cache)用于存储高频访问的数据以提升性能,但如果未设置过期时间、容量限制或清理策略,缓存会无限增长,导致无用对象持续占用内存。
示例代码:
java
public class SimpleCache {
private static final Map<String, Object> cache = new HashMap<>(); // 无界缓存
public void put(String key, Object value) {
cache.put(key, value); // 缓存无限添加,无淘汰机制
}
}
原理 :随着时间推移,cache中会积累大量不再使用的键值对,但由于没有清理逻辑,这些对象会被持续保留,最终导致内存溢出。
场景6:线程池使用不当(无界队列)
问题描述 :Java的Executors.newFixedThreadPool默认使用无界队列(如LinkedBlockingQueue)。若任务提交速度远快于处理速度,队列会无限堆积任务对象,导致内存被占满。
示例代码:
java
ExecutorService executor = Executors.newFixedThreadPool(2); // 底层使用无界队列
while (true) {
executor.execute(() -> {
try {
Thread.sleep(1000); // 模拟耗时任务
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
原理 :无界队列(LinkedBlockingQueue)没有容量限制,任务会持续堆积。若每个任务占用内存较大(如携带大对象),最终会导致OutOfMemoryError。
场景7:JNI本地方法未释放内存
问题描述 :通过JNI(Java Native Interface)调用C/C++本地方法时,若本地代码分配了堆外内存(如malloc)但未释放(未调用free),会导致Java对象与本地内存的循环引用,GC无法回收本地内存,最终导致内存泄漏。
三、内存泄漏的检测与解决方法
(一)检测工具
要解决内存泄漏,首先需要定位泄漏点。常用工具包括:
- JDK自带工具 :
jconsole/jvisualvm:可视化监控堆内存使用情况,观察是否有内存持续增长。jmap -histo:live <pid>:查看存活对象的统计(类名、数量、内存占用)。jmap -dump:format=b,file=heap.bin <pid>:生成堆转储文件(.hprof),用于离线分析。
- 专业分析工具 :
- Eclipse MAT(Memory Analyzer Tool):自动分析堆转储文件,定位大对象、引用链,生成泄漏报告。
- JProfiler:商业工具,实时监控对象生命周期、引用关系,定位泄漏源。
- YourKit:类似JProfiler,支持深度堆分析和性能瓶颈诊断。
(二)具体解决方法
针对不同场景的内存泄漏,需采取针对性措施:
1. 避免静态集合滥用
- 原则:静态集合仅用于存储真正需要全局共享且长期存活的对象,避免存储业务临时对象。
- 优化方案 :
- 若需缓存临时对象,使用
WeakHashMap(键为弱引用,无强引用时键值对可被GC回收)。 - 定期清理静态集合(如定时任务调用
clear()方法)。
- 若需缓存临时对象,使用
示例优化:
java
// 使用WeakHashMap替代普通HashMap
private static final Map<String, Object> CACHE = new WeakHashMap<>();
2. 及时关闭资源
- 原则 :所有实现了
AutoCloseable接口的资源(如InputStream、Connection)必须通过try-with-resources或finally块显式关闭。 - 优化方案 :
- 使用Java 7+的
try-with-resources语法自动关闭资源。 - 对于未实现
AutoCloseable的资源(如自定义资源),在finally块中手动调用关闭方法。
- 使用Java 7+的
示例优化:
java
// try-with-resources自动关闭流
try (FileInputStream fis = new FileInputStream("data.txt")) {
// 读取文件内容
} // 自动调用fis.close()
3. 注销监听器与回调
- 原则 :注册监听器时,必须提供对应的注销方法(如
unregisterListener),确保不再使用时移除引用。 - 优化方案 :
- 在长生命周期对象(如单例)中维护监听器列表,并提供
removeXXXListener方法。 - 使用弱引用存储监听器(如
WeakReference<EventListener>),避免监听器被意外持有。
- 在长生命周期对象(如单例)中维护监听器列表,并提供
示例优化:
java
public class EventManager {
private List<WeakReference<EventListener>> listeners = new ArrayList<>(); // 弱引用存储监听器
public void register(EventListener listener) {
listeners.add(new WeakReference<>(listener)); // 弱引用,无强引用时监听器可被回收
}
public void unregister(EventListener listener) {
listeners.removeIf(ref -> ref.get() == listener); // 移除指定监听器
}
}
4. 避免非静态内部类持有外部类引用
- 原则 :若内部类不需要访问外部类的实例成员,应声明为
static(静态内部类)。 - 优化方案 :
- 将非必要的内部类改为静态内部类。
- 若必须使用非静态内部类,确保其生命周期不超过外部类。
示例优化:
java
public class Outer {
private byte[] largeData = new byte[1024 * 1024];
static class StaticInner { // 静态内部类,不持有Outer引用
public void doSomething() {
// 无法直接访问Outer的实例成员(如largeData)
}
}
}
5. 合理设计缓存策略
- 原则:缓存需设置容量限制、过期时间或淘汰策略(如LRU、LFU)。
- 优化方案 :
- 使用
Caffeine、Guava Cache等成熟缓存库,替代手动实现的HashMap。 - 对于需要长期存活的缓存,定期清理无效数据(如过期键值对)。
- 使用
示例优化(Caffeine):
java
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1000) // 最大容量1000
.expireAfterAccess(10, TimeUnit.MINUTES) // 10分钟无访问则过期
.build();
6. 避免死循环与无界数据结构
- 原则 :禁止在循环中无限制地向集合添加元素(如死循环调用
List.add())。 - 优化方案 :
- 对循环添加元素的逻辑添加终止条件(如达到最大容量)。
- 使用有界队列(如
ArrayBlockingQueue)替代无界队列(如LinkedBlockingQueue)。
7. 监控与预防
- 日常监控:通过APM工具(如Prometheus+Grafana)监控应用的内存使用率、GC频率,发现异常增长及时排查。
- 代码审查:在代码评审中重点检查静态集合、资源关闭、监听器注册等高风险点。
四、总结
内存泄漏是Java应用中常见的性能问题,其本质是无用对象被存活对象的强引用链持续持有。通过理解GC可达性分析规则,结合常见场景(如静态集合、未关闭资源、监听器未注销等),可以针对性地预防和解决泄漏问题。关键实践包括:合理使用弱引用、及时关闭资源、注销监听器、优化缓存策略,以及借助工具(如MAT、JProfiler)定位泄漏点。通过代码规范和持续监控,可有效避免内存泄漏,保障应用的长期稳定性。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
线程池的原理,为什么要创建线程池?创建线程池的方式
一、线程池核心原理与组件详解
线程池的核心设计思想是**"复用线程+任务队列+资源控制"**,通过预先创建线程并重复利用它们处理任务,避免频繁创建/销毁线程的开销,同时通过任务队列和饱和策略控制资源使用。以下结合具体案例展开说明。
案例背景:模拟一个电商平台的"秒杀活动"请求处理场景
假设某电商平台在"双11"期间发起秒杀活动,预计短时间内会有1000个用户请求(任务)涌入。若直接为每个请求创建新线程处理,可能导致:
- 线程创建/销毁开销过大(每个线程需分配约1MB栈空间,1000个线程需约1GB内存)。
- 大量线程竞争CPU资源,导致上下文切换频繁(CPU利用率下降)。
- 若请求量超过系统承载能力(如CPU最多同时处理200个任务),可能导致系统崩溃。
此时,线程池是更优选择:通过限制最大线程数(如200)、使用任务队列缓冲请求,可有效控制资源使用,保证系统稳定。
二、线程池核心组件与案例对应关系
1. 核心线程(Core Threads)------ 长期驻守的"常备军"
- 定义 :线程池中始终存活的线程,即使空闲也不会被销毁(除非设置
allowCoreThreadTimeOut(true))。 - 案例对应:秒杀活动中,即使没有请求,也需要保留一定数量的线程(如50个)随时待命,避免请求到来时重新创建线程的延迟。
2. 最大线程数(Maximum Pool Size)------ 临时扩军的"预备队"
- 定义:线程池允许创建的最大线程数。当任务队列已满且当前线程数未达上限时,线程池会创建非核心线程处理任务。
- 案例对应:当秒杀请求量激增(如1000个请求),核心线程(50个)全忙,任务队列(假设容量300)已满,线程池会创建最多150个非核心线程(总线程数=50+150=200)处理剩余请求。
3. 任务队列(Work Queue)------ 缓冲请求的"候车室"
- 定义:存放待执行任务的阻塞队列。当核心线程全忙时,新任务会先进入队列等待,而非立即创建新线程。
- 案例对应:若瞬间涌入1000个请求,核心线程(50个)只能处理50个,剩余950个请求会先进入队列(假设队列容量300),队列填满后才会创建非核心线程。
4. 饱和策略(Rejected Execution Handler)------ 应对"超员"的"应急方案"
- 定义:当任务队列已满且线程数已达最大值时,对新任务的处理策略。
- 案例对应:若秒杀请求量超过200个(最大线程数)+300(队列容量)=500个,第501个请求将根据策略处理(如丢弃或由调用者线程执行)。
三、线程池任务处理流程(结合代码案例)
以下通过一个模拟秒杀请求处理的代码案例,演示线程池的任务处理流程。
案例代码:秒杀请求处理线程池
java
import java.util.concurrent.*;
public class SeckillThreadPoolDemo {
// 模拟秒杀任务(耗时操作)
static class SeckillTask implements Runnable {
private final int taskId;
public SeckillTask(int taskId) {
this.taskId = taskId;
}
@Override
public void run() {
try {
System.out.printf("线程 %s 开始处理秒杀任务 %d\n", Thread.currentThread().getName(), taskId);
Thread.sleep(100); // 模拟处理耗时(如数据库操作、库存校验)
System.out.printf("线程 %s 完成秒杀任务 %d\n", Thread.currentThread().getName(), taskId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
// 创建线程池(核心线程50,最大线程200,队列容量300,空闲线程存活60秒,饱和策略为CallerRunsPolicy)
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
50, // corePoolSize: 核心线程50个
200, // maximumPoolSize: 最大线程200个
60, // keepAliveTime: 非核心线程空闲60秒后销毁
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(300), // workQueue: 任务队列容量300
new ThreadPoolExecutor.CallerRunsPolicy() // 饱和策略:调用者线程执行
);
// 模拟1000个秒杀请求
for (int i = 0; i < 1000; i++) {
final int taskId = i;
threadPool.execute(new SeckillTask(taskId));
}
// 关闭线程池(实际生产环境中需根据业务需求调整)
threadPool.shutdown();
}
}
任务处理流程解析(结合代码输出)
- 前50个任务 :线程池核心线程(50个)空闲,直接创建线程处理,输出:
线程 pool-1-thread-1 开始处理秒杀任务 0
线程 pool-1-thread-2 开始处理秒杀任务 1
... - 第51-350个任务:核心线程全忙(50个),任务进入队列(容量300),队列未满时线程池等待队列中的任务被处理。
- 第351-500个任务:队列已满(300个),线程池创建非核心线程(最多200-50=150个),处理剩余任务。此时总线程数=50(核心)+150(非核心)=200。
- 第501-1000个任务 :线程池已达最大线程数(200)且队列已满(300),触发饱和策略
CallerRunsPolicy,由调用者线程(主线程)直接执行任务。输出:
主线程 开始处理秒杀任务 501(主线程被阻塞,直到任务完成)
四、线程池源码关键方法深度解析(结合案例)
1. execute()方法:任务提交的核心逻辑
线程池通过execute(Runnable command)方法提交任务,其核心逻辑如下(简化版):
java
public void execute(Runnable command) {
if (command == null) throw new NullPointerException();
// 获取运行状态和线程数
int c = ctl.get();
// 1. 核心线程未满:尝试创建核心线程
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) // 创建核心线程并执行任务
return;
c = ctl.get(); // 重新获取状态(可能被其他线程修改)
}
// 2. 核心线程已满,任务入队
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 入队后检查线程池是否停止,若停止则移除任务并拒绝
if (!isRunning(recheck) && remove(command))
reject(command);
// 若线程数为0(核心线程被回收),创建非核心线程执行队列中的任务
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 3. 任务队列已满,尝试创建非核心线程
else if (!addWorker(command, false))
// 4. 非核心线程也无法创建,触发饱和策略
reject(command);
}
案例对应:
- 当提交第51个任务时,核心线程(50个)已满,任务被加入队列(
workQueue.offer(command))。 - 当提交第351个任务时,队列已满(300个),尝试创建非核心线程(
addWorker(command, false))。 - 当提交第501个任务时,线程池已达最大线程数(200)且队列已满,触发
CallerRunsPolicy(reject(command)调用AbortPolicy默认实现,但此处策略被设置为CallerRunsPolicy)。
2. Worker类:线程的"封装者"
Worker是线程池内部类,继承自AbstractQueuedSynchronizer(AQS),用于封装线程和任务。其run()方法循环从队列中获取任务执行:
java
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
final Thread thread; // 工作线程
Runnable firstTask; // 初始任务(可选)
Worker(Runnable firstTask) {
setState(-1); // 初始状态为未启动
this.thread = getThreadFactory().newThread(this); // 创建线程
this.firstTask = firstTask; // 初始任务(如核心线程的第一个任务)
}
@Override
public void run() {
runWorker(this); // 核心逻辑:循环获取任务执行
}
// 从队列中获取任务(阻塞或超时)
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask; // 初始任务
w.firstTask = null;
try {
while (task != null || (task = getTask()) != null) { // 循环获取任务
try {
task.run(); // 执行任务
} finally {
task = null; // 清空任务引用
}
}
} finally {
workerDone(this); // 线程退出,更新线程池状态
}
}
}
关键方法getTask():
- 从任务队列中获取任务,若队列为空则阻塞等待(
workQueue.take())。 - 若线程池已停止或线程数超限(非核心线程空闲超时),返回
null触发线程销毁。
五、Callable、Future与异步任务(结合案例)
案例背景:模拟"商品库存校验"异步任务
秒杀活动中,需先校验商品库存(耗时操作),再处理订单。若直接同步校验,会导致用户等待时间过长。使用Callable和Future可实现异步校验,提升响应速度。
代码示例:异步库存校验
java
import java.util.concurrent.*;
public class FutureDemo {
// 模拟库存校验(耗时操作)
static class StockCheckTask implements Callable<Boolean> {
private final String productId;
public StockCheckTask(String productId) {
this.productId = productId;
}
@Override
public Boolean call() throws Exception {
System.out.printf("开始校验商品 %s 的库存...\n", productId);
Thread.sleep(2000); // 模拟数据库查询耗时
boolean inStock = Math.random() > 0.3; // 70%概率库存充足
System.out.printf("商品 %s 库存校验结果:%s\n", productId, inStock ? "充足" : "不足");
return inStock;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executor = Executors.newSingleThreadExecutor();
// 提交异步任务(Callable)
Future<Boolean> stockFuture = executor.submit(new StockCheckTask("P001"));
// 主线程继续执行其他操作(如响应用户请求)
System.out.println("主线程:用户已提交秒杀请求,正在校验库存...");
// 阻塞获取异步任务结果(最多等待5秒)
Boolean inStock = stockFuture.get(5, TimeUnit.SECONDS);
if (inStock) {
System.out.println("库存充足,允许下单!");
} else {
System.out.println("库存不足,拒绝下单!");
}
executor.shutdown();
}
}
执行结果与分析
markdown
主线程:用户已提交秒杀请求,正在校验库存...
开始校验商品 P001 的库存...
商品 P001 库存校验结果:充足
库存充足,允许下单!
Callable:定义异步任务(call()方法返回库存校验结果)。Future:表示异步任务的结果,通过future.get()阻塞获取结果(或设置超时时间避免永久阻塞)。- 优势:主线程无需等待库存校验完成,可继续处理其他请求(如记录用户请求日志),提升系统吞吐量。
六、线程池的常见问题与优化(结合案例)
问题1:任务队列选择不当导致OOM
- 案例 :某日志系统使用
newFixedThreadPool(10)(无界队列),因日志请求量突增(10万条/秒),队列堆积导致内存溢出。 - 优化 :改用有界队列(如
LinkedBlockingQueue<>(1000)),并设置合理的饱和策略(如DiscardOldestPolicy丢弃旧日志)。
问题2:线程池参数配置不合理
- 案例 :某API服务使用
newCachedThreadPool()(最大线程数=∞),因突发流量(1000个请求)创建大量线程,导致CPU上下文切换频繁,响应时间激增。 - 优化 :使用
ThreadPoolExecutor自定义参数(核心线程=50,最大线程=200,队列容量=1000),限制线程数量。
问题3:未正确关闭线程池导致资源泄漏
- 案例 :某定时任务系统未调用
shutdown(),线程池中的线程随JVM退出未被回收,导致资源无法释放。 - 优化 :在应用退出时调用
executor.shutdown()或executor.shutdownNow(),优雅关闭线程池。
总结
线程池是Java并发编程的核心工具,通过线程复用、任务队列、资源控制 三大机制,有效解决了频繁创建线程的开销、资源竞争和系统崩溃问题。结合具体案例(如秒杀请求处理、异步库存校验),可以更直观地理解其工作流程和设计思想。实际开发中,需根据业务场景(如CPU密集型、IO密集型)合理配置线程池参数,并结合Callable/Future实现异步任务,以提升系统性能和稳定性。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
线程的生命周期,什么时候会出现僵死进程?
一、线程生命周期状态全景图(分层解析)
1. 新建状态(New)
-
定义:线程对象已创建,但尚未启动,操作系统尚未将其纳入线程调度体系。
-
核心特征:
- 仅完成Java对象内存分配(栈帧、PC寄存器等线程私有数据结构),但未分配CPU资源和系统级线程句柄。
- 调用
thread.getState()返回NEW枚举值。
-
进入条件:
javaThread t = new Thread(() -> { // 线程任务代码 }); // 此时线程处于新建状态,未启动 -
关键限制:
- 无法调用
t.getState()以外的线程方法(如t.join()会抛出IllegalThreadStateException)。 - 未与操作系统线程绑定,
jstack等工具无法监控到该线程。
- 无法调用
-
转换路径 :唯一出口是通过
t.start()方法,触发操作系统线程创建。
2. 可运行状态(Runnable)
- 定义:线程已启动,具备运行资格,等待或正在占用CPU时间片。
- 细分状态 :
- 就绪(Ready):线程已加入调度队列,等待操作系统分配时间片(Java层面可见,但未实际运行)。
- 运行中(Running) :线程已获取时间片,正在执行
run()方法代码(对应图示中的"运行状态")。
- 核心特征 :
- 操作系统已创建线程句柄,分配虚拟地址空间,线程上下文(寄存器、程序计数器)已初始化。
getState()返回RUNNABLE。
- 进入条件 :
- 从新建状态调用
t.start(),触发JVM向操作系统申请创建原生线程。 - 从阻塞/等待状态解除阻塞(如锁释放、
wait()唤醒、sleep()超时)。
- 从新建状态调用
- 转换条件 :
- 进入运行中:操作系统调度器分配时间片(抢占式或协作式调度)。
- 退回就绪 :时间片耗尽、主动让出(
Thread.yield())、被更高优先级线程抢占。 - 进入阻塞/等待 :执行耗时操作(如
sleep()、IO、synchronized锁竞争)。
- 底层原理:
- Java的
RUNNABLE状态映射到操作系统的"可运行"或"运行中"线程,包含用户态和内核态切换成本。 - JVM通过
pthread_create(Linux)或CreateThread(Windows)创建原生线程,与Java线程一一对应。
- Java的
3. 阻塞状态(Blocked)
-
定义:线程暂时放弃CPU使用权,暂停执行,但不释放已持有的锁,等待资源或条件满足。
-
核心特征:
- 持有对象监视器锁(
monitor),但无法进入同步代码块(因目标锁被占用)。 getState()返回BLOCKED。
- 持有对象监视器锁(
-
进入条件:
-
锁竞争失败:尝试进入
synchronized(obj)代码块,而obj的监视器锁被其他线程持有。javaObject lock = new Object(); // 线程A持有lock锁 new Thread(() -> { synchronized (lock) { // 线程B在此处尝试获取lock锁,进入阻塞队列(Lock Pool) synchronized (lock) { // 阻塞,进入Blocked状态 // ... } } }).start(); -
非锁相关的阻塞操作 :如
BlockingQueue.put()(生产者阻塞)、Selector.select()(NIO多路复用阻塞)。
-
-
转换条件:
- 锁释放:持有锁的线程退出同步代码块,阻塞线程被操作系统调度重新竞争锁(回到"锁池"→"可运行")。
- 注意:阻塞状态不释放任何锁,仅暂停当前线程执行。
-
底层实现:
- JVM通过操作系统提供的同步原语(如Linux的
futex、Windows的Event)实现锁等待队列。 - 线程状态从
RUNNABLE切换到BLOCKED时,会释放CPU时间片,进入内核态等待。
- JVM通过操作系统提供的同步原语(如Linux的
4. 等待队列(Wait Queue,又称"等待状态")
-
定义 :线程调用
wait()方法后,主动释放已持有的锁,进入等待状态,需被notify()/notifyAll()唤醒。 -
核心特征:
- 释放所有锁 :不仅释放当前对象的监视器锁,还释放所有已获取的锁(与
sleep()不同)。 getState()返回WAITING(无参wait())或TIMED_WAITING(带超时参数wait(long))。
- 释放所有锁 :不仅释放当前对象的监视器锁,还释放所有已获取的锁(与
-
进入条件:
-
在同步代码块内调用
obj.wait()(必须在持有obj锁的状态下调用,否则抛出IllegalMonitorStateException)。javasynchronized (obj) { obj.wait(); // 释放obj锁,进入等待队列,等待notify()/notifyAll() }
-
-
转换条件:
- 显式唤醒 :其他线程调用
obj.notify()(随机唤醒一个等待线程)或obj.notifyAll()(唤醒所有等待线程),线程回到"锁池"。 - 超时唤醒 :带超时参数的
wait(timeout)在指定时间后自动唤醒,回到"锁池"。 - 中断唤醒 :线程在等待时被
interrupt(),抛出InterruptedException并唤醒。
- 显式唤醒 :其他线程调用
-
关键区别于阻塞状态:
特性 等待队列(Wait Queue) 阻塞状态(Blocked) 锁释放 主动释放所有锁 不释放任何锁 唤醒方式 依赖 notify()/notifyAll()依赖锁释放或中断 典型场景 生产者-消费者模式 锁竞争、IO等待
5. 死亡状态(Dead)
- 定义:线程执行完毕或异常终止,生命周期结束,无法再恢复。
- 核心特征 :
run()方法正常返回,或执行过程中抛出未捕获的异常(如RuntimeException)。getState()返回TERMINATED。
- 进入条件 :
- 正常结束 :
run()方法执行完毕(如循环条件不满足、任务队列为空)。 - 异常终止 :未捕获的异常导致线程退出(建议使用
try-finally确保资源释放)。 - 强制终止 :调用
thread.stop()(已废弃,不推荐)或线程被杀死(如JVM崩溃)。
- 正常结束 :
- 资源回收 :
- 线程栈内存、本地方法栈、程序计数器等私有资源被JVM回收。
- 若线程持有非线程安全对象的引用,可能导致内存泄漏(需通过
ThreadLocal清理或显式置空)。
二、状态转换路径全链路解析(结合图示)
1. 新建 → 可运行(New → Runnable)
- 触发事件 :
t.start() - 底层动作 :
- JVM验证线程状态(仅允许NEW→RUNNABLE转换)。
- 调用操作系统API创建原生线程,建立Java线程与OS线程的映射关系。
- 将线程加入操作系统的调度队列(如Linux的运行队列)。
- 注意 :
start()方法不可重复调用(重复调用会抛出IllegalThreadStateException)。
2. 可运行 → 运行中(Runnable → Running)
- 触发条件:操作系统调度器分配时间片。
- 调度算法 :
- 抢占式调度(Java默认):高优先级线程抢占低优先级线程的时间片。
- 时间片轮转:每个线程分配固定时间片(如10ms),到期后挂起,切换下一线程。
- 性能影响:频繁的上下文切换(保存/恢复线程现场)会增加系统开销。
3. 运行中 → 可运行 / 阻塞 / 等待队列
- 主动让出CPU :
Thread.yield():回到可运行状态(提示调度器让出当前时间片,但不保证立即生效)。Thread.sleep(millis):进入等待队列(TIMED_WAITING),释放CPU但不释放锁(若持有锁)。obj.wait():进入等待队列(WAITING),释放所有锁。
- 被动阻塞 :
- 锁竞争:尝试获取已被占用的锁,进入阻塞队列(Blocked)。
- IO操作:如
FileInputStream.read(),线程进入内核态等待数据,JVM标记为RUNNABLE(实际阻塞)。
4. 阻塞 → 可运行(Blocked → Runnable)
- 触发条件:持有锁的线程退出同步代码块,阻塞线程竞争锁成功。
- 公平性 :
- 非公平锁:新线程可直接插队获取锁,绕过等待队列中的线程。
- 公平锁:按线程到达顺序获取锁(Java原生锁为非公平锁,可通过
ReentrantLock(true)实现公平锁)。
5. 等待队列 → 锁池(Wait Queue → Blocked)
- 触发条件 :
obj.notify()/notifyAll():唤醒等待线程,使其进入阻塞队列(Lock Pool),等待重新竞争锁。- 超时:
wait(timeout)到期后自动进入锁池。
- 关键逻辑 :
唤醒后的线程不会直接运行,而是先进入锁池,与其他竞争同一把锁的线程一起等待调度。
6. 锁池 → 可运行(Blocked → Runnable)
- 触发条件:锁池中的某一线程成功获取锁(其他线程仍留在锁池)。
- 竞争规则 :
- 多个线程竞争同一把锁时,仅有一个线程获取锁并进入可运行状态,其余继续阻塞。
7. 等待队列 / 锁池 → 死亡(→ Terminated)
- 触发条件 :
- 线程从等待/阻塞状态恢复后,执行完
run()方法或抛出异常。 - 线程在等待/阻塞期间被中断且未处理(如
wait()未捕获InterruptedException)。
- 线程从等待/阻塞状态恢复后,执行完
三、核心概念对比与易错点
1. 阻塞 vs 等待队列(关键区别)
| 特性 | 阻塞状态(Blocked) | 等待队列(Wait Queue) |
|---|---|---|
| 锁状态 | 持有锁(竞争其他锁时阻塞) | 释放所有锁 |
| 唤醒方式 | 依赖锁释放 | 依赖notify()/notifyAll()或超时 |
| 典型场景 | synchronized锁竞争 |
wait()、生产者-消费者模式 |
| 状态枚举值 | BLOCKED |
WAITING/TIMED_WAITING |
2. 易混淆操作的行为
sleep() vs wait():sleep():不释放锁,进入等待队列(TIMED_WAITING),需时间或中断唤醒。wait():释放锁,进入等待队列(WAITING),需notify()或超时唤醒。
- yield() vs sleep(0) :
yield():仅提示调度器让出时间片,不保证效果,状态回到可运行。sleep(0):强制当前线程重新竞争时间片,可能立即被调度(行为依赖JVM实现)。
3. 僵死进程与线程的关系
- 用户问题中提到的"僵死进程"实际是僵死线程 的概念误用(Java中线程无"进程"概念)。正确表述应为:
僵死线程 :子线程结束后,父线程未调用join()或isAlive()检测其状态,导致子线程资源未及时回收(但Java有GC机制,僵死问题远轻于C/C++)。 - 与操作系统的"僵尸进程(Zombie Process)"本质不同(后者是进程终止后PCB未释放,需父进程调用
wait()/waitpid()回收)。
四、实战:线程状态监控与调试
1. 查看线程状态
-
使用
jstack <pid>命令导出线程栈,搜索"Thread State"字段,示例:markdown"main" #1 prio=5 os_prio=0 tid=0x00007f489c009800 nid=0x2a10 runnable [0x00007f489fd8a000] java.lang.Thread.State: RUNNABLE at com.example.MyThread.run(MyThread.java:10) -
状态枚举值映射:
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED。
2. 避免状态转换死锁
- 死锁场景 :线程A持有锁1等待锁2,线程B持有锁2等待锁1,双方永久阻塞在
Blocked状态。 - 解决方案 :
- 按固定顺序获取锁(如先锁A后锁B)。
- 使用
Lock.tryLock(timeout)设置超时,避免无限等待。 - 使用
java.util.concurrent工具类(如ReentrantLock、CountDownLatch)替代底层同步。
五、总结:线程生命周期的本质
线程生命周期本质是操作系统调度单元与Java并发编程模型的映射,核心设计目标是:
- 高效利用CPU:通过状态切换实现多任务并发。
- 安全共享资源:通过阻塞/等待机制协调线程对临界区的访问。
- 优雅终止:避免线程泄漏(如未正确结束的守护线程)。
理解每个状态的进入/转换条件,是编写高并发程序的基础------错误的阻塞/等待使用会导致性能瓶颈(如活锁、饥饿)或资源泄漏(如未释放的锁),而合理利用状态转换(如await()/signal())则是构建高效并发组件的关键。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
说说线程安全问题,什么是线程安全,如何实现线程安全?
一、线程安全的本质与核心问题
1. 定义的严格表述
线程安全是指:当多个线程并发访问某个对象、方法或资源时,无论线程的执行顺序如何(即"任意调度"),最终的结果都符合预期,不会出现数据不一致、逻辑错误或系统崩溃 。其核心是对共享资源的访问控制,确保共享资源在被多个线程修改或读取时保持一致性。
2. 线程不安全的三大根源(JMM视角)
Java内存模型(JMM)规定:
- 主内存:所有线程共享,存储实例变量、静态变量、数组元素等。
- 工作内存:每个线程私有,存储主内存中变量的副本(缓存)。
线程安全问题的本质是多线程对主内存共享变量的操作未满足JMM的三大特性:
| 特性 | 问题描述 | 示例 |
|---|---|---|
| 原子性 | 一个操作或多个操作要么全部执行完成,要么完全不执行(不可分割)。 | i++(实际是read i → compute i+1 → write i三步,可能被其他线程打断) |
| 可见性 | 线程对共享变量的修改及时同步到主内存,其他线程能立即感知最新值。 | 线程A修改了flag=true但未刷新到主内存,线程B仍读取到旧值false |
| 有序性 | 程序执行的顺序与代码编写的顺序一致(禁止编译器/CPU的指令重排序)。 | 双重检查锁定(DCL)中,若未正确使用volatile,可能导致构造函数未完成时其他线程看到半初始化对象 |
二、互斥同步(悲观锁):强制独占,避免冲突
核心思想:假设线程一定会冲突,通过"加锁"强制同一时间只有一个线程访问共享资源(互斥),确保操作的原子性。属于阻塞同步,未获取锁的线程会被挂起。
1. synchronized:JVM层面的内置锁
(1)底层实现原理
synchronized的底层通过**监视器锁(Monitor)**实现,依赖JVM的monitorenter和monitorexit指令。每个Java对象关联一个Monitor对象(存储在对象头中),Monitor包含:
- 所有权:当前持有锁的线程ID。
- 等待队列:未获取锁的线程等待队列(双向链表)。
- 计数器:记录当前线程重入锁的次数(可重入的基础)。
锁升级过程(JDK6后优化):
- 偏向锁:首次访问对象时,Mark Word记录线程ID(无竞争时直接进入,减少CAS开销)。
- 轻量级锁:若偏向锁被其他线程竞争,升级为轻量级锁,通过CAS将Mark Word替换为锁记录指针(适用于短时间竞争)。
- 自旋锁:轻量级锁竞争失败时,线程自旋(循环检查锁状态)而非阻塞(减少上下文切换)。
- 重量级锁:自旋次数超过阈值(默认10次),升级为重量级锁,通过操作系统互斥量(Mutex)阻塞线程(代价高,但保证互斥)。
(2)关键特性
- 可重入性:同一线程可多次获取同一锁(计数器递增),避免死锁(如递归调用)。
- 隐式释放:JVM自动释放锁(进入同步块获取,退出时释放,包括异常退出)。
- 锁类型 :
- 对象锁:
synchronized(obj),保护实例变量或方法(非静态方法默认锁是this)。 - 类锁:
synchronized(Class.forName("Xxx"))或Xxx.class,保护静态变量或类级别的方法。
- 对象锁:
(3)使用示例与注意事项
java
public class SyncDemo {
private int counter = 0;
private final Object lock = new Object(); // 专用锁对象(避免与其他同步代码块竞争)
// 同步方法(锁是this)
public synchronized void syncMethod() {
counter++;
}
// 同步代码块(细粒度控制)
public void syncBlock() {
synchronized (lock) { // 推荐使用专用锁对象,避免外部干扰
counter++;
}
}
}
注意:
- 避免使用
String或基本类型包装类作为锁对象(可能被JVM缓存或重用,导致意外锁竞争)。 - 同步块应尽可能小(减少阻塞时间),仅保护共享资源的修改逻辑。
2. ReentrantLock:API层面的可扩展锁
(1)核心原理
ReentrantLock基于AQS(AbstractQueuedSynchronizer)实现,通过CLH队列(Craig-Landin-Hagersten)管理等待线程。AQS内部维护一个volatile int state(锁状态:0=未锁,1=已锁)和一个CLHQueue(双向链表,存储等待线程)。
(2)关键特性
- 手动控制 :需显式调用
lock()和unlock()(推荐在finally中释放锁,避免死锁)。 - 公平性 :
- 公平锁:按等待队列顺序分配锁(
new ReentrantLock(true)),避免线程饥饿。 - 非公平锁(默认):允许新来的线程插队(提高吞吐量,但可能导致某些线程长期等待)。
- 公平锁:按等待队列顺序分配锁(
- 条件变量(Condition) :通过
newCondition()创建多个条件队列,实现精准唤醒(如生产者-消费者模型中的"满"和"空"条件)。
(3)使用示例与注意事项
java
public class ReentrantLockDemo {
private int counter = 0;
private final ReentrantLock lock = new ReentrantLock(false); // 非公平锁
private final Condition notFull = lock.newCondition(); // 自定义条件
public void increment() {
lock.lock();
try {
while (counter >= 100) { // 防止超过100
notFull.await(); // 释放锁并等待
}
counter++;
notFull.signal(); // 唤醒一个等待线程
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断状态
} finally {
lock.unlock();
}
}
}
注意:
lock()和unlock()必须成对出现(遗漏unlock()会导致其他线程永久阻塞)。- 条件变量的
await()需在循环中检查条件(避免虚假唤醒,即wait()可能在没有收到通知时返回)。
三、非阻塞同步(乐观锁):冲突检测与重试
核心思想:假设线程冲突概率低,先操作共享资源,再检查是否冲突(通过版本号或旧值校验),若冲突则重试。无需阻塞线程,属于无锁编程。
1. CAS(Compare-And-Swap):CPU级别的原子操作
(1)底层原理
CAS是CPU提供的原子指令(如x86的CMPXCHG),包含三个操作数:
V:内存中的变量地址(目标值)。A:线程预期的旧值(比较值)。B:线程希望设置的新值(替换值)。
执行逻辑 :若V == A,则将V更新为B;否则不做操作(返回当前V的值)。
Java中通过sun.misc.Unsafe类调用CAS指令(JDK9后推荐使用VarHandle替代),JUC(java.util.concurrent.atomic)中的原子类(如AtomicInteger)基于此实现。
(2)原子类的实现
以AtomicInteger为例,其核心字段是private volatile int value(保证可见性),incrementAndGet()方法通过CAS实现:
java
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
// U是Unsafe实例,getAndAddInt底层调用CAS指令
(3)ABA问题与解决
ABA问题 :线程1将V从A改为B,线程2又将V从B改回A,此时线程1的CAS操作(预期A,实际A)会误认为未发生冲突。
解决方案 :使用AtomicStampedReference(带版本号的CAS),通过stamp(时间戳或版本号)检测中间是否有修改:
java
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
int stamp = ref.getStamp();
// 比较值和版本号
boolean success = ref.compareAndSet(100, 200, stamp, stamp + 1);
(4)适用场景与局限性
- 适用场景:读多写少、冲突概率低的场景(如计数器、状态标志、缓存更新)。
- 局限性 :
- 仅保证单个变量的原子性(无法直接用于多变量同步)。
- 自旋重试可能浪费CPU资源(高冲突时性能下降,甚至不如阻塞锁)。
四、无同步方案:避免共享或线程隔离
核心思想:通过设计避免共享资源,或保证共享资源的线程隔离,无需显式同步机制。
1. 不可变对象:无状态即安全
(1)不可变对象的条件
- 所有字段
final:构造后无法修改(基本类型字段值不可变,引用类型字段指向的对象也不可变)。 - 类声明为
final:防止子类修改行为(如String类是final)。 - 无
setter方法:禁止外部修改状态。 - 防御性拷贝 :构造函数和
getter中对可变参数进行拷贝(避免外部传入可变对象的引用)。
(2)示例:不可变类的实现
java
public final class ImmutableUser {
private final String name; // 基本类型,final保证不可变
private final List<String> roles; // 引用类型,需防御性拷贝
public ImmutableUser(String name, List<String> roles) {
this.name = name;
// 防御性拷贝:外部传入的roles可能被修改,复制到新列表
this.roles = new ArrayList<>(roles);
}
// getter返回不可变视图(避免外部修改内部列表)
public List<String> getRoles() {
return Collections.unmodifiableList(roles);
}
}
(3)优势与适用场景
- 优势:绝对线程安全(无需同步),性能最优(无锁开销)。
- 适用场景:对象创建后状态不变(如配置信息、常量、领域模型中的值对象)。
2. ThreadLocal:线程本地存储
(1)核心原理
ThreadLocal为每个线程提供独立的变量副本,通过ThreadLocalMap实现(每个线程持有ThreadLocalMap实例,键为ThreadLocal弱引用,值为变量副本)。
关键流程:
get():获取当前线程的ThreadLocalMap,查找对应键的值(不存在则调用initialValue()初始化)。set(T value):将值存入当前线程的ThreadLocalMap。remove():删除当前线程的变量副本(避免内存泄漏)。
(2)内存泄漏问题与解决
- 原因 :
ThreadLocalMap的Entry使用弱引用(键)和强引用(值)。若线程长期存活(如线程池),且ThreadLocal实例被回收(弱引用键被GC),则Entry的值为null,但未被清理,导致值无法被访问但占用内存。 - 解决 :显式调用
remove()方法(尤其在try-finally块中),确保及时清理。
(3)典型应用场景
- 线程隔离的资源 :数据库连接、用户会话(如Spring的
RequestContextHolder)、事务ID。 - 框架中的上下文传递 :Hibernate的
Session管理、Log4j的MDC(Mapped Diagnostic Context)。
示例:
java
public class ThreadLocalDemo {
private static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatDate(Date date) {
// 每个线程使用自己的SimpleDateFormat实例(避免多线程格式化冲突)
return dateFormat.get().format(date);
}
public void cleanup() {
dateFormat.remove(); // 手动清理(重要!)
}
}
3. 线程本地存储(TLS,Thread-Local Storage)
(1)底层机制
TLS是操作系统或JVM提供的底层机制,为每个线程分配独立的存储空间(如x86的FS/GS寄存器,Windows的TlsAlloc/TlsSetValue)。Java中主要通过ThreadLocal间接使用TLS,底层通过JNI调用本地方法实现。
(2)与ThreadLocal的关系
ThreadLocal是Java对TLS的封装,提供了更易用的API。TLS是更底层的机制,可用于非Java代码(如C/C++本地方法)的线程本地存储。
五、方案对比与选择指南
| 方案 | 核心思想 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 互斥同步 | 强制独占,避免冲突 | synchronized、ReentrantLock |
逻辑简单,强一致性 | 可能阻塞,性能开销大 | 高冲突场景(如库存扣减、账户转账) |
| 非阻塞同步 | 冲突检测与重试 | CAS(AtomicXXX) |
无阻塞,低冲突时性能高 | 高冲突时重试开销大,仅单变量 | 读多写少、冲突概率低(如计数器) |
| 无同步方案 | 避免共享或线程隔离 | 不可变对象、ThreadLocal |
无锁,性能最优 | 设计复杂度高(如不可变对象) | 状态不变(不可变对象)或线程隔离(ThreadLocal) |
六、最佳实践与常见误区
- 避免过度同步 :同步块应尽可能小(仅保护共享资源),减少阻塞时间(如避免在同步块内调用
sleep()或IO操作)。 - 优先使用
volatile替代轻量级同步 :若仅需保证可见性(如状态标志),volatile比synchronized更高效(无锁开销)。 - CAS的合理使用:CAS适用于"乐观估计冲突少"的场景,若冲突频繁(如高并发计数器),CAS的重试开销可能超过锁。
- ThreadLocal的清理 :在线程复用(如线程池)的场景中,必须显式调用
remove(),避免内存泄漏和数据污染。 - 不可变对象的设计 :对于复杂对象,可通过
Collections.unmodifiableXXX包装可变集合,或使用record(Java 16+)自动生成不可变类。
总结 :线程安全的核心是控制共享资源的访问。根据场景选择合适的方案:高冲突用互斥锁(synchronized更简单),低冲突用CAS,状态不变用不可变对象,线程隔离用ThreadLocal。理解底层原理(如JMM、Monitor、CAS指令)有助于在实际开发中做出正确决策
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
创建线程池有哪几个核心参数? 如何合理配置线程池的大小?
一、线程池核心参数深度解析
Java的ThreadPoolExecutor是线程池的核心实现类,其构造方法定义了7个核心参数,这些参数共同决定了线程池的行为模式、资源使用效率和任务处理能力。以下是对每个参数的详细说明 、底层逻辑 及注意事项:
1. corePoolSize(核心线程数)
- 定义 :线程池长期保留的最小线程数量(即使线程处于空闲状态)。除非显式设置
allowCoreThreadTimeOut(true),否则核心线程不会被回收。 - 底层逻辑 :
当通过execute(Runnable)提交任务时,若当前活跃线程数(workerCount)小于corePoolSize,无论是否有空闲线程,线程池都会创建一个新线程(核心线程)来执行任务。
核心线程的存在是为了避免频繁创建/销毁线程的开销(线程创建需分配栈空间、JVM资源,销毁需GC回收)。 - 注意事项 :
- 若任务量长期小于
corePoolSize,多余的线程会被回收(非核心线程),但核心线程会保留(除非allowCoreThreadTimeOut为true)。 corePoolSize不能超过maximumPoolSize,否则构造线程池时会抛出IllegalArgumentException。
- 若任务量长期小于
2. maximumPoolSize(最大线程数)
- 定义:线程池允许的最大线程总数(核心线程+临时线程)。超过此值时,新任务将根据拒绝策略处理。
- 底层逻辑 :
当任务队列(workQueue)已满且当前活跃线程数达到corePoolSize时,线程池会创建临时线程(非核心线程)处理新任务,直到线程数达到maximumPoolSize。
临时线程的超时时间由keepAliveTime控制(即使任务未完成,超时后也会被回收)。 - 注意事项 :
- 最大线程数并非越大越好,过大的线程数会导致频繁的线程切换(上下文切换),消耗CPU资源。
- 若
workQueue是无界队列(如LinkedBlockingQueue),则maximumPoolSize不会生效(任务永远进入队列,不会创建超过corePoolSize的线程)。
3. keepAliveTime(非核心线程空闲存活时间)
- 定义 :当线程池中的线程数量超过
corePoolSize时,空闲线程的最大存活时间。超过此时间后,非核心线程会被回收。 - 底层逻辑 :线程池通过
Worker类(继承自AbstractQueuedSynchronizer)管理线程。每个Worker线程在执行完任务后,会检查当前活跃线程数是否超过corePoolSize:- 若超过且空闲时间超过
keepAliveTime,则线程终止,被线程池回收。 - 若
allowCoreThreadTimeOut(true),则核心线程也会受此参数约束(空闲时会被回收)。
- 若超过且空闲时间超过
- 注意事项 :
- 时间单位(
unit)需与keepAliveTime匹配(如TimeUnit.SECONDS对应秒)。 - 对于IO密集型任务,可适当延长
keepAliveTime,避免短时间任务后线程被频繁回收。
- 时间单位(
4. unit(keepAliveTime的时间单位)
- 定义 :枚举类型(
TimeUnit),指定keepAliveTime的时间单位(如NANOSECONDS、MILLISECONDS、SECONDS等)。 - 注意事项 :
需根据业务场景选择合适的单位(如任务间隔为毫秒级时,用TimeUnit.MILLISECONDS)。
5. workQueue(任务等待队列)
-
定义:当核心线程全忙时,新任务会被暂存至此阻塞队列。队列的选择直接影响线程池的任务处理策略。
-
常见队列类型及适用场景:
队列类型 特点 适用场景 ArrayBlockingQueue有界队列(固定容量),基于数组实现,FIFO顺序。 任务量可预估,需严格控制内存使用(避免OOM)。 LinkedBlockingQueue无界队列(默认容量 Integer.MAX_VALUE),基于链表实现,FIFO顺序。任务量不确定,但需避免任务丢失(可能导致OOM,需谨慎)。 SynchronousQueue同步移交队列,不存储任务,直接将任务移交线程处理。 任务量小且提交速度快(如短平快的任务),需线程立即处理(无队列缓冲)。 PriorityBlockingQueue优先级队列,按任务优先级排序(需实现 Comparable接口)。任务有优先级差异(如订单系统中"加急订单"优先处理)。 DelayedWorkQueue延迟队列,任务需等待指定延迟时间后才被处理。 定时任务或延迟任务(如定时清理日志、延迟通知)。 -
底层逻辑 :
线程池的任务提交流程为:
plaintext提交任务 → 若活跃线程数 < corePoolSize → 创建核心线程执行任务 ↓ 若活跃线程数 ≥ corePoolSize → 将任务加入workQueue ↓ 若workQueue已满 → 创建临时线程(≤maximumPoolSize)执行任务 ↓ 若临时线程数 ≥ maximumPoolSize → 触发拒绝策略 -
注意事项:
- 无界队列(如
LinkedBlockingQueue)可能导致任务堆积,需结合监控避免OOM。 - 优先级队列需任务实现
Comparable,否则会抛出ClassCastException。
- 无界队列(如
6. threadFactory(线程工厂)
-
定义 :自定义线程创建逻辑的接口(
ThreadFactory),用于设置线程名、优先级、是否为守护线程等属性。 -
默认实现 :
Executors.defaultThreadFactory()创建的线程工厂,线程名为pool-N-thread-M(N为线程池编号,M为线程编号),非守护线程,优先级为NORM_PRIORITY(5)。 -
自定义示例:
javaThreadFactory customThreadFactory = new ThreadFactoryBuilder() .setNameFormat("MyThreadPool-Worker-%d") // 自定义线程名格式 .setPriority(Thread.MAX_PRIORITY) // 设置最高优先级 .setDaemon(false) // 非守护线程 .build(); -
注意事项:
- 守护线程(
daemon=true)会在JVM退出时自动终止,可能影响任务完整性,需谨慎使用。 - 线程名需清晰标识线程池用途(如
OrderProcessor-Worker-1),便于日志排查。
- 守护线程(
7. rejectedExecutionHandler(拒绝策略)
- 定义 :当任务队列已满且线程数达到
maximumPoolSize时,对新任务的拒绝策略。线程池提供了4种内置策略,也支持自定义。
| 策略类型 | 实现类 | 行为描述 | 适用场景 |
|---|---|---|---|
| AbortPolicy(默认) | ThreadPoolExecutor.AbortPolicy |
抛出RejectedExecutionException异常,任务被拒绝且不执行。 |
明确需要拒绝多余任务(如关键任务必须立即处理,不允许堆积)。 |
| CallerRunsPolicy | ThreadPoolExecutor.CallerRunsPolicy |
由调用者线程(提交任务的线程)直接执行任务(减缓任务提交速度)。 | 流量削峰(如防止突发大量任务压垮系统)。 |
| DiscardPolicy | ThreadPoolExecutor.DiscardPolicy |
静默丢弃新任务,不抛出异常。 | 允许丢失非关键任务(如日志上报,重复上报不影响结果)。 |
| DiscardOldestPolicy | ThreadPoolExecutor.DiscardOldestPolicy |
丢弃队列中最旧的任务(等待时间最久的任务),尝试重新提交新任务。 | 允许牺牲旧任务(如实时性要求高的场景,旧任务已过时)。 |
-
自定义拒绝策略:可通过实现
RejectedExecutionHandler接口自定义逻辑(如记录日志、发送告警):javaRejectedExecutionHandler customHandler = (r, executor) -> { log.error("任务被拒绝,任务:{},当前线程池状态:活跃线程数={}, 队列大小={}", r.toString(), executor.getActiveCount(), executor.getQueue().size()); // 可选:将任务重新放入另一个备用队列或通知上游系统 };
二、线程池大小的合理配置:从原理到实践
线程池的大小(corePoolSize和maximumPoolSize)需根据任务类型 、CPU核心数 、任务执行特性(如IO耗时、CPU耗时)综合计算。以下是详细的分类配置策略和推导过程。
1. 关键前提:理解任务的两种时间占比
任务的执行时间由两部分组成:
- CPU时间:任务实际占用CPU进行计算的时间(如数学运算、加密)。
- 等待时间:任务因IO、锁、网络等原因无法占用CPU的时间(如数据库查询、文件读写)。
通过统计任务的CPU时间和等待时间占比,可确定线程池的最佳线程数。例如:
若一个任务的CPU时间为T_cpu,等待时间为T_wait,则总执行时间为T_total = T_cpu + T_wait。
2. CPU密集型任务:线程数 ≈ CPU核心数
-
任务特点 :
T_wait ≈ 0(几乎无等待),任务主要消耗CPU资源(如视频编码、大数据计算)。 -
配置原理 :
CPU的核心数决定了并行计算的能力。若线程数超过CPU核心数,多余的线程会因CPU资源竞争而频繁切换上下文(Context Switch),导致性能下降。
上下文切换的开销包括:保存当前线程的寄存器状态、加载新线程的寄存器状态、JVM和操作系统的调度耗时。
-
推荐配置:
javaint corePoolSize = Runtime.getRuntime().availableProcessors(); // CPU核心数 int maximumPoolSize = corePoolSize + 1; // 允许1个临时线程应对突发任务 -
示例 :
4核CPU的机器,CPU密集型线程池配置为
core=4,max=5。此时,4个核心线程满负荷运行,1个临时线程处理突发任务,避免任务堆积。
3. IO密集型任务:线程数 >> CPU核心数
-
任务特点 :
T_wait >> T_cpu(大部分时间等待IO),任务需要大量线程等待IO完成后继续执行(如Web服务器处理HTTP请求、数据库查询)。 -
配置原理 :
IO密集型任务的瓶颈是IO等待时间。当线程因IO阻塞时,CPU处于空闲状态,此时可创建更多线程,让CPU处理其他任务,提升整体吞吐量。
最佳线程数的经验公式为:`最佳线程数 = (T_wait / T_cpu + 1) × CPU核心数
其中:
T_wait / T_cpu:线程等待时间与CPU时间的比例(称为"等待比")。- 若等待比为
k,则每个CPU核心可支撑k个线程(每个线程等待时,CPU处理另一个线程的计算)。
-
推导示例 :
假设一个IO密集型任务的
T_cpu=0.2s(CPU计算时间),T_wait=1.8s(IO等待时间),则等待比为1.8/0.2=9。若CPU核心数为4,则最佳线程数为
(9 + 1) × 4 = 40。此时,4个CPU核心可同时处理4个线程的计算,其余36个线程处于IO等待状态,CPU空闲时立即接管计算。 -
经验简化配置 :
若无法精确统计
T_wait和T_cpu,可按以下经验值配置:- 轻量级IO任务(如本地文件读写):
线程数 = CPU核心数 × 2。 - 重量级IO任务(如数据库查询、网络请求):
线程数 = CPU核心数 × 4 ~ 8。
- 轻量级IO任务(如本地文件读写):
-
示例 :
4核CPU,处理数据库查询(IO密集型),配置
core=16,max=32(允许临时线程应对突发流量)。
4. 混合型任务:拆分任务到独立线程池
-
任务特点:同时包含CPU密集型和IO密集型操作(如电商订单处理:计算优惠→查询库存→更新数据库)。
-
配置原理 :
混合型任务若使用同一线程池,会导致CPU线程被IO任务阻塞,或IO线程被CPU任务占用,降低整体效率。
最佳实践是将任务拆分为独立的子任务,分别由不同的线程池处理:
- CPU密集子任务 (如优惠计算):使用CPU密集型线程池(
core=CPU核心数)。 - IO密集子任务 (如库存查询、数据库更新):使用IO密集型线程池(
core=CPU核心数×4)。
- CPU密集子任务 (如优惠计算):使用CPU密集型线程池(
-
示例 :
订单处理流程:
plaintext提交订单 → CPU密集子线程(计算优惠) → IO密集子线程(查询库存) → IO密集子线程(更新数据库)每个子任务使用独立的线程池,避免资源竞争。
5. 其他影响线程数的因素
- 任务队列类型 :
- 若使用
SynchronousQueue(无存储),需maximumPoolSize接近corePoolSize(因任务无法暂存,必须立即处理)。 - 若使用
LinkedBlockingQueue(大容量),可适当增大maximumPoolSize(任务先入队,再由临时线程处理)。
- 若使用
- 任务执行时间 :
- 短任务(如HTTP请求处理):可增大线程数,充分利用CPU空闲时间。
- 长任务(如批量数据处理):需减小线程数,避免长时间占用线程导致其他任务阻塞。
- 系统资源限制 :
- 内存:每个线程默认栈大小为1MB(64位JVM),1000个线程需约1GB内存。需根据机器内存调整线程数。
- 文件描述符:大量IO线程可能耗尽文件描述符(
ulimit -n),需调整系统参数或限制线程数。
三、线程池配置的验证与调优
合理配置线程池后,需通过监控验证效果,并根据实际运行数据动态调整。以下是关键监控指标和调优方法:
1. 关键监控指标
- 活跃线程数(
activeCount) :当前正在执行任务的线程数。若长期接近maximumPoolSize,说明线程池可能过小(需增大maximumPoolSize)。 - 队列大小(
queueSize):任务队列中的待处理任务数。若长期接近队列容量,说明线程池处理能力不足(需增大线程数或优化任务逻辑)。 - 任务完成率(
completedTaskCount):历史完成任务总数。若远小于提交任务数,说明拒绝策略频繁触发(需调整线程数或队列容量)。 - CPU利用率:线程池所在进程的CPU使用率。若CPU利用率长期>80%,可能是CPU密集型任务过多或线程数过大(需优化任务或减小线程数)。
2. 调优步骤
- 初始配置 :根据任务类型(CPU/IO混合)设置初始线程数(如
core=CPU核心数×2,max=CPU核心数×4)。 - 监控运行 :通过
ThreadPoolExecutor的内置方法(如getActiveCount()、getQueue().size())或监控工具(如Prometheus+Grafana)采集指标。 - 分析瓶颈 :
- 若
activeCount长期=corePoolSize且queueSize增长,说明核心线程不足(需增大corePoolSize)。 - 若
activeCount长期=maximumPoolSize且queueSize=队列容量,说明最大线程数不足(需增大maximumPoolSize)。 - 若CPU利用率低但
queueSize增长,说明任务等待时间过长(需优化IO操作或增大线程数)。
- 若
- 动态调整 :通过
setCorePoolSize()和setMaximumPoolSize()动态调整线程数(需谨慎,避免频繁调整导致线程频繁创建/销毁)。
四、常见误区与最佳实践
误区1:盲目使用Executors工厂方法
Executors提供的工厂方法(如newFixedThreadPool、newCachedThreadPool)虽方便,但存在潜在风险:
newFixedThreadPool:使用无界队列(LinkedBlockingQueue),任务量过大时会导致OOM。newCachedThreadPool:最大线程数为Integer.MAX_VALUE,可能创建大量线程导致资源耗尽。
最佳实践 :手动创建ThreadPoolExecutor,显式指定所有参数,避免无界队列和线程数失控。
误区2:线程数越大越好
过多线程会增加线程切换开销(上下文切换),反而降低性能。需根据任务类型和CPU核心数合理设置。
误区3:忽略拒绝策略的选择
默认的AbortPolicy会抛出异常,可能导致任务丢失。需根据业务场景选择策略(如流量削峰用CallerRunsPolicy,允许丢失用DiscardPolicy)。
最佳实践总结
- CPU密集型 :
core=CPU核心数,max=CPU核心数+1,队列用ArrayBlockingQueue(有界防OOM)。 - IO密集型 :
core=CPU核心数×2,max=CPU核心数×4,队列用LinkedBlockingQueue(适当容量缓冲)。 - 混合型:拆分任务到独立线程池,分别配置CPU和IO线程数。
- 监控优先:上线后持续监控线程池指标,动态调整参数。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴
volatile、ThreadLocal的使用场景和原理
一、ThreadLocal 原理与实现细节详解
1. 核心定位:线程本地存储(Thread-Local Storage, TLS)
ThreadLocal 是 Java 中实现线程本地存储 的核心工具,其核心目标是:为每个线程维护独立的变量副本 ,避免多线程共享变量时的竞争问题。每个线程通过 ThreadLocal 访问的变量,本质上是该线程私有的副本,与其他线程的副本互不干扰。
2. 底层存储结构:ThreadLocalMap
ThreadLocal 的底层依赖 ThreadLocalMap 实现数据存储。每个 Thread 对象内部都持有一个 ThreadLocalMap 实例(通过 threadLocals 字段引用),其作用是为当前线程存储所有 ThreadLocal 变量的副本。
(1) ThreadLocalMap 的结构
ThreadLocalMap 是一个类似 HashMap 的哈希表,但采用线性探测法解决哈希冲突(而非链表或红黑树)。其核心组成如下:
| 组成部分 | 类型 | 说明 |
|---|---|---|
table |
Entry[] 数组 |
存储键值对的底层数组,初始容量为 16(INITIAL_CAPACITY)。 |
size |
int |
当前存储的键值对数量。 |
threshold |
int |
扩容阈值(默认 table.length * 2/3),超过时触发扩容(expungeStaleEntries)。 |
Entry |
自定义 Map.Entry |
键为 ThreadLocal<?> 实例(弱引用),值为线程本地变量的副本(强引用)。 |
(2) Entry 的设计细节
Entry 是 ThreadLocalMap 的核心存储单元,其定义如下:
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 线程本地变量的值(强引用)
Entry(ThreadLocal<?> k, Object v) {
super(k); // Key 是弱引用,指向 ThreadLocal 实例
value = v;
}
}
- Key(弱引用) :
Entry的键是对ThreadLocal实例的弱引用(WeakReference<ThreadLocal<?>>)。这意味着,若ThreadLocal实例在ThreadLocalMap外部没有被强引用(如被置为null),则 GC 时会回收该ThreadLocal实例,避免内存泄漏。 - Value(强引用) :
Entry的值是对线程本地变量的强引用。即使ThreadLocal实例被回收,value仍会被Entry强引用,导致无法被 GC 回收(这是ThreadLocal内存泄漏的主要原因)。
3. 数据存储与访问流程
(1) 存储过程(set 方法)
当调用 ThreadLocal.set(value) 时,底层执行以下步骤:
- 获取当前线程 :通过
Thread.currentThread()获取当前线程对象。 - 获取线程的
ThreadLocalMap:从线程对象的threadLocals字段获取ThreadLocalMap。若不存在(首次使用),则创建新的ThreadLocalMap并绑定到线程。 - 计算哈希值 :以当前
ThreadLocal实例为键,计算其在table数组中的索引(hash = key.threadLocalHashCode & (table.length - 1))。 - 插入或更新
Entry:- 若该索引位置已有
Entry,则遍历后续位置(线性探测)查找是否存在相同的ThreadLocal键(处理哈希冲突)。 - 若找到相同键的
Entry,则更新其value。 - 若未找到,则在空闲位置新建
Entry并插入。
- 若该索引位置已有
示例 :
线程 Thread1 调用 threadLocal1.set("value1"),则 Thread1 的 ThreadLocalMap 中会生成一个 Entry,其 key 是 threadLocal1 的弱引用,value 是字符串 "value1"。
(2) 读取过程(get 方法)
当调用 ThreadLocal.get() 时,底层执行以下步骤:
- 获取当前线程 :同
set方法。 - 获取线程的
ThreadLocalMap:若不存在则返回null(首次使用时会初始化)。 - 查找
Entry:以当前ThreadLocal实例为键,在table数组中查找对应的Entry(同样使用线性探测处理哈希冲突)。 - 返回值或初始化 :
- 若找到
Entry,则返回其value。 - 若未找到,调用
initialValue()方法生成默认值(默认返回null,可重写此方法自定义初始值),并将其存入ThreadLocalMap后返回。
- 若找到
示例 :
线程 Thread2 调用 threadLocal1.get(),若 Thread2 的 ThreadLocalMap 中已有 threadLocal1 对应的 Entry,则直接返回 "value1"(假设 Thread1 已设置过);若没有,则调用 initialValue() 返回 null(或自定义值)。
(3) 移除过程(remove 方法)
调用 ThreadLocal.remove() 时,会从当前线程的 ThreadLocalMap 中删除该 ThreadLocal 对应的 Entry,避免内存泄漏。若不手动调用 remove(),当线程被线程池复用时,Entry 中的 value 可能残留(因 ThreadLocal 实例被回收但 value 仍被强引用)。
4. 内存泄漏与规避
(1) 泄漏原因
ThreadLocal 的内存泄漏主要源于 Entry 中 value 的强引用无法被回收:
- 当
ThreadLocal实例被外部置为null(如不再使用),由于Entry的key是弱引用,GC 会回收ThreadLocal实例。 - 但
Entry的value是强引用,若Thread未被销毁(如线程池中的常驻线程),value无法被回收,导致内存泄漏。
(2) 规避方法
- 手动调用
remove():在使用完ThreadLocal后(如请求结束、线程复用前),调用threadLocal.remove()清除当前线程的Entry。 - 使用静态
ThreadLocal:将ThreadLocal声明为static,确保其生命周期与类一致(避免频繁创建/回收)。 - 重写
initialValue():返回null或轻量级对象,减少泄漏影响(但无法彻底解决)。
5. 典型应用场景
(1) 数据库连接管理
-
场景:每个线程需要独立的数据库连接(避免多线程共享连接的线程安全问题)。
-
示例:
javapublic class DBUtil { // 静态 ThreadLocal,生命周期与类一致 private static final ThreadLocal<Connection> connectionHolder = ThreadLocal.withInitial(() -> { try { return DriverManager.getConnection(DB_URL, USER, PASSWORD); } catch (SQLException e) { throw new RuntimeException("获取连接失败", e); } }); // 获取当前线程的连接 public static Connection getConnection() { return connectionHolder.get(); } // 关闭并清除当前线程的连接 public static void closeConnection() { Connection conn = connectionHolder.get(); if (conn != null) { try { conn.close(); } catch (SQLException e) { // 日志记录 } finally { connectionHolder.remove(); // 关键:避免内存泄漏 } } } }每个线程通过
getConnection()获取独立连接,closeConnection()确保连接关闭并清除ThreadLocal中的副本。
(2) Web 请求中的 Session 管理
-
场景:Web 应用中,每个用户的 HTTP Session 需要独立存储(避免多线程共享 Session 导致的并发问题)。
-
示例:
javapublic class SessionManager { private static final ThreadLocal<HttpSession> sessionHolder = ThreadLocal.withInitial(() -> { HttpServletRequest request = getRequest(); // 从当前请求中获取 return request.getSession(); }); public static HttpSession getSession() { return sessionHolder.get(); } public static void invalidateSession() { HttpSession session = sessionHolder.get(); if (session != null) { session.invalidate(); sessionHolder.remove(); // 清除副本 } } }每个请求线程通过
getSession()获取当前用户的 Session,避免多线程竞争。
(3) 分布式日志追踪(MDC)
-
场景 :在分布式系统中,每个请求需要记录唯一的调用链 ID(如
traceId),确保全链路日志可追溯。 -
示例:
javapublic class LogContext { private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>(); // 设置当前线程的 traceId(通常在请求入口设置) public static void setTraceId(String traceId) { traceIdHolder.set(traceId); } // 获取当前线程的 traceId(日志框架调用) public static String getTraceId() { return traceIdHolder.get(); } // 清除当前线程的 traceId(请求结束时) public static void clear() { traceIdHolder.remove(); } }每个请求线程通过
setTraceId设置唯一traceId,日志框架通过getTraceId输出,实现全链路追踪。
二、Volatile 原理与深度解析
1. Java 内存模型(JMM)的核心规则
理解 volatile 前,必须先理解 Java 内存模型(JMM)的基本结构。JMM 定义了多线程环境下变量的访问规则:
(1) 主内存(Main Memory)
- 所有线程共享的内存区域,存储变量的实际值(类似物理内存)。
- 变量的读写必须通过主内存完成,线程无法直接访问其他线程的工作内存。
(2) 工作内存(Working Memory)
- 每个线程私有的内存区域,存储主内存中变量的副本(类似 CPU 高速缓存)。
- 线程对变量的所有操作(读取、修改)必须在工作内存中进行,无法直接操作主内存。
(3) 数据流动规则
-
线程写入:工作内存 → 主内存(写操作完成后,必须将副本同步到主内存)。
-
线程读取:主内存 → 工作内存(读操作前,必须从主内存重新加载最新值到工作内存)。
但 JMM 允许编译器和处理器对指令进行重排序(优化性能),可能导致多线程下的逻辑错误(如双重检查锁定问题)。
2. Volatile 的三重语义
volatile 是 Java 中唯一能保证多线程可见性 和禁止指令重排序 的关键字(但不保证原子性)。其核心语义通过 JVM 发出的内存屏障实现。
(1) 可见性(Visibility)
-
定义 :当一个线程修改了
volatile变量的值,其他线程能立即感知到最新值(无需等待工作内存缓存)。 -
实现机制:
- 写屏障(Store Barrier) :对
volatile变量的写操作会触发写屏障,强制将工作内存中的修改值刷新到主内存。 - 读屏障(Load Barrier) :对
volatile变量的读操作会触发读屏障,强制从主内存中重新加载最新值到工作内存。
示例 :
线程 A 修改
volatile boolean flag = true,写屏障会立即将flag的新值刷新到主内存;线程 B 读取flag时,读屏障会从主内存加载最新值,确保看到true。 - 写屏障(Store Barrier) :对
(2) 禁止指令重排序
-
定义 :编译器和处理器无法对
volatile变量的读写操作进行重排序,确保多线程下的操作顺序符合代码逻辑。 -
实现机制:
- 写前屏障(StoreStore Barrier) :禁止
volatile写操作与之前的普通写操作重排序。 - 写后屏障(StoreLoad Barrier) :禁止
volatile写操作与之后的volatile读/写操作重排序。 - 读前屏障(LoadLoad Barrier) :禁止
volatile读操作与之后的普通读操作重排序。 - 读后屏障(LoadStore Barrier) :禁止
volatile读操作与之后的普通写操作重排序。
示例(双重检查锁定):
javapublic class Singleton { private static volatile Singleton instance; // 必须 volatile public static Singleton getInstance() { if (instance == null) { // 第一次检查(无锁) synchronized (Singleton.class) { if (instance == null) { // 第二次检查(防多线程竞争) instance = new Singleton(); // volatile 禁止重排序 } } } return instance; } }若没有
volatile,instance = new Singleton()可能被重排序为:
分配内存 → 引用指向内存 → 初始化对象。此时,其他线程可能看到非空的instance,但对象尚未初始化完成,导致逻辑错误。volatile的写后屏障禁止了这种重排序,确保对象初始化完成后再赋值。 - 写前屏障(StoreStore Barrier) :禁止
(3) 原子性的限制
volatile 仅保证单次读/写操作的原子性 (如 boolean、int 等基本类型的读写),但无法保证复合操作的原子性(如 i++)。
- 原因 :
i++本质是read(i) → add(1) → write(i)三个操作的组合,volatile无法保证这三个操作的原子性。 - 解决方案 :使用
AtomicInteger(基于 CAS 实现原子操作)或synchronized同步。
3. Volatile 的适用场景
(1) 状态标志(单次写、多次读)
-
场景:标记某个操作是否完成(如初始化完成、系统停机),仅需单次写、多次读。
-
示例:
javapublic class Worker { private volatile boolean running = true; // 状态标志 public void start() { new Thread(() -> { while (running) { // 多次读 // 执行任务... } }).start(); } public void stop() { running = false; // 单次写 } }running被声明为volatile,确保stop()方法修改后,工作线程能立即感知并退出循环。
(2) 一次性安全发布(防止指令重排序)
- 场景:延迟初始化单例对象,避免多线程下获取到未完全构造的实例(双重检查锁定问题)。
- 示例 (同前文 Singleton 类):
volatile确保instance = new Singleton()的写操作不会被重排序到构造函数之前,避免其他线程获取到未初始化的实例。
(3) 开销较低的"读-写锁"策略
-
场景 :读操作远多于写操作时,结合
volatile(保证读可见性)和synchronized(保证写原子性)提升性能。 -
示例:
java@ThreadSafe public class CheesyCounter { @GuardedBy("this") private volatile int value; // 写时加锁,读时 volatile 可见 public int getValue() { return value; // 读操作无锁,性能高 } public synchronized int increment() { return value++; // 写操作加锁,保证原子性 } }此模式适用于计数器等读多写少场景,减少锁竞争开销(读路径仅涉及
volatile读,无需加锁)。
(4) 独立观察(定期发布观察结果)
-
场景:后台线程定期更新某个观测值(如温度、统计指标),其他线程实时读取最新值。
-
示例:
javapublic class WeatherMonitor { public volatile double currentTemperature; // 发布当前温度 public void updateTemperature(double temp) { this.currentTemperature = temp; // 后台线程定期更新 } }其他线程(如 UI 线程)可直接读取
currentTemperature获取最新温度,无需同步。
4. 使用注意事项
- 仅用于单线程写、多线程读 :
volatile无法解决复合操作的原子性问题(如i++),需配合synchronized或Atomic类。 - 避免过度使用 :
volatile的可见性和有序性保证会增加内存屏障的开销(虽比锁小,但仍需评估)。 - 正确初始化 :
volatile变量的初始值需合理设置(如false、null),避免多线程下的脏读。
总结
- ThreadLocal :通过
ThreadLocalMap实现线程本地存储,解决多线程共享变量的竞争问题,适用于数据库连接、Session 管理等场景,但需注意内存泄漏(手动remove()是关键)。 - Volatile:通过内存屏障保证可见性和禁止重排序,适用于状态标志、一次性安全发布等场景,但无法保证原子性,需结合其他同步机制使用。
🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴🔴🟠🟡🟢🔵🟣🔴