1. 并发编程的前提:先理解“等待的代价”
我早期写并发代码的时候,犯过一个特别典型的错误:以为只要用了多线程,性能就一定快。结果一个并发Demo跑下来,反而比单线程还慢。排查下来发现,大部分时间都耗在锁竞争和上下文切换上,真正的业务逻辑连10%都没占到。那次教训让我意识到,并发编程是一套独立的思维体系,不是说开几个线程就完事。
想理解并发,得先回答一个问题:CPU在“等待”的时候,到底在做什么?
正常情况下,一个程序跑一个线程,一旦遇到卡点(比如读磁盘、等网络响应、处理慢接口),CPU就直接空转。现代CPU单核一秒钟能执行几十亿条指令,空转一毫秒都是一种浪费。并发的核心思路,就是把这些“等待的空闲”利用起来——在一条任务卡住的时候,让CPU去执行其他任务,等卡点结束了再切回来。
这里有两类等待,区分它们很重要:
- IO密集型等待:程序大部分时间在等外部资源(磁盘、网络、数据库、外部接口),CPU几乎不参与。这类任务,并发的收益巨大,开几十甚至几百个并发都很正常。
- CPU密集型等待:程序大部分时间在计算、循环、处理大数组,CPU一直满负荷工作。这类任务,并发数只要超过CPU核心数,收益就急剧下降,反而会因为来回切换任务导致性能变差。
拿生活打个比方。你一边烧水一边刷手机,烧水就是“IO等待”——你不需要盯着水壶,这段时间完全可以干别的。但如果你是在解一道复杂的数学题,那你只能一心一意,同时做三套数学题并不会让你算得更快,只会让你更累。这就是CPU密集型的现实。
所以,越早认清任务类型,后面设计和编码就越不会跑偏。
2. 并发模型的选择:进程、线程与协程
2.1 进程:开销最大,隔离最强
进程是操作系统分配资源的最小单位。每个进程有独立的内存空间、独立的文件描述符表,一个进程崩了,通常不会直接影响另一个进程。这是它的核心优势:隔离性极强。
但代价也很明显,创建进程特别重,需要复制一大堆资源,进程间通信(管道、消息队列、共享内存、Socket)也很麻烦。一个普通的服务,如果为每个请求都创建一个进程,系统很快就撑不住了。所以进程适合做多机部署、壳程序隔离、跨语言调用这类场景,不适合用来搞轻量级并发。
2.2 线程:轻量但共享,最常用的并发主力
线程是进程内部的执行单元,多个线程共享同一进程的内存空间,创建和切换的开销远小于进程。这也是绝大多数并发需求的首选方案。像 Java、C++、Python(对,Python线程虽然受GIL的影响,但IO场景照样有效)的并发编程,核心对象基本都是线程。
共享内存带来便利的同时,也带来了并发编程最大的难点:数据竞争。多个线程同时读写同一个变量,轻则结果错误,重则直接让进程崩溃、程序死锁。所有锁、原子操作、线程安全队列,本质都是在解决“数据竞争”问题。
线程的调度,由操作系统内核完成。线程数量一多,内核态和用户态的切换就变得频繁,性能也跟着下降。这也是为什么线程不是开得越多越好。
2.3 协程:用户态调度的“轻量级线程”
协程是一种完全在用户态实现的并发模型,它不依赖操作系统内核调度。协程切换时,只需要保存极少的上下文,所以切换成本比线程还低一个数量级。典型的代表有 Go 的 goroutine、Java 的虚拟线程、Python 的 asyncio。
协程适合处理高并发、高IO场景。比如某个网关服务要同时维持百万个长连接,如果用线程,光线程栈的内存就是一笔巨额开销;但用协程,内存占用会小得多,调度成本也低得多。一些实测项目里,单个进程用协程扛百万连接,是完全可以做到的。
协程的代价是:它不能利用多核并行(除非配合进程/线程使用),而且协程一旦写出阻塞代码(比如同步调用了一个耗时的API),整个调度器都会卡住。所以在协程里要么用异步API,要么用专用调度框架。
我的经验是:先分清场景,再选模型。没有“谁一定比谁强”,只有“谁在这个场景下更合适”。
3. 核心并发原语与实战要点
3.1 synchronized 与 Lock:锁的选型
锁是并发中用的最多的工具。两套主流方案,synchronized 和 Lock,我见过很多人在选择时很随意,其实里面有不少门道。
synchronized 是 JDK 内置的关键字,语法简单,用完之后锁会自动释放。JVM 底层做了大量优化,比如锁升级(无锁 → 偏向锁 → 轻量级锁 → 重量级锁),在竞争不激烈的情况下,它的性能其实非常优秀。
Lock 是接口层面的实现,最常用的就是 ReentrantLock。它的特点是非常灵活:可以尝试加锁(tryLock)、可以中断等待(lockInterruptibly)、可以选择公平锁或非公平锁、可以配合 Condition 实现精准唤醒。
我自己的选型习惯是:默认先用 synchronized,只有遇到以下需求时才换 Lock:
- 需要尝试获取锁,获取不到就去做其他事,而不是干等着
- 需要支持锁的超时时间,防止永久阻塞
- 需要支持公平锁策略
- 需要多个条件队列(Condition)进行更精细的线程唤醒
至于“锁粒度”,这是一个老生常谈但非常关键的问题。锁的粒度越粗,性能越差;锁的粒度太细,代码复杂度又会暴增,还容易出现并发 Bug。我通常遵循一个原则:锁的范围只包裹真正需要保护的共享资源,不要顺手把无关逻辑都锁起来。
3.2 原子类与 CAS:无锁并发
锁是一种悲观策略:总是假设会发生冲突,先锁住再干活。但很多场景下冲突没那么频繁,这时候无锁方案效率更高,而 CAS(Compare And Swap)是实现无锁并发的基石。
CAS 的逻辑很简单:在更新一个变量前,先比较它当前的值是否等于期望值,如果等于,就更新;如果不等于,说明变量被其他线程改过了,就重试。这个操作是CPU级别的原子指令,不用担心并发问题。
Java 里的 AtomicInteger、LongAdder、ConcurrentHashMap 底层就是用了 CAS。LongAdder 在超高并发计数场景下,性能比 AtomicInteger 好很多,因为它内部做了分段累加,最后再合并结果。
任何无锁方案都有个前提:要保证整个过程是可重试的。如果CAS一直失败,CPU 就会一直空转会浪费性能。另外,ABA问题也是CAS的经典坑——值从 A 改成 B 又改回 A,CAS 会误判为“没有变化”。实际业务里如果变量不涉及版本依赖,ABA问题基本无关紧要;如果涉及,就用版本号解决。
3.3 volatile 与内存可见性
线程修改了共享变量,其他线程可能看不到最新值。这不是玄学,而是现代CPU多级缓存架构的客观现实。
每颗CPU核心都有自己的高速缓存,线程在不同核心上运行时,读写的是缓存中的数据。如果没有同步机制,各核心缓存中的数据可能出现不一致,主内存中的数据却没有及时更新。
volatile 解决了可见性问题:一个线程修改了 volatile 变量,会立即刷新到主内存;其他线程读取 volatile 变量时,会直接从主内存读。它不保证原子性,只保证“每个线程读到的是最新值”。
所以 volatile 适合“一个线程写、其他线程读”的场景:状态开关、标记位、发布不可变对象。如果多个线程同时读写同一个变量,那依然要去用锁或原子类。
3.4 wait/notify 与 Condition:线程间的精确协作
有时候线程之间需要传递信号。比如某个线程负责生产数据,另一个线程负责消费数据。消费者在数据还没来的情况下,就干等浪费CPU;这时应该让消费者主动休眠,生产者完成后再唤醒它。
synchronized 搭配 Object 的 wait 和 notify 就是最基础的实现方式。有几个细节特别容易踩坑:
- wait、notify、notifyAll 必须在持有锁的代码块中调用,否则会抛 IllegalMonitorStateException
- wait 会让出锁,所以另一个线程才有机会进入同步块去 notify 它
- 用 if 判断等待条件,大概率会踩到“虚假唤醒”的坑;正确做法是用 while 循环反复判断
Condition 是 Lock 的配套设施,它比 wait/notify 更强大:一个 Lock 可以创建多个 Condition,实现多个队列的精确唤醒。比如一个线程池里,分别用“没活干”和“满了”两个 Condition 精准控制,效率比 notifyAll 高不少。
4. 实战示例:一个线程池+并发任务的完整Demo
我拿一个实际的例子来串一下上面所有知识点。假设我们要写一个图片处理服务,批量处理用户上传的图片,包括压缩、加水印、生成缩略图。
先搭建一个线程池:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(1000), // 任务队列 new ThreadFactoryBuilder().setNameFormat("img-worker-%d").get(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );参数选择背后是有逻辑的。核心线程数 4,因为目标机器是4核CPU;最大线程数 8,是为了应对短暂的任务高峰。任务队列设置1000,是因为这个服务的图片处理是IO密集型,队列太长会导致任务积压过久。
任务提交的逻辑:
List<Future<ImageResult>> futures = new ArrayList<>(); for (ImageTask task : taskList) { futures.add(executor.submit(() -> processImage(task))); } for (Future<ImageResult> future : futures) { ImageResult result = future.get(30, TimeUnit.SECONDS); // 处理结果,失败则重试或记录 }这里有几个关键点:
- future.get 设置了超时时间,避免线程任务卡死导致调用方无限等待
- 提交任务前,先检查线程池状态,如果队列已经满,就直接走降级逻辑(比如丢弃任务并记录),而不是让现场崩溃
- 任务里的 processImage 方法需要保证线程安全,如果用了非线程安全的工具类(比如 SimpleDateFormat),一定不要把它设为静态共享变量
线程池的拒绝策略也要重视。默认的 AbortPolicy 会直接抛异常,这在高并发场景下意味着大量任务丢失,而且没有日志。我常用的是 CallerRunsPolicy,它会把任务退回给提交任务的线程执行,不会丢任务,还能天然起到“流量控制”的作用——提交线程忙于执行任务时,就不会再继续提交新任务了。
最后记着,程序关闭时,线程池必须优雅关闭:
executor.shutdown(); if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { executor.shutdownNow(); }5. 并发场景下的典型Bug与排查方案
5.1 死锁:现象、定位与预防
死锁的典型特征,就是程序卡住不动,CPU占用率低,线程抛出 Blocked 状态。排查死锁时,第一步先把线程栈dump下来看。
jstack <pid>如果看到多个线程互相持有对方需要的锁,就能看到类似“waiting to lock”的提示,这就是死锁的现场。
死锁的核心预防思路是:统一加锁顺序。所有线程必须按同一个顺序获取多个锁,破环“循环等待”这个必要条件。如果有两把锁 A 和 B,所有代码都必须先锁 A 再锁 B,永远不要反着来。
还有一种做法是避免嵌套持锁,能用一把锁就不上两把锁。上锁的代码块必须短平快,锁内不要做耗时操作(比如访问网络、读写大文件),否则锁持有时间越长,死锁和竞争的概率就越高。
5.2 线程池中的异常被吞
submit 提交的任务,如果内部抛异常,默认情况下异常会被吞掉,调用方拿到的 Future.get 才会抛出 ExecutionException。
这个坑很隐蔽,因为并不崩,日志里也没有任何异常,导致任务静默失败。排查方法是:任务里的异常要自己捕获并记录下来,或者在 get 的时候打印 ExecutionException 的堆栈。
我的习惯是任务内部主动捕获并上传埋点日志,这样异常现场不会丢,后续排查更直观。
5.3 并发下的集合类踩坑
HashMap、SimpleDateFormat、ArrayList 这些线程不安全的类,一旦被多个线程共享,轻则丢数据,重则直接卡死(HashMap 在并发扩容时可能出现死循环)。
正确做法:
- 键值对结构用 ConcurrentHashMap
- 列表结构用 CopyOnWriteArrayList 或者 Collections.synchronizedList
- 时间格式化用 ThreadLocal 保存 SimpleDateFormat 实例
- 如果只是一次性的浅拷贝、只读操作,用普通集合也没问题,别过度设计
有个低成本保命原则:把“共享可变”当作事故多发区,默认拒绝;实在要共享,就用并发容器,别手写同步逻辑。
5.4 并发基数下的性能瓶颈
很多人在压测时发现,线程数一加多,性能反而下降。常见原因有三个:
第一,线程上下文切换太频繁。线程数远大于CPU核心数时,CPU大半时间都在做切换。处理方式是压测时观察 CPU 使用率和线程栈,找到平衡点,别无脑加线程。
第二,锁竞争加剧。大量线程争夺同一把锁,等待时间超过了执行时间。处理办法是减小锁粒度,或者换用无锁结构。
第三,日志输出阻塞。高并发下同步日志写磁盘会拖住业务线程。解决办法是日志框架调成异步模式,并做好磁盘IO评估。
6. 并发编程中的几个“反直觉”原则
有些并发原则看起来和直觉相反,但它是无数代码堆出来的经验教训。
6.1 并发数 ≠ 性能
线程多不代表快。尤其CPU密集型任务,并发数超过核心数后就是负优化。IO密集型任务也不是越多个越好,线程太多,上下文切换开销会抵消并发收益。
6.2 加锁简单,对性能影响也大
锁是实现并发安全的捷径,但代价巨大。追求性能的场景下,优先考虑无锁方案、不可变对象、线程封闭(ThreadLocal)等手段减少共享。
6.3 没有绝对安全的共享变量
即使你用 volatile 保证可见性,原子操作也未必安全。像 i++ 这种操作,在字节码层面是“读-改-写”三个步骤,volatile 不能保证原子性,得用 AtomicInteger 或加锁。
6.4 测试时用单线程跑不出问题,不代表没问题
并发Bug往往只在特定时间窗口或负载下出现。写并发代码时,一定要多做压力测试,并用并发工具(比如压测框架、竞态检测工具)辅助验证。
7. 这一篇聊聊我的心里话
从最早的“稀里糊涂开线程”到现在,我在并发编程上踩过的坑,加起来可以出一本小册子。最大的感受就是:并发是一个需要持续学习、持续还债的领域——这里的“债”,是指前期对同步机制、内存模型、线程安全理解不到位,后期就会在各种诡异的问题上疯狂暴雷。
如果你刚开始学并发,我的建议是:
- 先把 synchronized、volatile、线程池、Future、并发容器、Lock 这些基础原语彻底吃透,别急着追新型框架
- 多写、多跑、多看线程栈。没有实践支撑的并发理论,都是纸上谈兵
- 遇到诡异问题,第一反应不是“换个库”,而是 dump 现场、分析原因,理解底层比堆框架更有用
并发编程可能不会让你觉得“爽”,但它决定了你写的代码能不能扛得住真实世界的压力。把这里的每一个概念都亲手做一遍,再回头看那些“高深”的中间件,你会觉得它们原来也没那么神秘。