想真正理解多线程,不能一上来就背"什么是并发、什么是死锁"。得先搞清楚最底层的执行单元长什么样——进程是什么、线程是什么、各自的资源怎么划分,再往上谈并发、谈切换、谈安全。本文就按这条线,从地基开始一层层往上砌。


阅读路线

全文按"是什么 → 什么样 → 为什么 → 怎么运行 → 会出什么问题"推进,可以按需跳读:

  • 第一、二章(是什么):进程与线程的概念和区别;进程/线程各自持有什么资源,把 PC、虚拟机栈、本地方法栈讲透。
  • 第三章(在 Java 里长什么样):Java 线程和操作系统线程是什么关系——用户线程/内核线程、三种线程模型、平台线程与虚拟线程。
  • 第四章(几个关键名词):并发与并行是什么、同步与异步是什么——后面所有讨论都建立在这几个词上。
  • 第五章(为什么):为什么要用多线程——单核上靠什么提效率、多核上又收益在哪。
  • 第六~八章(怎么运行):线程生命周期与六种状态、上下文切换、sleep/wait 与 run/start。
  • 第九章(会出什么问题):线程安全、内存泄漏、死锁。

一、进程与线程:先弄清"执行单元"到底是什么

1.1 进程:程序的一次执行过程

进程是程序的一次执行过程,是系统运行程序的基本单位,因此它是动态的——系统运行一个程序,就是一个进程从创建、运行到消亡的过程。

在 Java 里,当我们启动 main 方法时,其实就是启动了一个 JVM 进程,main 方法所在的线程就是这个进程里的一个线程,也叫主线程。不止 Java,浏览器、抖音这类软件,只要被启动,就创建了一个进程,程序代码就在这个进程中运行。

一个进程被创建出来,操作系统至少要为它做这几件事:

  • 分配独立的虚拟地址空间(对应一套页表);
  • 分配文件描述符、权限、信号、CPU 时间配额等资源;
  • 把程序代码加载进地址空间的代码段;
  • 之后,就可以在这个进程里创建多个线程来真正执行代码。

一句话概括:进程是操作系统资源分配的最小单位,同时一个进程内部可以有多个线程——这也是后面所有内容的重点。

1.2 线程:进程内部的执行流

线程是进程内部的执行流,是 CPU 调度的最小单位。

它不拥有系统资源,而是"借用"所属进程的全部资源,只保留自己的执行现场。具体来说:

  • 线程自己持有:程序计数器 PC、虚拟机栈、本地方法栈、寄存器上下文。
  • 同类线程共享:堆和方法区(JVM 里方法区的实现就是元空间)。

堆和方法区是所有线程共享的资源。堆是进程中最大的一块内存,主要用于存放新创建的对象(几乎所有对象都在这里分配内存);方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

1.3 一张图看清:进程 vs 线程

进程与线程:资源归属对比

维度 进程 线程
定义 程序的一次执行过程 进程内部的执行流
资源 拥有独立地址空间与全部资源 借用进程资源,只保留执行现场
私有资源 地址空间、文件描述符等 PC、虚拟机栈、本地方法栈、寄存器上下文
共享资源 —— 堆、方法区
系统定位 资源分配的最小单位 CPU 调度的最小单位
切换开销 大 小(因此被称为轻量级进程)
隔离性 强,进程间相互独立 弱,同进程线程极易相互影响

二、线程里有什么,进程里有什么

上一节把"私有 / 共享"列成了清单,这一节就把清单上的每一项讲透——尤其是 PC、虚拟机栈、本地方法栈,它们是理解"为什么线程要私有资源"的关键。

2.1 线程私有的资源

  • 程序计数器 PC(Program Counter Register):一块很小的内存,记录当前线程的代码执行到了哪个位置,方便下一步取指令。
  • 虚拟机栈(JVM Stack):存放栈帧。有方法被调用,就会压入一个对应的栈帧。
  • 本地方法栈(Native Method Stack):为 native 方法服务。
  • 寄存器上下文:CPU 寄存器现场,随线程切换一起保存与恢复。

2.2 线程共享的资源

  • 堆(Heap):存放对象实例,是最大的一块内存。
  • 方法区 / 元空间(Method Area / Metaspace):存放类信息、常量、静态变量、JIT 编译后的代码等。

2.3 深入:PC 和两个栈到底在做什么

程序计数器 PC

PC 记录的是"当前线程下一条要执行的字节码指令地址"。它有两个作用:

  1. 字节码解释器通过改变 PC 来依次读取指令,从而实现顺序、选择、循环、异常处理等流程控制;
  2. 多线程下,PC 用于记录当前线程的执行位置,这样线程切换回来时能知道上次运行到哪了。

注意一个细节:当线程执行的是 native 方法时,PC 记录的是 undefined 地址;只有执行 Java 代码时,PC 记录的才是下一条指令的地址。

虚拟机栈与栈帧

每个 Java 方法在执行前都会创建一个栈帧(Stack Frame),一个栈帧包含四部分:

  • 局部变量表:存这个方法里的局部变量;
  • 操作数栈:专门用来做运算。比如执行 add(1,2),会把操作数按规则入栈,再按运算规则出栈计算——学过数据结构的话,这就是一个标准的栈式求值过程;
  • 动态连接:一个指向运行时常量池中"当前方法"的引用,用于把方法里的符号引用解析成直接引用。注意,它不是用来记录"代码执行到哪一行"的;
  • 返回地址:记录调用方在"调用点下一条指令"的位置。方法返回后,程序靠它回到调用方继续往下走。真正负责"回到哪里"的是返回地址,不是动态连接,这一点很容易记混。

本地方法栈

本地方法栈和虚拟机栈作用相似,区别是:虚拟机栈为执行 Java 方法(字节码)服务,本地方法栈为执行 native 方法服务。在 HotSpot 虚拟机上,两者合二为一。

这里把 native 方法这个名词说清楚。native 是 Java 的一个关键字,被它标记的方法,在 Java 源码里没有实现体、只有一行声明,真正的逻辑由其他语言(通常是 C/C++)编写,调用时由 JVM 直接执行那段本地代码。为什么需要这种机制?因为有些能力 Java 侧根本不具备:取系统当前时间、读写硬件、创建和调度操作系统线程——这些都要走操作系统,而能触到操作系统的只有 C/C++ 代码。例如 System.currentTimeMillis() 就是一个 native 方法:Java 里只声明,真正取时间的逻辑在 JVM 的 C++ 代码里。

顺带把 HotSpot 也交代清楚:"JVM" 是一份规范,HotSpot 是这份规范的参考实现,也就是官方 OpenJDK / Oracle JDK 默认自带的那个 JVM(此外还有 Eclipse OpenJ9 等其他实现)。我们平时说"Java 虚拟机",指的基本都是 HotSpot;它名字里的"热点",来自其 JIT 编译器会在运行时识别执行特别频繁的"热点代码"并编译优化。

2.4 一个完整例子:把 PC、虚拟机栈、本地方法栈串起来

假设一个线程执行这样一条调用链:

1
main()  →  methodA()  →  methodB()  →  System.currentTimeMillis()(native 方法)

整个过程如下:

  1. 线程刚启动,虚拟机栈是空的;主线程开始执行 main(),压入 main 的栈帧,PC 指向 main 的第一条字节码。
  2. main 里调用 methodA:返回地址记为"main 中调用点的下一条指令",PC 指向 methodA 的字节码,methodA 的栈帧入栈——栈变成了 [main, methodA]。
  3. methodA 里调用 methodB:同理,methodB 栈帧入栈——栈变成 [main, methodA, methodB]。
  4. methodB 里调用 native 方法 System.currentTimeMillis():执行切到本地方法栈,此时 PC 值变为 undefined(不指向任何 Java 字节码)。
  5. native 方法返回,PC 恢复,回到 methodB 继续执行;methodB 执行完,栈帧出栈,PC 恢复到 methodA 的返回地址,methodA 继续。
  6. methodA 执行完,栈帧出栈,PC 恢复到 main 的返回地址,main 继续,直到整个栈清空。

一句话记住:虚拟机栈负责"Java 方法一层层调用",本地方法栈负责"native 方法这一层",而 PC 只关心"当前这一层执行到哪条指令"——一旦进了 native,PC 就交白卷(undefined)。

栈帧入栈出栈与本地方法栈调用流程

2.5 为什么 PC、虚拟机栈、本地方法栈必须是私有的

原因只有一个词:多线程。

多线程下,线程会反复被切换。可能线程 1 跑到一半,来了个优先级更高的线程,或者它的时间片到期了,CPU 资源就被抽走、调度给了别的线程。如果线程 1 没有自己私有的 PC、虚拟机栈、本地方法栈,那么下次调度回来时,就完全不知道执行到哪了。

所以把这些执行现场做成线程私有,本质是:下次切回来时,还能准确恢复到上次的运行状态。

JVM 内存区:线程私有 vs 线程共享


三、Java 线程与操作系统线程的关系

3.1 用户线程与内核线程

  • 用户线程:由用户空间的运行时/程序自己管理和调度,运行在用户空间(专给应用程序使用)。
  • 内核线程:由操作系统内核管理和调度,运行在内核空间(只有内核程序能访问)。

两者的特点对比:

  • 用户级线程由运行库在用户空间调度,创建和切换成本较低;能否利用多核,取决于映射模型——多对一模型无法并行利用多核,多对多模型可以。
  • 内核线程由操作系统调度,创建和切换成本通常更高,但可以直接利用多核。

3.2 三种线程模型

线程模型,就是"用户线程"与"内核线程"之间的关联方式,常见有三种:

  • 一对一:一个用户线程对应一个内核线程;
  • 多对一:多个用户线程映射到一个内核线程(不能并行利用多核);
  • 多对多:多个用户线程映射到多个内核线程(可以利用多核)。

三种线程模型与虚拟线程

3.3 Java 的选择:平台线程一对一,虚拟线程多对多

Java 线程与操作系统线程的关系,一句话概括:

平台线程通常映射到操作系统线程,而虚拟线程由 JVM 调度并挂载到平台线程上执行。

具体来说:

  • 早期 JDK 曾用绿色线程(Green Threads)实现用户级线程——由 JVM 自己在用户空间管理和调度、不绑定内核线程,这种方式后来被废弃;
  • 后来通过 new Thread() 创建的传统线程,在 HotSpot 中采用平台线程(Platform Thread)实现。平台线程是什么?说白了:它就是操作系统线程在 Java 侧的"包装壳"——你在 Java 代码里 new Thread().start(),JVM 底层就调用操作系统 API 真正创建一个内核线程,两者一一绑定,之后的调度完全交给操作系统。所以它看起来像"用户线程"(Thread 对象在你自己的内存里),实际调度却发生在内核。在 Windows、Linux 等主流系统上,它以一对一(1:1)方式映射到操作系统线程;
  • Java 21 正式引入的虚拟线程(Virtual Thread)由 JVM 调度,大量虚拟线程可以复用少量平台线程作为载体——这才是真正意义上的"用户级线程",因此不能再把"所有 Java 线程"都等同于操作系统线程。

所以"Java 是一对一"这个说法,只对平台线程成立;虚拟线程走的是多对多——很多虚拟线程,挂载到少量平台线程上,由 JVM 自己调度,而不是简单地一对一对齐内核线程。

3.4 虚拟线程解决的是什么问题

传统平台线程是 1:1 映射内核线程的,所以:

  • "一个线程占一个内核线程",线程数量受限于系统资源,动辄上千就吃力;
  • 阻塞式 IO 时,线程连同它占用的内核线程一起被挂起,白白占着资源。

虚拟线程的思路是:把它和内核线程解耦。海量虚拟线程由 JVM 调度,只有在真正执行时才"挂载"到少量平台线程上;一旦遇到阻塞,JVM 就把它从平台线程上摘下来,让平台线程去跑别的虚拟线程。于是可以用极少的平台线程,撑起几十万级的阻塞任务。

这也是它最适合 IO 密集型场景(大量阻塞等待)的原因——先明白它"是什么、怎么工作",再看它"为什么被引入",就好理解多了。


四、并发 vs 并行、同步 vs 异步

在往下讲"为什么要用多线程"之前,得先把两组最基础的词定义清楚,不然后面全是空谈。

4.1 并发与并行

  • 并发(Concurrency):两个及以上的作业在同一时间段内执行。
  • 并行(Parallelism):两个及以上的作业在同一时刻执行。

区别的关键在于是否同时:

  • 并行与 CPU 核心数有关——几个核心,最多就能同时运行几个线程,是真正的"同一时刻执行"。
  • 并发则是"一个核心反复切换线程"——比如正在跑线程 1,方法还没结束就发生线程切换,换到线程 2 执行。

所以:并行是同一时刻执行,并发是同一时间段内执行,这就是最大的区别。

并发与并行

4.2 同步与异步

  • 同步:发出一个调用后,在没有得到结果之前,该调用不能返回,一直等待。
  • 异步:调用发出后,不等返回结果,直接返回。

两个贴近实际的例子:

  • 同步:用 MyBatis 去查数据库,就是典型的同步——必须阻塞等待数据库返回结果,当前主流用法并不支持真正的异步读取。
  • 异步:最经典的是消息队列。异步发送消息时,发送后就立刻结束,并不关心消息是否已经到达 Broker;同时注册一个回调函数,等持久化结果回来时,再根据成功或失败去更新对应状态。

提醒一个容易混的点:这里的"同步"指的是"同步/异步调用";而后面讲锁时会出现的"同步",指的是"互斥同步"。两者中文都叫"同步",含义完全不同。


五、为什么要用多线程

5.1 宏观:为什么整体"并发能力"能提升

先不如陷进某个细节,从两层来看:

从计算机底层看:

  • 线程可以看作轻量级的进程,是程序执行的最小单位,线程间的切换和调度成本远小于进程;
  • 多核 CPU 时代,多个线程可以真正同时运行,减少了线程上下文切换带来的开销。

从当代互联网趋势看:

  • 现在的系统动不动就要求百万级、千万级的并发量,多线程并发编程正是开发高并发系统的基础。

那为什么用好多线程,"整体并发能力"就能提升?本质是两点:

  1. 让等待和计算重叠。单线程下一旦卡在 IO 等待,CPU 只能空转;多线程下,一个线程在等 IO 时,别的线程可以继续占用 CPU,系统资源不被浪费。
  2. 让多个核心同时干活。多核机器上单线程永远只能用一个核,多线程才能把其余核心真正用起来。

这两点叠加,单位时间内系统"能处理的请求数"就上去了,也就是并发能力提升。

5.2 单核时代:解决 CPU 与 IO 的矛盾

在单核时代,多线程(本质是并发)主要是为了提高单进程对 CPU 和 IO 的利用效率。

假设只运行一个 Java 进程、且进程里只有一个线程:当我们去请求 IO 时,这个线程被 IO 阻塞,于是整个进程都被阻塞。此时 CPU 和 IO 设备里只有一个在运行,可以简单认为此时系统整体利用率只有 50%。而用了多线程之后,一个线程被 IO 阻塞,另一个线程还能继续用 CPU,于是整体资源的利用效率就上去了。

5.3 多核时代:为了把多核用起来

多核时代,多线程主要是为了提高进程利用多核 CPU 的能力。

举例:我们要计算一个复杂的任务,里面有很多本来可以并列的子任务。如果只有一个线程,那么无论系统有几个 CPU 核心,都只会有一个核心被用到,任务只能串行执行。而创建多个线程后,这些线程可以被映射到底层多个 CPU 核心上执行——在多个线程之间没有资源竞争的情况下,执行效率会显著提高,约等于:

1
多线程耗时 ≈ 单核串行耗时 / CPU 核心数

5.4 单核 CPU 支持多线程吗?一定更快吗?

支持。 单核 CPU 通过时间片轮转,把 CPU 时间分给不同线程。虽然单核一次只能执行一个任务,但通过快速在多个线程间切换,可以让用户"感觉"多个任务在同时进行——这就是并发的直观体现。

但效率不一定更高,要看任务类型:

  • IO 密集型:主要是读写文件、网络通信这类需要等待 IO 设备响应的任务。这类任务用多线程效率提升明显——等待 IO 的时候 CPU 是空闲的,正好去跑别的任务。就像做面:煮开水的时候可以同时备菜,等水开了再下锅,等待水开的这段时间厨师(CPU)并不闲着。
  • CPU 密集型:主要是计算和逻辑处理,需要长时间占用 CPU。这类任务如果用多线程,反而会多出线程上下文切换的开销,越切越慢。

5.5 抢占式调度 vs 协同式调度

操作系统主要用两种方式管理多线程执行:

  • 抢占式调度(Preemptive Scheduling):由操作系统决定何时暂停当前线程并切到另一个线程。切换通常由系统时钟中断(时间片轮转)或高优先级事件(如 IO 完成)触发。有上下文切换开销,但公平性和 CPU 利用率较好、不易阻塞。
  • 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换。能减少切换开销,但公平性差、容易阻塞。

Java 采用的是抢占式调度:JVM 本身不负责线程调度,而是把调度委托给操作系统。操作系统通常基于线程优先级和时间片来调度,高优先级线程获得 CPU 时间片的机会更多。


六、线程的生命周期与状态

6.1 Java 线程的六种状态

Java 线程在生命周期中的任一时刻,只可能处于以下 6 种状态之一:

  • NEW:初始状态,线程被创建出来,但还没有调用 start()。
  • RUNNABLE:运行状态,线程调用了 start()、等待运行时的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出特定动作(通知或中断)。
  • TIMED_WAITING:超时等待状态,可在指定时间后自行返回,而不像 WAITING 那样一直等。
  • TERMINATED:终止状态,表示线程已运行完毕。

线程在生命周期中并不固定在某一状态,而是随代码执行在不同状态间切换。

6.2 状态是怎么来回切换的

Java 线程六状态转换

  • 线程创建后处于 NEW;调用 start() 后进入 RUNNABLE(可运行);
  • 执行 wait() 后进入 WAITING,需要其他线程通知才能回到运行状态;
  • 带超时的等待,比如 sleep(millis) 或 wait(millis),进入 TIMED_WAITING,超时结束后回到 RUNNABLE;
  • 当线程进入 synchronized 方法/块,或 wait 被 notify 后重新进入 synchronized、但锁被别的线程占有时,进入 BLOCKED;
  • 执行完 run() 方法后,进入 TERMINATED。

下表把"动作 → 状态"对应起来,方便记忆:

触发动作 进入的状态 如何离开
创建线程 NEW start()
调用 start() RUNNABLE 被调度或被阻塞
synchronized 抢锁失败 BLOCKED 拿到锁 → RUNNABLE
Object.wait() WAITING notify()/notifyAll() → 重新抢锁
Thread.sleep(t) / wait(t) TIMED_WAITING 超时到 → RUNNABLE
run() 执行完毕 TERMINATED 终态

6.3 为什么 Java 不区分"就绪"和"运行"

在操作系统层面,线程有 READY(就绪)和 RUNNING(运行)两种状态;但在 JVM 层面,我们只能看到 RUNNABLE——Java 把这两个状态统称为 RUNNABLE。

原因在于:Java 的 Thread.State 描述的是 JVM 层面的状态,它并不负责反映操作系统调度器的全部内部状态。对 Java 来说,调用 start() 之后,它已经把"启动线程"的通知交给了系统,接下来线程什么时候真正被调度、等待多久、时间片多长,都是操作系统的事。具体的调度策略、时间片长度、是否轮转,都由操作系统及其配置决定,JVM 无法也不该替它概括。


七、线程上下文切换

线程在执行过程中有自己的"运行条件和状态",也就是所谓的上下文,比如前面讲的 PC、栈信息等。当出现以下情况时,线程会从"占用 CPU"的状态中退出:

  1. 主动让出 CPU,比如调用了 sleep()、wait() 等;
  2. 时间片用完,操作系统要防止某个线程长时间占用 CPU 导致其他线程饿死;
  3. 调用了阻塞类型的系统中断,比如请求 IO 被阻塞;
  4. 被终止或结束运行。

其中前三种都会发生线程切换。所谓线程切换,就是:

  • 保存当前线程的上下文,留待它下次占用 CPU 时恢复现场;
  • 加载下一个将要占用 CPU 的线程的上下文。

这一存一取,就是上下文切换。

线程上下文切换:保存与恢复现场

上下文切换是现代操作系统的基本功能。因为它每次都要保存信息、恢复信息,这些动作会占用 CPU、内存等资源,所以一定会有效率损耗——如果切换过于频繁,整体效率反而会明显下降。这也是 5.4 里"CPU 密集型任务多线程反而更慢"的根本原因。


八、sleep vs wait、run vs start

8.1 Thread#sleep() 与 Object#wait() 对比

共同点:两者都能暂停线程的执行。

区别:

  • sleep() 不释放锁,而 wait() 会释放锁;
  • wait() 通常用于线程间交互/通信,sleep() 通常用于暂停执行;
  • wait() 被调用后线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或 notifyAll();sleep() 执行完成后线程会自动苏醒,wait(long timeout) 超时后也会自动苏醒;
  • sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。

8.2 为什么 wait() 不定义在 Thread 中?

wait() 是让"获得对象锁的线程"进入等待,并且会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要操作的是"对象锁",自然要定义在对象上,而不是定义在"线程"上。

类比着问:为什么 sleep() 定义在 Thread 中? 因为 sleep() 只是让当前线程暂停执行,不涉及对象类,也不需要获得对象锁——所以它属于线程自己。

8.3 可以直接调用 Thread 类的 run() 方法吗?

这是非常经典的一道题,start() 和 run() 的区别一定要弄清楚。

  • new 一个 Thread,线程进入新建(NEW)状态;
  • 调用 start() 时,JVM 会启动一个新线程并让它进入就绪状态,分配到时间片后就执行 run() 方法的内容——这才是真正的多线程工作;
  • 而如果直接执行 run(),那么 run() 就只是被当成一个普通方法,在"调用它的那个线程"里执行——这并不产生多线程,就是当前线程串行地跑了一遍方法体。

用一个例子对比就清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StartVsRun {
public static void main(String[] args) {
Thread t = new Thread(() -> {
// 打印当前正在执行 run 方法的线程名
System.out.println("run() 执行者:" + Thread.currentThread().getName());
}, "自定义线程");

// 1) 直接调用 run():在 main 线程里执行,不是多线程
t.run(); // 输出:run() 执行者:main

// 2) 调用 start():启动新线程执行,才是多线程
t.start(); // 输出:run() 执行者:自定义线程
}
}

总结:调用 start() 才能启动线程并进入就绪状态;直接调用 run(),不会以多线程方式执行。


九、多线程带来的问题

多线程带来了性能,也带来了麻烦。常见的有三类:内存泄漏、线程不安全、死锁。

9.1 线程安全与线程不安全

  • 线程安全:不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
  • 线程不安全:可能出现数据错乱甚至丢失。

最典型的不安全场景就发生在堆上——堆是同类线程共享的。当多个线程并发地"读—改—写"同一个共享变量时,就可能出现时间窗口,导致结果出错。

一个最直观的例子是共享计数器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class LostUpdate {
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
count++; // 不是原子操作:读 → 加 1 → 写回
}
};
// 两个线程共用同一个 task,也就是在并发地改同一个 count
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
// start():启动线程,t1、t2 从此并发执行 task 里的循环
t1.start(); t2.start();
// join():阻塞主线程,直到 t1、t2 全部执行完毕才放行,
// 保证"打印"发生在两个线程都跑完之后,否则结果没有可比性
t1.join(); t2.join();
// 打印最终结果:期望 20000,实际往往偏小
System.out.println("期望:" + 20000 + ",实际:" + count);
}
}

两个线程各加 10000 次,期望结果是 20000,实际往往小于 20000。原因就是 count++ 实际分成"读取 → 加一 → 写回"三步。若两个线程都读到了同一个旧值,各自加一后写回,就会有一次更新被覆盖丢失。

线程不安全:读-改-写的时间窗口

从更底层看,线程不安全可以归结为三个根源,每个根源都得先看"内部是怎么执行的":

  • 原子性:count++ 在源码里是一行,编译成字节码却是三条指令——GETSTATIC(把 count 读进运算槽)→ IADD(加一)→ PUTSTATIC(写回)。CPU 随时可能在任意两条指令之间切走线程,切回来时世界可能已经变了。所谓"不具备原子性",不是说它不执行,而是这三步可以被别的线程插队。
  • 可见性:线程并不每次都老老实实去主内存读变量——CPU 核心有自己的高速缓存,JMM 把它抽象成"每个线程各持有一份共享变量的工作内存拷贝"。线程 A 改了 count,可能只更新到自己那份拷贝/缓存,还没刷回主内存;此时线程 B 去读,读到的仍是旧值——A 的修改对 B "不可见"。
  • 有序性:编译器和 CPU 为了优化会重排指令,它们只保证"在单个线程内看结果不变"。可一旦跨线程,别的线程观察到的顺序就可能和源码写的不一样。典型例子是 new 对象的三步——分配内存 → 初始化 → 把引用赋给变量——若被重排成"分配 → 赋引用 → 初始化",另一个线程就可能拿到一个还没初始化完的半成品对象。

对应的常见解决思路有:

  • 加锁:synchronized 或 ReentrantLock,把"读—改—写"整体变成互斥的;
  • CAS / 原子类:用 AtomicInteger 这类基于 CAS 的原子类,避免显式加锁;
  • 不共享:把变量改为局部变量,或使用 ThreadLocal,从根上避开共享;
  • 不可变:使用不可变对象,从设计上杜绝被修改。

9.2 内存泄漏:多半是 ThreadLocal 惹的祸

内存泄漏指的是:对象已经不再被业务使用,却仍然被引用,无法被 GC 回收,导致内存占用持续增长。

在多线程场景下,最典型的内存泄漏来源是 ThreadLocal。它内部的 ThreadLocalMap 里:

  • key 是对 ThreadLocal 的弱引用;
  • value 是强引用。

这里的坑在于:如果 ThreadLocal 对象被回收了,而线程还活着(尤其是线程池里线程长期存活),那么 value 就失去了 key 的对应,却还被线程强引用着无法回收——于是泄漏。

更麻烦的是,在线程池里线程是复用的,如果这次用完不清理,下一个任务就可能读到上一个任务残留的数据,造成数据串场。所以既是内存泄漏,也是数据污染。

正确做法很简单但必须做:用完 ThreadLocal 一定要 remove(),并放进 finally。

1
2
3
4
5
6
7
8
9
10
private static final ThreadLocal<User> CTX = new ThreadLocal<>();

public void handle() {
try {
CTX.set(loadUser());
// ... 业务逻辑
} finally {
CTX.remove(); // 必须清理,否则线程池复用时会串数据、并可能泄漏
}
}

9.3 死锁

什么是线程死锁? 死锁描述的是:多个线程同时被阻塞,其中一个或全部都在等待某个资源被释放。由于被无限期阻塞,程序无法正常终止。

经典的死锁形态:线程 A 持有资源 1、等待资源 2;线程 B 持有资源 2、等待资源 1——两者互不相让,等待链形成闭环。

死锁:循环等待形成闭环

下面这段代码就模拟了上面的死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DeadLockDemo {
private static final Object resource1 = new Object(); // 资源 1
private static final Object resource2 = new Object(); // 资源 2

public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();

new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}

线程 1 先拿到 resource1 的监视器锁,sleep(1000) 是为了让线程 2 先拿到 resource2。等两者都睡醒、又都想请求对方的资源时,就陷入互相等待——死锁产生。

这段代码符合产生死锁的四个必要条件:

  • 互斥条件:资源在任意时刻只能被一个线程占用;
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
  • 不剥夺条件:线程已获得的资源,在未使用完之前不能被强行剥夺,只有自己用完才释放;
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何检测死锁?

  • 用 jstack <pid> 或 jcmd <pid> Thread.print -l 查看线程栈和锁信息,若检测到 Java 级死锁,输出中会列出相关线程及其持有、等待的锁(jmap 主要看堆,不是死锁诊断工具);
  • 用 VisualVM、JConsole 等工具排查——打开 JConsole 连接目标程序,进入"线程"界面点"检测死锁"即可。

如何预防和避免?

预防的思路是"破坏四个必要条件",比如:

  • 破坏请求与保持:一次性申请所有资源;
  • 破坏不剥夺:占用部分资源的线程再去申请其他资源时,若申请不到,就主动释放已占有的资源;
  • 破坏循环等待:按某一固定顺序申请资源,释放时按反序释放。

避免死锁则是在资源分配时借助算法(如银行家算法)计算评估,使系统处于安全状态——即存在一个推进顺序 <P1, P2, …, Pn>,能依次为每个线程分配所需资源,直到它们都能顺利完成。

对上面的例子,只要把线程 2 也改成"先申请 resource1、再申请 resource2",打破循环等待,死锁就解除了:

1
2
3
4
5
6
7
8
9
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 2").start();

此时两个线程都按"先 1 后 2"的顺序申请锁,不会再形成循环等待。


十、总结

把全文收成一条线:

  • 地基:进程是资源分配的最小单位,线程是 CPU 调度的最小单位;线程借进程的资源,只保留自己的执行现场(PC、虚拟机栈、本地方法栈)。
  • 为什么私有:因为多线程会反复切换,私有的执行现场让线程切回来时能准确恢复。
  • 连接处:Java 平台线程 1:1 映射操作系统线程;虚拟线程则多对多,由 JVM 调度、复用少量平台线程。
  • 收益:多线程让"等待与计算重叠、多核同时干活",从而提升系统并发能力;但单核上是否更快,取决于任务类型(IO 密集 vs CPU 密集)。
  • 代价:共享带来线程不安全,切换带来性能损耗,加锁又可能带来死锁。

一句话记住:理解"共享可变状态 + 调度切换"这两件事,就拿到了理解一切并发问题的钥匙。