Java基础系列写到了第十六篇终于轮到了多线程。说实话很多朋友学Java学到多线程就开始发怵总觉得线程安全、锁、死锁这些东西看不见摸不着面试一被追问就露怯。其实多线程远没有想象中那么玄它本质上就解决两件事一是把多核CPU的资源真正用起来二是在多个线程同时操作共享数据时把秩序维护好。这篇文章就围绕这两个核心问题展开聊聊Java多线程的创建方式、生命周期、锁机制、线程协作、线程池以及我在实际项目里踩过的坑和排查问题的思路。不管你是刚把Java语法过完的初学者还是准备秋招刷题的求职者这篇都能给你一个比较完整的认知框架。1. 多线程到底在解决什么问题1.1 从单核到多核为什么需要并发很多刚接触多线程的朋友会有一个疑问单线程跑得好好的为什么要并发这个问题的答案得回到硬件层面。CPU的主频在过去十几年里增速越来越慢厂家转而通过增加核心数来提升性能。如果你写一个单线程程序它在4核机器上跑起来实际上只有1个核在忙剩下3个核全程围观。多线程的第一层价值就是让多个核心同时干活把硬件资源榨干。第二层价值藏在IO等待里。一个程序不可能永远在纯计算总要读写文件、访问数据库、调用远程接口。当线程向磁盘发出读请求或者等待网络响应的时候CPU其实处于空闲状态。单线程模型下这段等待时间就白白浪费了。多线程可以把CPU在这段空闲时间里切换去执行其他任务让整个系统的吞吐量成倍提升。举个实践的类比你开了一家奶茶店如果只有一个店员他要兼任接单、收银、做奶茶、打包排队点单的顾客就得等着多雇几个店员各管一摊整体效率就上来了。多线程做的事情本质上就是给程序多雇几个店员让等待和干活能重叠起来。1.2 Java里的线程长什么样Java的线程和操作系统的线程是什么关系在主流JVM实现里Java线程底层对应的是操作系统原生线程或者说一核一模型。你可以把Java的Thread对象理解成一张员工档案里面记录了员工的姓名、编号、状态当你调用start()方法的时候真正的员工才被招进来由操作系统负责给他排班调度。这里有个新手极易踩的坑直接把run()方法写了调用但调用run()和调用start()是两码事。调用run()只是在当前线程里执行一段普通方法并没有创建新线程只有start()才会触发JVM创建原生线程并执行run()。所以经常有朋友说我开了线程怎么没并发检查一下多半是直接调了run()。另外同一个Thread对象不能重复调用start()两次第二次会抛IllegalThreadStateException。线程的调度由操作系统决定JVM和Java代码本身无法精确控制某个线程在哪个时间点拿到CPU这也是后面理解优先级、sleep、yield这些API的关键心态。2. 线程的创建与生命周期管理2.1 五种创建方式的选择Java创建线程的姿势有好几种面试和实际开发围绕它们展开的频率都很高。我直接说结论再给你做张表方便对比。继承Thread类写一个子类重写run()。优点是最容易理解缺点是Java只能单继承一旦继承了Thread就不能继承别的类扩展性差。实现Runnable接口把任务逻辑丢给一个Runnable对象再传给Thread构造器。这个才是推荐的入门姿势因为任务和线程分离类还能再继承别的。实现Callable接口Runnable的加强版任务有返回值还能抛受检异常。它不能直接给Thread用需要包一层FutureTask。FutureTask包装CallableFutureTask实现了RunnableFuture本质上既是一个Runnable又是一个Future既能传给Thread又能通过get()拿结果。线程池提交不用手动new Thread把任务交给ExecutorService这是生产环境最常用的方式后面单独展开。代码上看最核心的区别如下// 方式一继承Thread Thread t1 new Thread() { Override public void run() { System.out.println(任务A执行); } }; t1.start(); // 方式二Runnable配合Lambda Runnable task () - System.out.println(任务B执行); Thread t2 new Thread(task); t2.start(); // 方式三Callable FutureTask FutureTaskInteger ft new FutureTask(() - 1 2); Thread t3 new Thread(ft); t3.start(); Integer result ft.get(); // 阻塞等待结果关于Java有几种创建线程的方式网上答案五花八门有的说四种有的说三种。我的建议是不要拘泥于形式理解本质本质上只有一种就是构造Thread对象并调用start()。其他方式都是在定义任务这个层面做文章把任务从固定的Thread子类里解放出来让代码更灵活。2.2 生命周期与状态流转Java线程的状态是面试常客jstack排查问题也必须理解它。线程总共六种状态很多人背不全或者记混我用平时的视角讲一遍NEWnew了一个Thread对象start()还没调。档案建好了员工还没入职。RUNNABLE调了start()之后只要线程能被调度就处于RUNNABLE。这里注意一个细节操作系统概念里的运行中和就绪在JVM里被合并成了RUNNABLE所以不要问为什么所有线程都RUNNABLE却你觉得卡。BLOCKED线程在等锁。比如synchronized锁被别的线程占着它进不了临界区就阻塞在这里。WAITING无限期等待需要别人唤醒。典型场景是Object.wait()、Thread.join()、LockSupport.park()。TIMED_WAITING限时等待到点自动醒。比如sleep(1000)、wait(1000)、join(1000)都进入这个状态。TERMINATED死了。run()正常结束或者抛出异常线程就到此为止不回头。下面这段代码可以很直观地观察状态变化public static void main(String[] args) throws Exception { Thread t new Thread(() - { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println(t.getState()); // NEW t.start(); System.out.println(t.getState()); // RUNNABLE Thread.sleep(200); System.out.println(t.getState()); // TIMED_WAITING }要提醒两个经典区别sleep()会让出CPU进入TIMED_WAITING但Thread.sleep不释放任何锁而Object.wait()会释放监视器锁并进入WAITING这是多线程通信的基础。理解了这两点后面再看到sleep和wait有什么区别这种问题就不会慌。2.3 优先级和守护线程这两个伪命题Thread.setPriority()接受1到10的整数默认5。很多初学者喜欢把优先级调到最高觉得这样线程跑得快。实际上这个优先级只是一个建议值最终调度权在操作系统手里。在Linux平台上JVM的线程优先级映射效果并不理想依赖优先级做业务逻辑是极度危险的。更别说低优先级的线程会不会饿死这种问题——现代操作系统的调度算法普遍有防止饥饿的机制你只需要明白别把宝押在优先级上。守护线程是另一个容易误解的概念。线程分用户线程和守护线程当进程里所有用户线程都结束不管守护线程跑没跑完JVM都会直接退出。典型的守护线程是垃圾回收线程。你在代码里设置了daemontrue的线程就要知道它随时可能被JVM丢弃比如finally块里做资源清理这种操作放在守护线程里是不可靠的因为JVM退出时不会保证守护线程的清理逻辑执行。3. 线程安全与锁机制3.1 数据竞争是怎么发生的讲锁之前必须先讲清楚为什么需要锁。设想两个线程同时执行一个count操作。count从字节码层面看不是一步操作而是读取count、count1、写回count三步。两个线程并行执行可能出现A读了1B也读了1A把2写回去B也把2写回去结果明明加了两次值却只有2。这种多个线程同时读写同一份数据最终结果取决于线程执行时序的情况就叫数据竞争。这个问题的生活化类比是两个人往同一个账本里记账记账动作要么同时凑近本子、要么另一个人的笔迹直接被覆盖。如果不给账本加锁账目早晚乱掉。Java解决这个问题的思路就是加锁和原子操作让读改写这个复合动作变成临界区同一时间只允许一个线程进入。3.2 synchronized的三种写法和锁升级synchronized在Java里可以加在三种位置修饰实例方法锁的是this对象、修饰静态方法锁的是Class对象、修饰代码块锁的是括号里指定的任意对象。写代码块是粒度最灵活的方式能把锁的范围缩到最小提升并发度。锁的本质是什么在JVM层面synchronized依赖对象监视器也就是对象头里的Mark Word相关标记位。JDK6之后JVM对锁做了大量优化引入了偏向锁、轻量级锁、重量级锁三个梯度。锁升级的大致路径是无锁→偏向锁→轻量级锁→重量级锁。偏向锁的意思是如果一把锁只有同一个线程反复进入JVM干脆给这个线程发一张专属通行证省去CAS加锁的成本当其他线程来竞争偏向锁撤销升级为轻量级锁通过自旋CAS抢锁如果竞争激烈自旋超过阈值就膨胀为重量级锁线程真正阻塞在操作系统的互斥量上。理解锁升级这个机制的最大价值在于调优心态锁不是洪水猛兽无竞争时开销极小。别为了优化在代码里写一堆无谓的LockSupport.park先把业务逻辑里的锁粒度做小收益大得多。3.3 volatile到底管什么volatile是Java里最容易被误解的关键字。它保证两件事可见性和禁止指令重排但它绝不保证原子性。可见性指一个线程修改了变量的值其他线程能立刻看到最新值本质上是JMMJava内存模型中主内存与工作内存之间加了屏障强制对volatile变量的写立即刷回主内存、读从主内存拿。禁止指令重排则针对编译器和CPU为了流水线优化而无序执行指令的问题。经典应用场景是双重检查锁的单例模式。new Singleton()在字节码层面并不只是分配内存调用构造器可能先分配内存、再赋引用、构造器后面才执行。两个线程第一次并发调用getInstance()一个线程new到一半另一个线程发现引用不为空直接拿去用就拿到一个半初始化对象。加上volatile禁止重排以后这个隐患才被彻底堵住。所以看到网上单例代码在instance字段上写volatile别再疑惑是不是多此一举。volatile的第二个场景是状态标志位比如boolean running作为线程停止信号。运行线程不断读running主线程改running为false因为volatile保证可见性运行线程很快就能感知。但要注意如果是count这种读-改-写复合操作volatile完全无能为力还是得用atomic类或者锁。3.4 ReentrantLock、原子类与AQS的一点认知ReentrantLock是JDK5之后java.util.concurrent包提供的显式锁对比synchronized有几个实打实的优势支持中断响应、支持超时获取、支持公平锁选项、可以绑定多个Condition条件队列。使用时必须记得在finally里unlock()否则锁万一没释放轻则性能下降重则线程卡死。写法是ReentrantLock lock new ReentrantLock(); try { lock.lock(); // 临界区 } finally { lock.unlock(); }底层离不开AQSAbstractQueuedSynchronizer。AQS就是并发包的大管家维护了一个volatile int state作为共享状态配合一个CLH变体的等待队列。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些工具的实现精髓都在AQS里。基础阶段你不需要把它背得滚瓜烂熟但理解AQS 状态变量 等待队列 模板方法这个骨架往后看并发源码会轻松很多。原子类走的是无锁路线底层用CAS配合自旋实现。AtomicInteger的incrementAndGet就是一个CAS循环如果读到的值等于期望值才写回新值否则重试。CAS三要素是内存位置、旧的预期值、新值。CAS存在一个经典问题叫ABA线程A读到值为1线程B把值改成2又改回1线程A的CAS判断还是1操作依旧成功但中间其实发生了两次改动。生产环境遇到ABA可以通过AtomicStampedReference加版本号解决不过日常场景碰到的概率不高先有个概念即可。4. 线程间协作与通信4.1 wait/notify的正确打开方式多线程不只是竞争同一把锁更多时候是线程之间需要互相配合。经典的等待-通知模型就是Object类的wait()和notify()。这儿有两个硬性前提必须在持有该对象监视器锁的代码块里调用否则抛IllegalMonitorStateExceptionwait()会释放锁让出CPU进入WAITING状态。用等待通知实现一个最简单的生产者消费者核心代码如下synchronized (BUFFER) { while (BUFFER.isEmpty()) { BUFFER.wait(); // 没东西可消费让出锁等着 } Object item BUFFER.poll(); BUFFER.notifyAll(); // 通知生产者可以补货了 }这里有一个非常关键的经验判断条件必须用while循环不能用if。原因是线程唤醒之后需要重新检查条件是否仍然成立尤其存在多个消费者线程时被唤醒的线程可能因为竞争失败又轮不到自己消费如果直接往下走就会取到空数据。Java官方文档明确指出wait应该放在循环里防止虚假唤醒。很多生产环境偶发bug查到最后都是这里写成了if。4.2 三个好用的同步工具类CountDownLatch、CyclicBarrier、Semaphore是并发包里的三个工具我分开讲清楚它们的定位CountDownLatch是一个一次性门闩。主线程await()若干工作线程各自完成后countDown()全部完成后主线程继续。典型的场景是并发请求多个接口汇总结果。CyclicBarrier是一道可循环的屏障。N个线程互相等待大家都到达屏障点才往下走适合分阶段并行任务的阶段同步。构造器可以传一个barrierAction在放行时执行一段逻辑。Semaphore是信号量本质是限流工具。比如最多同时放3个线程访问某个资源其他线程获取许可时阻塞等待。类似停车场只有3个车位。我实际项目里用得最多的是CountDownLatch和Semaphore。CountDownLatch有个坑是它不能被复用想重复等待得重新new实例。CyclicBarrier可以reset重置适合循环赛这种多次齐头并进的场景。Semaphore释放许可必须放在finally里否则一旦抛异常许可数就永久少一个时间长了所有线程都堵在acquire()上。4.3 ThreadLocal的隐雷ThreadLocal是线程局部变量每个线程都存一份自己的副本。它的Map结构比较特殊ThreadLocalMap的key是弱引用的ThreadLocal实例value是强引用。这个设计是双刃剑——如果线程池里的线程复用了线程一直存活而ThreadLocal被回收导致key变成nullvalue却仍然被线程的Map强引用内存泄漏就出现了。我的习惯是在使用ThreadLocal的线程结束后主动调用remove()尤其在finally块里彻底清掉当前线程的变量。如果你做线程池任务每个任务执行完也必须remove否则下一个任务复用线程时会读到上一个任务的残留数据这种bug非常隐蔽。还有一个冷知识InheritableThreadLocal可以在创建子线程时把父线程的值传过去但线程池复用线程的场景它就不靠谱了因为子线程创建的时候才拷贝值后面父线程改了它也不会感知。5. 线程池生产环境的主角5.1 七个参数搞懂线程池机制生产环境别手动new Thread一线程干一件事线城池才是正道。直接new Thread的问题在于线程创建销毁有开销、线程数量不可控、缺乏统一管理。线程池复用线程从根上解决了前两个问题。ThreadPoolExecutor构造器有七个参数我用一句话解释每个corePoolSize核心线程数常驻线程没事不会销毁。maximumPoolSize最多能开的线程数核心线程都在忙才考虑扩容到上限。keepAliveTime unit非核心线程空闲多久被回收。workQueue任务队列核心线程忙了任务先排队。threadFactory线程工厂为了让线程池里的线程能一键定位业务来源自定义工厂给线程起名字很值得做。handler拒绝策略队列满最大线程也满新增任务怎么办。任务提交后的流转顺序经常有人记反我总结成四句话先让核心线程干活核心线程满任务进队列排队队列也满开非核心线程去接活非核心线程也满走拒绝策略。所以线程池并不是一有任务就开新线程队列在中间起了缓冲作用。拒绝策略有四种AbortPolicy抛RejectedExecutionException、CallerRunsPolicy谁提交谁执行、DiscardPolicy静默丢弃、DiscardOldestPolicy丢最老的任务。生产环境我一般用CallerRunsPolicy至少保证任务不丢代价是提交任务的线程自己干活也能起到天然限流作用。5.2 Executors快捷方法的坑Executors工具类提供了newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor等方法看起来很省事但大厂面试基本都会问为什么不允许用Executors创建线程池。原因在队列和线程数的设置上newFixedThreadPool和newSingleThreadExecutor底层用的都是无界LinkedBlockingQueue默认容量Integer.MAX_VALUE。任务堆积时队列无限膨胀内存直接打爆OOM就是这么来的。newCachedThreadPool线程数上限Integer.MAX_VALUE线程频繁创建短时间大量任务同样可能OOM。所以有经验的团队都会直接new ThreadPoolExecutor把队列设置成有界队列比如ArrayBlockingQueue或LinkedBlockingQueue带容量核心线程数、最大线程数根据场景量化。线程数怎么选网上流传的经验值是CPU密集型设CPU核数1IO密集型设CPU核数乘以一个系数。CPU密集型每个线程几乎全程占着CPU线程多于核数只会增加上下文切换开销IO密集型的线程大部分时间在等IO等的时候CPU可以切换给别人所以线程数可以放宽。IO密集型更严谨的计算公式是线程数 CPU核数 * (1 平均等待时间 / 平均运行时间)。比如一个接口平均等待IO 80ms、计算20ms比值是48核机器上就可以开40个线程。指标不够准确可以先粗设再压测调优没有一劳永逸的公式。5.3 优雅关闭与Future的阻塞陷阱线程池用完了要关否则线程常驻导致资源泄漏。shutdown()会等已提交任务执行完然后关shutdownNow()立即中断正在执行任务并返回队列里未执行的任务列表。比较稳妥的三步关法是shutdown() → awaitTermination(超时时间) → 还没终止就shutdownNow()。这一步在Spring Boot应用优雅停机里尤其重要。提交任务时如果想拿结果用Future.get()。注意get()是阻塞的任务没完成主线程会一直等。把并行任务做汇总时最怕的就是循环里逐个get这样并行又变回串行。正确做法是先全部submit把Future收集起来再统一遍历get让任务真正并行跑。JDK8之后用CompletableFuture.allOf配合join可以更优雅地做任务编排我在接口聚合的场景里特别常用CompletableFutureInteger f1 CompletableFuture.supplyAsync(this::callServiceA); CompletableFutureInteger f2 CompletableFuture.supplyAsync(this::callServiceB); CompletableFuture.allOf(f1, f2).join(); Integer result f1.join() f2.join();6. 常见问题与排查技巧实录6.1 死锁的定位与预防死锁是最让人头痛的并发问题程序不报错、不退出就是卡在那儿。要死锁必须同时满足四个条件互斥、占有并等待、不可剥夺、循环等待。日常代码里最常见的死锁场景是两个线程拿着各自的锁又同时想拿对方的锁比如线程A持有锁1请求锁2线程B持有锁2请求锁1。排查死锁的标准操作是用jstack。首先jps找出Java进程pid然后执行jstack pid如果存在死锁输出末尾会明确提示Found one Java-level deadlock并列出互相等待的锁和线程栈。顺着栈里的锁定信息基本能一眼定位到哪两把锁形成了环。预防死锁的经验写在第一位的永远是锁顺序一致所有线程按照同一个全局顺序加锁就不会有环。其次是尽量用ReentrantLock的tryLock(timeout)代替lock()超时获取不到就放弃避免无限等待。还有锁粒度尽量小不要在一个锁里调用一个又要拿别的锁的外部方法。6.2 CPU飙高的排查路径线上应用CPU飙高多半是某个线程在死循环或者疯狂GC。排查思路是从进程到线程再到代码top命令找出CPU占用高的Java进程pid。top -Hp pid查看这个进程里哪个线程CPU最高记下线程id十进制。把线程id转成十六进制printf %x\n 线程id。jstack pid dump.txt在dump里搜nid0x十六进制id。对应线程的栈信息会告诉你它在执行哪段代码。这一步最关键的是先留现场再处理。不少朋友一看出问题就慌着重启应用等恢复了想查日志线程栈都没了。实践上遇到CPU问题先dump两三份栈隔几秒一份更有助于判断线程是间歇性忙还是持续忙。另外日志里一定要输出线程名和线程id我用自定义线程工厂给线程起名比如biz-order-async-1、biz-report-thread-3出问题的时候看线程名字就知道是哪个业务组的任务少排查一大圈。6.3 高频面试题快问快答多线程是Java面试八股文的重灾区我整理了被问频率最高的几个直接给出我的答题思路面试题答题要点创建线程有几种方式本质上是定义任务的方式Thread、Runnable、Callable、线程池核心是start()sleep和wait的区别sleep不释放锁、必须捕获异常wait释放锁并进入等待集必须配合synchronizedsynchronized和ReentrantLock区别前者JVM层面锁升级后者JDK层面灵活控制支持中断、超时、公平锁、多条件volatile和synchronized区别volatile解决可见性和重排不保证原子性synchronized保证原子性和可见性什么是线程安全多线程访问共享数据时结果正确满足原子性、可见性、有序性为什么不要用Executors无界队列或无限线程数容易OOM应该用有界队列自定义线程池什么是AQS一个状态变量加一个等待队列提供模板方法是众多同步器的基石回答这类题目有个通用技巧别背概念讲场景。比如面试官问volatile你先说它保证可见性和有序性再说为什么DCL单例要加volatile——因为new对象可能重排导致另一个线程拿到半初始化对象这个问题就活了。最后说说我的体会多线程这个主题书上看十遍不如自己在测试环境跑一遍。我建议你把本文里的例子动手敲一遍尤其是不加锁的count、生产者消费者里的while循环、线程池参数调整对比亲眼看到运行结果的差异比背任何概念都管用。我自己最早学多线程的时候也是一头雾水后来是在一个订单导出功能里用线程池把耗时从8秒压到2秒才算真正开窍——多线程不是考试题它是实打实的工程工具。最后再分享一个小技巧开发环境遇到奇怪的偶发bug先在日志里加上线程名然后观察是不是同一个线程在执行不同逻辑多线程问题九成以上靠这一条线索就能摸到源头。下一期我打算接着写并发编程里更深入的工具比如CompletableFuture的编排细节和StampedLock的使用场景感兴趣的朋友可以持续关注这个系列。