1. 先搞明白一件事:程序和进程到底差在哪
我第一次接触线程这个概念的时候,整个人是懵的。教科书上说"进程是资源分配的最小单位,线程是CPU调度的最小单位",这句话背下来容易,真到写代码的时候,该不会还是不会。后来我在某实验室做项目,被一个多线程下载工具折腾了两周,才真正把这块骨头啃下来。这篇文章我就用最直白的方式,把线程这条线从头捋一遍,看完你至少能搞清楚:程序跑起来之后,CPU到底在忙什么。
1.1 程序是菜谱,进程是灶台上的那口锅
先打个比方。程序就是你手机里安装包那个文件,它躺在磁盘上的时候,就是一个静态的东西,一堆字节码,什么也不干。进程是什么?是你双击图标之后,操作系统给它分配了内存、文件句柄、CPU时间片,它真正"活"过来的那个实例。
我举个例子你就明白了。你在电脑上打开记事本,输入一段文字,这时候"记事本"这个程序就变成了一个进程。你打开第二个记事本窗口,实际上是同一个程序对应的两个独立进程。它们各自有独立的内存空间,互不干扰,一个窗口卡死不会影响另一个。
进程和程序最大的区别就是:程序是静态的,进程是动态的。程序只是一堆指令,进程是这些指令被执行时的完整现场。这个"现场"包括内存里的数据、打开的文件、当前执行到了哪一行,全部打包在一起,才叫一个进程。
这里有一个非常重要的点:进程是操作系统分配资源的基本单位。也就是说,内存、文件、网络连接这些资源,都是按进程来分配的。两个进程之间天然隔离——你在进程A里改内存,进程B是感知不到的,因为它们的地址空间是独立的。这种设计的好处是稳定性,一个进程崩溃不会把整个系统拖垮;坏处是进程之间通信很麻烦,得靠管道、共享内存、消息队列这些机制。
1.2 线程才是真正干活的工人
那线程又是什么?在进程这个"工厂"里,线程就是里面干活的工人。一个进程至少有一个线程,就是主线程,程序入口那个线程。但是一个进程可以拥有多个线程,它们共享进程的资源。
还是拿记事本说事。一个记事本进程,如果只有一个主线程,那它边打字边检查拼写的时候,就会卡。因为单线程意味着同一时刻只能做一件事。如果你给记事本加一个后台线程负责拼写检查,主线程继续接收键盘输入,两个线程并行工作,体验就完全不一样了。
线程和进程最关键的区别,有一段话我必须原封不动写在这里:
进程是资源分配的单位,每个进程拥有独立的内存空间;线程是CPU调度的单位,同一进程下的线程共享该进程的内存空间。
我刚开始一直没转过弯来,觉得"线程也有自己的栈啊,怎么叫共享内存"。后来才想明白:线程确实有自己的栈、自己的寄存器上下文,但堆内存、方法区、打开的文件描述符这些,是进程内所有线程共享的。共享意味着什么?意味着两个线程可能同时修改同一个变量,这就引出后面要讲的一堆麻烦事——线程安全、锁、死锁,全是从"共享"这两个字长出来的。
2. 线程的一生:从出生到死亡的状态机
学线程绕不开生命周期。Java里线程有六个状态,初学者看着这张状态图就头疼。我当年也是死记硬背,直到自己做了一个线程监控的小工具,亲眼看着线程在状态之间跳来跳去,才算真正理解。这里我用一个"上班族的一天"来做类比,包你一遍记住。
2.1 创建与就绪:排进CPU的候机室
线程刚被创建出来的时候,在Java里叫NEW状态,意思是"我出生了,但还没开始跑"。这时候线程对象已经存在,但start()方法还没调用,它不会执行任何任务代码。
调用start()之后,线程进入RUNNABLE状态,这个状态比较特殊:它其实包含了"就绪"和"运行中"两种情况。为什么这么分?因为CPU的调度是抢占式的,线程觉得自己在跑,但可能随时被操作系统切出去,把CPU让给别的线程。从线程自己的视角看,它一直在"可运行"状态里,只有操作系统才知道它此刻到底在哪颗CPU核心上执行。
类比一下:你早上到了公司,去工位坐下开始干活,这是"运行中";但你中途站起来去茶水间倒水,人离开工位了,锅还是你的,这叫"就绪"——随时能回到工位继续干。线程的状态复杂就复杂在,它自己感知不到自己被切走了,它只知道自己还有没有资格干活。
2.2 阻塞与等待:被卡住的各种姿势
线程在运行过程中,经常会因为各种原因进不了CPU。Java把这种情况分成三类:
- BLOCKED:想进一个被占用的锁(比如
synchronized),只能在外面等着。 - WAITING:主动调用了
wait()、join(),等别人来唤醒。 - TIMED_WAITING:调用了
sleep(3000)或者wait(1000),等时间到了自动恢复。
差别在哪?BLOCKED是"被动等",你想进去但门锁着。WAITING和TIMED_WAITING是"主动等",你自己决定先歇一会儿。
以干饭为例:食堂窗口排了十几个人,你排在后面干着急,这就是BLOCKED,前一个人打完菜你才能往前挪。如果你觉得队伍太长,先回去打一局游戏,约定半小时后再来,这就是TIMED_WAITING,时间到了你自动回去看。如果你打完一局还不确定食堂开没开,坐在原地发呆等朋友电话通知你,这就是WAITING,必须等别人来叫你。
这三个状态看着是纯理论,实际上排查线上问题的时候天天用到。线程dump打出来,一看一堆线程卡在BLOCKED,那说明锁竞争激烈;一看全是TIMED_WAITING,那八成是业务里有大量sleep等待,可能是轮询写得不合理。
2.3 终止:线程也有"寿终正寝"的说法
线程跑完run()方法里的全部代码,就进入TERMINATED状态了,生命周期结束。这个状态没什么好说的,但有两点容易踩坑。
第一,线程不是说你调个stop()就能杀的。Thread.stop()这个方法已经废弃了,因为它会直接释放线程持有的所有锁,可能导致数据处于不一致状态。正确做法是通过标志位让线程自己优雅退出。
第二,线程死了不会自己通知你。主线程想等子线程干完活再继续,得调join()。我记得有一次写批量导入功能,开了十个线程处理文件分片,主线程不等它们跑完就开始合并结果,结果合并出来缺数据。后来查了半天才发现少了join()调用,真是一行代码引发的血案。
3. 单线程执行代码:CPU内部到底发生了什么
说完了线程的状态,我再说说单线程执行代码时,计算机内部是怎么运作的。这部分搞懂了,后面理解多线程的坑会顺很多。
3.1 程序计数器:记住当前位置的书签
每个线程都有一个程序计数器(PC Register),它就是记录"线程当前执行到哪条指令"的一个小寄存器。代码明明是高级语言,一层层编译成汇编、机器码之后,PC就跟人看书一样,得记住读到哪一页了。
为什么我说PC是线程私有的?因为CPU切换线程的时候,得能接着上次的位置继续执行。如果PC是全局共享的,线程A切出去再切回来,都不知道该从哪条指令继续了。所以每个线程带着自己的书签进了CPU,用完被切走,书签还在自己手里,下次回来接着读。
这里有一个冷门知识点分享给大家:现代JVM里,执行Java方法的时候,PC里存的是下一条字节码指令的地址;执行native方法的时候,PC值是undefined。因为native方法是直接操作系统交互的,不需要JVM去解释,也就无所谓"字节码位置"了。
3.2 调用栈:线程的"工作台账"
线程执行方法调用的时候,会往自己的栈帧里压数据。每次调用一个方法,就压入一个新栈帧。栈帧里有什么?局部变量表、操作数栈、方法返回地址。
我调试代码的时候经常看"Call Stack"面板,那就是当前线程的栈回溯。哪个方法调了哪个方法,一层一层全部列出来,这就是栈帧的实时快照。
容易出错的一个点是栈溢出。递归没有出口的时候,或者递归层数太深,线程的栈会被撑爆,抛出StackOverflowError。平时写代码递归层数不要超过几千层,真要处理深层次遍历,换成迭代循环更稳。
顺带说一句,线程的栈大小是可以调的。JVM启动参数-Xss就是干这个的。默认是1MB左右,你要是开几千个线程,光栈就要占好几个GB内存。所以线程池的线程数量不是越大越好,每个线程都带着自己的栈,内存是实打实要分的。
3.3 系统调用:线程请求操作系统帮忙
线程跑在用户态,有时候需要请求内核帮忙干活,比如读取文件、建立网络连接,这就要发系统调用。每次系统调用,线程要从用户态切到内核态,这个切换是有开销的。
为什么讲线程要提这个?因为多线程的优势恰恰在于:线程创建和上下文切换的开销远小于进程,所以处理高并发业务的时候,用多线程而不是多进程。但即便是线程,上下文切换也不是免费的。一次切换大概几微秒,看起来不起眼,如果每秒切换几万次,CPU时间的浪费就很可观了。
我之前调一个高并发服务就遇到过:线程数从200调到500,吞吐量反而降了。用监控工具一看,CPU时间大量花在上下文切换上。后来把线程数降到300,效果反而好了。所以说,"多线程就更快"这种直觉,在高并发场景下不一定成立。
4. 多线程实战:三个线程同时跑的现场
理论部分讲完了,下面进入上代码环节。我尽量用最简单的方式,展示多线程跑起来是什么样子,以及最常见的几个坑。
4.1 创建线程的几种姿势
以Java为例,创建线程有三种经典方式:
// 方式一:继承Thread类 class MyThread extends Thread { @Override public void run() { System.out.println("我是继承Thread的线程: " + Thread.currentThread().getName()); } } new MyThread().start(); // 方式二:实现Runnable接口 Runnable task = () -> System.out.println("我是Runnable线程: " + Thread.currentThread().getName()); new Thread(task).start(); // 方式三:实现Callable接口,获取返回值 Callable<String> callable = () -> "我是Callable线程,这是我的返回值"; FutureTask<String> futureTask = new FutureTask<>(callable); new Thread(futureTask).start(); String result = futureTask.get(); // 阻塞等待结果我的建议是优先使用Runnable,其次Callable,最后才考虑继承Thread。原因有三个:
- Java是单继承的,继承Thread之后你就再也不能继承别的类了。
- Runnable把"任务代码"和"线程对象"解耦了,同一个任务可以传给多个线程执行。
- Callable支持返回值,线程计算结果可以拿回来。
FutureTask.get()这个方法要特别注意,它会阻塞调用它的线程。实战中我见过不少人在线程池里用get()等结果,结果把工作线程也堵住了,线程池被掏空,整出一个诡异的饥饿问题。
4.2 线程安全:共享变量引发的血案
上面说了同一个进程里的线程共享内存空间,好处是通信方便,坏处就是接下来这个经典问题。先看代码:
public class Counter { private int count = 0; public void increment() { count++; // 这行不是原子操作! } }启动10个线程,每个线程执行10万次increment(),最终结果应该是100万对吧?实际跑下来,结果可能只有80多万,每次结果还不一样。
为什么?因为count++在字节码层面是三步:读取当前值、加一、写回。两个线程可能同时读到count=99,同时加一,同时写回100,结果本该加两次变成101,实际上只加了一次。这就是经典的竞态条件。
解决方式就是用原子类,或者加锁:
// 方案一:原子类 private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } // 方案二:synchronized锁 private int count = 0; public synchronized void increment() { count++; }原子类内部用的是CAS(比较并交换),乐观锁思路,无阻塞;synchronized是悲观锁思路,同时只有一个线程能进来。简单统计类的场景,优先用原子类,性能更好。
这里有个经验之谈:不要一上来就到处加锁。锁是有代价的,锁太大并发度上不去,锁太小容易出错。先分析清楚哪些变量是真正被多线程共享的,只保护那些临界区。我在实际项目中遇到过一个例子,某工程师把整个方法都加上了synchronized,方法内部有三次外部接口调用,结果并发量直接掉了一个数量级,线上事故就是这么来的。
4.3 并发编程三要素:原子性、可见性、有序性
理解了count++的例子,我顺便把并发编程的三大基石讲清楚,因为它们后面会反复碰到。
第一,原子性:一组操作要么全部完成,要么全部不完成,不能被线程调度打断。上面的count++就是被打断了。解决方案就是原子类、锁。
第二,可见性:一个线程修改了共享变量,另一个线程不一定立刻能看到。为什么?因为每个线程有自己的工作内存(寄存器或CPU缓存),它可能还没把最新值刷回主内存。volatile关键字就是解决这个的,它能保证每次读都从主内存拿最新值。
我用一个实际体验过的场景说明:某个开关配置由后台线程定期刷新,业务线程读取这个开关做判断。开关变量不加volatile的时候,业务线程可能一直读旧值,系统行为跟配置完全对不上,重启才恢复。加上volatile之后就好了。
第三,有序性:编译器和CPU为了性能,可能会对指令进行重排。在多线程环境下,重排可能导致别的线程观察到不一致的操作顺序。volatile还能禁止指令重排,这就是双重检查锁单例模式里,静态变量必须加volatile的原因。
4.4 线程池:别裸用Thread去new
实战中,没人会直接new Thread()来跑业务。原因一个是线程创建销毁的开销大,另一个是无法控制并发数量,几千个线程同时冲进来,系统直接扛不住。正确姿势是线程池。
ExecutorService executor = Executors.newFixedThreadPool(10); // 或者自己精细调参 ThreadPoolExecutor pool = new ThreadPoolExecutor( 10, // 核心线程数 20, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new LinkedBlockingQueue<>(100), // 队列大小 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );关于线程池,我有几条血泪经验:
- 不要用
Executors.newCachedThreadPool(),它的最大线程数是Integer.MAX_VALUE,等于无限创建线程,流量一上来直接OOM。 - 队列别用无界队列,
newFixedThreadPool默认用的是无界LinkedBlockingQueue,任务积压起来内存会涨到爆。 - 拒绝策略要选对。
AbortPolicy直接抛异常、CallerRunsPolicy让提交者自己执行任务,各有适用场景。我最常用的是CallerRunsPolicy,至少不会让任务丢失,还能起到天然限流的作用。
4.5 锁的选择:synchronized、ReentrantLock 还是读写锁
聊到锁,很多人纠结synchronized和ReentrantLock选哪个。我给一个简化版的判断:
- 读多写少的场景,用
ReentrantReadWriteLock或者StampedLock。多个读线程可以同时进去,不用互斥等待。 - 写多或者读写均匀,用synchronized就够了,它现在的性能经过优化,不比显式锁差多少。
- 需要可中断获取锁、超时获取锁、或者多个条件变量的时候,只能选ReentrantLock。
一句话总结:默认用synchronized,特殊需求再上显式锁。不要为了显得"高级"而引入复杂的锁机制。
5. 连接真实世界:线程在典型场景中的应用
前面讲的都是单体概念,你可能觉得"这些我写CRUD也用不上"。我带你看几个真实场景,就知道线程无处不在。
5.1 高并发请求:Web服务器如何处理万人同时请求
你访问一个网站,后端服务器(比如最常用的Tomcat)本质上就是在处理一个个请求线程。每来一个HTTP请求,Tomcat的线程池就从池里捞一个空闲线程处理它。这个过程就是这么简单。
但如果你在Controller里写了Thread.sleep(5000),那这个线程就会被占住5秒。假设Tomcat默认最大200个线程,那5秒内的并发请求超过200就会被排队或者拒绝。很多人问"为什么我的接口一压测就超时",先看看有没有这种无谓的阻塞。
再深入一层,为什么现在很多高性能框架开始用"异步非阻塞模型"了?因为线程是有成本的,一个"线程处理一个请求"的模式,线程数上去之后线程上下文切换的代价很惊人。异步模型让一个线程同时处理大量连接的I/O事件,省掉了大量线程切换的开销。这也解释了为什么支持高并发的框架往往更复杂——因为它们在想办法压榨有限的线程资源。
5.2 IO密集型 vs CPU密集型:线程数怎么定
线程池的核心线程数设置多少合适?这个问题困扰了很多刚入门的人。我给一个经验公式:
- CPU密集型任务:核心线程数约为CPU核心数 + 1。因为这类任务主要吃CPU,线程多了反而因为切换导致性能下降。
- IO密集型任务:核心线程数可以设置为 CPU核心数 × 2 或者更高。因为线程在等I/O的时候CPU空闲,多放几个线程能填上这段空闲时间。
我自己做文件服务器的时候,用的是CPU核数×2加几个余量,综合测试下来QPS曲线比较平稳。记住,这只是起点,最终数值一定要压测调优,纸上算出来的都是参考值。
5.3 死锁:所有并发问题的终极BOSS
我必须单独讲一下死锁,因为这是排查起来最痛苦的问题。死锁发生的四个必要条件:
- 互斥条件:资源一次只能被一个线程占用。
- 持有并等待:线程拿着一个锁,同时还在等另一个锁。
- 不可剥夺:已持有的锁不能被强行抢走。
- 循环等待:线程A等线程B的锁,线程B等线程A的锁。
最简单直观的死锁长这样:
// 线程A synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { // 等B锁 // 业务代码 } } // 线程B synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 等A锁 // 业务代码 } }两个线程互相等着对方手里的锁,谁也不会让,程序就僵在那里了。
排查死锁的时候,第一件事是拿到线程dump。Java里用jstack <pid>,线程状态会显示"Found one Java-level deadlock",直接告诉你哪两个线程卡在哪把锁上。防止死锁的最好办法是从设计上规避:多个线程都按同一个顺序去锁资源,比如都先锁A再锁B,就不会形成循环等待。还有一点,锁的粒度能小就小,持锁时间能短就短,局部变量能不加锁就不加锁,从源头减少死锁的概率。
6. 线程问题的排查工具箱:从理论到实战
理论说再多,最后还是看你会不会排查问题。这一节我分享一套我在实际工作中反复使用的排查链路,这些都是"教科书没写全"的部分。
6.1 线程dump:性能问题第一现场
线上服务卡了,CPU飙升了,第一时间打线程dump。
jstack <pid> > thread_dump.txt这个命令会输出所有线程的当前状态。我处理过一个CPU 100%的案例,top -Hp <pid>找到最耗CPU的那个线程ID,把它转成十六进制,去线程dump里一搜,发现是某个正则表达式回溯匹配卡死了。排查过程整个不超过十分钟,这就是工具链熟练的好处。
看线程dump重点看几个信号:
- 大量线程处于BLOCKED状态:锁竞争严重,查哪个锁被长时间持有。
- 大量线程TIMED_WAITING,并且带
parkNanos之类的:八成是业务里某个选择器在空轮询,或者是sleep用得不当。 - 线程池里的W线程全部在等队列:任务生产速度大于消费速度了。
6.2 线程泄漏与OOM:看不见的内存在流失
线程泄漏是特别隐蔽的问题。线程池创建的时候设置核心线程数,但核心线程是"没有任务也不销毁"的和"空闲超过时间销毁"的两种。如果你用了一个非核心线程数量异常膨胀的线程池,或者每次请求都new ThreadPoolExecutor而不shutdown,线程就只增不灭。
怎么判断是不是线程泄漏?
jmap -heap <pid> # 看堆内存 ps -eLf | grep <pid> | wc -l # 数线程数量或者用jvisualvm直接查看活跃线程数曲线。如果线程数只涨不降,而且每次涨完内存跟着涨,基本可以确定是线程泄漏。
针对这种问题的治本方法就是使用线程池,并统一管理生命周期。线程池里的线程是复用的,就算任务异常,线程本身不会退出,不会出现每次请求都创建新线程的情况。
6.3 一个压测引发的血案:从一次CPU飙升说开去
分享一个我做过的真实案例,这个案例串联了前面所有的知识点。
某公司内部系统上线前做压测,QPS冲到1500的时候,整个服务CPU飙升到99%,请求大量超时。我先打线程dump,发现大量线程卡在一个加锁的统计方法上。定位到代码后发现,这个统计方法内部会读取一个很大的内存列表,然后进行排序汇总,整个过程都锁住了。
当时的修法分三步:
- 把统计数据改用原子类型和ConcurrentHashMap,去掉大锁,读操作不再互斥。
- 排序汇总逻辑移到定时任务里异步完成,结果缓存起来,请求直接读缓存。
- 线程池参数调整了一版核心线程数和最大线程数,避免线程数过多导致频繁上下文切换。
改完之后重新压测,QPS到了3000,CPU稳定在60%左右。这个案例想说明的是:多数性能问题不是单一原因,而是设计层面的资源竞争。排查的时候一条线走到底:先看线程在干什么,再看锁在护什么,最后看资源够不够。
7. 写代码之外的几个建议
最后聊几句我这几年写并发代码攒下来的体会。
第一,多线程不是银弹。单线程能搞定的问题,就老老实实用单线程。引入并发意味着引入不确定性,意味着更多的出错可能和更高的排查成本,这种投入应该用在真正值得的场景上。
第二,测试并发代码要狠一点。普通的单元测试很难测出竞态条件,因为线程调度顺序是随机的。我建议你在条件允许的情况下,用压测工具跑高并发场景,并且故意制造延迟,放大竞态窗口,问题才容易暴露出来。
第三,日志是排查并发问题的第一眼。我习惯在进入临界区前和退出后打日志,带上线程名。排查问题的时候,看时间戳就能还原出线程的执行顺序,比猜快得多。
线程这个东西,看起来是一个简单概念,真正用好的时候却要涉及操作系统、编译原理、运行时、软件工程好几个层面的知识。刚入门的朋友不要急于求成,先把"程序怎么跑起来""线程怎么被调度"这两条主线搞清楚,再上手写多线程代码,路会顺很多。