文章收錄在 GitHub JavaKeeper ,N線互聯網開發必備技能兵器譜
作爲一位小菜 ”一面面試官“,面試過程中,我肯定會問 Java 集合的內容,同時作爲求職者,也肯定會被問到集合,所以整理下 Java 集合面試題
說說常見的集合有哪些吧?
HashMap說一下,其中的Key需要重寫hashCode()和equals()嗎?
HashMap中key和value可以爲null嗎?允許幾個爲null呀?
HashMap線程安全嗎?ConcurrentHashMap和hashTable有什麼區別?
List和Set說一下,現在有一個ArrayList,對其中的所有元素按照某一屬性大小排序,應該怎麼做?
ArrayList 和 Vector 的區別
list 可以刪除嗎,遍歷的時候可以刪除嗎,爲什麼
面嚮對象語言對事物的體現都是以對象的形式,所以爲了方便對多個對象的操作,需要將對象進行存儲,集合就是存儲對象最常用的一種方式,也叫容器。
從上面的集合框架圖可以看到,Java 集合框架主要包括兩種類型的容器
- 一種是集合(Collection),存儲一個元素集合
- 另一種是圖(Map),存儲鍵/值對映射。
Collection 接口又有 3 種子類型,List、Set 和 Queue,再下面是一些抽象類,最後是具體實現類,常用的有 ArrayList、LinkedList、HashSet、LinkedHashSet、HashMap、LinkedHashMap 等等。
集合框架是一個用來代表和操縱集合的統一架構。所有的集合框架都包含如下內容:
-
接口:是代表集合的抽象數據類型。例如 Collection、List、Set、Map 等。之所以定義多個接口,是爲了以不同的方式操作集合對象
-
實現(類):是集合接口的具體實現。從本質上講,它們是可重複使用的數據結構,例如:ArrayList、LinkedList、HashSet、HashMap。
-
算法:是實現集合接口的對象裏的方法執行的一些有用的計算,例如:搜索和排序。這些算法被稱爲多態,那是因爲相同的方法可以在相似的接口上有着不同的實現。
說說常用的集合有哪些吧?
Map 接口和 Collection 接口是所有集合框架的父接口:
- Collection接口的子接口包括:Set、List、Queue
- List是有序的允許有重複元素的Collection,實現類主要有:ArrayList、LinkedList、Stack以及Vector等
- Set是一種不包含重複元素且無序的Collection,實現類主要有:HashSet、TreeSet、LinkedHashSet等
- Map沒有繼承Collection接口,Map提供key到value的映射。實現類主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap 以及 Properties 等
ArrayList 和 Vector 的區別
相同點:
-
ArrayList 和 Vector 都是繼承了相同的父類和實現了相同的接口(都實現了List,有序、允許重複和null)
extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
-
底層都是數組(Object[])實現的
-
初始默認長度都爲10
不同點:
-
同步性:Vector 中的 public 方法多數添加了 synchronized 關鍵字、以確保方法同步、也即是 Vector 線程安全、ArrayList 線程不安全
-
性能:Vector 存在 synchronized 的鎖等待情況、需要等待釋放鎖這個過程、所以性能相對較差
-
擴容大小:ArrayList在底層數組不夠用時在原來的基礎上擴展 0.5 倍,Vector默認是擴展 1 倍
擴容機制,擴容方法其實就是新創建一個數組,然後將舊數組的元素都複製到新數組裏面。其底層的擴容方法都在 grow() 中(基於JDK8)
-
ArrayList 的 grow(),在滿足擴容條件時、ArrayList以1.5 倍的方式在擴容(oldCapacity >> 1 ,右移運算,相當於除以 2,結果爲二分之一的 oldCapacity)
private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; //newCapacity = oldCapacity + O.5*oldCapacity,此處擴容0.5倍 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); }
-
Vector 的 grow(),Vector 比 ArrayList多一個屬性,擴展因子capacityIncrement,可以擴容大小。當擴容容量增量大於0時、新數組長度爲原數組長度**+擴容容量增量、否則新數組長度爲原數組長度的2**倍
private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; // int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); elementData = Arrays.copyOf(elementData, newCapacity); }
-
ArrayList 與 LinkedList 區別
- 是否保證線程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保證線程安全;
- 底層數據結構: Arraylist 底層使用的是 Object 數組;LinkedList 底層使用的是雙向循環鏈表數據結構;
- 插入和刪除是否受元素位置的影響:
- ArrayList 採用數組存儲,所以插入和刪除元素的時間複雜度受元素位置的影響。 比如:執行
add(E e)
方法的時候, ArrayList 會默認在將指定的元素追加到此列表的末尾,這種情況時間複雜度就是O(1)。但是如果要在指定位置 i 插入和刪除元素的話(add(intindex,E element)
)時間複雜度就爲 O(n-i)。因爲在進行上述操作的時候集合中第 i 和第 i 個元素之後的(n-i)個元素都要執行向後位/向前移一位的操作。 - LinkedList 採用鏈表存儲,所以插入,刪除元素時間複雜度不受元素位置的影響,都是近似 ,而數組爲近似 。
- ArrayList 一般應用於查詢較多但插入以及刪除較少情況,如果插入以及刪除較多則建議使用 LinkedList
- ArrayList 採用數組存儲,所以插入和刪除元素的時間複雜度受元素位置的影響。 比如:執行
- 是否支持快速隨機訪問: LinkedList 不支持高效的隨機元素訪問,而 ArrayList 實現了 RandomAccess 接口,所以有隨機訪問功能。快速隨機訪問就是通過元素的序號快速獲取元素對象(對應於
get(intindex)
方法)。 - 內存空間佔用: ArrayList 的空間浪費主要體現在在 list 列表的結尾會預留一定的容量空間,而 LinkedList 的空間花費則體現在它的每一個元素都需要消耗比 ArrayList 更多的空間(因爲要存放直接後繼和直接前驅以及數據)。
高級工程師的我,可不得看看源碼,具體分析下:
-
ArrayList工作原理其實很簡單,底層是動態數組,每次創建一個 ArrayList 實例時會分配一個初始容量(沒有指定初始容量的話,默認是 10),以add方法爲例,如果沒有指定初始容量,當執行add方法,先判斷當前數組是否爲空,如果爲空則給保存對象的數組分配一個最小容量,默認爲10。當添加大容量元素時,會先增加數組的大小,以提高添加的效率;
-
LinkedList 是有序並且支持元素重複的集合,底層是基於雙向鏈表的,即每個節點既包含指向其後繼的引用也包括指向其前驅的引用。鏈表無容量限制,但雙向鏈表本身使用了更多空間,也需要額外的鏈表指針操作。按下標訪問元素
get(i)/set(i,e)
要悲劇的遍歷鏈表將指針移動到位(如果i>數組大小的一半,會從末尾移起)。插入、刪除元素時修改前後節點的指針即可,但還是要遍歷部分鏈表的指針才能移動到下標所指的位置,只有在鏈表兩頭的操作add()
,addFirst()
,removeLast()
或用iterator()
上的remove()
能省掉指針的移動。此外 LinkedList 還實現了 Deque(繼承自Queue接口)接口,可以當做隊列使用。
不會囊括所有方法,只是爲了學習,記錄思想。
ArrayList 和 LinkedList 兩者都實現了 List 接口
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
構造器
ArrayList 提供了 3 個構造器,①無參構造器 ②帶初始容量構造器 ③參數爲集合構造器
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
// 創建初始容量的數組
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
}
}
public ArrayList() {
// 默認爲空數組
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
public ArrayList(Collection<? extends E> c) { //...}
}
LinkedList 提供了 2 個構造器,因爲基於鏈表,所以也就沒有初始化大小,也沒有擴容的機制,就是一直在前面或者後面插插插~~
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
// LinkedList 既然作爲鏈表,那麼肯定會有節點
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
插入
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) {
// DEFAULT_CAPACITY 爲 10,所以調用無參默認 ArrayList 構造方法初始化的話,默認的數組容量爲 10
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 確保數組的容量,如果不夠的話,調用 grow 方法擴容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//擴容具體的方法
private void grow(int minCapacity) {
// 當前數組的容量
int oldCapacity = elementData.length;
// 新數組擴容爲原來容量的 1.5 倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果新數組擴容容量還是比最少需要的容量還要小的話,就設置擴充容量爲最小需要的容量
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//判斷新數組容量是否已經超出最大數組範圍,MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
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(int index, E element)
public void add(int index, E element) {
// 判斷 index 有沒有超出索引的範圍
rangeCheckForAdd(index);
// 和之前的操作是一樣的,都是保證數組的容量足夠
ensureCapacityInternal(size + 1); // Increments modCount!!
// 將指定位置及其後面數據向後移動一位
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
// 將該元素添加到指定的數組位置
elementData[index] = element;
// ArrayList 的大小改變
size++;
}
可以看到每次插入指定位置都要移動元素,效率較低。
再來看 LinkedList 的插入,也有插入末尾,插入指定位置兩種,由於基於鏈表,肯定得先有個 Node
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;
}
}
public boolean add(E e) {
// 直接往隊尾加元素
linkLast(e);
return true;
}
void linkLast(E e) {
// 保存原來鏈表尾部節點,last 是全局變量,用來表示隊尾元素
final Node<E> l = last;
// 爲該元素 e 新建一個節點
final Node<E> newNode = new Node<>(l, e, null);
// 將新節點設爲隊尾
last = newNode;
// 如果原來的隊尾元素爲空,那麼說明原來的整個列表是空的,就把新節點賦值給頭結點
if (l == null)
first = newNode;
else
// 原來尾結點的後面爲新生成的結點
l.next = newNode;
// 節點數 +1
size++;
modCount++;
}
public void add(int index, E element) {
// 檢查 index 有沒有超出索引範圍
checkPositionIndex(index);
// 如果追加到尾部,那麼就跟 add(E e) 一樣了
if (index == size)
linkLast(element);
else
// 否則就是插在其他位置
linkBefore(element, node(index));
}
//linkBefore方法中調用了這個node方法,類似二分查找的優化
Node<E> node(int index) {
// assert isElementIndex(index);
// 如果 index 在前半段,從前往後遍歷獲取 node
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// 如果 index 在後半段,從後往前遍歷獲取 node
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
void linkBefore(E e, Node<E> succ) {
// assert succ != null;
// 保存 index 節點的前節點
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++;
}
獲取
ArrayList 的 get() 方法很簡單,就是在數組中返回指定位置的元素即可,所以效率很高
public E get(int index) {
// 檢查 index 有沒有超出索引的範圍
rangeCheck(index);
// 返回指定位置的元素
return elementData(index);
}
LinkedList 的 get() 方法,就是在內部調用了上邊看到的 node() 方法,判斷在前半段還是在後半段,然後遍歷得到即可。
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
HashMap的底層實現
什麼時候會使用HashMap?他有什麼特點?
你知道HashMap的工作原理嗎?
你知道get和put的原理嗎?equals()和hashCode()的都有什麼作用?
你知道hash的實現嗎?爲什麼要這樣實現?
如果HashMap的大小超過了負載因子(load factor)定義的容量,怎麼辦?
HashMap 在 JDK 7 和 JDK8 中的實現方式略有不同。分開記錄。
深入 HahsMap 之前,先要了解的概念
-
initialCapacity:初始容量。指的是 HashMap 集合初始化的時候自身的容量。可以在構造方法中指定;如果不指定的話,總容量默認值是 16 。需要注意的是初始容量必須是 2 的冪次方。(1.7中,已知HashMap中將要存放的KV個數的時候,設置一個合理的初始化容量可以有效的提高性能)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
-
size:當前 HashMap 中已經存儲着的鍵值對數量,即
HashMap.size()
。 -
loadFactor:加載因子。所謂的加載因子就是 HashMap (當前的容量/總容量) 到達一定值的時候,HashMap 會實施擴容。加載因子也可以通過構造方法中指定,默認的值是 0.75 。舉個例子,假設有一個 HashMap 的初始容量爲 16 ,那麼擴容的閥值就是 0.75 * 16 = 12 。也就是說,在你打算存入第 13 個值的時候,HashMap 會先執行擴容。
-
threshold:擴容閥值。即 擴容閥值 = HashMap 總容量 * 加載因子。當前 HashMap 的容量大於或等於擴容閥值的時候就會去執行擴容。擴容的容量爲當前 HashMap 總容量的兩倍。比如,當前 HashMap 的總容量爲 16 ,那麼擴容之後爲 32 。
-
table:Entry 數組。我們都知道 HashMap 內部存儲 key/value 是通過 Entry 這個介質來實現的。而 table 就是 Entry 數組。
JDK1.7 實現
JDK1.7 中 HashMap 由 數組+鏈表 組成(“鏈表散列” 即數組和鏈表的結合體),數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的(HashMap 採用 “拉鍊法也就是鏈地址法” 解決衝突),如果定位到的數組位置不含鏈表(當前 entry 的 next 指向 null ),那麼對於查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對於添加操作,其時間複雜度依然爲 O(1),因爲最新的 Entry 會插入鏈表頭部,即需要簡單改變引用鏈即可,而對於查找操作來講,此時就需要遍歷鏈表,然後通過 key 對象的 equals 方法逐一比對查找。
所謂 “拉鍊法” 就是將鏈表和數組相結合。也就是說創建一個鏈表數組,數組中每一格就是一個鏈表。若遇到哈希衝突,則將衝突的值加到鏈表中即可。
源碼解析
構造方法
《阿里巴巴 Java 開發手冊》推薦集合初始化時,指定集合初始值大小。(說明:HashMap 使用HashMap(int initialCapacity) 初始化)建議原因: https://www.zhihu.com/question/314006228/answer/611170521
// 默認的構造方法使用的都是默認的初始容量和加載因子
// DEFAULT_INITIAL_CAPACITY = 16,DEFAULT_LOAD_FACTOR = 0.75f
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
// 可以指定初始容量,並且使用默認的加載因子
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
// 對初始容量的值判斷
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 設置加載因子
this.loadFactor = loadFactor;
threshold = initialCapacity;
// 空方法
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);
inflateTable(threshold);
putAllForCreate(m);
}
HashMap 的前 3 個構造方法最後都會去調用 HashMap(int initialCapacity, float loadFactor)
。在其內部去設置初始容量和加載因子。而最後的 init()
是空方法,主要給子類實現,比如LinkedHashMap。
put() 方法
public V put(K key, V value) {
// 如果 table 數組爲空時先創建數組,並且設置擴容閥值
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果 key 爲空時,調用 putForNullKey 方法特殊處理
if (key == null)
return putForNullKey(value);
// 計算 key 的哈希值
int hash = hash(key);
// 根據計算出來的哈希值和當前數組的長度計算在數組中的索引
int i = indexFor(hash, table.length);
// 先遍歷該數組索引下的整條鏈表
// 如果該 key 之前已經在 HashMap 中存儲了的話,直接替換對應的 value 值即可
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//先判斷hash值是否一樣,如果一樣,再判斷key是否一樣,不同對象的hash值可能一樣
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 之前沒有被存儲過,那麼就進入 addEntry 方法
addEntry(hash, key, value, i);
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);
}
//擴容,創建了一個新的數組,然後把數據全部複製過去,再把新數組的引用賦給 table
void resize(int newCapacity) {
Entry[] oldTable = table; //引用擴容前的Entry數組
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的數組大小如果已經達到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了
return;
}
// 創建新的 entry 數組
Entry[] newTable = new Entry[newCapacity];
// 將舊 entry 數組中的數據複製到新 entry 數組中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 將新數組的引用賦給 table
table = newTable;
// 計算新的擴容閥值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了舊的Entry數組
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
Entry<K,V> e = src[j]; //取得舊Entry數組的每個元素
if (e != null) {
src[j] = null;//釋放舊Entry數組的對象引用(for循環後,舊的Entry數組不再引用任何對象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置
e.next = newTable[i]; //標記[1]
newTable[i] = e; //將元素放在數組上
e = next; //訪問下一個Entry鏈上的元素
} while (e != null);
}
}
}
void createEntry(int hash, K key, V value, int bucketIndex) {
// 取出table中下標爲bucketIndex的Entry
Entry<K,V> e = table[bucketIndex];
// 利用key、value來構建新的Entry
// 並且之前存放在table[bucketIndex]處的Entry作爲新Entry的next
// 把新創建的Entry放到table[bucketIndex]位置
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 當前 HashMap 的容量加 1
size++;
}
最後的 createEntry() 方法就說明了當hash衝突時,採用的拉鍊法來解決hash衝突的,並且是把新元素是插入到單邊表的表頭。
get() 方法
public V get(Object key) {
// 如果 key 是空的,就調用 getForNullKey 方法特殊處理
if (key == null)
return getForNullKey();
// 獲取 key 相對應的 entry
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
//找到對應 key 的數組索引,然後遍歷鏈表查找即可
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 計算 key 的哈希值
int hash = (key == null) ? 0 : hash(key);
// 得到數組的索引,然後遍歷鏈表,查看是否有相同 key 的 Entry
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 != null && key.equals(k))))
return e;
}
// 沒有的話,返回 null
return null;
}
JDK1.8 實現
JDK 1.7 中,如果哈希碰撞過多,拉鍊過長,極端情況下,所有值都落入了同一個桶內,這就退化成了一個鏈表。通過 key 值查找要遍歷鏈表,效率較低。 JDK1.8在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲8)時,將鏈表轉化爲紅黑樹,以減少搜索時間。
TreeMap、TreeSet以及 JDK1.8 之後的 HashMap 底層都用到了紅黑樹。紅黑樹就是爲了解決二叉查找樹的缺陷,因爲二叉查找樹在某些情況下會退化成一個線性結構。
源碼解析
構造方法
JDK8 構造方法改動不是很大
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
確定哈希桶數組索引位置(hash 函數的實現)
//方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 爲第一步 取hashCode值
// h ^ (h >>> 16) 爲第二步 高位參與運算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) { //jdk1.7的源碼,jdk1.8沒有提取這個方法,而是放在了其他方法中,比如 put 的p = tab[i = (n - 1) & hash]
return h & (length-1); //第三步 取模運算
}
HashMap定位數組索引位置,直接決定了hash方法的離散性能。Hash算法本質上就是三步:取key的hashCode值、高位運算、取模運算。
爲什麼要這樣呢?
HashMap 的長度爲什麼是2的冪次方?
目的當然是爲了減少哈希碰撞,使 table 裏的數據分佈的更均勻。
-
HashMap 中桶數組的大小 length 總是2的冪,此時,
h & (table.length -1)
等價於對 length 取模h%length
。但取模的計算效率沒有位運算高,所以這是是一個優化。假設h = 185
,table.length-1 = 15(0x1111)
,其實散列真正生效的只是低 4bit 的有效位,所以很容易碰撞。 -
圖中的 hash 是由鍵的 hashCode 產生。計算餘數時,由於 n 比較小,hash 只有低4位參與了計算,高位的計算可以認爲是無效的。這樣導致了計算結果只與低位信息有關,高位數據沒發揮作用。爲了處理這個缺陷,我們可以上圖中的 hash 高4位數據與低4位數據進行異或運算,即
hash ^ (hash >>> 4)
。通過這種方式,讓高位數據與低位數據進行異或,以此加大低位信息的隨機性,變相的讓高位數據參與到計算中。此時的計算過程如下:在 Java 中,hashCode 方法產生的 hash 是 int 類型,32 位寬。前16位爲高位,後16位爲低位,所以要右移16位,即
hash ^ (hash >>> 16)
。這樣還增加了hash 的複雜度,進而影響 hash 的分佈性。
HashMap 的長度爲什麼是2的冪次方?
爲了能讓HashMap存取高效,儘量減少碰撞,也就是要儘量把數據分配均勻,Hash值的範圍是-2147483648到2147483647,前後加起來有40億的映射空間,只要哈希函數映射的比較均勻鬆散,一般應用是很難出現碰撞的,但一個問題是40億的數組內存是放不下的。所以這個散列值是不能直接拿來用的。用之前需要先對數組長度取模運算,得到餘數才能用來存放位置也就是對應的數組小標。這個數組下標的計算方法是(n-1)&hash,n代表數組長度
這個算法應該如何設計呢?
我們首先可能會想到採用%取餘的操作來實現。但是,重點來了。
取餘操作中如果除數是2的冪次則等價於其除數減一的與操作,也就是說hash%length=hash&(length-1),但前提是length是2的n次方,並且採用&運算比%運算效率高,這也就解釋了HashMap的長度爲什麼是2的冪次方。
put() 方法
public V put(K key, V value) {
// 對key的hashCode()做hash
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// tab爲空則創建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 計算index,並對null做處理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 節點key存在,直接覆蓋value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判斷該鏈爲紅黑樹
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//該鏈爲鏈表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//鏈表長度大於8轉換爲紅黑樹進行處理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//key已經存在直接覆蓋value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 超過最大容量 就擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize() 擴容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超過最大值就不再擴充了,就只好隨你碰撞了
if (oldCap >= MAXIMUM_CAPACITY) {
//修改閾值爲int的最大值(2^31-1),這樣以後就不會擴容了
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 沒超過最大值,就擴充爲原來的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 計算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 把每個bucket都移動到新的buckets中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order 鏈表優化重hash的代碼塊
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 原索引
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket裏
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket裏
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
get() 方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//定位鍵值對所在桶的位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 直接命中
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 未命中
if ((e = first.next) != null) {
// 如果 first 是 TreeNode 類型,則調用黑紅樹查找方法
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 在鏈表中查找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
Hashtable
Hashtable 和 HashMap 都是散列表,也是用”拉鍊法“實現的哈希表。保存數據和 JDK7 中的 HashMap 一樣,是 Entity 對象,只是 Hashtable 中的幾乎所有的 public 方法都是 synchronized 的,而有些方法也是在內部通過 synchronized 代碼塊來實現,效率肯定會降低。且 put() 方法不允許空值。
HashMap 和 Hashtable 的區別
-
線程是否安全: HashMap 是非線程安全的,HashTable 是線程安全的;HashTable 內部的方法基本都經過
synchronized
修飾。(如果你要保證線程安全的話就使用 ConcurrentHashMap 吧!); -
效率: 因爲線程安全的問題,HashMap 要比 HashTable 效率高一點。另外,HashTable 基本被淘汰,不要在代碼中使用它;
-
對Null key 和Null value的支持: HashMap 中,null 可以作爲鍵,這樣的鍵只有一個,可以有一個或多個鍵所對應的值爲 null。。但是在 HashTable 中 put 進的鍵值只要有一個 null,直接拋出 NullPointerException。
-
初始容量大小和每次擴充容量大小的不同 :
① 創建時如果不指定容量初始值,Hashtable 默認的初始大小爲11,之後每次擴充,容量變爲原來的2n+1。HashMap 默認的初始化大小爲16。之後每次擴充,容量變爲原來的2倍。
② 創建時如果給定了容量初始值,那麼 Hashtable 會直接使用你給定的大小,而 HashMap 會將其擴充爲2的冪次方大小。也就是說 HashMap 總是使用2的冪次方作爲哈希表的大小,後面會介紹到爲什麼是2的冪次方。
-
底層數據結構: JDK1.8 以後的 HashMap 在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲8)時,將鏈表轉化爲紅黑樹,以減少搜索時間。Hashtable 沒有這樣的機制。
-
HashMap的迭代器(
Iterator
)是fail-fast迭代器,但是 Hashtable的迭代器(enumerator
)不是 fail-fast的。如果有其它線程對HashMap進行的添加/刪除元素,將會拋出ConcurrentModificationException
,但迭代器本身的remove
方法移除元素則不會拋出異常。這條同樣也是 Enumeration 和 Iterator 的區別。
ConcurrentHashMap
HashMap在多線程情況下,在put的時候,插入的元素超過了容量(由負載因子決定)的範圍就會觸發擴容操作,就是rehash,這個會重新將原數組的內容重新hash到新的擴容數組中,在多線程的環境下,存在同時其他的元素也在進行put操作,如果hash值相同,可能出現同時在同一數組下用鏈表表示,造成閉環,導致在get時會出現死循環,所以HashMap是線程不安全的。
Hashtable,是線程安全的,它在所有涉及到多線程操作的都加上了synchronized關鍵字來鎖住整個table,這就意味着所有的線程都在競爭一把鎖,在多線程的環境下,它是安全的,但是無疑是效率低下的。
JDK1.7 實現
Hashtable 容器在競爭激烈的併發環境下表現出效率低下的原因,是因爲所有訪問 Hashtable 的線程都必須競爭同一把鎖,那假如容器裏有多把鎖,每一把鎖用於鎖容器其中一部分數據,那麼當多線程訪問容器裏不同數據段的數據時,線程間就不會存在鎖競爭,,這就是ConcurrentHashMap所使用的鎖分段技術。
在 JDK1.7版本中,ConcurrentHashMap 的數據結構是由一個 Segment 數組和多個 HashEntry 組成。Segment 數組的意義就是將一個大的 table 分割成多個小的 table 來進行加鎖。每一個 Segment 元素存儲的是 HashEntry數組+鏈表,這個和 HashMap 的數據存儲結構一樣。
ConcurrentHashMap 類中包含兩個靜態內部類 HashEntry 和 Segment。
HashEntry 用來封裝映射表的鍵值對,Segment 用來充當鎖的角色,每個 Segment 對象守護整個散列映射表的若干個桶。每個桶是由若干個 HashEntry 對象鏈接起來的鏈表。一個 ConcurrentHashMap 實例中包含由若干個 Segment 對象組成的數組。每個 Segment 守護着一個 HashEntry 數組裏的元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得它對應的 Segment 鎖。
Segment 類
Segment 類繼承於 ReentrantLock 類,從而使得 Segment 對象能充當可重入鎖的角色。一個 Segment 就是一個子哈希表,Segment 裏維護了一個 HashEntry 數組,併發環境下,對於不同 Segment 的數據進行操作是不用考慮鎖競爭的。
從源碼可以看到,Segment 內部類和我們上邊看到的 HashMap 很相似。也有負載因子,閾值等各種屬性。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount; //記錄修改次數
transient int threshold;
final float loadFactor;
Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
this.loadFactor = lf;
this.threshold = threshold;
this.table = tab;
}
//put 方法會有加鎖操作,
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
// ...
}
@SuppressWarnings("unchecked")
private void rehash(HashEntry<K,V> node) {
// ...
}
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
//...
}
private void scanAndLock(Object key, int hash) {
//...
}
final V remove(Object key, int hash, Object value) {
//...
}
final boolean replace(K key, int hash, V oldValue, V newValue) {
//...
}
final V replace(K key, int hash, V value) {
//...
}
final void clear() {
//...
}
}
HashEntry 類
HashEntry 是目前我們最小的邏輯處理單元。一個ConcurrentHashMap 維護一個 Segment 數組,一個Segment維護一個 HashEntry 數組。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value; // value 爲 volatie 類型,保證可見
volatile HashEntry<K,V> next;
//...
}
ConcurrentHashMap 類
默認的情況下,每個ConcurrentHashMap 類會創建16個併發的 segment,每個 segment 裏面包含多個 Hash表,每個 Hash 鏈都是由 HashEntry 節點組成的。
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V>
implements ConcurrentMap<K, V>, Serializable {
//默認初始容量爲 16,即初始默認爲 16 個桶
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默認併發級別爲 16。該值表示當前更新線程的估計數
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative
static final int RETRIES_BEFORE_LOCK = 2;
final int segmentMask; //段掩碼,主要爲了定位Segment
final int segmentShift;
final Segment<K,V>[] segments; //主幹就是這個分段鎖數組
//構造器
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//MAX_SEGMENTS 爲1<<16=65536,也就是最大併發數爲65536
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// 2的sshif次方等於ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
int sshift = 0;
// ssize 爲segments數組長度,根據concurrentLevel計算得出
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 創建segments數組並初始化第一個Segment,其餘的Segment延遲初始化
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
}
put() 方法
- **定位segment並確保定位的Segment已初始化 **
- 調用 Segment的 put 方法。
public V put(K key, V value) {
Segment<K,V> s;
//concurrentHashMap不允許key/value爲空
if (value == null)
throw new NullPointerException();
//hash函數對key的hashCode重新散列,避免差勁的不合理的hashcode,保證散列均勻
int hash = hash(key);
//返回的hash值無符號右移segmentShift位與段掩碼進行位運算,定位segment
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
get() 方法
get方法無需加鎖,由於其中涉及到的共享變量都使用volatile修飾,volatile可以保證內存可見性,所以不會讀取到過期數據
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
JDK1.8 實現
ConcurrentHashMap 在 JDK8 中進行了巨大改動,光是代碼量就從1000多行增加到6000行!1.8摒棄了Segment
(鎖段)的概念,採用了 CAS + synchronized
來保證併發的安全性。
可以看到,和HashMap 1.8的數據結構很像。底層數據結構改變爲採用數組+鏈表+紅黑樹的數據形式。
和HashMap1.8相同的一些地方
- 底層數據結構一致
- HashMap初始化是在第一次put元素的時候進行的,而不是init
- HashMap的底層數組長度總是爲2的整次冪
- 默認樹化的閾值爲 8,而鏈表化的閾值爲 6
- hash算法也很類似,但多了一步
& HASH_BITS
,該步是爲了消除最高位上的負符號,hash的負在ConcurrentHashMap中有特殊意義表示在擴容或者是樹節點
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
一些關鍵屬性
private static final int MAXIMUM_CAPACITY = 1 << 30; //數組最大大小 同HashMap
private static final int DEFAULT_CAPACITY = 16;//數組默認大小
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; //數組可能最大值,需要與toArray()相關方法關聯
private static final int DEFAULT_CONCURRENCY_LEVEL = 16; //兼容舊版保留的值,默認線程併發度,類似信號量
private static final float LOAD_FACTOR = 0.75f;//默認map擴容比例,實際用(n << 1) - (n >>> 1)代替了更高效
static final int TREEIFY_THRESHOLD = 8; // 鏈表轉樹閥值,大於8時
static final int UNTREEIFY_THRESHOLD = 6; //樹轉鏈表閥值,小於等於6(tranfer時,lc、hc=0兩個計數器分別++記錄原bin、新binTreeNode數量,<=UNTREEIFY_THRESHOLD 則untreeify(lo))。【僅在擴容tranfer時纔可能樹轉鏈表】
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;//擴容轉移時的最小數組分組大小
private static int RESIZE_STAMP_BITS = 16;//本類中沒提供修改的方法 用來根據n生成位置一個類似時間戳的功能
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; // 2^15-1,help resize的最大線程數
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS; // 32-16=16,sizeCtl中記錄size大小的偏移量
static final int MOVED = -1; // hash for forwarding nodes(forwarding nodes的hash值)、標示位
static final int TREEBIN = -2; // hash for roots of trees(樹根節點的hash值)
static final int RESERVED = -3; // ReservationNode的hash值
static final int HASH_BITS = 0x7fffffff; // 用在計算hash時進行安位與計算消除負hash
static final int NCPU = Runtime.getRuntime().availableProcessors(); // 可用處理器數量
/* ---------------- Fields -------------- */
transient volatile Node<K,V>[] table; //裝載Node的數組,作爲ConcurrentHashMap的數據容器,採用懶加載的方式,直到第一次插入數據的時候纔會進行初始化操作,數組的大小總是爲2的冪次方。
private transient volatile Node<K,V>[] nextTable; //擴容時使用,平時爲null,只有在擴容的時候才爲非null
/**
* 實際上保存的是hashmap中的元素個數 利用CAS鎖進行更新但它並不用返回當前hashmap的元素個數
*/
private transient volatile long baseCount;
/**
*該屬性用來控制table數組的大小,根據是否初始化和是否正在擴容有幾種情況:
*當值爲負數時:如果爲-1表示正在初始化,如果爲-N則表示當前正有N-1個線程進行擴容操作;
*當值爲正數時:如果當前數組爲null的話表示table在初始化過程中,sizeCtl表示爲需要新建數組的長度;若已經初始化了,表示當前數據容器(table數組)可用容量也可以理解成臨界值(插入節點數超過了該臨界值就需要擴容),具體指爲數組的長度n 乘以 加載因子loadFactor;當值爲0時,即數組長度爲默認初始值。
*/
private transient volatile int sizeCtl;
put() 方法
- 首先會判斷 key、value是否爲空,如果爲空就拋異常!
spread()
方法獲取hash,減小hash衝突- 判斷是否初始化table數組,沒有的話調用
initTable()
方法進行初始化 - 判斷是否能直接將新值插入到table數組中
- 判斷當前是否在擴容,
MOVED
爲-1說明當前ConcurrentHashMap正在進行擴容操作,正在擴容的話就進行協助擴容 - 當table[i]爲鏈表的頭結點,在鏈表中插入新值,通過synchronized (f)的方式進行加鎖以實現線程安全性。
- 在鏈表中如果找到了與待插入的鍵值對的key相同的節點,就直接覆蓋
- 如果沒有找到的話,就直接將待插入的鍵值對追加到鏈表的末尾
- 當table[i]爲紅黑樹的根節點,在紅黑樹中插入新值/覆蓋舊值
- 根據當前節點個數進行調整,否需要轉換成紅黑樹(個數大於等於8,就會調用
treeifyBin
方法將tabel[i]第i個散列桶
拉鍊轉換成紅黑樹) - 對當前容量大小進行檢查,如果超過了臨界值(實際大小*加載因子)就進行擴容
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 value 均不允許爲 null
if (key == null || value == null) throw new NullPointerException();
// 根據 key 計算出 hash 值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 判斷是否需要進行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// f 即爲當前 key 定位出的 Node,如果爲空表示當前位置可以寫入數據,利用 CAS 嘗試寫入,失敗則自旋保證成功
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果當前位置的 hashcode == MOVED == -1,則需要進行擴容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 如果都不滿足,則利用 synchronized 鎖寫入數據
else {
// 剩下情況又分兩種,插入鏈表、插入紅黑樹
V oldVal = null;
//採用同步內置鎖實現併發控制
synchronized (f) {
if (tabAt(tab, i) == f) {
// 如果 fh=f.hash >=0,當前爲鏈表,在鏈表中插入新的鍵值對
if (fh >= 0) {
binCount = 1;
//遍歷鏈表,如果找到對應的 node 節點,修改 value,否則直接在鏈表尾部加入節點
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 當前爲紅黑樹,將新的鍵值對插入到紅黑樹中
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 插入完鍵值對後再根據實際大小看是否需要轉換成紅黑樹
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 對當前容量大小進行檢查,如果超過了臨界值(實際大小*加載因子)就需要擴容
addCount(1L, binCount);
return null;
}
我們可以發現JDK8中的實現也是鎖分離的思想,只是鎖住的是一個Node,而不是JDK7中的Segment,而鎖住Node之前的操作是無鎖的並且也是線程安全的,建立在之前提到的原子操作上。
get() 方法
get方法無需加鎖,由於其中涉及到的共享變量都使用volatile修飾,volatile可以保證內存可見性,所以不會讀取到過期數據
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
// 判斷數組是否爲空
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 判斷node 節點第一個元素是不是要找的,如果是直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// // hash小於0,說明是特殊節點(TreeBin或ForwardingNode)調用find
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 不是上面的情況,那就是鏈表了,遍歷鏈表
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
Hashtable 和 ConcurrentHashMap 的區別
ConcurrentHashMap 和 Hashtable 的區別主要體現在實現線程安全的方式上不同。
- 底層數據結構: JDK1.7的 ConcurrentHashMap 底層採用 分段的數組+鏈表 實現,JDK1.8 採用的數據結構和HashMap1.8的結構類似,數組+鏈表/紅黑二叉樹。Hashtable 和 JDK1.8 之前的 HashMap 的底層數據結構類似都是採用 數組+鏈表 的形式,數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的;
- 實現線程安全的方式(重要):
- 在JDK1.7的時候,ConcurrentHashMap(分段鎖) 對整個桶數組進行了分割分段(Segment),每一把鎖只鎖容器其中一部分數據,多線程訪問容器裏不同數據段的數據,就不會存在鎖競爭,提高併發訪問率。(默認分配16個Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的時候已經摒棄了Segment的概念,而是直接用 Node 數組+鏈表/紅黑樹的數據結構來實現,併發控制使用 synchronized 和 CAS 來操作。(JDK1.6以後 對 synchronized鎖做了很多優化) 整個看起來就像是優化過且線程安全的 HashMap,雖然在 JDK1.8 中還能看到 Segment 的數據結構,但是已經簡化了屬性,只是爲了兼容舊版本;
- Hashtable(同一把鎖) :使用 synchronized 來保證線程安全,效率非常低下。當一個線程訪問同步方法時,其他線程也訪問同步方法,可能會進入阻塞或輪詢狀態,如使用 put 添加元素,另一個線程不能使用 put 添加元素,也不能使用 get,競爭越激烈效率越低。
Java快速失敗(fail-fast)和安全失敗(fail-safe)區別
快速失敗(fail—fast)
在用迭代器遍歷一個集合對象時,如果遍歷過程中對集合對象的內容進行了修改(增加、刪除、修改),則會拋出ConcurrentModificationException。
原理:迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變量。集合在被遍歷期間如果內容發生變化,就會改變 modCount 的值。每當迭代器使用 hashNext()/next() 遍歷下一個元素之前,都會檢測 modCount 變量是否爲 expectedmodCount 值,是的話就返回遍歷;否則拋出異常,終止遍歷。
注意:這裏異常的拋出條件是檢測到 modCount!=expectedmodCount 這個條件。如果集合發生變化時修改modCount 值剛好又設置爲了 expectedmodCount 值,則異常不會拋出。因此,不能依賴於這個異常是否拋出而進行併發操作的編程,這個異常只建議用於檢測併發修改的bug。
場景:java.util包下的集合類都是快速失敗的,不能在多線程下發生併發修改(迭代過程中被修改)。
安全失敗(fail—safe)
採用安全失敗機制的集合容器,在遍歷時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷。
原理:由於迭代時是對原集合的拷貝進行遍歷,所以在遍歷過程中對原集合所作的修改並不能被迭代器檢測到,所以不會觸發 Concurrent Modification Exception。
缺點:基於拷貝內容的優點是避免了Concurrent Modification Exception,但同樣地,迭代器並不能訪問到修改後的內容,即:迭代器遍歷的是開始遍歷那一刻拿到的集合拷貝,在遍歷期間原集合發生的修改迭代器是不知道的。
場景:java.util.concurrent包下的容器都是安全失敗,可以在多線程下併發使用,併發修改。
快速失敗和安全失敗是對迭代器而言的。 快速失敗:當在迭代一個集合的時候,如果有另外一個線程在修改這個集合,就會拋出ConcurrentModification異常,java.util下都是快速失敗。 安全失敗:在迭代時候會在集合二層做一個拷貝,所以在修改集合上層元素不會影響下層。在java.util.concurrent下都是安全失敗
如何避免fail-fast ?
- 在單線程的遍歷過程中,如果要進行remove操作,可以調用迭代器 ListIterator 的 remove 方法而不是集合類的 remove方法。看看 ArrayList中迭代器的 remove方法的源碼,該方法不能指定元素刪除,只能remove當前遍歷元素。
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
SubList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = ArrayList.this.modCount; //
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
- 使用併發包(
java.util.concurrent
)中的類來代替 ArrayList 和 hashMap- CopyOnWriterArrayList 代替 ArrayList
- ConcurrentHashMap 代替 HashMap
Iterator 和 Enumeration 區別
在Java集合中,我們通常都通過 “Iterator(迭代器)” 或 “Enumeration(枚舉類)” 去遍歷集合。
public interface Enumeration<E> {
boolean hasMoreElements();
E nextElement();
}
public interface Iterator<E> {
boolean hasNext();
E next();
void remove();
}
- 函數接口不同,Enumeration**只有2個函數接口。通過Enumeration,我們只能讀取集合的數據,而不能對數據進行修改。Iterator只有3個函數接口。**Iterator除了能讀取集合的數據之外,也能數據進行刪除操作。
- Iterator支持 fail-fast機制,而Enumeration不支持。Enumeration 是JDK 1.0添加的接口。使用到它的函數包括Vector、Hashtable等類,這些類都是JDK 1.0中加入的,Enumeration存在的目的就是爲它們提供遍歷接口。Enumeration本身並沒有支持同步,而在Vector、Hashtable實現Enumeration時,添加了同步。
而Iterator 是JDK 1.2才添加的接口,它也是爲了HashMap、ArrayList等集合提供遍歷接口。Iterator是支持fail-fast機制的:當多個線程對同一個集合的內容進行操作時,就可能會產生fail-fast事件
Comparable 和 Comparator接口有何區別?
Java中對集合對象或者數組對象排序,有兩種實現方式:
-
對象實現Comparable 接口
-
Comparable 在 java.lang 包下,是一個接口,內部只有一個方法 compareTo()
public interface Comparable<T> { public int compareTo(T o); }
-
Comparable 可以讓實現它的類的對象進行比較,具體的比較規則是按照 compareTo 方法中的規則進行。這種順序稱爲 自然順序。
-
實現了 Comparable 接口的 List 或則數組可以使用
Collections.sort()
或者Arrays.sort()
方法進行排序
-
-
定義比較器,實現 Comparator接口
-
Comparator 在 java.util 包下,也是一個接口,JDK 1.8 以前只有兩個方法:
public interface Comparator<T> { public int compare(T lhs, T rhs); public boolean equals(Object object); }
-
comparable相當於內部比較器。comparator相當於外部比較器
區別:
-
Comparator 位於
java.util
包下,而 Comparable 位於java.lang
包下 -
Comparable 接口的實現是在類的內部(如 String、Integer已經實現了 Comparable 接口,自己就可以完成比較大小操作),Comparator 接口的實現是在類的外部(可以理解爲一個是自已完成比較,一個是外部程序實現比較)
-
實現 Comparable 接口要重寫 compareTo 方法, 在 compareTo 方法裏面實現比較。一個已經實現Comparable 的類的對象或數據,可以通過 **Collections.sort(list) 或者 Arrays.sort(arr)**實現排序。通過 Collections.sort(list,Collections.reverseOrder()) 對list進行倒序排列。
-
實現Comparator需要重寫 compare 方法
HashSet
HashSet是用來存儲沒有重複元素的集合類,並且它是無序的。HashSet 內部實現是基於 HashMap ,實現了 Set 接口。
從 HahSet 提供的構造器可以看出,除了最後一個 HashSet 的構造方法外,其他所有內部就是去創建一個 Hashap 。沒有其他的操作。而最後一個構造方法不是 public 的,所以不對外公開。
HashSet如何檢查重複
HashSet的底層其實就是HashMap,只不過我們HashSet是實現了Set接口並且把數據作爲K值,而V值一直使用一個相同的虛值來保存,HashMap的K值本身就不允許重複,並且在HashMap中如果K/V相同時,會用新的V覆蓋掉舊的V,然後返回舊的V。
Iterater 和 ListIterator 之間有什麼區別?
- 我們可以使用Iterator來遍歷Set和List集合,而ListIterator只能遍歷List
- ListIterator有add方法,可以向List中添加對象,而Iterator不能
- ListIterator和Iterator都有hasNext()和next()方法,可以實現順序向後遍歷,但是ListIterator有hasPrevious()和previous()方法,可以實現逆向(順序向前)遍歷。Iterator不可以
- ListIterator可以定位當前索引的位置,nextIndex()和previousIndex()可以實現。Iterator沒有此功能
- 都可實現刪除操作,但是 ListIterator可以實現對象的修改,set()方法可以實現。Iterator僅能遍歷,不能修改
參考與感謝
所有內容都是基於源碼閱讀和各種大佬之前總結的知識整理而來,輸入並輸出,奧利給。
https://www.javatpoint.com/java-arraylist
https://www.runoob.com/java/java-collections.html
https://www.javazhiyin.com/21717.html
https://yuqirong.me/2018/01/31/LinkedList內部原理解析/
https://youzhixueyuan.com/the-underlying-structure-and-principle-of-hashmap.html
《HashMap源碼詳細分析》http://www.tianxiaobo.com/2018/01/18/HashMap-源碼詳細分析-JDK1-8/
《ConcurrentHashMap1.7源碼分析》https://www.cnblogs.com/chengxiao/p/6842045.html
http://www.justdojava.com/2019/12/18/java-collection-15.1/