常見的集合容器應當避免的坑

crossoverJie發表於2019-07-04

常見的集合容器應當避免的坑

前言

前不久幫同事一起 review 一個 job 執行緩慢的問題時發現不少朋友在擼碼實現功能時還是有需要細節不夠注意,於是便有了這篇文章。

ArrayList 踩坑

List<String> temp = new ArrayList() ;

//獲取一批資料
List<String> all = getData();
for(String str : all) {
    temp.add(str);
}

首先大家看看這段程式碼有什麼問題嘛?

其實在大部分情況下這都是沒啥問題,無非就是迴圈的往 ArrayList 中寫入資料而已。

但在特殊情況下,比如這裡的 getData() 返回資料非常巨大時後續 temp.add(str) 就會有問題了。

比如我們在 review 程式碼時發現這裡返回的資料有時會高達 2000W,這時 ArrayList 寫入的問題就凸顯出來了。

填坑指南

大家都知道 ArrayList 是由陣列實現,而資料的長度有限;需要在合適的時機對陣列擴容。

這裡以插入到尾部為例 add(E e)。

常見的集合容器應當避免的坑

ArrayList<String> temp = new ArrayList<>(2) ;
temp.add("1");
temp.add("2");
temp.add("3");

當我們初始化一個長度為 2 的 ArrayList ,並往裡邊寫入三條資料時 ArrayList 就得擴容了,也就是將之前的資料複製一份到新的陣列長度為 3 的陣列中。

常見的集合容器應當避免的坑

之所以是 3 ,是因為新的長度=原有長度 * 1.5

通過原始碼我們可以得知 ArrayList 的預設長度為 10.

常見的集合容器應當避免的坑
常見的集合容器應當避免的坑

但其實並不是在初始化的時候就建立了 DEFAULT_CAPACITY = 10 的陣列。

常見的集合容器應當避免的坑

而是在往裡邊 add 第一個資料的時候會擴容到 10.

既然知道了預設的長度為 10 ,那說明後續一旦寫入到第九個元素的時候就會擴容為 10*1.5 =15
這一步為陣列複製,也就是要重新開闢一塊新的記憶體空間存放這 15 個陣列。

一旦我們頻繁且數量巨大的進行寫入時就會導致許多的陣列複製,這個效率是極低的。

但如果我們提前預知了可能會寫入多少條資料時就可以提前避免這個問題。

比如我們往裡邊寫入 1000W 條資料,在初始化的時候就給定陣列長度與用預設 10 的長度之間效能是差距巨大的。

我用 JMH 基準測試驗證如下:

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public class CollectionsTest {

    private static final int TEN_MILLION = 10000000;

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void arrayList() {

        List<String> array = new ArrayList<>();

        for (int i = 0; i < TEN_MILLION; i++) {
            array.add("123");
        }

    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void arrayListSize() {
        List<String> array = new ArrayList<>(TEN_MILLION);

        for (int i = 0; i < TEN_MILLION; i++) {
            array.add("123");
        }

    }


    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(CollectionsTest.class.getSimpleName())
                .forks(1)
                .build();


        new Runner(opt).run();
    }
}

常見的集合容器應當避免的坑

根據結果可以看出預設長度的效率會比用預設的效率高上很多(這裡的 Score 指執行完函式所消耗的時間)。

所以這裡強烈建議大家:在有大量資料寫入 ArrayList 時,一定要初始化指定長度。


再一個是一定要慎用 add(int index, E element) 向指定位置寫入資料。

常見的集合容器應當避免的坑

通過原始碼我們可以看出,每一次寫入都會將 index 後的資料往後移動一遍,其實本質也是要複製陣列;

但區別於往常規的往陣列尾部寫入資料,它每次都會進行陣列複製,效率極低。

LinkedList

提到 ArrayList 就不得不聊下 LinkedList 這個孿生兄弟;雖說都是 List 的容器,但本質實現卻完全不同。

常見的集合容器應當避免的坑

LinkedList 是由連結串列組成,每個節點又有頭尾兩個節點分別引用了前後兩個節點;因此它也是一個雙向連結串列。

所以理論上來說它的寫入非常高效,將不會有 ArrayList 中效率極低的陣列複製,每次只需要移動指標即可。

這裡偷懶就不畫圖了,大家自行腦補下。

對比測試

坊間一直流傳:

LinkedList 的寫入效率高於 ArrayList,所以在寫大於讀的時候非常適用於 LinkedList 。

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void linkedList() {
        List<String> array = new LinkedList<>();

        for (int i = 0; i < TEN_MILLION; i++) {
            array.add("123");
        }

    }

常見的集合容器應當避免的坑

這裡測試看下結論是否符合;同樣的也是對 LinkedList 寫入 1000W 次資料,通過結果來看初始化陣列長度的 ArrayList 效率明顯是要高於 LinkedList

但這裡的前提是要提前預設 ArrayList 的陣列長度,避免陣列擴容,這樣 ArrayList 的寫入效率是非常高的,而 LinkedList 的雖然不需要複製記憶體,但卻需要建立物件,變換指標等操作。

而查詢就不用多說了,ArrayList 可以支援下標隨機訪問,效率非常高。

LinkedList 由於底層不是陣列,不支援通過下標訪問,而是需要根據查詢 index 所在的位置來判斷是從頭還是從尾進行遍歷。

常見的集合容器應當避免的坑

但不管是哪種都得需要移動指標來一個個遍歷,特別是 index 靠近中間位置時將會非常慢。

總結

高效能應用都是從小細節一點點堆砌起來的,就如這裡提到的 ArrayList 的坑一樣,日常使用沒啥大問題,一旦資料量起來所有的小問題都會成為大問題。

所以再總結下:

  • 再使用 ArrayList 時如果能提前預測到資料量大小,比較大時一定要指定其長度。
  • 儘可能避免使用 add(index,e) api,會導致複製陣列,降低效率。
  • 再額外提一點,我們常用的另一個 Map 容器 HashMap 也是推薦要初始化長度從而避免擴容。

本文所有測試程式碼:

https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/basic/CollectionsTest.java

你的點贊與分享是對我最大的支援

相關文章