线程这东西说难不难说简单也真不简单。前阵子帮一个朋友排查线上服务卡顿的问题日志里全是线程堆积的报错最后定位到是他们业务代码里创建了一大批“野线程”没有统一管理。这种问题在面试里属于“线程基础”范畴但在真实项目里能直接干趴一台8核16G的服务器。所以我觉得有必要把Java线程的基础和实现方式从头到尾捋一遍——这篇文章不搞那些虚的八股我会把四种实现方式、生命周期、同步机制、线程池配置、虚拟线程这些内容结合我自己踩过的坑和排查过的线上问题一次讲透。适合刚学完Java语法准备进阶的朋友也适合面完试想系统梳理的同行。1. 先把底子打好线程到底是什么1.1 进程和线程别再傻傻分不清我见过不少写了两三年Java的同学谈到进程和线程的区别时只能背出“进程是资源分配的最小单位线程是CPU调度的最小单位”这句话再往深问就卡壳。其实用生活化的方式理解特别简单进程就像一家独立的餐厅有自己的厨房内存空间、菜单代码、服务员资源线程就是餐厅里的厨师同一个餐厅可以有好几个厨师同时炒菜共享同一套厨房设备但每个人手里有自己的炒锅和锅铲——对应到技术里就是线程私有的栈空间和寄存器。从操作系统层面看进程拥有独立的内存地址空间一个进程崩了不会直接搞垮另一个进程线程则共享进程的堆和方法区通信成本低但正因为共享才引出了后面一整套线程安全的问题。这也就是为什么Java里new Thread()开线程很轻量而开进程要走IPC通信、代价高得多。1.2 线程的组成控制块、私有存储区、执行栈热词里有一条“线程控制块和私有存储区的关系”这确实是很多人忽略的考点。一个线程在JVM层面主要由三部分组成线程控制块TCBThread Control BlockJVM管理线程用的核心数据结构记录线程ID、状态、优先级、程序计数器等元信息。你可以把它理解成每个厨师的工牌上面写着这个厨师目前是空闲、上菜还是在休息。私有存储区包括虚拟机栈每个线程一个存局部变量、操作数栈和程序计数器记录当前执行到哪条字节码。这部分数据其他线程访问不到所以局部变量天然线程安全。共享资源引用线程对象、目标Runnable对象、所属ThreadGroup等这些是对外可操作的句柄。搞清这个组成后面理解“为什么局部变量线程安全而实例变量不一定安全”就顺理成章了。实例变量存在堆里是大家的“公共冰箱”局部变量存在线程自己的栈里是“私人储物柜”。2. 四种创建线程的方式这么选才对2.1 继承Thread类教学常用生产慎用public class MyThread extends Thread { Override public void run() { System.out.println(线程执行中 Thread.currentThread().getName()); } public static void main(String[] args) { MyThread t new MyThread(); t.start(); } }这种写法最大的问题是Java单继承你一继承Thread就没法再继承别的业务基类了。而且线程任务和线程控制逻辑耦合在一起不够灵活。我有个同事刚入行时写了一个继承Thread的下载任务类后来业务要加缓存逻辑、要复用任务体重构时改得头大。所以我的建议是教学演示可以这么写真实项目里除非是极其简单的脚本否则别这么干。2.2 实现Runnable接口解耦任务和线程public class DownloadTask implements Runnable { Override public void run() { // 下载逻辑 } } // 使用 new Thread(new DownloadTask(), download-1).start();Runnable把“要执行的任务”和“怎么执行任务的线程”拆开了。同一个DownloadTask实例可以丢给多个线程跑也可以配合线程池使用。这是实际项目里最常见的写法也是JDK文档推荐的方式。2.3 实现Callable接口需要返回结果时选它Runnable的run()方法没有返回值也不能抛受检异常。但当你有“提交一个任务等它算完拿到结果”的需求时就得用CallableCallableInteger task () - { Thread.sleep(1000); return 42; }; FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask).start(); Integer result futureTask.get(); // 阻塞等待结果这里有个非常容易踩的坑futureTask.get()是阻塞的。如果你在业务线程里直接调get()而任务本身又依赖这个业务线程的后续操作就会死锁。我在一个爬虫项目里就遇到过主线程get()等任务结果任务又需要主线程往队列里放URL直接卡死。正确做法是给get()加超时futureTask.get(3, TimeUnit.SECONDS)拿不到就先放弃别让一个异常任务拖垮整个调用链。2.4 线程池生产环境唯一推荐的方式前面三种本质上都是“来一个任务new一个线程”这在低并发场景下没问题但一旦任务量上来线程频繁创建销毁的成本会直接拖垮性能。而且线程数量不受控的话CPU频繁上下文切换服务响应时间会肉眼可见地恶化。线程池的核心思路是“线程复用 队列缓冲”。JDK提供了ThreadPoolExecutor这个完备的实现后面第6节我会详细讲参数怎么配。这里先记住一句话写生产代码不要用Executors.newFixedThreadPool()这类快捷方法要直接用ThreadPoolExecutor显式配置。原因后面展开。3. 线程生命周期六种状态和它们的故事3.1 六种状态全景图Java线程的Thread.State枚举定义了六种状态面试高频我直接用表格总结状态含义进入方式离开方式NEW刚new出来还没start()new Thread()调start()RUNNABLE可运行/运行中start()后调sleep/wait/等锁等BLOCKED阻塞等监视器锁进入synchronized块被阻塞获取到锁WAITING无限期等待wait()、join()、park()被notify/notifyAll、被join线程结束TIMED_WAITING限期等待sleep()、wait(1000)、join(2000)时间到或被唤醒TERMINATED已结束run()正常返回或抛异常-注意一个细节BLOCKED和WAITING在等待本质上都“等”但前者是等锁后者是等通知/等时间。JVM层面线程都是“BLOCKED_ON_MONITOR”或“IN_NATIVE”这些状态不过SDK层面六种状态足够用来做问题排查了。3.2 状态流转的几个关键动作start()只能调一次。调第二次会抛IllegalStateException因为线程启动后进入RUNNABLE再start就重复启动了。所以判断一个线程能不能重新运行答案是不能它跑完就是TERMINATED想再跑只能新建线程。sleep()不释放锁。sleep期间线程抱着锁睡觉其他线程进不了同步块。WAITING/BLOCKED的线程持锁状态完全不同这就是为什么说“sleep不会释放监视器锁wait会释放”。join()的本质。A线程调b.join()A会进入WAITING直到b线程死亡。它内部其实是基于wait/notify实现的所以join必须在持有b对象锁的线程里调用——不对join()的wait是在b对象上但不需要调用方提前持有锁因为Thread类内部处理了。这个细节很多人搞错面试被追问会露怯。4. 线程安全与同步并发编程的核心博弈4.1 为什么会有线程不安全一句话多线程同时读写共享变量且至少一个线程在写没有同步机制控制访问顺序就会出现竞态条件。经典的i问题public class Counter { private int count 0; public void increment() { count; // 不是原子操作是读取-加1-写回三步 } }两个线程同时执行count可能都读到count5各自加1写回6结果少了1次。这在现实中的例子就是两个人同时往同一个银行账户转账都先查余额、再加钱、再写回最后一笔转账的更新覆盖了另一笔。4.2 synchronized的三种用法锁的粒度是核心修饰实例方法锁的是this对象进入方法前要拿this的监视器锁。修饰静态方法锁的是Class对象因为静态方法属于类级别不管new多少个实例锁只有一个。修饰代码块锁的是括号里指定的对象粒度最灵活。public synchronized void add() { ... } // 锁this public static synchronized void addStatic() { ... } // 锁Class public void addBlock() { synchronized (lockObj) { ... } // 锁指定对象 }经验之谈能锁代码块就别锁整个方法。锁的粒度越小并发度越高。我之前优化过一个报表系统的导出功能原来整个方法上都加了synchronized导致多个用户导出时互相排队明明只需要保护中间一段写文件的逻辑硬是把前面的参数校验和后面的日志记录也锁住了性能差得离谱。4.3 volatile的可见性只保证“看得到”不保证“不打架”volatile解决的是可见性问题——一个线程改了变量另一个线程能立刻看到。但它不保证原子性不能解决i这种复合操作的竞态。适合用volatile的场景是“开关标志位”private volatile boolean running true; public void stop() { running false; } public void runLoop() { while (running) { // 业务逻辑 } }这里如果不用volatile工作线程可能一直读着缓存里的旧值循环永远停不下来。很多人写守护线程时踩过这个坑。记住volatile适合一写多读的简单标志不适合计数器这种需要“读改写”的场景。计数用AtomicInteger或LongAdder。4.4 死锁四个条件缺一不可死锁是线程进阶绕不开的硬骨头。经典场景是“哲学家就餐”——两个线程各自持有一把锁同时在等对方手里的锁。产生死锁需要四个条件同时成立互斥条件资源只能被一个线程持有持有并等待线程持有资源A又在等资源B不可剥夺资源不能被其他线程强行拿走循环等待存在线程环A等BB等A排查死锁最实用的工具是jstack它会输出Found one Java-level deadlock的提示并且明确指出哪些线程持有锁、正在等哪把锁。我在一次生产事故里用它三分钟就定位到了问题。避免死锁的实战原则尽量使用tryLock(long, TimeUnit)并设置超时拿不到锁就放弃。多个资源需要加锁时保证所有线程都按同一个全局顺序加锁——比如先锁ID小的对象再锁ID大的。缩小锁的范围减少嵌套锁。前阵子看一个业务代码在一个synchronized方法里又嵌套了另一个synchronized两个方法可能被不同线程按反序调用就形成了循环等待。改成统一按订单号hash排序加锁之后问题直接消失。5. 线程池生产配置与那些“表面工作”5.1 ThreadPoolExecutor七个参数逐一拆解ThreadPoolExecutor executor new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲保活时间 TimeUnit.SECONDS, // 时间单位 workQueue, // 任务队列 threadFactory, // 线程工厂 rejectedHandler // 拒绝策略 );执行流程我用大白话讲新任务进来时如果当前线程数小于核心线程数直接开新线程跑如果达到核心线程数任务先塞进队列队列满了之后才开始把线程数往最大线程数扩最大线程数也满了队列也满了就走拒绝策略。很多人以为核心线程数满了马上开新线程其实是先入队队列满了才扩容——这个顺序面试常问写代码也容易误解。5.2 阻塞队列怎么选JDK提供了几大常用队列各有侧重队列特点适用场景ArrayBlockingQueue有界数组队列公平/非公平可配需要控流时首选避免任务无限堆积LinkedBlockingQueue可选有界/无界链表结构无界时吞吐好但可能内存溢出SynchronousQueue不存任务直接交给线程配合maximumPoolSize实现“不排队直扩”PriorityBlockingQueue支持优先级需要按紧急程度处理任务时我在一个消息推送项目里用无界LinkedBlockingQueue吃过亏下游接口变慢任务在队列里疯狂堆积几分钟内存就报警了。后来改成ArrayBlockingQueue(10000)配合CallerRunsPolicy——队列满就让提交任务的线程自己跑既天然限流又不会丢任务。5.3 拒绝策略的四种选择AbortPolicy直接抛RejectedExecutionException默认策略。CallerRunsPolicy调用者线程自己执行任务能有效降低提交速度。DiscardPolicy静默丢弃任务不抛异常。DiscardOldestPolicy丢弃队列里最老的任务腾出空间。实际建议除非系统允许丢消息否则别用DiscardPolicy。如果业务对任务丢失零容忍就用CallerRunsPolicy或者自定义拒绝策略——比如把任务写入本地文件/MQ等系统恢复后重新投递。5.4 核心线程数和最大线程数怎么定网上各种公式很多比如CPU密集型用CPU核数1IO密集型用CPU核数 * (1 IO耗时/CPU耗时)。公式是参考实际一定要压测。我之前维护过一个网关服务压测前按公式配了core16跑起来发现线程大部分时间在等待下游HTTP响应典型的IO密集场景后来把core调到32吞吐量提升了将近一倍。还有一个必须知道的APIexecutor.allowCoreThreadTimeOut(true)——开启后核心线程空闲也会被回收适合那种任务有明显的波峰波谷、平时没什么任务的场景避免一堆空闲线程白占内存。5.5 为什么不推荐Executors快捷工厂Executors.newFixedThreadPool()底层用的是无界LinkedBlockingQueue任务堆积可能导致OOM。Executors.newCachedThreadPool()最大线程数是Integer.MAX_VALUE顶峰时可能创建海量线程导致线程切换爆炸。Executors.newSingleThreadExecutor()同样是无界队列。我亲眼见过一个项目用newCachedThreadPool之后某次流量突刺直接创建了上万个线程服务器CPU飙升到100%进程卡死。所以大厂规范里基本都禁止用Executors的快捷方法统一使用ThreadPoolExecutor手动配置道理就在这里——参数写明白心里才有底。6. 进阶玩法Java 21虚拟线程与未来趋势Java 21正式发布了虚拟线程Virtual Threads这是并发模型的一个大变革。以前我们用线程池来池化昂贵的平台线程虚拟线程直接把“线程创建成本”大幅降低——JVM在内部用少量载体线程Carrier Thread调度大量的虚拟线程虚拟线程的创建和阻塞成本都极低。这带来的变化是你可以用“每任务一线程”的写法而不用担心平台线程资源被耗尽try (var executor Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() - processTask()); }虚拟线程特别适合IO密集场景——每个请求开一个虚拟线程去调外部接口、读数据库、等响应等待期间虚拟线程自动从载体线程上让出不会白白占着平台线程。我在一个内部日志服务上试用过原来用固定线程池处理400个并发请求线程池很容易满换虚拟线程后写起来简单多了资源占用也降了下来。但要注意虚拟线程不适合CPU密集计算因为它本质还是跑在有限的CPU上纯计算场景开再多虚拟线程也没用且synchronized块在虚拟线程里可能会钉住载体线程需要留意实际表现。7. 面试高频题与实战避坑记录7.1 十个绕不开的面试问题我梳理一下最常被问到的几类附上回答要点线程和进程的区别答资源分配与调度单位、内存共享与隔离、通信方式差异。创建线程有几种方式答Thread、Runnable、Callable、线程池重点说后两者的优势。线程有哪些状态答NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED说清楚状态切换条件。sleep和wait有什么区别答sleep不释放锁、是Thread静态方法wait释放锁、是Object方法、必须在synchronized块中调用。synchronized和Lock的区别答Lock支持可中断、可超时、公平锁、多个条件队列synchronized自动释放锁更省心。volatile能保证原子性吗答不能只保证可见性和一定的有序性。HashMap线程安全吗答不安全多线程写会导致数据覆盖甚至死循环JDK7头插法用ConcurrentHashMap。线程池参数怎么调答CPU/IO密集场景不同压测为准拒绝策略按业务丢不丢消息来选。什么是死锁怎么避免答四个条件实际避免方法最好能现场讲一个案例。AQS是什么答AbstractQueuedSynchronizer是ReentrantLock、CountDownLatch等同步器的底层框架CLH队列state状态。7.2 实战中的典型坑线程名不设置线上排查时jstack打出来的全是Thread-12、Thread-28这种名字根本看不出是哪个业务线程。强烈建议所有线程都通过ThreadFactory设置有意义的名字比如“msg-push-worker-1”出问题一眼定位。线程里吞异常run()方法里不捕获异常或者catch了之后不打印日志任务失败了你根本不知道。Callable任务还要注意Future里get的时候会抛ExecutionException要正确区分业务异常和任务执行异常。忘记关闭线程池Spring Boot应用重启时如果线程池没shutdown可能会导致优雅停机失败。一般在PreDestroy或者应用停止钩子里调用shutdown()然后awaitTermination等待一定时间。三高场景用LongAdderAtomicInteger在高并发下CAS自旋比较严重LongAdder采用分段累加的方式热门场景性能好很多。8. 给新手的实战变现路径如果你刚接触多线程我建议按这个顺序做几个小练习用Runnable实现一个多线程下载器模拟分段下载文件。用Callable写一个并发聚合服务同时请求三个外部API全部完成后汇总结果。用ThreadPoolExecutor重构上面的聚合服务验证参数对性能的影响。自己写一个会死锁的Demo再用jstack把死锁抓出来体会排查过程。尝试用JDK19的虚拟线程跑一下IO密集任务对比线程池的性能。这几步走下来线程基础基本就扎实了。真遇到面试问“线程池的核心参数”你如果能把“为什么不用Executors快捷方法”“队列满时先扩线程还是先拒绝”这些讲清楚比背一百篇八股文都有用。最后说点个人体会线程相关的Bug往往不是每次必现的可能压测时跑几百次才冒出一次这种问题最难查。所以我写并发代码时宁可多写几行显式的锁和状态控制也不敢依赖“应该没事”的侥幸心理。把线程池参数、任务队列大小、超时时间都写成常量放到配置中心出问题时能快速调参——这是我现在团队里的硬性要求。希望这篇能帮你少踩一些我踩过的坑。
