什么是1BRC
编写一个 Java 程序,从文本文件中检索温度测量值并计算每个气象站的最小、平均和最大温度。但有一个警告:该文件有 1,000,000,000 行!
在Java21的基础上,各方大佬使用各种手段来压缩这个执行时间,包括但不限于并发,JVM,使用自定义的函数来代替系统函数.
这对我有什么用?
- 如果你是一个JVM的初学者,苦于没有场景来实践这些,1RBC机是一个对你来说完美的项目,你可以在这里观察GC,利用火焰图分析程序运行情况,逐步的优化代码.
- 如果你是一个性能调优达人,你可以在这个挑战中竭尽全力,通过自己的聪明才智和学习他人的优点来提升自己.
这些思路是你自己想到的么?
不,我只是一个Java的初学者,这些都是### Marko Topolnik大神参与这个项目之后分享的解决思路,他发表在questdb这篇博客上,我只是在他的代码上添加了一些学习时的注释,并希望可以对正在阅读的人提供一些绵薄的帮助.
想在本地运行这个代码?
可以的,我将它放到了我的gitee仓库上,gitee,包含生成测试文件的代码和优化步骤.
第一步(基线版本)
这个版本的解决思路就是官方提供的最基础版本的解决思路
public void execute() throws Exception {
// 这一行作用是读取文件,最后得到的是一个HashMap<String,DoubleSummaryStatistics>
var allStats = new BufferedReader(new FileReader("measurements.txt"))
// 将每一行都转化为一个字符串,整体就是一个数组
.lines()
// 并发处理,,因为后面采用的使用stream自身的转化map,所以没有线程安全问题
.parallel()
// 收集结果
.collect(
// 根据每一行的格式“城市;温度”,将城市名称作为key
groupingBy(line -> line.substring(0, line.indexOf(';')),
// 后续收集,将string转化为double,并且统计出来,并返回一个DoubleSummaryStatistics
summarizingDouble(line ->
parseDouble(line.substring(line.indexOf(';') + 1)))));
// 这一行代码经过测试使用stream和stream.parallel的执行结果相同,但是在十亿的数据量下并没发现并发有提高,分别为120,650和124415(并发)毫秒
// 使用并发有的时候还会导致时间变慢
// 这里的推测是因为allStats的key大概只有500左右,所以并发没有意义
// ps: 大佬就是大佬.连parallel用的都这么厉害,我什么时候才能达到这种水平呀(╯‵□′)╯︵┻━┻
var result = allStats.entrySet().stream().parallel().collect(Collectors.toMap(
Map.Entry::getKey,
e -> {
var stats = e.getValue();
return String.format("%.1f/%.1f/%.1f",
stats.getMin(), stats.getAverage(), stats.getMax());
},
(l, r) -> r,
// 采用TreeMap保证有序
TreeMap::new));
System.out.println(result);
}
火焰图:
存在的问题:
- 特别多的GC,本地测试在火焰图中查看有41.88%的CPU时间都在处理GC;
- 只有一个线程在读取文件;
第二步(优化GC和多线程读取文件)
这个版本采用多线程的方式读取文件,并且没有直接将一行数据转化成String在进行拆分城市和温度,降低了无用String的生成数量
public void execute() throws Exception {
final File file = new File("./measurements.txt");
final long length = file.length();
// 根据电脑的CPU数量来决定将文件划分为多少块
final int chunkCount = Runtime.getRuntime().availableProcessors();
final var results = new StationStats[chunkCount][];
final var chunkStartOffsets = new long[chunkCount];
// 以只读的方式打开文件,并且利用了try的自动close特性
try (var raf = new RandomAccessFile(file, "r")) {
for (int i = 1; i < chunkStartOffsets.length; i++) {
// 定义每个块读取的位置
var start = length * i / chunkStartOffsets.length;
// 移动文件指针到这里
raf.seek(start);
// 这一行的作用是指针指向的位置有大概率不是换行符,移动到换行符
while (raf.read() != (byte)'\n') {
}
// 当前指针位置已经指向新的一行开始位置了
start = raf.getFilePointer();
// 特意不管数组下标为0的long,因为按照这个逻辑会从第二行开始读取
chunkStartOffsets[i] = start;
}
// 将这个文件整体以只读的方式映射到内存中
final var mappedFile = raf.getChannel().map(MapMode.READ_ONLY, 0, length, Arena.global());
// 显示创建线程是因为不需要线程池管理他,他会一直使用并且不会被回收
var threads = new Thread[chunkCount];
// 创建线程开始执行程序
for (int i = 0; i < chunkCount; i++) {
final long chunkStart = chunkStartOffsets[i];
// 确定每个线程读取的末尾位置
final long chunkLimit = (i + 1 < chunkCount) ? chunkStartOffsets[i + 1] : length;
threads[i] = new Thread(new ChunkProcessor(
// 将上面的内存文件分片交给每一个线程
mappedFile.asSlice(chunkStart, chunkLimit - chunkStart), results, i));
}
for (var thread : threads) {
thread.start();
}
for (var thread : threads) {
thread.join();
}
}
var totalsMap = new TreeMap<String, StationStats>();
for (var statsArray : results) {
for (var stats : statsArray) {
totalsMap.merge(stats.name, stats, (old, curr) -> {
old.count += curr.count;
old.sum += curr.sum;
old.min = Math.min(old.min, curr.min);
old.max = Math.max(old.max, curr.max);
return old;
});
}
}
System.out.println(totalsMap);
}
private static class ChunkProcessor implements Runnable {
private final MemorySegment chunk;
private final StationStats[][] results;
private final int myIndex;
private final Map<String, StationStats> statsMap = new HashMap<>();
ChunkProcessor(MemorySegment chunk, StationStats[][] results, int myIndex) {
this.chunk = chunk;
this.results = results;
this.myIndex = myIndex;
}
@Override
public void run() {
for (var cursor = 0L; cursor < chunk.byteSize(); ) {
// 使用游标逐个读取每一个字符,找到最近的分号和换行符
var semicolonPos = findByte(cursor, ';');
var newlinePos = findByte(semicolonPos + 1, '\n');
var name = stringAt(cursor, semicolonPos);
var temp = Double.parseDouble(stringAt(semicolonPos + 1, newlinePos));
// 这里将数据转化成了整数,方便之后的计算
var intTemp = (int)Math.round(10 * temp);
var stats = statsMap.computeIfAbsent(name, k -> new StationStats(name));
stats.sum += intTemp;
stats.count++;
stats.min = Math.min(stats.min, intTemp);
stats.max = Math.max(stats.max, intTemp);
// 修改游标地址
cursor = newlinePos + 1;
}
// 这个就是该组的全部数据,之后只需要跟其他组相比较就好
results[myIndex] = statsMap.values().toArray(StationStats[]::new);
}
/**
* chunk按照偏移量依次读取,找到指定字符
* @param cursor
* @param b
* @return
*/
private long findByte(long cursor, int b) {
for (var i = cursor; i < chunk.byteSize(); i++) {
if (chunk.get(JAVA_BYTE, i) == b) {
return i;
}
}
throw new RuntimeException(((char)b) + " not found");
}
/**
* 按照其实位置将一定长度的字符串读取出来
* @param start
* @param limit
* @return
*/
private String stringAt(long start, long limit) {
return new String(
chunk.asSlice(start, limit - start).toArray(JAVA_BYTE),
StandardCharsets.UTF_8
);
}
}
/**
* 类似DoubleSummaryStatistics,对获取到的数据进行存储
*/
static class StationStats implements Comparable<StationStats> {
String name;
long sum;
int count;
int min;
int max;
StationStats(String name) {
this.name = name;
}
@Override
public String toString() {
return String.format("%.1f/%.1f/%.1f", min / 10.0, Math.round((double)sum / count) / 10.0, max / 10.0);
}
@Override
public boolean equals(Object that) {
return that.getClass() == StationStats.class && ((StationStats)that).name.equals(this.name);
}
@Override
public int compareTo(StationStats that) {
return name.compareTo(that.name);
}
}
火焰图:
在火焰图中可以看到:
- Double.parseDouble(15.89%),
- HashMap.computIfAbsent(14.72%)
- ChunkProcessor.findByte(26.07%)
- ChunkProcessor.stringAt(33.17%)
这四个方法占用了几乎全部CPU的时间,接下来的目标就是优化这四个方法.
第三步(采用自定义函数代替Double.parseDouble)
这个版本采用自定义的函数代替了SDK中的Double.parseDouble
第一个修改点:
public void run() {
for (var cursor = 0L; cursor < chunk.byteSize(); ) {
// 使用游标逐个读取每一个字符,找到最近的分号和换行符
var semicolonPos = findByte(cursor, ';');
var newlinePos = findByte(semicolonPos + 1, '\n');
var name = stringAt(cursor, semicolonPos);
// 原来的方式:
//var temp = Double.parseDouble(stringAt(semicolonPos + 1, newlinePos));
//// 这里将数据转化成了整数,方便之后的计算
//var intTemp = (int)Math.round(10 * temp);
// 修改后的方式:
var intTemp = parseTemperature(semicolonPos);
var stats = statsMap.computeIfAbsent(name, k -> new StationStats(name));
stats.sum += intTemp;
stats.count++;
stats.min = Math.min(stats.min, intTemp);
stats.max = Math.max(stats.max, intTemp);
// 修改游标地址
cursor = newlinePos + 1;
}
// 这个就是该组的全部数据,之后只需要跟其他组相比较就好
results[myIndex] = statsMap.values().toArray(StationStats[]::new);
}
添加的新方法:
private int parseTemperature(long semicolonPos) {
long off = semicolonPos + 1;
int sign = 1;
byte b = chunk.get(JAVA_BYTE, off++);
// 处理负数
if (b == '-') {
sign = -1;
b = chunk.get(JAVA_BYTE, off++);
}
// 因为char里面的编码顺序都是递增的,所以用这个去减就能获得对应的数字了
int temp = b - '0';
b = chunk.get(JAVA_BYTE, off++);
if (b != '.') {
//
temp = 10 * temp + b - '0';
// 温度在条件里面最多两位,所以这里直接跳过,因为下一位就是小数点
// we found two integer digits. The next char is definitely '.', skip it:
off++;
}
b = chunk.get(JAVA_BYTE, off);
temp = 10 * temp + b - '0';
return sign * temp;
}
火焰图:
到这里火焰图变成:
- ChunkProcessor.parseTemperature(5.32%),
- HashMap.computIfAbsent(19.66%)
- ChunkProcessor.findByte(37.27%)
- ChunkProcessor.stringAt(24.87%)
未完待续
接下来的优化手段是通过重写HashMap来减少HashMap.computIfAbsent时间,我还需要在学一学才能继续完善这篇文章