
刚开始学 JavaEE不管你前面集合、IO 学得多顺碰到多线程总会卡一下。倒不是语法有多难而是线程这个东西看不见摸不着程序跑起来到底是谁在执行哪段代码完全靠脑补。我当年学这一块的时候最大的困惑就是为什么我要在一个程序里同时干好几件事直接写好几段代码顺序执行不行吗直到后来真正理解了 Thread 和并发模型才明白多线程解决的不是能不能跑而是怎么跑得更快、响应更快、资源不被浪费。这篇内容我按初学者的认知路径来写先讲线程到底是啥再看 Thread 创建线程的几种写法然后用代码直观观察线程状态流转最后总结我刚学多线程时踩过的坑。适合还没系统学过并发、对 Thread 只有模糊概念的同学也适合已经会写new Thread(() - {})但说不清背后原理的人。1. 初学多线程先搞明白这件事进程与线程到底差在哪1.1 一个请求要干三件事单线程程序是怎么排队的先看一个最基础的程序一个 Java main 方法里从上往下写了三行代码第一行查数据库第二行调远程接口第三行把结果写进文件。单线程执行的时候这三件事是严格排队的第二行必须等第一行返回结果才能开始第三行必须等第二行完成。这个逻辑没毛病程序也不会出错。但问题出在等待上。查询数据库可能要几百毫秒调远程接口可能要一两秒如果三件事互不依赖只是恰好放在同一个方法里那么白白浪费了中间等待的时间。尤其在做 JavaEE Web 开发的时候一个用户请求到了服务器Servlet 容器会分配一个线程来处理如果这个线程在等远程接口返回那它啥也干不了用户的浏览器就一直转圈。更关键的是现代服务器都是多核 CPU你程序写得再老实操作系统也不可能只让你用一个核。如果代码是单线程的其他核心都在旁边闲着资源利用率就很差。多线程的核心目的就是把这些等待时间和空闲核心利用起来让多个任务真正同时推进。1.2 进程等于车间线程等于车间里的工人网上解释进程和线程的区别经常会说进程是资源分配的最小单位线程是 CPU 调度的最小单位。这话背下来容易真正理解需要打个比方。我习惯把进程比作一个车间。车间有自己独立的厂房、设备、原材料仓库多个车间之间互不干扰一个车间炸了不影响另一个车间开工。操作系统里的进程就是这样每个进程有自己独立的内存空间进程之间默认不能直接访问对方的数据。线程则是车间里的工人。一个车间里可以有多个工人这些工人共享同一个厂房和设备也就是共享进程的内存空间。工人之间交流特别方便因为都在一个车间里递个工具喊一嗓子就行这在 Java 里就是多个线程可以直接读写同一个对象的字段。但方便也有代价两个工人同时抢一把扳手就会打架对应到程序里就是线程安全问题。所以线程不是独立于进程之外的东西而是进程内部的执行路径。同一个进程内的多个线程共享堆内存和方法区但每个线程有自己的虚拟机栈和程序计数器。这也是为什么多线程编程比多进程编程更难因为共享带来的竞争问题比隔离带来的通信问题更隐蔽。1.3 线程带来的并发现实收益以及 Java 里的线程天生是抢活儿的Java 里的线程底层依赖操作系统线程。也就是说你 new 一个 Thread最终对应的是操作系统里的一个原生线程。JVM 本身不亲自实现线程调度而是委托给操作系统去管。所以哪个线程先跑、跑多久这件事程序员控制不了只能通过Thread.yield()、优先级这些手段去商量但最终决定权在操作系统的调度器手里。这个事实在学习阶段要时刻记住。因为很多初学者会有一种错觉我 new 了三个线程它们就会按某种公平的顺序轮流执行。实际情况是线程之间是抢活儿的关系谁抢到 CPU 时间片谁就执行抢不到就在那等着。后面我们用代码观察线程状态的时候你会发现很多不符合直觉的现象根源都在这里。Java 里创建一个线程非常简单new Thread()就完了。但简单不代表容易真正难的是控制多个线程之间的协作关系。所以学习路径应该是先搞清楚单线程怎么变成多线程再研究多线程之间怎么打架最后才学怎么让它们不打架、高效配合。2. Thread 创建线程的四种写法从最笨的继承到最常用的 Lambda2.1 写法一继承 Thread 类重写 run() —— 入门最容易理解但不推荐绝大多数 Java 教材讲线程第一个例子就是写一个类继承 Thread然后重写run()方法。我当年学的时候也是这么入门的因为逻辑很直白Thread 是线程我写一个类去继承它这个类就是一个线程然后往里面塞要执行的代码就行。class MyThread extends Thread { Override public void run() { System.out.println(线程执行了 Thread.currentThread().getName()); } } public class Demo { public static void main(String[] args) { MyThread t new MyThread(); t.start(); } }这段代码能跑但有两个问题比较致命。第一Java 是单继承的你继承了 Thread就不能再继承其他业务类了。第二你把任务代码和线程本身耦合在了一起以后想复用这个任务只能再创建一个线程子类非常不灵活。还有一个必须强调的细节启动线程调用的是start()不是run()。如果直接调run()不会创建新线程只是当前线程在普通方法调用按顺序执行一遍罢了。我在初学阶段犯过这个错打印结果倒也一样但用getName()一看发现执行线程是 main根本不是新线程。这个坑后面会细说。2.2 写法二实现 Runnable 接口把任务和线程拆开既然继承 Thread 不够灵活那就把要执行的代码抽出来单独作为一个任务。这就是 Runnable 接口的用处——它只有一个抽象方法run()不需要继承任何类都能实现它。class MyTask implements Runnable { Override public void run() { System.out.println(任务执行了 Thread.currentThread().getName()); } } public class Demo { public static void main(String[] args) { Thread t new Thread(new MyTask()); t.start(); } }这种写法的好处是任务逻辑 MyTask 和线程执行器 Thread 分开了。你可以随便创建多个 Thread把同一个 MyTask 实例传进去跑MyTask 也可以被线程池复用。以后学线程池的时候你会发现线程池接收的任务对象就是 Runnable 实现而不是 Thread 子类。从面向对象设计的角度看Runnable 是做什么Thread 是怎么跑。两者分离之后代码结构清晰了很多。但 Runnable 也有一个不够爽的地方run()方法没有返回值。如果你让一个线程去计算一个结果算完了主线程怎么拿Runnable 做不到要么自己写共享变量要么后面学 Callable 和 Future。初学者阶段先不用管知道有这个局限就行。2.3 写法三匿名内部类和 Lambda日常开发最常用的姿势很多初学者在项目里看到这样的代码会觉得很神奇public class Demo { public static void main(String[] args) { Thread t new Thread(new Runnable() { Override public void run() { System.out.println(匿名内部类线程); } }); t.start(); } }这个叫匿名内部类。它没有给 Runnable 实现类起名字直接在new Thread(...)的参数里把接口实现塞进去了。好处是如果这个任务只在这个地方用一次没必要单独定义一个类代码更紧凑。到了 Java 8Runnable 是个函数式接口只有一个抽象方法因此可以用 Lambda 表达式简化public class Demo { public static void main(String[] args) { Thread t new Thread(() - System.out.println(Lambda 线程)); t.start(); } }这一下代码量就少了很多。Lambda 的本质还是创建一个 Runnable 接口的实现对象编译器帮我省掉了冗长的匿名类语法。理解了这一层你在项目里看到new Thread(() - { ... }).start();就不会觉得陌生顺手就能翻译成匿名内部类再翻译成普通 Runnable 实现。2.4 写法四方法引用让已有方法直接作为线程任务稍微进阶一点的是方法引用。如果你的任务逻辑已经写在某个类的方法里了可以直接用类名::方法名的语法传进去。比如public class TaskHelper { public static void doWork() { System.out.println(方法引用执行的线程 Thread.currentThread().getName()); } } public class Demo { public static void main(String[] args) { Thread t new Thread(TaskHelper::doWork); t.start(); } }这种写法本质上和 Lambda 等价只是把调用已有方法这个场景单独简化了。初学阶段了解即可重点还是掌握 Lambda。2.5 四种写法怎么选不是越短越好我见过不少新手学了 Lambda 之后就一律new Thread(() - ...)不管任务多复杂都往里塞。结果一个 Lambda 里写了几十行业务逻辑可读性很差。我的建议是分场景写法适用场景优点缺点继承 Thread入门理解、一次性演示概念直观耦合高占用继承位实现 Runnable任务可复用、需要解耦灵活便于复用写法稍长匿名内部类局部使用一次的任务紧凑无需单独建类嵌套多时难读Lambda / 方法引用简单任务、现代 Java 项目最简洁复杂逻辑不适合堆叠真正到工作实际开发中new Thread(...)都不会直接用了而是用线程池ExecutorService。但线程池接收的也是 Runnable 或 Callable所以把 Runnable 的写法吃透是后续学线程池的基础。3. 用代码把 6 种线程状态看出来比死记硬背强太多3.1 从 new Thread() 到 start()线程经历了什么Java 线程有 6 种明确定义的状态定义在Thread.State枚举里。初学者背这些状态总是记混因为只看字面意思很难想象什么时候是 WAITING什么时候是 BLOCKED。我的建议是写代码把它们一个个逼出来看。public class StateDemo { public static void main(String[] args) throws Exception { Thread t new Thread(() - { System.out.println(线程运行中); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println(还没 start t.getState()); // NEW t.start(); Thread.sleep(300); System.out.println(start 之后线程睡着 t.getState()); // TIMED_WAITING t.join(); System.out.println(线程结束以后 t.getState()); // TERMINATED } }运行这段代码你就能亲眼看到 NEW、TIMED_WAITING、TERMINATED 三种状态。我再逐个拆解一下每个状态出现的时机。3.2 NEW 和 TERMINATED一个编译出来还没跑一个已经跑完NEW线程对象创建了但还没调用start()。此时线程还没有和操作系统线程关联它只是一个普通的 Java 对象躺在堆内存里。这个阶段线程还不存在只是线程的简历建好了。TERMINATEDrun()方法正常执行完毕或者中途抛了未捕获异常线程就结束了。一个线程结束之后不能再次start()如果强行调用会抛IllegalThreadStateException。这个异常我实习的时候见过不止一次原因都是同一个线程对象被多个地方复用没检查状态就直接start()。3.3 RUNNABLE一个词里藏着两种状态很多初学者以为 RUNNABLE 就是正在执行其实不完全对。RUNNABLE在 Java 里代表可运行状态包括两种子情况一种是正在 CPU 上跑另一种是准备好了在等待 CPU 分配时间片。为什么 Java 不区分这两种因为 JVM 把是否正在执行的判断交给了操作系统自己只管这个线程没有在等待锁或等待其他条件。所以你在jstack或者其他线程分析工具里看到大量 RUNNABLE 线程不代表它们都在占着 CPU 跑可能只是在排队等时间片。验证 RUNNABLE 状态可以写一个死循环线程public class RunnableStateDemo { public static void main(String[] args) throws Exception { Thread t new Thread(() - { while (true) { // 死循环持续占用 CPU } }); t.start(); Thread.sleep(500); System.out.println(t.getState()); // RUNNABLE } }这种线程只干一件事——疯狂占用 CPU所以它稳稳地停在 RUNNABLE。记住这个例子后面遇到为什么 CPU 占用率那么高的排查问题首先要想到是不是有线程在 RUNNABLE 空转。3.4 BLOCKED、WAITING、TIMED_WAITING三种卡住的区别这是最容易混淆的三兄弟它们的共同点是线程没有在干活在休息。但休息的原因不一样。BLOCKED等待进入同步代码块或同步方法。比如两个线程同时去抢一把锁拿到锁的线程进入 RUNNABLE 干活没拿到的线程就进入 BLOCKED站在锁门外等着。public class BlockedStateDemo { private static final Object lock new Object(); public static void main(String[] args) throws Exception { Thread t1 new Thread(() - { synchronized (lock) { while (true) { // 持锁不放 } } }); t1.start(); Thread.sleep(300); // 确保 t1 先拿到锁 Thread t2 new Thread(() - { synchronized (lock) { System.out.println(拿到锁了); } }); t2.start(); Thread.sleep(300); System.out.println(t2 状态 t2.getState()); // BLOCKED } }WAITING线程在无限期等待另一个线程的通知。调用了Object.wait()、Thread.join()不带超时、LockSupport.park()都会进入这个状态。它和 BLOCKED 最大的区别是BLOCKED 是被动挡在锁外WAITING 是主动调用方法让自己停住等别人来叫醒。TIMED_WAITING和 WAITING 类似但带了时间限制。Thread.sleep(毫秒)、wait(超时)、join(超时)都是这种状态。超时时间一到线程会自动回到 RUNNABLE 或重新竞争锁。这三个状态在项目里也是最常见的线程卡住来源。排查线上问题的时候如果看到大量线程在 BLOCKED说明锁竞争激烈看到大量 WAITING/TIMED_WAITING就要检查是不是某个线程一直在等 wait/join/sleep没被正确唤醒。3.5 状态流转并不总是单向的来回切换才正常很多教材画的状态图是从 NEW 到 RUNNABLE再到 BLOCKED再到 TERMINATED看起来像是一条流水线。但实际运行时线程是在 RUNNABLE、BLOCKED、WAITING 之间来回蹦的。举个例子一个线程在 RUNNABLE 里干活干着干着碰到synchronized抢锁发现锁被占了转 BLOCKED拿到锁了回 RUNNABLE执行到wait()转 WAITING被notify()唤醒后又回到 RUNNABLE。整个过程循环往复直到run()返回才进入 TERMINATED。初学者只需要记住一个核心结论start()之后线程就是在运行和等待之间切换等待的原因不同状态名就不同。等学到锁、条件队列的时候再有具体场景去深化这个状态模型就很立体了。4. 初学者最容易翻车的三个现场for 循环开线程、共享变量、线程数越大越好4.1 for 循环内开线程变量捕获的坑很多新手写多线程第一反应就是我要开 10 个线程处理一批数据于是一拍脑袋写出了这样的代码for (int i 0; i 10; i) { new Thread(() - { System.out.println(处理第 i 个任务); }).start(); }这段代码根本编译不过报错说local variables referenced from a lambda expression must be final or effectively final。原因是 Lambda 表达式里使用的外部局部变量必须是 final 或事实上的 final。循环变量 i 每次都在变所以不能直接用。正确做法是把 i 复制给一个局部变量再传进 Lambdafor (int i 0; i 10; i) { int taskId i; new Thread(() - { System.out.println(处理第 taskId 个任务); }).start(); }还有个更隐蔽的问题这个 for 循环创建线程的方式本质上是来一个任务开一个线程。如果任务量小还行但假如循环一万次就创建一万个线程系统资源直接被打爆。这个问题我在后面的线程池部分专门讲。4.2 多个线程改同一个变量i 根本不是原子操作比 for 循环开线程更经典的坑是多个线程同时对一个 int 变量做自增。很多新手觉得i就是一行代码肯定没问题。但实际上一行i在字节码层面要拆成读取、加一、写回三步。两个线程同时执行到读取这一步都读到了同一个旧值然后各自加一写回结果只加了一次。public class CounterDemo { private static int count 0; public static void main(String[] args) throws Exception { for (int i 0; i 1000; i) { new Thread(() - count).start(); } Thread.sleep(3000); System.out.println(最终 count count); // 大概率不是 1000 } }这个实验我在学线程的时候做过最终结果经常是 980 多、990 多反正到不了 1000而且每次跑结果还不一样。这就是并发编程最核心的竞态问题。解决思路有很多初学阶段只需要知道三个方向加锁synchronized、使用原子类AtomicInteger、保证线程不共享变量。不用着急全学会关键是要建立共享可变数据是有风险的这个意识。4.3 线程数不是越多越好背后是上下文切换的成本新手还有一个误区多线程越多越快。我用一句话打破这个幻想一个单核 CPU同一时刻只能真正执行一个线程其他线程都是在假装同时运行。操作系统在这些线程之间不停切换每次切换都要保存当前线程的寄存器、程序计数器等状态再加载下一个线程的状态这个开销叫上下文切换。如果线程数量过多上下文切换的成本甚至可能超过线程干活本身带来的收益。你开了 100 个线程去处理 100 个小任务每个任务只要 1 毫秒但光切换线程就可能占了 90% 的开销得不偿失。所以判断开多少线程合适要结合任务类型。CPU 密集型任务线程数大约等于 CPU 核心数就够了IO 密集型任务因为线程大量时间在等待 IO可以适当多开。但具体怎么算涉及线程池参数调优了这些都是后话初学阶段记住线程不是免费的就行。4.4 线程名称和主线程的关系容易被忽略初学者还有一个常见误区在 main 里 new 了线程就觉得代码一定是 main 先执行完。实际上 main 方法执行完进程不会立即结束JVM 会等所有非守护线程执行完才退出。public class DaemonDemo { public static void main(String[] args) { Thread t new Thread(() - { for (int i 0; i 5; i) { System.out.println(子线程还在跑...); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start(); System.out.println(main 结束了); } }这段代码的运行结果是main 结束了先打印出来但子线程还会继续打印五条信息等它跑完整个 JVM 进程才退出。除非你把子线程设置成守护线程t.setDaemon(true)那 main 结束之后 JVM 就不会等它了。理解这一点很有用。至少你写代码的时候会知道主线程结束不等于所有线程结束进程的生命周期由所有存活线程共同决定。很多场景下线程跑完之后程序还挂着不退多半就是还有非守护线程存活。5. 学完这一篇该怎么验收以及下一步往哪走5.1 自己动手跑通这几个实验才算真的入门只看不练等于白学。这一节的每一段代码我都建议你打开 IDE 亲手跑一遍再改几个参数观察结果变化。特别是线程状态那一节试着把 sleep 时间改短改长或者把锁竞争改成多个线程抢一把锁观察状态会不会从 BLOCKED 变成 TIMED_WAITING。亲手用getState()把六种状态都打出来一遍比背十遍状态定义都有用。还有一个值得做的实验造一个死等的场景。写两个线程互相持有对方需要的锁让程序卡死然后用jps找到 JVM 进程再用jstack查看线程栈信息。你会发现线程状态和栈信息能直接看出卡在哪一行代码这是所有线上问题排查的基本功。不过死锁涉及 synchronized 的嵌套加锁属于后面锁章节的内容初学阶段先知道有这回事就行。5.2 下一步线程池、锁、原子类学习顺序别搞反学完 Thread 和线程状态之后我建议的下一步是学习线程池。理由很简单实际项目中几乎没人裸用new Thread()线程的创建和销毁是有成本的线程池可以复用线程还能控制并发数量。Java 里最常用的就是ExecutorService、Executors工具类以及面试必问的核心参数corePoolSize、maximumPoolSize、workQueue、拒绝策略。再然后是锁和原子类。从synchronized关键字开始理解互斥、可见性、可重入然后学习Lock接口、ReentrantLock理解锁的公平性、可中断等更高级的特性AtomicInteger这类原子类也要掌握因为它们在低竞争场景下比加锁性能好得多。我自己走过一段弯路略过线程池直接去啃 AQSAbstractQueuedSynchronizer、ConcurrentHashMap 的源码结果看到一半就懵了。后来退回来老老实实先把线程状态、synchronized、线程池的用法搞清楚再回头看源码思路一下就通了。学习并发没有捷径但顺序对了能少走很多弯路。5.3 做一个小项目验收一下用多线程改造一个假想场景最好能找个实际场景练手。我提供一个思路假设你有一个方法需要同时向 3 个外部接口发请求然后汇总结果。先用单线程写一版记录总耗时再用三个线程并发请求用join()或者CountDownLatch等待全部完成最后对比一下耗时的差异感受多线程带来的提升。这个练习几乎覆盖了这一篇的所有知识点创建线程、启动线程、等待线程、线程协作。做的时候你会碰到各种各样的奇怪结果比如为什么汇总结果是乱序的、为什么有时候性能没提升反而变慢这些问题都会逼着你去翻线程状态、去思考等待机制。把这些问题都解决掉JavaEE 阶段的多线程基础就算是打牢了。最后再分享一个我自己的习惯遇到线程相关的问题先打开jstack看线程栈再想解决方案不要瞎猜。线程的问题多半都能在线程栈里直接看出来——是锁竞争、死锁还是资源等待一目了然。这一篇只是认识线程的起点后面内容还多但把这层地基打扎实了后面的路会顺很多。