Java泛型底層源碼解析-ArrayList,LinkedList,HashSet和HashMap


聲明:以下源代碼使用的都是基於JDK1.8_112版本

1. ArrayList源碼解析

  <1. 集合中存放的依然是對象的引用而不是對象本身,且無法放置原生數據類型,我們需要使用原生數據類型的包裝類才能加入到集合中去

  <2. 集合中放置的都是Object類型,因此取出來的也是Object類型,那么必須要使用強制類型轉換將其轉換為真正需要的類型即放置進行的類型

1 ArrayList list = new ArrayList();
2 list.add(new Integer(4)); list.add("abc");
3 System.out.println((Integer)list.get(0));
4 System.out.println((String)list.get(1));

  <3. ArrayList底層采用數組實現,當使用不帶參數的構造方法生成ArrayList對象時,實際上會在底層生成一個長度為10的Object類型數組。

    這里需要區分JDK版本的區別,jdk1.6或之前底層在擴容的時候使用的是基本乘法運算:(oldCapacity * 3)/2 + 1 ; 而在jdk1.7之后底層在擴容的時候采用位移運算,且也沒有多加1操作:oldCapacity + (oldCapacity >> 1)  (我猜想應該是充分考慮提升運算性能)

    即:JDK1.6以及之前擴容規則為:1.5倍+1 ; JDK1.7以及之后擴容規則為:1.5倍

  <4. 真正的擴容是將原數組的內容復制到新數組當中,並且后續增加的內容都會放到這個新的數組當中去。

  這里貼出來jdk1.8擴容代碼:

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

其中elementData定義如下:

transient Object[] elementData; // non-private to simplify nested class access

  transient Object[] elementData;,細心的盆友可以看出它的關鍵字是transient,看到這個關鍵字,都以為此數組是不可序列化的,其實不然,因為ArrayList實現了Serializable的writeObject()可以定制化,ArrayList實現writeObject並且強制將 transient elementData 序列化。那么為什么這樣設計呢?個人認為是因為ArrayList內部實現是數組,大部分情況下會有空值,比如elementData的大小是10,但實際只有6個元素,那么剩下的4個元素沒有實際的意義,如果直接將此標識為可序列化,那么最終會把空值同樣序列化,因此將elementData設計為transient,然后實現Serializable的writeObject()方法將其序列化,只序列化實際存儲的元素,而不是整個數組。當然還是個人認為,降低序列化的傳輸量來變向的提升性能(速度)

  這里如果有盆友不理解transient相關概念,請查看我的另一篇博客:http://www.cnblogs.com/liang1101/p/6382765.html 

  <5. boolean add(E) 和 void add(int, E) 底層使用的方法不同

  直接add對象到集合中默認是將該對象加入到數據最末端,這樣也是最快的,即:elementData[size++] = e;-->將元素賦值給數組順序的最后一位

  而指定插入位置的add方法則需要從指定位置+1的位置開始往后使用System.arraycopy方法賦值數組操作,之后再將對應的元素賦值,具體源代碼:

1 public void add(int index, E element) {
2     rangeCheckForAdd(index);
3 
4     ensureCapacityInternal(size + 1);  // Increments modCount!!
5   //public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
6 System.arraycopy(elementData, index, elementData, index + 1, size - index); 7 elementData[index] = element; 8 size++; 9 }

對應還有set(int, E)、remove(int)、remove(Object)等都是類似的行為,即數組是怎么操作的ArrayList底層就應該是怎么操作的。

2. LinkedList源碼解析

 LinkedList底層源碼是采用雙向鏈表的方式實現的,具體雙向列表初始化定義如下:

 1 1 private static class Node<E> {
 2  2     E item;  //結點的值
 3  3     Node<E> next;  //結點的后繼指針
 4  4     Node<E> prev;  //結點的前驅指針
 5  5    //構造函數完成Node成員的賦值
 6  6     Node(Node<E> prev, E element, Node<E> next) {
 7  7         this.item = element;
 8  8         this.next = next;
 9  9         this.prev = prev;
10 10     }
11 11 }

  何為雙向列表,單向鏈表為通過后繼可以找到下一個指向的元素;雙向鏈表為既可以通過后繼找到下一個指向的元素,也可以通過前驅找到前一個元素。基於鏈表實現的方式使得LinkedList在插入和刪除時更優於ArrayList,而隨機訪問則比ArrayList遜色些。那么剩下的add、remove等方法就是改變相應的指向即可,這個實現起來就很簡單了,這里就不再做詳細的說明了,不會的可以查看一下源代碼就明白了。

3. HashSet源碼解析

首先,咱們經常使用的HashSet的無參構造函數,那么來看一下對應的源碼:

/**
 * Constructs a new, empty set; the backing HashMap instance has
 * default initial capacity (16) and load factor (0.75).
 */
public HashSet() {
    map = new HashMap<>();
}

從以上源代碼可以看出,HashSet底層是使用HashMap來實現的,且對應HashMap默認初始長度為16,對應的負載因子為0.75。那我們再看看常用的add()、remove()、iterator()等方法源代碼:

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

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

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

其中PRESENT常量定義如下:

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

  從以上源代碼發現,當使用add()方法將對象添加到Set當中時,實際上是將該對象作為底層所維護的Map對象的key,而value則是同一個Object對象(該對象我們其實是用不上的)

那么既然HashSet底層直接使用的是HashMap進行維護,那么我們的重點就是要分析HashMap底層源代碼到底是什么情況。

4. HashMap源碼解析

  首先,咱們經常使用的HashMap的無參構造函數,那么來看一下對應的源碼:(JDK1.8代碼)

/**
 * Constructs an empty HashMap with the default initial capacity (16) and the default load factor (0.75).
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

而對應的JDK1.7和JDK1.6代碼有一定的區別,如下:

/**
 * Constructs an empty HashMap with the default initial capacity (16) and the default load factor (0.75).
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
    table = new Entry[DEFAULT_INITIAL_CAPACITY];
}

  由上面不同版本比較發現,在1.8之后其他不必要的參數都已經修改為全局默認值,不需要在每次申請的時候再進行開辟。我的理解可能為了提升創建對象的性能,可見java底層為了提升性能可謂是下足了功夫。

  再來看下他們共同的常量設置:

/**
 * The default initial capacity - MUST be a power of two.
 * JDK1.8
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka-->as known as(亦稱) 16
//static final int DEFAULT_INITIAL_CAPACITY = 16; //JDK1.7 OR JDK1.6源代碼

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

  看見了沒,這么細小的優化真的是"無所不用其極"了,java一直秉承着:快才是王道的真理,做的很到位啊!其中需要注意到注釋標紅的地方,意思是對應的HashMap初始大小"必須為2的整數倍"才可以,這點需要注意,具體原因請繼續往后看,后面有詳細的解釋說明。

  通過JDK1.7的HashMap構造函數可以發現並推斷,HashMap底層實現也是基於數組實現的,但是該數組為Entry對象數組,不過這點通過查看源代碼發現1.7之前是直接使用Entry對象來操作的,在1.8之后換為Node對象了,它們兩者都是繼承自Map.Entry<K,V>,所以都是一樣的,我猜想應該是為了區別Map中的Entry對象防止混用,故將底層封裝對象該名為Node,具體就上源代碼看吧:

JDK1.8源碼:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

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

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}
View Code

JDK1.7和JDK1.6源碼:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    final int hash;

    /**
     * Creates new entry.
     */
    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;
    }

    public final boolean equals(Object o) {
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        Object k1 = getKey();
        Object k2 = e.getKey();
        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;
    }

    public final int hashCode() {
        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) {
    }
}
View Code

  通過源代碼發現,在構造節點的時候又一個next屬性,其實它就是指向下一個的引用,不難推測到HashMap底層其實是:基於數組和單向鏈表結合的存儲方式實現。其中不同版本的JDK存取值都是要計算key值的hash值,計算hash值的作用就是避免hash碰撞,盡量減少單向鏈表的產生,因為鏈表中查找一個元素需要遍歷。HashMap底層結構圖:

  這里需要提到的是,使用hashMap的時候,引入的key對象必須重寫hashCode()和equal()兩個函數,原因可以參考源碼判斷條件(if (e.hash == hash && ((k = e.key) == key || key.equals(k)))),如果hashCode()沒重寫,則壓根找不到對應數組,如果equal()沒重寫,則無法判斷key值的內容是否相等。

Get — 獲取數據

1.7底層源碼使用的是有一個很關鍵的地方為:

static int indexFor(int h, int length) {
     return h & (length-1);
}

  第一次看到這個方法很是不理解,不是應該用 h % length嗎?其實這里用了一個非常巧妙的方法來取這個余數。在計算機中CPU做除法運算、取余運算耗費的CPU周期都比較長,一般幾十個CPU周期,而位移運算、位運算只用一個CPU周期。這樣對於性能要求高的地方,就可以用位運算代替普通的除法、取余等運算,JDK源碼中有很多這樣的例子。

  為了能夠使用位運算求出這個余數,length必須是2的N次方,這也是我們上面初始化數組大小時要求的,然后使用 h & (length-1),就可以求出余數。具體的算法推導,請自行搜索。

  我們用個例子來說明下,如一個Key經過運算的hash為21,length為16:

  直接取余運算:21 % 16 = 5

  位運算:10101(21) & 01111(16-1) = 00101(5)

  哇,這就是計算機運算的魅力,這就是算法的作用。

  另外,在java8之后hashmap進行了優化:由於單向鏈表的查詢時間復雜度為O(n),在極端情況下可能存在性能問題,於是java8針對鏈表長度大於8的情況會使用時間復雜度為O(log n)的紅黑樹進行存儲來提升存儲查詢的效率。具體可以查看java8的源代碼

addEntry — 添加數據

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

  threshold:HashMap實際可以存儲的Key的個數,如果size大於threshold,說明HashMap已經太飽和了,非常容易發生hash碰撞,導致單向鏈表的產生。

  在inflateTable方法中,我們可以看到

threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);

  所以這個值是由HashMap的capacity 和負載因子(loadFactor默認:0.75)計算出來的。loadFactor越小,相同的capacity就更頻繁地擴容,這樣的好處是HashMap會很大,產生hash碰撞的幾率就更小,但需要的內存也更多,這就是所謂的空間換時間。
  在這里也注意,擴容時會直接將原來容量乘以2,滿足了length為2的N次方的條件。

整個處理過程:

  put的時候,首先會根據key的hashCode值計算出一個位置,該位置就是此對象准備往數組中存放的位置。如果該位置沒有對象存在,就將此對象直接放到數組當中;如果該位置已經有對象存在,則順着此存在的對象的鏈開始尋找(Entry或Node類有一個其自身類型的 next 成員變量,指向了該對象的下一個對象),如果此鏈上有對象的話,再去使用equals方法進行比較,都比較完發現都是false,則將該對象放到數組中,然后將數組中該位置以前存在的那個對象鏈接到此對象的后面。(這個是因為我們一般后進入的數據應該是屬於比較新或熱的數據,用戶一般常用的是最新數據,故將后進入的數據優先放入到數組上可以直接查詢到,將以前存在的數據往列表上順延)

HashMap源碼自我認知總結

  (1)HashMap的內部存儲結構其實是數組和鏈表的結合。當實例化一個HashMap時,系統會創建一個長度為Capacity的Entry數組,這個長度被稱為容量(Capacity),在這個數組中可以存放元素的位置我們稱之為“桶”(bucket),每個bucket都有自己的索引,系統可以根據索引快速的查找bucket中的元素。每個bucket中存儲一個元素,即一個Entry對象,但每一個Entry對象可以帶一個引用變量,用於指向下一個元素,因此,在一個桶中,就有可能生成一個Entry鏈。 
  (2) 在存儲一對值時(Key—->Value對),實際上是存儲在一個Entry的對象e中,程序通過key計算出Entry對象的存儲位置。換句話說,Key—->Value的對應關系是通過key—-Entry—-value這個過程實現的,所以就有我們表面上知道的key存在哪里,value就存在哪里。 
  (3)HashMap的沖突處理是用的是鏈地址法, 將所有哈希地址相同的記錄都鏈接在同一鏈表中。也就是說,當HashMap中的每一個bucket里只有一個Entry,不發生沖突時,HashMap是一個數組,根據索引可以迅速找到Entry。但是,當發生沖突時,單個的bucket里存儲的是一個Entry鏈,系統必須按順序遍歷每個Entry,直到找到為止。為了減少數據的遍歷,沖突的元素都是直接插入到當前的bucket中的,所以,最早放入bucket中的Entry,位於Entry鏈中的最末端。這從put(K key,V value)中也可以看出,在同一個bucket存儲Entry鏈的情況下,新放入的Entry總是位於bucket中。

生產應用總結

HashMap是一個非常高效的Key、Value數據結構,GET的時間復雜度為:O(1) ~ O(n),我們在使用HashMap時需要注意以下幾點:

  1. 聲明HashMap時最好使用帶initialCapacity的構造函數,傳入數據的最大size,可以避免內部數組resize;

  2. 性能要求高的地方,可以將loadFactor設置的小於默認值0.75,使hash值更分散,用空間換取時間;

 

  以上都是個人的見解,有什么不對的或者有什么建議的歡迎指正,希望不吝賜教哈!后續我會將其他源碼心得持續更新的,敬請關注哈!!!另外,JDK1.8對應的HashMap有了很大的改變,底層不僅使用了數組 + 鏈表,還使用了 紅黑二叉樹 用來提升查詢性能,這快需要單獨提出一章來說這塊的內容;還有,針對多線程下的ConcurrentHashMap也是一個比較重要的知識點,也需要單獨拿出來說一下。如果有感興趣的盆友,歡迎查看我后續的博客:http://www.cnblogs.com/liang1101/p/6407871.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM