ThreadLocal
1.ThreadLocal是什么?
ThreadLocal是Java用于实现“线程本地存储变量”的工具类。他的作用是:为每个线程创建独有的变量副本,线程只能读写自己的副本,所以不存在多线程竞争资源问题,更不需要加锁来实现线程安全。
2.为什么要使用ThreadLocal
同一份数据被多线程同时访问会出现线程安全问题,通常解决方案是加锁,但加锁会造成阻塞、会降低并发性能。而ThreadLocal 天然线程安全,不需要加锁也能够提升并发性能。
3.ThreadLocal如何使用?
标准使用步骤(3 步)
1. 定义ThreadLocal实例(全局唯一,通常用static修饰)
方式1:初始值为 null private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); 方式2:用 withInitial 指定初始值(JDK8+) private static ThreadLocal<Integer> numLocal = ThreadLocal.withInitial(() -> 0);2. 在任意线程中,通过 set方法设置当前线程的副本 \ 通过get方法获取自己的副本
线程1中设置值 new Thread(() -> { threadLocal.set("线程1的私有数据"); // 为当前线程(线程1)创建副本并赋值 // ... 其他操作 String data = threadLocal.get(); }).start(); 线程2中设置值 new Thread(() -> { threadLocal.set("线程2的私有数据"); // 为当前线程(线程2)创建副本并赋值 // ... 其他操作 String data = threadLocal.get(); }).start();3. 必须:使用完毕后调用remove()清理副本(避免内存泄漏)
new Thread(() -> { try { threadLocal.set("临时数据"); // 业务逻辑... } finally { threadLocal.remove(); 无论是否异常,都清理当前线程的副本 } }).start();实战结果
public class test { private static ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0); public static void main(String[] args) { Runnable task = () -> { try { int value = threadLocal.get(); value += 1; threadLocal.set(value); System.out.println(Thread.currentThread().getName() + " Value: " + threadLocal.get()); } finally { threadLocal.remove(); } }; Thread thread1 = new Thread(task, "Thread-1"); Thread thread2 = new Thread(task, "Thread-2"); thread1.start(); // 输出: Thread-1 Value: 1 thread2.start(); // 输出: Thread-2 Value: 1 } }4.ThreadLocal原理
ThreadLocal实际是为每个线程创建一个ThreadLocalMap。ThreadLocalMap通过Entry数组存储数据,Entry数组为key-value结构,key为ThreadLocal对象,value为变量副本(Entry(ThreadLocal 对象, 变量副本))。当线程进行ThreadLocal.get()时,会根据自己的ThreadLocalMap找到对应的变量副本进行读取。
5.ThreadLocal内存泄露问题
内存泄漏同时满足两个条件:
- ThreadLocal 实例不再被强引用
- 线程持续存活,导致 ThreadLocalMap 长期存在
强引用
- 最普通的引用方式,即通过赋值操作创建的引用(如
Object obj = new Object()中,obj就是强引用) - 只要对象被强引用指向,垃圾回收器就不会回收该对象,即使内存不足,JVM 也会抛出
OutOfMemoryError而不会回收强引用对象。
弱引用:
- 一种强度较弱的引用,无法阻止垃圾回收。当对象仅被弱引用指向时,一旦触发垃圾回收,该对象就会被回收(无论内存是否充足)。
- 弱引用需要通过专门的类(如 Java 的
Weak Reference)来创建,不能直接通过赋值生成。
内存泄露的原因
ThreadLocal发生内存泄露的原因是因为内部实现机制导致的。
ThreadLocalMap的key是ThreadLocal对象,ThreadLocal被Weak Reference包装为弱引用,value为强引用。ThreadLocal对象为弱引用,意味着一旦触发垃圾回收,该对象就会被回收,导致ThreadLocalMap的key为null,但value的值还存在ThreadLocalMap中。如果线程持续存活(如:线程池中的核心线程),ThreadLocalMap会长期占用内存,导致内存泄露。如果泄露的内存持续累积,最终会导致内存溢出(OOM,OutOfMemoryError)。
创建强引用指向 ThreadLocal 实例 ThreadLocal<String> tl = new ThreadLocal<>(); 移除强引用(tl 不再指向该实例) tl = null;通常解决方式是使用后主动调用ThreadLocal.remove()清除value。
6.应用场景
存储登录用户信息
场景:当用户发起请求时,拦截器解析 Token 获取登录用户信息后存入ThreadLocal, Controller、Service、DAO层都可以从ThreadLocal中要拿到当前用户的信息,不需要每层方法都传额外参数。
不使用 ThreadLocal,必须每层传用户 ID
Controller 层 // 从token解析出当前用户id=1001 Long loginUserId = getUserIdByToken(request); // 必须把id当做参数传给service orderService.createOrder(loginUserId, goodsId); Service 层 public void createOrder(Long userId, Long goodsId) { Order order = new Order(); order.setCreateUserId(userId); // 接收上层传过来的id orderMapper.insert(order, userId); // 还要继续传给mapper } Mapper 层 void insert(Order order, Long userId);使用 ThreadLocal,不用传递任何参数
写一个全局工具类存放用户信息: public class UserContext { private static ThreadLocal<Long> userIdTL = new ThreadLocal<>(); // 存入 public static void setUserId(Long id) { userIdTL.set(id); } // 取出 public static Long getUserId() { return userIdTL.get(); } // 清理 public static void clear() { userIdTL.remove(); } }Controller层完全不用传 id 给 service orderService.createOrder(goodsId); // 只传业务参数,不用传用户id Service层直接自己获取,不需要上层传入 public void createOrder(Long goodsId) { Long userId = UserContext.getUserId(); // 自己拿,不靠传参 Order order = new Order(); order.setCreateUserId(userId); orderMapper.insert(order); // mapper也不用传userId }线程池
1.什么是线程池
线程池是一种池化技术,预先创建好一组线程,当有任务要处理时,直接从线程池中获取线程来处理任务,处理完后不会立即销毁线程,而是等待下一个任务执行。从而避免频繁创建线程和销毁线程带的性能开销。
2.如何创建线程池?
1.ThreadPoolExecutor构造函数直接创建
2.Executors工具类
1、newFixedThreadPool():创建一个固定数量的线程池
2、newCachedThreadPool():创建一个可缓存的线程池,
- 初始核心线程数为 0,最大线程数为
Integer.MAX_VALUE(约 20 亿)。 - 线程复用:当有新任务需要处理,使用空闲的线程
- 线程回收:超过60秒后线程未使用,线程自动消除
3、newSingleThreadPool():创建一个单线程的线程池
4、newScheduledThreadPool():创建一个定时的线程池
5、newSingleThreadScheduledExecutor():创建一个单线程的定时任务线程池
创建案例:
public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(3); for (int i =0;i<5;i++){ int testId = i; executorService.execute(new Runnable() { @Override public void run() { System.out.println("线程:" + Thread.currentThread().getName() + " 执行任务" + testId); try { Thread.sleep(5000); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); } executorService.shutdown(); }注意事项
默认使用的队列类型可能带来OOM风险,因此在实际开发中(尤其是服务端),阿里巴巴开发规范不推荐使用Executors直接创建线程池,而是推荐显式使用 ThreadPoolExecutor。
3.七大核心参数(ThreadPoolExecutor 构造函数)
Executors 三大方法的底层,本质上都是调用ThreadPoolExecutor来创建线程池。
ThreadPoolExecutor executor = new ThreadPoolExecutor( int corePoolSize, 核心线程数 int maximumPoolSize, 最大线程数 long keepAliveTime, 空闲存活时间 TimeUnit unit, 时间单位 BlockingQueue<Runnable> workQueue, 阻塞队列 ThreadFactory threadFactory, 线程工厂 RejectedExecutionHandler handler 拒绝策略 );核心线程数:线程池长期保持存活的线程数量
最大线程数:线程池最多能创建多少个线程。任务队列已满,且当前创建的线程数量已经达到最大线程数,触发拒绝策略。
空闲存活时间:非核心线程空闲后不会立即回收,而是空闲的线程达到存活时间后就会被回收
时间单位:给存活时间加单位,这个时间是 “秒”“毫秒” 还是 “分钟”
阻塞队列:暂时存放待执行的任务。
线程工厂:专门创建线程的地方,而不是直接调用new Thread()
拒绝策略:当任务队列已满且线程数达到最大线程数时,如何处理新任务的方式
4.四种拒绝策略
| 策略类名 | 策略名称 | 行为说明 |
|---|---|---|
AbortPolicy(默认) | 中止策略 | ❌ 抛出RejectedExecutionException异常,拒绝处理新任务 |
CallerRunsPolicy | 调用者运行策略 | ✅ 不让线程池处理新任务,由提交任务的线程自己执行该任务 |
DiscardPolicy | 丢弃策略 | 🚫 不处理新任务,直接丢弃任务,不会报错、不会阻塞 |
DiscardOldestPolicy | 丢弃最老策略 | 🔁 丢弃最早的未处理的任务 |
| 自定义拒绝策略 | 通过实现RejectedExecutionHandler接口实现自定义拒绝策略 |
5.线程池如何合理设置线程数?
线程池中的线程数如何合理的设置,具体要看是执行什么任务类型,任务类型分为:CPU 密集任务、IO 密集型任务。
- “4 核 CPU” 是指一个物理 CPU 芯片里,包含 4 个独立的核心,每个核心都能单独执行任务。
1.CPU 密集型任务:任务的执行主要依赖 CPU 的计算能力,大部分时间都在进行逻辑运算、数据处理、循环操作等。线程大部分时间都在占用 CPU 进行计算,特性决定了其线程数不能过多
建议核心线程数=CPU 核心数 + 1
建议最大线程数=核心线程数
- 例如:图片处理、大量数学计算、复杂算法
- 场景一:后台数据处理服务,特点稳定流量、任务处理时间长(秒级)、允许一定延迟,线程池的配置可设置如下
new ThreadPoolExecutor( 8, // corePoolSize = 8(8核CPU) 8, // maximumPoolSize = 8(禁止扩容, 避免资源耗尽) 0, TimeUnit.SECONDS, // 不回收线程 new ArrayBlockingQueue<>(1000), // 有界队列, 容量1000 new CallerRunsPolicy() // 队列满后由调用线程执行 );
2.IO 密集型任务:任务的执行过程中,大部分时间都在等待外部 IO 操作完成(如等待磁盘读写、网络响应、数据库返回结果等)。线程的大部分时间都处于等待状态,CPU处于空闲状态,需要更多线程来 “填满” CPU 的空闲时间。
建议核心线程数=CPU核心数 × 2 或更多
建议最大线程数=核心线程数 x 1.5
- 例如:数据库查询、文件读写
- 场景二:电商场景,特点瞬时高并发、任务处理时间短,线程池的配置可设置如下
new ThreadPoolExecutor( 16, // corePoolSize = 16(假设8核CPU × 2) 32, // maximumPoolSize = 32(突发流量扩容) 10, TimeUnit.SECONDS, // 非核心线程空闲10秒回收 new SynchronousQueue<>(), // 不缓存任务, 直接扩容线程 new AbortPolicy() // 直接拒绝, 避免系统过载 );
6.线程池工作原理
1、默认情况下核心线程不会预先创建,而是有任务提交时逐步创建核心线程。
2、如果核心线程满时,有新任务提交则放入任务队列中等待执行。
3、如果任务队列也满时,会创建非核心线程来处理新任务。
4、如果任务队列已满 + 线程数达到最大线程数时,有新任务提交则触发拒绝策略。
5、当非核心线程空闲时间超过设定的空闲存活时间时,非核心线程会被回收。
7.线程池中submit和execute的区别
相同点:
- 都是把任务提交给线程池执行
不同点:
- execute只能提交Runnable类型的任务;
- submit能提交 Runnable 和 Callable 类型的任务
AQS
1.AQS是什么?
AQS (AbstractQueuedSynchronizer)翻译过来的意思就是 “抽象队列同步器”。
本质是Java中的一个抽象类,他的作用是构建锁、同步器、线程协作工具类的基础框架,底层依靠CLH 双向阻塞队列管理等待线程,CAS来保证原子操作,volatile来保证变量可见性, 最终实现线程的同步。
可重入锁(ReentrantLock)、可重入读写锁(ReentrantReadWriteLock)、信号量(Semaphore),都是基于AQS实现。
2.AQS核心思想
AQS使用一个被Volatile修饰的int类型的 state 变量来表示共享资源的状态,如:
ReentrantLock(可重入锁):state表示锁的重入次数。Semaphore(信号量):state表示剩余的可用许可数。CountDownLatch(倒计时器):state表示剩余未递减的计数。
线程获取资源方式,是通过 CAS 尝试修改 state变量的值,修改失败的线程会被封装成一个Node 节点放入到CLH双向阻塞队列中进行排队,只有当持有资源的线程释放资源时,才会唤醒队列中的线程。 被唤醒的线程会重新尝试通过 CAS 获取资源,修改成功:获取资源,执行业务;修改失败:重新进入队列继续阻塞等待。
3.AQS如何使用?
AQS 本身是抽象类,不能直接使用。 使用方式就是:写一个类继承 AQS,重写 tryAcquire 和 tryRelease 方法, 利用 AQS 提供的 state、CAS、队列、park/unpark 能力
// 自己写一把锁 class MyLock { // 内部类 继承 AQS private class Sync extends AbstractQueuedSynchronizer { // 重写:尝试获取锁 @Override protected boolean tryAcquire(int arg) { // CAS 把 state 从 0 → 1 if (compareAndSetState(0, 1)) { // 抢到了! setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } // 重写:尝试释放锁 @Override protected boolean tryRelease(int arg) { setExclusiveOwnerThread(null); // state 设回 0 setState(0); return true; } } private Sync sync = new Sync(); // 对外提供 lock public void lock() { sync.acquire(1); // AQS 自带方法 } // 对外提供 unlock public void unlock() { sync.release(1); // AQS 自带方法 } }MyLock lock = new MyLock(); lock.lock(); // 抢锁 try { // 业务代码 } finally { lock.unlock(); // 释放锁 }4.三大同步工具(AQS 同步工具类)
【Java并发工具三剑客】CountDownLatch、CyclicBarrier和Semaphore详解 - 佛祖让我来巡山 - 博客园
1.CountDownLatch(闭锁)
内部维护计数器,计数器的初始值代表需要等待的次数,子线程完成任务后调用countDown()减少计数,通过await () 来阻塞主线程执行,当计数归零主线程才开始执行。
2.CyclicBarrier(循环屏障)
一组线程互相等待,当所有线程到达屏障点后,再一起继续执行;执行后自动重置屏障重复使用。
3.Semaphore(信号量)
通过维护固定数量的许可证,用来限制同一时间访问共享资源的线程数量,线程执行前调用acquire()获取许可证,如果没有许可证则阻塞等待;线程使用完资源后调用release()归还许可证
4.三者的区别
核心目的不同
- CountDownLatch:等待事件完成再持续后续逻辑
- CyclicBarrier:线程组在屏障点相互等待,全部到位后再持续后续逻辑
- Semaphore:限制并发访问资源
复用性不同
- CountDownLatch:计数器归零后失效,不能重复使用
- CyclicBarrier:一轮结束自动重置计数器,可重复多次使用
- Semaphore:许可释放后将其归还,可重复多次使用
适用场景不同
- CountDownLatch 是任务协调器,解决"主线程等子线程"的同步问题
- CyclicBarrier 是线程同步器,解决"多个线程协同"问题
- Semaphore 是资源控制器,解决"并发访问量限制"问题
5.三者的应用场景
CountDownLatch
本地测试接口:让一组线程在指定时刻统一触发执行业务,模拟高并发场景。
数据汇总:比如数据详情页需要同时调用多个接口获取数据,并发请求获取到数据后,将数据进行汇总
CyclicBarrier
- 多阶段数据处理:多线程执行多轮任务,每一轮都要等待所有线程就绪再开始下一阶段
Semaphore
- 单机限流:可以控制同一时间访问接口、数据库、第三方服务的并发请求数量
6.三者的坑点、异常、业务问题(重点)
CountDownLatch
1.子线程抛异常没执行 countDown () 会发生什么?怎么解决?
现象:假设总任务数 3,1 个子线程没执行 countDown () ,计数器只会降到 2,主线程会一直阻塞。
解决方案:
- 使用try-catch 语句块中的 finally 执行 countDown (),无论正常走完还是抛异常,
finally一定会执行 - 使用带超时 await(),超时后主线程自动放行,避免永久阻塞
2.计数器归 0 后再调用 await () 线程会阻塞吗?
await ()的底层逻辑是:先判断state是否等于 0 ,如果是,直接放行。
CyclicBarrier
1.线程数量少于 等待线程总数 会怎样?
现象:CyclicBarrier barrier = new CyclicBarrier (3); // 等待线程总数 = 3,必须 3 个线程调用 await 才放行,但只开 2 个线程执行 await(),会导致所有线程全部阻塞;
解决方案:使用带超时 await(),超时后抛出异常来结束阻塞
Semaphore
1.release () 为什么要写在 finally?不写会有什么后果(许可泄露)
现象:线程执行业务代码时一旦抛出异常,代码会直接跳出,release 没机会执行,许可证无法归还,当所有许可证弄丢时,后续调用 acquire ()获取许可的线程会全部阻塞卡死。
2.同一线程多次 release () 会怎么样?
现象:Semaphore 不会记录「哪个线程持有许可」,只单纯维护总可用许可数量; 同一线程多次 release () ,会凭空增加可用许可,破坏限流逻辑。
并发容器
BlockingQueue
JDK 内置 4 种常用阻塞队列
1. ArrayBlockingQueue 有界阻塞队列(生产环境一般都用)
- 必须指定固定容量,有边界;
- 队列满后才会创建非核心线程;
2. LinkedBlockingQueue 无界阻塞队列
- 不指定指定固定容量,任务无限放入队列中
- 永远不会走到创建非核心线程,最大线程参数失效;
3. SynchronousQueue 不存储元素队列
- 无容量,插入任务必须等待有线程立刻消费;
- 提交任务直接尝试新建线程,容易快速达到最大线程;
4. PriorityBlockingQueue 优先级无界队列
- 按任务优先级排序执行,无界;
- 不按提交顺序,适合需要优先级调度的任务。
ConcurrentHashMap
他是HashMap的线程安全版本,与HashMap不同的是,他不允许为null键或值,底层在JDK1.7时靠Segment 分段锁保证线程安全,在JDK1.8后靠CAS + synchronized 保证线程安全。
- 分段锁:通过将数据分成多个段(Segment),每个分段拥有 ReentrantLock 锁;
ConcurrentHashMap的数据结构
JDK1.7:Segment数组+HashEntry数组+链表的结构
ConcurrentHashMap内部使用了一个名为segments 的数组结构,数组中每个元素是 Segment 对象,该内部类包含一个HashEntry数组用来存储数据,并且继承自ReentrantLock
//Segment数组 final Segment<K,V>[] segments;static final class Segment<K,V> extends ReentrantLock implements Serializable { transient volatile HashEntry<K,V>[] table; }JDK1.8:数组+链表+红黑树的结构
- 默认数组大小:16,负载因子:0.75
- 链表长度 ≥ 8时转换为红黑树(树化)
- 树节点数 ≤ 6时退化为链表(反树化)
应用场景
只要是多线程并发读写的场景,无论数据量大小,都优先使用 ConcurrentHashMap; 如果是单线程,使用 HashMap。例如:
- 网站访问量统计:大量用户同时访问网站,多条线程实时累加每个页面的点击次数。
- 商品库存实时更新:大量用户同时读取商品库存,后台线程实时更新剩余库存。
CompletableFuture
Java8 新增的异步多线程工具,用来简化异步任务、线程等待、任务编排的。