不可不知的可變Java長數組

前言

有時我們希望將把數據保存在單個連續的數組中,以便快速、便捷地訪問數據,但這需要調整數組大小或者對其擴展。Java 數組不能調整大小,只用數組不足以達成目標。可變長原始類型數組需要自己實現。本文將展示如何實現 Java 可變長數組。

爲什麼不用 ArrayList?

要滿足文章開頭的需求,爲什麼不使用 Java ArrayList?如果滿足下面條件之一,可以使用 ArrayList:

在數組中存儲某種對象類型;

在數組中存儲原始類型,沒有特別的性能或內存要求。

Java ArrayList 類只適用於對象,不適合原始類型(byte、int、long等)。使用 ArrayList 存儲原始類型數據,插入 ArrayList 時會自動裝箱爲對象中,從中取出時會進行拆箱轉爲原始類型。裝箱和拆箱在插入元素和訪問元素時會帶來額外開銷,在嘗試針對性能進行優化時,應該避免這些開銷(本文是 Java 性能跟蹤的一部分)。

不僅如此,這種方式無法確定自動裝箱對象在內存中的存儲位置。很可能分散存儲在堆中各個位置。因此,與原始類型數組相比訪問速度要慢得多,前者在內存中連續存儲。

另外,原始類型裝箱會帶來額外的內存開銷,例如 long 會存到 Long 對象。

本文源代碼

本文實現了 Java 變長數組源代碼可以在 GitHub 下載:

github.com/jjenkov/java-resizable-array

代碼包含三個 Java 類和兩個單元測試。

可變長數組用例

假設有一臺服務器接收大小不同的郵件。其中有些郵件很小(小於4KB),另一些很大(1MB 甚至更大)。

如果服務器同時從多個(10萬多個)連接接收消息,那麼需要爲每個消息限制預分配內存。每個緩衝區不能只按最大值(1MB或16MB)分配內存。當連接和消息數量增加時,這種方式會快速耗盡服務器內存!100_000 x 1MB = 100GB(這是估計值,幫助問題理解)。

假設大多數消息比較小,一開始可以使用較小的緩衝區。如果消息超出緩存大小,則分配一個更大的新數組,並把數據拷貝到該數組中。如果消息超出分配的新數組,接着分配一個比之前更大的數組,並把消息複製到該數組。

使用這種策略,大多數消息通常只會存入小數組。這意味着服務器內存得到了更有效的利用。100_000 x 4KB (小緩衝) = 400MB大多數服務器應該能夠正常處理。即使是 4GB (1_000_000 x 4KB),現在的服務器也能滿足要求。

可變長數組設計

可變長數組包含兩個組件:

ResizableArray

ResizableArrayBuffer

ResizableArrayBuffer 包含一個大數組。該數組被劃分爲三個部分。一段用作小數組,一段用作中數組,一段用作大數組。ResizableArray類表示一個可變長數組,底層數據存儲在ResizableArrayBuffer中。

下圖展示了數組分爲三段,每段再分爲小塊:

通過爲小、中、大不同類型數據預留空間,ResizableArrayBuffer 能夠確保不會被某種大小的數據塞滿。例如,小數據不會佔用數組的所有內存,進而阻斷中型和大數據存儲。同樣,接收大數據也不會佔用所有內存,進而阻斷小數據和中型數據存儲。

由於底層存儲以小數據開始,如果小數組存儲空間耗盡,那麼無論中數組或大數組是否還有空間,都無法分配新的數組。可以讓使小數組足夠大,減小發生這種情況的可能性。

即使小數組已經全部用完,仍然可以把小數據變成中型和大型數據。

優化方案

一種優化方案:只用一個存儲塊。需要的時候在待擴展的塊後面直接分配新塊。這樣不需要把數據從舊數組拷貝到新數組,可以直接“擴展”存儲塊容納舊數據和新數據,新數據直接寫入新增的第二個擴展塊即可。這樣避免了拷貝所有數組數據的情況。

上述優化的缺點在於,如果無法擴展下一個內存塊仍然需要拷貝數據。因此需要加入“可擴展”檢查,這個操作開銷不大。此外,如果存儲塊大小設置過小,在小數據、中等數據和大數據都存在的情況下會出現頻繁擴展。

跟蹤空閒塊

ResizableArrayBuffer 內部的大數組同樣分爲三段。每段都被分爲更小的存儲塊;每段中的存儲塊大小相同;小數組中的存儲塊大小相同;中型數組中的存儲塊大小相同;大數組中的存儲塊大小相同。

每段中的存儲塊大小相同可以更方便地追蹤塊使用狀態。可以使用隊列記錄每個塊的起始索引。還需要一個隊列記錄每段中的共享數組。最終,一個隊列來跟蹤空閒小數據塊,一個隊列用記錄空閒的中型數據塊,一個隊列用於空閒的大數據塊。

根據數據類型從響應隊列獲取下一個空閒塊起始索引,可以實現從任意數據段分配存儲塊。把起始索引放回相應隊列可以釋放數據塊。

這裏我用簡單的環形緩衝區實現隊列。GitHub 倉庫對應的代碼爲 QueueIntFlip。

擴展寫

向數組寫數據時,可變長數組自動擴展。如果嘗試向數組寫入的數據超出當前分配的存儲空間,將分配一個新的更大的存儲塊並把所有數據拷貝到新塊中,然後釋放之前較小的存儲塊。

釋放數組

一旦可變長數組完成了大小調整,應該對其進行釋放以便可以接收其他消息。

使用 ResizableArrayBuffer

下面展示如何使用 GitHub 中 ResizableArrayBuffer。

創建一個 ResizableArrayBuffer

首先,必須創建一個 ResizableArrayBuffer。示例如下:

這個例子創建的 ResizableArrayBuffer 包含一個4KB小數組,128KB中數組和1MB大數組。ResizableArrayBuffer存儲空間包含1024個小數組(共4MB)、32箇中數組(共4MB)和4個大數組(共4MB),完整的共享數組大小總計12MB。

獲取 ResizableArray 實例

要得到 ResizableArray 實例,調用 ResizableArrayBuffer的getArray() 方法,如下所示:

這裏得到一個最小的 ResizableArray(之前設置爲4KB)。

向 ResizableArray 寫數據

調用 write() 方法向 ResizableArray 寫數據。GitHub 中 ResizableArray 類只包含一個 write() 方法,其參數爲 ByteBuffer。不過,可以根據需要自行添加更多 write() 方法。

下面是寫數據示例:

上面的代碼把 ByteBuffer 內容複製到 ResizableArray 數組中。Write() 返回從 ByteBuffer 拷貝的字節數。

如果 ByteBuffer 包含的數據超出了 ResizableArray 容量,這時 ResizableArray 會嘗試擴展,爲 ByteBuffer 中的數據留出空間。如果 ResizableArray 即使擴展到最大值也無法容納 ByteBuffer 中的所有數據,則 write() 方法返回-1,並且不會複製任何數據!

從 ResizableArray 讀數據

從 ResizableArray 中讀數據時,可以直接從 ResizableArray 所有實例中直接讀取共享數組。ResizableArray 包含以下 public 字段:

sharedArray 字段對應所有 ResizableArray 實例中的共享數組,即ResizableArrayBuffer 的內部數組。

offset 字段對應共享數組的起始索引,ResizableArray 在這裏保存數據。

capacity 字段包含分配給 ResizableArray 實例中的塊大小。

length 字段包含 ResizableArray 實際使用的塊數量。

要讀取 ResizableArray 的寫入數據,只要讀取從sharedArray[offset] 到sharedArray[offset+ length -1] 的字節即可。

釋放 ResizableArray

一旦 ResizableArray 實例使用完畢應該釋放。只要在 ResizableArray 上調用 free() 方法即可,如下所示:

無論分配給 ResizableArray 塊大小如何,調用 free() 都能將使用的塊正確返還隊列。

變換設計

您可以根據自己的需要修改 ResizableArrayBuffer 設計。例如,可以在其中創建多於三個數據段。操作起來應該也很容易。

最後,如果今天的文章讓你有新的啓發,學習能力的提升上有新的認識,歡迎轉發分享給更多人。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章