有一个10亿行的文本文件,每一行记录一个气象站的温度值,气象站和温度用分号分隔,温度值保留一位小数,就像下面这样。
Djibouti;17.6Kuopio;1.1Marseille;10.7Cracow;0.2
要求编写一个 Java 程序,读取以上的十亿行文本(这个文本文件大小大概 14G 左右),计算每个气象站的最低、平均和最高温度值,并输出最终结果,结果按气象站名称的字母顺序排序,每个气象站的结果值在格式 <最小值>/<平均值>/<最高值>,四舍五入到一位小数,如下所示:
{Abha=-23.0/18.0/59.2, Abidjan=-16.2/26.0/67.3, Abéché=-10.0/29.4/69.0, Accra=-10.1/26.4/66.4, Addis Ababa=-23.7/16.0/67.0, Adelaide=-27.8/17.3/58.5, ...}
如果是你的话,你会怎样计算呢,运行时间越少越好。
这是 GitHub 上国外开发者发起的一个活动,为的是探讨现代 Java 在聚合文本文件中的 10 亿行方面能走多远。可使用Java所有可用的功能,使用 SIMD,优化 GC,或使用任何其他技巧,并创建最快的实现来解决此任务!
仓库地址:https://github.com/gunnarmorling/1brc/tree/main
发起者要求JDK的版本为 JDK21(也就是目前位置的最新版本),但是不限制是 Oracle JDK、OpenJDK或者是GraalVM。
最快的 6 秒,最慢的4分多
下面是截止到今天为止,一些挑战者实现的执行时间和版本,一共40多个实现,这是其中排在前面的和最后的三个,可以看到,最快的6秒多,最慢的是发起者给的基础版本,执行时间是4分13秒。
执行时间 |
JDK版本 |
00:06.159 |
21.0.1-graal |
00:06.532 |
21.0.1-graal |
00:07.620 |
21.0.1-open |
00:09.062 |
21.0.1-open |
00:09.338 |
21.0.1-graal |
00:10.589 |
21.0.1-graal |
00:10.613 |
21.0.1-graal |
00:11.038 |
21.0.1-open |
00:11.222 |
21.0.1-open |
00:13.277 |
21.0.1-graal |
00:13.430 |
21.0.1-open |
00:13.463 |
21.0.1-open |
00:13.615 |
21.0.1-graal |
00:13.709 |
21.0.1-open |
00:13.857 |
21.0.1-graal |
00:14.411 |
21.0.1-open |
03:16.334 |
21.0.1-open |
03:42.297 |
21.0.1-open |
04:13.449 |
(baseline) |
最慢的版本
下面是 baseline 版本,几乎没有任何优化,就是执行下面这三步:
- 一行一行的读取文本;
- 每一行按照分号进行拆分,分开气象站和温度值;
- 按照气象站进行分组,并且在每组中计算最大值、最小值和平均值;最终执行下来的时间是 4 分多钟。在我们平时的开发中,大多数时候我们都会按照这个逻辑思考,然后写代码,幸运的是,大部分场景下都没有这么大的数据量。
public static void main(String[] args) throws IOException { long before = System.currentTimeMillis(); Collector<Measurement, MeasurementAggregator, ResultRow> collector = Collector.of( MeasurementAggregator::new, (a, m) -> { a.min = Math.min(a.min, m.value); a.max = Math.max(a.max, m.value); a.sum += m.value; a.count++; }, (agg1, agg2) -> { var res = new MeasurementAggregator(); res.min = Math.min(agg1.min, agg2.min); res.max = Math.max(agg1.max, agg2.max); res.sum = agg1.sum + agg2.sum; res.count = agg1.count + agg2.count; return res; }, agg -> { return new ResultRow(agg.min, agg.sum / agg.count, agg.max); }); Map<String, ResultRow> measurements = new TreeMap<>(Files.lines(Paths.get(FILE)) .map(l -> new Measurement(l.split(";"))) .collect(groupingBy(m -> m.station(), collector))); System.out.println(measurements); System.out.println("Took: " + (System.currentTimeMillis() - before)); }
无成本改进的版本
上面的代码经过很小的改动,就能降低一半的时间,那就是把读取文件的方式改成并行的就可以了。也就是 Files.lines(Path.of(FILE)).parallel(),这一行改动几乎没有成本,JDK1.8就支持了。就是要注意的是,因为是并行的,要注意临时保存拆分结果的时候要注意并发带来的问题。说白了,parallel()就是开了多个线程,拆分后要存到一个结果集中,再通过这个结果集计算,所以这个结果集不能有资源冲突的问题,比如用 Map 存储,多线程情况下就容易产生哈希冲突的问题,所以下面这个例子用了ConcurrentMap。
这样一来呢,执行时间差不多降到了2分钟。这种实现很容易想到,不用算法,甚至于都不用自己控制多线程。
public static void main(String[] args) throws IOException { Map<String, Measurement> resultMap = Files.lines(Path.of(FILE)).parallel() .map(line -> { int separatorIndex = line.indexOf(";"); String key = line.substring(0, separatorIndex); double value = Double.parseDouble(line.substring(separatorIndex + 1)); return new AbstractMap.SimpleEntry<>(key, value); }) .collect(Collectors.toConcurrentMap( entry -> entry.getKey(), entry -> new Measurement(entry.getValue()), ((measurement1, measurement2) -> new Measurement( measurement1.count + measurement2.count, measurement1.sum + measurement2.sum, Math.min(measurement1.min, measurement2.min), Math.max(measurement1.max, measurement2.max))))); System.out.print( resultMap.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Object::toString).collect(Collectors.joining(", ", "{", "}"))); }
进阶版本
我们分析一下这个任务,看看耗时的点在哪里,然后逐点击破,就能得到差不多一个合格的进阶版本,一分钟之内,甚至30秒之内。
大文件读取
第一个问题就是大文件读取,这个文件大小有14G 那么大,啥都不干,只是从头到尾读一下文件,时间也是不短的。
针对大文件读取有道和术两方面可优化的点:
什么是道呢,那就是分治之法,不光在这里,很多场景下分治法都适用。一个线程读取是慢的,多个线程是不是就能快一些,比如4个、6个,具体的方法是根据当前机器的核心数来考虑,如果你的机器只做这一件事,那开的线程数可以等于核心数。
什么是术呢,普通的读取方式慢,可以换成非阻塞的方式(NIO),这也是 JDK1.8就已经具备了的。
两相结合,性能就能提升一大截。
中间值存取
看这个任务,首先要做的是拆分每一行数据,然后存到内存的一个容器中,上面的例子看到了都是用的Hash结构,气象站名称当做key,value 是一个数组或列表的结构。
我看到很多的性能很好的版本中都是自定义这个 Hash 结构,可以认为是精简版的 HashMap。
计算
最后就是计算了,计算没什么好说的,能优化的点不多,但是分治法同样适用,一部分一部分的计算,然后把各部分的结果合并再计算。
可以到仓库上看那些一分钟以内的版本,基本上都是这个思路。
极致优化
在这个挑战中,前三名是执行时间差不多6、7秒。
前两名用了sun.misc.Unsafe直接访问内存。这和写C++有什么区别吗,没几年C++经验谁敢这么写。
第三名用了 Vector API ,这一套API 是专门用来进行 SIMD(Single Instruction, Multiple Data)并行计算的,但是从 JDK16开始一直到 JDK21,一直还是孵化状态,还没有正式投入使用。所以,这种方式也算是使用外挂了。
关于 JDK 版本
我刚把这个仓库 pull 下来,按照要求构建项目的时候就出现了问题,问题原因也很明显,因为我本机的 JDK 版本默认还是1.8,虽然各种版本的我都装了,但也只是在 IDEA 中用,公司的主力版本还是 JDK1.8。
这属于老生常谈了,你发任你发,我用 Java 8。虽然 JDK17已经是市场占有率最多的版本了,但是在国外来看,用 Java 8 的还是占绝大多数。
另外,我们看到所有参与者中,使用 GraalVM 的占到了半数以上,不知道这些开发者中有多少将 GraalVM 作为主力 JDK 的,之所以这里有半数之多,也可能是专门针对这个场景的原因。毕竟为了提高性能,而 GraalVM 在很多方面性能是优于 JDK 默认的 HotSpot 的。
如果你对 GraalVM 还不了解,可以读一下 过两年 OpenJDK 可能就要被 GraalVM 替代了这篇文章,了解一下 GraalVM 和 JDK 的关系,GraalVM 和 JVM 的关系,GraalVM 和 HotSpot 的关系,以及如何使用 GraalVM 来进行开发。