一文搞懂线程与并发编程:从进程到线程池的实战指南
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接口获取返回值 CallableString callable () - 我是Callable线程这是我的返回值; FutureTaskString 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在字节码层面是三步读取当前值、加一、写回。两个线程可能同时读到count99同时加一同时写回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到了3000CPU稳定在60%左右。这个案例想说明的是多数性能问题不是单一原因而是设计层面的资源竞争。排查的时候一条线走到底先看线程在干什么再看锁在护什么最后看资源够不够。7. 写代码之外的几个建议最后聊几句我这几年写并发代码攒下来的体会。第一多线程不是银弹。单线程能搞定的问题就老老实实用单线程。引入并发意味着引入不确定性意味着更多的出错可能和更高的排查成本这种投入应该用在真正值得的场景上。第二测试并发代码要狠一点。普通的单元测试很难测出竞态条件因为线程调度顺序是随机的。我建议你在条件允许的情况下用压测工具跑高并发场景并且故意制造延迟放大竞态窗口问题才容易暴露出来。第三日志是排查并发问题的第一眼。我习惯在进入临界区前和退出后打日志带上线程名。排查问题的时候看时间戳就能还原出线程的执行顺序比猜快得多。线程这个东西看起来是一个简单概念真正用好的时候却要涉及操作系统、编译原理、运行时、软件工程好几个层面的知识。刚入门的朋友不要急于求成先把程序怎么跑起来线程怎么被调度这两条主线搞清楚再上手写多线程代码路会顺很多。