Java容器(List、Set、Map)知識點快速複習手冊

qqxx6661發表於2019-01-22

Java容器(List、Set、Map)知識點快速複習手冊

前言

本文快速回顧了Java中容器的知識點,用作面試複習,事半功倍。

其它知識點複習手冊

概覽

容器主要包括 Collection 和 Map 兩種,Collection 又包含了 List、Set 以及 Queue。

Collection

在這裡插入圖片描述

在這裡插入圖片描述

陣列和集合的區別:

  • 長度
    • 陣列的長度固定
    • 集合的長度可變
  • 內容
    • 陣列儲存的是同一種型別的元素
    • 集合可以儲存不同型別的元素(但是一般我們不這樣幹..)
  • 元素的資料型別
    • 陣列可以儲存基本資料型別,也可以儲存引用型別
    • 集合只能儲存引用型別(若儲存的是簡單的int,它會自動裝箱成Integer)

1. Set(元素不可重複)

  • HashSet:基於HashMap實現,支援快速查詢,但不支援有序性操作

  • TreeSet:基於紅黑樹實現,支援有序性操作,但是查詢效率不如 HashSet,HashSet 查詢時間複雜度為 O(1),TreeSet 則為 O(logN);

  • LinkedHashSet:具有 HashSet 的查詢效率,且內部使用連結串列維護元素的插入順序

2. List(有序(儲存順序和取出順序一致),可重複)

  • ArrayList:基於動態陣列實現,支援隨機訪問;

  • Vector:和 ArrayList 類似,但它是執行緒安全的;

  • LinkedList:基於雙向連結串列實現,只能順序訪問,但是可以快速地在連結串列中間插入和刪除元素。不僅如此,LinkedList 還可以用作棧、佇列和雙向佇列。

3. Queue

  • LinkedList:可以用它來支援雙向佇列;

  • PriorityQueue:基於堆結構實現,可以用它來實現優先佇列。

Map

在這裡插入圖片描述

  • HashMap:基於雜湊實現;

  • HashTable:和 HashMap 類似,但它是執行緒安全的,這意味著同一時刻多個執行緒可以同時寫入 HashTable 並且不會導致資料不一致。它是遺留類,不應該去使用它

  • ConcurrentHashMap:支援執行緒安全,並且 ConcurrentHashMap 的效率會更高,因為 ConcurrentHashMap 引入了分段鎖。

  • LinkedHashMap:使用連結串列來維護元素的順序,順序為插入順序或者最近最少使用(LRU)順序。

  • TreeMap:基於紅黑樹實現。

Fail-Fast 機制和 Fail-Safe 機制

blog.csdn.net/Kato_op/art…

Fail-Fast

Fail-fast 機制是 java 集合(Collection)中的一種錯誤機制。 當多個執行緒對同一個集合的內容進行操作時,就可能會產生 fail-fast 事件。

  • 迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個modCount變數,

  • 集合中在被遍歷期間如果內容發生變化(增刪改),就會改變modCount的值,

  • 每當迭代器使用 hashNext()/next()遍歷下一個元素之前,都會執行checkForComodification()方法檢測,modCount變數和expectedmodCount值是否相等,

  • 如果相等就返回遍歷,否則丟擲異常,終止遍歷.

注意,如果集合發生變化時修改modCount值, 剛好有設定為了expectedmodCount值, 則異常不會丟擲.(比如刪除了資料,再新增一條資料)

所以,一般來說,存在非同步的併發修改時,不可能作出任何堅決的保證。

迭代器的快速失敗行為應該僅用於檢測程式錯誤, 而不是用他來同步。

java.util包下的集合類都是Fail-Fast機制的,不能在多執行緒下發生併發修改(迭代過程中被修改).

Fail-Safe

採用安全失敗(Fail-Safe)機制的集合容器,在遍歷時不是直接在集合內容上訪問的,而是先copy原有集合內容,在拷貝的集合上進行遍歷

原理:

由於迭代時是對原集合的拷貝的值進行遍歷,所以在遍歷過程中對原集合所作的修改並不能被迭代器檢測到,所以不會出發ConcurrentModificationException

缺點:

迭代器並不能訪問到修改後的內容(簡單來說就是, 迭代器遍歷的是開始遍歷那一刻拿到的集合拷貝,在遍歷期間原集合發生的修改迭代器是不知道的)

使用場景:

java.util.concurrent包下的容器都是Fail-Safe的,可以在多執行緒下併發使用,併發修改

容器中使用的設計模式

迭代器模式

在這裡插入圖片描述

  • Iterator它是在ArrayList等集合的內部類的方式實現

Collection 實現了 Iterable 介面,其中的 iterator() 方法能夠產生一個 Iterator 物件,通過這個物件就可以迭代遍歷 Collection 中的元素。

從 JDK 1.5 之後可以使用 foreach 方法來遍歷實現了 Iterable 介面的聚合物件。

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
    System.out.println(item);
}
複製程式碼

介面卡模式

介面卡模式解釋:www.jianshu.com/p/93821721b…

java.util.Arrays#asList() 可以把陣列型別轉換為 List 型別。

@SafeVarargs
public static <T> List<T> asList(T... a)
複製程式碼

如果要將陣列型別轉換為 List 型別,應該注意的是 asList() 的引數為泛型的變長引數,因此不能使用基本型別陣列作為引數,只能使用相應的包裝型別陣列。

Integer[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
複製程式碼

也可以使用以下方式生成 List。

List list = Arrays.asList(1,2,3);
複製程式碼

原始碼分析

ArrayList

關鍵詞

  • 預設大小為 10
  • 擴容 1.5 倍,載入因子為 0.5
  • 基於動態陣列實現
  • 刪除元素時不會減少容量,若希望減少容量則呼叫trimToSize()
  • 它不是執行緒安全的
  • 它能存放null值。
  • 擴容操作需要呼叫 Arrays.copyOf() 把原陣列整個複製到新陣列
  • 刪除需要呼叫 System.arraycopy() 將 index+1 後面的元素都複製到 index 位置上,複製的代價很高。 -序列化:只序列化陣列中有元素填充那部分內容

概覽

在這裡插入圖片描述

實現了 RandomAccess 介面,因此支援隨機訪問。這是理所當然的,因為 ArrayList 是基於陣列實現的。

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
複製程式碼

擴容

如果不夠時,需要使用 grow() 方法進行擴容,新容量的大小為 oldCapacity + (oldCapacity >> 1),也就是舊容量的 1.5 倍。

擴容操作需要呼叫 Arrays.copyOf() 把原陣列整個複製到新陣列

因此最好在建立 ArrayList 物件時就指定大概的容量大小,減少擴容操作的次數。

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);
}
複製程式碼

加入元素:add

add(E e)

首先去檢查一下陣列的容量是否足夠

  • 足夠:直接新增
  • 不足夠:擴容

擴容到原來的1.5倍,第一次擴容後,如果容量還是小於minCapacity,就將容量擴充為minCapacity。

add(int index, E element)

步驟:

  • 檢查角標
  • 空間檢查,如果有需要進行擴容
  • 插入元素

刪除元素:remove

步驟:

  • 檢查角標
  • 刪除元素
  • 計算出需要移動的個數,並移動
  • 設定為null,讓GC回收(所以說不是立刻回收,而是等待GC回收)
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+1 後面的元素都複製到 index 位置上,複製的代價很高。

複製陣列:System.arraycopy()

看到arraycopy(),我們可以發現:該方法是由C/C++來編寫的

在這裡插入圖片描述

Fail-Fast

modCount 用來記錄 ArrayList 結構發生變化的次數。結構發生變化是指新增或者刪除至少一個元素的所有操作,或者是調整內部陣列的大小,僅僅只是設定元素的值不算結構發生變化。

在進行序列化或者迭代等操作時,需要比較操作前後 modCount 是否改變,如果改變了需要丟擲 ConcurrentModificationException。

private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out size as capacity for behavioural compatibility with clone()
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }

    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}
複製程式碼

構造器

ArrayList 提供了三種方式的構造器:

  • public ArrayList()可以構造一個預設初始容量為10的空列表;
  • public ArrayList(int initialCapacity)構造一個指定初始容量的空列表;
  • public ArrayList(Collection<? extends E> c)構造一個包含指定 collection 的元素的列表,這些元素按照該collection的迭代器返回它們的順序排列的。

序列化

補充:transient講解

www.importnew.com/21517.html

你只需要實現Serilizable介面,將不需要序列化的屬性前新增關鍵字transient,序列化物件的時候,這個屬性就不會序列化到指定的目的地中。

ArrayList 基於陣列實現,並且具有動態擴容特性,因此儲存元素的陣列不一定都會被使用,那麼就沒必要全部進行序列化。

儲存元素的陣列 elementData 使用 transient 修飾,該關鍵字宣告陣列預設不會被序列化

transient Object[] elementData; // non-private to simplify nested class access
複製程式碼

ArrayList 實現了 writeObject() 和 readObject() 來控制只序列化陣列中有元素填充那部分內容

private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    elementData = EMPTY_ELEMENTDATA;

    // Read in size, and any hidden stuff
    s.defaultReadObject();

    // Read in capacity
    s.readInt(); // ignored

    if (size > 0) {
        // be like clone(), allocate array based upon size not capacity
        ensureCapacityInternal(size);

        Object[] a = elementData;
        // Read in all elements in the proper order.
        for (int i=0; i<size; i++) {
            a[i] = s.readObject();
        }
    }
}
複製程式碼
private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out size as capacity for behavioural compatibility with clone()
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }

    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}
複製程式碼

序列化時需要使用 ObjectOutputStream 的 writeObject() 將物件轉換為位元組流並輸出。而 writeObject() 方法在傳入的物件存在 writeObject() 的時候會去反射呼叫該物件的 writeObject() 來實現序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法,原理類似。

ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);
複製程式碼

Vector

關鍵詞

  • 預設大小為 10(與Arraylist相同)
  • 擴容 2 倍,載入因子是 1(Arraylist是擴容 1.5 倍,載入因子為 0.5)
  • 其它幾乎與ArrayList完全相同,唯一的區別在於 Vector 是同步的,因此開銷就比 ArrayList 要大,訪問速度更慢。
  • 使用了 synchronized 進行同步
  • Vector是jdk1.2的類了,比較老舊的一個集合類。應使用JUC的CopyOnWriteArrayList代替

替代方案

可以使用 Collections.synchronizedList(); 得到一個執行緒安全的 ArrayList。

List<String> list = new ArrayList<>();
List<String> synList = Collections.synchronizedList(list);
複製程式碼

也可以使用 concurrent 併發包下的 CopyOnWriteArrayList 類。

List<String> list = new CopyOnWriteArrayList<>();
複製程式碼

CopyOnWriteArrayList

關鍵詞

  • 寫操作在一個複製的陣列上進行,讀操作還是在原始陣列中進行,讀寫分離,互不影響。
  • 寫操作需要加鎖,防止併發寫入時導致寫入資料丟失。
  • 寫操作結束之後需要把原始陣列指向新的複製陣列。
  • 適用於讀操作遠大於寫操作的場景。

讀寫分離

public boolean add(E 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;
}
複製程式碼
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}
複製程式碼

適用場景

CopyOnWriteArrayList 在寫操作的同時允許讀操作,大大提高了讀操作的效能,因此很適合讀多寫少的應用場景。

缺陷

  • 記憶體佔用:在寫操作時需要複製一個新的陣列,使得記憶體佔用為原來的兩倍左右;
  • 資料不一致:讀操作不能讀取實時性的資料,因為部分寫操作的資料還未同步到讀陣列中

所以 CopyOnWriteArrayList 不適合記憶體敏感以及對實時性要求很高的場景。

LinkedList

關鍵詞

  • 雙向連結串列
  • 預設大小為 10
  • 帶 Head 和 Tail 指標
  • Node 儲存節點資訊

概覽

在這裡插入圖片描述

基於雙向連結串列實現,內部使用 Node 來儲存連結串列節點資訊。

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;
}
複製程式碼

每個連結串列儲存了 Head 和 Tail 指標:

transient Node<E> first;
transient Node<E> last;
複製程式碼

在這裡插入圖片描述

ArrayList 與 LinkedList 比較

  • ArrayList 基於動態陣列實現,LinkedList 基於雙向連結串列實現;
  • ArrayList 支援隨機訪問,LinkedList 不支援;
  • LinkedList 在任意位置新增刪除元素更快。

刪除元素:remove

在這裡插入圖片描述

獲取元素:get

  • 下標小於長度的一半,從頭遍歷
  • 反之,從尾部遍歷

替換元素:set

set方法和get方法其實差不多,根據下標來判斷是從頭遍歷還是從尾遍歷

其他方法

LinkedList實現了Deque介面,因此,我們可以操作LinkedList像操作佇列和棧一樣

LinkedList的方法比ArrayList的方法多太多了,這裡我就不一一說明了。具體可參考:

HashMap

wiki.jikexueyuan.com/project/jav…

原始碼分析:segmentfault.com/a/119000001…

關鍵詞

  • 初始容量16
  • 擴容是2倍,載入因子0.75
  • 頭插法
  • 0桶存放null
  • 從 JDK 1.8 開始,一個桶儲存的連結串列長度大於 8 時會將連結串列轉換為紅黑樹(前提:鍵值對要超過64個)
  • 自動地將傳入的容量轉換為2的冪次方
    • 保證運算速度:確保用位運算代替模運算來計算桶下標。hash& (length-1)運算等價於對 length 取模。
    • hash均勻分佈:資料在陣列上分佈就比較均勻,並且能夠利用全部二進位制位,也就是說碰撞的機率小
  • table陣列+Entry<K,V>[]連結串列(雜湊表),紅黑樹
  • 擴容操作需要把鍵值對重新插入新的 table 中,重新計算所有key有特殊機制(JDK1.8後)

儲存結構

hashMap的一個內部類Node:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next; //連結串列結構,儲存下一個元素
複製程式碼

在這裡插入圖片描述

Node內部包含了一個 Entry 型別的陣列table,陣列中的每個位置被當成一個桶。

transient Entry[] table;
複製程式碼

Entry 儲存著鍵值對。它包含了四個欄位,從 next 欄位我們可以看出 Entry 是一個連結串列。即陣列中的每個位置被當成一個桶,一個桶存放一個連結串列。

HashMap 使用拉鍊法來解決衝突,同一個連結串列中存放雜湊值相同的 Entry。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    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;
    }

    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 Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }
}
複製程式碼

構造器

在這裡插入圖片描述

構造時就會呼叫tableSizeFor():返回一個大於輸入引數且最近的2的整數次冪。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼

拉鍊法

應該注意到連結串列的插入是以頭插法方式進行的

HashMap<String, String> map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
複製程式碼
  • 新建一個 HashMap,預設大小為 16;
  • 插入 <K1,V1> 鍵值對,先計算 K1 的 hashCode 為 115,使用除留餘數法得到所在的桶下標 115%16=3。
  • 插入 <K2,V2> 鍵值對,先計算 K2 的 hashCode 為 118,使用除留餘數法得到所在的桶下標 118%16=6。
  • 插入 <K3,V3> 鍵值對,先計算 K3 的 hashCode 為 118,使用除留餘數法得到所在的桶下標 118%16=6,插在 <K2,V2> 前面。

查詢需要分成兩步進行:

  • 計算鍵值對所在的桶;
  • 在連結串列上順序查詢,時間複雜度顯然和連結串列的長度成正比。

put 操作

  • 當我們 put 的時候,如果 key 存在了,那麼新的 value 會代替舊的 value
  • 如果 key 存在的情況下,該方法返回的是舊的 value,
  • 如果 key 不存在,那麼返回 null。
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 鍵為 null 單獨處理
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    // 確定桶下標
    int i = indexFor(hash, table.length);
    // 先找出是否已經存在鍵為 key 的鍵值對,如果存在的話就更新這個鍵值對的值為 value
    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;
}
複製程式碼

HashMap 允許插入鍵為 null 的鍵值對。但是因為無法呼叫 null 的 hashCode() 方法,也就無法確定該鍵值對的桶下標,只能通過強制指定一個桶下標來存放。HashMap 使用第 0 個桶存放鍵為 null 的鍵值對。

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;
}
複製程式碼

使用連結串列的頭插法,也就是新的鍵值對插在連結串列的頭部,而不是連結串列的尾部。

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 = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 頭插法,連結串列頭部指向新的鍵值對
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
複製程式碼
Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}
複製程式碼

補充:hashmap裡hash方法的高位優化:

www.cnblogs.com/liujinhong/…

note.youdao.com/yws/res/187…

設計者將key的雜湊值的高位也做了運算(與高16位做異或運算,使得在做&運算時,此時的低位實際上是高位與低位的結合),這就增加了隨機性,減少了碰撞衝突的可能性!

為何要這麼做?

table的長度都是2的冪,因此index僅與hash值的低n位有關,hash值的高位都被與操作置為0了。

這樣做很容易產生碰撞。設計者權衡了speed, utility, and quality,將高16位與低16位異或來減少這種影響。設計者考慮到現在的hashCode分佈的已經很不錯了,而且當發生較大碰撞時也用樹形儲存降低了衝突。僅僅異或一下,既減少了系統的開銷,也不會造成的因為高位沒有參與下標的計算(table長度比較小時),從而引起的碰撞。

確定桶下標

很多操作都需要先確定一個鍵值對所在的桶下標。

int hash = hash(key);
int i = indexFor(hash, table.length);
複製程式碼

4.1 計算 hash 值

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // 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);
}
複製程式碼
public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
}
複製程式碼

4.2 取模

令 x = 1<<\4,即 \x 為 2 的 4 次方,它具有以下性質:

x   : 00010000
x-1 : 00001111
複製程式碼

令一個數 y 與 x-1 做與運算,可以去除 y 位級表示的第 4 位以上數:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010
複製程式碼

這個性質和 y 對 x 取模效果是一樣的:

y   : 10110010
x   : 00010000
y%x : 00000010
複製程式碼

我們知道,位運算的代價比求模運算小的多,因此在進行這種計算時用位運算的話能帶來更高的效能。

確定桶下標的最後一步是將 key 的 hash 值對桶個數取模:hash%capacity,如果能保證 capacity 為 2 的 n 次方,那麼就可以將這個操作轉換為位運算。

static int indexFor(int h, int length) {
    return h & (length-1);
}
複製程式碼

當 length 總是 2 的 n 次方時,h& (length-1)運算等價於對 length 取模,也就是 h%length,但是 & 比 % 具有更高的效率。這看上去很簡單,其實比較有玄機的,我們舉個例子來說明:

h & (table.length-1) hash table.length-1
8 & (15-1): 0100 & 1110 = 0100
9 & (15-1): 0101 & 1110 = 0100
8 & (16-1): 0100 & 1111 = 0100
9 & (16-1): 0101 & 1111 = 0101
  • 從上面的例子中可以看出:當它們和 15-1(1110)“與”的時候,8 和 9產生了相同的結果,也就是說它們會定位到陣列中的同一個位置上去,這就產生了碰撞,8 和 9 會被放到陣列中的同一個位置上形成連結串列,那麼查詢的時候就需要遍歷這個鏈 表,得到8或者9,這樣就降低了查詢的效率。

  • 同時,我們也可以發現,當陣列長度為 15 的時候,hash 值會與 15-1(1110)進行“與”,那麼最後一位永遠是 0,而 0001,0011,0101,1001,1011,0111,1101 這幾個位置永遠都不能存放元素了空間浪費相當大,陣列可以使用的位置比陣列長度小了很多,這意味著進一步增加了碰撞的機率。

  • 而當陣列長度為16時,即為2的n次方時,2n-1 得到的二進位制數的每個位上的值都為 1,這使得在低位上&時,得到的和原 hash 的低位相同,加之 hash(int h)方法對 key 的 hashCode 的進一步優化,加入了高位計算,就使得只有相同的 hash 值的兩個值才會被放到陣列中的同一個位置上形成連結串列。

所以說,當陣列長度為 2 的 n 次冪的時候,不同的 key 算得得 index 相同的機率較小,那麼資料在陣列上分佈就比較均勻,也就是說碰撞的機率小

擴容-基本原理

設 HashMap 的 table 長度為 M,需要儲存的鍵值對數量為 N,如果雜湊函式滿足均勻性的要求,那麼每條連結串列的長度大約為 N/M,因此平均查詢次數的複雜度為 O(N/M)。

為了讓查詢的成本降低,應該儘可能使得 N/M 儘可能小,因此需要保證 M 儘可能大,也就是說 table 要儘可能大。HashMap 採用動態擴容來根據當前的 N 值來調整 M 值,使得空間效率和時間效率都能得到保證。

和擴容相關的引數主要有:capacity、size、threshold 和 load_factor。

引數 含義
capacity table 的容量大小,預設為 16。需要注意的是 capacity 必須保證為 2 的 n 次方。
size 鍵值對數量。
threshold size 的臨界值,當 size 大於等於 threshold 就必須進行擴容操作。
loadFactor 裝載因子,table 能夠使用的比例,threshold = capacity * loadFactor。
static final int DEFAULT_INITIAL_CAPACITY = 16;

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

transient Entry[] table;

transient int size;

int threshold;

final float loadFactor;

transient int modCount;
複製程式碼

從下面的新增元素程式碼中可以看出,當需要擴容時,令 capacity 為原來的兩倍。

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

擴容使用 resize() 實現,需要注意的是,擴容操作同樣需要把 oldTable 的所有鍵值對重新插入 newTable 中,因此這一步是很費時的。

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

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);
        }
    }
}
複製程式碼

擴容-重新計算桶下標

Rehash優化:my.oschina.net/u/3568600/b…

在進行擴容時,需要把鍵值對重新放到對應的桶上。HashMap 使用了一個特殊的機制,可以降低重新計算桶下標的操作。

假設原陣列長度 capacity 為 16,擴容之後 new capacity 為 32:

capacity     : 00010000
new capacity : 00100000
複製程式碼

對於一個 Key,

  • 它的雜湊值如果在第 5 位上為 0,那麼取模得到的結果和之前一樣;
  • 如果為 1,那麼得到的結果為原來的結果 +16。

總結:

經過rehash之後,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置

因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”,可以看看下圖為16擴充為32的resize示意圖:

在這裡插入圖片描述

計算陣列容量

HashMap 建構函式允許使用者傳入的容量不是 2 的 n 次方,因為它可以自動地將傳入的容量轉換為 2 的 n 次方。

先考慮如何求一個數的掩碼,對於 10010000,它的掩碼為 11111111,可以使用以下方法得到:

mask |= mask >> 1    11011000
mask |= mask >> 2    11111110
mask |= mask >> 4    11111111
複製程式碼

mask+1 是大於原始數字的最小的 2 的 n 次方。

num     10010000
mask+1 100000000
複製程式碼

以下是 HashMap 中計算陣列容量的程式碼:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製程式碼

連結串列轉紅黑樹

並不是桶子上有8位元素的時候它就能變成紅黑樹,它得同時滿足我們的鍵值對大於64才行的

這是為了避免在雜湊表建立初期,多個鍵值對恰好被放入了同一個連結串列中而導致不必要的轉化。

HashTable

關鍵詞:

  • Hashtable的迭代器不是 fail-fast,HashMap 的迭代器是 fail-fast 迭代器。
  • Hashtable 的 key 和 value 都不允許為 null,HashMap 可以插入鍵為 null 的 Entry。
  • HashTable 使用 synchronized 來進行同步。
  • 基於 Dictionary 類(遺留類)
  • HashMap 不能保證隨著時間的推移 Map 中的元素次序是不變的。

HashMap 與 HashTable

在這裡插入圖片描述

  • HashTable 基於 Dictionary 類(遺留類),而 HashMap 是基於 AbstractMap。
    • Dictionary 是任何可將鍵對映到相應值的類的抽象父類
    • 而AbstractMap是基於Map介面的實現,它以最大限度地減少實現此介面所需的工作。
  • HashMap 的 key 和 value 都允許為 null,而 Hashtable 的 key 和 value 都不允許為 null
  • HashMap 的迭代器是 fail-fast 迭代器,而 Hashtable 的 enumerator 迭代器不是 fail-fast 的。
  • 由於 Hashtable 是執行緒安全的也是 synchronized,所以在單執行緒環境下它比 HashMap 要慢。
  • Hashtable 中的幾乎所有的 public 的方法都是synchronized的,而有些方法也是在內部通過 synchronized 程式碼塊來實現。
    • 但是在 Collections 類中存在一個靜態方法:synchronizedMap(),該方法建立了一個執行緒安全的 Map 物件,並把它作為一個封裝的物件來返回。
    • 也可以使用 ConcurrentHashMap,它是 HashTable 的替代,而且比 HashTable 可擴充套件性更好

ConcurrentHashMap

談談ConcurrentHashMap1.7和1.8的不同實現:

www.importnew.com/23610.html

詳細原始碼分析(還未細看):

blog.csdn.net/yan_wenlian…

segmentfault.com/a/119000001…

主要針對jdk1.7的實現來介紹

關鍵詞

  • key和value都不允許為null
  • Hashtable是將所有的方法進行同步,效率低下。而ConcurrentHashMap通過部分鎖定+CAS演算法來進行實現執行緒安全的
  • get方法是非阻塞,無鎖的。重寫Node類,通過volatile修飾next來實現每次獲取都是最新設定的值
  • 在高併發環境下,統計資料(計算size...等等)其實是無意義的,因為在下一時刻size值就變化了。
  • 實現形式不同:
    • 1.7:Segment + HashEntry的方式進行實現
    • 1.8:與HashMap相同(雜湊表(陣列+連結串列)+紅黑樹)採用Node陣列 + CAS + Synchronized來保證併發安全進行實現

儲存結構

jdk1.7

jdk1.7中採用Segment + HashEntry的方式進行實現

在這裡插入圖片描述

Segment:其繼承於 ReentrantLock 類,從而使得 Segment 物件可以充當鎖的角色。

Segment 中包含HashBucket的陣列,其可以守護其包含的若干個桶。

static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;
}
複製程式碼

ConcurrentHashMap採用了分段鎖,每個分段鎖維護著幾個桶,多個執行緒可以同時訪問不同分段鎖上的桶,從而使其併發度更高(併發度就是 Segment 的個數)。

jdk1.8

在這裡插入圖片描述

  • JDK 1.7 使用分段鎖機制來實現併發更新操作,核心類為 Segment,它繼承自重入鎖 ReentrantLock,併發程度與 Segment 數量相等。

  • JDK 1.8 使用了 CAS 操作來支援更高的併發度,在 CAS 操作失敗時使用內建鎖 synchronized。

  • 並且 JDK 1.8 的實現也在連結串列過長時會轉換為紅黑樹。

1.8中放棄了Segment臃腫的設計,取而代之的是採用Node陣列 + CAS + Synchronized來保證併發安全進行實現

新增元素:put

在這裡插入圖片描述

只讓一個執行緒對雜湊表進行初始化!

獲取元素:get

從頂部註釋我們可以讀到,get方法是不用加鎖的,是非阻塞的。

Node節點是重寫的,設定了volatile關鍵字修飾,致使它每次獲取的都是最新設定的值

獲取大小:size

每個 Segment 維護了一個 count 變數來統計該 Segment 中的鍵值對個數。

在執行 size 操作時,需要遍歷所有 Segment 然後把 count 累計起來。

ConcurrentHashMap 在執行 size操作時先嚐試不加鎖,如果連續兩次不加鎖操作得到的結果一致,那麼可以認為這個結果是正確的。

嘗試次數使用 RETRIES_BEFORE_LOCK 定義,該值為 2,retries 初始值為 -1,因此嘗試次數為 3。

如果嘗試的次數超過 3 次,就需要對每個 Segment 加鎖。

刪除元素:remove

在這裡插入圖片描述

為什麼用這麼方式刪除呢,細心的同學會發現上面定義的HashEntry的key和next都是final型別的,所以不能改變next的指向,所以又複製了一份指向刪除的結點的next。

Collections.synchronizedMap()與ConcurrentHashMap的區別

參考:blog.csdn.net/lanxiangru/…

  • Collections.synchronizedMap()和Hashtable一樣,實現上在呼叫map所有方法時,都對整個map進行同步,而ConcurrentHashMap的實現卻更加精細,它對map中的所有桶加了鎖同步操作精確控制到桶,所以,即使在遍歷map時,其他執行緒試圖對map進行資料修改,也不會丟擲ConcurrentModificationException。
  • ConcurrentHashMap從類的命名就能看出,它是個HashMap。而Collections.synchronizedMap()可以接收任意Map例項,實現Map的同步。比如TreeMap。

總結

ConcurrentHashMap 的高併發性主要來自於三個方面:

  • 分離鎖實現多個執行緒間的更深層次的共享訪問。
  • HashEntery物件的不變性來降低執行讀操作的執行緒在遍歷連結串列期間對加鎖的需求。
  • 通過對同一個 Volatile 變數的寫 / 讀訪問,協調不同執行緒間讀 / 寫操作的記憶體可見性。

LinkedHashMap

wiki.jikexueyuan.com/project/jav…

segmentfault.com/a/119000001…

關鍵詞

  • 允許使用 null 值和 null 鍵
  • 此實現不是同步的(linkedlist,lilnkedhashset也不是同步的)
  • 維護著一個執行於所有條目的雙向連結串列。定義了迭代順序,該迭代順序可以是插入順序或者是訪問順序
  • 初始容量對遍歷沒有影響:遍歷的雙向連結串列,而不是雜湊表
  • 在訪問順序的情況下,使用get方法也是結構性的修改(會導致Fail-Fast)

概論

在這裡插入圖片描述

在這裡插入圖片描述

成員變數

該 Entry 除了儲存當前物件的引用外,還儲存了其上一個元素 before 和下一個元素 after的引用,從而在雜湊表的基礎上又構成了雙向連結列表。

/**
* LinkedHashMap的Entry元素。
* 繼承HashMap的Entry元素,又儲存了其上一個元素before和下一個元素after的引用。
 */
static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }
複製程式碼

構造器

在這裡插入圖片描述

  • 通過原始碼可以看出,在 LinkedHashMap 的構造方法中,實際呼叫了父類 HashMap 的相關構造方法來構造一個底層存放的 table 陣列,但額外可以增加 accessOrder 這個引數,如果不設定

    • 預設為 false,代表按照插入順序進行迭代;
    • 當然可以顯式設定為 true,代表以訪問順序進行迭代。
  • 在構建新節點時,構建的是LinkedHashMap.Entry 不再是Node.

獲取元素:get

LinkedHashMap 重寫了父類 HashMap 的 get 方法,實際在呼叫父類 getEntry() 方法取得查詢的元素後,再判斷當排序模式 accessOrder 為 true 時,記錄訪問順序,將最新訪問的元素新增到雙向連結串列的表頭,並從原來的位置刪除。

由於的連結串列的增加、刪除操作是常量級的,故並不會帶來效能的損失。

遍歷元素

為啥註釋說:初始容量對遍歷沒有影響?

因為它遍歷的是LinkedHashMap內部維護的一個雙向連結串列,而不是雜湊表(當然了,連結串列雙向連結串列的元素都來源於雜湊表)

LinkedHashMap應用

wiki.jikexueyuan.com/project/jav…

LRU最近最少使用(訪問順序)

用這個類有兩大好處:

  • 它本身已經實現了按照訪問順序或插入順序的儲存
  • LinkedHashMap 本身有removeEldestEntry方法用於判斷是否需要移除最不常讀取的數,但是,原始方法預設不需要移除,我們需要override這樣一個方法。

Java裡面實現LRU快取通常有兩種選擇:

  • 使用LinkedHashMap
  • 自己設計資料結構,使用連結串列+HashMap

以下是使用 LinkedHashMap 實現的一個 LRU 快取:

  • 設定最大快取空間 MAX_ENTRIES 為 3;
  • 使用 LinkedHashMap 的建構函式將 accessOrder 設定為 true,開啟 LRU 順序;
  • 覆蓋 removeEldestEntry() 方法實現,在節點多於 MAX_ENTRIES 就會將最近最久未使用的資料移除。
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_ENTRIES = 3;

    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }

    LRUCache() {
        super(MAX_ENTRIES, 0.75f, true);
    }
}
複製程式碼
public static void main(String[] args) {
    LRUCache<Integer, String> cache = new LRUCache<>();
    cache.put(1, "a");
    cache.put(2, "b");
    cache.put(3, "c");
    cache.get(1);
    cache.put(4, "d");
    System.out.println(cache.keySet());
}
複製程式碼
[3, 1, 4]
複製程式碼

實現詳細程式碼請參考文章:補充知識點-快取

FIFO(插入順序)

還可以在插入順序的LinkedHashMap直接重寫下removeEldestEntry方法即可輕鬆實現一個FIFO快取

TreeMap

關鍵詞

  • 紅黑樹
  • 非同步
  • key不能為null
  • 實現了NavigableMap介面,而NavigableMap介面繼承著SortedMap介面,是有序的(HahMap是Key無序的)
  • TreeMap的基本操作 containsKey、get、put 和 remove 的時間複雜度是 log(n) 。
  • 適用於查詢效能要求不那麼高,反而對有序性要求比較高的應用場景
  • 使用Comparator或者Comparable來比較key是否相等與排序的問題

概覽

在這裡插入圖片描述

獲取元素:get

詳細看:

segmentfault.com/a/119000001…

總結:

  • 如果在構造方法中傳遞了Comparator物件,那麼就會以Comparator物件的方法進行比較。否則,則使用Comparable的compareTo(T o)方法來比較。
  • 值得說明的是:如果使用的是compareTo(T o)方法來比較,key一定是不能為null,並且得實現了Comparable介面的。
  • 即使是傳入了Comparator物件,不用compareTo(T o)方法來比較,key也是不能為null的

刪除元素:remove

刪除節點並且平衡紅黑樹

HashSet

wiki.jikexueyuan.com/project/jav…

segmentfault.com/a/119000001…

關鍵詞:

  • 預設容量16,擴容兩倍,載入因子0.75

  • 允許元素為null

  • 實現Set介面

  • 不保證迭代順序

  • 非同步

  • 初始容量非常影響迭代效能

  • 底層實際上是一個HashMap例項

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

如果新增的是在 HashSet 中不存在的,則返回 true;如果新增的元素已經存在,返回 false。

對於 HashSet 中儲存的物件,請注意正確重寫其 equals 和 hashCode 方法,以保證放入的物件的唯一性。

HashSet 和 HashMap 的區別

重要:

1. HashMap中使用鍵物件來計算hashcode值

2. HashSet使用成員物件來計算hashcode值,對於兩個物件來說hashcode可能相同,所以equals()方法用來判斷物件的相等性,如果兩個物件不同的話,那麼返回false

在這裡插入圖片描述

TreeSet

關鍵詞

  • 實現NavigableSet介面
  • 可以實現排序功能
  • 底層實際上是一個TreeMap例項
  • 非同步
  • 不允許為null

LinkedHashSet

關鍵詞

  • 迭代是有序的
  • 允許為null
  • 底層實際上是一個HashMap+雙向連結串列例項(其實就是LinkedHashMap)
  • 非同步
  • 效能比HashSet差一丟丟,因為要維護一個雙向連結串列
  • 初始容量與迭代無關(與LinkedHashMap相同),因為LinkedHashSet迭代的是雙向連結串列

總結Set

HashSet:

  • 無序,允許為null,底層是HashMap(雜湊表+紅黑樹),非執行緒同步

TreeSet:

  • 有序,不允許為null,底層是TreeMap(紅黑樹),非執行緒同步

LinkedHashSet:

  • 迭代有序,允許為null,底層是HashMap+雙向連結串列,非執行緒同步

WeekHashMap

儲存結構

WeakHashMap 的 Entry 繼承自 WeakReference,被 WeakReference 關聯的物件在下一次垃圾回收時會被回收

WeakHashMap 主要用來實現快取,通過使用 WeakHashMap 來引用快取物件,由 JVM 對這部分快取進行回收。

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>
複製程式碼

ConcurrentCache

Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 來實現快取功能。

ConcurrentCache 採取的是分代快取:

  • 經常使用的物件放入 eden 中,eden 使用 ConcurrentHashMap 實現,不用擔心會被回收(伊甸園);
  • 不常用的物件放入 longterm,longterm 使用 WeakHashMap 實現,這些老物件會被垃圾收集器回收。
  • 當呼叫 get() 方法時,會先從 eden 區獲取,如果沒有找到的話再到 longterm 獲取,當從 longterm 獲取到就把物件放入 eden 中,從而保證經常被訪問的節點不容易被回收。
  • 當呼叫 put() 方法時,如果 eden 的大小超過了 size,那麼就將 eden 中的所有物件都放入 longterm 中,利用虛擬機器回收掉一部分不經常使用的物件。
public final class ConcurrentCache<K, V> {

    private final int size;

    private final Map<K, V> eden;

    private final Map<K, V> longterm;

    public ConcurrentCache(int size) {
        this.size = size;
        this.eden = new ConcurrentHashMap<>(size);
        this.longterm = new WeakHashMap<>(size);
    }

    public V get(K k) {
        V v = this.eden.get(k);
        if (v == null) {
            v = this.longterm.get(k);
            if (v != null)
                this.eden.put(k, v);
        }
        return v;
    }

    public void put(K k, V v) {
        if (this.eden.size() >= size) {
            this.longterm.putAll(this.eden);
            this.eden.clear();
        }
        this.eden.put(k, v);
    }
}
複製程式碼

常見問題總結

Enumeration和Iterator介面的區別

Iterator替代了Enumeration,Enumeration是一箇舊的迭代器了。

與Enumeration相比,Iterator更加安全,因為當一個集合正在被遍歷的時候,它會阻止其它執行緒去修改集合。

區別有三點:

  • Iterator的方法名比Enumeration更科學
  • Iterator有fail-fast機制,比Enumeration更安全
  • Iterator能夠刪除元素,Enumeration並不能刪除元素

ListIterator有什麼特點

  • ListIterator繼承了Iterator介面,它用於遍歷List集合的元素。
  • ListIterator可以實現雙向遍歷,新增元素,設定元素

在這裡插入圖片描述

與Java集合框架相關的有哪些最好的實踐

如果是單列的集合,我們考慮用Collection下的子介面ArrayList和Set。

如果是對映,我們就考慮使用Map

  • 是否需要同步:去找執行緒安全的集合類使用

  • 迭代時是否需要有序(插入順序有序):去找Linked雙向列表結構的

  • 是否需要排序(自然順序或者手動排序):去找Tree紅黑樹型別的(JDK1.8)

  • 估算存放集合的資料量有多大,無論是List還是Map,它們實現動態增長,都是有效能消耗的。在初始集合的時候給出一個合理的容量會減少動態增長時的消耗

  • 使用泛型,避免在執行時出現ClassCastException

  • 儘可能使用Collections工具類,或者獲取只讀、同步或空的集合,而非編寫自己的實現。它將會提供程式碼重用性,它有著更好的穩定性和可維護性

參考

關注我

本人目前為後臺開發工程師,主要關注Python爬蟲,後臺開發等相關技術。

原創部落格主要內容:

  • 筆試面試複習知識點手冊
  • Leetcode演算法題解析(前150題)
  • 劍指offer演算法題解析
  • Python爬蟲相關實戰
  • 後臺開發相關實戰

同步更新以下幾大部落格:

相關文章