Java线程并发编程完全指南:从线程生命周期到线程池调优实践
做Java开发的早晚得撞上“并发”这堵墙。我见过不少写了好几年业务代码的同学一聊到Java线程就发怵——进程和线程什么关系说不清线程状态能背下来但遇到问题不会分析更别说线程池参数怎么调、死锁怎么排查了。这篇文章我就把Java线程这摊事从头捋一遍从线程的本质、生命周期到创建方式、线程安全、协作通信再到线程池选型和参数设置全程结合面试高频题和实际踩坑经历来讲希望能帮你把并发这块基础彻底打牢。1. 先搞清楚进程和线程到底差在哪1.1 进程为什么“重”进程是操作系统进行资源分配的基本单位。每个进程都有自己独立的内存空间、文件描述符表、信号处理器等资源这是它的隔离性优势——一个进程崩溃不会直接拖垮另一个进程。但也正因为这种隔离创建和销毁进程的开销非常大。我当时第一次用Linux的fork()创建进程时直观感受到它要复制父进程的页表、文件描述符、信号处理等一堆东西即便有写时复制机制成本也远高于创建线程。更关键的是进程切换时CPU要保存和恢复完整的上下文包括寄存器、程序计数器、内存管理单元的状态还要刷新TLB缓存这些操作都是实打实的性能损耗。所以进程适合做重量级的资源隔离但如果你只是想同时执行几个轻量任务用进程就太重了。1.2 线程为什么“轻”线程是CPU调度的基本单位归属于某个进程。同一个进程里的多个线程共享堆内存、方法区、文件描述符等资源只有程序计数器、虚拟机栈、本地方法栈是线程私有的。正因为共享线程的创建开销比进程小得多切换成本也更低。而且线程之间通信非常方便——直接读写共享变量就行不需要像进程间通信那样搞管道、消息队列、共享内存这些复杂机制。但共享也带来了新的问题多个线程同时读写同一个变量就可能出现数据不一致。这就是并发编程里最核心的难点——线程安全问题。1.3 并发和并行别再混为一谈我面试候选人的时候经常有人把并发和并行说成一回事。实际上并发指的是多个任务在同一个时间段内交替执行是逻辑上的“同时”并行指的是多个任务在同一个时刻同时执行是物理上的“同时”。单核CPU上可以跑多线程那是并发交替执行。只有多核CPU才能做到真正的并行让不同线程在不同的核心上同时运行。现在很多机器都是多核的所以多线程的优势也体现得更明显——原本要串行执行的任务拆成多个线程后可以利用多核缩短整体执行时间。这也解释了为什么现在写Java并发代码几乎成了标配业务复杂度上来了机器核数也上来了还用单线程去写等于主动放弃了硬件性能。2. 线程生命周期六种状态要背更要懂流转逻辑2.1 Java线程的六种状态Java线程有六种状态定义在Thread.State这个枚举里状态含义NEW线程刚创建还没调用start()RUNNABLE可运行状态可能正在执行也可能在等CPU时间片BLOCKED被阻塞等待获取监视器锁synchronizedWAITING无限期等待需要其他线程显式唤醒TIMED_WAITING限期等待到时间自动唤醒TERMINATED执行完毕或异常退出这里最容易踩坑的是RUNNABLE。很多初学者以为RUNNABLE就是“正在运行”其实它涵盖了操作系统层面的“就绪”和“运行”两个状态。Java没有单独区分这两个因为JVM层面不好精细控制CPU调度所以统一叫RUNNABLE。2.2 状态流转的关键路径线程的状态流转是有规律可循的我把最核心的几条路径列出来new Thread()之后是NEW状态调用start()后进入RUNNABLE。RUNNABLE的线程遇到synchronized代码块但没抢到锁就变成BLOCKED抢到锁后回到RUNNABLE。RUNNABLE的线程调用wait()变成WAITING被notify()/notifyAll()唤醒后回到RUNNABLE。RUNNABLE的线程调用sleep(long)或join(long)变成TIMED_WAITING时间到了自动回到RUNNABLE。线程执行完run()方法进入TERMINATED。这里要特别注意wait()是释放锁的而sleep()不释放锁。这也是面试里问“wait和sleep有什么区别”时最核心的考点。2.3 面试最容易翻车的几个状态问题有一道经典的面试题线程调用join()它的状态是什么答案是WAITING或TIMED_WAITING取决于是否传了超时时间。我在面试里问过不少人很多人第一反应是“在等别的线程执行完所以是BLOCKED”这是错的它确实是WAITING。还有个容易混淆的点BLOCKED和WAITING都是“等”但等待的对象不同。BLOCKED是等锁WAITING是等某个条件被满足比如join等线程结束、wait等被唤醒。一个是排队拿钥匙一个是等快递员送货本质上是两种等待。另外提醒一句不要用Thread.stop()强制终止线程这个方法早就被标记废弃了。它会导致线程在任意位置突然中断很可能把共享数据写到一半就停掉破坏一致性还可能让持有的锁永远不被释放。正确做法是用中断机制配合协作式退出这个后面细说。3. 创建线程的几种方式别只会new Thread了3.1 Runnable最常用的姿势实现Runnable接口是最推荐的入门方式。把任务逻辑定义在run()方法里然后传给Thread对象启动Runnable task () - { System.out.println(当前线程 Thread.currentThread().getName()); }; Thread thread new Thread(task, worker-1); thread.start();因为Runnable是函数式接口Java 8之后可以直接用Lambda表达式写起来非常简洁。用Runnable的好处是任务和线程解耦。Runnable只描述“要做什么”至于用哪个线程执行、什么时机执行由外部控制。这也是后面线程池能复用的基础——线程池里的Worker线程可以反复执行不同的Runnable任务。3.2 Callable FutureTask想要返回值就用它Runnable的run()方法没有返回值也不能抛出受检异常。如果你需要线程计算结果就得用CallableCallableInteger task () - { Thread.sleep(1000); return 1 1; }; FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask, compute-task).start(); Integer result futureTask.get(); // 会阻塞等待结果 System.out.println(result);FutureTask实现了RunnableFuture接口既能当Runnable传给Thread又能当Future获取返回值。get()方法会阻塞当前线程直到任务执行完并返回结果可以传入超时时间来避免无限阻塞比如futureTask.get(3, TimeUnit.SECONDS)。这里有个小坑get()一旦超时抛TimeoutException任务线程其实还在跑要注意后续对资源的处理别一超时就把整个流程干懵了。3.3 继承Thread能用但不推荐继承Thread类也是一种方式重写run()方法即可class MyThread extends Thread { Override public void run() { System.out.println(自定义线程执行); } } new MyThread().start();为什么不推荐核心原因是Java是单继承继承了Thread就不能继承其他类扩展性很差。而且这种方式把线程和任务绑死了同一个任务想用多个线程执行还得创建多个Thread子类实例很别扭。我自己的习惯是除非是特别简单的演示代码否则不用继承Thread统一走Runnable或Callable。3.4 创建方式怎么选直接给个选型建议场景推荐方式简单任务无返回值Runnable Lambda需要返回结果或抛出异常Callable FutureTask批量执行、需要复用线程线程池有延迟/周期任务ScheduledExecutorService最不推荐的就是每次需要线程时手动new Thread().start()。频繁创建线程的开销很大而且线程数量不可控高并发场景下容易把系统拖垮。用线程池管理才是正路。4. 线程安全并发出问题的根源到底在哪4.1 并发三大问题原子性、可见性、有序性并发Bug基本都出在这三个问题上原子性一个操作不可被中断。比如i它包含了读取、加一、写回三个步骤在多线程下可能交错执行结果就不是预期值。可见性一个线程修改了共享变量其他线程能不能立刻看到。CPU缓存和寄存器都可能让修改暂时不可见。有序性编译器和CPU为了优化可能对指令进行重排在多线程下重排会导致逻辑错乱。举个例子两个线程同时执行i如果i初始是0理论上最终应该是2但实际可能得到1因为两个线程同时读到了0分别加1再写回后写的覆盖了先写的。4.2 volatile到底保证了什么volatile解决的是可见性和有序性问题不解决原子性。它有两层语义写入volatile变量时JMM会强制把该线程工作内存中的值刷新到主内存。读取volatile变量时会强制从主内存读取而不是用缓存。禁止指令重排。最经典的用法是双重检查锁单例class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里的volatile非常关键。如果不加new Singleton()这个操作不是原子的——它包含分配内存、初始化对象、把引用指向内存三步CPU可能重排成“先指向内存再初始化对象”。另一个线程在第一次判空时发现instance不为null直接返回一个还没初始化完成的对象就会出大问题。但要说清楚如果你做的是类似count这样的操作光靠volatile是扛不住的必须加锁或用AtomicInteger这种原子类。4.3 synchronized的三种用法与锁升级synchronized是Java内置的锁机制用法有三种修饰实例方法锁的是当前实例对象。修饰静态方法锁的是Class对象。修饰代码块锁的是括号里指定的对象。public synchronized void add() { ... } public static synchronized void staticAdd() { ... } public void blockAdd() { synchronized (this) { ... } }add()和blockAdd(this)锁的都是this但staticAdd()锁的是Class对象两者互不干扰。HotSpot对synchronized做了锁升级优化路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁用于“一个线程反复获取同一把锁”的场景竞争激烈时会升级为轻量级锁通过CAS自旋抢占如果自旋次数超了或者有线程阻塞等待就膨胀为重量级锁此时线程会真正被挂起进入BLOCKED状态。这个升级过程是我面试时比较喜欢追问的点。很多人背过“锁可以升级但不能降级”这句话但不知道背后是为了减少锁竞争带来的上下文切换开销。锁升级是JVM根据竞争程度动态调整的目的是尽量用便宜的机制处理临界区只有搞不定时才动用重量级锁。4.4 死锁四个必要条件与排查思路死锁的经典定义是四个必要条件同时成立互斥条件资源每次只能被一个线程使用。占有并等待一个线程占着资源又在等别的资源。不可剥夺已获得的资源不能被强制夺走。循环等待若干线程之间形成一个资源等待环。实际写代码最常见的死锁场景是加锁顺序不一致。比如线程A先锁resource1再锁resource2线程B先锁resource2再锁resource1两边各持一把锁等着对方释放就死锁了。排查死锁有一套标准操作先jps -l找到进程PID然后jstack PID导线程快照。在输出里搜Found one Java-level deadlock就能看到死锁线程互相等待的锁信息。拿到快照后再分析代码里的加锁顺序把顺序统一就能解决。我实际踩坑很深的一次是业务代码里两个系统组件共用了一把锁但加锁顺序不一致平时数据量小看不出来一到大促流量上来就卡死一片。那次全靠jstack定位最后统一了加锁顺序才解决。5. 线程协作与通信别让线程各自为战5.1 wait/notify的正确姿势wait()和notify()是Object类的方法必须在synchronized代码块或方法中调用否则会抛IllegalMonitorStateException。重点来了正确的写法是while循环判断条件而不是ifsynchronized (lock) { while (!condition) { lock.wait(); } // 条件满足执行后续逻辑 }原因是wait()被唤醒后并不代表条件一定成立了。可能有多个线程都被唤醒其中一个抢先修改了条件其他线程醒来后条件又变回了不满足的状态这时如果用的if就会直接往下走导致逻辑错误。这就是经典的“虚假唤醒”问题用while再检查一遍条件才是最稳的。另外能用notifyAll()就别用notify()。notify()只唤醒一个线程如果被唤醒的线程正好不满足条件然后继续wait其他可能满足条件的线程却没被唤醒就会一直等下去甚至死锁。5.2 join等待线程完成join()的作用是让当前线程等待被调用的线程结束。Thread t1 new Thread(() - { try { Thread.sleep(2000); } catch (InterruptedException ignored) {} }); t1.start(); t1.join(); // 主线程会在这里等t1执行完join()底层其实是wait(0)主线程在t1这个对象上等待等t1执行完JVM会调用notifyAll唤醒它。所以join也会抛出InterruptedException调用方需要处理。如果你在等一批线程都完成除了逐个join()还可以用CountDownLatch或CompletableFuture.allOf()后者写起来更现代。实际业务里“一堆线程都跑完再汇总结果”用CompletableFuture.allOf().join()会更省心。5.3 守护线程默默干活的后台角色Java线程分用户线程和守护线程两类。通过setDaemon(true)把线程设为守护线程它通常在后台执行一些辅助工作比如JMX、GC线程都是守护线程。JVM退出的条件是所有非守护线程都结束守护线程不会阻止JVM退出。这个特性意味着守护线程里不能放关键逻辑。如果JVM快退出了守护线程可能随时被掐断。我见过有人把定时任务写在守护线程里结果应用停机时定时任务还没跑完数据没落库事后排查很痛苦。所以守护线程适合做监控、心跳、清理这类“没了也无所谓”的活。5.4 ThreadLocal线程的私有空间ThreadLocal为每个线程维护一份独立的变量副本常用于存放连接、用户会话等线程不共享的数据。private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd));每个线程调用get()拿到的是自己的副本线程之间互不干扰。使用ThreadLocal有个著名的坑内存泄漏。如果线程池中的线程生命周期很长而ThreadLocal的value对象一直不被清理就可能因为线程复用导致对象无法被GC回收。尤其是在Web应用里请求线程从池里拿用完后归还value如果不显式移除就可能残留到下一个请求。正确的做法是在finally块里调用remove()try { // 业务逻辑 } finally { DATE_FORMAT.remove(); }这个坑我项目里碰到过线上某个服务内存一直涨排查半天发现是ThreadLocal里存了一个很大的对象没清理线程池复用后对象一直挂在Thread上最后靠jmap堆转储定位到的。6. 线程池并发应用的核心基础设施6.1 为什么必须用线程池很多人写并发代码习惯new Thread()但生产环境几乎不会这样干。原因有三点创建和销毁线程的开销大。线程是稀缺资源频繁创建销毁会带来明显的性能损耗。线程数量不可控。并发一高无限制地创建线程轻则CPU飙高重则内存溢出。管理能力缺失。没有统一的拒绝策略、超时设置、任务队列出了问题非常难排查。线程池的出现就是为了解决这些问题。它通过复用线程、统一调度、控制最大并发量让线程管理变得可控。6.2 ThreadPoolExecutor七个参数Java线程池的核心类是ThreadPoolExecutor构造参数有7个要理解线程池就必须把这7个参数吃透ThreadPoolExecutor executor new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 workQueue, // 任务队列 threadFactory, // 线程工厂 handler // 拒绝策略 );执行流程是这样的任务提交后如果当前线程数小于核心线程数创建新线程执行任务如果达到核心线程数任务进入队列排队如果队列满了再创建临时线程执行如果线程数已经到了最大线程数且队列也满了触发拒绝策略。这里有个容易被忽略的点keepAliveTime只对超出核心线程数的临时线程生效核心线程默认是不会被回收的。如果你希望核心线程也回收可以调用allowCoreThreadTimeOut(true)。6.3 阻塞队列怎么选队列的选择直接决定了线程池的行为特性队列类型特性适用场景LinkedBlockingQueue默认无界可以指定容量固定线程数、任务较多时用无界队列要小心内存ArrayBlockingQueue有界基于数组可指定容量需要严格控制排队长度的场景SynchronousQueue不存储任务直接转给线程希望任务立即执行、不做积压的场景常见的坑是用Executors.newFixedThreadPool()默认创建的无界队列如果任务一直提交而线程一直执行不过来队列会无限增长最终内存溢出。所以网上很多文章说“不建议用Executors工具类直接创建”核心原因就在这里。我个人的习惯是生产环境一律手动new ThreadPoolExecutor指定有界队列和自定义拒绝策略宁可拒绝任务也不能让内存被打爆。6.4 核心线程数和最大线程数到底怎么设这道题的完整回答框架要按任务类型来分CPU密集型任务核心线程数设为CPU核数1。因为CPU密集任务很少阻塞线程数太多反而因为频繁切换而降低吞吐。1是为了避免某个线程因缺页中断等原因暂停时CPU能顶上。IO密集型任务核心线程数可以设大一些经验值是CPU核数×2甚至更多。因为IO等待期间线程不占用CPU多开一些线程能提升吞吐。有人问过我“2C4G的Pod支持多少并发量”这个问题不能拍脑袋。要结合任务的RT、QPS、线程池参数、队列上限一起来算。比如一个任务平均耗时100ms2核CPUIO密集型场景线程数可以设16配合有界队列1000理论上能支撑的QPS上限就是(1000ms/100ms)×16160再加上队列缓冲实际能扛住的流量会有富余。核心逻辑是让“线程数 × 单线程每秒处理任务数”大于业务需要的QPS再留出30%左右的余量。7. 高频面试题速查与避坑经验7.1 面试高频题速查表这几年在面试里反复出现的问题我整理了一张速查表问题核心答案要点进程与线程的区别进程是资源分配单位线程是调度单位线程共享进程资源start()和run()的区别start()会创建新线程并执行run直接调run只会在当前线程执行sleep和wait的区别sleep不释放锁wait释放锁sleep固定时间wait可以被唤醒volatile和synchronized区别volatile解决可见性/有序性不解决原子性synchronized解决三者死锁的四个条件互斥、占有并等待、不可剥夺、循环等待ThreadLocal内存泄漏线程池复用线程未remove导致value无法回收线程池拒绝策略有哪些AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy如何排查死锁jps jstack搜索Java-level deadlock这寿表不是背完就完每个答案背后都要能展开两三个细节比如拒絴策略各自的适用场景AbortPolicy是默认的但会抛异常CallerRunsPolicy会让提交任务的线程自己执行任务相当于变相限流DiscardPolicy和DiscardOldestPolicy是静默丢弃用的时候要非常小心。7.2 几个我踩过的坑写给你避雷第一个坑是线程池里的线程名字不设置。默认的pool-1-thread-1一多起来日志分析直接懵。务必用ThreadFactory给线程起个有业务含义的名字比如order-worker-thread-1出问题翻日志时你就知道这有多重要。第二个坑是Future.get()忘记设超时。如果不设超时任务一直不返回调用方就无限期等下去和死锁差不多。我建议所有get()都带超时时间比如get(5, TimeUnit.SECONDS)宁可超时后走降级逻辑也不能让线程被活活挂住。第三个坑是全局共享的大对象没有加锁或副本隔离。比如SimpleDateFormat不是线程安全的我之前差点在并发环境下直接共享一个实例后来改成ThreadLocal方案问题立刻消失。这种问题有个共同特征平时测试测不出来并发一上来就偶发报错非常隐蔽。第四个坑是错误的线程池关闭方式。shutdownNow()会尝试中断正在执行的任务但并不保证任务真正停止awaitTermination()设个合理超时时间给任务一个收尾的窗口。不要在主流程里无脑调用shutdownNow()很容易把正在写库的任务掐掉。7.3 并发编程的学习路径建议如果你现在还在啃并发编程我建议按这个顺序来先搞懂进程、线程、JMM然后动手写各种小Demo验证可见性、原子性问题接着学习synchronized、volatile、Lock再研究线程池源码最后再看CompletableFuture、ForkJoinPool这些高级工具。有一点是我带新人时反复强调的不要只停留在背概念一定要用jstack、jmap、jvisualvm这些工具去观察线程状态、堆内存、锁竞争。并发问题单靠眼睛看代码是很难发现的学会用工具定位才算真正入了门。线程这块东西表面上是技术点本质上是对系统运行规律的理解。搞定了线程再看高并发下的各种框架和中间件你会发现底层逻辑都是相通的。希望这篇整理能帮你少走一些我走过的弯路。