写 Java 的人一搜并发,满屏都是锁、线程池、内存模型,真到项目里又不知道先看哪个。这篇不堆术语,按「概念对照 + 排查顺序」的方式把核心概念串一遍,并顺带对上 Python 那套说法,方便跨语言转过来的同学少走弯路。看完你应该能自己判断一段并发代码该从哪里入手看。
写 Java 的人一搜并发,满屏都是锁、线程池、内存模型,真到项目里又不知道先看哪个。这篇不堆术语,按「概念对照 + 排查顺序」的方式把核心概念串一遍,并顺带对上 Python 那套说法,方便跨语言转过来的同学少走弯路。看完你应该能自己判断一段并发代码该从哪里入手看,而不是背完定义还是不敢改。
先说清楚定位:这是一份判断标准和排查顺序,不是语法手册。里面的每条准则都有它不适用的场景,我会尽量写出来。如果你想要的是各家中转站或官方接口在并发调用上的表现差异,那属于另一类问题,可以到站内的工具目录和价格追踪页面核对当时的记录,本文只讲代码层面的事。
先分清三组常被混用的词
很多人讨论并发问题时吵起来,根源是两个人说的根本不是同一件事。先把三组词掰开,后面所有判断才有共同基础。这三组词决定了你该选什么工具,也决定了你该盯什么指标。
- 并发
- 任务在调度上交替推进,同一时刻未必真的在同时跑,重点是「看起来同时在处理很多事」
- 并行
- 多个任务在同一时刻真的同时执行,通常要求有多核或独立的执行单元
- 串行
- 一件事做完再做下一件,没有交错,最容易推理也最容易成为瓶颈
- 同步/异步
- 说的是「要不要等结果」:同步是调用方一直等到有结果,异步是先拿到一个凭证、结果稍后交付
- 阻塞/非阻塞
- 说的是「等的时候线程能不能干别的」:阻塞时线程被占住,非阻塞时线程可以先去处理别的任务
- GIL
- Python 解释器层面的一把全局锁,使得同一进程内多个线程无法真正并行执行字节码
最容易混的是同步/异步和阻塞/非阻塞这两组。它们是两个独立维度,组合起来有四种情况,别用一个词概括。判断准则其实很简单:任务里大部分时间在等 IO,就偏并发模型,用少量线程撑住大量请求;任务里大部分时间在算 CPU,就偏并行模型,得靠多核分担。
这条准则不适用的情况是任务本身非常短,短到线程调度和上下文切换的开销盖过了并发带来的收益。这种时候老老实实串行跑,往往比硬上线程池更快也更稳。Python 那边还多一层:因为 GIL,多线程更偏并发,想真并行通常得走多进程,代价是进程间数据要序列化传递。
原子性、可见性、有序性:Java 并发的三个根问题
Java 里的并发 bug,绝大多数最后都能归到这三件事上。记住这三个词不是为了考试,是为了排查时有个固定的提问顺序,不至于东看西看。
| 问题 | 一句话解释 | Java 里靠什么保证 | Python 里的对应情况 |
|---|---|---|---|
| 原子性 | 一段操作不可被切开,中间不会被别的线程插进来 | 锁、原子类 | 锁;`+=` 这类复合操作同样不是原子的 |
| 可见性 | 一个线程改了值,另一个线程能不能及时看到 | volatile、synchronized | GIL 让单条字节码级的问题少一些,但复合操作照样会丢更新 |
| 有序性 | 编译器和 CPU 可能重排指令,执行顺序和写的顺序不一致 | volatile 划出边界 | 没有对应的显式关键字,靠锁和队列把顺序固定住 |
排查顺序建议固定成三步:先问有没有共享可变状态,再问这份状态有没有被正确保护,最后才考虑要不要换并发模型。顺序反了会很痛苦——很多人一上来就想换框架、换模型,结果发现根因只是某个字段忘了做保护。
- 共享可变状态是万恶之源,能做成不可变或每线程一份,就别共享
- 能被多个线程读写的字段,逐个过一遍它是被锁保护、被 volatile 标记,还是压根没保护
- `i++` 这种看着像一条语句的写法,实际上读改写三步,两边语言里都不是原子的
- 单例、缓存、计数器、连接池这几类对象是并发问题的重灾区,优先审它们
- 如果一段逻辑只在单线程里跑,就别为了「以后可能并发」提前加锁,锁会掩盖真正的结构问题
线程池:Java 这边最该先搞懂的一块
线程池是 Java 并发里性价比最高的知识点:搞懂了能避开一大类线上问题,投入也不大。核心参数就那么几个,真正容易出事的只有队列选型和拒绝策略这两处。
队列用无界队列时,最大线程数这个参数基本失效——任务进不来新线程,全堆在队列里,表现是「不报错,但越来越慢,内存还一直涨」。这种坑在压测时未必暴露,因为压测时间短,队列还没堆满。拒绝策略也别用默认值,默认那个会直接抛异常,业务侧得想清楚到底是丢任务、丢最老的,还是让提交方自己跑。
- 先确定任务类型任务是 CPU 密集还是 IO 密集?CPU 密集的线程数别超过核数太多,IO 密集可以适当放大。这个判断没有标准答案,要靠压测找拐点。
- 再定队列有界队列能逼出压力,让系统在过载时明确拒绝;无界队列会吞掉压力,直到内存或延迟崩掉。生产环境优先有界。
- 显式指定拒绝策略别依赖默认。想清楚过载时业务能接受什么:丢新任务、丢老任务、还是让调用线程同步执行来自然限流。
- 验证回收观察空闲回收时间设得合不合理。线程建了不回收,数量会一直挂在高位,这类问题在流量回落之后才看得出来。
- 对上 PythonPython 对应的是线程池和进程池两套执行器,思路一样,区别在于用线程还是用进程,以及有没有 GIL 这道坎。
锁与无锁:什么时候该换工具
这一节只讲一件事:换工具的时机。很多人一遇到锁竞争就想上无锁方案,顺序反了,结果是把简单问题做成了复杂问题。
先用最简单的互斥锁把逻辑写对,确认确实存在性能问题,再考虑优化。反过来做的,基本都是在给自己挖坑:无锁代码难写、难测、难排查,收益还不一定为正。无锁不是没有锁,是把竞争交给 CAS 重试,适合冲突少、操作简单的场景;冲突一多,大量线程反复重试,反而比直接排队加锁更慢。
| 现象 | 可能原因 | 先查什么 |
|---|---|---|
| 结果偶尔错 | 共享状态没保护,或可见性问题 | 临界区是否完整覆盖了读写,有没有字段漏加保护 |
| 吞吐上不去 | 锁竞争,或临界区里有耗时操作 | 把 IO 和外部调用挪出锁外,再看锁粒度 |
| 程序卡住不响应 | 死锁,或线程被长时间占住 | 两个锁的获取顺序是否一致,锁内是否调了外部方法 |
| 线程/进程数持续上涨 | 任务没正常结束,或池子没回收 | 任务标识和线程名对不对得上,有没有任务卡在等待 |
死锁排查有固定套路,按顺序过一遍能覆盖大部分情况:先看有没有两个锁的获取顺序不一致,再看锁住的区间里是不是调了外部方法(包括回调、日志、远程调用)。不适用的情况也有:如果临界区里本来就有 IO 或耗时调用,正确做法是先把它挪出锁外,而不是去换一种锁。
并发优化里,把耗时操作挪出临界区,收益通常比换锁方案大一个量级。
内存模型与运行时:Java 独有、Python 没有的那部分
这是跨语言迁移时最容易踩空的一块。Java 的内存模型规定了线程之间通过主内存交换数据的规则,volatile、synchronized、final 各在这个规则里占一个位置。Python 没有这套显式规范,它的并发正确性更多依赖 GIL 和标准库给的对象语义。
关键在于:这套保证在跨进程时会变。一旦数据要经过序列化传给另一个进程,原来靠共享内存享受的语义就没了,你得自己重新确认数据完整性和顺序。所以从 Python 转 Java,最该补的是可见性和指令重排这两块;从 Java 转 Python,最该补的是「什么时候必须上多进程」。
- 共享状态怎么传:是共享内存、消息队列,还是每次拷贝一份,三种方式对正确性的要求完全不同
- 生命周期谁管:谁创建、谁释放、异常时谁来收尾,这个问题在两边都容易被忽略
- 异常怎么被观察到:子线程里抛的异常默认可能被吞掉,必须有地方兜住并上报
- 顺序有没有保证:跨进程、跨队列之后,原来依赖的先后关系未必还成立
- 别把 Java 的写法直译过去:final 字段的语义、volatile 的作用,在 Python 里都没有一一对应的东西
上线之后:并发问题的排查顺序
线上出并发问题,最忌讳一上来就翻代码。先看现象落在哪,能省掉大量时间。三种现象——结果错、慢、线程或进程数一直涨——对应的排查方向完全不同。
- 先分类现象是结果错了,还是变慢了,还是执行单元的数量一直涨?先归类,别混着查。
- 结果错走原子性和可见性重点看共享字段有没有被完整保护,锁的范围够不够,有没有半途被人改了值。
- 慢就走队列和锁竞争看队列有没有积压、临界区里有没有耗时操作、锁的粒度是不是太粗。
- 数量涨就走生命周期看线程池有没有正常回收、任务有没有卡在等待、有没有任务创建后没被正确结束。
- 日志要能对上至少记线程名或任务标识,以及进入和离开临界区的时间点。没有这个,并发问题基本没法复盘。
- 用拐点代替理论值把并发数逐步往上加,观察从哪个点开始退化。这个拐点比任何教科书上的数字都有参考意义。
Java 和 Python 的并发概念对照表怎么用
跨语言读代码时,最怕的是把两边的概念硬套。对照表的作用是让你看到熟悉的名字时知道对方在说什么,而不是让你照搬实现——两边的运行时假设本来就不一样。
| 概念 | Java 里的说法 | Python 里的说法 | 注意点 |
|---|---|---|---|
| 执行单元 | 线程 | 线程 / 进程 | Python 多线程受 GIL 限制,真并行走多进程 |
| 互斥 | synchronized、显式锁 | 锁对象、上下文管理器 | 锁的语义两边不完全一致,别直译 |
| 可见性 | volatile | 无直接对应 | Python 靠 GIL 和对象语义,跨进程后保证更弱 |
| 池化执行 | 线程池 | 线程池 / 进程池执行器 | 参数含义接近,但队列和拒绝策略的默认行为不同 |
真正会跨语言的人,是先确定并发模型,再挑语言里对应的工具,而不是先挑了工具再倒推模型。如果你只是临时在项目里加一段并发逻辑,最省事的做法是照着已有代码的写法来——保持一致性比引入新范式重要得多。
另外提醒一句:不同语言、不同运行时、不同部署环境下,同一种并发模型的实际表现差异可能很大,尤其是在网关或转发层。这部分不属于语言概念,属于选型和环境问题。如果你在挑具体的接口服务或转发方案,最好按当时的实测记录来判断,不要凭概念表直接下结论,可以到站内相关页面核对当时的数字。
下一步概念对照看完了,接下来该看真实环境的并发表现。站内的测速与工具目录里有按场景分类的验证方式,配合价格追踪页面一起看,能帮你判断某个方案在你的并发模型下到底扛不扛得住,而不是只看宣传页上的说法。→常见问题
Java 并发和 Python 并发最大的区别在哪?
主要在运行时假设上。Java 是真正的多线程并行,共享状态的可见性和重排要你自己管;Python 有 GIL,多线程更适合 IO 密集,要真并行一般走多进程。所以同一套并发思路,落到两个语言里的默认做法和踩坑点并不一样。
线程池的参数到底怎么定?
没有通用答案,但有个顺序:先按任务性质分 IO 密集还是 CPU 密集,IO 密集可以多给线程,CPU 密集一般贴着核数。然后看队列会不会积压、拒绝策略触发时业务能不能接受。最后压一轮,按实际拐点回调参数,比照抄别人的配置靠谱。
volatile 是不是就能保证线程安全?
不是。它主要解决可见性和一定的有序性,但复合操作比如自增,它的原子性并不由 volatile 保证。要保证这种操作不出错,还是得用锁或原子类。很多人第一次踩坑就是以为加了 volatile 就万事大吉。
怎么判断一段代码有没有并发问题?
按三个问题过一遍:有没有共享可变状态,这份状态有没有被保护,保护的范围是不是刚好包住临界区。三条里有一条答不上来,就大概率有问题。另外别忘了子线程里的异常如果没被观察,会表现为「什么都没发生但结果不对」。
并发数往上调就能更快吗?
不一定。到某个点之后,锁竞争、上下文切换、下游限流都会吃掉收益,曲线会变平甚至往下走。正确做法是逐步加压找拐点,并且同时看错误率和延迟,别只看吞吐。具体在哪个点退化,跟你的业务和下游能力强相关。