这篇文章记录了作者排查一台机器因CPU利用率异常飙升至3200%(32核心全负荷)的过程。
作者发现机器几乎无法通过SSH访问,CPU利用率高达3200%,远超之前仅占用单核心100%的bug。
借助Java 17运行时的线程转储,作者定位到高CPU占用源于BusinessLogic.java:29,其中TreeMap.put被频繁调用。
代码分析显示,someFunction中有一个循环,迭代unrelatedObjects,但仅操作无关的relatedObject,导致不必要的重复调用TreeMap.put。
优化后,复杂度从O(N lg(M))(N为unrelatedObjects大小,M为TreeMap大小)简化为O(1)。
然而,即使模拟百万条目也未重现问题,作者怀疑TreeMap和unrelatedObjects规模应远小于1000,不足以引发性能瓶颈。
进一步调查发现:TreeMap定义为无锁的private final Map
作者设计实验模拟多线程随机更新共享TreeMap,结果显示若不捕获NullPointerException(NPE),线程会崩溃;但捕获NPE后,CPU利用率飙升至500%,暗示竞争条件可能引发无限循环。使用反射检查TreeMap(红黑树实现)的节点,作者确认并发修改可能形成循环结构,导致put操作卡死。
实验表明,只有特定语言(如Java、C#、Kotlin)因NPE处理特性易重现此问题,而Ruby(因GIL)、Rust(因编译器限制)等则不然。
作者提出简单修复:用Collections.synchronizedMap包装TreeMap或改用ConcurrentHashMap;
Java 中的核心集合在设计上不是线程安全的,这一点应该引起注意。
网友: 1、作者发现了一种“毒丸”的味道。这种东西在事件源系统里挺常见的,就是一种消息,它会把碰到的任何东西都“毒死”,然后被下一个碰到它的家伙再“吃”下去,那个家伙也会死得很难看。不过在这儿,它不是一般的死,而是“活锁”。 一旦数据结构变得不正常了,每个后来的线程都会掉进同一个逻辑陷阱里,而不是像更常见的那样,在非法状态下出个“空指针错误”(NPE)就完事儿。
2、我在同步不足的 java.util.HashMap 中也见过同样的情况。这大概是 2009 年的事情了。
3、我遇到过很多次这种情况。在 Java 或任何语言中对非线程安全对象执行并发操作会产生世界上最有趣的错误。
4、Rust 以其“无畏并发”而自豪(严格的编译时检查,以确保锁或类似结构用于跨线程数据,以及常见的通道等),而 Go 则以其在大多数任务中使用通道和 goroutine 而自豪。并非所有东西都像 C/C++/C#/Java 的情况一样,其中同步结构与它们负责的数据分离。
5、我曾经是将单线程应用程序变成多线程的开发人员。不过这是最好的学习方法!
6、我很高兴看到这篇文章不仅涵盖了其他语言,而且这个错误也发生在 Go 中。我有点惊讶,因为 map 访问通常受到竞争检测器的保护,但使用的 RedBlack 树不会在 map 的任何地方存储任何东西。