前言
Hello,大家好!今天的分享的是面試中常問到的集合內容,其中HashMap是集合中的重點,如果是對HashMap感興趣可以直接到跳到這【我是HashMap】,希望這篇內容能給你帶來收穫😀
目錄
先上個java集合體系圖(簡略版)
其實,java中的集合分爲
存儲單列數據
的集合和存儲鍵和值
這樣的雙列數據的集合
存儲單列數據的集合:
其中就有list和set,
list有序允許重複
,set無序不允許重複
首先說一下list的家族
ArrayList
、LinkedList
、Vector
就是list的三大實現類
ArrayList
基於
數組、有序的、允許重複
的list集合,但是線程不同步
LinkedList
基於
雙向鏈表,有序,允許重複
的list集合,但是線程不同步
Vector
基於
數組、有序的、允許重複
的list集合,並且線程同步
三者的區別
- ArrayList
和
Vector底層用的
數組,
LinkedList使用的是
鏈表`,- 數組的優點是
查詢指定的比較快
,因爲有索引,而增刪改比較慢
,因爲數組在內存中是一塊連續的數據集合,每次刪除和添加都會影響索引,所以性能上弱點兒- 鏈表是當前元素中存放下一個或者上一個的元素的地址,但是如果你查詢時候需要從開頭
一個一個的找,效率比較低
,而插入時不需要移動內存,只需要改變引用指向即可,所以鏈表增刪改效率高
- Vector是線程同步的,其他兩個線程不同步
- ArrayList每次擴容是其大小的
1.5倍
,而Vector是其大小的2倍
再來說一下set的家族
HashSet
、TreeSet
、LinkedHashSet
是set集合的主要三個實現類
- 但要知道一點的就是如果你把map集合搞懂了,set也就迎刃而解了,因爲這三個其實底層都
用到了map集合的實現類
- HashSet對應着
HashMap
、TreeSet對應着TreeMap
、LinkedHashSet對應LinkedHashMap
HashSet
- 基於HashMap的Set集合,
無序
並不允許重複
且線程是非同步
的,底層是哈希表
(數組+單鏈表)結構。- 保證元素唯一性的原理:
判斷元素的hashcode值是否相同。如果相同,還會繼續判斷元素的equals方法,是否爲true
。- HashSet的add()就是往一個HashMap裏面put(),只是key一直不同,而value是一直相同的就是上面的那個僞值
PRESENT
,允許有null值
。- HashSet的值是存儲在一個HashMap的key裏面,而HashMap的key是不能重複的;詳細的請看下面的map集合
LinkedHashSet
- 基於
LinkedHashMap
的Set集合,不允許重複
底層是哈希表和雙鏈表
結構- 本身就
繼承HashSet
,所以它也根據元素hashCode值來決定元素存儲位置,但它同時使用雙鏈表維護元素的次序
,這樣使得元素看起來是以插入的順序保存的
TreeSet
- 基於
TreeMap
並且不重複
的set集合,底層就是二叉樹結構
(即紅黑樹結構,也是一種自平衡
的二叉樹),注意底層沒有hash算法- 可以對set集合中的元素
進行排序
,如果是引用數據類型一定要實現Comparable接口重寫compareTo方法,不然會拋出異常。- comareTo返回正數,負數,0,都代表着結果爲 正序,倒序,和只有根元素
- 不重複或者元素唯一性就是 compareTo 方法結果爲0,
這裏面就要說下兩種排序方式了:
1、讓元素自身具有比較性(自然排序): 元素實現Comparable接口,重寫compareTo方法
2、讓容器自身具備比較性(比較器): 定義類去實現Comparator接口,重寫compare(Object,Object)方法,然後作爲TreeSet的構造參數
那他倆之間的區別是什麼嘞?
自然排序
因爲實現了Comparable接口,重寫方法後,這些元素就由內部的排序規則了,比入Integer,String等等比較器
則是這種自身的排序規則不是我想要的,我想要創建自己的排序規則,但是String是改不了的,所以就有了Comparator接口,來創建外部排序規則
存儲鍵和值的集合
Map 集合可謂是java中相當重要的一個角色了,他涵蓋了list和set,其中最熟悉的就是
HashMap
了,不過還有Hashtable,TreeMap,LinkedHashMap
,當然還有ConCurrentHashMap
這個角色,聽我11道來
TreeMap
TreeMap底層是
二叉樹(紅黑樹)線程不同步
。可以用於給map集合中的鍵進行排序
,上面的TreeSet
底層就是用的TreeMap
,只不過TreeMap是一個K-V集合
Hashtable
Hashtable底層是
哈希表數據結構 (數組+鏈表)
,無序的
、不可以
存入null鍵和null值,線程同步
ConCurrentHashMap
JDK1.7底層實現:
- 首先將數據分爲一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據時,其他段的數據也能被其他線程訪問。
- 在JDK1.7中,ConcurrentHashMap採用Segment + HashEntry的方式進行實現,結構如下:
- 一個 ConcurrentHashMap 裏包含一個 Segment 數組。Segment 的結構和HashMap類似,是一種數組和鏈表結構,一個 Segment 包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素,每個 Segment 守護着一個HashEntry數組裏的元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment的鎖。
- 該類包含兩個靜態內部類 HashEntry 和 Segment ;前者用來封裝映射表的鍵值對,後者用來充當鎖的角色;
- Segment 是一種可重入的鎖 ReentrantLock,每個 Segment 守護一個HashEntry 數組裏得元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment 鎖。
HashMap
- HashMap底層是
哈希表數據結構 (數組+鏈表)
,無序的
、可以
存入null鍵和null值,線程非同步
,jdk1.8 HashMap底層又加入了紅黑樹(why?)- 因爲HashMap爲面試中重點,這裏多介紹介紹,先附上HashMap的部分源碼圖
// 數組的默認初始長度,java規定hashMap的數組長度必須是2的次方
// 擴展長度時也是當前長度 << 1。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 數組的最大長度
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默認負載因子,當元素個數超過這個比例則會執行數組擴充操作。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 樹形化閾值,當鏈表節點個大於等於TREEIFY_THRESHOLD - 1時,
// 會將該鏈表換成紅黑樹。
static final int TREEIFY_THRESHOLD = 8;
// 解除樹形化閾值,當鏈表節點小於等於這個值時,會將紅黑樹轉換成普通的鏈表。
static final int UNTREEIFY_THRESHOLD = 6;
// 最小樹形化的容量,即:當內部數組長度小於64時,不會將鏈表轉化成紅黑樹,而是優先擴充數組。
static final int MIN_TREEIFY_CAPACITY = 64;
// 這個就是hashMap的內部數組了,而Node則是鏈表節點對象。
transient Node<K,V>[] table;
// 下面三個容器類成員,作用相同,實際類型爲HashMap的內部類KeySet、Values、EntrySet。
// 他們的作用並不是緩存所有的key或者所有的value,內部並沒有持有任何元素。
// 而是通過他們內部定義的方法,從三個角度(視圖)操作HashMap,更加方便的迭代。
// 關注點分別是鍵,值,映射。
transient Set<K> keySet; // AbstractMap的成員
transient Collection<V> values; // AbstractMap的成員
transient Set<Map.Entry<K,V>> entrySet;
// 元素個數,注意和內部數組長度區分開來。
transient int size;
// 再上一篇文章中說過,是容器結構的修改次數,fail-fast機制。
transient int modCount;
// 閾值,超過這個值時擴充數組。 threshold = capacity * loadfactor,具體看上面的靜態常量。
int threshold;
// 裝在因子,具體看上面的靜態常量。
final float loadFactor;
- 沒有閱讀過源碼的同學可能慌了,沒事~跟着博主走,什麼都會有
- 根據這個代碼圖,然後給大家溜下HashMap的運行流程和一些內部機制,可能有的地方不是很對,請大家諒解
- 其實上面就是HashMap1.8版本的 一些重要的成員變量
- 都知道HashMap底層是數組和鏈表,這個數組
Node<K,V>[] table
的初始容量爲16,而容量是以2的次方擴充的爲什麼呢?,一是爲了提高性能使用足夠大的數組
,二是爲了能使用位運算代替取模預算效率更高
。 - 數組是否需要擴充是通過負載因子判斷的,如果當前元素個數爲數組容量的0.75時,就會擴充數組。這個0.75就是默認的負載因子,可由構造傳入。我們也可以設置大於1的負載因子,這樣數組就不會擴充,犧牲性能,節省內存。
- 爲了解決碰撞,數組中的元素是單向鏈表類型。當鏈表長度到達一個閾值時(7或8),會將鏈表轉換成紅黑樹提高性能。而當鏈表長度縮小到另一個閾值時(6),又會將紅黑樹轉換回單向鏈表提高性能,這裏是一個平衡點。
- 對於第三點補充說明,檢查鏈表長度轉換成紅黑樹之前,還會先檢測當前數組數組是否到達一個閾值(64),如果沒有到達這個容量,會放棄轉換,先去擴充數組。所以上面也說了鏈表長度的閾值是7或8,因爲會有一次放棄轉換的操作。
HashMap的原理
:因爲是Hash表結構,Hash表的做法其實很簡單,就是把Key通過一 個固定的算法函數既所謂的哈希函數轉換成一個整型數字,然後就將該數字對數組長度進行取餘,取餘結果就當作數組的下標,將value存儲在以該數字爲下標 的數組空間裏,而當使用哈希表進行查詢的時候,就是再次使用哈希函數將key轉換爲對應的數組下標,並定位到該空間獲取value,如此一來,就可以充分利用到數組的定位性能進行數據定位
。- 然後根據
put()
和get()
這兩個重要的方法來具體分析一下(1.8)
put方法底層實現及分析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//判斷當前桶是否爲空
if ((tab = table) == null || (n = tab.length) == 0)
//空的就需要初始化(resize 中會判斷是否進行初始化)。
n = (tab = resize()).length;
//根據當前 key 的 hashcode 定位到具體的桶中並判斷是否爲空,
if ((p = tab[i = (n - 1) & hash]) == null)
//爲空表明沒有 Hash 衝突就直接在當前位置創建一個新桶即可。
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果當前桶有值( Hash 衝突),那麼就要比較當前桶中的 key、key 的 hashcode 與寫入的 key 是否相等,
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//相等就賦值給 e,然後統一返回。
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) {
//就需要將當前的 key、value 封裝成一個新節點寫入到當前桶的後面(形成鏈表)。
p.next = newNode(hash, key, value, null);
//接着判斷當前鏈表的大小是否大於預設的閾值,大於時就要轉換爲紅黑樹。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果在遍歷過程中找到 key 相同時直接退出遍歷。
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果 e != null 就相當於存在相同的 key,那就需要將值覆蓋。
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 中會判斷是否進行初始化)。
- 根據當前 key 的 hashcode 定位到具體的桶中並判斷是否爲空,爲空表明沒有 Hash 衝突就直接在當前位置創建一個新桶即可。
- 如果當前桶有值( Hash 衝突),那麼就要比較當前桶中的 key、key 的 hashcode 與寫入的 key 是否相等,相等就賦值給 e,在第 8 步的時候會統一進行賦值及返回。
- 如果當前桶爲紅黑樹,那就要按照紅黑樹的方式寫入數據。
- 如果是個鏈表,就需要將當前的 key、value 封裝成一個新節點寫入到當前桶的後面(形成鏈表)。
- 接着判斷當前鏈表的大小是否大於預設的閾值,大於時就要轉換爲紅黑樹。
- 如果在遍歷過程中找到 key 相同時直接退出遍歷。
- 如果 e != null 就相當於存在相同的 key,那就需要將值覆蓋。
- 最後判斷是否需要進行擴容。
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;
//首先將 key hash 之後取得所定位的桶。如果桶爲空則直接返回 null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否爲查詢的 key,是就直接返回 value。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果第一個不匹配,
if ((e = first.next) != null) {
//則判斷它的下一個是紅黑樹還是鏈表。
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;
}
總結一下:
- 首先將 key hash 之後取得所定位的桶。
- 如果桶爲空則直接返回 null 。
- 否則判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否爲查詢的 key,是就直接返回 value。
- 如果第一個不匹配,則判斷它的下一個是紅黑樹還是鏈表。
- 紅黑樹就按照樹的查找方式返回值。
- 不然就按照鏈表的方式遍歷匹配返回值。
接下來給大家說幾個面試中經常問到有關HashMap的問題
那麼HashMap的容量爲什麼要取2的n次冪
- 因爲如果是2的n次冪 那數據應該是這樣的
二進制 :10 / 100 / 1000 / 10000 / 100000 / 1000000 / …
十進制 :2 / 4 / 8 / 16 / 32 / 64 / …- 而當他們減1的時候二進制結果是這樣的
01 / 011 / 0111 / 01111 / 011111 / 0111111 / …- 而我門看到的HashMap源碼中計算或分配數據在數組中的位置用的是
hash & (n - 1)
,其實等價於hash % n
- &這個符號是位運算中的位與,只有當對應位置的數據都爲1時,運算結果也爲1,看如下:
// 例子:就用HashMap默認的初始容量 16 來說 hash & (16 - 1) ==》 hash % 16 16的二進制爲:10000 ; 10000 - 1 = 1111 那結過就成: hash & 1111 ; 這樣能快速得到 hash值二進制的後四位,這後四位就是餘數
- 總結下:計算機中直接求餘
a % b
效率不如位移運算a &(b - 1)
,但是這需要b是2的n次冪纔有效
,所以爲了存取高效
,要儘量較少碰撞
,HashMap就這樣做了
HashMap8爲什麼引入紅黑樹
- 看一下HashMap1.7版本的底層結構圖吧,
數組+單鏈表
;這其中其實就能看出來當Hash 衝突嚴重時
,這個鏈表會變的很長
的話,這樣在查詢時的效率就會越來越低;時間複雜度爲 O(N)
,所以1.8就引入了紅黑樹。
- 那有的就會問了,既然紅黑樹這麼好,爲什麼HashMap不直接採用紅黑樹,而用鏈表呢?
- 因爲紅黑樹需要進行左旋,右旋操作, 而單鏈表不需要,
以下都是單鏈表與紅黑樹結構對比。
如果元素小於8個,紅黑樹查詢成本高,新增成本低
如果元素大於8個,紅黑樹查詢成本低,新增成本高
hashcode 和 equals的區別
hashCode()方法和equal()方法的作用其實一樣,在Java裏都是用來對比兩個對象是否相等一致,那麼equal()既然已經能實現對比的功能了,爲什麼還要hashCode()呢?
因爲重寫的equal()裏一般比較的比較全面比較複雜,這樣效率就比較低,而利用hashCode()進行對比,則只要生成一個hash值進行比較就可以了,效率很高,那麼hashCode()既然效率這麼高爲什麼還要equal()呢?
- equal()相等的兩個對象他們的hashCode()肯定相等
- hashCode()相等的兩個對象他們的equal()不一定相等
所以對於需要大量並且快速的對比的話如果都用equal()去做顯然效率太低,解決方式是,
每當需要對比的時候,首先用hashCode()去對比,如果hashCode()不一樣,則表示這兩個對象肯定不相等(也就是不必再用equal()去再對比了),如果hashCode()相同,此時再對比他們的equal(),如果equal()也相同,則表示這兩個對象是真的相同了
,這樣既能大大提高了效率也保證了對比的絕對正確性!那麼再回過頭來看看HashMap源碼是不是更清晰了呢
爲什麼 HashMap 中 String、Integer 這樣的包裝類適合作爲 key 鍵
- String,Integer等這些類都被final修飾,具有不變性;也保證了key的不變性,並且內部重寫了equals和hashCode方法,不容易出現hash計算錯誤等。
- 所以String,Integer等包裝類的特性
保證了Hash值的不可變性和準確性
,有效減少了hash碰撞
- 所以作爲HashMap的key一定要重寫equals和hashCode方法
HashMap1.7中的死循環問題
HashMap1.7中的擴容機制實際上就是用一個新的2倍長度的數組來替代舊的數組,使用具體就是resize方法,裏面調用的是transfer方法,這個方法用來轉移數組數據,採用的就是頭插法,但是存在一個問題:
併發操作容易在一個桶上形成環形鏈表;這樣當獲取一個不存在的 key 時,計算出的 index 正好是環形鏈表的下標就會出現死循環。
結言:最後,感謝你能夠看到這裏,相信你也是拼搏道路上的追夢人,也歡迎評論區留言 ,喜歡博文可以給個👍呦,更多文章在這裏【jar殼蟲】,後續會有更多分享哦🎈🎈