JAVA 筆記(三) 從源碼深入淺出集合框架

集合框架概述

以Java來說,我們日常所做的編寫代碼的工作,其實基本上往往就是在和對象打交道。
但顯然有一個情況是,一個應用程序裏往往不會僅僅只包含數量固定生命週期都是已知的對象。
所以,就需要通過一些方式來對對象進行持有,那麼通常是通過怎麼樣的方式來持有對象呢?
通過數組是最簡單的一種方式,但其缺陷在於:數組的尺寸是固定的,即數組在初始化時就必須被定義長度,且無法改變。
也就說,通過數組來持有對象雖然能解決對象生命週期的問題,但仍然沒有解決對象數量未知的問題。
這也是集合框架出現的核心原因,因爲大多數時候,對象需要的數量都是在程序運行期根據實際情況而定的。

實際上集合框架就是Java的設計者:對常用的數據結構和算法做了一些規範(接口)和實現(具體實現接口的類),而用以對對象進行持有的。
也就是說,最簡單的來說,我們可以將集合框架理解爲:數據結構(算法)+ 對象持有。與數組相比,集合框架的特點在於:

  • 集合框架下的容器類只能存放對象類型數據;而數組支持對基本類型數據的存放。
  • 任何集合框架下的容器類,其長度都是可變的。所以不必擔心其長度的指定問題。
  • 集合框架下的不同容器類底層採用了不同的數據結構實現,而因不同的數據結構也有自身各自的特性與優劣。

而既然被稱爲框架,就自然證明它由一個個不同的“容器”結構集體而構成的一個體系。
所以,在進一步的深入瞭解之前,我們先通過一張老圖來了解一下框架結構,再分而進之。
這裏寫圖片描述

這張圖能體現最基本的“容器”分類結構,從中其實不難看到,所謂的集合框架:
主要是分爲了兩個大的體系:Collection與Map;而所有的容器類都實現了Iterator,用以進行迭代。

總的來說,集合框架的使用對於開發工作來說是很重要的一塊使用部分。
所以在本篇文章裏,我們將對各個體系的容器類的使用做常用的使用總結。
然後對ArrayList,LinkList之類的常用的容器類通過源碼解析來加深對集合框架的理解。

Collection體系

Java容器類的作用是“保存對象”,而從我們前面的結構圖也不難看到,集合框架將容器分爲了兩個體系。
體系之一就是“Collection”,其特點在於:一條存放獨立元素的序列,而其中的元素都遵循一條或多條規則。

相信你一定熟悉ArrayList的使用,而當你通過ArrayList一層層的去查看源碼的時候,就會發現:
它經歷了AbstractList → AbstractCollection → Collection這樣的一個繼承結構。
由此,我們也看見Collection接口正是位於這個體系之中的衆多容器類的根接口。這也爲什麼我們稱該體系爲“Collection體系”。

既然Collection爲根,瞭解繼承特性的你,就不難想象,Collection代表了位於該體系之下的所有類的最通性表現。
那我們自然有必要首先來查看一下,定義在Collection接口當中的方法聲明:

  • boolean add(E e) 確保此 collection 包含指定的元素(可選操作)。
  • boolean addAll(Collection< ? extends E> c) 將指定 collection 中的所有元素都添加到此 collection 中(可選操作)。
  • void clear() 移除此 collection 中的所有元素(可選操作)。
  • boolean contains(Object o) 如果此 collection 包含指定的元素,則返回 true。
  • boolean containsAll(Collection< ? > c) 如果此 collection 包含指定 collection 中的所有元素,則返回 true。
  • boolean equals(Object o) 比較此 collection 與指定對象是否相等。
  • int hashCode() 返回此 collection 的哈希碼值。
  • boolean isEmpty() 如果此 collection 不包含元素,則返回 true。
  • Iterator< E > iterator() 返回在此 collection 的元素上進行迭代的迭代器。
  • boolean remove(Object o) 從此 collection 中移除指定元素的單個實例,如果存在的話(可選操作)。
  • boolean removeAll(Collection< ? > c) 移除此 collection 中那些也包含在指定 collection 中的所有元素(可選操作)。
  • boolean retainAll(Collection< ? > c) 僅保留此 collection 中那些也包含在指定 collection 的元素(可選操作)。
  • int size() 返回此 collection 中的元素數。
  • Object[] toArray() 返回包含此 collection 中所有元素的數組。
  • < T > T[] toArray(T[] a) 返回包含此 collection 中所有元素的數組;返回數組的運行時類型與指定數組的運行時類型相同。

上述的方法也代表將被所有Collection體系之下的容器類所實現,所以不難想象它們也就代表着使用Collection體系時最常使用和最基本的方法。
所以,我們當然有必要熟練掌握它們的使用,下面我們通過一個例子來小試身手:

public class CollectionTest {

    public static void main(String[] args) {
        Collection<String> c = new ArrayList<String>();
        c.add("abc"); // 添加單個元素

        Collection<String> sub = new ArrayList<String>();
        sub.add("123");
        sub.add("456");
        c.addAll(sub); // 添加集合
        c.addAll(Arrays.asList("111", "222"));
        System.out.println("1==>" + c);

        System.out.println("2==>" + c.contains("123")); // 查看容器內是否包含元素"123"
        System.out.println("3==>" + c.containsAll(sub));// 查看容器c內是否包含容器sub內的所有元素

        System.out.println("4==>" + c.isEmpty()); // 查看容器是否爲空

        c.retainAll(sub);// 取容器c與sub的交集
        System.out.println("5==>" + c);
        c.remove("123"); // 移除單個元素
        c.removeAll(sub);// 從容器c當中移除sub內所有包含的元素

        System.out.println("6==>" + c);

        c.add("666");
        Object[] oArray = c.toArray();
        String[] sArray = c.toArray(new String[] {});
        System.out.println("7==>" + c.size() + "//" + oArray.length + "//"
                + sArray.length);

        c.clear();
        System.out.println("8==>"+c.size());

    }

}

上面演示代碼的輸出結果爲:

1==>[abc, 123, 456, 111, 222]
2==>true
3==>true
4==>false
5==>[123, 456]
6==>[]
7==>1//1//1
8==>0

到此,我們嘗試了Collection體系下的容器類的基本使用。其中還有一個很重要的方法“iterator”。
但這個方法並不是聲明在Collection接口當中,而是繼承自另一個接口Iterable。
所以,我們將它放在之後的迭代器的部分,再來看它的相關使用。

List 體系

事實上,通過對於Colleciton接口內的方法瞭解。我們已經發現,對於Collection來說:
實際上已經提供了對於對象進行添加,刪除,訪問(通過迭代器)等等一些列的基本操作。
那麼,爲什麼還要在其之下,繼續劃出一個List體系呢?通過查看源碼,你可以發現List接口同樣繼承自Colleciton接口。
由此也就不難想到,List接口是在Collection接口的基礎上,又添加了一些額外的操作方法。
而這些額外的操作方法,其核心的用途概括來說都是:在容器的中間插入和移除元素(即操作角標)

查看Java的API說明文檔,你會發現對於List接口的說明當中,會發現類似下面的幾段話:

  • List 接口在 iterator、add、remove、equals 和 hashCode 方法的協定上加了一些其他約定,超過了 Collection 接口中指定的約定。。
  • List 接口提供了特殊的迭代器,稱爲 ListIterator,除了允許 Iterator 接口提供的正常操作外,該迭代器還允許元素插入和替換,以及雙向訪問。

上面的話中,很清楚的描述了List體系與Collection接口表現出的最共性特徵之外的,自身額外的特點。
那麼,我們也可以來看一下,在List接口當中額外添加的方法:

  • void add(int index, E element) 在列表的指定位置插入指定元素(可選操作)。
  • boolean addAll(int index, Collection< ? extends E > c) 將指定 collection 中的所有元素都插入到列表中的指定位置(可選操作)。
  • E get(int index) 返回列表中指定位置的元素。
  • int indexOf(Object o) 返回此列表中第一次出現的指定元素的索引;如果此列表不包含該元素,則返回 -1。
  • int lastIndexOf(Object o) 返回此列表中最後出現的指定元素的索引;如果列表不包含此元素,則返回 -1
  • ListIterator< E > listIterator() 返回此列表元素的列表迭代器(按適當順序)。
  • ListIterator< E > listIterator(int index) 返回列表中元素的列表迭代器(按適當順序),從列表的指定位置開始。
  • E remove(int index) 移除列表中指定位置的元素(可選操作)。
  • E set(int index, E element) 用指定元素替換列表中指定位置的元素(可選操作)。
  • List< E > subList(int fromIndex, int toIndex) 返回列表中指定的 fromIndex(包括 )和 toIndex(不包括)之間的部分視圖。

由此,我們看見,List接口相對於Collction接口,進行了添加或針對某些方法進行重載得工作, 從而得到了10個新的方法
而從方法的說明當中,我們很容易發現它們存在一個共性,就是都存在着針對於角標進行操作的特點。
這也就是爲什麼我們前面說,List出現的核心用途就是:在容器的中間插入和移除元素。

從源碼解析ArrayList

前面我們已經說了不少,我們應該掌握了不少關於集合框架的內容的使用,至少了解了Collection與List體系。
但嚴格的來說,前面我們所做的都還停留在“紙上談兵”的階段。之所這麼說,是因爲我們前面說到的都是兩個接口內的東西,即沒有具體實現。
那麼,ArrayList可能是我們實際開發中絕逼會經常用到的容器類了,我們就通過這個類爲切入點,通過研究它的源碼來真正的一展拳腳。

爲了將思路儘量的理的比較清晰,我們先從該容器類的繼承結構說起,打開ArrayList的源碼,首先看到這樣的類聲明:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

我們對該容器類的聲明進行分析,其中:

  1. Cloneable接口是用於實現對象的複製;Serializable接口用於讓對象支持實現序列化。它們在這裏並非我們關心的重點,故不多加贅述。
  2. RandomAccess接口是一個比較有意思的東西,因爲打開源碼你會發現這就是一個空接口,那麼它的用意何在?
    通過註釋你其實可以很容易推斷出,這個接口的出現是爲了用來對容器類進行類型判斷,從而選擇合適的算法提高集合的循環效率的。
    通常在對List特別是Huge size的List的遍歷算法中,我們要儘量來判斷是屬於RandomAccess(如ArrayList)還是Sequence List (如LinkedList)。
    這樣做的原因在於,因爲底層不同的數據結構,造成適合RandomAccess List的遍歷算法,用在Sequence List上就差別很大。
    我們當然仍舊通過代碼來進行驗證,因爲實踐是檢驗理論的唯一標準:
public class CollectionTest {

    public static void main(String[] args) {
        ArrayList<Integer> arrayList = new ArrayList<Integer>();
        LinkedList<Integer> linkedList = new LinkedList<Integer>();

        initList(arrayList);
        initList(linkedList);

        loopSpeed(ArrayList.class.getSimpleName(), arrayList);
        iteratorSpeed(ArrayList.class.getSimpleName(), arrayList);

        loopSpeed(LinkedList.class.getSimpleName(), linkedList);
        iteratorSpeed(LinkedList.class.getSimpleName(), linkedList);
    }

    private static void initList(List<Integer> list) {
        for (int i = 1; i <= 100000; i++) {
            list.add(i);
        }
    }

    private static void loopSpeed(String prefix, List<Integer> list) {
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < list.size(); i++) {
            list.get(i);
        }

        long endTime = System.currentTimeMillis();
        System.out.println(prefix + "通過循環的方式,共花費時間:" + (endTime - startTime)
                + "ms");
    }

    private static void iteratorSpeed(String prefix, List<Integer> list) {
        long startTime = System.currentTimeMillis();

        Iterator<Integer> itr = list.iterator();
        while (itr.hasNext()) {
            itr.next();
        }

        long endTime = System.currentTimeMillis();

        System.out.println(prefix + "通過迭代器的方式,共花費時間:" + (endTime - startTime)
                + "ms");
    }
}

在我的機器上,這段程序運行的結果爲:

ArrayList通過循環的方式,共花費時間:0ms
ArrayList通過迭代器的方式,共花費時間:15ms
LinkedList通過循環的方式,共花費時間:7861ms
LinkedList通過迭代器的方式,共花費時間:16ms

由此,你可以發現:

  • 對於ArrayList來說,使用loop進行遍歷相對於迭代器速度要更加快,但這個差距相對還稍微能夠接受一點。
  • 對於LinkedList來說,使用loop與迭代器進行遍歷的速度,相比之下簡直天差地別,迭代器要快上幾個世紀。

所以,如果在你的代碼中想要針對於遍歷這個功能來提供一個足夠通用的方法。
我們就可以以上面的代碼爲例,對其加以修改,得到類似下面的代碼:

    private static <E> void loop(List<E> list) {
        if (list instanceof RandomAccess) {
            for (int i = 0; i < list.size(); i++) {
                list.get(i);
            }
        } else {
            Iterator<E> itr = list.iterator();
            while (itr.hasNext()) {
                itr.next();
            }
        }
    }

是不是很給力呢?好了,廢話少說,我們接着看:

3.然後,至於List接口來說的話,我們在之前已經分析過了。它做的工作正是:
在Collection的基礎上,根據List(即列表結構)的自身特點,添加了一些額外的方法聲明。

4.同時可以看到,ArrayList繼承自AbstractList,而打開AbstractList類的源碼,又可以看到如下聲明:

public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E>

根據代碼,我們又可以分析得到:

  • AbstractList自身是一個抽象類。而其自身繼承自AbstractCollection,也就是說它又繼承自另一個抽象類。
  • 而“public abstract class AbstractCollection< E > implements Collection< E >”則是AbstractCollection類的申明。
  • 到此我們已經明確了我們之前說的“List → AbstractList → AbstractCollection → Collection”的繼承結構。
  • 對於AbstractCollection來說,它與Collection接口的方法列表幾乎是完全一樣,因爲它做的工作僅僅是:
    覆寫了從Object類繼承來的toString方法用以打印容器;以及對Collection接口提供部分骨幹默認實現
  • 而與AbstractCollection的工作相同,但AbstractList則負責提供List接口的部分骨幹默認實現。不難想象它們有一個共同的出發點則是:
    提供接口的骨幹實現,爲那些想要通過實現該接口來完成自己的容器的開發者以最大限度地減少實現此接口所需的工作
  • 最後,AbstractList還額外提供了一個新的方法:
    protected void removeRange(int fromIndex, int toIndex) 從此列表中移除索引在 fromIndex(包括)和 toIndex(不包括)之間的所有元素。

到這個時候,我們對於ArrayList類自身的繼承結構已經有了很清晰的認識。至少你知道了你平常用到的ArrayList的各種方法,分別都來自哪裏。
相信這對於我們使用Collection體系的容器類會有不小的幫助,下面我們就正式開始來分析ArrayList的源碼。

  • 構造器源碼解析

首先,就像我們使用ArrayList時一樣,我們首先會做什麼?當然是構造一個容器對象,就像下面這樣:

ArrayList<Integer> arrayList = new ArrayList<Integer>();

所以,我們首先從源碼來看一看ArrayList類的構造器的定義,ArrayList提供了三種構造器,分別是:

    //第一種
    public ArrayList(int initialCapacity) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "
                    + initialCapacity);
        this.elementData = new Object[initialCapacity];
    }
    //第二種
    public ArrayList() {
        this(10);
    }
    //第三種
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        size = elementData.length;
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    }

我們以第一個構造器作爲切入點,你會發現:等等?似乎什麼東西有點眼熟?

this.elementData = new Object[initialCapacity];

沒錯,就是它。這時你有話要說了:我靠?如果我沒看錯,這不是。。。數組。。。嗎?
其實沒錯,ArrayList的底層就是採用了數組的結構實現,只不過它維護的這個數組其長度是可變的。就是下面這個東西:

private transient Object[] elementData;

於是,構造器的內容,你已經很容器能夠弄清楚了:

  • 第一種構造器,可以接受一個int型的參數,它使用來指定ArrayList內部的數組elementData的初始範圍的。
    如果該參數傳入的值小於0,則會拋出一個IllegalArgumentException異常。
  • 第二種構造器,就更簡單了,它就在內部調用第一種構造器,並將參數值指定爲10。
    也就是說,當我們使用默認的構造器,內部就會默認初始化一個長度爲10的數組。
  • 第三種構造器,接收Collection接口類型的參數。然後通過調用其toArray方法,將其轉換爲數組,賦值給內部的elementData。
    完成數組的初始化賦值工作後,緊接着做的工作就是:將代表容器當前存放數量的變量size設置爲數組的實際長度。
    正常來說,第三種構造器所作的工作就是這麼簡單,但你肯定在意在這之後的兩行代碼。在此先不談,我們馬上會講到。

  • 插入元素 源碼解析

當我們初始化完成,得到一個可以使用的ArrayList容器對象後。最常做的操作是什麼?
答案顯而易見:通常我們都是對該容器內做元素添加、刪除、訪問等工作。
那麼,首先,我們就以添加元素的方法“add(E e)“爲起點,來看看源碼中它是怎麼做實現的?

    public boolean add(E e) {
        ensureCapacity(size + 1); // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

這就是ArrayList源碼當中add方法的定義,看上去並不複雜,我們來加以分析:
1.首先,就可以看到其調用了另一個成員方法ensureCapacity(int minCapacity)。
2.該方法的註釋說明是這樣的:如有必要,增加此ArrayList實例的容量,以確保它至少能夠容納最小容量參數所指定的元素數。
3.也就是說,簡單來講該方法就是用來修改容器的長度的。我們來看一下該方法的源碼:

    public void ensureCapacity(int minCapacity) {
        modCount++;
        int oldCapacity = elementData.length;
        if (minCapacity > oldCapacity) {
            Object oldData[] = elementData;
            int newCapacity = (oldCapacity * 3) / 2 + 1;
            if (newCapacity < minCapacity)
                newCapacity = minCapacity;
            // minCapacity is usually close to size, so this is a win:
            elementData = Arrays.copyOf(elementData, newCapacity);
        }
    }
  • 首先看到了“modCount++”這行代碼。這個變量其實是繼承自AbstractList類當中的一個成員變量。
    而它的作用則是:記錄已從結構上修改此列表的次數。從結構上修改是指更改列表的大小,或者打亂列表。
  • 所以,很自然的,因爲在這裏我們往容器內添加了元素,自然也就會改變容器的現有結構。故讓該變量自增。
  • 代碼“int oldCapacity = elementData.length;”則是通過內部數組的現有長度得到容器的現有容量。
  • 接下來的工作就是判斷 我們傳入的“新容量(最小容量)”是否大於“舊容量”。這樣做的目的是:
    如果新容量小於舊容量,則代表現有的容器已經足夠容納指定的數量,不必進行擴充工作;反之才需要對容器進行擴充。
  • 當判斷後,發現需要對容器進行擴充後。首先,會聲明一個新的數組引用來拷貝出原本elementData數組裏存放的元素。
  • 然後,會通過“int newCapacity = (oldCapacity * 3) / 2 + 1;“來計算初步得到一個新的容量值。
  • 如果計算得到的容量值小於我們傳入的指定的新的容量值,那麼就使用我們傳入的容量值。否則就使用計算得到的值作爲新的容量值。
    這兩步工作可能也值得說一下,爲什麼有一個傳入的指定值“minCapacity”了,還額外做了這個“newCapacity”的運算。
    其實不難想象到,這樣做是爲了提高程序效率。假設我們通過默認構造器構建了一個ArrayList,那麼容器內部就有了一個大小爲10的初始數組了。
    這個時候,我們開始循環的對容器進行“add”操作。不難想象當執行到第11次add的時候,就需要擴充數組長度了。
    那麼根據add方法自身的定義,這裏傳入的“minCapacity”值就是11。而通過計算得到的“newCapacity ”= (10 * 3)/2 +1 =16。
    到這裏就很容易看到好處了,因爲如果不進行上面的運算:那麼當超過數組的初始長度後,每次add都需要執行數組擴充的工作。
    而因爲newCapacity的出現,程序得以確保當執行第11次添加時,數組擴充後,直到執行到第16次添加,都不需要進行擴充了。

  • 最後,就是最關鍵的一步,也就是根據得到的新的容量值,來對容器進行擴充工作。我們有必要好好看一看。

我們發現對於容器的擴充工作,是通過調用Arrays類當中的copyOf方法進行的。
當你點擊源碼進入後,你會發現,實際上,在該方法裏又調用了其自身的另一個重載方法:

    public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
        T[] copy = ((Object)newType == (Object)Object[].class)
            ? (T[]) new Object[newLength]
            : (T[]) Array.newInstance(newType.getComponentType(), newLength);
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }

由此,你可能也回憶起來,在我們上面說到的第三種構造器裏實際也用到了這個方法。所以,我們有必要來好好看一看:

  • 首先,會通過一個三元運算符的表達式來計算傳入的newType是不是”Objcet[]”類型。
  • 如果是,則會直接構造一個Objcet[]類型的數組。如果不是,則會根據具體類型來進行構造。
  • 具體構造的方式是通過調用Array類裏的newInstance方法,這個方法的說明是:
    static Object newInstance(Class< ? > componentType, int length) 創建一個具有指定的組件類型和長度的新數組。
  • 其中參數componentType就是指數組的組件類型。而在上面的源碼中它是通過Class類裏的getComponentType()方法得到的。
    該方法很簡單,就是來獲取表示數組組件類型的 Class,如果組件不爲數組,則會返回“null”。
    通過一段簡單的代碼,我們能夠更形象的理解它的用法:
    public static void main(String[] args) {
        System.out.println(String.class.getComponentType()); //輸出結果爲 null
        System.out.println(String [].class.getComponentType());//輸出結果爲 class java.lang.String
    }
  • 接着,當執行完三元運算表達式的運算工作後,就會得到一個長度爲”newLength”的全新的數組“copy”了。
  • 問題在於,此時的數組”copy”內仍然沒有任何元素。所以我們還要完成最後一步動作,將源數組當中的元素拷貝新的數組當中。
  • 拷貝的工作正是通過調用System類當中的navtie方法“arraycopy”完成的,該方法的說明爲:
  • public static void arraycopy(Object src, int srcPos, Object dest,int destPos, int length)
  • 從指定源數組中複製一個數組,複製從指定的位置開始,到目標數組的指定位置結束。
  • 從 src 引用的源數組到 dest 引用的目標數組,數組組件的一個子序列被複製下來。被複制的組件的編號等於 length 參數。
  • 源數組中位置在 srcPos 到 srcPos+length-1 之間的組件被分別複製到目標數組中的 destPos 到 destPos+length-1 位置。
  • 到了這裏“ensureCapacity”方法就已經執行完畢了,內部的elmentData成功得以擴充。接下只要進行元素的存放工作就搞定了。
  • 但這時候,不知道你還記不記得我們前面說到的一個東西:那就是第三種構造器中,在執行完toArray獲取數組後,還進行了一個有趣的判斷如下:
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
  • 思考一下爲什麼會做這樣的判斷,實際上這樣做正是爲了避免某種運行時異常。從代碼的註釋得到的信息是:
    通過調用Collection.toArray()方法得到的數組並不能保證絕對會返回Object[]類型的數組。
    通過下面的測試代碼,我們就能驗證這種情況的發生:
    public static void main(String[] args) {
        Collection<String> c1 = new ArrayList<String>();
        Collection<String> c2 = Arrays.asList("123");

        System.out.println(c1.toArray().getClass()==Object[].class);//true
        System.out.println(c2.toArray().getClass()==Object[].class);//false
        System.out.println(c2.toArray().getClass());//class [Ljava.lang.String;

    }

從輸出結果我們不難發現,例如通過Arrays.asList方式得到的Collection對象,其調用toArray方法轉換爲數組後:
得到的就並非一個Object[]類型的數組,而是String[]類型的數組。也就說:如果我們使用c2來構造ArrayList,之前的數組拷貝語句就變爲了:

elementData = c.toArray(); 
//等同於:
Object [] elementData = new String[x];

雖然說這樣做看上去並沒有什麼問題,因爲有“向上轉型”的關係。進一步來說,上面的代碼原理就等同於:

        Object [] elementData = new Object[10];
        String [] copy = new String [12];
        elementData = copy;

但這個時候如果在上面的代碼的基礎上,再加上一句代碼,實際上這的確也就是add方法在完成數組擴充之後做的工作,就是:

        elementData [11] = new Object();

然後,運行代碼,你會發現將得到一個運行時異常“java.lang.ArrayStoreException”。
如果想了解異常的原因可以參見:JDK1.6集合框架bug:c.toArray might (incorrectly) not return Object[] (see 6260652)
所以就像我們之前說的那樣,第三種構造器內添加這樣的額外判斷,正是出於程序健壯性的考慮。
這樣的原因,正是因爲避免出現上述的情況,導致在數組需要擴充之後,向擴充後的數組內添加新的元素出現運行時異常的情況。

  • 到了這時,我們終於可以回到“add”方法內了。之後的代碼是簡單的“elementData[size++] = e;”
    實際這行代碼所做的工作就正如我們上面說到的一樣,數組完成擴充後,此時則進行元素插入就行了。
    同時在完成添加過後,將代表容器內當前存放的元素量的變量“size”的值進行一次自增。

到此,我們就完成了對添加元素的方法”add(E e)”的源碼進行了一次完整的剖析。有沒有一丟丟成就感?
革命還得繼續,我們趁熱打鐵,馬上來看一看另一個添加元素方法的實現,即”add(int index, E element)“:

    public void add(int index, E element) {
        if (index > size || index < 0)
            throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
                    + size);

        ensureCapacity(size + 1); // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1, size
                - index);
        elementData[index] = element;
        size++;
    }

我們前面花了那麼多的功夫,在這裏應該有所見效。相信對於上面的代碼,你已經很容易理解了。

  • 首先,既然是通過角標來插入元素。那麼自然會有一個健壯性判斷,index大於容器容量或者小於0都將拋出越界異常。
    在這裏,額外說明一下,特別注意一個使用誤區,尤其是在我們剛剛分析完了前面的源碼,信心十足的情況下。
    我們一定要注意這裏index是與代表容器實際容量的變量size進行比較的,而不是與elmentData.length!!!

我們仍然通過實際操作,來更深刻的加深印象,來看這樣的一段代碼:

        ArrayList<String> list = new ArrayList<String>();
        list.add(2, "123");

我們可能會覺得這是可行的,因爲在list完成構造後,內部的elmentData就會默認初始化爲長度爲10的數組。
這時,通過”list.add(2, “123”);”來向容器插入元素,我們可能就會下意識的聯想到這樣的東西”elmentData[2] = “123”;”,覺得可行。
但很顯然的,實際上這樣做等待你的就是數組越界的運行時異常。

  • 接着,與add(E e)相同,這裏仍然會調用“ensureCapacity”來判斷是否進行數組的擴充工作。有了之前的分析,我們不再廢話了。
  • 接下來的代碼,就是該添加方法能夠針對角標在容器中間進行元素插入工作的重點了,就是這兩句小東西:
        System.arraycopy(elementData, index, elementData, index + 1, size
                - index);
        elementData[index] = element;

System.arraycopy方法我們在之前也已經有過解釋。對應於上面的代碼,它做的工作你是否已經看穿了?
沒錯,其實最基本的來說,我們可以理解爲:“對elementData數組從下標index開始進行一次向右位移”。
還是老辦法,通過代碼我們能夠更形象直接的體會到其用處,就像下面做的:

    public static void main(String[] args) {
        String[] elmentData = new String[] { "1", "2", "3", "4", null };
        int index = 2, size = 4;
        System.arraycopy(elmentData, index, elmentData, index + 1, size - index);
        System.out.print("[");
        for (int i = 0; i < elmentData.length; i++) {
            System.out.print(elmentData[i]);
            if (i < elmentData.length - 1)
                System.out.print(",");
        }
        System.out.print("]");

    }

以上程序的輸入結果爲:”[1,2,3,3,4]“。也就是說,假設一個原本爲”[1,2,3,4]”的數組經過擴充後,
再調用源碼當中的“System.arraycopy(elementData, index,elementData, index + 1, size - index);”,
最終得到的結果就是[1,2,3,3,4]也就是說,將指定角標開始的元素都向後進行了一次位移。

  • 這個時候,再通過”elementData[index] = e;”來將指定角標的元素更改爲新存放的值,不就達到在中間插入元素的效果了嗎?
    所以說,對於向ArrayList容器中間插入元素的工作,我們歸納一下,發現實際上需要做的工作很簡單,不過就是:
    將原本數組中角標index開始的元素按指定位數(根據要插入的元素個數決定)進行位移 + 替換index角標的元素 = 在容器中間插入元素
    而這其實也解釋了:爲什麼相對於LinkedList來說,ArrayList在執行元素的增刪操作時,效率低很多。
    因爲在數組結構下,每當涉及到在容器中間增刪元素,就會產生蝴蝶效應波及到大量的元素髮生位移。

OK,又有進一步的收穫。到了這裏,對於插入元素的方法來說,還有另外兩個它們分別是:
addAll(Collection< ? extends E > c) 以及addAll(int index, Collection< ? extends E > c)
在這裏,我們就不一一再分析它們的源碼了,因爲有了之前的基礎,你會發現,它們的核心思想都是一樣的:
都是先判斷是否需要對現有的數組進行擴充;然後根據具體情況(插入單個元素還是多個,在中間插入還是在微端插入)進行元素的插入保存工作。
有興趣可以自己看一下另外兩個方法的源碼,相信對加深理解有不錯的幫助。

  • 刪除元素 源碼解析

看完了向容器添加元素的方法源碼,接着,我們來看一看與之對應的刪除元素的方法的實現。
在這裏,我們首先選擇刪除方法”remove(int index)“作爲切入點,來對源碼加以分析:

    public E remove(int index) {
        RangeCheck(index);

        modCount++;
        E oldValue = (E) elementData[index];

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index + 1, elementData, index,
                    numMoved);
        elementData[--size] = null; // Let gc do its work

        return oldValue;
    }
  • 首先就看到一個名爲rangeCheck的方法調用,從命名就不難看出,這應該是做容器範圍檢查的工作的。查看它的源碼:
    private void RangeCheck(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
                    + size);
    }
  • 由此,RangeCheck所做的工作很簡單:對傳入的角標進行判斷,如果它大於或等於容器的實際存放量,則報告越界異常。
  • 接下來的一行代碼,你已經很熟悉了。刪除元素自然也會改變容器的現有結構,所以讓該變量自增。
  • 然後是根據該角標從內部維護的elementData數組中,將該角標對應的元素取出。
  • 之後的兩行代碼就是刪除元素的關鍵,你可能會覺得有點熟悉。沒錯,因爲其中心思想與插入元素時是一致的。
    這個時候我們發現,其實在對於ArrayList來說,每當需要改變內部數組的結構的時候,都是通過arrayCopy執行位移拷貝。不同在於:
  • 刪除元素是將源數組index+1開始的元素複製到目標數組的index處。也就是說,與添加相反,是在做元素左移拷貝。
  • 刪除元素時,用於指定數組拷貝長度的變量numMoved不再是size - index而變爲了size - index -1。
    造成差異的原因就在於,在實現左移效果的時候,源數組的拷貝起始座標是使用index+1而不再是index了。
  • 接下來的一行代碼則是“elementData[–size]”,它的效果一目瞭然,既是將數組最後的一個元素設置爲null。
    註釋“// Let gc do its work”則說明,我們將元素值設爲null。之後就由gc堆負責廢棄對象的清理。

到此你不得不說別人的代碼確實寫的牛逼,remove裏的代碼短短几行,卻思路清晰,且完全達到了以下效果:

  • 要remove,首先進行rangeCheck,如果你指定要刪除的元素的index超過了容器實際容量size,則報告越界異常。

  • 經過rangeCheck後,index就只會小於size。那麼通過numMoved就能判斷你指定刪除的元素是否位於數組末端。
    這是因爲數組的特點在於:它的下標是從0而非1開始的,也就是如果長度爲x,最末端元素的下標就爲x-1。
    也就是說,如果我們傳入的index值如果恰好等於size-1,則證明我們要刪除的恰好是末端元素,
    如果這樣則不必進行數組位移,反之則需要調用System.arrayCopy進行數組拷貝達到左移刪除元素的效果。

  • 到這裏我們就能確保,無論如何我們要做的就是刪除數組末端的元素。所以,最後就將該元素設置爲null,讓size自減就搞定了。

接下來,我們再看看另一個刪除方法”remove(Object o)“:

public boolean remove(Object o) {
        if (o == null) {
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                    fastRemove(index);
                    return true;
                }
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }

是不是覺得這個方法看上去也太輕鬆了,沒錯,實際上就是對數組做了遍歷。
當發現有與我們傳入的對象參數相符的元素時,就調用fastRemove方法進行刪除。
所以,我們再來點開fastRemove的源碼來看一下:

private void fastRemove(int index) {
        modCount++;
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index + 1, elementData, index,
                    numMoved);
        elementData[--size] = null; // Let gc do its work
    }

這個時候,我們已經胸有成竹了。因爲你發現已經沒上面新鮮的了,上面的代碼相信不用再多做一次解釋了。

你可能會提起,還有另外一個刪除方法removeAll。但查看源碼,你就會發現:
這個方法是直接從AbstractCollection類當中繼承來的,也就是說在ArrayList裏並沒有做任何額外實現。

  • 訪問元素 源碼解析

其實對於數據來說,所做的操作無非就是“增刪查改”。我們前面已經分析了增刪,接下來就看看“查”和“改”。
ArrayList針對於這兩種操作,提供了”get(int index)“和”set(int index, E element)“方法。

其中”get(int index)“方法的源代碼如下:

    public E get(int index) {
        RangeCheck(index);

        return (E) elementData[index];
    }

。。。。。簡單到我們甚至懶得解釋。首先仍然是熟悉的RangeCheck,判斷是否越界。然後就是根據下標從數組中查找並返回元素。
是的,就是這麼容易,就是這麼任性。事實上這也是爲什麼ArrayList對於隨機訪問元素的執行速度快的原因,因爲基於數組就是這麼輕鬆。

那麼再來看一看”set(int index, E element)“的源碼吧:

    public E set(int index, E element) {
        RangeCheck(index);

        E oldValue = (E) elementData[index];
        elementData[index] = element;
        return oldValue;
    }

不知道你是不是已經覺得越來越沒難度了。的確,因爲硬骨頭我們在之前都啃的差不多了。。。
上面的代碼歸根結底就是一個通過角標對數組元素進行賦值的操作而已。老話,基於數組就是這麼任性。。

  • 其它的常用方法 源碼解析

事實上到了這裏,關於容器最核心部分的源碼(增刪改查)我們都瞭解過了。
但我們也知道除此之外,還有許多其它的常用方法,它們在ArrayList類裏是怎麼實現的,我們就簡單來一一瞭解一下。

  • size();
    public int size() {
        return size; //似乎都沒什麼好說的!! - -!
    }
  • isEmpty();
    public boolean isEmpty() {
        return size == 0; //。。依舊。。沒什麼好說的。。。
    }
  • contains
    public boolean contains(Object o) {
        //內部調用indexOf方法,indexOf是查詢對象在數組中的位置(下標)。如果不存在,則會返回-1.所以如果該方法返回結果>=0,自然容器就包含該元素
        return indexOf(o) >= 0;
    }
  • indexOf(Object o)
    // 核心思想也就是對數組進行遍歷,當遍歷到有元素符合我們傳入的對象時,就返回該元素的角標值。如果沒有符合的元素,則返回-1。
    public int indexOf(Object o) {
        if (o == null) {
            for (int i = 0; i < size; i++)
                if (elementData[i] == null)
                    return i;
        } else {
            for (int i = 0; i < size; i++)
                if (o.equals(elementData[i]))
                    return i;
        }
        return -1;
    }
  • lastIndexOf(Object o)
    // 與indexOf方法唯一的不同在於,這裏選擇將數組從後向前進行遍歷。所以返回的值就將是元素在數組裏最後出現的角標。同樣,如果沒有遍歷到,則返回-1。
    public int lastIndexOf(Object o) {
        if (o == null) {
            for (int i = size - 1; i >= 0; i--)
                if (elementData[i] == null)
                    return i;
        } else {
            for (int i = size - 1; i >= 0; i--)
                if (o.equals(elementData[i]))
                    return i;
        }
        return -1;
    }
  • Object[] toArray() 與 public T[] toArray(T[] a)
    public Object[] toArray() {
        //也就是說,底層仍然是通過Arrays.copyOf進行轉換。
        //另外,這行代碼在底層等同於:Arrays.copyOf(elementData, size,Object[].class);
        return Arrays.copyOf(elementData, size);
    }

    public <T> T[] toArray(T[] a) {
        if (a.length < size)
            // Make a new array of a's runtime type, but my contents:
            /*
             * 上面的英文註釋是源碼自身的註釋,從源碼中的註釋就可以發現,
             * 這裏是在程序運行時根據實際類型返回對應類型的數組對象。
             * 例如傳入的a是String[],就將返回String類型的數組。
             * 這也是與Object[] toArray()方法的不同。*/
            return (T[]) Arrays.copyOf(elementData, size, a.getClass());

        // 熟悉的數組拷貝的工作,注意這裏的目標數組是“a”。
        // 也就是說,這裏做的工作是將elementData作爲源數組,將其中的元素拷貝到a當中,然後返回。
        System.arraycopy(elementData, 0, a, 0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }
  • clear()
    public void clear() {
        modCount++;

        // Let gc do its work
        // 遍歷數組,將元素全部設置爲null
        for (int i = 0; i < size; i++)
            elementData[i] = null;
        // 將實際容量還原爲0
        size = 0;
    }
  • removeRange(int fromIndex, int toIndex)
protected void removeRange(int fromIndex, int toIndex) {
        modCount++;
        /*
         *  相信有了之前remove(int index)的基礎,這個方法你理解起來也應該很輕鬆。
         *  因爲原理完全相同,唯一的區別在於,之前的方法是針對一個元素位置的位移拷貝。
         *  而這裏則是針對一個區間的元素。但要注意的是toIndex自身實際是不被包括的。
         *  舉例來說,現在有“[1,2,3,4,5]”。我們希望將下標"1-3"之間的元素(即[2,3])移除。
         *  那麼,通過位移拷貝,我們首先需要實現的效果就是得到[1,4,5,4,5]這樣的數組,然後將之後的兩個個元素設置爲null。
         *  而這樣的操作體現在代碼上,就正如下面的表達方式。
         */
        int numMoved = size - toIndex;
        System.arraycopy(elementData, toIndex, elementData, fromIndex, numMoved);

        // Let gc do its work
        int newSize = size - (toIndex - fromIndex);
        while (size != newSize)
            elementData[--size] = null;
    }

到這裏,我們終於完成了對於ArrayList容器類的源碼解析。相信一定有不少收穫。
我們只需要記住,它是基於維護一個可變數組來實現的容器結構。至於它自身的種種特點:
例如在容器中間插入/刪除元素效率較低,隨機訪問元素速度快之類的特點。
相信經過這一番解析,你已經不僅僅是知道它們,甚至已經更進一步,瞭解造成這些特點的原因了。

LinkedList用法見析

其實,我最初的想法是像ArrayList一樣,把主流的這幾個容器類的源碼都過一遍,寫一個較爲詳細的解析。
但寫完ArrayList我發現,這。。。實在是。。。太累心了。。。
所以,在這裏,我們就只看一些比較關鍵部分的源碼,重點就放在明白其底層實現原理就可以了!

與ArrayList的套路一樣,我們首先仍然選擇查看構造器部分:

    private transient Entry<E> header = new Entry<E>(null, null, null);
    private transient int size = 0;
    public LinkedList() {
        header.next = header.previous = header;
    }

    public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
    }

我們發現,與ArrayList不同,LinkedList內部維護的不再是數組,而是一個叫做“Entry”的東西。
點開這個東西的源碼,你發現這是定義在LinkedList之中的一個靜態內部類,我們看這個類的定義:

    private static class Entry<E> {
    E element;
    Entry<E> next;
    Entry<E> previous;

    Entry(E element, Entry<E> next, Entry<E> previous) {
        this.element = element;
        this.next = next;
        this.previous = previous;
    }
    }

根據最直觀的翻譯,我們可以把這個東西叫做“記錄”。這也能體現它自身的作用。
可以看到,該類型的內部首先有一個泛型E,這實際上就是用來保存我們的插入的元素。
然後又有另外兩個“記錄(Entry)”分別是“next”,“previous”。正如同它們的名字一樣:
它們正是用來記錄和保存:位於自身“記錄”其之前和之後的“記錄”的信息的。
這樣的一種容器結構,實際上也就符合所謂的“鏈表”數據結構的特徵。

那麼,對應於這樣的一種數據結構來說,如果我們執行add添加元素時,其內部是怎麼做的呢?

    public boolean add(E e) {
    addBefore(e, header);
        return true;
    }

我們發現:add(E e)方法又調用了私有的一個靜態方法addBefore,我們再查看這個方法的源碼定義:

    private Entry<E> addBefore(E e, Entry<E> entry) {
        Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
        newEntry.previous.next = newEntry;
        newEntry.next.previous = newEntry;
        size++;
        modCount++;
        return newEntry;
    }

初看之下,可能會覺得有點發蒙。。。至少我是這樣,搞什麼呢?玩繞口令呢?而弄明白過後,你會發現寫的確實牛逼!
我們首先要明確一個行爲:當我們調用add(E e)方法添加元素,所做的工作是將元素添加到列表末尾。
那麼,想象一下,ArrayList因爲內部維護的一個數組,所以如果要向尾部添加元素是很容易的。
然而,LinkedList自身其實只通過一個Entry型的變量header來完成這種數據結構,它要怎麼保證依次向表位插入元素呢?

不知道你注意到沒有,add(E e)方法在調用addBefore的時候傳入的第二個參數固定爲“header”。
由此,我們也不難想象,這個header肯定在用來確保以上的功能得以完成起到了關鍵的作用。

從源碼中,我們可以看到,假設我們調用無參的構造器創建LinkedList對象,那麼就會創建一個空的列表。
創建空的列表的代碼很簡單,就是:“header.next = header.previous = header;”。
也就是說,在剛剛構造完成的時候,現有的空列表中,沒有存放任何元素,只維持一個表頭(header)。

現在重頭戲要登場了,我們來看看別人的源碼到底寫的有多叼,首先我們來看addBefore的第一行代碼:

Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
  • 我們說了add(E e)方法穿入的entry值恆定爲header,於是這行代碼就代表着:
    newEntry.next永遠等於header,而newEntry.previous永遠爲header.previous。
  • 最初一看可能會覺得很困惑,因爲我們不知道這樣做的意義在哪?但其實精髓就在這。

在這之前,我們先來研究一個神功:“九九歸一”大法。雖然帶點玩笑的成分,但我們這裏使用的確實就是類似的思想。

想象一下,假設現在的情況是:有數字“1”到“9”按順序依次排列。同時有一個指針在它們頭上移動。

那麼,我們可以說”1.previous = 9”或者”9.next = 1”嗎?通常來說不行,因爲你說9.next當然是10啊大兄弟。

但是,如果我們將“1-9”視爲一個輪迴,沒有其它元素的干擾。那麼,在這個輪迴移動至9的時候,

再下一步的時候,它就應該“返璞歸真”,再次迴歸到1了。

好了,九九歸一大法修煉完畢。我們接着來看我們的源代碼,不知道現在你是不是已經能理解之前我們說的代碼了。

分析一下,之前的代碼做的工作實際上就是說:

  • 我們將“header”作爲鏈表世界的“原點”。
  • 那麼,”header.previous”就是世界的終點(列表末端元素);
  • 同理,”末端元素.next”就意味着世界的”原點”(header);

現在我們再來分析源碼做的工作就很清晰了:聲明一個新的Entry變量”newEntry”,這個Entry保存我們插入的元素e。
並且將其的next節點信息記錄爲“header”;將其previous節點信息記錄爲”header.previous”(即在newEntry節點進入之前的末端元素)”。
由此,我們不難發現,其實源碼的作者通過一行代碼,實現了將元素依次從列表末端插入的工作,確實牛逼。

接着,我們來看之後的兩行代碼:

        newEntry.previous.next = newEntry;
        newEntry.next.previous = newEntry;

有了第一行代碼理解的基礎,這兩行代碼我們理解起來就相對容易多了。這步工作就好比:
在newEntry出現之前,鏈表的末端是另一個元素。所以當newEntry出現後:
我們自然不能忘記,將因爲newEntry的插入而發生位置改變的節點記錄(Entry)裏的next與previous的值做更新。

之後的兩行代碼“size++”和“modCount++”自然就不需要我們多費口舌了。

那麼,我們再來看一下如果是要在列表的中間插入元素,LinkedList又是怎麼做的呢?

    public void add(int index, E element) {
        addBefore(element, (index == size ? header : entry(index)));
    }

OK,我們看到該方法在底層,實際仍然是通過調用addBefore方法來實現的。
同時用一個三元運算表達式對index做了判斷,如果index==size,實際就等同於在列表末端插入元素,也就與add(E e)沒有區別。
而如果index位於列表中間位置,那麼傳入addBefore方法的entry參數與就不再是header節點了。
而是會先通過entry(index)方法查詢到當前在該位置(index)存放的節點信息,作爲參數傳入。
想象一下,這樣做與傳入header節點有什麼不同,在回顧一下那行經典的代碼:

Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);

對應到這裏說,假設我們選擇在index爲5的地方插入元素e,那麼所做的工作就是:

  • 新建節點newEntry,保存插入的元素e。
  • 將newEntry的next節點,設置爲之前存放在index=5位置上的節點。
  • 將newEntry的previous節點,設置爲之前存放在index=5位置上的節點之前的節點。

由此,也就完美實現了在某個指定index的位置插入元素的需求。
然後同樣是通過該行代碼之後的兩行代碼,更新相關的節點信息的改變。

我們看一下上面的代碼出現的另一個比較關鍵的方法“entry(int index)”。
因爲我們在調用LinkedList的元素訪問方法get(index)時,底層就是通過該方法實現的:

    public E get(int index) {
        return entry(index).element;
    }

而entry(int index)方法的源碼實現則是下面這樣:

    private Entry<E> entry(int index) {
        if (index < 0 || index >= size)
            throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
                    + size);
        Entry<E> e = header;
        if (index < (size >> 1)) {
            for (int i = 0; i <= index; i++)
                e = e.next;
        } else {
            for (int i = size; i > index; i--)
                e = e.previous;
        }
        return e;
    }

由此可以看到,與ArrayList不同,LinkedList在實現隨機訪問元素的時候,是通過循環的進行e.next來進行的。
爲了提高該方法的效率,這裏使用了“二分查找法”的方式來減少循環工作量。
但不幸的是,如果要進行頻繁的元素訪問工作,尤其是對於huge zize的LinkedList來說,效率仍然是十分低的。
因爲就像代碼表現的那樣,假設我們在LinkedList中存放了一百萬個元素,我們要查找index爲50萬位置的元素。
那麼,雖然做了一個二分式的判斷,但是仍然要循環的從header節點開始做50萬次“e = e.next”的操作。

到此,我們相信我們應該瞭解了爲什麼說:

  • 對於頻繁的元素隨機訪問工作,ArrayList的工作效率高於LinkedList
    (因爲ArrayList可以直接通過數組下標訪問。而LinkedList則需要循環的進行e.next)
  • 而對於在列表中間進行插入和刪除元素的操作,LinkedList則高於ArrayList。
    (ArrayList底層的數組結構發生改變,則會涉及到大量的相關元素髮生拷貝位移;
    而LinkedList則只需要找到該index的位置進行對應操作,並修改相關的節點信息)

再來看看一看addAll()方法的源碼:

    public boolean addAll(Collection<? extends E> c) {
        return addAll(size, c);
    }

    public boolean addAll(int index, Collection<? extends E> c) {
        if (index < 0 || index > size)
            throw new IndexOutOfBoundsException("Index: " + index + ", Size: "
                    + size);
        Object[] a = c.toArray();
        int numNew = a.length;
        if (numNew == 0)
            return false;
        modCount++;

        Entry<E> successor = (index == size ? header : entry(index));
        Entry<E> predecessor = successor.previous;
        for (int i = 0; i < numNew; i++) {
            Entry<E> e = new Entry<E>((E) a[i], successor, predecessor);
            predecessor.next = e;
            predecessor = e;
        }
        successor.previous = predecessor;

        size += numNew;
        return true;
    }

有了之前的基礎,相信上面的代碼已經不再陌生了,我們不再花過多的精力去進行分析。主要看一下以下的代碼:

        //獲取後端節點。如果index=size則代表末端插入,則後端節點爲header,否則爲當前位於index位置上的節點元素。
        Entry<E> successor = (index == size ? header : entry(index));
        //獲取前端節點。此時的前端節點自然就是之前的“successor”的“previous”節點
        Entry<E> predecessor = successor.previous;
        //根據插入的元素的數量進行循環
        for (int i = 0; i < numNew; i++) {
            // 新建Entry保存插入元素。並設置next,previous節點信息
            Entry<E> e = new Entry<E>((E) a[i], successor, predecessor);
            // 因爲新的e節點的插入,predecessor.next就應該由successor變爲e了。
            predecessor.next = e;
            // 因爲還要在此次加入的節點後面繼續添加節點,所以這個時候predecessor就應該變爲此時新加入的節點e了。
            predecessor = e;
        }
        // 當循環完成。當然不要忘記更新successor的previous節點信息。
        successor.previous = predecessor;

最後看一看移除元素的方法。你會發現對於LinkedList來說,幾乎所有的刪除方法最後都會回到下面:

    private E remove(Entry<E> e) {
        if (e == header)
            throw new NoSuchElementException();
        // 取出元素
        E result = e.element;
        // 這個節點被刪除 = 該節點的前一個節點的後一個節點的值應該更新爲此時被刪除的節點的後一個節點。
        e.previous.next = e.next;
        // 道理同上
        e.next.previous = e.previous;
        // 接下來的兩行代碼都是將e中保存的對象設置爲空,方便gc在適當時候回收。
        e.next = e.previous = null;
        e.element = null;
        size--;
        modCount++;
        return result;
    }

到這裏爲止,實際我們就已經瞭解了LinkedList源碼當中最核心的部分。

但你肯定聽說過關於LinkedList還有一個很重要的使用方式:即可以藉助其實現另外兩種數據結構:“棧”以及“隊列”。
而LinkedList針對於實現這些數據結構,也封裝提供了一些額外的元素操作方法。
但你可能也發現了,這些方法例如在ArrayList當中是不存在的,由此你不難想象,它們肯定是來自於另外一個接口。
打開源碼後發現,事實和我們想象的一樣,LinkedList實現了Deque接口,而Deque又繼承自Queue接口。

但實際上,查看LinkedList源碼中這些接口的實現,你會發現,基本上對我們前面講到的核心部分的代碼做一個封裝:

    public E getFirst() {
        if (size == 0)
            throw new NoSuchElementException();

        return header.next.element;
    }

    public E getLast() {
        if (size == 0)
            throw new NoSuchElementException();

        return header.previous.element;
    }

    public E removeFirst() {
        return remove(header.next);
    }

    public E removeLast() {
        return remove(header.previous);
    }

    public void addFirst(E e) {
        addBefore(e, header.next);
    }

    public void addLast(E e) {
        addBefore(e, header);
    }

    public E peek() {
        if (size == 0)
            return null;
        return getFirst();
    }

    public E element() {
        return getFirst();
    }

    public E poll() {
        if (size == 0)
            return null;
        return removeFirst();

    public boolean offer(E e) {
        return add(e);
    }

    public boolean offerFirst(E e) {
        addFirst(e);
        return true;
    }

    public boolean offerLast(E e) {
        addLast(e);
        return true;
    }

    public E peekFirst() {
        if (size == 0)
            return null;
        return getFirst();
    }

    public E peekLast() {
        if (size == 0)
            return null;
        return getLast();
    }

    public E pollFirst() {
        if (size == 0)
            return null;
        return removeFirst();
    }

    public E pollLast() {
        if (size == 0)
            return null;
        return removeLast();
    }

    public void push(E e) {
        addFirst(e);
    }

    public E pop() {
        return removeFirst();
    }

同時,你還會發現,在這一系列的方法中。很多方法只是名稱有些差異,或者實現由細微不同。
所以,之所以提供這麼多的方法接口,實際上是爲了這些名字在特定的上下文環境中(例如不同的數據結構)更加適用。

而接下來,我們就來看一看,怎麼樣調用LinkedList來實現我們說到的”棧“和”隊列“的數據結構。

  • 用LinkedList實現棧結構

”棧“結構的特點在於,存放在其中的元素總是會保持“先進後出”的特點。藉助LinkedList就可以輕鬆實現:

public class Stack<E> {
    private LinkedList<E> mList = new LinkedList<E>();

    public void push(E e) {
        mList.addFirst(e);
    }

    public E peek() {
        return mList.getFirst();
    }

    public E pop() {
        return mList.removeFirst();
    }

    public boolean isEmpty() {
        return mList.isEmpty();
    }

    public String toString() {
        return mList.toString();
    }
}


  • 用LinkedList實現隊列結構

與棧結構的特點恰好相反,隊列結構則有着元素“先進先出”的特點。所以我們同樣很容易實現:

public class Queue<E> {
    private LinkedList<E> mList = new LinkedList<E>();

    public void offer(E e) {
        mList.addLast(e);
    }

    public E peek() {
        return mList.getFirst();
    }

    public boolean isEmpty() {
        return mList.isEmpty();
    }

    public String toString() {
        return mList.toString();
    }
}


Set體系

Set是位於Collection體系下的另一個常用的容器類體系。與List體系內的容器的特點不同的是:
Set容器不允許存放重複的元素,如果你試圖將相同對象的多個實體添加到容器中,就會被阻止。
Set最常被使用的情況就是用來測試歸屬性,你可以很容易的查詢到某個對象是否在某個Set當中。

另外,值得注意的是,雖然新聲明出了Set接口,但是Set具有和Collection完全相同的方法聲明。
所以Set的作用更多的是用以聲明類型區別,它雖然與Collection具有完全相同的方法入口,但具體表現行爲不同。

同時,Set體系最常被使用的容器類有兩個,分別是HashSet與TreeSet。但對於這兩個容器類來說:
通過查看源碼你就可以看見,雖然他們隸屬於Set體系當中,但實際內部還是通過HashMap與TreeMap的原理來實現的。
所以在這裏,我們先簡單的來看一下它們的源碼有個印象,而其實現原理,我們之後在Map的章節再通過源碼來分析。

  • HashSet源碼淺析

public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
{
    static final long serialVersionUID = -5024744406713321676L;

    private transient HashMap<E,Object> map;

    private static final Object PRESENT = new Object();

    public HashSet() {
    map = new HashMap<E,Object>();
    }

    public HashSet(Collection<? extends E> c) {
    map = new HashMap<E,Object>(Math.max((int) (c.size()/.75f) + 1, 16));
    addAll(c);
    }

    public HashSet(int initialCapacity, float loadFactor) {
    map = new HashMap<E,Object>(initialCapacity, loadFactor);
    }

    public HashSet(int initialCapacity) {
    map = new HashMap<E,Object>(initialCapacity);
    }

    HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<E,Object>(initialCapacity, loadFactor);
    }

    public Iterator<E> iterator() {
    return map.keySet().iterator();
    }

    public int size() {
    return map.size();
    }

    public boolean isEmpty() {
    return map.isEmpty();
    }

    public boolean contains(Object o) {
    return map.containsKey(o);
    }

    public boolean add(E e) {
    return map.put(e, PRESENT)==null;
    }

    public boolean remove(Object o) {
    return map.remove(o)==PRESENT;
    }

    public void clear() {
    map.clear();
    }

    public Object clone() {
    try {
        HashSet<E> newSet = (HashSet<E>) super.clone();
        newSet.map = (HashMap<E, Object>) map.clone();
        return newSet;
    } catch (CloneNotSupportedException e) {
        throw new InternalError();
    }
    }

    private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
        s.defaultWriteObject();

        s.writeInt(map.capacity());
        s.writeFloat(map.loadFactor());

        s.writeInt(map.size());

    for (Iterator i=map.keySet().iterator(); i.hasNext(); )
            s.writeObject(i.next());
    }

    private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {

    s.defaultReadObject();

        int capacity = s.readInt();
        float loadFactor = s.readFloat();
        map = (((HashSet)this) instanceof LinkedHashSet ?
               new LinkedHashMap<E,Object>(capacity, loadFactor) :
               new HashMap<E,Object>(capacity, loadFactor));

        int size = s.readInt();

    for (int i=0; i<size; i++) {
            E e = (E) s.readObject();
            map.put(e, PRESENT);
        }
    }
}

由此,我們發現其實HashSet類的源碼很簡單。同時也印證了我們前面說的:
HashSet的接口是與Collection完全一樣的,但其內部實際就是維護了一個HashMap對象“map”。
我們在源碼中也可以看到,基本上HashSet所有的接口內部的實現都是在調用HashMap的方法來實現的。

  • TreeSet源碼淺析

由於文章的篇幅原因,對於TreeSet我們只截取最具代表性的一段源碼,如下:

public class TreeSet<E> extends AbstractSet<E>
    implements NavigableSet<E>, Cloneable, java.io.Serializable
{

    private transient NavigableMap<E,Object> m;

    private static final Object PRESENT = new Object();

    TreeSet(NavigableMap<E,Object> m) {
        this.m = m;
    }

    public TreeSet() {
    this(new TreeMap<E,Object>());
    }

    public TreeSet(Comparator<? super E> comparator) {
    this(new TreeMap<E,Object>(comparator));
    }

    public TreeSet(Collection<? extends E> c) {
        this();
        addAll(c);
    }


    public TreeSet(SortedSet<E> s) {
        this(s.comparator());
    addAll(s);
    }

}

通過源碼我們可以知道,TreeSet如果說與HashSet的原理有所不同的話,就是:
它內部不是那麼直接的維護一個“TreeMap”的對象,而是維護了一個“NavigableMap”類型的對象。
打開源碼我們可以知道“NavigableMap”是一個接口,而TreeMap正是實現了該接口。
TreeSet的其中一種構造器“TreeSet(NavigableMap< E,Object > m) ”允許我們自定義傳入該類型的對象。
而除此之外,其它的構造器內部實際上仍然就是直接在構造一個“TreeMap”類型的容器對象。

  • Set容器使用小結

總的來說,我們把對於HashSet與TreeSet的容器的使用可以簡單的歸納總結爲:

  • 由於具有相同的方法接口,我們可以像使用Collection一樣的使用它們,不同之處在於Set不允許重複元素。
  • HashSet是基於哈希(即散列)結構來實現的;而TreeSet則是通過二叉樹結構來實現的(在後面的Map我們具體分析)。
  • 判斷元素是否的一句來說:HashSet通過覆寫hashcode()與equals()方法實現;TreeSet通過內部維護的比較器(Comparator)實現。

Map體系

接下來,我們就來到了Java集合框架的另一個重點“Map”。從命名我們就不難看出:
與“Collection體系”單值存儲的特點不同,Map體系下的容器除了允許我們存儲值(value)之外,
還允許我們對該值設置一個對應的索引(key)方便我們查找,就像我們查詢字典一樣。

與Colleciton一樣,Map也是該體系下的容器類的根接口,打開其接口聲明:

  • void clear() 從此映射中移除所有映射關係(可選操作)。
  • boolean containsKey(Object key) 如果此映射包含指定鍵的映射關係,則返回 true。
  • boolean containsValue(Object value) 如果此映射將一個或多個鍵映射到指定值,則返回 true。
  • Set< Map.Entry< K,V > > entrySet() 返回此映射中包含的映射關係的 Set 視圖。
  • boolean equals(Object o) 比較指定的對象與此映射是否相等。
  • V get(Object key) 返回指定鍵所映射的值;如果此映射不包含該鍵的映射關係,則返回 null。
  • int hashCode() 返回此映射的哈希碼值。
  • boolean isEmpty() 如果此映射未包含鍵-值映射關係,則返回 true。
  • Set< K > keySet() 返回此映射中包含的鍵的 Set 視圖。
  • V put(K key, V value) 將指定的值與此映射中的指定鍵關聯(可選操作)。
  • void putAll(Map< ? extends K,? extends V > m) 從指定映射中將所有映射關係複製到此映射中(可選操作)。
  • V remove(Object key) 如果存在一個鍵的映射關係,則將其從此映射中移除(可選操作)。
  • int size() 返回此映射中的鍵-值映射關係數。
  • Collection< V > values() 返回此映射中包含的值的 Collection 視圖。

由此,其實我們不難發現,Map與Colleciton相比,其實大部分的接口聲明都是差不多的。
因爲歸根結底它們都是容器,根本來說它們都是做元素的存取等操作。不同之處就是在於,Map的存取開始針對於key。


  • HashMap源碼剖析

相對來說的話,HashMap數據結構的實現要更爲複雜一點。但實際上,如果我們瞭解之後,也就會覺得其實這種結構很清晰。
所以在分析HashMap的源碼之前,我們首先要弄清一種數據結構的特點。因爲顧名思義,HashMap正是基於這種數據結構來實現的。

  • 散列表(Hash table,也叫哈希表),是根據關鍵碼值(Key value)而直接進行訪問的數據結構。
  • 也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。
  • 這個映射函數叫做散列函數,存放記錄的數組就叫做散列表。
  • 給定表M,存在函數f(key),對任意給定的關鍵字值key,代入函數後若能得到包含該關鍵字的記錄在表中的地址
    我們就稱表M爲哈希(Hash)表,函數f(key)爲哈希(Hash) 函數。

也就是說,我們歸納來說,雖然被命名爲哈希表。但實際上,內部的數據結構仍然是基於數組來維護的。
只是該數組與List爲例而言,對比來說,它不再直接存儲值。而是通過將我們存儲的對象計算出一個值,
這個值就是所謂的哈希值(散列碼),也就是上面的概念提到的Key Value(關鍵碼值)。
然後數組通過維護該關鍵碼值,從而避免了速度緩慢的線性查詢,從而提高數據的訪問速度。

概念性的東西說了一大堆,我們現在就正式開始,通過分析HashMap的源碼來對它進行一次深入的瞭解。
與之前對於ArrayList與LinkedList的源碼分析時做的一樣,我們首先來看HashMap的類聲明:

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

與之前一樣,從以上的類聲明我們可以得到主要的如下信息:

  • HashMap繼承自AbstractMap,AbstractMap是對Map接口提供了骨幹部分默認實現。
  • HashMap同樣實現了Map接口,除此之外實現了Cloneable, Serializable接口,確保對象能被複制,以及實現序列化。

構造器部分源碼解析

接下來,我們仍然首先來看HashMap的構造器部分:

    // 默認的初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    // 容器的最大容量 (2的30次方)
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默認的加載因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 一個存儲Entry類型的對象的數組,實際上這個數組就是所謂的散列表(即散列數組)
    transient Entry[] table;

    // 當前容器內的數據量
    transient int size;

    // 當容量達到該值,就需要進一步散列進行擴容
    int threshold;

    // 加載因子
    final float loadFactor;

    // 修改容器結構的次數
    transient volatile int modCount;

    public HashMap(int initialCapacity, float loadFactor) {
        // 如果初始容量小於0,則報告異常(道理很簡單,實際這樣就是把“table”初始化爲長度0的數組)
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // 確保設置的初始容量不能超過最大容量
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;

        // 確保加載因子必須大於0,否則報告異常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // 容器容量必須是2的n次冪
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        // 設置加載因子
        this.loadFactor = loadFactor;

        // 計算需要進行擴容的界限,實際就是 初始容量 * 加載因子。
        threshold = (int)(capacity * loadFactor);

        // 設置哈希表大小
        table = new Entry[capacity];
        init();
    }

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }

    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        putAllForCreate(m);
    }

我們對最關鍵的代碼部分都做了註釋,並且我們可以總結得到:

  • HashMap內部維護了一個Entry類型的數組對象“table”,這實際上就是所謂的哈希表。至於Entry我們緊接着就會說到。
  • HashMap的初始容量由“initialCapacity”決定,默認的初始容量爲16,即我們不傳入initialCapacity,“table”的長度就默認爲16。
  • loadFactor即“加載因子”,它的作用是決定容器在什麼時候需要進行擴充,默認的加載因子爲0.75f.
  • 而容器具體在什麼時候就會進行擴充工作呢?即達到容量極限“threshold ”時,threshold = capacity * loadFactor。

瞭解了以上信息,我們對於HashMap的基本結構就有了一個初步的認識。而另外值得一提的是:
我們注意到容器的容量值總是被確保爲2的N次方,也就是說,如果我們傳入的容量值爲7。那麼:
構造器內部就會初始化一個capacity =1,然後循環做左位移操作,直到它的值變爲8(即2的3次方,且大於我們傳入的6)。
至於究竟爲什麼這樣做,我們不難猜想多半是爲了提高之後的存取效率,我看到一邊文章中的解釋是:

  • HashMap的底層數組長度總是2的n次方,因爲在構造函數中存在:capacity <<= 1;
  • 而當length爲2的n次方時,h&(length – 1)就相當於對length取模,而且速度比直接取模快得多。

原理就在這了,但因爲在算法方面,我是一個巨大的菜鳥。所以看了我也沒能十分明白。
那總之,我就記住它這樣做,歸根結底是爲了提高之後對於容器的存取操作的效率。

關於加載因子這個東西,我們可以額外多說一點:

  • 加載因子衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。
  • 對於使用鏈表法的散列表來說,查找一個元素的平均時間是O(1+a)。
  • 因此如果負載因子越大,對空間的利用更充分。然而後果是查找效率的降低;
  • 如果負載因子太小,那麼散列表的數據將過於稀疏,對空間造成嚴重浪費。

我們看一下爲什麼會造成這樣的現象。首先因爲擴容界限 = 加載因子 * 容量。那麼,也就是說。
我們假設現在有一個初始容量爲8(8個桶)的散列表,而我們將加載因子設置爲1。結果就是:
當散列表內存放進了8組鍵值對的時候,該散列表就會進行再散列的工作而完成擴容。
假設我們將加載因子設置爲2,也就是說散列表將存放進16組鍵值對時,纔會進行擴容。
由此我們可以發現,加載因子設置的越大,出現“碰撞(衝突)”的機率也就越大。
因爲我們看到將加載因子設置爲2的時候,即使以最平均的方式來分配桶位,每個桶位的鏈表上也會有2組鍵值對。
而出現衝突的機會越大,也就導致存取的效率越低,因爲一旦存在衝突,每次存取元素時都會經歷鏈表的遍歷。

由此我們發現,加載因子從某種程度上決定了散列表容器的存取效率。但這種效率的消耗很大程度就是因爲”再散列”的原因造成的。
所以如果我們知道一個HashMap上將要存放的元素的數量,那麼指定一個合適的初始容量就可以避免“再散列”而帶來的消耗。

內部類元素類型”Entry”

緊接着,我們已經說到,對於HashMap,它內部維護的數組“table”的類型是”Entry”。
我們肯定還記得LinkedList當中就有這樣的東西,但這裏的“Entry”是定義在HashMap當中的內部類,一個全新的類型:

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;    // 鍵
        V value;        // 值
        Entry<K,V> next;// 鏈表的下一個鍵值對元素
        final int hash; // 哈希值

        /*
         *構造器
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        /*
         * equals判斷
         */ 
        public final boolean equals(Object o) {
            // 首先通過類型判斷
            if (!(o instanceof Map.Entry))
                return false;
            // 類型轉換
            Map.Entry e = (Map.Entry)o;
            // 獲取key
            Object k1 = getKey();
            Object k2 = e.getKey();
            /*
             * 判斷結果分爲幾種情況:
             * 1.如果k1==k2,即k1和k2在內存中指向同一個對象,則返回true。
             * 2.如果k1不爲null,且滿足k1.equals(k2),即兩個Entry對象的key滿足equals。則再判斷它們的value是否滿足equals,如果都滿足則返回true.
             * 3.否則其他情況則將返回false
             */
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            return false;
        }

        /*
         * Entry對象的哈希值計算
         */
        public final int hashCode() {
            // 即key的哈希值與value的哈希值做亦或運算
            return (key==null   ? 0 : key.hashCode()) ^
                   (value==null ? 0 : value.hashCode());
        }

        public final String toString() {
            return getKey() + "=" + getValue();
        }

        /**
         * This method is invoked whenever the value in an entry is
         * overwritten by an invocation of put(k,v) for a key k that's already
         * in the HashMap.
         */
        void recordAccess(HashMap<K,V> m) {
        }

        /**
         * This method is invoked whenever the entry is
         * removed from the table.
         */
        void recordRemoval(HashMap<K,V> m) {
        }
    }

我們注意到,在這個Entry內部類當中,我們可以得到比較重要的信息有:

  • 兩個關鍵的字段:key和value,即是我們存放的鍵值對(對應於map.put(key,value))。
  • 一個Entry類型的變量next,即代表當前Entry的下一個節點元素信息。
  • 另一個關鍵的int型變量“hash”即代表當前Entry對象的哈希值(散列碼)

在上面的信息當中,容器引起我們注意的當然是變量“next”。爲什麼會持有這樣一個變量?
這是因爲,我們說過哈希表當中的元素,是通過計算散列碼來決定其在數組中的存放位置的。
但是,無論是怎麼樣的哈希函數,都不能絕對保證不會出現兩個不同元素但哈希值計算相同的情況。
也就是說,也就是說,很可能在同一個哈希值代表的索引處,會存放多個不同的元素。
那麼,這種情況是怎麼樣得以實現的呢?答案還是鏈表。不知道你否對LinkedList當中的Entry有印象沒。
在LinkedList當中的Entry除了有next節點之外,還有previous節點,所以又稱爲雙向鏈表結構。
而在HashMap內部定義的Entry只保留next節點,所以是單向鏈表結構。我們藉助一張圖可以更清楚的瞭解這種結構:
這裏寫圖片描述
上圖中橙色部分代表的就是內部的”table”數組,藍色的則是代表:
如果之後在相同哈希值代表的索引處存放進元素,則通過鏈表形式進行鏈接。

put/get 元素的存取剖析
到此爲止,我們已經瞭解了關於構造一個HashMap容器對象,所做的工作及其原理。
老樣子,當我們有容器對象過後,我們所做的就是元素的存取工作,所以put和get方法當然是我們關心的重點。
自然而然,先有存纔有取,所以我們就先來看一下put方法的源碼是怎麼樣子的吧:

    public V put(K key, V value) {
         //當key爲null,調用putForNullKey方法,保存null於table第一個位置中,這也是HashMap允許爲null的原因
        if (key == null)
            return putForNullKey(value);
        // 計算key的哈希值(散列碼)
        // key.hashCode()的調用說明了爲什麼使用HashMap存儲自定義類型時,要求覆寫hashCode()方法
        int hash = hash(key.hashCode());
        // 通過散列碼計算出其應該在散列表(table)中存放的索引位置
        int i = indexFor(hash, table.length);

        // 從結算得到的索引處取出頭結點元素e,然後依次通過e.next遍歷鏈表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //判斷該條鏈上是否有相同的key存在
            //即"hash"相等,且key1=key2或者key1.equals(key2)(這也是爲什麼在用)
            //key.equals(k)的調用說明了爲什麼使用HashMap存儲自定義類型時,要求覆寫equals方法
            //若存在相同,則直接覆蓋value,返回舊value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        // 增加結構修改次數
        modCount++;
        // 將key、value添加至i位置處
        addEntry(hash, key, value, i);
        return null;
    }

    private V putForNullKey(V value) {
        /* 這段代碼你很熟悉,因爲在put方法內部見過,不同的是:
         * 這裏是直接使用table[0]索引處,這也是爲什麼說key爲null,將被直接保存在哈希表的第一個位置。
         */
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

    /*
     * 這就是一個純粹的數學運算,用以計算對象的哈希值。
     * 好像之所以額外定義這樣一個方法,是因爲我們自定義的hashCode方法有時可能寫的很不好。
     * 所以作者額外提供這樣一個計算方法,並且配合indexFor方法提升運行效率。
     */
    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    // 調用該方法得到哈希值對應的存放索引,可以看到計算方法是計算得到的哈希值與table的長度-1做與運算。
    static int indexFor(int h, int length) {
        return h & (length-1);
    }


    void addEntry(int hash, K key, V value, int bucketIndex) {
    //獲取bucketIndex處的Entry
    Entry<K,V> e = table[bucketIndex];
    //將新創建的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry 
    //另外注意到,新的Entry的next被設置爲原來的Entry“e”,也就是說新放入的元素將放置在鏈頭而不是鏈尾。
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        // 如果超過容量極限,則將容量擴大爲2倍
        if (size++ >= threshold)
            resize(2 * table.length);
    }

事實上,上面的代碼我們已經做了較爲詳細的代碼註釋,我們可以由此總結put的工作流程:

  • 首先判斷傳入的key是否爲null,如果是則直接調用putForNullKey將元素存放在table[0]處。
  • 通過“hash(key.hashCode())”計算出key的哈希值;接着通過indexFor計算得到的哈希值在table中對應存放的索引。
  • 對該索引位置進行遍歷,查看該位置是否已經存有元素。如果沒有,則直接將則直接插入。
  • 否則迭代該處元素鏈表並依此比較其key的hash值,此時又分爲兩種情況:
    1、如果通過hash()方法計算得到的”hash”值相等,且key也相同,則代表是對同一個key對應的值做更新,所做的操作也就是用新值覆蓋舊值。
    2、而如果“hash”值相等,但是key不相同。則代表雖然具有相同的散列碼,應該在同一索引位置存放,但實際是兩個不同的鍵值對。則將新的鍵值對添加至該索引處的鏈表表頭。

由此,我們瞭解了put方法的工作原理。接着我們就看與之對應的get方法:

    public V get(Object key) {
        // 先判斷key是否爲null,若爲null則直接通過getForNullKey返回對應的value
        if (key == null)
            return getForNullKey();
        // 計算哈希值
        int hash = hash(key.hashCode());
        // 遍歷該哈希值所處索引處的值,直到滿足hash值相等,且key相同的條件,則代表找到key所對應的值,從而返回。
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        // 爲查找到則返回null
        return null;
    }

    private V getForNullKey() {
        // 不再贅述,唯一的不同就是不用計算hash值,在計算得到索引。直接使用索引“0”
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }

到此,我們對於HashMap源碼的核心部分就都進行了分析。而其他部分的源碼來說,
實際上基本都是在我們上面分析過的部分加上對應的邏輯處理,我們也就不一一再去分析了。
對於HashMap來說,我們可以總結出並記住以下一些有趣的關鍵點:

  • HashMap有一個叫做Entry的內部類,它用來存儲key-value對。
  • 上面的Entry對象是存儲在一個叫做table的Entry數組中。
  • table的索引在邏輯上叫做“桶”(bucket),即我們所說的具有相同散列碼但實際不同的對象我們可以理解爲它們都存放在同一個桶。
  • HashMap.hash(key.hashCode())方法用來找到Entry對象所在的桶。
  • key的equals()方法,被用來進一步確保key的唯一性。
  • value對象的equals()和hashcode()方法根本一點用也沒有。


  • TreeMap源碼淺析

TreeMap顧名思義,其內部就是根據”紅黑樹“的數據結構來實現的。紅黑樹又稱紅-黑二叉樹,它首先是一顆二叉樹,它具體二叉樹所有的特性。同時紅黑樹更是一顆自平衡的排序二叉樹。
我們知道一顆基本的二叉樹他們都需要滿足一個基本性質–即樹中的任何節點的值大於它的左子節點,且小於它的右子節點。
而TreeMap之所以能讓內部存放的元素按照指定的順序來存放,也是基於這個原因。

瞭解了基本的概念,我們仍然從其構造器部分的源碼看起:

    // 比較器對象
    private final Comparator<? super K> comparator;

    // 樹的根節點
    private transient Entry<K,V> root = null;

    // 當前存放數
    private transient int size = 0;

    // 從結構上修改容器的次數
    private transient int modCount = 0;

    public TreeMap() {
        comparator = null;
    }

    public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;
    }

    public TreeMap(Map<? extends K, ? extends V> m) {
        comparator = null;
        putAll(m);
    }

    public TreeMap(SortedMap<K, ? extends V> m) {
        comparator = m.comparator();
        try {
            buildFromSorted(m.size(), m.entrySet().iterator(), null, null);
        } catch (java.io.IOException cannotHappen) {
        } catch (ClassNotFoundException cannotHappen) {
        }
    }

TreeMap的構造器部分,比較簡單,值得我們關注的就是兩個東西:

  • Comparator - 也就是所謂的比較器,我們說了二叉樹的原理就是比較排序,而在TreeMap裏,元素的比較久是通過這個東西完成的。
    Comparator比較後將返回一個int型的值,小於0則代表元素應該放置在父節點左邊,大於0則放在右邊,等於0則代表同一個節點。
  • 另一個則是我們熟悉的“Entry”,當然,這仍是定義在TreeMap當中的全新的類型。打開其源碼:
   static final class Entry<K,V> implements Map.Entry<K,V> {
        K key;    // 傳入的key
        V value;  // 傳入的value
        Entry<K,V> left = null;  // 左子節點元素
        Entry<K,V> right = null; // 右子節點元素
        Entry<K,V> parent;       // 父節點元素
        boolean color = BLACK;   // 顏色(紅/黑)

        /**
         * Make a new cell with given key, value, and parent, and with
         * <tt>null</tt> child links, and BLACK color.
         */
        Entry(K key, V value, Entry<K,V> parent) {
            this.key = key;
            this.value = value;
            this.parent = parent;
        }

我們發現,正如我們所瞭解的二叉樹的結構特點,與鏈表不同,這裏的Entry內部保存是其父節點及左、右子節點的信息。

而實際上,我們已經瞭解二叉樹對於元素的比較排序而存放的原理,而它反應在Java代碼上是如何的?

    public V put(K key, V value) {
            //用t表示二叉樹的當前節點
            Entry<K,V> t = root;
            //t爲null表示一個空樹,即TreeMap中沒有任何元素,直接插入
            if (t == null) {
                //比較key值,個人覺得這個比較好像是多餘的?
                compare(key, key); 
                //將新的key-value鍵值對創建爲一個Entry節點,並將該節點賦予給root
                root = new Entry<>(key, value, null);
                //修改size
                size = 1;
                //修改次數 + 1
                modCount++;
                return null;
            }
            int cmp;    //key經過排序的返回結果
            Entry<K,V> parent;   //父節點

            Comparator<? super K> cpr = comparator;    //指定的排序算法

            //如果cpr不爲空,則採用既定的排序算法進行創建TreeMap集合
            if (cpr != null) {
                do {
                    parent = t;      //parent指向上次循環後的t
                    //比較新增節點的key和當前節點key的大小
                    cmp = cpr.compare(key, t.key);
                    //cmp返回值小於0,表示新增節點的key小於當前節點的key,則以當前節點的左子節點作爲新的當前節點
                    if (cmp < 0)
                        t = t.left;
                    //cmp返回值大於0,表示新增節點的key大於當前節點的key,則以當前節點的右子節點作爲新的當前節點
                    else if (cmp > 0)
                        t = t.right;
                    //cmp返回值等於0,表示兩個key值相等,則新值覆蓋舊值,並返回新值
                    else
                        return t.setValue(value);
                } while (t != null);
            }
            //如果cpr爲空,則採用默認的排序算法進行創建TreeMap集合
            else {
                if (key == null)     //key值爲空拋出異常
                    throw new NullPointerException();
                /* 下面處理過程和上面一樣 */
                Comparable<? super K> k = (Comparable<? super K>) key;
                do {
                    parent = t;
                    cmp = k.compareTo(t.key);
                    if (cmp < 0)
                        t = t.left;
                    else if (cmp > 0)
                        t = t.right;
                    else
                        return t.setValue(value);
                } while (t != null);
            }
            //將新增節點當做parent的子節點
            Entry<K,V> e = new Entry<>(key, value, parent);
            //如果新增節點的key小於parent的key,則當做左子節點
            if (cmp < 0)
                parent.left = e;
          //如果新增節點的key大於parent的key,則當做右子節點
            else
                parent.right = e;
            /*
             *  上面已經完成了排序二叉樹的的構建,將新增節點插入該樹中的合適位置
             *  下面fixAfterInsertion()方法就是對這棵樹進行調整、平衡,具體過程參考上面的五種情況
             */
            fixAfterInsertion(e);
            //TreeMap元素數量 + 1
            size++;
            //TreeMap容器修改次數 + 1
            modCount++;
            return null;
        }

注意了,對於上面的代碼之中do-while之間的算法過後,實際上就可以完成普通二叉樹的排序。
但是,我們說過了TreeSet是基於紅黑樹來實現,紅黑樹是一種平衡排序二叉樹。
也就是說,還需要對元素的存放存續進行適當調整,這個調整工作就是通過fixAfterInsertion完成的。
這個方法我也沒具體研究明白,就跳過不談了。而對應的,TreeMap的get方法又是怎麼樣的呢?

    public V get(Object key) {
        Entry<K,V> p = getEntry(key);
        return (p==null ? null : p.value);
    }

    final Entry<K,V> getEntry(Object key) {
        // Offload comparator-based version for sake of performance
        if (comparator != null)
            return getEntryUsingComparator(key);
        if (key == null)
            throw new NullPointerException();
    Comparable<? super K> k = (Comparable<? super K>) key;
        // 獲取根節點
        Entry<K,V> p = root;
        // 進行遍歷
        while (p != null) {
            // 通過compareTo進行比較
            int cmp = k.compareTo(p.key);
            // 如果比較結果小於0,則繼續向左邊的子樹進行遍歷
            if (cmp < 0)
                p = p.left;
            // 如果比較結果大於0,則繼續向右邊的子樹進行遍歷
            else if (cmp > 0)
                p = p.right;
            // 等於0,則代表找到了對應的節點
            else
                return p;
        }
        return null;
    }

因爲TreeMap底層是基於紅黑樹的,而紅黑樹的實現本身的確十分複雜。
因爲能力有限,所以在這裏我們就只對TreeMap裏比較常用關鍵的一些源碼做了分析,
主要的目的是對TreeMap的實現原理有一定的瞭解,並知道這種數據結構能給我們帶來什麼方便。

迭代器 - 統一對容器的訪問方式

通過前面這麼多的介紹,我們知道Java的集合框架提供了各種各樣特點的容器類型。
不同的數據結構也導致了它們對於元素的訪問形式的不一,而我們知道代碼的通用性有多麼重要?
所以,如何通過一種方式能夠通用的對所有的容器類型進行訪問,就很關鍵了。
而迭代器 (iterator),也就是這樣,應運而生。

迭代器的使用

我們首先來看一下,對於迭代器來說,它的使用方式是怎麼樣的?

    public static void main(String[] args) {
        Collection<String> c = new ArrayList<String>();
        c.add("1");
        c.add("2");
        c.add("3");
        c.add("4");

        Iterator<String> itr = c.iterator(); 
        while (itr.hasNext()) {
            System.out.println(itr.next()); 
        }

    } 

對於迭代器的時候,相比大家都比較熟悉。我們也不多說,我們這裏來關心一下,我們怎麼樣纔可以構建自己的迭代器。

  • iterator與iteratable接口

實際上,向我們平常常用的容器類的迭代器都是通過iterator與iteratable來實現的。
舉例來說,假設來定義一個屬於自己的簡易的容器類:

public class SimpleCollection<E>{
    private Object[] array;

    private final static int DEFAULT_CAPACITY = 16;

    private int size = 0;

    public SimpleCollection() {
        this(DEFAULT_CAPACITY);
    }

    public SimpleCollection(int capacity) {
        array = new Object[capacity];
    }

    public void add(E e){
        if(size < 0 || size > array.length)
            throw new ArrayIndexOutOfBoundsException();
        array[size++] = e;
    }

    public E get(int index){
        if(index < 0 || index > array.length)
            throw new ArrayIndexOutOfBoundsException();

        return (E) array[index];
    }

    public int size(){
        return size;
    }

}

我們當然可以通過最平常的方式來訪問我們自己的容器:

    public static void main(String[] args) {
        SimpleCollection<String> sc = new SimpleCollection<String>();
        sc.add("123");
        sc.add("345");
        sc.add("456");

        for (int i = 0; i < sc.size(); i++) {
            System.out.println(sc.get(i));
        }

    }

但是,如果我們想要通過迭代器的方式來訪問又該怎麼樣去做呢?我們可以在我們的容器類當中添加如下的代碼:

    public Iterator<E> iterator(){
        return new SimpleIterator<E>();
    }

    private class SimpleIterator<E> implements Iterator<E>{

        private int index = 0;
        @Override
        public boolean hasNext() {
            return size - index > 0 ;
        }

        @Override
        public E next() {
            return (E) array[index++];
        }

        @Override
        public void remove() {
            throw new UnsupportedOperationException();
        }

    }

現在我們就可以將訪問容器的代碼修改爲下面這樣使用了:

    public static void main(String[] args) {
        SimpleCollection<String> sc = new SimpleCollection<String>();
        sc.add("123");
        sc.add("345");
        sc.add("456");

        Iterator<String> itr = sc.iterator();

        while(itr.hasNext()){
            System.out.println(itr.next());
        }

    }

而我們打開另一個接口iteratable的源碼:

public interface Iterable<T> {

    /**
     * Returns an iterator over a set of elements of type T.
     * 
     * @return an Iterator.
     */
    Iterator<T> iterator();
}

我們發現該接口實際上就是提供了一個返回Iterator的方法。這個接口有什麼用?我們下面就將看到。


  • for each語法原理

對於對象使用for each語法,實際上原理就是基於Iterable接口。
不知道大家在使用Java的過程中,有沒有寫過類似下面的代碼:

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

        for (String string : list) {
            if(string.equals("1"))
                list.remove("1");
        }

當我們運行上面的帶的時候,就會得到一個異常:java.util.ConcurrentModificationException。
而造成這個異常的原因,我們現在也可以分析出來了,這是因爲:

  • 我們已經知道了,ArrayList有個modCount字段表示ArrayList被修改的次數
  • 而foreach語法的原理,最終就還是調用iterator接口。對於ArrayList來說,iterator是在AbstarctList中默認實現的。
  • 在獲取iterator的時候ArrayList的modCount會被保存在iterator的expectedModCount中。
  • 每次修改ArrayList的時候modCount就會改變,例如我們上面代碼中做的remove操作。
  • 而在每次調用iterator的hasNext和next方法的時候,會先檢查ArrayList的modCount和iterator的expectedModCount是否相同,不同就會拋出上面的異常。

那麼,如果我們想讓我們上面自己定義的容器類SimpleCollection能夠使用for-each語法,我們就可以讓類它實現iterable接口。
而由於,我們之前的代碼就已經實現了iterator方法,所以都不用做任何修改了。
最終,我們就可以用for-each的形式來訪問容器了:

    public static void main(String[] args) {
        SimpleCollection<String> c = new SimpleCollection<String>();
        c.add("1");
        c.add("2");
        c.add("3");

        for (String str : c) {
            System.out.println(str);
        }

    }


集合框架的使用總結

對於集合框架的使用選擇,《Thinking in java》一書中實際做了很詳細的總結:

  • Collection保存單一的元素;Map保存相關的鍵值對。
  • 如果要進行大量的隨機訪問,則應該選擇ArrayList;如果要在表中間進行頻繁的元素操作,則應該選擇LinkedList。
  • 各種Queue以及棧數據結構的操作,由LinkedList負責提供支持。
  • HashMap設計用來快速訪問;而TreeMap則保持“鍵”使用保持排序狀態。
    LinkedHashMap保持元素插入的順序,同時也通過實現散列提供了快速訪問能力。
  • Set不接受重複元素;HashSet提供最快的元素訪問速度;TreeSet讓元素保持排序狀態;
  • 新程序中不應該使用過時的Vector,HashTable和Stack。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章