写完这篇花了我挺长时间但想到当年自己也是这么一路踩坑过来的还是决定把整个思路完完整整地整理出来。希望对正在入门的你有帮助。线程这东西刚接触并发编程的人多少都会有点懵。我自己刚入行那会儿写过不少单线程代码逻辑顺着走挺顺畅的结果第一次在日志里看到几十个线程名交替出现直接就慌了——明明代码是一行一行往下执行哪来的这么多“分身”在干活后来把操作系统的基础补上来才明白线程并不是什么玄学它就是程序运行流程里的一个执行单元。今天这篇用比较接地气的方式从程序怎么在操作系统里跑起来讲起一步步拆到线程、线程状态、同步互斥最后聊到线程池和虚拟线程。适合刚学并发编程的Java侧同学也适合准备面试想快速梳理线程体系的开发者。1. 先搞懂程序运行的基本流程1.1 从进程到线程程序的“执行单位”是怎么演变的要理解线程先得把进程这条线捋清楚。程序是一个静态概念你写的.java文件、编译出来的.class文件、打包好的 jar 包本质上都只是一堆躺在磁盘上的二进制数据它们自己不会“跑”。操作系统把你的程序加载进内存分配好虚拟地址空间准备好各种资源这时候程序才变成了一个动态的实体也就是进程。每个进程都有自己独立的地址空间A 进程里的数据B 进程默认是碰不到的这也是操作系统隔离性的体现。但进程有个很大的问题它是系统资源分配的基本单位创建和销毁的成本都比较高而且同一个进程内想要“同时做多件事”从架构上就很难优雅地实现。你不可能为了做一个文件下载功能就启动十个独立进程然后还要想办法让它们互相通信——进程间通信IPC那套东西写起来是真费劲。于是线程就出现了。线程是进程内部的一个执行流一个进程可以包含一个或多个线程。线程是程序运行流程中的实际执行者进程只是给线程提供“生存环境”的容器。我用一个比较贴近生活的类比来帮你理解把程序运行流程想象成一家餐厅。进程就是这家餐厅本身。它租了店面内存地址空间雇了员工线程准备了厨房设备文件描述符、信号量等资源。线程就是餐厅里的厨师、服务员、收银员。真正干活的是他们他们共享这家餐厅的后厨、仓库和菜单。餐厅可以只有一个老板亲自上阵那就是单线程进程也就是我们写的 main 方法从头跑到尾的那种程序。餐厅也可以有多个员工各司其职一个炒菜一个传菜一个收银这就是多线程程序。他们都在同一家餐厅里干活共享同一个仓库堆内存所以拿货很方便但也正因如此一个员工把盐罐子换成了糖罐子修改共享数据整个餐厅的菜都变味儿了——这就是线程安全问题的最初形态。1.2 线程的组成私有存储区、线程控制块和共享内存很多人背过“线程是CPU调度的基本单位”但具体到底调度的是什么一句话概括不了。一个线程要能独立运行它必须有自己的一套“执行现场”也就是私有存储区和线程控制块。从技术层面拆开看一个线程主要由以下几部分组成组成部分作用性质线程控制块TCB记录线程的状态、优先级、寄存器上下文、所属进程等信息线程私有的管理结构程序计数器PC记录线程当前执行到哪一条指令线程私有的寄存器集合通用寄存器、栈指针等在线程切换时保存和恢复现场线程私有的调用栈Stack存储方法调用的局部变量、参数、返回地址等线程私有的堆空间、全局变量、文件描述符表进程内所有线程共享线程共享的这里要注意一个很容易被忽略的点线程私有的东西其实很少共享的东西特别多。堆内存、静态变量、打开的文件、数据库连接池这些统统是进程级的资源所有线程随便用。而每根线程真正独享的就是那一小块调用栈、一组寄存器快照和一小块内存区域用来存线程自己的元数据。我之前面试过一个同学他跟我说“线程创建的匿名内部类里的局部变量是共享的吗”这明显就是把线程私有存储区搞混了。局部变量是存储在调用栈里的每个线程执行某个方法时都会在自己的栈上压入一份独立的栈帧所以线程内的局部变量天然是线程隔离的不存在共享问题。真正需要操心的是那些定义在类里的成员变量、静态变量以及通过参数传进来的共享对象引用。理解了私有和共享的边界后面理解线程安全就顺了。2. 线程的一生生命周期与调度流程2.1 从创建到销毁一个线程完整跑完会发生什么线程不是凭空出现的它得经历一套完整的生命周期。Java 里最直观的体现就是Thread.State枚举把线程状态分成了六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这里我不打算给你背状态图我直接带你走一遍一个线程从出生到死亡的完整流程。假设你写了这样一段代码public class ThreadDemo { public static void main(String[] args) { Thread t new Thread(() - { System.out.println(线程开始执行); try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(线程执行结束); }); t.start(); System.out.println(主线程继续干自己的事); } }第一行代码new Thread(...)执行完线程对象已经创建出来了但此刻它还不是一个真正被操作系统管理的线程它只是躺在堆内存里的一个 Java 对象状态是NEW新建。这时候你没有调用start()这个线程就永远是个“空壳”里面的 run 方法永远不会被执行。关键来了你调用t.start()的那一刻JVM 会向操作系统内核申请创建一个原生线程同时把这个 Java 线程和内核线程绑定起来。这个时候线程进入RUNNABLE可运行状态。注意RUNNABLE 这个状态其实包含了两个子状态一个是正在被 CPU 执行的“运行中”另一个是万事俱备、只欠 CPU 时间片的“就绪”。在 Java 层面你区分不出来但在操作系统层面这两个状态之间切换是非常频繁的。当Thread.sleep(2000)执行时线程进入TIMED_WAITING限时等待状态。它明确告诉操作系统“你先别调度我了我睡两秒两秒后你再叫我。”睡醒之后线程并不会立刻执行它先回到 RUNNABLE 队列排队等 CPU 重新分配给时间片。最后 run 方法执行完线程进入TERMINATED终止状态。但这里有一个很少人注意的细节线程对象本身还留在堆内存里你可以继续持有这个引用甚至调用它的getState()方法但你再也没办法让它重新启动了。已经终止的线程调用start()会直接抛出IllegalStateException。整个流程走下来你会发现程序运行的基本流程就是“创建—就绪—运行—阻塞—终止”的循环往复。CPU 不会专门去等某个线程它像一个高速切换的接线员每个线程分到一小段时间片时间片到了就强行切换出去保存现场换下一个线程进来。2.2 上下文切换到底切换了什么很多人听说线程切换有性能开销但不知道开销具体花在哪。线程切换在操作系统里叫上下文切换Context Switch它干的活很机械把当前正在运行的线程的寄存器值、程序计数器、栈指针这些“现场信息”全部保存到它的线程控制块TCB里再从下一个要运行的线程的 TCB 里把这些信息恢复出来。这个过程有多快大概几百纳秒到几微秒听起来好像不贵。但在高并发场景下线程数量一多上下文切换本身就会大量消耗 CPU。你在日志里看到线程数跑到两三百CPU 使用率却不怎么高大概率是时间都耗在切换上了。这也是为什么后来大家疯狂推崇“减少线程数”和“虚拟线程”因为虚拟线程的切换成本被 JVM 优化到了极低这是后话后面专门讲。顺便提醒一下线程切换还有一个经常被忽略的“副作用”缓存失效。每个线程可能跑在不同的 CPU 核心上线程之前操作过的数据在 L1、L2 缓存里切换之后缓存可能就废了下一次要重新从内存加载。线程切换次数越多缓存命中率越低程序整体性能就越难看。3. 线程安全、同步互斥与死锁高并发路上最真实的三个坎3.1 为什么会出现线程安全从 i 和 HashMap 聊起线程这块大部分初学者最初的困惑不是线程怎么创建而是“为什么线程不安全”。我用两个最常见的例子给你拆开看。第一个例子是i。你可能会想这一行代码多简单CPU 执行一下不就完事了吗可是实际上i在字节码层面是三步操作从内存读取 i 的值到寄存器在寄存器里把值加 1把寄存器里的新值写回内存如果两个线程同时执行i操作它们可能同时读到 i10然后各自加一最后都写回 11。你执行了两次加法结果 i 只从 10 变成了 11这就是经典的竞态条件Race Condition。你没加任何同步措施Java 里的普通变量并不是原子操作的。第二个例子是大家都踩过的HashMap。HashMap 线程安全吗答案是不安全。如果多个线程同时往一个 HashMap 里 put 数据而且开始发生扩容操作的时候HashMap 内部的链表结构可能被打乱严重的时候还会造成死循环——在 JDK 7 那个年代这个问题是真实存在的CPU 会被打到 100% 的那种死循环。JDK 8 改了实现死循环问题被解决了但数据丢失、值覆盖的问题依然存在。所以并发环境下老老实实用ConcurrentHashMap。那怎样才算安全几个常用手段使用线程安全的容器比如 ConcurrentHashMap、CopyOnWriteArrayList使用原子变量比如AtomicInteger它内部通过 CASCompare And Swap机制保证了单个操作的原子性。你问AtomicInteger线程安全吗答案是安全的CAS 是硬件级别支持的操作比较并交换不需要加锁使用锁也就是synchronized关键字或者Lock接口的实现类来保护临界区这里我想多说一句锁的本质是让并发执行的代码串行化。加了锁之后一个线程在临界区里执行时其他线程必须在外头排队这就牺牲了一部分并发性能换来了数据的一致性。你必须在并发度和数据安全之间做权衡没有万能的“既要又要”。3.2 synchronized 和 Lock互斥锁的两种主流姿势Java 里实现线程互斥最基础的手段就是synchronized。可以修饰方法也可以包裹代码块。它的核心思想是每个对象都有一把隐式的监视器锁线程进入 synchronized 代码块之前必须拿到这把锁拿不到就阻塞在BLOCKED状态。public class Counter { private int count 0; public synchronized void increment() { count; } }这段代码你看着简单实际执行流程是线程 A 进入increment()成功获取 this 对象的锁执行 count。线程 B 同时想进来发现锁被 A 占用于是进入阻塞队列。A 执行完方法退出自动释放锁B 被唤醒进入 RUNNABLE 状态重新竞争锁。记住synchronized 锁是可重入的也就是说同一个线程可以重复获取同一把锁多次不用先释放再获取。synchronized在 JDK 6 之后引入了偏向锁、轻量级锁、重量级锁的升级机制锁的开销已经大幅降低了日常开发里大部分场景直接用 synchronized 就够了。但如果你需要更精细的控制——比如超时等待、公平锁、多个条件变量那就要用到ReentrantLockLock lock new ReentrantLock(); boolean tryLock lock.tryLock(3, TimeUnit.SECONDS); if (tryLock) { try { // 临界区 } finally { lock.unlock(); // 必须在 finally 里释放锁 } }用Lock有一个容易被新手忽略的细节必须手动 unlock而且一定要放在 finally 里。如果中间抛了异常锁没有释放就会造成死锁。synchronized 是 JVM 自动释放锁的所以不用担心这个问题。3.3 死锁是怎么产生的四个必要条件别硬背死锁算是并发编程里最让人头疼的问题之一了面试也爱考。本质上就是两个或者多个线程互相持有对方想要的资源谁都不肯放手于是所有线程都卡死在那里。经典的死锁场景是“哲学家就餐问题”代码里长这样public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { System.out.println(T1 持有锁A); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(T1 持有锁B); } } }); Thread t2 new Thread(() - { synchronized (lockB) { System.out.println(T2 持有锁B); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(T2 持有锁A); } } }); t1.start(); t2.start(); } }这段代码跑起来很大概率会卡死。T1 持有 A 等 BT2 持有 B 等 A互相干瞪眼。死锁的四个必要条件是互斥条件、持有并等待条件、不可剥夺条件、循环等待条件。这四个条件同时满足死锁才会发生。所以破解死锁的思路就是打破其中任意一个条件。实际写代码里最常用的方式有两种加锁顺序一致所有线程都按照相同的顺序获取锁比如先锁 A 再锁 B就不会出现循环等待。使用 tryLock 超时机制在获取锁时设置超时时间拿不到就主动放弃已经持有的锁避免无限等下去。真遇到了死锁怎么排查Java 自带的jstack命令会直接告诉你哪里发生了死锁。使用方法很简单先找到 Java 进程的 PID然后jstack pid在输出末尾会看到明显的 “Found one Java-level deadlock” 字样同时会显示两个线程各自持有哪些锁、正在等待哪些锁、堆栈信息在哪个类哪一行。配合线程 dump 来看死锁位置一目了然。线上排查的时候我一般是先jstack一下确定死锁线程再往上游查加锁顺序。4. 从线程到线程池并发编程的工程实践4.1 为什么不能无脑地 new Thread线程创建的代价有多高之前有个同事写了个定时任务每次执行都new Thread(...).start()还理直气壮地说“Java 线程不是很简单吗”。结果系统一压测线程数直接飙到几千服务开始频繁 Full GC然后各种超时告警。问题就出在线程本身不是免费的。每创建一个线程JVM 要分配独立的调用栈空间Java 默认栈大小一般是 1MB你自己算算几千个线程要占多少内存操作系统要创建对应的内核线程线程创建和销毁本身就涉及系统调用。更关键的是线程之间的上下文切换会消耗大量 CPU。你表面上是在用多线程提升并发能力实际上大部分时间都浪费在创建、销毁、切换上了。线程池存在的意义就是把线程的创建和销毁成本降低到极致。核心思想是提前创建好一批线程放在池子里来了任务就分配给池里的线程执行线程执行完任务后不销毁继续等待下一个任务。线程池内部一般配合一个阻塞队列来存放来不及处理的任务。4.2 线程池核心参数怎么配阻塞队列、核心线程数、拒绝策略Java 里最正统的线程池创建方式不是用Executors而是直接用ThreadPoolExecutor。我之前在团队里明确要求过禁止用Executors.newFixedThreadPool()这类快捷方法因为它们内部的队列大小默认是Integer.MAX_VALUE在任务积压的场景下内存会被打爆。ThreadPoolExecutor有七个参数但真正核心的就四个corePoolSize核心线程数池子里保底保留的线程数量即使空闲也不会被回收。任务来了先看核心线程是不是都忙有空的直接用。maximumPoolSize最大线程数当核心线程全忙且阻塞队列也满了才会尝试增加线程到最大线程数用完之后如果超时多余线程会被回收。workQueue阻塞队列核心线程全忙时新任务排队进队列。队列的选择直接决定线程池的行为这个参数特别关键后面展开讲。饱和策略RejectedExecutionHandler任务数超过 maximumPoolSize 队列容量时新任务会被拒绝。默认的AbortPolicy会直接抛异常实际项目里我更喜欢用CallerRunsPolicy让提交任务的线程自己来跑被拒绝的任务这样至少不会丢任务。关于阻塞队列的选择这是很多初学者容易忽略的细节。常见的三种队列类型特点适用场景ArrayBlockingQueue有界队列容量固定必须手动指定大小推荐使用可以限制内存占用LinkedBlockingQueue默认无界也可以指定容量无界时容易堆积任务慎用SynchronousQueue不存储任务直接转交给空闲线程适合想要“立刻执行”的极端场景我给一个日常推荐配置比如一个 IO 密集型的任务ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(1000), // 有界队列容量1000 new ThreadPoolExecutor.CallerRunsPolicy() // 任务满了让提交者自己执行 );核心线程数怎么算经验公式是CPU 密集型任务核心线程数约等于 CPU 核心数 1。因为这种任务主要靠 CPU 计算线程开多了反而因为上下文切换拖慢速度。IO 密集型任务比如等待网络响应、磁盘读写线程大部分时间在等待可以开多一点一般是 CPU 核心数 * 2。拿我们线上的一台 8 核机器举例上面跑的是 IO 密集型的接口任务我配置的核心线程数是 8 的 1.5 到 2 倍也就是 12 到 16 之间实际压测下来16 个线程的吞吐量比 8 个提了差不多一半。4.3 新趋势虚拟线程到底在解决什么问题聊到线程池就不得不提这两年很火的虚拟线程Virtual Threads。如果你用的是 Java 21 起步版本结合 Spring Boot 3.5你可以直接把虚拟线程开通那体验和传统线程池完全是两个世界。传统线程池之所以难配本质原因是线程太贵了你算来算去的核心线程数、队列容量都是因为这个限制。虚拟线程的思路是既然线程主要是在 IO 等待上浪费时间而不是真的在跑 CPU 计算那我干脆把线程造得足够便宜而不是复用。虚拟线程是 JVM 级别的轻量线程一个平台线程底下可以挂几千几万个虚拟线程在底层共享那几十个内核线程。IO 等待的时候虚拟线程会自动从平台线程上“卸载”等 IO 就绪了再“挂回去”整个过程不需要锁也不需要阻塞操作系统线程。你在代码里写Thread.sleep(1000)虚拟线程会自己让出底层平台线程让其他虚拟线程去跑。配置方式很简单Spring Boot 3.5 里把spring.threads.virtual.enabledtrue打开Tomcat 接收每个请求处理的线程就会自动变成虚拟线程。这时候你不用再费心思计算线程池大小直接让每个请求独占一个虚拟线程代码写起来跟同步阻塞一样但并发能力比用线程池还高。我自己的直观感受是虚拟线程不是让你更高效率地复用线程而是让你彻底忘记“线程数量”这个概念。它的核心价值是把并发模型简化了你不需要为了追求并发性能去写复杂的异步回调链老老实实用同步代码的风格就能享受到高吞吐量。当然虚拟线程也有它的边界比如它在 synchronized 块或原生 IO 里会被“钉扎”在底层线程上遇到那种场景性能会退化不过日常的大部分业务接口问题不大。5. 常见问题速查与排查工具5.1 真正的生产环境里线程相关的坑都在哪儿这一节我把这些年实际线上遇到的高频问题整理成了一张速查表每一个都是真实案例。你在开发的时候多用几分钟扫一眼可能就能避开几个大坑。现象原因排查手段接口偶发数据错乱多个线程共享同一个非线程安全对象比如 SimpleDateFormat、HashMap检查成员变量是否有并发读写替换成 ThreadLocal 或线程安全类CPU 100% 但线程数不高某个线程死循环或者连接泄漏最常见的是 SDK 里的重试机制出问题了top -Hp pid查看所有线程 CPU 占用再 jstack 定位具体线程线程数持续上涨内存慢慢漏线程池使用不当或者每次请求都 new 了线程池没有复用全局排查new ThreadPoolExecutor确保线程池是单例的服务假死请求全部卡住可能出现死锁或者数据库连接池被占满线程都阻塞在获取连接上jstack 看 BLOCKED 状态的线程堆栈重点看锁信息和调用链高并发环境下 HashMap 数据丢失HashMap 在多线程扩容时丢数据替换成 ConcurrentHashMap这里多说一句线上排查时jstack是最有用的工具没有之一。拿到一个新的 Java 服务进程我的习惯是# 先看进程的 PID jps -l # 输出线程状态快照保存到文件慢慢分析 jstack pid thread_dump.txt然后打开文件重点看java.lang.Thread.State为BLOCKED或者WAITING的大量线程它们如果恰好都卡在同一个类名、同一个方法名上那里就是问题所在。有一次我们线上接口晚上高峰期集体超时我就是这么定位到一个日志框架内部synchronized块过长的问题瞬间解决。5.2 几个我反复踩过的线程使用经验聊几个具体的经验都是我踩过坑换来的教训写在文档里没人告诉你。第一个是线程必须显式命名。你问我为什么等你看到线上日志里几十个Thread-5、Thread-12混在一起查问题想骂人的时候就明白了。给线程取个有意义的名字排查问题的时候能省一半时间ThreadFactory namedThreadFactory new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, order-save-thread- counter.getAndIncrement()); } };第二个是线程等待所有任务完成时别光用 join()试试 CountDownLatch 或 CompletableFuture。join()的问题是写法比较原始主线程只能等一个线程结束。如果你要等十个线程都完成才能汇总老老实实用CountDownLatchint taskCount 5; CountDownLatch latch new CountDownLatch(taskCount); for (int i 0; i taskCount; i) { executor.submit(() - { try { // 任务逻辑 } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.SECONDS); // 最多等10秒防止等死注意countDown()必须放在finally里不然任务抛异常会导致计数器永远减不到零调用方就永远等下去了。用CompletableFuture更现代一点但底层思路是一样的都要注意任务异常时的处理。第三个是不要在线程使用结束后直接手动 stop 线程。Thread.stop()方法在 Java 里已经废弃了直接强制终止线程会导致资源没释放、锁状态不一致甚至数据错乱。要停止一个线程正确做法是通过一个标志位配合interrupt()方法协作完成。线程在等待或休眠状态下收到中断信号会抛出InterruptedException如果你不处理线程就会一直睡下去。第四个是理解“线程消息不能跨进程”。这一点我当时也是实际操作后才刻骨铭心。某个项目里我用一个 JVM 的BlockingQueue做任务推送结果部署了两台机器发现两台机器上的队列里数据根本不相通——这太正常了每个 Java 进程是独立的它的内存空间互相隔离线程是进程内部的执行单元线程 A 的队列数据不可能直接给另一个进程里的线程 B 用。你要跨进程通信要么用数据库要么用消息队列MQ要么用网络通信。记住这条边界能少犯很多低级错误。第五个是关于Linux环境下库函数里能不能用线程。答案是能但要注意库函数必须做到线程安全。C/C 里像strtok()这种老函数内部用了静态变量多线程并发调用就有问题后来规范给了strtok_r()这种带_r后缀的线程安全版本。Java 里虽然不需要关心这个但底层 JVM 调用的原生库一样有类似逻辑好消息是 JDK 的标准库绝大多数都是线程安全的你不用太担心。5.3 不同语言和框架下的线程形态最后简单聊一下线程在不同环境下的形态变化这件事容易被人忽略因为面试时老问 Java 的线程但实际工作里你可能同时在碰多种技术栈。C# 串口线程C# 里访问串口设备读数据是阻塞的不能把主线程卡死所以要开一个后台线程去读。需要注意串口数据处理完毕之后及时关闭线程和串口对象否则资源会一直占用。这种场景我建议用BackgroundWorker或者Task.Run()别直接裸写线程。Qt 线程Qt 的规则很严格UI 操作必须在 GUI 线程主线程里做子线程里不能直接碰组件否则程序直接崩溃或者行为异常。正确的做法是子线程发信号主线程通过信号槽机制来更新界面。这其实是一个典型的线程消息传递模型理解了信号槽之后写起来很顺手。安卓 Fragment 开线程安卓上 Fragment 里开线程要注意线程在后台执行网络请求没问题但请求回来之后要更新 UI必须切回主线程常见手段有runOnUiThread()或者Handler.post()跟 Qt 的思路异曲同工。说来说去这些框架的本质其实都一样谁拥有数据谁就有资格改数据跨线程传递数据的正确姿势是传递消息而不是直接操作他人。把握住这个本质不管换什么语言、什么框架你的线程模型都不会乱。最后再分享一个小技巧我在实际项目里一直用的写并发代码的时候在关键临界区入口和出口各打一条日志包含线程名和耗时。刚开始你可能觉得日志有点多但等到线上出问题的时候这些日志就是你还原现场的唯一线索。线程安全这种事光靠看代码很难捕捉但日志会诚实地告诉你你的程序运行流程里到底发生了什么。基础的线程知识学好了这些日志看起来就是在跟你讲故事而不是一堆乱糟糟的字符。
