
如果你在网上搜“Java线程”十有八九会先撞见一堆面试题线程有几种创建方式、wait和sleep有什么区别、线程池核心参数怎么配……这些问题背熟并不难难的是真正把线程用在项目里的时候总会在意想不到的地方翻车。我最早写多线程程序是在一个报表导出功能里想着开几个线程并行查数据能快一点结果上线当晚就碰到了CPU飙高、任务卡死、日志里啥都没留下。后来把线程相关的知识系统捋了一遍把踩过的坑一个个记下来才发现线程这玩意儿创建只是入门优化要讲策略而避坑才是真正的进阶题。这篇文章我打算按一条完整的实战链路来讲线程到底是什么、创建线程有哪些姿势、生命周期和协作机制怎么用、线程安全怎么做、线程池怎么配置、有哪些高频的坑必须避开最后再看看虚拟线程带来的新选择。内容不绕弯子每一步都给出为什么这样做、以及我在实际项目里验证过的结论。1. 从进程到线程先搞清楚Java并发的最小单元1.1 进程和线程到底差在哪很多人会告诉你“进程是资源分配的最小单位线程是CPU调度的最小单位”这句话背起来容易但真正影响写代码的是下面这些差异。进程有自己独立的内存空间进程之间天然隔离一个进程崩了不会直接影响另一个。操作系统切换进程的成本很高因为要切换地址空间、页表、文件描述符这些“重装备”。线程则不一样同一个进程里的线程共享堆内存和方法区只有每个线程私有的栈、程序计数器是独立的。共享带来的是通信方便——线程间传数据不需要像进程那样走管道、消息队列、共享内存之类的IPC机制直接操作同一个对象就行。但共享也带来了灾难的入口——多个线程同时改一个变量就会出现数据错乱。我刚开始写并发代码时也犯过一个低级错误以为线程之间只要不用同一个变量就安全了结果在静态工具类里放了一个SimpleDateFormat多线程跑起来后日期解析偶尔会出错而且是不定期复现的那种。原因就是SimpleDateFormat里的Calendar对象不是线程安全的多个线程同时调用parse内部的共享状态被写乱了。1.2 Java里的线程和操作系统线程的关系Java的线程在HotSpot虚拟机里是一对一映射到操作系统原生线程的也就是说你每new一个Thread底层就对应一个内核线程。这里有一个很关键的点Java线程的创建、销毁、阻塞、唤醒最终都是通过操作系统完成的所以线程切换的成本一点都不低——每次切换都要保存上下文、恢复上下文涉及用户态到内核态的切换。正因为这样的映射关系“创建线程很轻量”这种说法在Java里是不成立的。频繁地new线程再销毁会对系统造成不小的压力。我见过一个项目每次请求进来都new一个线程去处理高峰期瞬间创建几百上千个线程系统直接进入假死状态。后来换成线程池同样的业务量系统稳稳当当。所以Java里真正轻量的并发单元不是线程本身而是线程池对线程的复用。1.3 线程模型选择决定了你的编程思维方式Java的线程模型在一开始就决定了你要面对并发问题。多线程编程的核心矛盾是三点可见性、原子性、有序性。可见性是指一个线程修改了变量另一个线程能不能立刻看到原子性是指一组操作是不是不可分割的有序性是指代码执行的顺序和指令重排之间的关系。这三个问题在后面的章节里会反复出现很多看起来玄乎的并发Bug归根结底就是其中一个或多个没处理好。2. 创建线程的几种姿势选型逻辑比语法更重要2.1 继承Thread和实现Runnable选哪个创建线程最基础的两种方式一是继承Thread类重写run方法二是实现Runnable接口传给Thread。语法层面没有任何神秘感关键在于选型。继承Thread意味着你的任务类不能再继承其他类了Java是单继承这会让设计受限。更重要的是继承Thread的方式把“任务”和“执行者”耦合在了一起你得到的是一整个线程对象而不仅仅是“要执行的事情”。实现Runnable则把任务本身抽象出来想用线程执行、线程池执行、定时执行都可以灵活得多。所以我个人几乎从来不继承Thread所有自定义任务一律实现Runnable或Callable。这也是业界的主流做法。如果你的任务需要返回值那就用Callable 配合FutureTask或者提交给线程池获取Future。2.2 Runnable和Callable任务返回值的正确姿势Runnable的run方法没有返回值也不能抛受检异常。Callable的call方法可以返回值可以抛异常这是两者最实用的差异。实际项目中需要返回值的场景特别多——并行查数据库返回结果、同时调多个外部接口再聚合、批量处理文件后返回统计信息。这些任务用Runnable会很别扭你得自己搞一个共享容器去收结果还要自己处理异常。我常用的做法是这样ExecutorService pool Executors.newFixedThreadPool(8); ListFutureString futures new ArrayList(); for (String url : urlList) { FutureString future pool.submit(() - fetchData(url)); futures.add(future); } for (FutureString future : futures) { String result future.get(5, TimeUnit.SECONDS); // 聚合处理 }这里有个细节值得注意future.get()是阻塞的如果某个任务执行特别慢主线程会一直等在这里。所以我都会加一个超时参数get(5, TimeUnit.SECONDS)防止个别慢任务拖垮整个聚合流程。如果超时还没拿到结果会抛TimeoutException你要么取消这个任务要么走降级逻辑。2.3 线程命名和daemon设置看起来不重要排查问题全靠它很多新手创建线程的时候会忽略两件事线程名字和daemon属性。线程名不设的话默认是Thread-0、Thread-1这种一旦生产环境出问题你翻线程dump的时候看到满屏的Thread-23、Thread-45根本分不清是哪个业务创建的线程。我的经验是无论用什么方式创建线程都强制给它一个有业务含义的名字。用线程池的时候直接提供一个ThreadFactory手工创建线程的时候也把名字在构造时传进去。ThreadFactory namedFactory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(report-export-thread- seq.getAndIncrement()); t.setDaemon(true); return t; } };daemon线程也要谨慎对待。daemon线程的特点是如果JVM里只剩daemon线程JVM就会直接退出不会等它执行完。所以在后台任务里如果用了daemon线程主线程结束就意味着任务可能没执行完就随进程消失了。我需要后台任务可靠落地时会用非daemon线程或者用线程池并确保shutdown时优雅关闭。3. 生命周期与协作机制线程不是跑起来就完事3.1 六种状态从New到Terminated的完整切换路径Java线程在任意时刻有且只有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。NEW刚new出来还没调用start()。RUNNABLE调用start()之后线程已经就绪等待CPU调度或者正在运行。注意Java的RUNNABLE状态把操作系统层面的“就绪”和“运行中”合并了。BLOCKED等待进入synchronized同步块时被阻塞在这里。WAITING调用了wait()、join()、LockSupport.park()等方法后无限期等待。TIMED_WAITING调用了sleep(ms)、wait(timeout)、join(timeout)等带超时时间的等待方法。TERMINATEDrun方法执行完或者中途抛出未捕获异常线程终止。理解这几个状态最直接的价值在于你看到线程dump时能快速判断线程卡在哪。一个线程大量处于BLOCKED多半是锁竞争激烈大量处于WAITING可能是任务在无谓地等待大量处于TIMED_WAITING可能是代码里sleep用得过多。3.2 wait/notify、join、CountDownLatch协作机制怎么选线程与线程之间不是孤立的经常需要互相配合。最经典的配合是生产者-消费者模型用wait和notify实现。wait会让当前线程释放锁并进入等待状态notify会唤醒一个在该锁上等待的线程notifyAll唤醒所有等待线程。这里有个铁律wait和notify必须放在synchronized同步块里否则会抛IllegalMonitorStateException因为调用wait之前必须持有该对象的监视器锁。synchronized (queue) { while (queue.isEmpty()) { queue.wait(); } Item item queue.poll(); }注意判断条件一定要用while而不是if。因为线程被唤醒后还要重新检查条件是否满足如果被“假唤醒”spurious wakeup或者多个消费者同时抢到一个空队列用if就会出问题。这是教科书反复强调的坑我在实际代码里也坚持用while。join的作用是把一个线程“插队”到当前线程前面让当前线程等待该线程执行完毕再继续。CountDownLatch则是更灵活的协作工具它允许一个或多个线程等待一组事件全部完成。比如主任务要等3个子任务都完成才能继续就可以初始化new CountDownLatch(3)每个子任务完成后countDown()一次主任务在await()处等待计数归零。近几年我写协作代码时更倾向于用CountDownLatch、CyclicBarrier、CompletableFuture这些高层工具而不是直接操作wait/notify。wait/notify的粒度太细容易出错且代码可读性差。JUC包的同步工具把常见协作模式封装好了现代化开发真没必要天天手搓wait循环。3.3 中断机制为什么Thread.stop是禁区Thread.stop方法在很早以前就被标记为废弃了。原因非常直接它会在任意位置强制终止线程导致锁被突然释放共享数据可能处于不一致状态。正确的中断方式是通过interrupt信号。在线程A里调用线程B的interrupt()并不会强行停止B而是给B设置了一个中断标志位。B在哪些地方感知到这个信号呢如果你在sleep、wait、join这些方法里会收到InterruptedException如果你在循环里可以通过Thread.currentThread().isInterrupted()主动检查标志位。然后由你决定怎么退出是清理资源后正常返回还是抛出异常交给上层处理。while (!Thread.currentThread().isInterrupted()) { // 处理任务 }中断机制本质上是“协作式”的线程自己决定要不要停下来。虽然麻烦一点但这是安全的退路。我见过好多线上故障都是有人想“快速终止一个卡住的线程”而用了stop或者suspend/resume后面全被扫进了坑里。4. 线程安全从理论到实战的三座大山4.1 volatile它解决的只是可见性先明确一个结论volatile不能保证原子性。它保证的是一个线程修改了volatile变量的值其他线程能立刻看到修改后的新值。为什么需要这个机制因为CPU缓存的存在线程读取主内存的数据时会先把数据加载到自己的工作内存或CPU缓存中之后操作的都是这个副本。一个线程修改了副本如果没有同步回主内存其他线程读到的还是旧值。volatile关键字强制读写都直接操作主内存同时禁止指令重排。一个典型的应用场景是状态标志volatile boolean running true; // 线程A while (running) { // 轮询处理 } // 线程B void stop() { running false; }但如果有人把计数操作写在volatile变量上比如多线程累加变量就会出错因为count不是原子操作它包含读取、加一、写回三步volatile管不住中间被其他线程打断的问题。4.2 synchronized与Lock什么时候用谁synchronized是Java内置的关键字使用简单自动释放锁而且随着JVM优化它不再是“重量级锁”的代名词了。偏向锁、轻量级锁、锁膨胀这些机制让synchronized在低竞争场景下开销很小。Lock主要是ReentrantLock是java.util.concurrent包提供的显式锁。它比synchronized多了几个实用特性可中断获取锁、可超时获取锁、支持公平锁、可以绑定多个Condition条件队列。选型判断我一般遵循两个原则只要能满足需求优先用synchronized代码最简洁也不会出现忘记释放锁的问题。如果确实需要“一定时间内获取不到锁就放弃”这种场景或者需要多个条件队列来精细控制线程唤醒那就用Lock。ReentrantLock lock new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务操作 } finally { lock.unlock(); } } else { // 抢锁超时做降级处理 }这段代码里tryLock配合超时非常实用。比如多个实例去争抢一个定时任务时抢不到锁就直接跳过不可能出现线程无限期等锁的情况。4.3 AtomicInteger与CAS无锁并发适合什么场景Atomic系列的类用的是CASCompare And Swap机制。CAS的核心思想是更新一个值之前先比较当前内存值是不是预期的旧值如果是才替换成新值如果不是说明有别的线程改过了就循环重试。在低竞争场景下CAS比加锁效率高得多因为它不需要线程阻塞和唤醒。AtomicInteger、AtomicLong、AtomicReference都是这样实现的。之前热词里有人问“atomicinteger线程安全吗”答案是它是线程安全的单点计数用它是靠谱的。但高竞争场景下CAS会有一个问题大量线程同时争抢失败重试的次数会非常多白白消耗CPU。这时候反而用synchronized更高效因为阻塞会让线程让出CPU。所以无锁不是万金油要结合竞争激烈程度来选。AtomicInteger counter new AtomicInteger(0); counter.incrementAndGet();这段代码的语义和synchronized包裹的count基本等价但性能特征不一样。在一些超高并发场景还有LongAdder这种分段累加的优化方案适合写多读少的统计场景。5. 线程池参数配置才是真正的技术含量5.1 七个核心参数逐个拆解线程池的核心构造参数有七个很多面试题都考但真正在工程里把每个参数都配正确的并不多。我来逐个说清楚。corePoolSize核心线程数。即使线程空闲也不会被销毁除非设置了allowCoreThreadTimeOut。maximumPoolSize最大线程数。线程数超过核心数后且队列也满了才会创建额外线程直到达到最大。keepAliveTime和unit额外线程非核心线程空闲多久后被回收。workQueue任务队列核心线程忙不过来时新任务先进队列排队。threadFactory创建线程的工厂主要用来定制线程名。handler拒绝策略队列满了且线程也达到最大数时新任务怎么办。new ThreadPoolExecutor( 8, 16, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), namedFactory, new ThreadPoolExecutor.CallerRunsPolicy() );这里要注意一个隐含的顺序问题任务提交后如果当前线程数小于核心线程数优先创建核心线程执行如果核心线程满了任务进队列如果队列也满了才创建额外线程如果线程数已经到最大值队列也满了才触发拒绝策略。很多人以为“线程不够了就直接加线程”其实队列才是最容易被忽略的缓冲层。5.2 阻塞队列怎么选LinkedBlockingQueue与ArrayBlockingQueueLinkedBlockingQueue是链表实现如果不指定容量默认是无界的ArrayBlockingQueue是数组实现必须指定容量。两者行为上有差异。用无界队列比如new LinkedBlockingQueue()会让maximumPoolSize失去意义——因为任务永远有地方放根本不会触发额外线程的创建。如果任务提交速率长期超过消费速率任务会在队列里积压导致内存被撑爆。所以生产环境我基本不用无界队列至少给个上限比如new LinkedBlockingQueue(1000)。ArrayBlockingQueue的一个区别是它可以预设一个公平性参数但实际场景中用得相对少。另外还有SynchronousQueue这个队列不存储任务每个任务都直接转手给线程处理没有缓冲。用它的场景是“希望任务尽快被消费而不是排队等待”配合较大的maximumPoolSize使用时要小心线程创建过猛。我选择队列的标准很朴素需要缓冲就选有界LinkedBlockingQueue容量根据业务峰值的积压量估算不需要缓冲、任务来了就要立刻处理用SynchronousQueue需要优先级调度用PriorityBlockingQueue这个也是无界队列必须严格控制提交量。5.3 线程数到底设多少从公式到实际修正这是一个老生常谈但总有人问的问题。线程池的核心线程数到底设多少取决于任务类型。CPU密集型任务线程数应该接近CPU核心数设成n1比较合适。因为CPU密集任务占用CPU执行线程多了反而会因为上下文切换增加开销。IO密集型任务线程数可以远大于核心数。计算公式一般是线程数 CPU核心数 * (1 平均等待时间/平均计算时间)。通俗点说如果任务80%的时间都在等IO那么线程数大概是核心数的5倍。int cores Runtime.getRuntime().availableProcessors(); int ioThreadCount cores * 4;实际项目中我还会做二次修正。比如线程池里的任务调了外部接口超时设置是5秒那么线程数就不能按理论值拍脑袋要考虑外部依赖的并发承受能力。你线程配得再多下游接口扛不住只会把对方打挂。我的习惯是先按公式算一个初始值再用压测结果去校正。压测时观察指标CPU利用率稳定在80%左右、队列没有积压、任务平均耗时在可接受范围这三个条件同时满足基本就是比较优的参数了。5.4 拒绝策略不一定都用AbortPolicyJDK自带了四种拒绝策略AbortPolicy直接抛RejectedExecutionException让调用方感知任务被拒。CallerRunsPolicy被拒绝的任务在调用者线程里执行相当于变相限流——调用者线程被占住了就没法继续提交任务了。DiscardPolicy静默丢弃不抛异常。DiscardOldestPolicy丢弃队列里最老的任务再把新任务加进去。我在互联网项目里最喜欢配合CallerRunsPolicy。这个策略的最大价值是提供了天然的背压机制。任务被拒后不是简单丢掉而是让提交任务的线程自己去做这样任务不会丢同时降低了提交速率给线程池争取了喘息空间。6. 高频踩坑现场死锁、泄漏、异常吞掉的完整复盘6.1 死锁的四个必要条件与一次真实排查死锁的形成需要四个条件同时满足互斥、持有并等待、不可剥夺、循环等待。Java代码里的死锁本质就是多个线程各自持有一把锁又在等待对方手里的锁。我曾经在一家公司排查过一个偶发性的系统无响应故障。现象是服务偶尔会卡几分钟然后自动恢复。第一次遇到时线程dump里大量线程卡在BLOCKED状态。仔细分析dump发现A线程持有了lock1在等待lock2B线程持有了lock2在等待lock1。典型的循环等待。根因是代码里有两把锁加锁顺序不一致。一个方法先拿lock1再拿lock2另一个方法先拿lock2再拿lock1。线程A执行第一个方法线程B执行第二个方法刚好在时间上交叉死锁就产生了。修复方式有两种。最直接的是统一加锁顺序让所有线程都按相同的顺序获取锁。另一种是用tryLock配合超时拿不到锁就退避重试从机制上让死锁不可能永远持续下去。我的处理是先统一加锁顺序解决当前问题再在关键位置加tryLock兜底。6.2 ThreadLocal引发的内存泄漏成因与规避ThreadLocal的作用是让每个线程拥有自己的变量副本。常用的场景有保存当前登录用户信息、保存数据库连接、传递链路追踪ID。但ThreadLocal有个著名的坑如果线程池里的线程存活时间很长而你往ThreadLocal里塞了比较大的对象使用完又没有remove那么这个对象会一直被线程持有引用永远无法被GC回收导致内存泄漏。为什么ThreadLocal会有这种问题因为ThreadLocalMap的key是弱引用指向ThreadLocal对象但value是强引用。ThreadLocal对象本身被回收后value仍然被线程引用着如果不手动清理这个entry永远不能回收。规避方法非常简单使用完ThreadLocal之后在finally块里调用remove。try { threadLocal.set(userInfo); // 业务处理 } finally { threadLocal.remove(); }我现在已经形成了条件反射凡是往ThreadLocal里放东西就一定会写finally remove。这个习惯帮我避开了好多次潜在的内存泄漏问题。6.3 线程切换真的会“泄漏”吗热搜词里看到“线程切换时会泄漏吗”这个问题。如果指的是“线程切换会不会导致资源泄漏”答案是正常的线程上下文切换本身不会泄漏资源但切换是有开销的开销包含CPU时间、缓存失效、内存屏障开销等。但有一种容易混淆情况是“ThreadLocal里的资源泄漏”和“线程切换”一起出现。比如线程A处理完任务后没有清理ThreadLocal里的连接对象线程池把线程A回收后线程A又去执行线程B的任务这时候ThreadLocal里的旧数据就被下一个任务读到了。这不是切换泄漏而是清理不到位造成的“脏数据串号”。还有一种情况是线程池使用不当导致的“资源累积”。比如任务里打开了文件流、数据库连接、HTTP连接但没有在finally里关闭虽然线程池限制了线程数这些不释放的资源会随着任务执行不断累积最终把内存和句柄耗尽。这锅不能甩给线程切换得算在资源管理头上。6.4 线程异常被吞为什么日志里什么都没留下另一个高频坑是线程成片“消失”或者“没执行”看日志却什么都没发现。很多时候不是线程没执行而是任务里抛出的异常被线程的run方法吞掉了——如果没有给线程设置UncaughtExceptionHandlerrun方法里抛出的运行时异常会直接打印到标准错误流而在某些日志配置下标准错误流根本没被收集。如果是线程池执行任务异常处理又分两种情况。用pool.execute()提交任务时异常会抛出到线程的UncaughtExceptionHandler用pool.submit()提交任务时异常会被封装进Future里如果你不调用future.get()异常就悄无声息了。所以我在提交任务时会强制写一个统一异常处理逻辑。最简单的方式是给线程池的threadFactory设置UncaughtExceptionHandler或者在提交的Runnable里用try-catch包住业务逻辑并输出error日志。不要以为任务提交给线程池就万事大吉了异常处理必须做在任务内部。pool.execute(() - { try { doTask(); } catch (Exception e) { log.error(task execute failed, e); } });7. 虚拟线程Java 21带来的并发范式转变7.1 虚拟线程是什么解决了什么问题虚拟线程是Java 21正式引入的特性。前面说过传统Java线程一对一映射到内核线程创建和切换成本高导致在高并发IO场景下你不敢开太多线程只能用异步回调、响应式编程这些“反人类”的写法。虚拟线程的核心思路是把线程的调度往上移一层。一个操作系统线程可以承载成千上万个虚拟线程虚拟线程的创建、阻塞、唤醒都由JVM在用户态处理不再需要每次都走内核。阻塞IO操作发生时JVM会把虚拟线程从载体线程上卸下来让载体线程去做别的虚拟线程的任务IO完成后再把虚拟线程挂回去继续执行。这意味着什么意味着你写同步阻塞代码就能支撑高并发。不用再为了并发去搞CompletableFuture套回调也不用在MySQL连接池和线程池之间反复调试参数。try (var executor Executors.newVirtualThreadPerTaskExecutor()) { ListFutureString futures tasks.stream() .map(task - executor.submit(() - handle(task))) .toList(); // 直接同步获取结果 }这个代码在虚拟线程下每个任务一个虚拟线程轻轻松松开几千上万个完全不会压垮系统。7.2 迁移虚拟线程的实际建议我自己的验证结论是虚拟线程非常适合IO密集型的业务比如大量读写数据库、调外部接口、处理文件IO。因为这些场景里线程大部分时间在等待IO虚拟线程能把等待时间节省下来给别的任务用。但虚拟线程也有不适用的地方。CPU密集型计算任务虚拟线程帮不上忙因为CPU资源是有限的虚拟线程最终还是要轮流占用CPU。另外synchronized同步块在虚拟线程里有一个限制如果虚拟线程在synchronized块里被阻塞它不会让出载体线程可能会阻塞载体线程。如果你的代码里大量使用synchronized迁移到虚拟线程之前要做检查。我个人的迁移策略是新项目、新模块从Java 21起步就直接考虑虚拟线程老项目不急着大规模改造先在IO密集的独立模块里试点确认线程池、连接池、ThreadLocal这些下游组件都能兼容再推广。写在最后从“会用线程”到“会优化线程”的进阶路线回头看我自己的经历线程的学习曲线其实不是线性上升的而是分阶段的。第一阶段是搞懂创建和生命周期能用多线程把单线程任务改快第二阶段是理解线程安全和锁能处理并发冲突第三阶段是掌握线程池的参数设计和任务调度能在高并发下保持系统稳定第四阶段是懂得避坑——死锁排查、内存泄漏、异常吞掉、资源释放这些才是区分写代码和搞工程的分水岭。线程相关的知识有一个特点就是“知识点都不难难的是它们会联合起来出问题”。你可能把八股文背得滚瓜烂熟但线上故障不会按照面试题出牌。所以我一直建议的做法是动手写一个小项目模拟生产者-消费者、模拟线程池任务积压、模拟死锁然后亲自用jstack定位一次。真正踩过一遍坑比刷一百道面试题都管用。如果你正在研究某个具体的线程问题比如线程池参数怎么调优、死锁怎么定位、虚拟线程怎么迁移可以顺着这篇文章的章节继续深挖。线程是个大话题上面这些内容是我项目中实际验证过的核心结论希望对你有用。