1. 程式人生 > 其它 >Java 集合(一)List

Java 集合(一)List

在 Java 中,主要存在以下三種類型的集合:Set、List 和 Map,按照更加粗略的劃分,可以分為:Collection 和 Map,這些型別的繼承關係如下圖所示:

  • Collection 是集合 List、Set、Queue 等最基本的介面
  • Iterator 即迭代器,可以通過迭代器遍歷集合中的資料
  • Map 是對映關係的基本介面

本文將主要介紹有關 List 集合的相關內容,ArrayListLinkedList 是在實際使用中最常使用的兩種 List,因此主要介紹這兩種型別的 List

本文的 JDK 版本為 JDK 17


ArrayList

具體的使用可以參考 List

介面的相關文件,在此不做過多的介紹


初始化

ArrayList 存在三個建構函式,用於初始化 ArrayList

  • ArrayList()

    對應的原始碼如下所示:

    // 預設的空元素列表
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    
    // 實際儲存元素的陣列
    transient Object[] elementData; 
    
    public ArrayList() {
        // 呼叫次無參建構函式時,首先將元素列表指向空的列表,在之後的擴容操作中再進行進一步的替換
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
    
  • ArrayList(int)

    對應的原始碼如下所示:

    private static final Object[] EMPTY_ELEMENTDATA = {};
    
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }
    
  • ArrayList(Collection<? extend E>)

    對應的原始碼:

    public ArrayList(Collection<? extends E> c) {
        Object[] a = c.toArray();
        if ((size = a.length) != 0) {
            if (c.getClass() == ArrayList.class) {
                /* 
                	如果新增的集合和 ArrayList 相同,那麼直接修改當前的資料列表的引用
                	因此在某些情況下對於該陣列的修改會出現一些奇怪的問題,
                	因為對於當前陣列元素的修改也會導致原陣列元素的改變
                */
                elementData = a;
            } else {
                /*
                	想比較之下,複製一個數組會更加安全,缺點在於執行速度不是那麼的好
                */
                elementData = Arrays.copyOf(a, size, Object[].class);
            }
        } else {
            // 如果集合中元素的個數為 0,那麼替換為空陣列
            elementData = EMPTY_ELEMENTDATA;
        }
    }
    

新增元素

新增元素是比較重要的部分,特別是 ArrayList 的自動擴容機制

ArrayList 中存在以下三個過載函式:

  • add(E, Object[], int)
  • add(E)
  • add(int, E)

一般情況下,都會呼叫 add(E) 的過載方法完成新增元素的功能,具體的原始碼如下所示:

public boolean add(E e) {
    modCount++; // 記錄當前的陣列的修改次數,適用於併發環境下的檢測
    add(e, elementData, size);
    return true;
}

新增時呼叫過載函式 add(E, Object[], int),對應的原始碼如下所示:

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow(); // 比較關鍵的地方在這,在這裡完成自動擴容
    elementData[s] = e;
    size = s + 1;
}

add(int, E) 的目的是向陣列中指定的位置插入對應的元素,對應的原始碼如下所示:

public void add(int index, E element) {
    rangeCheckForAdd(index); // 首先,檢查插入的索引位置是否合法
    modCount++; // 記錄當前的修改次數
    final int s;
    Object[] elementData; 
    if ((s = size) == (elementData = this.elementData).length)
        elementData = grow(); // 此時的陣列長度不夠,需要進行擴容
    // 將擴容後(已經複製了原有陣列的資料)的陣列按照指定的 index 分開復制到原有陣列中
    System.arraycopy(elementData, index,
                     elementData, index + 1,
                     s - index);
    // 修改 index 位置的陣列元素
    elementData[index] = element;
    size = s + 1;
}

擴容

grow() 擴容的實現的原始碼如下所示:

private Object[] grow() {
    return grow(size + 1);
}

private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;
    if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 判斷當前愛你的陣列元素是否被修改過
        // 首先,計算擴容後的陣列的大小
        int newCapacity = ArraysSupport.newLength(oldCapacity,
                                                  minCapacity - oldCapacity,  // 最少需要增長的空間
                                                  oldCapacity >> 1           /*  每次增大的預設空間大小 ,為 0.5 倍的舊空間大小*/  );
        // 然後將建立原有的資料元素陣列的副本物件,再將資料填入到副本陣列中,完成擴容操作
        return elementData = Arrays.copyOf(elementData, newCapacity);
    } else {
        /* 
        	由於沒有被修改過,那麼直接擴容到目標大小即可,
        	DEFAULT_CAPACITY=10,這是為了提供一個最小的初始容量,以免擴容機制過於頻繁造成的效能損失
         */
        return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
    }
}

擴容時關鍵的是在於新的容量的計算,具體的原始碼如下所示:

// jdk.internal.util.ArraysSupport
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
    /* 
    	參照上文中 grow 中傳入的引數,最小大小擴容到原來陣列大小的 1.5 倍,
    	如果 minGrwoth 大於 oldCapacity,則以 minGrowth 為準
    */
    int prefLength = oldLength + Math.max(minGrowth, prefGrowth);
    // 加法操作可能會導致整形變數溢位
    if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {
        return prefLength;
    } else {
        // 如果溢位的話則呼叫 hugeLength 進行計算
        return hugeLength(oldLength, minGrowth);
    }
}

/*
	之所以最大值為 Integer.MAX_VALUE - 8 是由於有些 JVM 的實現會限制其不會達到 Integer.MAX_VALUE
*/
public static final int SOFT_MAX_ARRAY_LENGTH = Integer.MAX_VALUE - 8;

private static int hugeLength(int oldLength, int minGrowth) {
    int minLength = oldLength + minGrowth;
    if (minLength < 0) { // 這裡溢位的說明確實是沒有足夠的空間可以進行分配了
        throw new OutOfMemoryError(
            "Required array length " + oldLength + " + " + minGrowth + " is too large");
    } else if (minLength <= SOFT_MAX_ARRAY_LENGTH) { // 小於最大限制,直接使用即可
        return SOFT_MAX_ARRAY_LENGTH;
    } else { 
        //  這種情況說明在 SOFT_MAX_ARRAY_LENGTH — Integer.MAX_VALUE 之間,依舊是一個有效的 int 值
        return minLength;
    }
}

擴容完成之後,將元素直接放入到末尾位置,完成元素的插入


移除元素

ArrayList 中移除元素也存在兩個過載函式:

  • remove(int):移除指定位置的元素
  • remove(Object):移除列表中的指定元素(依據 Object 的 equals 方法)

remove(int) 方法對應的原始碼如下所示:

public E remove(int index) {
    Objects.checkIndex(index, size); // 檢查刪除的索引位置是否是有效的
    final Object[] es = elementData;

    // 獲取這個索引位置的舊有元素
    @SuppressWarnings("unchecked") E oldValue = (E) es[index];
    fastRemove(es, index); // 移除該索引位置的元素

    return oldValue;
}

比較關鍵的 fastRemove 方法對應的原始碼如下所示:

private void fastRemove(Object[] es, int i) {
    modCount++;
    final int newSize;
    // 直接呼叫 native 方法將 index 後的元素向前移動一個位置
    if ((newSize = size - 1) > i)
        System.arraycopy(es, i + 1, es, i, newSize - i);
    // 最後一個位置現在已經是無效的,設定為 null 幫助 gc
    es[size = newSize] = null;
}

remove(Object) 對應的原始碼如下所示:

public boolean remove(Object o) {
    final Object[] es = elementData;
    final int size = this.size;
    int i = 0;
    /* 
    	這一步的目的是找到對應的元素位置的索引
    	可以看到,在找到第一個元素後就不會繼續向後找了,
    	因此使用該方法是需要注意這一點
     */
    found: {
        if (o == null) {
            for (; i < size; i++)
                if (es[i] == null)
                    break found;
        } else {
            for (; i < size; i++)
                if (o.equals(es[i]))
                    break found;
        }
        return false;
    }
    // 同 remove(int) 的移除元素一致
    fastRemove(es, i);
    return true;
}

值得注意的是,在實際使用的過程中,由於 Java “自動裝箱/拆箱” 機制的存在,如果此時恰好列表的元素型別為 Integer,那麼在呼叫 remove 方法時將會自動完成拆箱呼叫 remove(int) 過載方法。可以顯示地通過傳入 Integer 物件來避免自動拆箱錯誤地呼叫 remove(int),如 list.remove(Integer.valueOf(1)) 就會正確地呼叫 remove(object ) 方法


收縮列表

由於擴容機制的存在,因此出現列表很長,但是資料元素不多的情況是可能的。ArrayList 並不會自動呼叫收縮列表的方法來收縮列表的長度,但是我們自己可以顯示地通過呼叫 trimToSize 方法來收縮列表。

trimToSize 對應的原始碼如下所示:

public void trimToSize() {
    modCount++;
    if (size < elementData.length) {
        elementData = (size == 0)
            ? EMPTY_ELEMENTDATA
            : Arrays.copyOf(elementData, size);
    }
}

LinkedList

除了 ArrayList 之外,對於 List 介面的實現類,LinkedList 可能是使用得比較多的實現類。和 ArrayList 的實現不同,LinkedList 的底層資料結構是雙向連結串列,具體的節點的定義如下所示:

private static class Node<E> {
    E item; // 實際資料元素
    Node<E> next; // 後繼節點指標
    Node<E> prev; // 前驅節點指標

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

新增元素

同樣德,從 List 介面過來的兩個過載方法:

  • add(E):新增一個元素到列表的末尾
  • add(int, E):新增一個元素到指定的索引位置

其中, add(E) 對應的原始碼如下所示:

public boolean add(E e) {
    linkLast(e);
    return true;
}

// 連結元素到連結串列的末尾
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

這個就是簡單的雙向連結串列的插入操作,不做過多的介紹。


add(int, E) 對應的原始碼如下所示:

public void add(int index, E element) {
    checkPositionIndex(index); // 首先,檢查插入的索引位置是否合法

    // 如果插入的位置就是在末尾,那麼直接連結到末尾即可
    if (index == size)
        linkLast(element);
    else
        linkBefore(element, node(index));
}

比較關鍵的是確定插入位置的元素節點,即 node(index) 方法的實現,對應的原始碼如下所示:

Node<E> node(int index) {
    // 如果索引小於列表長度的一般,則從頭開始查詢位置節點
    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        // 否則,從尾部節點開始,向前查詢到對應的位置節點
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

最後,將對應的元素插入到找到的元素節點之前即可完成該項操作


移除元素

依舊是從 List 介面帶過來的過載方法:

  • remove(int):移除指定位置的元素
  • remove(Object):移除第一個出現的指定元素

remove(int) 對應的原始碼如下所示:

public E remove(int index) {
    checkElementIndex(index); // 檢查索引位置是否合法
    /*
    	首先通過 node(index) 方法找到對應的索引位置的節點,
    	然後移除它的連結即可(注意頭節點和尾節點的變化)
    */
    return unlink(node(index));
}

E unlink(Node<E> x) {
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;
    
    // 移除前驅節點,注意前驅節點為 null 的情況
    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }
    
    // 移除後繼節點,注意後繼節點為 null 的情況
    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }

    x.item = null; // 設定為 null 方便 gc
    size--;
    modCount++;
    return element;
}

remove(Object) 對應的原始碼如下所示:

public boolean remove(Object o) {
    /* 	
    	簡單來講就是遍歷整個連結串列,找到要移除的元素,
    	然後取消它在連結串列中的連結即可
    	
    	unlink 方法在上文有所介紹
    */
    if (o == null) {
        for (Node<E> x = first; x != null; x = x.next) {
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        for (Node<E> x = first; x != null; x = x.next) {
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}

兩者的比較

在實際使用中,ArrayList 的使用次數要比 LinkedList 的次數要高。這是由於使用 List 更加傾向於儲存資料,然後獲取資料的情況要多一些。

其實,在教科書上會提及連結串列和陣列的比較,諸如:連結串列使用了額外的空間來維護節點的前後指標、插入和刪除都在常數的時間複雜度內完成。但是實際上,這兩者的操作效能不會有太大的區別,甚至可能 LinkedList 的插入操作的效能還不及 ArrayList(因為要找到插入的位置節點);同樣的,ArrayList 對於空間的利用率在某些情況下可能還沒有 LinkedList 高,這是由於擴容時預設會擴容到原來的 1.5 倍,有時那 0.5 倍的額外空間是完全沒有被使用的。

按照本人的使用情況,一般在選取 List 的實現時會採用 ArrayList 作為具體的實現類,因為 ArrayList 在轉換為陣列的這個過程會比 LinkedList 更加便捷。而 LinkedList 不僅僅實現了 List 介面,而且還實現了 QueueDeque 等其它的介面,因此 LinkedList 一般會作為這些介面的預設實現類來使用