热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

《阿里云第三届数据库性能挑战赛》分享

一、前言赛题官网:阿里云第三届数据库大赛-性能挑战赛今年的数据库比赛可谓异常激烈,原定2021年07月02日~2021年08月06日的复赛,因为主办方原因被延期至2021-08-2

一、前言

赛题官网: 阿里云第三届数据库大赛 - 性能挑战赛

今年的数据库比赛可谓异常激烈,原定 2021年07月02日 ~ 2021年08月06日 的复赛,因为主办方原因被延期至 2021-08-20,而前排的分数相差都在秒、半秒、甚至毫秒级,“卷”的程度可见一斑

一般这种限定Java语言的比赛,鄙人都是会义无反顾参与的,在享受比赛的期间,更可以提高自身技术,何乐而不为呢?国际惯例,先报下本次比赛成绩哈



























赛段排名
预热塞3
第一赛季2
第二赛季6
决赛答辩季军

季军的奖励是人民币1万块钱,决赛答辩环节也是激烈异常,文末附上决赛期间的一些图片


二、赛制介绍

具体赛制规则大家可查看官网介绍

此处我简单描述下大规则



  • 第一赛季 2021年5月17日 ~ 2021年6月30日(含预热赛)

  • 第二赛季 2021年7月02日 ~ 2021年8月20日

5月17日至8月20日,比赛历经3个月,可谓旷日持久


三、赛题介绍

语言限定



  • Java

  • 只能使用 JDK 8 标准库

赛题本身描述比较简洁,简言之就是给你一堆数,排序后返回第K大值


3.1、第一赛季(初赛)



  • 选手需要设计实现 quantile 分析函数,导入指定的数据,并回答若干次 quantile 查询

  • 实现 load 和 quantile 接口。load 接口会先被调用,负责加载测试数据(将提供选手一块高性能磁盘存储处理好的数据);quantile 接口在 load 后调用,负责处理查询

  • 可用资源 4核 4G

  • 测试数据:只有一张表 lineitem,只有两列 L_ORDERKEY (bigint], L_PARTKEY (bigint),数据量 3亿行

  • 初赛会单线程查询 10 次

  • 查询结果正确的前提下,耗时越低排名越高

格式类似于:

MacDown logo


3.2、第二赛季(复赛)

复赛在初赛的基础上增加持久化和高并发要求



  • 可用资源 8核 8G

  • 测试数据:多张表,多列,数据量 10亿行

  • 复赛会用多个线程并发查询若干次

  • 复赛查询会分两轮,先并发查询一轮,然后kill掉进程,然后重启,再并发查询一轮

  • 查询结果正确的前提下,耗时越低排名越高

说明:因复赛是初赛的升级版,所以后续论述主要针对复赛展开,其中会掺杂一些初赛的历程


四、解题

4.1、大思路

我们最终是需要将数据排序,并返回K大值的,但面对的源文件达74G,即便全部数据都用字节存储,也有30G之多,而评测机的内存只有8G,明显不可能将全部数据放入内存后再排序。那为了解决此问题,比较容易想到的一个点便是:多part快排,整体归并的思路


4.1.1、局部快排、整体多路归并

假定我们启动8个线程,每个线程一次读取4M的数据,那程序完全可以将4M的数据进行快排后落盘,等全部数据读取完毕后,我们便积累了多个但有序的数据块,然后再将这些数据块进行多线程归并排序

排序

当数据全部有序后,莫说查询4000次,即便是查询4000万次,查询模块的性能也能达到最优;但此方案的劣势也相当明显,全量排序需要消耗大量的cpu,最终的瓶颈很有可能由IO转移到cpu排序上,经过小数据量的benchmark,该方案很快被摒弃


4.1.2、分桶

既然数据量巨大,我们为什么不采用分桶排序呢?将全量数据拆分成N个桶,每个桶内的数据都可以被直接加载至内存,一次性排序完毕(目标数据是30G,假定我们分1024桶的话,每个桶的数据量仅有30M左右);当所有分桶数据都排序完成,那全量数据自然也是全量有序的了。但某个分桶内的数据只有将全量数据读取完毕后,才能确定,所以我们必须要经历:读->分桶(不排序)->落盘->读取分桶全量数据->排序->落盘排序后数据

分桶初始方案

选择分桶方案后,我们发现方案的实操性变得可控了,但上述方案同时也存在明显的不足:那就是频繁的IO。数据被读取、写入、再读取、再写入。


4.1.3、分桶2.0

我们仔细分析一下便发现,虽然步骤繁琐,但是貌似每一步都必不可少:



  • 读取 如果不读取完整数据,就无法确定每个分桶内的数据集

  • 写入 如果不将分桶内的数据进行无序落盘,那8G的内存根本存储不了30G的目标数据

  • 再读取 如果不排序,我们在查找的时候,会无从得知该具体返回哪条记录

  • 再写入 最终的30G有序数据也一定要落盘

反复思考几次后,便发现第二阶段仅仅会查询4000次,而4000次的查询有可能不会命中所有分桶,但我们却兴师动众的将全量数据进行了全排序。例如假定我们将每一列都分成2048个桶,这样全部4列的数据,就会被分割为2048*4=8192个分桶,而在第二阶段查询的时候,也仅仅会查询4000次,这就意味着即便4000次查询每一次都命中不同的分桶,那么也至少有一半儿多的分桶没有命中。

那最后可得出结论:排序不是必须的,那什么时候排序呢?我们可以在查询阶段再对具体命中的分桶排序,何乐而不为呢?这样便可大大提高程序性能

那分桶的方案便可简化为:

分桶不排序方案

那如何确定目标数据落在哪个分桶呢?其实我们只要保证桶之间有序即可;假如我们分了4个桶,每个桶数据及范围如下:



  • bucket 0 存储1-100范围的数据,总共数量有20个

  • bucket 1 存储101-200范围的数据,总共数量有50个

  • bucket 2 存储201-300范围的数据,总共数量有35个

  • bucket 3 存储301-400范围的数据,总共数量有38个

当寻找排序为100大的数据时,其一定是落在bucket 2号分桶内,如下图所示:

定位分桶


总结:之所以最终锁定分桶且不排序的方案,是因为查询的次数太少了,为了仅仅4000次的查询,而进行全量数据的排序的方案性价比实在太低。不过我们可以思考一个问题,如果第二阶段不是查询4000次,而是查询4000万次呢?如果真是如此的话,那么我相信全量排序一定会定位成:“磨刀不误砍柴工”了



4.2、流程分析

大思路定了以后,我们再来分析下赛题。复赛给出了2个接口:



  • load

  • quantile

其实选手本质上就是实现这2个接口,把接口逻辑填充完整即可,接口的协议内容如下

public interface AnalyticDB {
void load(String tpchDataFileDir, String workspaceDir) throws Exception;
String quantile(String table, String column, double percentile) throws Exception;
}

进程会被评测程序启动2次:



  • 一、进程第一次启动,首先调用load接口,接下来调用10次quantile接口

  • 二、整个进程被 kill 掉

  • 三、进程第二次启动,首先调用load接口,接下来调用4000次quantile接口

复赛给出了2张源表的数据,每张表均为2列,每列的数据行数是10亿行,因为所有数据均为long类型,这样总共存在40亿个long值,在Java中,使用8个字节存储长整型,所以我们简单做个算式便可得出,40亿个long大约会占用40亿*8/1024/1024/1024 30G 的空间。但源文件是以字符存储的,即一个十进制的位占用一个字节,一个long如果是19位的话,就会占用19个字节,因long的值为随机生成,故源文件大小约 74G

在我们读取数据后,需要对数据进行排序等cpu操作,因内存只有8G,且进程会被kill,所以解析出来的30G数据一定需要落盘,至此我们可以描绘一下整个进程的运行轨迹

整体流程

我们简单把所有行为分为4个步骤:



  • load_1 加载源文件74G的数据,解析处理

  • query_1 10次查询

  • load_2 加载关键部分数据

  • query_2 4000次查询

由于load_2只会加载一些关键数据,且query_1只会进行10次查询,基数较小;所以耗时操作分布在load_1query_2


4.3、读

IO读取貌似没有什么可展开说的,注意一些关键点即可:



























注意点说明
1、基准测试在评测机上做IO的benchmark test,探测到启动多少线程、单次写入量多大时能打满IO
2、文件读取方式FileChannel vs MappedByteBuffer(mapp) 两者的性能做下对比,虽然通常情况下,mapp只在单次写入小数据量时才有优势,但有时不同的评测机表现得差异很大,所以针对性的比较一下还是很有必要
3、堆外内存因为JVM对于IO操作的特殊处理,在对文件进行读、写时,无论采用的是DirectByteBuffer还是HeapByteBuffer,JVM均会将数据首先拷贝至堆外内存中,所以无形中,使用HeapByteBuffer会多一次数据拷贝(其实还是JAVA垃圾回收带来的问题,在垃圾回收时,堆内存的数据地址会发生移动并重排,而native方法接收的是address以及写入大小,address变动会带来致命的问题,但总不能设定在IO操作时,不能进行GC吧。又因为堆外内存不受GC约束,所以设计者将数据主动拷贝一份至堆外,来避免垃圾回收带来的尴尬;在java doc中也标注使用者尽量使用堆外内存以提高性能,此处不再赘述
4、串行读取此处较好理解,即便是多线程读取IO,我们也要控制请求是串行访问的。因为不论是机械磁盘还是SSD,其本身的并发读写能力是非常低的,所以当我们多线程读取某个文件时,一定要控制读取姿势,保证串行读取的同时,充分利用好操作系统的 Page Cache

对于第4点,简单展开说一下。我们看以下代码:

场景一:

@Test
public void test() throws Exception {
int threadNum = 8;
int readSize = 1024 * 1024;
FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
AtomicInteger readFlag = new AtomicInteger();
Thread[] threads = new Thread[threadNum];
for (int i = 0; i threads[i] = new Thread(() -> {
try {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(readSize);
while (true) {
int blockIndex = readFlag.getAndIncrement();
int flag = fileChannel.read(byteBuffer, blockIndex * readSize);
if (flag == -1) {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}

场景二:

@Test
public void test2() throws Exception {
int threadNum = 8;
int readSize = 1024 * 1024;
FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
AtomicInteger readFlag = new AtomicInteger();
Thread[] threads = new Thread[threadNum];
for (int i = 0; i threads[i] = new Thread(() -> {
try {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(readSize);
while (true) {
synchronized (Object.class) {
int blockIndex = readFlag.getAndIncrement();
int flag = fileChannel.read(byteBuffer, blockIndex * readSize);
if (flag == -1) {
break;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}

场景二对比场景一,仅仅是在读取数据时,添加了synchronized锁,感觉性能没有场景一高。但由于操作系统的pageCache存在,场景二其实是顺序读的模式,所以真实测下来的话,场景二的性能肯定要高于场景一的

然而,正是在这个众所周知的点上,栽了一个大跟头。。。。

评测机采用的 intel 的持久内存存储介质PMEM,使得这块盘具备了并发能力,也就是说,在本次评测机上,场景一的性能是要高于场景二的。下面附上 PMEM 的官网,有兴趣的同学可以去了解下,本文不再展开

Intel傲腾持久化内存介绍


4.4、提取long


说明:此处的解析主要是指将字符存储的10进制数据,转换为字节存储


这个命题感觉很小,没什么值得聊得,但是不得不说,小细节中藏着大文章。


4.4.1、转换字节存储

为什么要转换为字节存储? 其实目的主要是为了存储压缩。假定现在有一个数据,内容是123,字符在存储的时候,不关心这个数据是什么类型,且只认为这是一组字符数组组成,因为目标值中,只存在0-9,10个数字,所以存储123的话,只需要3个字节

字符转换字节

那这不是好事儿吗?如果123存储在一个long类型时,需要8个byte,而现在存储123仅需要3个字节。如果仅拿123举例的话,的确是这样,但比赛的数据都是随机生成,且数据散列,绝大多数的数据都是19位,这样的话,存储一个long就需要19个字节,远大于8个字节

源数据举例

2747223341331115405,4799778556018601156
3277655512998525145,5145305521134065229
5014057769282191800,1358990770775079655
6180255258051820430,9182333839965782307

4.4.2、如何转换


方式一

比较容易想到的方式便是利用jdk进行字符串分割

String[] split = str.split(",");
long data1 = Long.parseLong(split[0]);
long data2 = Long.parseLong(split[1]);

当然这种看起来就慢的方式实在是太慢了split()parseLong()内部都是大量的计算以及各类校验,如果你真的采用这种方式解析字符的话,那可能估计得有一半儿以上的时间浪费在了这里


方式二

其实经典的将十进制转换二进制的方式便是乘10法



  • 1 如果只有1位,那么直接将其返回

  • 12 可通过1*10+2得到

  • 123 层层计算 (1*10+2)*10+3得到

  • 1234 层层计算 ((1*10+2)*10+3)*10+4得到

  • ... 以此类推

由此不难写出如下代码

for (int i = 0; i byte element = unsafe.getByte(addressTmp++);
if (element <45) {
storeData(data);
data = 0L;
} else {
data = data * 10 + (element - 48);
}
}

方式三

方式二已经很快了,难道有更快的策略吗?答案是肯定的。我们看一下方式二存在的弊端,那就是每个字节都要执行if (element <45)的判断,假定每个long为19位,40亿long的话需要进行判断的次数为40亿*19次,而在cpu优化中,if是比较耗时的。通常我们采用分支预判或者减少分支的方式,那上述逻辑如何减少分支判断呢?

我们发现一点,大多数的数字长度均为19位,比例几乎占到 90%,且最小的数字长度也 >= 11位,所以我们可以直接判断当前位置后的第20位是否为分隔符,如果是的话,那么就可以肯定这段range中的数据均为0-9,这样便可以不用分支判断

while (endAddress > addressTmp) {
byte element = unsafe.getByte(addressTmp + 19);
long tmp = 0;
if (element <45) {
for (int j = 0; j <19; j++) {
tmp = tmp * 10 + (unsafe.getByte(addressTmp++) & 15);
}
addressTmp++;
storeData(element, tmp);
} else {
for (int j = 0; j <19; j++) {
byte ele = unsafe.getByte(addressTmp++);
if (ele <45) {
storeData(ele, tmp);
break;
} else {
tmp = tmp * 10 + (ele & 15);
}
}
}
}

另外乘10法依旧还有优化的空间,例如123,如果采用data = data * 10 + (element - 48)来计算,自然无可厚非,但如果我们已经知晓1231已处于百位的位置、2处于十位、3处于个位,那么可以直接执行1*100 + 2*10 + 3的运算,这样性能会有半秒的提升


方式四

方式四是比赛结束后才找到资料,特此说明哈

因为方式一至方式三,都是面向字节的,如果我们能面向long操作,直接将一个“十进制”的long转换为二进制的,那性能不可同日而语。其主要思想是将一个long拆成2个,long1保留奇数位的字节,long2保留偶数位的字节,然后执行 long2*10 + (long1>>8)

终级乘十法

以下是本人根据论文实现的 long 值转换

@Test
public void test() {
String str = "1234567890123456789";
byte[] bytes = str.getBytes();
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder());
long long1 = byteBuffer.getLong();
long1 = transLong(long1);
long long2 = byteBuffer.getLong();
long2 = transLong(long2);
long byte1 = byteBuffer.get();
byte1 &= 0x0f;
long byte2 = byteBuffer.get();
byte2 &= 0x0f;
long byte3 = byteBuffer.get();
byte3 &= 0x0f;
long tail = byte1 * 100 + byte2 * 10 + byte3;
long res_0 = long1 * 100000000000L + long2 * 1000L + tail;
System.out.println(res_0);
}
private long transLong(long half) {
long upper = (half & 0x000f000f000f000fL) * 10;
long lower = (half & 0x0f000f000f000f00L) >> 8;
half = lower + upper;
upper = (half & 0x000000ff000000ffL) * 100;
lower = (half & 0x00ff000000ff0000L) >> 16;
half = lower + upper;
upper = (half & 0x000000000000ffffL) * 10000;
lower = (half & 0x0000ffff00000000L) >> 32;
return lower + upper;
}

有兴趣的同学可以翻阅原文连接:Faster Integer Parsing 本文不再展开


4.5、分桶


分桶思想是整个赛题的大脉络,策略好坏直接影响最终成绩



4.5.1、如何分桶

如何进行分桶呢?我们可以利用数据散列的特点,假定现在所有的数据范围是[1,1000],因为要保证桶自身是有序的,所以可以将数据分为10个桶:[1,100]、[101,200]、[201,300]、[301,400]......、[901,1000],这样就可保证第一个分桶的数据都是小于第二个分桶的,第二个分桶的数据都是小于第三个分桶的。。。

不过我们分桶时除了满足桶有序外,还要对二进制友好,最好通过简单的位移操作便可获取分桶号,所以自然我们便想到通过截取高字节的bit来确定分桶个数

如何分桶

这样带来的好处是,直接执行data >> shift便可以获取到分桶下标


4.5.2、分几个桶

既然分桶的话,就存在一个重要命题:分多少个桶合适?我们知道最终耗时是load阶段跟query阶段的加和。



  • 如果分桶数量少了,那么load阶段耗时变小,因为逻辑需要处理的分流变得简单。最极端的情况是整个程序只分一个桶,那分桶阶段的耗时将会变得忽略不计。但分桶数量变少必然导致每个分桶内的数据增多,那么查询阶段的耗时也会骤增

  • 如果分桶数量多了,其结果正好相反,即load阶段的耗时增加、query阶段的耗时减少

所以我们需要在分桶数量上寻找平衡点,此消彼长的模型一定存在一个中轴值,或者说是抛物线的最高点,来保证load阶段与query阶段的加和最小,也就是整体耗时最少。经过大量的benchmark,我的方案最终得出的结论是:2048个桶。在2048桶时,load阶段的耗时为28s左右,4000次的query阶段的耗时为3.3s左右


4.5.3、二次(多次)分桶

如果选手一上来就将总的分桶数量设置为2048,并开始进行分发、cpu计算等,那最终的成绩一定上不来:简单设想一下,40亿个long值,每一次分发都面对是一个长度是2048的数组或二维数组或数组引用,每进行一次数据分发,就加大了cpu各级缓存失效的几率,从而拖慢性能。那该如何解决此问题呢?

答案就是二次分发,或者多次分发。我们可以将一个2048长度的数组拆分为128个大分桶,每个大分桶内再拆分16个小分桶。128*16=2048,这样数据每次分流时,面对的是128或16长度的数组,大大增加cpu cache的命中率。本人亲测,这块能提升10s左右的性能

二次分桶

那多次分桶的数量是不是越多越好呢?例如我进行11次分发,这样每一次分发的数组长度可能只有2,岂不是更能提高cpu cache的命中率了吗?但多级分桶并不是比赛的银弹,它带来最直观的问题就是数据拷贝,如果真的进行了11次多级分桶,那30G的数据将会在内存中进行11次的拷贝,这个带来的后果是灾难性的,同4.5.1论述的场景类似,多级分桶跟耗时也是一个抛物线的模型,具体进行几次分桶就需要选手摸爬滚打的benchmark


4.6、寻找K大值

load阶段结束后就要进行4000次的桶内查询了


4.6.1 排序

最直观的方式当然是排序了,因为目标数据量比较大,分了2048个桶后,每个桶内的数据也有大约50万,所以直接进行快排,并返回第80位的数据arr[79]即可

此处额外提一下JDK自带的Arrays.sort()排序方法,此方法是直接进行快排的吗?答案是否定的,该方法真实的逻辑为:

jdk_Arrays_sort方法

但是如果想利用Arrays.sort()进行归并排序的话,需要注意的是,归并排序需要用到额外的数组来存放临时排序结果,而直接调用Arrays.sort()会每次都新建数组,拖慢性能,所以真有此类需求的话需留意,根据场景可将辅助数组绑定至线程上下文中


4.6.2 寻找K大值

我们冷静下来再来梳理一遍此处的需求,我们真实的需求只是想找到某个数组中的K大值,而排序的方案则是兴师动众的将全量数据都进行了一遍排序。

而寻找K大值的过程其实与快排类似,例如我们寻找第80大值,然后找到了中轴值50,最终发现,中轴值左边有70个值,右边有30个,那第80大值一定在中轴值的右侧,所以我们再针对右边的30个数重复这个操作,而左边的70个值,可以直接放弃,不用再迭代排序,贴一下代码:

/**
* 寻找K大值
* @param nums 目标数组
* @param l 左index
* @param r 右index
* @param k K大
* @return 具体值
*/
public static long solve(long[] nums, int l, int r, int k) {
if (l == r) {
return nums[l];
}
int p = partition(nums, l, r);
if (k == p) {
return nums[p];
} else if (k return solve(nums, l, p - 1, k);
} else {
return solve(nums, p + 1, r, k);
}
}
private static int partition(long[] arr, int l, int r) {
long v = arr[l];
int j = l;
for (int i = l + 1; i <= r; i++) {
if (arr[i] j++;
swap(arr, j, i);
}
}
swap(arr, l, j);
return j;
}
private static void swap(long[] arr, int a, int b) {
long temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}

4.6.3 再快一点

寻找K大值的方案已经很快了,难道还有更快的方案?

是的,此处就要利用原题意描述的数据特征了:随机生成、离散。为了叙述方便,我们简化一下模型:随机给定100个数,这些数据的范围是[1-100],然后需要返回第80大的数据。这样的话我们能否对目标数据进行预测?返回第80大的数据,且数据分布是[1-100],那可以预测目标值就是80,可80大概率不是正确答案,但一定是在80左右浮动,此时我们可以设定一个浮动百分比,或者上下浮动的范围,例如:[75-85],所以我们接下来的工作就是寻找目标数组落在这个区域以及小于这个区域的数量了,假定统计的结果如下:

范围寻找K大值

所以我们可以非常确定目标数据一定落在[75-85]区间,这样只扫描一遍数据后,便将数据缩小到了很小的范围。有同学可能会问,如果扫描一遍后没有命中范围怎么办?那就重新执行寻找K大值的方法,保证程序不出错,而至于浮动范围设定为多少合适,就又是抛物线模型,寻找最高点了


4.7、写

写入操作没有太多值得分享的点,保证堆外内存写入、以及单次写入量不宜过小都是一些基本注意事项

值得一提的是,有小伙伴建议写入使用write(ByteBuffer[] srcs)的方式,其底层调用函数做了很多优化,我方案修改成此方式后,性能并没有有效提升,有兴趣的同学可以深入探索下


五、线程模型

线程模型

针对于进程内的“读-解析(cpu)-写”场景,此处提出两个线程模型



  • 1、读、cpu、写放在同一个线程中,通过增加线程来提高整体性能。这样做的好处是减少线程交互的开销,降低内耗,适用于大多数的场景

  • 2、在进程内,将不同的操作交由不同的线程池分别处理,虽然可能增加线程交互的内耗,不过在特定的场景下对提高性能可以起到正向优化

两个线程模型各有优劣,很难明确地说哪种模型更好,不同的场景表现差异较大,所以本人的结论还是那句亘古不变的话:Benchmark Everything


六、其他优化

6.1、压缩

随机生成的离散数据如何压缩呢?其实倒也不难想到,因为我们已经将数据前N个bit提取出来作为分桶编号了,所以这N个bit都是重复数据,例如想压缩掉高位的8个bit(1个字节)的话,可以有2种方式:

writeBuffer.putLong(index, data);
index += 7;

或者

writeBuffer.put((byte) ((data1 <<8 >>> 56)));
writeBuffer.putInt((int) (data1 <<16 >>> 32));
writeBuffer.putShort((short) (data1));

6.2、优化开辟空间


6.2.1 堆内存

有时候数组开辟空间的耗时也将会是一个很大的提升点,可以通过多线程并发开辟空间的方式,来提供性能。为什么数组开辟这么耗时?不就是申请一段连续的内存空间吗?其实本身申请内存空间不耗时,但内存申请完毕后,会对数组内全部数据有个赋0操作,而这个操作本身是相当耗时的


6.2.2 堆外内存(直接内存)

当我们想申请堆外内存DirectByteBuffer时,发现速度也相当慢,翻看其源码便能发觉其本身开辟空间时,同样存在赋0操作

long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
// 赋0操作
unsafe.setMemory(base, size, (byte) 0);

那如何绕过这个蹩脚操作呢?同样翻看源码便可发现,其控制写入、读取是通过两个关键变量addresscapacity:一个是当前buffer的内存地址,一个是buffer的长度,我们是否可以通过反射瞒天过海呢?以下贴上源码

private static Field addr;
private static Field capacity;
static {
try {
addr = Buffer.class.getDeclaredField("address");
addr.setAccessible(true);
capacity = Buffer.class.getDeclaredField("capacity");
capacity.setAccessible(true);
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
public static ByteBuffer newFastByteBuffer(int cap) {
long address = unsafe.allocateMemory(cap);
ByteBuffer bb = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder());
try {
addr.setLong(bb, address);
capacity.setInt(bb, cap);
} catch (IllegalAccessException e) {
return null;
}
bb.clear();
return bb;
}

6.3 滚动读

多线程如何读取文件呢?我们获取到文件大小后,完全可以为每个线程指定读取区间,但这样可能会造成木桶效应,即程序的耗时取决于最慢线程的耗时,同时可能带来评测程序的不稳定

理想的情况是滚动读,多个线程一起来消费数据;但如果一次读取的数据量不够大,可能执行滚动的cas操作会消耗较多的cpu,简单直接的解决方式是一次标记一段数据,减少线程见的争抢

private int tmpBlockIndex = -1;
private int threadReadData() throws Exception {
if (tmpBlockIndex == -1) {
tmpBlockIndex = number.getAndAdd(cpuThreadNum);
} else {
if ((tmpBlockIndex + 1) % cpuThreadNum == 0) {
tmpBlockIndex = number.getAndAdd(cpuThreadNum);
} else {
tmpBlockIndex++;
}
}
int indexNum = tmpBlockIndex;
}

还有很多小的cpu优化不能穷举,有兴趣同学可以参看源码


七、致谢

首先给本次adb比赛点个大大的赞,不论是初赛还是复赛,本次比赛没有修改过题目描述、没有私自换过评测数据、排行榜没有清空、更没有给选手留下漏洞,是我近几年参赛中最干净、纯粹的赛题了;其他赛道或将来的比赛应该向人家学习

其次整个比赛期间,真心感谢身边小伙伴@振兴、@满仓、@新然、@笳鑫的鼎力协助 [抱拳]

源码地址: git@github.com:xijiu/tianchi-2021-db-contest.git











图片替换文本

图片替换文本

图片替换文本

图片替换文本

图片替换文本


推荐阅读
  • Android中高级面试必知必会,积累总结
    本文介绍了Android中高级面试的必知必会内容,并总结了相关经验。文章指出,如今的Android市场对开发人员的要求更高,需要更专业的人才。同时,文章还给出了针对Android岗位的职责和要求,并提供了简历突出的建议。 ... [详细]
  • XML介绍与使用的概述及标签规则
    本文介绍了XML的基本概念和用途,包括XML的可扩展性和标签的自定义特性。同时还详细解释了XML标签的规则,包括标签的尖括号和合法标识符的组成,标签必须成对出现的原则以及特殊标签的使用方法。通过本文的阅读,读者可以对XML的基本知识有一个全面的了解。 ... [详细]
  • HTML学习02 图像标签的使用和属性
    本文介绍了HTML中图像标签的使用和属性,包括定义图像、定义图像地图、使用源属性和替换文本属性。同时提供了相关实例和注意事项,帮助读者更好地理解和应用图像标签。 ... [详细]
  • Android源码深入理解JNI技术的概述和应用
    本文介绍了Android源码中的JNI技术,包括概述和应用。JNI是Java Native Interface的缩写,是一种技术,可以实现Java程序调用Native语言写的函数,以及Native程序调用Java层的函数。在Android平台上,JNI充当了连接Java世界和Native世界的桥梁。本文通过分析Android源码中的相关文件和位置,深入探讨了JNI技术在Android开发中的重要性和应用场景。 ... [详细]
  • Java在运行已编译完成的类时,是通过java虚拟机来装载和执行的,java虚拟机通过操作系统命令JAVA_HOMEbinjava–option来启 ... [详细]
  • 本文介绍了Python高级网络编程及TCP/IP协议簇的OSI七层模型。首先简单介绍了七层模型的各层及其封装解封装过程。然后讨论了程序开发中涉及到的网络通信内容,主要包括TCP协议、UDP协议和IPV4协议。最后还介绍了socket编程、聊天socket实现、远程执行命令、上传文件、socketserver及其源码分析等相关内容。 ... [详细]
  • 在说Hibernate映射前,我们先来了解下对象关系映射ORM。ORM的实现思想就是将关系数据库中表的数据映射成对象,以对象的形式展现。这样开发人员就可以把对数据库的操作转化为对 ... [详细]
  • 基于事件驱动的并发编程及其消息通信机制的同步与异步、阻塞与非阻塞、IO模型的分类
    本文介绍了基于事件驱动的并发编程中的消息通信机制,包括同步和异步的概念及其区别,阻塞和非阻塞的状态,以及IO模型的分类。同步阻塞IO、同步非阻塞IO、异步阻塞IO和异步非阻塞IO等不同的IO模型被详细解释。这些概念和模型对于理解并发编程中的消息通信和IO操作具有重要意义。 ... [详细]
  • JVM 学习总结(三)——对象存活判定算法的两种实现
    本文介绍了垃圾收集器在回收堆内存前确定对象存活的两种算法:引用计数算法和可达性分析算法。引用计数算法通过计数器判定对象是否存活,虽然简单高效,但无法解决循环引用的问题;可达性分析算法通过判断对象是否可达来确定存活对象,是主流的Java虚拟机内存管理算法。 ... [详细]
  • sklearn数据集库中的常用数据集类型介绍
    本文介绍了sklearn数据集库中常用的数据集类型,包括玩具数据集和样本生成器。其中详细介绍了波士顿房价数据集,包含了波士顿506处房屋的13种不同特征以及房屋价格,适用于回归任务。 ... [详细]
  • Google Play推出全新的应用内评价API,帮助开发者获取更多优质用户反馈。用户每天在Google Play上发表数百万条评论,这有助于开发者了解用户喜好和改进需求。开发者可以选择在适当的时间请求用户撰写评论,以获得全面而有用的反馈。全新应用内评价功能让用户无需返回应用详情页面即可发表评论,提升用户体验。 ... [详细]
  • Python正则表达式学习记录及常用方法
    本文记录了学习Python正则表达式的过程,介绍了re模块的常用方法re.search,并解释了rawstring的作用。正则表达式是一种方便检查字符串匹配模式的工具,通过本文的学习可以掌握Python中使用正则表达式的基本方法。 ... [详细]
  • Tomcat/Jetty为何选择扩展线程池而不是使用JDK原生线程池?
    本文探讨了Tomcat和Jetty选择扩展线程池而不是使用JDK原生线程池的原因。通过比较IO密集型任务和CPU密集型任务的特点,解释了为何Tomcat和Jetty需要扩展线程池来提高并发度和任务处理速度。同时,介绍了JDK原生线程池的工作流程。 ... [详细]
  • 本文介绍了Java高并发程序设计中线程安全的概念与synchronized关键字的使用。通过一个计数器的例子,演示了多线程同时对变量进行累加操作时可能出现的问题。最终值会小于预期的原因是因为两个线程同时对变量进行写入时,其中一个线程的结果会覆盖另一个线程的结果。为了解决这个问题,可以使用synchronized关键字来保证线程安全。 ... [详细]
  • 在CentOS/RHEL 7/6,Fedora 27/26/25上安装JAVA 9的步骤和方法
    本文介绍了在CentOS/RHEL 7/6,Fedora 27/26/25上安装JAVA 9的详细步骤和方法。首先需要下载最新的Java SE Development Kit 9发行版,然后按照给出的Shell命令行方式进行安装。详细的步骤和方法请参考正文内容。 ... [详细]
author-avatar
出典mosha
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有