如果你接手过任何一个带状态、带交互、带实时反馈的系统你一定清楚并发编程不是面试八股文而是项目能不能扛住真实场景的分水岭。前阵子我用纯 Java 做了一个智能仿真项目模拟城市多个路口的交通流量车辆按泊松过程随机到达信号灯根据车流压力动态调整配时。整个系统涉及几十个可独立推进的路口节点同一时刻要处理大量车辆事件还要保证统计数据的最终一致。做完之后我对并行和并发这两个词的差异对线程池参数为什么不能拍脑袋定对锁和原子类各自适用的场景都有了比看十篇理论文章更深的体会。这篇文章就围绕这个仿真项目的完整落地产开来写技术点会按设计思路—核心原理—实操细节—问题排查的顺序走适合刚学完 Java 并发基础准备做项目的人也适合面过高并发岗位、想看看真实场景下这些技术怎么配合使用的朋友。1. 项目背景与整体设计思路1.1 为什么选择仿真场景来串联并发技术仿真类项目和普通 CRUD 系统最大的区别在于它的核心不是读写数据库而是在内存里推进世界状态。交通仿真里每一辆车都是一个独立活动对象每一个路口都是一个独立的状态机信号灯切换、车辆通行、排队长度统计这些事件天然就是并发的。你不需要硬造并发场景问题本身就把并发需求摆在你面前了。选这个场景还有一个现实考量仿真系统的正确性是可验证的。单线程跑一遍的结果和多线程跑一遍的结果可以对拍数据不一致一眼就能看出来这比业务系统里偶尔出个 bug 查半天要直观得多。对于一个学习型项目来说可验证性是最大的学习杠杆——你能立刻知道自己的并发代码写错了。项目整体定位也决定了技术选型的方向目标一模拟 16 个路口的交通流每路口每秒钟产生 10~50 个车辆事件目标二信号灯每 15 秒评估一次车流压力动态调整绿灯时长目标三全系统运行 100 万个模拟秒统计总通行量、平均等待时间、路口拥堵排名目标四支持调整并行度参数能对比单线程、多线程、虚拟线程三套运行模式的性能差异。这个定位很关键它直接把并发数线程池数据一致性性能对比这些点全部包含进来了。后面每一个技术决策都是为了让这个系统跑得更快、结果更准。1.2 整体架构与模块划分在动手写代码之前我先画了模块边界。仿真系统按功能可以切成四块模块职责并发关注点车辆生成器按泊松分布生成车辆到达事件事件队列的无锁写入路口仿真器维护信号灯状态与车辆排队状态变更的线程安全调度引擎推进仿真时钟触发各路口 tick并行任务拆分与归并统计聚合器汇总通行量、等待时间原子累加与无锁聚合模块拆分的原则是让锁的粒度尽量小让并行度尽量高。车辆生成器负责生产事件路口仿真器负责消费事件调度引擎负责把每个路口推进一个时间片这个动作并行化统计聚合器则把所有路口的输出汇总。仿真时钟是整个系统一个特别有意思的设计点。真实时间是单调递增的但仿真时钟可以由调度引擎统一控制——每个 tick 代表模拟世界里的 1 秒。这意味着所有路口可以在同一个虚拟时刻上并行推进天然适合并行计算。每个路口只感知自己的状态和相邻路口传入的车辆到达事件不需要共享可变状态只要在事件队列和统计累加这两个汇聚点上做好同步就够了。这个设计让我尝到了甜头大部分业务并发系统都在跟共享可变状态较劲而仿真系统把共享状态压缩到了两条边界上代码写起来轻松很多。后面讲线程安全和锁的时候你会发现很多问题根本就不用发生——因为设计上就避开了。2. 并行与并发的概念辨析及在本项目的落点2.1 两种同时的区别用路口做类比并发和并行这两个词在中文技术社区里经常被混用但它们在系统设计上的含义完全不同。并发是逻辑上的同时——多个任务在同一个时间段内交替执行单核 CPU 也能做到并行是物理上的同时——多个任务在同一时刻由不同 CPU 核心执行必须多核硬件支撑。用路口来做类比一个单车道上的多辆车交替通过是并发多车道上的多辆车同时通过是并行。仿真场景里16 个路口如果在一个线程里轮流推进那就是并发如果 16 个路口分别由 16 个线程同时推进那就是并行。区别直接对应了最终性能我在后面实测中看到同样的模拟任务量并行模式相比单线程模式有将近 7 倍的吞吐提升——注意不是 16 倍原因后面实测章节再展开。这个区分不是理论抠字眼它直接影响代码怎么写。如果你的目标是并发你需要关心的是任务调度、锁、等待通知这些机制如果你的目标是并行你还需要额外关心 CPU 核心数、任务拆分粒度、缓存伪共享、线程上下文切换开销这些硬件层面的东西。2.2 本项目三套运行模式的并发模型选型仿真系统天然适合做并发模型对比测试因为业务逻辑是固定的变的只是执行框架。我实现并对比了三套运行模式单线程顺序模式、线程池并行模式、虚拟线程模式。单线程模式是最简单的基准线。所有路口在同一个循环里依次推进Event Loop 风格没有任何锁、没有线程切换、没有共享内存竞争。它的性能上限就是单核计算能力但它给出了正确性基准——多线程跑出来的结果必须和它一致。线程池并行模式是核心。每个 tick 周期内把 16 个路口的推进任务提交给线程池并行执行。这里用到了Executors.newFixedThreadPool()和ExecutorCompletionService前者控制并行度后者能拿到每个任务的完成顺序方便做屏障同步。关键点是每个 tick 必须等所有路口推进完才能进入下一秒所以我在每轮 tick 结束时要awaitTermination或者用CountDownLatch做屏障。这一步不能省否则下一个 tick 读到的是上个 tick 的中间状态仿真结果就错了。虚拟线程模式是 JDK 21 的新特性我在项目里也跑了一版。虚拟线程的亮点在于线程创建成本极低可以每辆车开一个虚拟线程去处理调度由 JVM 接管。实测下来对于这种轻量短任务场景虚拟线程性能和线程池接近但代码更简洁——不需要手动控制线程池大小不需要考虑任务排队。不过要注意虚拟线程不是万能的它解决的是高并发 IO 阻塞问题对 CPU 密集型计算并没有数量级优势这点下面实测数据也能看到。2.3 为什么说并发编程的难点在共享状态仿真系统把共享状态压缩到两个端点之后剩下的问题全部集中在怎么让多条线程同时操作同一份数据这件事安全可靠。这个才是并发编程真正的深水区。大多数并发 bug 不是因为你不知道 synchronized 怎么写而是因为你没想清楚什么数据是共享的、哪些操作是复合的、内存可见性由谁保证。JMM 是理解这一切的地基每个线程有自己的工作内存共享变量在主内存线程间通过加载—修改—写回的机制交互。如果两个线程同时读到了同一个变量的旧值各自修改后写回就会互相覆盖。经典例子就是count它看起来是一行代码实际上是读取旧值、加一、写回新值三步完全不是原子的。我的经验是在设计阶段把共享数据流图画出来标出每个共享变量的读写位置再决定用什么手段保护它。如果发现某一个共享变量在多个线程里既读又写优先怀疑设计是否合理而不是急着加锁。这个项目里车辆事件队列和统计计数器就是两个必须重点保护的共享点我分别用了无锁队列和原子类来搞定下面详细展开。3. 核心实现线程池参数、任务拆分与同步机制3.1 线程池参数是算出来的不是猜出来的线程池的参数为什么不能拍脑袋定因为它直接决定了系统的吞吐和延迟特性。核心线程数、最大线程数、队列容量、拒绝策略这四个参数之间是有联动关系的。CPU 密集型任务线程数设太多会导致大量上下文切换反而变慢IO 密集型任务线程数设太少会让 CPU 在等待 IO 时闲置吞吐上不去。通用经验公式大家应该都知道CPU 密集型核心线程数 CPU 核数 1IO 密集型核心线程数 CPU 核数 * (1 IO 等待时间 / CPU 计算时间)。但真实场景往往不是纯 CPU 或纯 IO。我的仿真任务里路口推进涉及集合遍历、状态计算、计数器更新是典型 CPU 密集型但信号灯决策模块要读取实时车流统计内存操作会有一点锁竞争等待。所以我在固定线程池基础之上做了二次确认先用Runtime.getRuntime().availableProcessors()探测可用核数然后写了一个小工具类跑不同核心线程数的对比最终选定 8 线程——我的测试机器是 8 核而任务数量正好是 16 个路口8 线程意味着每个线程大致处理两个路口这个分配比在实测中表现最优。核心线程数每秒推进 tick 数平均等待耗时模拟秒434208.6869126.21268816.31664336.8线程不是越多越好8 核机器上 8 线程跑得最快12 线程和 16 线程由于上下文切换开销反而略有下降。这就是算和猜的区别——拍脑袋定 16 线程性能和 8 线程差了将近 7%。创建线程池的代码我最终用的是ThreadPoolExecutor的完整构造器而不是Executors工具类的快捷方法。区别在于newFixedThreadPool用的是无界队列任务积压时不限容量占用内存可能飙到不可控完整构造器能显式指定ArrayBlockingQueue容量和CallerRunsPolicy拒绝策略让它满了就由提交线程自己跑既不会丢任务也不会无限制积压。int cores Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor executor new ThreadPoolExecutor( cores, // 核心线程数 cores 2, // 最大线程数留一点余量 30L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue(1024), // 有界队列 new CustomThreadFactory(sim-worker), new CallerRunsPolicy() // 拒绝策略提交线程自己执行 );3.2 任务拆分粒度与 Fork/Join 的取舍线程池只是并发执行框架更关键的是任务怎么拆。我的调度引擎每个 tick 要做两件事advanceAllRoads()推进所有路口状态collectStats()收集统计结果。如果我把整个advanceAllRoads()作为一个任务丢进线程池并行度就是 1毫无意义。如果我把每个路口的推进作为一个独立任务16 个任务丢进去并行度就到了 16。任务粒度越细并行潜力越大但拆分和合并的开销也越大。每个路口推进本身很小拆成 16 个任务后线程池调度和结果汇总的耗时占比会上升。我做过一个实验把一个路口继续拆成一个车道的推进32 个任务、64 个任务分别跑结果吞吐反而下降了。因为任务本身只有几十微秒的运算量任务拆分的开销已经超过了并行带来的收益。这个经验是任务粒度不能过细最直观的教训。Fork/Join 框架我也尝试过。它的核心优势是工作窃取Work Stealing空闲线程会主动从其他线程的任务队列尾部偷任务执行天然实现负载均衡。但在这个项目里收益不明显因为 16 个路口的计算量非常均匀不需要动态负载均衡。Fork/Join 更适合任务量不均、无法预估每个子任务耗时的场景比如大数组的分治排序、树的递归遍历。如果任务均匀ThreadPoolExecutor就够了引入 Fork/Join 反而增加理解成本。任务拆分时还有一个容易踩的坑子任务如果持有共享资源的引用并且会修改它那拆分得越细锁竞争的概率就越大。我在实现时让每个路口只持有自己的状态对象路口之间通过车辆到达事件传递数据本质上把共享降到了最低。每个路口有一个小型事件队列另一个路口的线程把事件投递到这个队列然后该路口在下一个 tick 消费它。队列本身用ConcurrentLinkedQueue保证线程安全投递和消费都不需要加锁。3.3 数据一致性与同步机制从乐观锁到 CAS 再到 AQS先说结论在这个仿真项目里我用到了三种不同层级的同步手段——synchronized关键字保护路口状态的复合操作、AtomicLong和LongAdder做统计计数、ReentrantReadWriteLock保护统计数据快照的读取。每种手段的适用场景完全不同。synchronized是最基础的内置锁它的好处是简单可靠、自动释放、异常安全。但它是重量级的JVM 会经历偏向锁、轻量级锁、重量级锁的升级过程在线程竞争激烈时性能下降明显。我的使用场景是信号灯决策——每 15 秒一次频率低用synchronized完全够代码可读性最好。不建议对这个场景上ReentrantLock纯属过度设计。统计计数器是高并发热点了。每辆车通过路口都要更新通行量几万次每秒的更新频率如果用synchronized包起来锁竞争就成了瓶颈。这里我采用了LongAdder——它是 JDK 8 引入的高并发计数器内部维护一组 base 和 Cell 数组每个线程更新时只操作自己的 Cell最后求和时再把所有 Cell 累加。实测对比AtomicLong在高竞争下性能约为LongAdder的 60% 左右因为AtomicLong的 CAS 循环在高竞争时会导致大量自旋重试而LongAdder把竞争分摊到了多个 Cell 上。CAS 本身是并发编程的基石。AtomicInteger的incrementAndGet就是无限循环 CAS读旧值、算新值、compareAndSet尝试写入失败就重读重试。它避免了锁带来的线程阻塞但有两个前提条件竞争不激烈且每次操作只是单一变量的更新。多个变量的一致更新CAS 就搞不定了需要AtomicReference把多个字段封装成一个不可变对象来更新或者直接上锁。AQS 是ReentrantLock、CountDownLatch、Semaphore背后的统一底座。理解了 AQS你就理解了大半个 java.util.concurrent 包。它维护了一个 volatile 状态变量和一个等待队列通过 CAS 修改状态获取不到资源的线程会被封装成节点进入队列阻塞等待释放资源时再唤醒队头节点。项目里我用CountDownLatch做 tick 屏障16 个路口任务全部完成后调度线程才允许推进到下一个 tick。CountDownLatch tickLatch new CountDownLatch(16); for (RoadIntersection intersection : intersections) { executor.submit(() - { try { intersection.advanceOneTick(); } finally { tickLatch.countDown(); } }); } tickLatch.await(); // 所有路口推进完成进入下一 tick这个场景用CountDownLatch是标准解法你也可以用CyclicBarrier——两者区别是CountDownLatch是一次性的减到 0 就不能复用CyclicBarrier是可以循环使用的。仿真系统每 tick 都要同步理论上CyclicBarrier更贴合但我在实践中发现每轮都new CountDownLatch的开销极小而且代码意图更清晰就没换。4. 实测过程与性能对比4.1 测试设计与对比数据写并发项目最忌讳感觉变快了。我针对三套运行模式做了一组对照实验同样的 100 万个模拟秒、同样的车流参数、同样的随机数种子分别在单线程、8 线程线程池、虚拟线程三种模式下跑。随机数种子必须一样否则每次仿真的随机序列不同结果没有可比性。测试结果如下运行模式总耗时秒吞吐量事件/秒通行总量一致性单线程127.3约 8100基准8 线程线程池18.4约 56000一致虚拟线程19.2约 53500一致8 线程相对单线程有约 6.9 倍提升远低于理想中的 8 倍。原因主要有三个一是统计聚合和任务提交的串行部分无法并行二是 16 个路口任务分配到 8 个线程可能出现短时间的负载不均三是 Amdahl 定律——程序的串行部分占比决定了并行加速比的理论上限。我的调度引擎里每 tick 的tickLatch.await()就是不可避免的串行点。虚拟线程没有跑出超越线程池的成绩这是符合预期的。虚拟线程主要解决的是大量阻塞等待的场景比如 IO 密集型任务线程阻塞时 JVM 可以让其他虚拟线程复用载体线程。而我的仿真任务是纯 CPU 密集型虚拟线程的调度优势无从发挥。如果你拿它去模拟大量网络请求虚拟线程的优势才会显出来。4.2 结果分析瓶颈在哪里怎么定位跑完数据之后我还做了一步关键工作定位瓶颈。这不是靠猜而是靠工具。项目里我用到了三种手段JFR 事件记录、jstack线程转储、JMH 微基准。JFRJava Flight Recorder是 JDK 自带的性能剖析工具它对运行中的 JVM 记录各种事件——线程阻塞、锁竞争、CPU 使用率、GC 暂停等。我需要知道每 tick 的耗时都花在哪于是开启了 JFR 录制跑完一段仿真后把录制文件导入 JMCJava Mission Control分析。分析结果让我挺意外最大的耗时点不在路口状态的业务计算而是LongAdder.sum()的频繁调用——统计聚合器每 tick 都在累加所有 cell 的值这个操作在高频调用下会触碰内存栅栏代价不低。解决办法是降低聚合频率从每 tick 聚合改为每 10 tick 聚合一次总耗时又下降了 11%。jstack是排查线程问题的利器。在仿真运行到一半时执行jstack pid能看到所有线程的当前状态是 RUNNABLE 在计算还是 WAITING 在等锁还是 BLOCKED 卡在同步块。有一次我怀疑信号灯决策的加锁代码导致线程阻塞跑jstack一看大量线程处于WAITING状态锁竞争确实存在。但进一步分析锁持有时间后发现信号灯决策每 15 秒才执行一次每次只有几毫秒这个等待完全在可接受范围。这也提醒我不是所有锁竞争都是问题先看锁持有时间和竞争频率再决定要不要换更复杂的无锁方案。JMH 微基准用来测试单个操作的耗时。比如LongAdder.increment()和AtomicLong.incrementAndGet()在高并发下分别多少纳秒ConcurrentLinkedQueue.offer()和SynchronousQueue.put()又差多少。这种微基准不能直接代表系统性能但它能帮你在做技术选型时排除明显不合适的方案。比如我在比较每辆车一个虚拟线程和每路口一个任务两种模型时先用 JMH 测了虚拟线程的创建和调度成本几十万次的创建开销远小于预期才敢放进去做全量对比。5. 常见问题与排查实录5.1 死锁现场还原从表象到根因开发过程中我遇到过一次经典死锁场景是信号灯决策和车辆通行统计互相持有锁。信号灯决策方法要读取路口的实时车流数据所以它先锁住了统计数据锁然后尝试更新信号灯状态时又需要信号灯状态锁与此同时车辆通行的统计更新流程先拿信号灯状态锁短暂读灯色然后去更新统计时又需要统计数据锁。两个线程各自持有一把锁等待对方释放就死锁了。排查死锁的标准做法是先jstack抓线程转储JVM 检测到死锁时会在转储文件里直接输出Found one Java-level deadlock并列出两个线程分别持有哪把锁、等待哪把锁。看到输出后三条路可选一是调换加锁顺序让所有线程都按同一顺序获取锁二是用ReentrantLock的tryLock带超时时间获取锁获取失败就回退重试三是缩小锁粒度让持锁时间降到最低。我选的是第一种统一规定必须先获取统计数据锁再获取信号灯状态锁。虽然信号灯决策从业务逻辑上看只需要读车流数据不需要反向拿锁但代码里有一个更新信号灯配置时同时刷新统计缓存的操作引入了反向依赖。重构之后加锁顺序一致死锁从根上消失。这个教训是死锁往往不是无解而是设计上没有规划好锁的层次。5.2 伪共享与缓存行性能杀手另一个隐蔽问题是伪共享False Sharing它导致的性能问题我排查了很久。现象是16 个路口各有一个long类型的计数变量分布在不同的对象里多线程并行更新各自的计数器但整体吞吐一直在波动而且 8 线程的性能提升远低于预期。伪共享的机理是CPU 缓存以缓存行通常是 64 字节为单位加载数据。如果两个线程操作的变量恰好落在同一条缓存行内即使它们在逻辑上是独立的CPU 也会强制维持缓存一致性协议导致一个线程修改自己的变量时另一个线程的整个缓存行都失效需要重新从主内存加载。这就是兄弟打架——各有各的房间但房间在同一堵墙里一个人敲墙隔壁就睡不着。用 JMH 验证我构造了两个线程分别更新两个相邻的long变量对比不加填充和加了 7 个long填充把变量推离同一缓存行两种场景性能差距接近 3 倍。修复方案是在共享计数器之间添加 padding 填充class PaddedCounter { public volatile long value; // 缓存行填充防止伪共享 public long p1, p2, p3, p4, p5, p6, p7; }不过要强调的是Java 引入了Contended注解需要-XX:-RestrictContended开启以及 JDK 8 的LongAdder内部已经做了 Cell 填充普通业务代码完全不用手动写 padding。我自己写的时候主要是为了理解和教学生产环境直接LongAdder就好。排查伪共享的实践方法是如果多线程各写各的变量但性能上不去先怀疑变量是不是在一个对象/数组里排列得太近再用perf stat看缓存未命中事件确认。5.3 线程安全之外的坑模拟随机性与浮点累积误差除了线程竞争仿真项目还有两个不显眼但很磨人的坑值得单独记一笔。第一个是随机数生成器的线程安全问题。我用ThreadLocalRandom.current().nextDouble()生成车辆到达时间间隔这个生成器本身就是线程安全的不需要额外同步。但一开始我用了new Random(seed)并且所有路口共享同一个实例——多个线程同时调用Random的nextDouble会导致内部种子被并发修改生成的随机数序列退化了最终仿真结果和单线程对不上。后来改成每个路口持有独立的Random实例且用固定种子初始化才保证多线程和单线程结果一致。这一点特别重要仿真系统如果连可复现性都保证不了后面所有统计分析都没有意义。第二个是浮点累积误差。车辆等待时间的平均值是用double totalWaitTime / totalPassedCount算的但如果totalWaitTime用double累加跑 100 万模拟秒后会积累明显的浮点误差。我改成用long纳秒级累加最后再做一次转换平均等待时间的计算结果更稳定了。这个小细节在架构图上完全看不出来但真实跑数据时才体会到高精度计算场景能用整数就不用浮点。5.4 常见问题速查表问题现象可能原因解决办法多线程结果与单线程不一致并发修改共享变量、随机数线程不安全、缺少屏障同步检查共享数据流图独立随机数实例每 tick 屏障等待线程数增加性能反而下降上下文切换开销过大、任务粒度过细用 JMH/实际压测找到最优线程数调大任务粒度死锁程序卡死无响应多个线程互相持有锁等待jstack定位统一加锁顺序tryLock超时吞吐忽高忽低伪共享、频繁 GC、锁竞争热点JFR 分析缓存行填充优化聚合频率内存持续增长无界队列积压任务线程池用有界队列 拒绝策略高竞争下计数性能差AtomicLong自旋开销过大换成LongAdder我这个项目跑到现在最大的体会是并发编程不能靠记住 API 用法而是要靠理解数据在不同线程间的流动方式。线程池、锁、原子类、虚拟线程这些都是工具工具怎么选取决于你对场景的判断——是 CPU 密集还是 IO 密集是竞争激烈还是偶尔互斥是短任务还是长任务。最后再分享一个小技巧调试并发问题时别急着上工具先想设计。我在做仿真项目时每次遇到诡异的性能问题或者数据不一致第一个动作永远是停下来看共享数据流图而不是盯着代码逐行找 bug。大多数并发问题不是代码写错是设计上让多线程碰了不该碰的数据。先隔离共享再决定用锁还是用原子类最后才是调性能。按照这个顺序来你会发现 Java 并发编程其实一点都不玄乎。