第六章 Java數據結構和算法 之 容器類(一)


數據結構是以某種形式將數據組織在一起的集合,它不僅存儲數據,還支持訪問和處理數據的操作。JDK提供了幾個能有效地組織和操作數據的數據結構(位於java.util包),這些數據結構通常稱爲Java集合框架。
在這裏插入圖片描述

一、常見集合類概述

集合繼承關係圖
在這裏插入圖片描述
在Java容器中一共定義了2種集合, 頂層接口分別是Collection和Map。但是這2個接口都不能直接被實現使用,分別代表兩種不同類型的容器。
簡單來看,Collection代表的是單個元素對象的序列,(可以有序/無序,可重複/不可重複 等,具體依據具體的子接口Set,List,Queue等);Map代表的是“鍵值對”對象的集合(同樣可以有序/無序 等依據具體實現)

(1)Collection 集合接口

Collection是最基本的集合接口,存儲對象元素集合。一個Collection代表一組Object(元素)。有些容器允許重複元素有的不允許,有些有序有些無需。由Collection接口派生的兩個接口是List和Set。這個接口的設計目的是希望能最大程度抽象出元素的操作。
定義

public interface Collection<E> extends Iterable<E> {
    ...
}

泛型即該Collection中元素對象的類型,繼承的Iterable是定義的一個遍歷操作接口,採用hasNext next的方式進行遍歷。具體實現還是放在具體類中去實現。
主要方法

  • boolean add(Object o) 添加對象到集合
  • boolean remove(Object o) 刪除指定的對象
  • int size() 返回當前集合中元素的數量
  • boolean contains(Object o) 查找集合中是否有指定的對象
  • boolean isEmpty() 判斷集合是否爲空
  • Iterator iterator() 返回一個迭代器
  • boolean containsAll(Collection c) 查找集合中是否有集合c中的元素
  • boolean addAll(Collection c) 將集合c中所有的元素添加給該集合
  • void clear() 刪除集合中所有元素
  • void removeAll(Collection c) 從集合中刪除c集合中也有的元素
  • void retainAll(Collection c) 從集合中刪除集合c中不包含的元素

1、List子接口

List是一個允許重複元素的指定索引、有序集合。
從List接口的方法來看,List接口增加了面向位置的操作,允許在指定位置上操作元素。用戶可以使用這個接口精準掌控元素插入,還能夠使用索引index(元素在List中的位置,類似於數組下標)來訪問List中的元素。List接口有兩個重要的實現類:ArrayList和LinkedList。
Set裏面和List最大的區別是Set裏面的元素對象不可重複。

(1)ArrayList 數組

定義
1、ArrayList實現了List接口的可變大小的數組。(數組可動態創建,如果元素個數超過數組容量,那麼就創建一個更大的新數組)
2、它允許所有元素,包括null
3、它的size, isEmpty, get, set, iterator,add這些方法的時間複雜度是O(1),如果add n個數據則時間複雜度是O(n)
4、ArrayList沒有同步方法
常用方法

  • Boolean add(Object o)將指定元素添加到列表的末尾
  • Boolean add(int index,Object element)在列表中指定位置加入指定元素
  • Boolean addAll(Collection c)將指定集合添加到列表末尾
  • Boolean addAll(int index,Collection c)在列表中指定位置加入指定集合
  • Boolean clear()刪除列表中所有元素
  • Boolean clone()返回該列表實例的一個拷貝
  • Boolean contains(Object o)判斷列表中是否包含元素
  • Boolean ensureCapacity(int m)增加列表的容量,如果必須,該列表能夠容納m個元素
  • Object get(int index)返回列表中指定位置的元素
  • Int indexOf(Object elem)在列表中查找指定元素的下標
  • Int size()返回當前列表的元素個數
    常見源碼分析
    add代碼分析
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }

    ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

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);
}

remove代碼分析

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

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

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

    return oldValue;
}

其實就是直接使用System.arraycopy把需要刪除index後面的都往前移一位然後再把最後一個去掉。

與之前學的數據結構中數組實現方法聯繫

(2)LinkedList 鏈表

定義
1、LinkedList是一個實現了List接口的鏈表維護的序列容器
2、允許null元素。
3、LinkedList提供額外的get,remove,insert方法在LinkedList的首部或尾部。這些操作使LinkedList可被用作堆棧(stack),隊列(queue)或雙向隊列(deque)。
4、LinkedList沒有同步方法。如果多個線程同時訪問一個List,則必須自己實現訪問同步。
結點定義(雙向鏈表)

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;
    }
}

鏈表定義
每個LinkedList中會持有鏈表的頭指針和尾指針

transient int size = 0;
transient Node<E> first;
transient Node<E> last;

插入/刪除操作

private void linkFirst(E e) {
    final Node<E> f = first;
    final Node<E> newNode = new Node<>(null, e, f);
    first = newNode;
    if (f == null)
        last = newNode;
    else
        f.prev = newNode;
    size++;
    modCount++;
}

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++;
}
    
void linkBefore(E e, Node<E> succ) {
    // assert succ != null;
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;
}

private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    final E element = f.item;
    final Node<E> next = f.next;
    f.item = null;
    f.next = null; // help GC
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;
    return element;
}

private E unlinkLast(Node<E> l) {
    // assert l == last && l != null;
    final E element = l.item;
    final Node<E> prev = l.prev;
    l.item = null;
    l.prev = null; // help GC
    last = prev;
    if (prev == null)
        first = null;
    else
        prev.next = null;
    size--;
    modCount++;
    return element;
}

E unlink(Node<E> x) {
    // assert x != null;
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    if (prev == null) {
        first = next;
    } else {
        prev.next = next;
        x.prev = null;
    }

    if (next == null) {
        last = prev;
    } else {
        next.prev = prev;
        x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
}
(2.1)ArrayList與LinkedList

鏈表LinkedList和數組ArrayList的最大區別在於它們對元素的存儲方式的不同導致它們在對數據進行不同操作時的效率不同。實際使用時根據特定的需求選用合適的類。

  • 查找方面。數組的效率更高,可以直接索引出查找,而鏈表必須從頭查找。
  • 插入刪除方面。特別是在中間進行插入刪除,這時候鏈表體現出了極大的便利性,只需要在插入或者刪除的地方斷掉鏈然後插入或者移除元素,然後再將前後鏈重新組裝,但是數組必須重新複製一份將所有數據後移或者前移。
  • 在內存申請方面,當數組達到初始的申請長度後,需要重新申請一個更大的數組然後把數據遷移過去纔行。而鏈表只需要動態創建即可。
    如果主要是查找元素則使用ArrayList。
    如果主要是插入/刪除元素則使用LinkedList。
    在這裏插入圖片描述

(3)Vector 向量

Vector非常類似ArrayList。
Vector是同步的。當一個Iterator被創建而且正在被使用,另一個線程改變了Vector的狀態(例如,添加或刪除了一些元素),這時調用Iterator的方法時將拋出ConcurrentModificationException,因此必須捕獲該異常。

(3.1)Stack 棧

Stack繼承自Vector,實現一個後進先出的堆棧。Stack提供5個額外的方法使得Vector得以被當作堆棧使用。基本的push和pop方法,還有peek方法得到棧頂的元素,empty方法測試堆棧是否爲空,search方法檢測一個元素在堆棧中的位置。

2、Set子接口

Set是一種不包含重複的元素的Collection,即任意的兩個元素e1和e2都有e1.equals(e2)=false,Set最多有一個null元素。

(1)HashSet 散列集

HashSet實現了Set接口,基於HashMap進行存儲。遍歷時不保證順序,並且不保證下次遍歷的順序和之前一樣。HashSet中允許null元素。

private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();

意思就是HashSet的集合其實就是HashMap的key的集合,然後HashMap的val默認都是PRESENT。HashMap的定義即是key不重複的集合。使用HashMap實現,這樣HashSet就不需要再實現一遍。
所以所有的add,remove等操作其實都是HashMap的add、remove操作。遍歷操作其實就是HashMap的keySet的遍歷

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

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

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

public void clear() {
    map.clear();
}
...
(1.1)LinkedHashSet 鏈式散列集

LinkedHashSet的核心概念相對於HashSet來說就是一個可以保持順序的Set集合。HashSet是無序的,LinkedHashSet會根據add,remove這些操作的順序在遍歷時返回固定的集合順序。這個順序不是元素的大小順序,而是可以保證2次遍歷的順序是一樣的。
類似HashSet基於HashMap的源碼實現,LinkedHashSet的數據結構是基於LinkedHashMap。

(2)TreeSet 樹形集

TreeSet即是一組有次序的集合,如果沒有指定排序規則Comparator,則會按照自然排序。(自然排序即e1.compareTo(e2) == 0作爲比較)
TreeSet源碼的算法即基於TreeMap,擴展自AbstractSet,並實現了NavigableSet,AbstractSet擴展自AbstractCollection,樹形集是一個有序的Set,其底層是一顆樹,這樣就能從Set裏面提取一個有序序列了。在實例化TreeSet時,我們可以給TreeSet指定一個比較器Comparator來指定樹形集中的元素順序。樹形集中提供了很多便捷的方法。

注:TreeSet內的元素必須實現Comparable接口。

public class TestSet {

    public static void main(String[] args) {

        TreeSet<Integer> set = new TreeSet<>();

        set.add(1111);
        set.add(2222);
        set.add(3333);
        set.add(4444);
        set.add(5555);

        System.out.println(set.first()); // 輸出第一個元素
        System.out.println(set.lower(3333)); //小於3333的最大元素
        System.out.println(set.higher(2222)); //大於2222的最大元素
        System.out.println(set.floor(3333)); //不大於3333的最大元素
        System.out.println(set.ceiling(3333)); //不小於3333的最大元素

        System.out.println(set.pollFirst()); //刪除第一個元素
        System.out.println(set.pollLast()); //刪除最後一個元素
        System.out.println(set);
    }
}

在這裏插入圖片描述

3、Queue 隊列

隊列是一種先進先出的數據結構,元素在隊列末尾添加,在隊列頭部刪除。Queue接口擴展自Collection,並提供插入、提取、檢驗等操作。

  • offer:向隊列添加一個元素
  • poll:移除隊列頭部元素(隊列爲空返回null)
  • remove:移除隊列頭部元素(隊列爲空拋出異常)
  • element:獲取頭部元素
  • peek:獲取頭部元素

(1)Deque 雙端隊列

接口Deque,是一個擴展自Queue的雙端隊列,它支持在兩端插入和刪除元素,因爲LinkedList類實現了Deque接口,所以通常我們可以使用LinkedList來創建一個隊列。PriorityQueue類實現了一個優先隊列,優先隊列中元素被賦予優先級,擁有高優先級的先被刪除。

Queue<String> queue = new LinkedList<>();

補:Iterator迭代器

如何遍歷Collection中的每一個元素?不論Collection的實際類型如何,它都支持一個iterator()的方法,該方法返回一個迭代子,使用該迭代子即可逐一訪問Collection中每一個元素。典型的用法如下:

Iterator it = collection.iterator(); // 獲得一個迭代子  
while(it.hasNext()) {  
Object obj = it.next(); // 得到下一個元素  
} 

(2)Map 圖接口

Map是圖接口,存儲鍵值對映射的容器類。Map提供key到value的映射。一個Map中不能包含相同的key,每個key只能映射一個value。
接口定義

public interface Map<K,V> {
    ...
    
    interface Entry<K,V> {
        K getKey();
        V getValue();
        ...
    } 
}

泛型<K,V>分別代表key和value的類型。這時候注意到還定義了一個內部接口Entry,其實每一個鍵值對都是一個Entry的實例關係對象,所以Map實際其實就是Entry的一個Collection,然後Entry裏面包含key,value。再設定key不重複的規則,自然就演化成了Map。
遍歷方法
Map集合提供3種遍歷訪問方法,

  1. Set keySet() 獲得所有key的集合然後通過key訪問value
    會返回所有key的Set集合,因爲key不可以重複,所以返回的是Set格式,而不是List格式。(之後會說明Set,List區別。這裏先告訴一點Set集合內元素是不可以重複的,而List內是可以重複的) 獲取到所有key的Set集合後,由於Set是Collection類型的,所以可以通過Iterator去遍歷所有的key,然後再通過get方法獲取value。如下
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");

Set<String> keySet = map.keySet();//先獲取map集合的所有鍵的Set集合
Iterator<String> it = keySet.iterator();//有了Set集合,就可以獲取其迭代器。

while(it.hasNext()) {
       String key = it.next();
       String value = map.get(key);//有了鍵可以通過map集合的get方法獲取其對應的值。
       System.out.println("key: "+key+"-->value: "+value);//獲得key和value值
}
  1. Collection values() 獲得value的集合
    直接獲取values的集合,無法再獲取到key。所以如果只需要value的場景可以用這個方法。獲取到後使用Iterator去遍歷所有的value。如下
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");

Collection<String> collection = map.values();//返回值是個值的Collection集合
System.out.println(collection);
  1. Set< Map.Entry< K, V>> entrySet() 獲得key-value鍵值對的集合
    getValue獲取key和value。如下
Map<String,String> map = new HashMap<String,String>();
map.put("01", "zhangsan");
map.put("02", "lisi");
map.put("03", "wangwu");

//通過entrySet()方法將map集合中的映射關係取出(這個關係就是Map.Entry類型)
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//將關係集合entrySet進行迭代,存放到迭代器中                
Iterator<Map.Entry<String, String>> it = entrySet.iterator();

while(it.hasNext()) {
       Map.Entry<String, String> me = it.next();//獲取Map.Entry關係對象me
       String key = me.getKey();//通過關係對象獲取key
       String value = me.getValue();//通過關係對象獲取value
}

通過以上3種遍歷方式我們可以知道,如果你只想獲取key,建議使用keySet。如果只想獲取value,建議使用values。如果key value希望遍歷,建議使用entrySet。
Map的訪問順序取決於Map的遍歷訪問方法的遍歷順序。 有的Map,比如TreeMap可以保證訪問順序,但是有的比如HashMap,無法保證訪問順序。
主要方法

  • boolean equals(Object o)比較對象
  • boolean remove(Object o)刪除一個對象
  • put(Object key,Object value)添加key和value

1、HashTable 哈希表

Hashtable繼承Map接口,實現一個key-value映射的哈希表。任何非空(non-null)的對象都可作爲key或者value。
添加數據使用put(key, value),取出數據使用get(key),這兩個基本操作的時間開銷爲常數。HashTable是同步方法,線程安全但是效率低。

由於作爲key的對象將通過計算其散列函數來確定與之對應的value的位置,因此任何作爲key的對象都必須實現hashCode和equals方法。hashCode和equals方法繼承自根類Object,如果你用自定義的類當作key的話,要相當小心,按照散列函數的定義,如果兩個對象相同,即obj1.equals(obj2)=true,則它們的hashCode必須相同,但如果兩個對象不同,則它們的hashCode不一定不同,如果兩個不同對象的hashCode相同,這種現象稱爲衝突,衝突會導致操作哈希表的時間開銷增大,所以儘量定義好的hashCode()方法,能加快哈希表的操作。
如果相同的對象有不同的hashCode,對哈希表的操作會出現意想不到的結果(期待的get方法返回null),要避免這種問題,只需要牢記一條:要同時複寫equals方法和hashCode方法,而不要只寫其中一個。

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

  • 鍵(key):又稱爲關鍵字。唯一的標示要存儲的數據,可以是數據本身或者數據的一部分。
  • 槽(slot/bucket):哈希表中用於保存數據的一個單元,也就是數據真正存放的容器。
  • 哈希函數(hash function):將鍵(key)映射(map)到數據應該存放的槽(slot)所在位置的函數。
  • 哈希衝突(hash collision):哈希函數將兩個不同的鍵映射到同一個索引的情況。

解決哈希衝突的方法:

  • 拉鍊法
    哈希衝突後,用鏈表去延展來解決。將所有關鍵字爲同義詞的記錄存儲在同一線性鏈表中。如下圖:
    在這裏插入圖片描述
  • 開地址法
    哈希衝突後,並不會在本身之外開拓新的空間,而是繼續順延下去某個位置來存放。
    開放地執法有一個公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)
    其中,m爲哈希表的表長。di 是產生衝突的時候的增量序列。如果di值可能爲1,2,3,…m-1,稱線性探測再散列。
    如果di取1,則每次衝突之後,向後移動1個位置.如果di取值可能爲1,-1,2,-2,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2),稱二次探測再散列。
    如果di取值可能爲僞隨機數列。稱僞隨機探測再散列。

2、HashMap 哈希表

HashMap和Hashtable類似,不同之處在於HashMap是非同步的,並且允許null,即null value和null key。
定義
HashMap就是最基礎最常用的一種Map,它無序,以散列表的方式進行存儲。之前提到過,HashSet就是基於HashMap,只使用了HashMap的key作爲單個元素存儲。
HashMap實現了Map接口,即允許放入key爲null的元素,也允許插入value爲null的元素;除該類未實現同步外,其餘跟Hashtable大致相同;跟TreeMap不同,該容器不保證元素順序,根據需要該容器可能會對元素重新哈希,元素的順序也會被重新打散,因此不同時間迭代同一個HashMap的順序可能會不同。 根據對沖突的處理方式不同,哈希表有兩種實現方式,一種開放地址方式(Open addressing),另一種是衝突鏈表方式(Separate chaining with linked lists)。Java HashMap採用的是衝突鏈表方式。
HashMap的訪問方式就是繼承於Map的最基礎的3種方式。
存儲方式
散列表(哈希表)。哈希表是使用數組和鏈表的組合的方式進行存儲。如下圖就是HashMap採用的存儲方法。
在這裏插入圖片描述
hash得到數值,放到數組中,如果遇到衝突則以鏈表方式掛在下方。
從上圖容易看出,如果選擇合適的哈希函數,put()和get()方法可以在常數時間內完成。但在對HashMap進行迭代時,需要遍歷整個table以及後面跟的衝突鏈表。因此對於迭代比較頻繁的場景,不宜將HashMap的初始大小設的過大。
有兩個參數可以影響HashMap的性能:初始容量(inital capacity)和負載係數(load factor)。初始容量指定了初始table的大小,負載係數用來指定自動擴容的臨界值。當entry的數量超過capacity*load_factor時,容器將自動擴容並重新哈希。對於插入元素較多的場景,將初始容量設大可以減少重新哈希的次數。
將對象放入到HashMap或HashSet中時,有兩個方法需要特別關心:hashCode()和equals()。hashCode()方法決定了對象會被放到哪個bucket裏,當多個對象的哈希值衝突時,equals()方法決定了這些對象是否是“同一個對象”。所以,如果要將自定義的對象放入到HashMap或HashSet中,需要@OverridehashCode()和equals()方法。
存儲定義

transient Node<K,V>[] table;

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

數組table存放元素,如果遇到衝突下掛到衝突元素的next鏈表上。
get核心方法和put核心方法的源碼
get()
get(Object key)方法根據指定的key值返回對應的value,該方法調用了getEntry(Object key)得到相應的entry,然後返回entry.getValue()。因此getEntry()是算法的核心。 算法思想是首先通過hash()函數得到對應bucket的下標,然後依次遍歷衝突鏈表,通過key.equals(k)方法來判斷是否是要找的那個entry。
在這裏插入圖片描述
上圖中hash(k)&(table.length-1)等價於hash(k)%table.length,原因是HashMap要求table.length必須是2的指數,因此table.length-1就是二進制低位全是1,跟hash(k)相與會將哈希值的高位全抹掉,剩下的就是餘數了。

//getEntry()方法
final Entry<K,V> getEntry(Object key) {
    ......
    int hash = (key == null) ? 0 : hash(key);
    for (Entry<K,V> e = table[hash&(table.length-1)];//得到衝突鏈表
         e != null; e = e.next) {//依次遍歷衝突鏈表中的每個entry
        Object k;
        //依據equals()方法判斷是否相等
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            return e;
    }
    return null;
}

put()
put(K key, V value)方法是將指定的key, value對添加到map裏。該方法首先會對map做一次查找,看是否包含該元組,如果已經包含則直接返回,查找過程類似於getEntry()方法;如果沒有找到,則會通過addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式爲頭插法。
在這裏插入圖片描述

//addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);//自動擴容,並重新哈希
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = hash & (table.length-1);//hash%table.length
    }
    //在衝突鏈表頭部插入新的entry
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

remove()
remove(Object key)的作用是刪除key值對應的entry,該方法的具體邏輯是在removeEntryForKey(Object key)裏實現的。removeEntryForKey()方法會首先找到key值對應的entry,然後刪除該entry(修改鏈表的相應引用)。查找過程跟getEntry()過程類似。
在這裏插入圖片描述

//removeEntryForKey()
final Entry<K,V> removeEntryForKey(Object key) {
    ......
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);//hash&(table.length-1)
    Entry<K,V> prev = table[i];//得到衝突鏈表
    Entry<K,V> e = prev;
    while (e != null) {//遍歷衝突鏈表
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {//找到要刪除的entry
            modCount++; size--;
            if (prev == e) table[i] = next;//刪除的是衝突鏈表的第一個entry
            else prev.next = next;
            return e;
        }
        prev = e; e = next;
    }
    return e;
}

HashSet與HashMap如何判斷相同元素
HashSet和HashMap一直都是JDK中最常用的兩個類,HashSet要求不能存儲相同的對象,HashMap要求不能存儲相同的鍵。
(1)equals(Object obj)和hashcode()
在Java中任何一個對象都具備equals(Object obj)和hashcode()這兩個方法,因爲他們是在Object類中定義的。
equals(Object obj)方法用來判斷兩個對象是否“相同”,如果“相同”則返回true,否則返回false。
hashcode()方法返回一個int數,在Object類中的默認實現是“將該對象的內部地址轉換成一個整數返回”。
對於這兩個方法,有以下兩個重要規範:

  • 規範1:若重寫equals(Object obj)方法,有必要重寫hashcode()方法,確保通過equals(Object obj)方法判斷結果爲true的兩個對象具備相等的hashcode()返回值。說得簡單點就是:“如果兩個對象相同,那麼他們的hashcode應該相等”。
  • 規範2:如果equals(Object obj)返回false,即兩個對象“不相同”,並不要求對這兩個對象調用hashcode()方法得到兩個不相同的數。說的簡單點就是:“如果兩個對象不相同,他們的hashcode可能相同”。
    (2)Java 保證對象的一致性
    因此,在Java運行時環境判斷HashSet和HastMap中的兩個對象相同或不同應該先判斷hashcode是否相等,再判斷是否equals。 只有兩者均相同,才能保證對象的一致性。
    結論:爲了保證HashSet中的對象不會出現重複值,在被存放元素的類中必須要重寫hashCode()和equals()這兩個方法。

(1)LinkedHashMap 鏈式哈希表

LinkedHashSet是用一個鏈表實現來擴展HashSet類,它支持對規則集內的元素排序。HashSet中的元素是沒有被排序的,而LinkedHashSet中的元素可以按照它們插入規則集的順序提取。
其實LinkedHashMap的存儲還是跟HashMap一樣,採用哈希表方法存儲,只不過LinkedHashMap多維護了一份head,tail鏈表。

transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;

即在創建新Node的時候將新Node放到最後,這樣遍歷的時候不再像HashMap一樣,從數組開始判斷第一個非空元素,而是直接從表頭進行遍歷。這樣即滿足有序遍歷。

(2)HashMap和Hashtable的區別

  • 線程安全性:同步(synchronization)
    HashMap幾乎可以等價於Hashtable,除了HashMap是非synchronized的,並可以接受null(HashMap可以接受爲null的鍵值(key)和值(value),而Hashtable則不行)。
    HashMap是非synchronized,而Hashtable是synchronized,這意味着Hashtable是線程安全的,多個線程可以共享一個Hashtable;而如果沒有正確的同步的話,多個線程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的擴展性更好。
  • 速度
    由於Hashtable是線程安全的也是synchronized,所以在單線程環境下它比HashMap要慢。如果你不需要同步,只需要單一線程,那麼使用HashMap性能要好過Hashtable。

Hashtable和HashMap有幾個主要的不同:線程安全以及速度。僅在你需要完全的線程安全的時候使用Hashtable,而如果你使用Java 5或以上的話,請使用ConcurrentHashMap。

(2)HashMap和HashSet的區別

HashSet不能添加重複的元素,當調用add(Object)方法時候,首先會調用Object的hashCode方法判hashCode是否已經存在,如不存在則直接插入元素;如果已存在則調用Object對象的equals方法判斷是否返回true,如果爲true則說明元素已經存在,如爲false則插入元素。
HashSet是藉助HashMap來實現的,利用HashMap中Key的唯一性,來保證HashSet中不出現重複值。HashSet是對HashMap的簡單包裝,對HashSet的函數調用都會轉換成合適的HashMap方法,因此HashSet的實現非常簡單,只有不到300行代碼。這裏不再贅述。

//HashSet是對HashMap的簡單包裝
public class HashSet<E>
{
    ......
    private transient HashMap<E,Object> map;//HashSet裏面有一個HashMap
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    public HashSet() {
        map = new HashMap<>();
    }
    ......
    public boolean add(E e) {//簡單的方法轉換
        return map.put(e, PRESENT)==null;
    }
    ......
}

3、WeakHashMap

WeakHashMap是一種改進的HashMap,它對key實行“弱引用”,如果一個key不再被外部所引用,那麼該key可以被GC回收。
舉例:聲明瞭兩個Map對象,一個是HashMap,一個是WeakHashMap,同時向兩個map中放入a、b兩個對象,當HashMap remove掉a 並且將a、b都指向null時,WeakHashMap中的a將自動被回收掉。出現這個狀況的原因是,對於a對象而言,當HashMap remove掉並且將a指向null後,除了WeakHashMap中還保存a外已經沒有指向a的指針了,所以WeakHashMap會自動捨棄掉a,而對於b對象雖然指向了null,但HashMap中還有指向b的指針,所以
WeakHashMap將會保留。

4、TreeMap

TreeMap基於紅黑樹數據結構的實現,是有序的key-value集合。鍵值可以使用Comparable或Comparator接口來排序。TreeMap繼承自AbstractMap,同時實現了接口NavigableMap,而接口NavigableMap則繼承自SortedMap。SortedMap是Map的子接口,使用它可以確保圖中的條目是排好序的。
(4.1)特點

  • 內部紅黑樹實現
  • key-value不爲空
  • TreeMap有序

(4.2)數據結構
如下圖所示,是TreeMap(key:爲[4,2,5,6,8,7,9])的一個內部結構示意圖,其中每個節點都是Entry類型的。
Entry節點源碼分析

//比較器
private final Comparator<? super K> comparator;
// Entry節點,這個表示紅黑樹的根節點
private transient Entry<K,V> root;
// TreeMap中元素的個數
private transient int size = 0;
// TreeMap修改次數
private transient int modCount = 0;

static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;//對於key值
    V value;//對於value值
    Entry<K,V> left;//指向左子樹的引用
    Entry<K,V> right;//指向右子樹的引用
    Entry<K,V> parent;//指向父節點的引用
    boolean color = BLACK;//節點的顏色默認是黑色
    
    // 省略部分代碼
}

(4.3)具體操作
put操作
1.校驗根節點:校驗根節點是否爲空,若爲空則根據傳入的key-value的值創建一個新的節點,若根節點不爲空則繼續第二步
2.尋找插入位置:由於TreeMap內部是紅黑樹實現的則插入元素時,實際上是會去遍歷左子樹,或者右子樹(具體遍歷哪顆子樹是根據當前插入key-value與根節點的比較判定的,這部在代碼裏面其實分爲兩步來體現是否指定比較器,若指定了則使用指定的比較器比較,否則使用默認key的比較器進行比較(這裏有一點需要注意是TreeMap是不允許key-value爲NULL)
3.新建並恢復:在第二步中實際上是需要確定當前插入節點的位置,而這一步是實際的插入操作,而插入之後爲啥還需要調用fixAfterInsertion方法,這裏是因爲紅黑樹插入一個節點後可能會破壞紅黑樹的性質,則通過修改的代碼使得紅黑樹從新達到平衡。
get操作
1.構造器校驗:判斷是否指定構造器,若指定則調用getEntryUsingComparator,若沒有則進行第二步
2.空值校驗:key若爲空直接拋出NullPointerException,從這點可以看出TreeMap是不允許Key-value爲空的
3.遍歷返回:遍歷整個紅黑樹若找到對應的值則返回,否則返回null值

補:總結

在實際使用中,如果更新圖時不需要保持圖中元素的順序,就使用HashMap,如果需要保持圖中元素的插入順序或者訪問順序,就使用LinkedHashMap,如果需要使圖按照鍵值排序,就使用TreeMap。
在這裏插入圖片描述
1、如果涉及到堆棧,隊列等操作,應該考慮用List,對於需要快速插入,刪除元素,應該使用LinkedList,如果需要快速隨機訪問元素,應該使用ArrayList。
2、如果程序在單線程環境中,或者訪問僅僅在一個線程中進行,考慮非同步的類,其效率較高,如果多個線程可能同時操作一個類,應該使用同步的類。
3、要特別注意對哈希表的操作,作爲key的對象要正確複寫equals和hashCode方法。
4、儘量返回接口而非實際的類型,如返回List而非ArrayList,這樣如果以後需要將ArrayList換成LinkedList時,客戶端代碼不用改變。這就是針對抽象編程。

二、Java多線程之同步集合與併發集合

(1)同步集合類

包括Hashtable、Vector、同步集合包裝類,Collections.synchronizedMap()和Collections.synchronizedList()

(2)併發集合類

包括ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteHashSet

1、性能

同步集合比並發集合會慢得多,主要原因是鎖,同步集合會對整個Map或List加鎖

2、實現原理

ConcurrentHashMap:把整個Map 劃分成幾個片段,只對相關的幾個片段上鎖,同時允許多線程訪問其他未上鎖的片段。
CopyOnWriteArrayList:允許多個線程以非同步的方式讀,當有線程寫的時候它會將整個List複製一個副本給它。如果在讀多寫少這種對併發集合有利的條件下使用併發集合,這會比使用同步集合更具有可伸縮性。

3、使用建議

一般不需要多線程的情況,只用到HashMap、ArrayList,只要真正用到多線程的時候就一定要考慮同步。所以這時候才需要考慮同步集合或併發集合。

4、ConcurrentHashMap

ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖ReentrantLock,在ConcurrentHashMap裏扮演鎖的角色,HashEntry則用於存儲鍵值對數據。一個ConcurrentHashMap裏包含一個Segment數組,Segment的結構和HashMap類似,是一種數組和鏈表結構, 一個Segment裏包含一個HashEntry數組,每個HashEntry是一個鏈表結構的元素, 每個Segment守護者一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時,必須首先獲得它對應的Segment鎖。
在這裏插入圖片描述

5、CopyOnWrite容器

CopyOnWrite容器即寫時複製的容器。通俗的理解是當我們往一個容器添加元素的時候,不直接往當前容器添加,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器裏添加元素,添加完元素之後,再將原容器的引用指向新的容器。這樣做的好處是我們可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因爲當前容器不會添加任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器。

(5.1)CopyOnWriteArrayList

可以發現在添加的時候是需要加鎖的,否則多線程寫的時候會Copy出N個副本出來。

public boolean add(T e) {  
    final ReentrantLock lock = this.lock;  
    lock.lock();  
    try {  
  
        Object[] elements = getArray();  
  
        int len = elements.length;  
  
        // 複製出新數組  
        Object[] newElements = Arrays.copyOf(elements, len + 1);  
  
        // 把新元素添加到新數組裏  
        newElements[len] = e;  
  
        // 把原數組引用指向新數組  
        setArray(newElements);  
  
        return true;  
  
    } finally {  
        lock.unlock();  
    }  
}  
  
  
final void setArray(Object[] a) {  
  
    array = a;  
  
}  

讀的時候不需要加鎖,如果讀的時候有多個線程正在向ArrayList添加數據,讀還是會讀到舊的數據,因爲寫的時候不會鎖住舊的ArrayList。

public E get(int index) {  
    return get(getArray(), index);  
}  

(5.2)應用場景

CopyOnWrite併發容器用於讀多寫少的併發場景。比如白名單,黑名單,商品類目的訪問和更新場景,假如我們有一個搜索網站,用戶在這個網站的搜索框中,輸入關鍵字搜索內容,但是某些關鍵字不允許被搜索。這些不能被搜索的關鍵字會被放在一個黑名單當中,黑名單每天晚上更新一次。當用戶搜索時,會檢查當前關鍵字在不在黑名單當中,如果在,則提示不能搜索。實現代碼如下:

public class BlackListServiceImpl {  
   
    private static CopyOnWriteMap<String, Boolean> blackListMap = new CopyOnWriteMap<String, Boolean>(  
            1000);  
   
    public static boolean isBlackList(String id) {  
        return blackListMap.get(id) == null ? false : true;  
    }  
   
    public static void addBlackList(String id) {  
        blackListMap.put(id, Boolean.TRUE);  
    }  
   
    /** 
     * 批量添加黑名單 
     * 
     * @param ids 
     */  
    public static void addBlackList(Map<String,Boolean> ids) {  
        blackListMap.putAll(ids);  
    }  
}  
 

注意兩點:
1、減少擴容開銷。根據實際需要,初始化CopyOnWriteMap的大小,避免寫時CopyOnWriteMap擴容的開銷。
2、使用批量添加。因爲每次添加,容器每次都會進行復制,所以減少添加次數,可以減少容器的複製次數。如使用上面代碼裏的addBlackList方法。

(5.3)缺點

(5.3.1)內存佔用問題

因爲CopyOnWrite的寫時複製機制,所以在進行寫操作的時候,內存裏會同時駐紮兩個對象的內存,舊的對象和新寫入的對象(注意:在複製的時候只是複製容器裏的引用,只是在寫的時候會創建新對象添加到新容器裏,而舊容器的對象還在使用,所以有兩份對象內存)。如果這些對象佔用的內存比較大,比如說200M左右,那麼再寫入100M數據進去,內存就會佔用300M,那麼這個時候很有可能造成頻繁的Yong GC和Full GC。之前我們系統中使用了一個服務由於每晚使用CopyOnWrite機制更新大對象,造成了每晚15秒的Full GC,應用響應時間也隨之變長。
針對內存佔用問題,可以通過壓縮容器中的元素的方法來減少大對象的內存消耗,比如,如果元素全是10進制的數字,可以考慮把它壓縮成36進制或64進制。或者不使用CopyOnWrite容器,而使用其他的併發容器,如ConcurrentHashMap。

(5.3.2)數據一致性問題

CopyOnWrite容器只能保證數據的最終一致性,不能保證數據的實時一致性。所以如果你希望寫入的的數據,馬上能讀到,請不要使用CopyOnWrite容器。

三、HashMap

1、HashMap簡介

HashMap就是最基礎最常用的一種Map,它無序,以散列表(數組+鏈表/紅黑樹)的方式進行存儲,存儲內容是鍵值對映射。是一種非同步的容器類,故它的線程不安全。

2、HashMap類圖結構

此處的類圖根據JDK1.6畫出來的,如下:
在這裏插入圖片描述
HashMap 繼承於AbstractMap,實現了Map、Cloneable、java.io.Serializable接口。

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable { }
  • HashMap繼承於AbstractMap類,實現了Map接口。Map是"key-value鍵值對"接口,AbstractMap實現了"鍵值對"的通用函數接口。
  • HashMap是通過"拉鍊法"實現的哈希表。它包括幾個重要的成員變量:table, size, threshold, loadFactor, modCount。
  • table是一個Entry[]數組類型,而Entry實際上就是一個單向鏈表。哈希表的"key-value鍵值對"都是存儲在Entry數組中的。
  • size是HashMap的大小,它是HashMap保存的鍵值對的數量。
  • threshold是HashMap的閾值,用於判斷是否需要調整HashMap的容量。threshold的值=“容量*加載因子”,當HashMap中存儲數據的數量達到threshold時,就需要將HashMap的容量加倍。
  • loadFactor就是加載因子。
  • modCount是用來實現fail-fast機制的。

3、HashMap存儲結構

HashMap存儲結構由數組和單向鏈表共同完成的,如圖:
在這裏插入圖片描述
從上圖可以看出HashMap是Y軸方向是數組,X軸方向就是鏈表的存儲方式。大家都知道數組的存儲方式在內存的地址是連續的,大小固定,一旦分配不能被其他引用佔用。它的特點是查詢快,時間複雜度是O(1),插入和刪除的操作比較慢,時間複雜度是O(n),鏈表的存儲方式是非連續的,大小不固定,特點與數組相反,插入和刪除快,查詢速度慢。
單向鏈表的表示

static class Entry<K,V> implements Map.Entry<K,V> {
     final K key;
     V value;
     // 指向下一個節點
     Entry<K,V> next;
     final int hash;
 
     // 構造函數。
     // 輸入參數包括"哈希值(h)", "鍵(k)", "值(v)", "下一節點(n)"
    Entry(int h, K k, V v, Entry<K,V> n) {
         value = v;
         next = n;
         key = k;
         hash = h;
     }
     ...

數組的表示

transient Entry[] table;

(數組的初始化:Node[] table = new Node[16];resize();//數組的初始化)
HashMap中的key-value都是存儲在Entry數組中的。Entry實際上就是一個單向鏈表。Entry實現了Map.Entry接口,即實現getKey(),getValue(),setValue()等讀取/修改key和value值的函數。

4、HashMap基本原理

(4.1)概念介紹

變量

變量 術語 說明
size 大小 HashMap的存儲大小
threshold 臨界值 HashMap大小達到臨界值,需要重新分配大小
loadFactor 負載因子 HashMap大小負載因子,默認爲75%
modCount 統一修改 HashMap被修改或者刪除的次數總數
Entry 實體 HashMap存儲對象的實際實體,由Key,value,hash,next組成

常量

常量 大小 說明
DEFAULT_INITIAL_CAPACITY 1<<4 默認初始數組大小 2的4次方=16
DEFAULT_INITIAL_CAPACITY 1<<30 數組大小最大值 2的30次方
DEFAULT_LOAD_FACTOR 0.75 負載因子,數組需要擴大的容量標準,當容量>數組大小負載因子時(160.75),需要重新分配大小
TREEIFY_THRESHOLD 8 閾值:鏈表長度超過閾值,將鏈表轉換爲紅黑樹(平衡二叉樹——壓縮深度+方便查找);當鏈表長度小於閾值,將紅黑樹轉換爲鏈表

(4.2)初始化

通過調用new HashMap()來初始化的,這裏分析new HashMap(int initialCapacity, float loadFactor)的構造函數,代碼如下:

public HashMap(int initialCapacity, float loadFactor) {
     // initialCapacity代表初始化HashMap的容量,它的最大容量是MAXIMUM_CAPACITY = 1 << 30,默認爲DEFAULT_INITIAL_CAPACITY = 1 << 4
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;

     // loadFactor代表它的負載因子,默認是是DEFAULT_LOAD_FACTOR=0.75,用來計算threshold臨界值的。
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // Find a power of 2 >= initialCapacity
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
        threshold = (int)(capacity * loadFactor);
        //初始化哈希表
        table = new Entry[capacity];
        init();
    }

初始化的時候需要知道初始化的容量大小,因爲在後面要通過按位與的Hash算法計算Entry數組的索引,那麼要求Entry的數組長度是2的N次方

(4.3)Hash計算和碰撞問題

計算出的哈希值需要滿足:
(1)結果爲Int類型
(2)數組長度範圍內(0~n-1)
(3)儘可能充分利用數組中每一個位置
HashMap的hash計算時先計算hashCode(),然後進行二次hash。代碼如下:

// 計算二次Hash    
int hash = hash(key.hashCode());

// 通過Hash找數組索引
int i = indexFor(hash, table.length);

1、第一次Hash:String.hashCode()
JDK的String的Hash算法。
2、第二次Hash:hash(key.hashCode())

static int hash(int h) {
        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

這裏就是解決Hash的的衝突的函數,通過讓高位參與運算使得結果儘可能不一樣,均勻分佈,充分利用數組中每一個位置。
解決Hash的衝突有以下幾種方法:

  1. 開放定址法(線性探測再散列,二次探測再散列,僞隨機探測再散列)
  2. 再哈希法
  3. 鏈地址法
  4. 建立一 公共溢出區

而HashMap採用的是鏈地址法。

3、通過Hash查找數組的索引indexFor(hash,tableLength)

/**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

其中h是hash值,length是數組的長度,這個按位與的算法其實就是h%length求餘:n-1 & hash === hash%(n-1)。保證計算結果是一個0~n-1範圍內的值。
既然知道了分組的原理了,那我們看看幾個例子,代碼如下:

        int h=15,length=16;
        System.out.println(h & (length-1));
        h=15+16;
        System.out.println(h & (length-1));
        h=15+16+16;
        System.out.println(h & (length-1));
        h=15+16+16+16;
        System.out.println(h & (length-1));

運行結果都是15,爲什麼呢?我們換算成二進制來看看。

System.out.println(Integer.parseInt("0001111", 2) & Integer.parseInt("0001111", 2));
System.out.println(Integer.parseInt("0011111", 2) & Integer.parseInt("0001111", 2));
System.out.println(Integer.parseInt("0111111", 2) & Integer.parseInt("0001111", 2));
System.out.println(Integer.parseInt("1111111", 2) & Integer.parseInt("0001111", 2));

這裏你就發現了,在做按位與操作的時候,後面的始終是低位在做計算,高位不參與計算,因爲高位都是0。這樣導致的結果就是隻要是低位是一樣的,高位無論是什麼,最後結果是一樣的。

(4.4)HashMap的put解析

總流程:

  • 如果HashMap爲空,則進行初始化;
  • 對Key求Hash值,然後再計算下標。
  • 如果沒有碰撞,則直接放入桶中。
  • 如果碰撞了,以鏈表的方式連接到後面。
  • 如果鏈表長度超過閾值(TREEIFY_THRESHOLD == 8),就把鏈表轉換成紅黑樹。
  • 如果結點已經存在就替換舊值。
  • 如果桶滿了(容量+加載因子),就需要resize(雙倍擴容,保證2的n次冪),並且爲了使結點均勻分散,應該重新分配結點位置
    if(++size>threshold)resize();

代碼如下:

 /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

從代碼可以看出,步驟如下:
(1) 首先判斷key是否爲null,如果是null,就單獨調用putForNullKey(value)處理。
代碼如下:

 /**
     * Offloaded version of put for null keys
     */
    private V putForNullKey(V value) {
        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;
    }

從代碼可以看出,如果key爲null的值,默認就存儲到table[0]開頭的鏈表了。然後遍歷table[0]的鏈表的每個節點Entry,如果發現其中存在節點Entry的key爲null,就替換新的value,然後返回舊的value,如果沒發現key等於null的節點Entry,就增加新的節點。
(2) 計算key的hashcode,再用計算的結果二次hash,通過indexFor(hash, table.length);找到Entry數組的索引i。
(3) 然後遍歷以table[i]爲頭節點的鏈表,如果發現有節點的hash,key都相同的節點時,就替換爲新的value,然後返回舊的value。
(4) modCount是幹嘛的啊? 讓我來爲你解答。
衆所周知,HashMap不是線程安全的,但在某些容錯能力較好的應用中,如果你不想僅僅因爲1%的可能性而去承受hashTable的同步開銷,HashMap使用了Fail-Fast機制來處理這個問題,你會發現modCount在源碼中是這樣聲明的。
volatile關鍵字聲明瞭modCount,代表了多線程環境下訪問modCount,根據JVM規範,只要modCount改變了,其他線程將讀到最新的值。其實在Hashmap中modCount只是在迭代的時候起到關鍵作用。

private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;    // next entry to return
        int expectedModCount;    // For fast-fail
        int index;        // current slot
        Entry<K,V> current;    // current entry

        HashIterator() {
            expectedModCount = modCount;
            if (size > 0) { // advance to first entry
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }

        public final boolean hasNext() {
            return next != null;
        }

        final Entry<K,V> nextEntry() {
        // 這裏就是關鍵
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();

            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        current = e;
            return e;
        }

        public void remove() {
            if (current == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Object k = current.key;
            current = null;
            HashMap.this.removeEntryForKey(k);
            expectedModCount = modCount;
        }

    }

使用Iterator開始迭代時,會將modCount的賦值給expectedModCount,在迭代過程中,通過每次比較兩者是否相等來判斷HashMap是否在內部或被其它線程修改,如果modCount和expectedModCount值不一樣,證明有其他線程在修改HashMap的結構,會拋出異常。
所以HashMap的put、remove等操作都有modCount++的計算。
(5) 如果沒有找到key的hash相同的節點,就增加新的節點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);
    }

這裏增加節點的時候取巧了,每個新添加的節點都增加到頭節點,然後新的頭節點的next指向舊的老節點。
(6) 如果HashMap大小超過臨界值,就要重新設置大小,擴容。
容量 是哈希表中桶的數量,初始容量 只是哈希表在創建時的容量。加載因子 是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表進行 rehash 操作(即重建內部數據結構),從而哈希表將具有大約兩倍的桶數。
通常,默認加載因子是 0.75, 這是在時間和空間成本上尋求一種折衷。加載因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數 HashMap 類的操作中,包括 get 和 put 操作,都反映了這一點)。在設置初始容量時應該考慮到映射中所需的條目數及其加載因子,以便最大限度地減少 rehash 操作次數。如果初始容量大於最大條目數除以加載因子,則不會發生 rehash 操作。
見size解析。

(4.5)HashMap的get解析

計算key的hashcode,再用計算的結果二次hash,通過indexFor(hash, table.length);找到Entry數組的索引i。然後遍歷以table[i]爲頭節點的鏈表,如果發現有節點的hash,key都相同的節點時,取出該結點的值。

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        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;
        }
        return null;
    }

(4.6)HashMap的size解析

HashMap的大小很簡單,不是實時計算的,而是每次新增加Entry的時候,size就遞增。刪除的時候就遞減。空間換時間的做法。因爲它不是線程安全的。完全可以這麼做。效力高。

(4.7)HashMap的reSzie解析

擴容+重新分配結點過程:

  • 將新結點加到鏈表後
  • 容量擴充爲原來的兩倍,然後對每個結點重新計算哈希值
  • 這個值只可能在兩個地方,一個是原下標的位置,另一種是在下標爲<原下標+原容量>的位置

當HashMap的大小超過臨界值的時候,就需要擴充HashMap的容量了。代碼如下:

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

從代碼可以看出,如果大小超過最大容量就返回。否則就new 一個新的Entry數組,長度爲舊的Entry數組長度的兩倍(保證數組長度爲2的n次冪)。然後將舊的Entry[]複製到新的Entry[].(保證均勻分佈)代碼如下:

void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

在複製的時候數組的索引int i = indexFor(e.hash, newCapacity);重新參與計算。
針對HashMap中某個Entry鏈太長,查找的時間複雜度達到O(n),JDK做了優化,將鏈表轉換成紅黑樹。

5、HashMap、ArrayMap和SparseArray源碼分析和性能對比

ArrayMap及SparseArray是android的系統API,是專門爲移動設備而定製的。用於在一定情況下取代HashMap而達到節省內存的目的。
HashMap存儲原理
在這裏插入圖片描述
從hashMap的結構中可以看出,首先對key值求hash,根據hash結果確定在table數組中的位置,當出現哈希衝突時採用開放鏈地址法進行處理。Map.Entity的數據結構如下:

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

從空間的角度分析,HashMap中會有一個利用率不超過負載因子(默認爲0.75)的table數組,其次,對於HashMap的每一條數據都會用一個HashMapEntry進行記錄,除了記錄key,value外,還會記錄下hash值,及下一個entity的指針。
時間效率方面,利用hash算法,插入和查找等操作都很快,且一般情況下,每一個數組值後面不會存在很長的鏈表(因爲出現hash衝突畢竟佔比較小的比例),所以不考慮空間利用率的話,HashMap的效率非常高。
ArrayMap存儲原理
在這裏插入圖片描述
ArrayMap利用兩個數組,mHashes用來保存每一個key的hash值,mArrray大小爲mHashes的2倍,依次保存key和value。源碼的細節方面會在下一篇文章中說明。現在我們先拋開細節部分,只看關鍵語句:

mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;

相信看到這大家都明白了原理了。但是它怎麼查詢呢?答案是二分查找。當插入時,根據key的hashcode()方法得到hash值,計算出在mArrays的index位置,然後利用二分查找找到對應的位置進行插入,當出現哈希衝突時,會在index的相鄰位置插入。
總結一下,空間角度考慮,ArrayMap每存儲一條信息,需要保存一個hash值,一個key值,一個value值。對比下HashMap 粗略的看,只是減少了一個指向下一個entity的指針。還有就是節省了一部分可見空間上的內存節省也不是特別明顯。是不是這樣呢?後面會驗證。
時間效率上看,插入和查找的時候因爲都用的二分法,查找的時候應該是沒有hash查找快,插入的時候呢,如果順序插入的話效率肯定高,但如果是隨機插入,肯定會涉及到大量的數組搬移,數據量大,肯定不行,再想一下,如果是不湊巧,每次插入的hash值都比上一次的小,那就得次次搬移,效率一下就扛不住了的感腳。
SparseArray存儲原理
在這裏插入圖片描述
sparseArray相對來說就簡單的多了,但是不要以爲它可以取代前兩種,sparseArray只能在key爲int的時候才能使用,注意是int而不是Integer,這也是sparseArray效率提升的一個點,去掉了裝箱的操作!
因爲key爲int也就不需要什麼hash值了,只要int值相等,那就是同一個對象,簡單粗暴。插入和查找也是基於二分法,所以原理和Arraymap基本一致,這裏就不多說了。
總結一下:空間上對比,與HashMap,去掉了Hash值的存儲空間,沒有next的指針佔用,還有其他一些小的內存佔用,看着節省了不少。
時間上對比:插入和查找的情形和Arraymap基本一致,可能存在大量的數組搬移。但是它避免了裝箱的環節,不要小看裝箱過程,還是很費時的。所以從源碼上來看,效率誰快,就看數據量大小了。
總結
1.在數據量小的時候一般認爲1000以下,當你的key爲int的時候,使用SparseArray確實是一個很不錯的選擇,內存大概能節省30%,相比用HashMap,因爲它key值不需要裝箱,所以時間性能平均來看也優於HashMap,建議使用!
2.ArrayMap相對於SparseArray,特點就是key值類型不受限,任何情況下都可以取代HashMap,但是通過研究和測試發現,ArrayMap的內存節省並不明顯,也就在10%左右,但是時間性能確是最差的,當然了,1000以內的數據量也無所謂了,加上它只有在API>=19纔可以使用,個人建議沒必要使用!還不如用HashMap放心。估計這也是爲什麼我們再new一個HashMap的時候google也沒有提示讓我們使用的原因吧。

四、ConcurrentHashMap

1、解決問題

HashMap是我們平時開發過程中用的比較多的集合,但它是非線程安全的,在涉及到多線程併發的情況,進行put操作有可能會引起死循環,導致CPU利用率接近100%。

final HashMap<String, String> map = new HashMap<String, String>(2);
for (int i = 0; i < 10000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            map.put(UUID.randomUUID().toString(), "");
        }
    }).start();
}

解決方案有Hashtable,但Hashtable基本上是對讀寫進行加鎖操作,get/put所有相關操作都是synchronized的,這相當於給整個哈希表加了一把大鎖。多線程訪問時候,只要有一個線程訪問或操作該對象,那其他線程只能阻塞,相當於將所有的操作串行化,在競爭激烈的併發場景中性能就會非常差。
所以,Doug Lea給我們帶來了併發安全的ConcurrentHashMap,它的實現是依賴於 Java 內存模型。解決HashMap在併發環境下不安全而誕生的。

2、JDK1.7:數組+Segment+分段鎖機制

ConcurrentHashMap採用 分段鎖的機制,實現併發的更新操作,底層採用數組+鏈表的存儲結構。其包含兩個核心靜態內部類 Segment和HashEntry。

  • Segment:分段鎖。繼承ReentrantLock用來充當鎖的角色,類似HashMap的結構,內部擁有一個Entry數組,數組中每個元素又是一個鏈表。每個 Segment 對象守護每個散列映射表的若干個桶。
  • HashEntry用來封裝映射表的鍵 / 值對;每個桶是由若干個 HashEntry 對象鏈接起來的鏈表。

一個 ConcurrentHashMap 實例中包含由若干個 Segment 對象組成的數組Segments,下面我們通過一個圖來演示一下 ConcurrentHashMap 的結構:
在這裏插入圖片描述
ConcurrentHashMap使用分段鎖技術,將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問,能夠實現真正的併發訪問。
Hashtable的synchronized是針對整張Hash表的,即每次鎖住整張表讓線程獨佔,ConcurrentHashMap允許多個修改操作併發進行,其關鍵在於使用了鎖分離技術。有些方法需要跨段,比如size()和containsValue(),它們可能需要鎖定整個表而而不僅僅是某個段,這需要按順序鎖定所有段,操作完畢後,又按順序釋放所有段的鎖。

理解:寫操作的時候可以只對元素所在的Segment進行加鎖即可,不會影響到其他的Segment,這樣,在最理想的情況下,ConcurrentHashMap可以最高同時支持Segment數量大小的寫操作(剛好這些寫操作都非常平均地分佈在所有的Segment上)。

從上ConcurrentHashMap 的結構圖可瞭解,ConcurrentHashMap定位一個元素的過程需要進行兩次Hash操作。第一次Hash定位到Segment,第二次Hash定位到元素所在的鏈表的頭部。

3、JDK1.8:數組+鏈表/紅黑樹+CAS+Synchronized

利用CAS+Synchronized來保證併發更新的安全,底層採用數組+鏈表+紅黑樹的存儲結構。
在這裏插入圖片描述
重要結構
(1)Node:保存key,value及key的hash值的數據結構。

class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    ... 省略部分代碼
}

其中value和next都用volatile修飾,保證併發的可見性。
(2)sizeCtl :默認爲0,用來控制table的初始化和擴容操作。
-1 代表table正在初始化
-N 表示有N-1個線程正在進行擴容操作
其餘情況:
1、如果table未初始化,表示table需要初始化的大小。
2、如果table初始化完成,表示table的容量,默認是table大小的0.75倍,居然用這個公式算0.75(n - (n >>> 2))。
實現原理
JDK8中ConcurrentHashMap參考了JDK8 HashMap的實現,採用了數組+鏈表+紅黑樹的實現方式來設計,內部大量採用CAS操作,這裏我簡要介紹下CAS。
CAS是compare and swap的縮寫,即我們所說的比較交換。cas是一種基於鎖的操作,而且是樂觀鎖。在java中鎖分爲樂觀鎖和悲觀鎖。悲觀鎖是將資源鎖住,等一個之前獲得鎖的線程釋放鎖之後,下一個線程纔可以訪問。而樂觀鎖採取了一種寬泛的態度,通過某種方式不加鎖來處理資源,比如通過給記錄加version來獲取數據,性能較悲觀鎖有很大的提高。
CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。如果內存地址裏面的值和A的值是一樣的,那麼就將內存裏面的值更新成B。CAS是通過無限循環來獲取數據的,若果在第一輪循環中,a線程獲取地址裏面的值被b線程修改了,那麼a線程需要自旋,到下次循環纔有可能機會執行。

4、JDK1.7 & JDK1.8 對比

1.數據結構:取消了Segment分段鎖的數據結構,取而代之的是數組+鏈表+紅黑樹的結構。
2.保證線程安全機制:JDK1.7採用segment的分段鎖機制實現線程安全,其中segment繼承自ReentrantLock。JDK1.8採用CAS+Synchronized保證線程安全。
3.鎖的粒度:原來是對需要進行數據操作的Segment加鎖,現調整爲對每個數組元素加鎖(Node)。
4.鏈表轉化爲紅黑樹:定位結點的hash算法簡化會帶來弊端,Hash衝突加劇,因此在鏈表節點數量大於8時,會將鏈表轉化爲紅黑樹進行存儲。
5.查詢時間複雜度:從原來的遍歷鏈表O(n),變成遍歷紅黑樹O(logN)。

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