Java并发核心面试知识点
前言
本文为自用复习笔记,核心用于梳理Java并发编程核心高频考点,方便后续快速回顾、巩固底层细节,规避面试易踩坑点,查漏补缺遗忘的基础原理。
本次笔记将围绕线程生命周期与start/run区别、线程池7大参数+工作流程+拒绝策略、synchronized锁升级全流程、volatile两大核心特性、ThreadLocal原理与内存泄漏五大面试必考模块展开,同步对比JDK8与最新JDK21的差异,贴合后端开发、Java全栈、架构岗面试标准。
本文结合源码深度拆解、面试高频设问、落地避坑技巧整理而成,和HashMap笔记逻辑保持一致,方便成套复习。
PS:
最近跳槽准备Java全栈架构岗,并发是面试必问模块,之前业务写多了,底层源码细节、版本差异、面试坑点很容易遗忘,专门整理成逐句可背诵的复习笔记,把原理、流程、易错点一次性捋清楚,面试不慌。
一、Thread 线程核心:生命周期、start()与run()区别
本模块为多线程基础开篇题,面试100%会考,核心区分API用法、底层逻辑、JDK版本差异,杜绝死记硬背。
1.1 start() 与 run() 核心区别(面试必问第一题)
废话少说,先上Thread类核心源码,对应JDK8原生实现:
java
// 线程启动核心方法,native修饰,JVM底层实现
public synchronized void start() {
// 线程状态校验,只能启动一次,否则抛出IllegalThreadStateException
if (threadStatus != 0)
throw new IllegalThreadStateException();
// 线程加入线程组
group.add(this);
boolean started = false;
try {
// 本地方法,真正创建操作系统线程,启动线程
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
}
}
}
// 业务逻辑执行方法,Runnable接口重写方法
@Override
public void run() {
if (target != null) {
target.run();
}
}
// native本地方法,JVM底层实现,与操作系统交互创建线程
private native void start0();
1.1.1 核心执行流程与本质区别
start() 方法:真正的线程启动方法
-
校验线程状态,一个线程只能调用一次start(),重复调用直接抛异常,这是面试高频坑点;
-
通过native本地方法start0(),向操作系统申请创建内核线程,完成线程初始化,将线程转为就绪状态;
-
等待操作系统CPU时间片调度,调度成功后,自动回调run()方法执行业务逻辑;
-
核心作用:开启新的独立执行线程,实现多线程并发执行。
run() 方法:普通的业务逻辑方法
-
就是Runnable接口的普通重写方法,没有任何特殊语法修饰,就是普通类的成员方法;
-
直接调用run()方法,不会创建新线程,只会在当前主线程中同步执行run()里面的代码,完全没有多线程效果;
-
核心作用:封装线程需要执行的业务逻辑,只能被start()启动后,由JVM自动回调执行。
1.1.2 极简对比表格(面试直接背诵)
| 对比维度 | start() 方法 | run() 方法 |
|---|---|---|
| 底层修饰 | synchronized 修饰 + native本地方法 | 普通接口重写方法,无特殊修饰 |
| 线程创建 | 会创建新的操作系统线程 | 不会创建任何线程,复用当前线程 |
| 调用限制 | 单个线程只能调用1次,重复调用抛异常 | 可以无限次重复调用,无任何限制 |
| 执行效果 | 多线程并发执行,异步逻辑 | 同步串行执行,和普通方法无区别 |
| 核心作用 | 启动线程,触发线程生命周期 | 封装线程业务执行逻辑 |
1.1.3 面试易错点
-
启动线程必须用start(),直接调run()=白写,没有多线程效果;
-
线程启动后进入终止状态,无法再次调用start()重启,只能新建线程对象。
1.2 线程完整生命周期:JDK8 vs JDK21
线程生命周期由Thread类内部枚举State定义,JDK8为官方标准6状态,JDK21引入虚拟线程后,状态语义、底层实现有优化,面试必须区分清楚。
1.2.1 JDK8 标准6种线程状态(必考,全版本通用基础)
源码枚举定义:
java
public enum State {
NEW, // 新建状态
RUNNABLE, // 可运行状态
BLOCKED, // 阻塞状态
WAITING, // 无限等待状态
TIMED_WAITING, // 限时等待状态
TERMINATED; // 终止状态
}
-
NEW(新建状态):线程对象被创建,还没有调用start()方法,仅存在Java堆内存中,未和操作系统线程关联;
-
RUNNABLE(可运行状态):调用start()之后进入该状态,包含「就绪」和「运行中」两个子状态,就绪等待CPU调度,调度后运行,是Java线程唯一的"执行中"状态;
-
BLOCKED(阻塞状态) :线程等待synchronized独占锁,进入同步代码块/方法时,锁被其他线程占用,就会进入该状态,仅针对synchronized锁;
-
WAITING(无限等待状态) :线程主动进入无限等待,必须被其他线程唤醒才能恢复,不会自动苏醒,触发方法:
Object.wait()、Thread.join()、LockSupport.park(); -
TIMED_WAITING(限时等待状态) :线程主动进入带超时时间的等待,超时时间到自动苏醒,无需其他线程唤醒,触发方法:
Thread.sleep(long)、Object.wait(long)、Thread.join(long); -
TERMINATED(终止状态) :线程run()方法执行完毕、异常终止未捕获,线程生命周期结束,终止后无法恢复,不能再次启动。
1.2.2 JDK21 虚拟线程优化差异
-
线程状态枚举名称、数量完全不变,兼容JDK8语法,面试基础答案不变;
-
虚拟线程无独立操作系统内核线程,RUNNABLE状态下,阻塞、等待操作不会阻塞平台线程,CPU利用率大幅提升;
-
虚拟线程的BLOCKED、WAITING状态为纯用户态管控,不再和操作系统线程挂钩,生命周期开销极低。
1.2.3 面试核心考点
线程只有6种状态,RUNNABLE包含运行和就绪,没有专门的"运行状态";BLOCKED仅针对synchronized锁,Lock锁的等待是WAITING状态,二者严格区分。
二、ThreadPoolExecutor 线程池:7大参数、工作流程、拒绝策略
线程池是Java并发面试TOP1考点,和HashMap put流程、扩容机制同等重要,必须把参数含义、执行步骤、拒绝策略、设计逻辑背到脱口而出。
2.1 线程池7大核心参数(全拆解,一个都不能漏)
JDK8线程池核心实现类为ThreadPoolExecutor,完整构造方法源码:
java
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 阻塞任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略处理器
) {
// 源码校验逻辑省略
}
7个参数逐个拆解,附带面试设问点、默认值、设计逻辑:
-
corePoolSize 核心线程数线程池长期存活的核心线程数量,默认情况下,核心线程会一直存活,即使空闲也不会被回收,是线程池的基础承载能力,建议设置为CPU核心数。
-
maximumPoolSize 最大线程数线程池能创建的最大线程总数,包含核心线程+非核心临时线程,是线程池的承载上限,当任务爆发时,最多能创建这么多线程处理任务。
-
keepAliveTime 非核心线程空闲存活时间非核心临时线程,空闲时间超过该阈值,就会被线程池回收销毁,释放资源,核心线程默认不受该参数控制。
-
unit 时间单位存活时间的单位,常用秒、毫秒,配合keepAliveTime使用。
-
workQueue 阻塞任务队列 核心线程全忙时,新增任务会进入该队列排队等待执行,是线程池的缓冲核心,常用队列:
ArrayBlockingQueue(有界队列)、LinkedBlockingQueue(无界队列,慎用)、SynchronousQueue(不存储元素的同步队列)。 -
threadFactory 线程工厂用于创建新线程的工厂,默认使用Executors默认工厂,建议自定义线程工厂,设置线程名称、优先级、守护线程属性,方便问题排查。
-
handler 拒绝策略线程池达到最大线程数、阻塞队列已满,无法接收新任务时,触发拒绝策略,处理新增任务,JDK内置4种标准实现。
2.2 线程池完整工作流程(分步拆解,面试必背)
完全对标HashMap put方法流程,按执行顺序分步拆解,一步都不能错:
-
任务提交,判断核心线程数 调用execute()/submit()提交任务,首先判断当前运行的线程数,是否小于核心线程数corePoolSize;如果小于,直接创建新的核心线程执行任务,即使有核心线程空闲,不会复用空闲线程。
-
核心线程已满,进入阻塞队列排队 如果当前线程数≥核心线程数,判断阻塞队列workQueue是否已满;如果未满,将新任务加入阻塞队列排队等待,等待空闲核心线程拉取任务执行。
-
队列已满,创建非核心临时线程 如果阻塞队列已满,判断当前线程总数,是否小于最大线程数maximumPoolSize;如果小于,立即创建非核心临时线程执行任务,快速处理爆发任务。
-
达到最大线程数,触发拒绝策略 如果当前线程总数已经达到最大线程数,队列也已满,线程池完全无法接收新任务,执行预设的拒绝策略,处理新增任务。
-
任务执行完毕,线程回收逻辑任务执行完成后,线程会循环从阻塞队列拉取任务执行;非核心线程空闲时间超过keepAliveTime,就会被回收,核心线程默认一直存活。
面试易错点
核心线程未满时,不会复用空闲线程,一定会新建核心线程;无界队列会导致最大线程数、拒绝策略完全失效,生产环境绝对禁止使用无界队列。
2.3 JDK内置4种拒绝策略
全部实现RejectedExecutionHandler接口,逐个拆解含义、适用场景、面试考点:
-
**AbortPolicy(默认拒绝策略)**直接抛出未检查异常
RejectedExecutionException,阻止任务提交,不丢弃任务,让开发者感知到任务被拒绝,生产环境常用,方便告警排查。 -
CallerRunsPolicy(调用者执行策略)不抛弃任务,不抛出异常,将任务回退给提交任务的主线程同步执行,降低主线程提交任务的速率,实现隐式限流,不会丢失任务,适合低并发、不允许任务丢失的场景。
-
**DiscardPolicy(静默丢弃策略)**直接静默丢弃当前无法处理的新任务,不抛出任何异常,不做任何处理,完全无感知,生产环境绝对禁止使用,会导致任务无声丢失。
-
**DiscardOldestPolicy(丢弃最老任务策略)**丢弃阻塞队列中,排队时间最长的老任务,腾出队列空间,将当前新任务加入队列,尝试提交执行,会丢失历史排队任务,慎用。
面试核心:默认策略是AbortPolicy,生产环境优先用默认策略+自定义告警,其次用CallerRunsPolicy,禁止用静默丢弃策略。
三、synchronized 锁:完整锁升级全流程
synchronized是Java内置独占锁,JDK1.6之前是重量级锁,性能极差;JDK1.6之后引入锁升级机制,大幅优化性能,是面试必考的底层原理题,全程无锁→偏向锁→轻量级锁→重量级锁,一步都不能错。
3.1 锁升级核心前提
-
synchronized锁对象,是Java对象头中的Mark Word,锁状态、线程ID、指针全部存在对象头里,锁升级本质就是Mark Word里的标记位修改;
-
锁升级只能单向升级,不能降级,只会从无锁逐步升级为重量级锁,不会逆向回退,只有在全局安全点(STW)才会做锁降级优化;
-
锁升级的核心设计逻辑:无竞争用最轻的锁,竞争逐步升级,用最低的开销实现线程同步,用空间换时间,减少内核态切换开销。
3.2 完整锁升级流程(分步拆解,底层原理)
阶段1:无锁状态
锁对象没有任何线程竞争,对象头Mark Word存储对象的哈希码、分代年龄,没有任何锁标记,线程可以随意访问,无任何同步开销。
阶段2:偏向锁(默认开启,JDK15默认关闭,JDK21已废弃)
适用场景:只有一个线程反复获取锁,完全无多线程竞争,是最轻量级的锁,几乎无同步开销。
-
第一个线程访问同步代码,获取锁时,锁对象Mark Word改为偏向模式,CAS操作将当前线程ID写入对象头;
-
后续该线程再次获取锁,无需任何CAS操作、无需同步判断,只需要校验对象头里的线程ID是自己,就可以直接获取锁,开销极低;
-
一旦有第二个线程来竞争锁,偏向锁立即撤销,停止偏向模式,升级为轻量级锁。
阶段3:轻量级锁(自旋锁)
适用场景:多线程交替获取锁,锁持有时间极短,无长时间锁竞争,线程空循环自旋等待锁,避免进入内核态阻塞。
-
偏向锁撤销后,升级为轻量级锁,当前线程在自己的栈帧中创建锁记录(Lock Record);
-
通过CAS操作,将锁对象的Mark Word,替换为指向当前线程栈帧中锁记录的指针;
-
CAS替换成功,当前线程成功获取轻量级锁,执行同步代码;
-
CAS替换失败,说明有其他线程持有锁,当前线程自适应自旋等待,循环尝试CAS获取锁,不进入阻塞;
-
自旋一定次数仍未获取到锁,轻量级锁升级为重量级锁。
阶段4:重量级锁
适用场景:多线程激烈竞争锁,锁持有时间长,自旋等待浪费CPU,是JDK1.6之前的原生锁,底层依赖操作系统互斥量Mutex实现。
-
轻量级锁自旋失败,升级为重量级锁,锁对象Mark Word指向操作系统互斥量指针;
-
未获取到锁的线程,全部进入操作系统阻塞队列,线程状态变为BLOCKED,释放CPU资源,不会空自旋浪费性能;
-
持有锁的线程释放锁后,操作系统会唤醒阻塞队列中的线程,重新竞争锁;
-
核心缺点:线程阻塞/唤醒需要用户态和内核态切换,开销极大,性能最低。
3.3 面试核心考点与易错点
-
锁升级顺序固定:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,单向不可逆;
-
JDK21已经完全废弃偏向锁,默认直接启动轻量级锁,面试回答基础流程不变,补充版本差异即可;
-
synchronized是可重入锁,同一个线程多次获取锁,不会自己阻塞自己;
-
锁升级的核心目的:减少重量级锁的使用,降低线程上下文切换的开销。
-
volatile修饰的共享变量,线程修改后,会立即强制刷新到主内存,同时失效其他所有线程工作内存中的变量副本;
-
其他线程读取该变量时,必须重新从主内存拉取最新值,保证所有线程总能读到变量的最新值,彻底解决可见性问题;
-
底层实现:缓存一致性协议(MESI),CPU层面实现缓存同步。
特性2:禁止CPU指令重排序
指令重排:CPU和编译器为了提升执行效率,会在不改变单线程执行结果的前提下,调整代码指令的执行顺序,单线程下无问题,多线程高并发下会导致逻辑异常。
-
volatile修饰的变量,会在指令序列中插入内存屏障,严格限制指令的执行顺序,禁止屏障前后的指令重排;
-
写屏障:volatile变量写操作之前的所有指令,必须全部执行完毕,才能执行写操作,写操作之后立即刷新到主内存;
-
读屏障:volatile变量读操作之后的所有指令,必须等读操作执行完毕,从主内存拉取最新值后,才能执行;
-
经典应用场景:单例模式双重校验锁(DCL),必须用volatile修饰实例对象,禁止指令重排,避免线程获取到未初始化完成的半初始化对象。
4.2 面试必考三大核心误区(必背)
-
volatile 绝对不保证原子性volatile只保证单次读、单次写的原子性,不保证复合操作的原子性。比如i++操作,分为「读取i值→计算+1→写入i值」三步,volatile无法保证三步操作的原子性,并发下依然会出现线程安全问题,解决原子性必须用synchronized、Lock或Atomic原子类。
-
volatile 不能替代锁volatile只能解决可见性、有序性问题,无法解决原子性问题,而线程安全的三大要素是:原子性、可见性、有序性,三者缺一不可,volatile无法实现完整的线程同步。
-
volatile 修饰的变量,每次读写都必须操作主内存不能使用线程本地缓存,所以volatile变量的读写性能,比普通变量略低,不要滥用volatile,只有需要保证多线程可见性、禁止重排的场景才使用。
4.3 最佳使用场景
-
状态标记量:多线程控制的开关变量,比如线程停止标记、初始化完成标记;
-
双重校验锁单例模式,禁止指令重排;
-
无需原子性,只需要保证多线程可见性的共享变量。
五、ThreadLocal 核心原理与内存泄漏解决方案
ThreadLocal是面试高频压轴题,核心考察底层存储结构、核心原理、内存泄漏的完整根源、解决方案,很多人只背表面答案,底层逻辑一问就崩。
5.1 ThreadLocal 核心底层原理
先打破误区:ThreadLocal不是存储数据的容器,数据根本不存放在ThreadLocal中,这是90%的人面试答错的第一个点。
核心源码结构:
-
每个Thread线程对象内部,都有一个成员变量
ThreadLocal.ThreadLocalMap threadLocals,这才是真正存储数据的容器; -
ThreadLocalMap是ThreadLocal的静态内部类,底层是Entry数组,以ThreadLocal对象为key,存储的业务数据为value;
-
ThreadLocal的set/get/remove方法,本质都是操作当前线程自身的ThreadLocalMap,不同线程的Map相互隔离,互不干扰,天然实现线程封闭,不存在线程安全问题。
核心作用:实现线程内数据全局共享,线程间数据完全隔离,同一个线程内,任意代码位置都能通过ThreadLocal获取到存储的数据,无需层层传参。
5.2 ThreadLocal 内存泄漏的完整根源
前提:ThreadLocalMap的Entry结构设计
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
// key为ThreadLocal对象,存入弱引用
super(k);
// value为强引用
value = v;
}
}
内存泄漏的完整链路,一步都不能错:
-
Entry的key(ThreadLocal对象)是弱引用 ,value是强引用;
-
GC回收时,只要ThreadLocal对象没有外部强引用,就会被GC回收,Entry的key变为null;
-
只要当前线程还存活(比如线程池中的核心线程,永久存活),线程对象就一直强引用ThreadLocalMap,Map就一直强引用Entry,Entry就一直强引用value;
-
key已经为null,这个value对象永远无法被访问、无法被GC回收,一直占用堆内存,造成内存泄漏;
-
长时间运行,大量key为null的Entry堆积,最终会导致OOM内存溢出。
面试核心:内存泄漏的根本原因是key为null的Entry,value强引用无法释放,线程长期存活导致value无法GC回收,不是弱引用的问题,弱引用是为了减少内存泄漏,而非导致内存泄漏。
5.3 内存泄漏解决方案与最佳实践
-
强制规范:每次使用完ThreadLocal,必须手动调用remove()方法这是根治内存泄漏的唯一方案,remove()方法会主动删除当前线程Map中对应的Entry,清空key和value,断开强引用,GC可以正常回收,杜绝内存泄漏。
-
杜绝使用static修饰的ThreadLocal对象
static修饰的ThreadLocal生命周期和类加载器绑定,永远不会被GC回收,key永远不会为null,会导致Entry和value永久强引用,加剧内存泄漏。
-
线程池场景下,必须严格清理ThreadLocal
线程池线程会复用,上一个任务存储的数据,会残留在ThreadLocalMap中,导致下一个任务读到脏数据,同时引发内存泄漏,任务执行完毕必须手动remove。
面试核心考点总结
-
数据存在当前线程的ThreadLocalMap中,不是ThreadLocal本身;
-
内存泄漏根源:线程存活+key为null+value强引用无法释放;
-
根治方案:使用完毕手动调用remove()方法,没有其他替代品。
结尾PS
本文和HashMap面试笔记为成套复习资料,覆盖Java后端面试80%的高频考点,所有知识点均贴合源码、面试设问逻辑,可直接逐段背诵。复习时优先背流程、核心结论、易错点,底层原理辅助理解,面试就能稳定输出。
(注:文档部分内容可能由 AI 生成)