
1. 1BRC到底在考什么一个10亿行文件把我逼到了墙角1.1 题面解析与赛果门槛远比读文件复杂先说清楚1BRC是什么。它是Gunnar Morling发起的One Billion Row Challenge给你一个1,000,000,000行10亿行的文本文件每一行格式固定类似城市名;温度值比如Hamburg;12.0。要求程序读取全部数据对每个城市统计最低温、最高温和平均温最终按城市名的字母序输出结果。难点不在于算法而在于数据规模和隐形的性能红线——官方默认提供的基础实现大约要跑4分多钟而头部选手用Java能压进1.5秒以内相当于240倍的提升。这个差距不是某一种优化能拉出来的而是内存映射、解析方式、并发模型、哈希设计、GC策略共同作用的结果。我拿到这个题的第一反应是这有什么难的毕竟写了这么多年JavaHashMap谁不会用。但真正跑通第一版之后我盯着那个61秒的耗时沉默了。10亿行数据意味着大约13GB的解析量如果每行都走String.split、Double.parseDouble、HashMap.computeIfAbsent每秒钟只能处理1600万行左右整个时间就全耗在对象创建和GC上了。更关键的是1BRC对输出格式有严格校验结果必须按城市名的字典序排列每座城市的输出格式是{城市最低值/平均值/最高值}平均值要求精确到一位小数数值取整有严格要求。这意味着不是算出结果就行而是算得快且准。很多人在中途为了提速把浮点运算改成整数运算之后数值精度会出问题这是另一个隐蔽的大坑。1.2 为什么我会带着Agent来做这场优化我坦白说一开始我没打算用Agent。但在这个项目推进到中后段时我发现自己陷入了典型的局部最优困境每次改完一个点跑一次Profiler能提升个10%到20%但改到第五六个版本之后改动的收益越来越小有些改动甚至出现回退。这个时候人类直觉的不可靠性暴露得很明显——凭感觉猜瓶颈十次有七次猜错。我当时的做法是把Agent当作一个会读代码的实验搭档来用。我不会让它直接写完整答案而是把当前版本的性能数据、Profiler采样结果、源代码片段喂给它让它帮我生成候选优化方向再手工审查它给出的代码决定值不值得上机验证。后来我复盘时统计了一下整个优化周期里Agent贡献了大约15个有效优化方向其中10个方向最终被采纳节省了大量读文档和试错的时间。但与此同时它也给我挖过坑——它曾强烈建议我用String.repeat之类的技巧去构建输出缓冲区理由是减少IO次数实际测下来性能毫无变化白改了一版。这个体验让我意识到一件事在1BRC这种性能极限项目中AI Agent的真正价值不是替你做决策而是帮你把猜测和实验这两个环节提速。你仍然是那个对JVM、对硬件、对数据特征负责的人。2. 第一版暴力实现先跑起来才知道往哪里优化2.1 朴素版本的技术构成与基线成绩我的第一版实现非常老实就是标准的三板斧MapString, double[] stats new HashMap(); try (BufferedReader reader Files.newBufferedReader(Path.of(measurements.txt))) { String line; while ((line reader.readLine()) ! null) { int idx line.indexOf(;); String station line.substring(0, idx); double temp Double.parseDouble(line.substring(idx 1)); double[] arr stats.computeIfAbsent(station, k - new double[]{0, 100, -100}); arr[0] temp; arr[1] Math.min(arr[1], temp); arr[2] Math.max(arr[2], temp); } }这一段代码逻辑没有任何问题性能却是灾难级的。在我的机器上Intel i7-12700H32GB内存NVMe SSD完整跑完10亿行耗时61.2秒。压力主要来自三个地方每行创建一个String对象用于substringBufferedReader默认8KB缓冲区导致海量系统调用HashMap在高并发哈希碰撞下的扩容与computeIfAbsent闭包开销。有意思的是很多人在第一步就摔倒了——我见过有人用Files.lines()加Stream并行流去跑表面上有并行实际上lines()的Spliterator特性导致分片不均而且共享的HashMap线程安全问题只能靠ConcurrentHashMap硬扛最后的耗时比串行还差。这个教训是1BRC这类任务并行不是加一个parallel()那么简单关键是数据分片和合并策略。我建议任何人动手优化前先把这个基线版本跑出来记下数字。因为没有基线后面所有优化的收益都是空中楼阁。我当时用perf stat顺手记录了几个硬件指标CPI大约是1.3说明CPU大量时间在等待内存和IO用户态占比85%内核态15%说明瓶颈不是纯粹的等待磁盘而是用户态的字符串解析。2.2 第一次交给Agent做剖析它给的方向和我的最终判断跑完基线之后我把Profiler的火焰图数据整理了一下连同朴素实现源码一起发给Agent问它三个问题最大的瓶颈在哪、按收益排序的优化建议、以及每个建议的风险。Agent给出的判断是短期收益最大的是两个方向一是合并读取缓冲区二是把HashMap换成数组加开放寻址。这两条我后来都采纳了但顺序上我做了调整。Agent建议先做缓冲区改造因为改动小、收益快我做的时候发现FileChannel加ByteBuffer之后文件读取本身从8KB的缓冲翻到64MB的映射段读取速度确实上去了但这只是把瓶颈从IO读取转移到了解析整体耗时从61秒降到43秒收益不算惊艳。真正让我对Agent的答案产生警惕的是它忽略了一个点Double.parseDouble的调用开销。哪怕我们已经用Float精度去近似一个parseDouble也要走完整的浮点语法识别路径包括符号、指数、小数点校验。这个函数每条数据调用一次10亿次就是天文数字。Agent给的建议集中在数据结构层面反而对热路径上的单点函数关注不够。这是我在后面手动优化的一个重点。我的总结是Agent适合做宽泛的候选方案生成但不适合做精确的热路径定位。热路径定位必须靠Profiler和底层计数器的数据说话AI再聪明也看不见你的CPU Cache和分支预测器。3. 三大瓶颈逐一拆除解析、哈希与内存局部性3.1 字符串解析的隐性税从逐字符手工解析到字节级处理第一版里最浪费时间的操作其实是substring和Double.parseDouble的叠加。每个substring会创建一个新的String对象底层还要复制一次byte[]。10亿行数据哪怕只有一半行做了substring那也是5亿个短命对象GC的压力直接爆表。我做的第一次大手术后把行的解析改成对byte[]的原地操作找到分号的位置后不再创建子串而是记录start和end的索引直接用这两个索引去构造一个轻量级的站名视图。由于站名只需要作为哈希表的key来定位我用Arrays.hashCode(byte[], start, end)来计算哈希值用Arrays.mismatch来做相等比较。这套思路本质上是绕开String的不变性设计。温度解析我同样改成了手工字节解析从分号后面第一个字符开始扫描累加整数部分、小数部分和符号。其实温度值范围固定在-99.9到99.9之间写法固定完全没必要让parseDouble做通用计算。我把double值改成了int乘以10之后存整数例如12.3就存成123最后输出时再除以10。这样既避免了浮点精度误差又把解析速度提升了一大截。字符串解析修改前后的差异可以直接体现在数字上修改前单线程处理速度约1600万行/秒修改后单线程直接翻到6000万行/秒。这还没有并行。3.2 哈希表改造从HashMap到开放寻址还是封装数组HashMap的问题在于它每个键值对都要创建一个Node对象而且当哈希桶冲突时形成链表对缓存极不友好。在14亿级别的高频访问场景里这种指针追逐的开销会被无限放大。我把核心容器改成了开放寻址的哈希表底层是三个平行的数组一个byte[][]存原始的站点名一个int[]存哈希值一个long[]存统计结果用bit-packing打包min/max/sum/count。哈希表的容量取实际站点数的两倍左右用2的幂确保取模快速化。实际上1BRC的数据集里站点名大约有400多个但真实测试集中站点数上限是10K量级所以我给容量直接设成65536避免动态扩容的随机性。插入的时候用线性探测遇到哈希冲突就往后扫。查询的时候同样线性探测。因为站点数有限且哈希值分布分散探测次数很少超过两三次。这里有一个小细节我不会在任何一次插入或查询中调用equals方法去比较站点名只有哈希值命中的情况下才做一次byte[]的mismatch检查这样80%的碰撞都能在哈希值层面过滤掉。这个改造的效果非常明显解析速度从6000万行/秒提高到约7000万行/秒。别小看这1000万行每秒的提升10亿行数据累积下来就是几十秒的差距。3.3 并行分片与False Sharing的交锋以及为什么我不能让你抄作业到这里串行已经做到极限了瓶颈重新回到了单核心的计算能力上。接下来的突破只能靠并行。1BRC的输入文件虽然巨大但是每行都是独立的天然可以分片。标准做法是把文件按MappedByteBuffer映射成多段每个线程负责一段。分片时有一个细节你必须在每个分片的开头找到第一个换行符把它丢弃否则会切在一行数据的中间解析直接错乱。每一段数据的起始位置需要从分片边界往后扫描到\n为止这个扫描本身有几百万次的字节判断但相比10亿行解析的总无妨。我用Runtime.getRuntime().availableProcessors()检测核心数本机是20线程。理论上分20片就能全部占满。但实际跑下来我发现分12片成绩最好——这是因为超线程的两个逻辑核心共享同一个物理核心的执行单元你硬塞20个线程反而增加上下文切换和锁竞争。CountDownLatch或ForkJoin框架来合并各线程结果。这里最大的坑是false sharing。每线程维护自己的统计数组时如果不做填充多个线程的数组元素会躺在同一个Cache Line上一旦互相写入就会强制缓存同步性能下降20%以上。我给每个线程的统计结果数组加了一个64字节Padding问题才消失。这个坑是在实测中发现的同样的代码加Padding之前是1.8秒加Padding之后是1.25秒。Cache Line的惩罚在数据量小、循环频繁的场景下比锁竞争更难排查。并行改造完成后我的总耗时从61秒降到了大约3.2秒。从61秒到3.2秒靠的是串行优化加并行分片。但3.2秒距离顶部选手的1.5秒还有一条鸿沟这个鸿沟靠什么填下一节说。4. Agent参与的热循环战斗日志18个版本的取舍依据4.1 从3.2秒到1.5秒每轮实验设计成最小闭环3.2秒之后我进入了一段非常磨人的时间。盲目尝试各种微优化基本无效平台期非常明显。我和Agent约定了一套实验流程确保每个改动都能被可信地评估每次只改一个变量其余代码不动跑至少3遍取中位数而不是平均值避免系统噪声干扰每次改动前先做代码diff记录改动原因和预期收益结果不合预期就立刻回滚不恋战。这套流程的价值在于它把性能优化从玄学变成了可验证的实验。我统计了一下从第8版到第18版一共尝试了22个优化点子实际被采纳的只有11个每个点子的收益从1%到18%不等。很多看起来很美的做法实测以后根本没效果。举个例子Agent某次建议我把输出阶段用StringBuilder预先拼好所有城市结果再一次性写文件。听起来很合理减少IO次数嘛。但实测之后毫无变化——因为文件只有几十KB写一次和写一百次的时间差异根本测不出来。这就是典型的理论收益远小于实际噪声需要实验来过滤。4.2 立竿见影的三板斧sun.misc.Unsafe、批处理与长聚合在平台期最有效的三个优化我必须详细说。第一热路径上的哈希表访问改用sun.misc.Unsafe。这里说的不是用Unsafe做内存破坏而是利用它提供的putInt/getInt等内存屏障级的操作绕开数组边界检查。HotSpot对普通数组访问是有边界检查的虽然JIT会优化掉一部分但在10亿次级别的循环里边界检查仍然是一个真实开销。当我们自己知道索引必然合法时用Unsafe是合理的选择。private static final Unsafe UNSAFE Unsafe.getUnsafe(); private static final long ARRAY_BASE UNSAFE.arrayBaseOffset(int[].class); private static final long ARRAY_SHIFT 31 - Integer.numberOfLeadingZeros(UNSAFE.arrayIndexScale(int[].class));这段代码在团队代码评审里可能很难通过但在1BRC这种单文件、单任务的竞技场景里规则允许你为性能放弃一些常规保守性。第二把解析和聚合合并成一个大循环减少分支判断次数。原来我的循环里要先判断是否到达分片末尾再判断是否找到分号再做解析。这些分支会干扰CPU的分支预测器。我把循环改成了先找到换行符再在已确定的数据区域内用差量索引来解析的结构每次循环的时间从大约80ns降到了60ns。第三采用LongAdder思路的合并策略。各线程完成解析后不是把统计数组直接合并进主哈希表而是先聚合到线程局部数组再用LongAdder的方式在主表中累加。这一步其实是为了彻底消除合并阶段的锁竞争。原来我用ConcurrentHashMap合并耗时约0.8秒改用分段数组之后合并降到0.2秒不到。这三板斧做完成绩到了1.9秒左右。4.3 Agent真正帮到我的几个瞬间以及它的能力边界我说几个Agent帮了大忙的具体场景。第一个场景是性能可疑点排查。有一次我的成绩在1.9秒之后无论如何都上不去了我怀疑是分片数量不对就手动调了好几个值都没有明显变化。我把Profiler数据发给Agent它提出一个我之前完全忽略的角度检查文件系统读取和用户态计算是否存在DMA中断干扰建议用vmtouch去预热页缓存并把分片边界对齐到2MB的Huge Page边界。我照做了之后成绩掉到1.65秒。这个方向的思路确实有价值虽然是纯系统层面的操作。第二个场景是代码审查。Agent会在我写好的修改上做静态扫描找出可能的边界问题比如分片定位时如果遇到\n刚好在缓冲区最后几个字节索引会越界。这个提醒帮我在正式提交前避免了一个崩溃级别的Bug。但Agent的边界也很明显。它无法感知JVM底层的即时编译行为比如逃逸分析是否成功、循环是否被JIT完全展开、Intrinsic是否被应用。有一次它强烈建议我用ArrayList替代自定义数组来存放站点统计信息说代码更简洁、性能相近。实测之后性能掉了200毫秒我立刻回滚。在这个层面任何Agent的建议都不如一次可控的实测可靠。5. 赛后的完整配置复盘我最终采用的方案与参数5.1 最终版本的构成清单以及成绩数字最终提交版本的成绩在我本地机器上是1.47秒从基线61.2秒算起提升了约41倍。这个成绩在1BRC赛榜上属于优秀区间但不算顶尖距离传说中的0.5秒级别还有差距。不过考虑到我用了很克制的技术栈仅标准库加Unsafe这个成绩我认为可以接受。最终版本的构成如下模块技术选择预期贡献文件读取FileChannel.map 2MB对齐分片,预缓存页IO不再成为瓶颈行解析字节级手工解析,整数表示温度省掉字符串和浮点解析开销哈希容器开放寻址平行数组Unsafe访问缓存友好,无GC压力并行模型12分片,线程间无共享,分段合并多核利用率最大化输出阶段预排序批量字节拷贝输出对总耗时几乎无感为了让这个表格不是纸上谈兵我再说说每个模块的实际效果数据。文件读取方面映射20个分片之后读取速度约2.6GB/s接近NVMe顺序读的极限。行解析方面手工解析器可以在10亿次迭代中保持稳定没有出现浮点舍入错误。这也算检测了一轮温标的正确性。5.2 为什么我没有继续追极限两个被否决的激进方向到了1.47秒之后我评估了两个更激进的优化方向。第一是采纳off-heap内存和-XX:UnlockExperimentalVMOptions -XX:UseEpsilonGC完全抛弃GC第二是实现定点数累加加上20字节写死的最小字符串价值计算。两个方向的预期收益大概还能压到1.2秒以内但代码复杂度和出错概率急剧上升。我最终放弃了。原因很实际这次项目的目标不是刷新排行榜而是理解每一条优化背后的原理。再往下去优化的边际收益不再来自技术选择而来自针对特定输入特征的极端定制比如假设人名长度固定、温度位数固定这会严重伤害代码通用性。竞技项目可以有这种定制但工程实践里我不会建议任何人这么做。5.3 对新手的三条建议以及我踩过的两个逆向坑我给想挑战1BRC的新手三条建议。第一条先跑Profiler再看代码。不要凭直觉去猜瓶颈。我在1.5秒到1.9秒的平台期浪费了整整半天就是因为反复调整一个已经无关紧要的循环展开参数。第二条把每个优化单独提交一个版本写清楚spec和实测数据。这会让你在回滚时非常痛快。如果所有改动混在一起一旦成绩回退你根本不知道罪魁祸首是谁。第三条用Agent可以但永远不要跳过上机验证。Agent给你的代码尤其是涉及并发和哈希的部分必须自己在测试集上跑过再说。我再分享一个逆向坑分片合并时一个线程合并到主表、另一个线程还在读自己的局部数组如果不做VarHandle.releaseFence或Thread.onSpinWait在小概率下会因为内存可见性问题得到错误的汇总结果。我遇到过一次均值偏差0.1度的情况百思不得其解最后定位到是缺少内存屏障。第二个坑是输出排序时我用Arrays.sort对城市名排序但城市名内部是按byte[]存储的排序器每访问一个城市都有一次数组越界检查的额外开销后来切换成长度字典序的手写排序输出段耗时从0.15秒降到了0.03秒。6. 这次Agent协助优化留给我的几条判断标准现在回看整个过程我最想分享给同行的是几条关于人机协作做性能优化的判断标准。第一条是Agent给方向人给约束。Agent擅长在巨大的搜索空间里给出候选路径甚至能想到一些人类习惯性忽略的偏门方向比如页缓存预热。但你必须在前面立好约束条件哪些代码复杂度可以接受哪些轮子不能重复造哪些测试数据必须跑得过。没有约束Agent会把你的项目越改越偏。第二条是性能数据是唯一的裁判。我在这个项目里学会了不跟任何一段代码谈恋爱。无论是人写的还是Agent写的上了Benchmark跑分Unsafe的魔法可能不如几行笨代码。性能优化不能讲情怀只能讲测量。第三条是留好一条绝对安全的后路。我始终保留着一个基线版本的JAR包当所有优化版本在验证时出现异常随时可以切回基线确认是不是优化本身导致的”。如果没有这个后手遇到Java版本切换或JVM参数改变时你会分不清是代码问题还是环境问题。最后说一点关于Agent工具的思考。在这轮优化中我真正受益最大的不是Agent帮我写的一行代码而是它逼着我养成了最小闭环数据存档的实验习惯。这种习惯一旦养成以后做任何性能优化都不会再靠猜。1BRC这个项目做完之后我把这套流程带到日常工作中的慢SQL优化和接口延迟治理上效率同样提升明显。这大概就是这类极限挑战赛真正的价值——它不见得让你学到什么高深算法但它会逼你学会如何系统性地逼近一个性能目标。