时隔多年,我决定重新拾起Java。这次的目标很明确:深入理解操作系统层面的并发与线程机制,尤其是想看看Go的goroutine、Kotlin的协程在底层究竟是怎么工作的,以及还有没有优化空间。

为了验证这些概念,我没有选择简单的计数器 demo,而是在YouTube上看到了"10亿行挑战"后,给自己定了一个目标:统计2亿个单词的出现频次。文件大小约1.5GB,这个体量刚好能体现性能优化的真实效果。

打开网易新闻 查看精彩图片

第一步:搭建基准线

打开网易新闻 查看精彩图片

任何优化都需要起点。我的初始方案很直接:用nio包读取文件,按空格切分字符串,HashMap统计词频。代码跑通后,我发现内存占用是个问题——readAllLines会把整文件载入内存

于是换成Stream API:Files.lines(file)配合forEach处理。流式读取不加载完整文件,内存压力骤降。这一步让我熟悉了Java的nio包,API设计和Node.js的fs模块很像,上手很快。

第二步:引入并发,应用OS原理

读完《Operating Systems: Three Easy Pieces》的关键章节后,我开始实践多线程。策略是"分而治之":固定8线程池,每线程处理独立数据段,各自计算后合并结果。这种"无共享"(Shared-Nothing)架构避免了锁竞争,是典型的线程本地策略。

但这里我犯了个错。第一版实现用了ConcurrentHashMap,所有线程往同一个map里写。虽然用了并发容器,实际测量发现:线程越多,性能反而越差。8线程比单线程还慢。

问题出在伪共享(False Sharing)和缓存行争用上。多个线程同时修改相邻内存位置,触发CPU缓存一致性协议反复同步,开销巨大。

第三步:真正的并行——消除共享

打开网易新闻 查看精彩图片

修正方案:每个线程维护自己的HashMap,处理完再合并。这样彻底消除了写竞争。实现上用LongAdder替代AtomicLong做计数,减少高并发下的CAS重试。

文件切分也有讲究。不能简单按字节均分,否则会把单词拦腰截断。我的做法是:先定位到每个分区的起始位置,向后搜索到下一个换行符,确保边界完整。

最终版本还引入了内存映射文件(FileChannel.map),绕过用户态到内核态的数据拷贝,配合直接缓冲区减少GC压力。

结果对比

从最初单线程Stream版的基准,到最终优化版,整体耗时从约140秒降到20秒,提升7倍。内存峰值从堆内存溢出边缘稳定在可控范围。

这个过程中,最反直觉的发现是:并发容器不等于高性能。真正决定效率的是数据访问模式——让线程各自为政,比精巧的锁机制更有效。

代码已开源,包含5个递进版本,每步都有性能数据记录。如果你也在研究Java并发,建议亲手跑一遍,感受缓存行、伪共享这些抽象概念如何具象为毫秒级的差异。