气象站行业新闻

新闻中心

Service support

Java 十亿行数据计算,最快6秒出结果,你能吗?

 

有一个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 版本,几乎没有任何优化,就是执行下面这三步:

  1. 一行一行的读取文本;
  2. 每一行按照分号进行拆分,分开气象站和温度值;
  3. 按照气象站进行分组,并且在每组中计算最大值、最小值和平均值;最终执行下来的时间是 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 来进行开发。

来源:编辑:author发布时间:2024-03-15