时隔多年,我决定重新拾起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并发,建议亲手跑一遍,感受缓存行、伪共享这些抽象概念如何具象为毫秒级的差异。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.