一、前言
賽題官網: 阿里雲第三屆數據庫大賽 - 性能挑戰賽
今年的數據庫比賽可謂異常激烈,原定 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 次
- 查詢結果正確的前提下,耗時越低排名越高
格式類似於:
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_1
及query_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 < threadNum; 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 < threadNum; 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 的官網,有興趣的同學可以去了解下,本文不再展開
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 < length; 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)
來計算,自然無可厚非,但如果我們已經知曉123
中1
已處於百位的位置、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()
排序方法,此方法是直接進行快排的嗎?答案是否定的,該方法真實的邏輯為:
但是如果想利用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 < p) {
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] < v) {
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],所以我們接下來的工作就是尋找目標數組落在這個區域以及小於這個區域的數量了,假定統計的結果如下:
所以我們可以非常確定目標數據一定落在[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);
那如何繞過這個蹩腳操作呢?同樣翻看源碼便可發現,其控制寫入、讀取是通過兩個關鍵變量address
、capacity
:一個是當前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
![]() |
![]() |
![]() |
![]() |
![]() |