1BRC这项挑战在Java社区里热度一直很高凡是碰过的人都绕不开两个关键词1BRC和优化。我这次在原有Java实现基础上引入了Agent辅助整个优化过程跑完几轮下来性能和工程效率都提升得比较明显。这篇文章就按我的实际操作顺序把思路、代码、参数和踩坑点完整记录下来希望能给准备玩1BRC或者想用Agent帮忙搞性能优化的朋友一些参考。1. 项目概述与优化目标拆解1.1 1BRC是什么十亿行数据的含义1BRC是“One Billion Row Challenge”的缩写核心任务非常直接给定一个约13GB的文本文件里面包含十亿行城市温度采样数据每行格式是“城市名;温度值”例如哈尔滨;-12.3 三亚;28.7 杭州;19.2最终要求按城市名字典序输出各城市的最低温度、平均温度、最高温度。听起来很简单难点在于数据量十亿行、十几GB如果用最朴素的方式逐行读入、字符串切割、HashMap统计在普通开发机上可能要跑好几分钟而顶尖方案能把时间压到几秒甚至一秒以内。这个挑战最适合两类人来玩一类是想深入理解JVM内存布局、IO模型、并发编程的Java开发者另一类是搞大数据或中间件开发、需要经常面对海量数据处理的工程师。因为1BRC把很多性能问题都浓缩到了一个看似简单的文本统计任务里优化过程中涉及的知识点非常密集。1.2 为什么选Agent参与优化而不是纯手写我这次采用的方式是把对话式AI Agent当作“结对编程搭档”让它负责提出优化方案、生成代码片段、辅助审查补丁。之所以选这条路不是因为自己写不了而是因为1BRC的优化空间实在太大了单人手工迭代一轮可能要半天Agent可以在几秒内生成三到五个版本我再针对性地评审、修改和压测整体效率明显提升。不过这里要说清楚边界Agent只能提供方案和代码最终的性能验证、参数调整、异常排查仍然要我亲自完成。盲目信任Agent生成的“看起来更高级”的代码往往会引入隐藏的并发问题或正确性问题。我的原则是“Agent给弹药我自己扣扳机”。2. 基线实现与性能瓶颈分析2.1 朴素实现的性能数据我先用一段最常规的Java实现作为性能基准。思路很简单用BufferedReader逐行读取文件按分号拆分成城市和温度用HashMap聚合最后排序输出。核心代码如下MapString, double[] map new HashMap(); try (BufferedReader reader Files.newBufferedReader(Paths.get(measurements.txt))) { String line; while ((line reader.readLine()) ! null) { int idx line.indexOf(;); String city line.substring(0, idx); double temp Double.parseDouble(line.substring(idx 1)); double[] arr map.computeIfAbsent(city, k - new double[]{Double.MAX_VALUE, 0, Double.MIN_VALUE, 0}); arr[0] Math.min(arr[0], temp); arr[1] temp; arr[3] 1; arr[2] Math.max(arr[2], temp); } }这段代码在2019年的MacBook Pro8核i9上跑完13GB数据耗时在3分50秒左右。如果换成项目评审里常见的低配虚拟机可能要六分钟以上。2.2 性能瓶颈的三个主要来源基线代码慢在哪里我逐层拆开看第一是字符串对象爆炸。每一行都要创建一个String对象十亿行就是十亿个String每个String底层还有char[]或byte[]光对象头就有16字节内存分配和GC压力巨大。实测GC日志里Old GC占比接近60%。第二是HashMap的哈希与扩容开销。虽然城市名种类只有几百个但每次都执行hashCode、哈希寻址、链表遍历computeIfAbsent还会触发lambda调用这些都是纯CPU浪费。第三是I/O与业务逻辑串行执行。BufferedReader默认8KB缓冲readLine内部要逐字节检查换行符这之后才轮到业务代码拿数据。整个处理是一个单线程的线性流水线CPU大部分时间在空等磁盘数据。理解了这三个瓶颈后续优化就有明确方向了减少对象分配、降低查找开销、提高I/O并行度。3. 核心优化方案与Agent协作实录3.1 第一轮内存映射与行切分基线代码最大的问题是单线程逐行读CPU利用率上不去。第一轮优化我直接换成了内存映射文件加多线程并行分块处理。思路是把整个文件映射到虚拟内存然后按固定大小分块我按CPU核数分为16块每个线程处理一个独立的字节区间。这里有个细节块边界可能落在某一行中间所以各线程需要做“行对齐”找到本块第一个换行符和最后一个换行符只处理完整行。Agent给出的并行框架大概是这样的long fileSize Files.size(path); int chunkSize (int) (fileSize / N_THREADS); try (FileChannel channel FileChannel.open(path)) { long start System.currentTimeMillis(); ExecutorService pool Executors.newFixedThreadPool(N_THREADS); for (int i 0; i N_THREADS; i) { long startPos i * chunkSize; long endPos (i N_THREADS - 1) ? fileSize : (i 1) * chunkSize; pool.submit(() - processChunk(channel, startPos, endPos)); } pool.shutdown(); }我对这个方案做了一些补充不要在每次任务里都重新建立FileChannel而是共享同一个FileChannel让它内部的position指针往前移动并映射对应区域。为了减少锁竞争每个线程维护自己的统计结果最后在主线程里合并。这一轮优化跑下来耗时降到了约55秒。虽然效果显著但我注意到一个问题线程数继续从16增加到32时性能并没有线性提升反而出现抖动。这是因为内存映射在大文件上会产生大量page fault磁盘通道成为新的瓶颈。这个现象后面在“常见问题”部分再细说。3.2 第二轮自定义哈希表与温度解析并行读取解决了吞吐问题接下来就是要干掉字符串对象。我让Agent生成一个自定义哈希表直接对字节数组做键查找而不是用String。具体做法是记录每行城市名的起始偏移和长度直接对byte[]中的那一段计算哈希然后在一个开放寻址表中存储。如果遇到哈希冲突就逐个字节比较是否真正相等。这样既不会创建String也不会调用hashCode()还省去了HashMap的Entry对象。温度解析我也改为手工解析读数部分是“±XX.X”或“±XXX.X”格式先扫描到分号然后解析整数部分和小数部分最后用int表示温度值的10倍避免Double装箱。private static long parseLong(byte[] buf, int start, int end) { boolean negative false; int value 0; for (int i start; i end; i) { byte b buf[i]; if (b -) negative true; else if (b ! . b ! \n b ! ;) value value * 10 (b - 0); } return negative ? -value : value; }这里有个要注意的点解析结果我统一用“温度乘以10的整数值”保存比如-12.3存成-123。这样做的好处是整个处理过程完全不涉及浮点数运算累加和比较都在int范围里完成最后输出时才除以10既快又避免浮点误差。这一轮配合自定义线程合并整体耗时从55秒降到了21秒左右。此时GC已经基本消失了因为所有热点路径都不再分配对象。3.3 第三轮UNSAFE与SIMD风格合并第二轮之后还有明显的耗时我继续深挖。此时主要时间已经花在字节数组的逐字节比较和哈希查找上于是第三轮优化引入Unsafe类跳过数组边界检查直接用裸内存地址读取。Unsafe本身就是双刃剑它绕过了JVM的数组访问安全检查一旦算错偏移很容易导致JVM崩溃。我的做法是让Agent生成候选代码后自己严格审查每一处地址计算并且在一个小的临时文件上反复验证确认无误后才跑全量数据。核心思路是把每个块映射到内存后先找到每行城市名的起点然后用Unsafe去读多个字节做一次粗判断减少逐字节比较的次数// 伪代码示意 // 直接用UNSAFE.getLong(address offset)读8字节与城市的首8字节比较 long cmp UNSAFE.getLong(base, startAddress cityStart); if (cmp packedKey) { // 快速命中 // 再做尾部字节精确比较防止8字节边界截断 }这条路径能让哈希表查询命中时只做一次8字节长整型比较而不是逐个字节比较。实测下来哈希冲突低的情况下这个改进能让数据密集型场景再提速30%左右。再加上一个小的分支优化把温度累加合并写成无分支形式避免每条记录出现分支预测失败。最终耗时降到了约11秒。3.4 参数选择与计算过程参数选择是整个优化过程中极其重要的一环我从一开始就有意识地记录各个场景下的参数选择依据。线程数我选择线程数等于物理核心数。线程数超过核心数时上下文切换开销增长非常快尤其在内存带宽已经饱和的IO密集阶段。这台开发机是8核物理核所以我固定用8个线程。你可以用Runtime.getRuntime().availableProcessors()获取逻辑核数但在大部分机器上逻辑核包含超线程不一定更优。分块大小分块大小 文件大小 / 线程数每块约800MB左右。这个数值没有特别神秘目的是让每块足够大、减少映射次数又不至于让单个线程处理时间太长。如果你发现某一块明显比别的块慢多半是文件末尾不满一块、或者块边界对齐处理有偏差。哈希表初始容量1BRC总共不超过500个城市我把哈希表容量计算为“预估条目数 / 0.75”然后向上取整到2的幂最终用了1024。如果初始容量设置太小扩容时需要重新哈希期间要暂停当前线程既浪费CPU又增加代码复杂度。缓冲区大小因为走的是内存映射不需要额外的大型BufferedReader缓冲区。但如果后期尝试普通FileInputStream拷贝一般建议设置为64KB以上过小的缓冲区会放大系统调用次数。4. 性能对比与复现细节4.1 各版本跑分对比我把各轮优化的耗时整理成了表格方便直观对比。需要说明的是这个数据只代表我开发机上的实际表现你的机器、JDK版本、文件所在磁盘类型都会直接影响最终数字。版本主要手段耗时基线版BufferedReader String HashMap230秒V1并行版FileChannel 内存映射 多线程分块55秒V2对象消除版自定义哈希表 温度整数化21秒V3零拷贝版Unsafe批量读取 无分支累加11秒从数据可以看到每轮优化的收益都很大但收益来源不同。V1主要吃到了多核红利V2吃掉了GC和哈希开销V3则是压榨了最后的CPU指令级效率。4.2 开发机上的复现注意事项有几个环境因素直接影响复现效果我强烈建议跑之前先确认JDK版本至少用JDK 17建议用JDK 21。原因很简单新版JVM对代码缓存、对象布局、GC都有很大优化而且是正式版、不是预览版稳定性高。我用JDK 17验证过一轮同样的代码比JDK 8快大约5%到8%。内存参数我用-Xms8g -Xmx8g固定堆大小避免自动扩容带来的停顿。有人说可以用默认堆大小但1BRC的并行处理会把内存占用很快推上去如果你只有4GB堆很容易触发Full GC那样V2、V3的优势就完全被吞掉了。CPU频率如果开发机是笔记本一定插电测试有些笔记本在电池模式会把CPU锁在很低的频率这种时候跑分完全失真。我一开始没注意跑出来比预期慢30%后来查监控才发现频率被限制在0.8GHz。热数据13GB文件第一次读取时操作系统会把大量内存页面加载进页缓存。第一次跑和第二次跑的耗时会差很多属于正常现象。所有版本我都先跑一次“暖场”然后取第二、第三次的平均值。4.3 Agent辅助测试与回归用Agent生成代码容易难的是验证正确性。如果直接在13GB大数据上验证输出每次都要等十几秒甚至几十秒不适合做快速迭代。我设计了一套三层校验策略第一层构造小样本数据。自己写脚本生成包含10个城市、每城100条记录的小文件人工计算期望输出跑V1到V3所有版本保证输出一致。这一层能快速暴露哈希表越界、解析错误、城市名边界错位等问题。第二层官方样例对比。1BRC官方仓库提供了一个10000行的样例文件和标准答案我把生成结果与官方结果逐字节比对。这个样例覆盖了负温度、城市名长度1、长度100等边界情况很有价值。第三层全量数据多重校验。最后跑13GB全量数据时我把基线版虽然慢但逻辑简单的结果作为参照再和V3版做diff。如果两者一致基本可以确认正确性如果不一致用二分法在小样本上定位差异。Agent在这里的用法是我会把校验失败的小样本文件和当前版本代码一起丢给Agent让它推断是解析逻辑问题还是哈希冲突处理问题然后给出补丁。它定位能力很强多数情况能给到正确的方向但最终还是由我亲自改代码。5. 常见问题与排查技巧实录5.1 线程数增加反而变慢我最初用了16个线程以为并行度越高越好结果跑出来的耗时比8线程时多了将近5秒。排查后发现当线程数超过物理核心数后系统开始频繁做上下文切换同时每个线程都在抢占内存带宽导致L3缓存命中率大幅下降。经验是这类数据并行任务线程数等于物理核心数通常是最优解而不是简单地等于CPU逻辑线程数。超线程在计算密集型场景里并不能提供双倍吞吐反而可能互相干扰。5.2 MappedByteBuffer映射区域过大导致异常在尝试一次性映射整个13GB文件时遇到过IOException: Map failed这是因为在32位体系或某些容器环境中单次映射的虚拟内存空间有限。解决方案很简单分块映射每块控制在1GB以内同时注意关闭上一块映射后再映射下一块。我后来用单独的FileChannel配合map(READ_ONLY, position, size)每块映射后立刻处理处理完释放这样既规避了映射上限问题也让内存占用更可控。5.3 Agent生成的哈希表出现碰撞误判Agent第一次生成的自定义哈希表只比较了哈希值没有做字节级的精确比较导致两个哈希相同的不同城市名被当成同一个key数据全错。这种错误非常隐蔽官方样例数据量小、城市名区分度高不容易触发全量数据一跑就露馅。我的排查方法是把全量输出的城市数量打印出来1BRC应该恰好是几百个固定城市如果出现数量不符基本就是哈希碰撞或key清空逻辑出问题。修正后我在哈希表查找逻辑里加入了逐字节比较同时在温度解析循环上加了一个城市名长度断言防止越界。5.4 输出平均温度格式不对齐1BRC官方要求平均温度保留一位小数比如12.3、28.5。因为我全程用整数表示10倍温度输出时需要转换成带一位小数点的字符串。这个地方最容易出现两种问题负数直接除以10丢精度以及正数忘记补小数点后面的零。一个简单的处理方式是private static String formatTenths(int value) { boolean negative value 0; if (negative) value -value; String intPart String.valueOf(value / 10); String decPart String.valueOf(value % 10); return (negative ? - : ) intPart . decPart; }每次输出都做这样的转换虽然有一点字符串分配但只在最终聚合阶段发生几百次对整体耗时的影响可以忽略。5.5 Agent优化建议的“正确性陷阱”用Agent优化时我踩过最深的坑是它时常会提出一些看起来很精妙但实际有副作用的建议。比如它建议我直接复用Unsafe的base地址跳过起始偏移量的对齐校验小数据量测试没问题但换到文件末尾的数据块时由于块内起始位置并不对齐直接偏移就可能读到块外的数据。后来我养成了一个习惯不管Agent的建议多吸引人先问三个问题——这段代码的历史上下文是什么它的边界条件在哪里它有没有破坏原有统计逻辑回答不了这三个问题就不会合入代码。这个习惯也推荐给所有准备让Agent参与优化项目的人。6. 一些值得展开的记录与心得6.1 Agent与手工优化的分工心得经过这几次实战我对Agent在性能优化项目里的定位有了新的认识。它最擅长的是“生成”而不是“判断”给它一份慢代码它能在十秒内产出三到五种不同思路的优化版本这对开阔视野很有帮助。但它并不擅长告诉你“你的CPU瓶颈到底在哪”“为什么16线程比8线程慢”这些结论需要你自己用压测工具和JFR去验证。我甚至把Agent当作一个“快速原型生成器”一次让它把V1到V3的所有版本代码都生成出来然后我自己做code review、跑基准、逐个合入。这个过程比纯手工写代码快很多也比纯信任Agent安全很多。如果你正打算拿Agent做同类项目的性能调优我建议你可以按这个模式来Agent批量出方案你逐版本验证不要跳过验证步骤。6.2 后续能扩展的方向1BRC的优化空间很大我这次做到了11秒但我知道顶尖方案已经做到2秒以内说明还有很多地方可以继续挖掘。比如改用更细粒度的并行合并、在哈希表命中时用SIMD指令做更宽的比较、甚至可以尝试用Arena分配器来管理临时结构。这些方向我可以后续慢慢试也欢迎有经验的朋友一起交流。6.3 留给自己的几个检查项每次压测前确认电源模式、后台进程、CPU频率每次改动后至少跑小样本加官方样例双重校验每次合入Agent代码前明确回答“为什么改”“边界在哪”“影响谁”保留基准数据的历史曲线出了问题可以快速回溯是哪一轮改动引入的这次1BRC Agent优化实录的整体收获比单纯抄一份最优答案要多得多。真正有价值的不是那个11秒的结果而是过程中建立起来的排查思路和优化习惯。如果你也打算在类似项目里试试Agent我的体会是让它做你的军师别让它当你的替身。
阅读完成 · 觉得有帮助?