jdk 1.7HashMap 底層實現是數組+鏈表(爲什麼用鏈表呢?詳情看問題五中)。
存儲結構
哈希表(hash table)也叫散列表,是一種非常重要的數據結構,應用場景及其豐富,許多緩存技術(比如memcached)的核心其實就是在內存中維護一張大的哈希表,而HashMap的實現原理就是基於此。
幾種數據結構之間的情況對比:
1、數組:採用一段連續的內存空間來存儲數據。對於指定下標的查找,時間複雜度爲O(1),對於給定元素的查找,需要遍歷整個數據,時間複雜度爲O(n)。但對於有序數組的查找,可用二分查找法,時間複雜度爲O(logn),對於一般的插入刪除操作,涉及到數組元素的移動,其平均時間複雜度爲O(n)。對應到集合實現,代表就是ArrayList。
2、二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均複雜度均爲O(logn)。對應的集合類有TreeSet和TreeMap。
3、線性鏈表:對於鏈表的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間複雜度爲O(1),而查找操作需要遍歷整個鏈表,複雜度爲O(n)。對應的集合類是LinkedList。
4、哈希表:也叫散列表,用的是數組支持元素下標隨機訪問的特性,將鍵值映射爲數組的下標進行元素的查找。所以哈希表就是數組的一種擴展,將鍵值映射爲元素下標的函數叫做哈希函數,哈希函數運算得到的結果叫做哈希值。哈希函數的設計至關重要,好的哈希函數會儘可能地保證計算簡單和散列地址分佈均勻。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱爲哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。這會涉及到哈希衝突。
哈希衝突(也叫哈希碰撞):不同的鍵值通過哈希函數運算得到相同的哈希值。哈希衝突的解決方案有多種:開放定址法(發生衝突,繼續尋找下一塊未被佔用的存儲地址)、再散列函數法、鏈地址法。ThreadLocalMap由於其元素個數較少,採用的是開放尋址法,而HashMap採用的是鏈表法來解決哈希衝突,即所有散列值相同的元素都放在相同槽對應的鏈表中(也就是數組+鏈表的方式)
HashMap是由數組+鏈表構成的,即存放鏈表的數組,數組是HashMap的主體,鏈表則是爲了解決哈希碰撞而存在的,如果定位到的數組不包含鏈表(當前的entry指向爲null),那麼對於查找,刪除等操作,時間複雜度僅爲O(1),如果定位到的數組包含鏈表,對於添加操作,其時間複雜度爲O(n),首先需要遍歷鏈表,存在相同的key則覆蓋value,否則新增;對於查找操作,也是一樣需要遍歷整個鏈表,然後通過key對象的equals方法逐一比對,時間複雜度也爲O(n)。所以,HashMap中鏈表出現的越少,長度越短,性能才越好,這也是HashMap設置閥值即擴容的原因。
一、前期
默認參數配置
/** 初始容量,2^4,默認16 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** 最大初始容量,2^30 */
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 臨界值(HashMap 實際能存儲的大小),公式爲(threshold = capacity * loadFactor) */
int threshold; //默認構造時默認爲DEFAULT_INITIAL_CAPACITY = 16
//負載因子,代表了table的填充度有多少,默認是0.75
final float loadFactor;
//用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException
transient int modCount;
//默認裝載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
構造方法
無參最終傳入默認的初始容量,加載因子
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);
// 設置負載因子,臨界值此時爲容量大小,後面第一次put時由inflateTable(int toSize)方法計算設置
this.loadFactor = loadFactor;
//初始閾值爲初始容量,與JDK1.8中不同,JDK1.8中調用了tableSizeFor(initialCapacity)得到大於等於初始容量的一個最小的2的指數級別數,比如初始容量爲12,那麼threshold爲16,;如果初始容量爲5,那麼初始容量爲8
threshold = initialCapacity;
init();//空實現
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
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);
}
計算hash值 - jdk 1.8有變動
final int hash(Object k) {
int h = hashSeed;//默認爲0
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);
}
initHashSeedAsNeeded()方法,
final boolean initHashSeedAsNeeded(int capacity) {
//當我們初始化的時候hashSeed爲0,0!=0 這時爲false.
boolean currentAltHashing = hashSeed != 0;
//isBooted()這個方法裏面返回了一個boolean值,我們看下面的代碼
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
默認返回false,但是測試時發現vm啓動後賦值爲true,所以在上面initHashSeedAsNeeded()方法中,主要看capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD,然後決定是否需要重新賦值hashSeed。否則默認爲0。
private static volatile boolean booted = false;
public static boolean isBooted() {
return booted;
}
關於變量Holder.ALTERNATIVE_HASHING_THRESHOLD,發現其值就是threshold。所以主要看該等式是否成立:capacity >= threshold
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;//這裏爲2147483647,它是HashMap的屬性,初始化的時候就已賦值
//Holder這個類是HashMap的子類,
private static class Holder {
//這裏定義了我們需要的常量,但是它沒賦值,我們看看它是怎麼賦值的?
static final int ALTERNATIVE_HASHING_THRESHOLD;
static {
String altThreshold = java.security.AccessController.doPrivileged(
new sun.security.action.GetPropertyAction(
"jdk.map.althashing.threshold"));
int threshold;
try {
threshold = (null != altThreshold)
? Integer.parseInt(altThreshold)
: ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;
// disable alternative hashing if -1
if (threshold == -1) {
threshold = Integer.MAX_VALUE;
}
if (threshold < 0) {
throw new IllegalArgumentException("value must be positive integer.");
}
} catch(IllegalArgumentException failed) {
throw new Error("Illegal value for 'jdk.map.althashing.threshold'", failed);
}
ALTERNATIVE_HASHING_THRESHOLD = threshold;
}
}
二、增/改
public V put(K key, V value) {
// 如果table引用指向成員變量EMPTY_TABLE,那麼初始化HashMap(設置容量、臨界值,新的Entry數組引用)
// 默認容量16,threshold = loadfactor
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 若“key爲null”,則將該鍵值對添加到table[0]處,遍歷該鏈表,如果有key爲null,則將value替換。沒有就創建新Entry對象放在鏈表表頭
// 所以table[0]的位置上,永遠最多存儲1個Entry對象,形成不了鏈表。key爲null的Entry存在這裏
if (key == null)
return putForNullKey(value);
// 若“key不爲null”,則計算該key的哈希值
int hash = hash(key);
// 搜索指定hash值在對應table中的索引
int i = indexFor(hash, table.length);
// 循環遍歷table數組上存在的Entry對象的鏈表,判斷該位置上hash是否已存在
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))) {
// 如果這個key對應的鍵值對已經存在,就用新的value代替老的value,並返回老的value!
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 修改次數+1
modCount++;
// table數組中沒有key對應的鍵值對,就將key-value添加到table[i]處
addEntry(hash, key, value, i);
return null;
}
懶加載,新增的時候初始化hash map
//初始化HashMap
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize - 找到一個 >= toSize的2^x次方數,即是2的次冪增長的
int capacity = roundUpToPowerOf2(toSize); // 實現了增長爲2的冪運算. 實現也比較簡單
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);//初始化hashSeed變量
}
找到一個 >= number的2^x次方數,即是2的次冪
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
// 方法Integer.highestOneBit((number - 1) << 1) 即2^x <= ((number-1)<<1);如number = 7, (6<<1) =6*2^1=12 ,需要(2^3 = 8) >= 6,返回2^3
//即該方法會返回一個大於等於(number-1)的2^x數,而Integer.highestOneBit(number) 返回的是小於等於number的2^x數。造成這個原因的是<<1將number的值變大
//爲什麼要number-1?如果number是2^x的值,那麼返回的是number。如number=8,Integer.highestOneBit(number << 1)返回16,不合需要做的含義
//,Integer.highestOneBit((number - 1) << 1)返回的是8
}
插入鍵爲null的值
private V putForNullKey(V value) {
//可以看到鍵爲null的值永遠被放在哈希表的第一個桶中,即永遠放在table[0]中,再次入值時,只會覆蓋原來的,不會形成鏈表
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//一旦找到鍵爲null,替換舊值
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//如果第一個桶中爲null或沒有節點的鍵爲null的,插入新節點
modCount++;
addEntry(0, null, value, 0);
return null;
}
新增一個Entry
void addEntry(int hash, K key, V value, int bucketIndex) {
//如果尺寸已將超過了閾值並且桶中索引處不爲null
if ((size >= threshold) && (null != table[bucketIndex])) {
//擴容2倍
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];
//將該節點作爲頭節點,此時如果hash衝突,則頭插法,next指向原來的頭
table[bucketIndex] = new Entry<>(hash, key, value, e);
//尺寸+1,這個值在獲取、刪除時都有用到
size++;
}
三、查
獲取key的的value值
public V get(Object key) {
//如果Key值爲空,則獲取對應的值,這裏也可以看到,HashMap允許null的key,其內部針對null的key有特殊的邏輯(詳細看插入時的操作)
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);//獲取實體
return null == entry ? null : entry.getValue();//判斷是否爲空,不爲空,則獲取對應的值
}
獲取key爲null的值
private V getForNullKey() {
//如果元素個數爲0,則直接返回null;說明沒有值插入或者已刪除完
if (size == 0) {
return null;
}
//key爲null的元素存儲在table的第0個位置;這個循環最多做一次操作,因爲插入時值存儲最新key爲null的value
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)//判斷是否爲null
return e.value;//返回其值
}
return null;
}
獲取鍵值爲key的元素
final Entry<K,V> getEntry(Object key) {
if (size == 0) {//元素個數爲0
return null;//直接返回null
}
int hash = (key == null) ? 0 : hash(key);//獲取key的Hash值
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))))//判斷Hash值和對應的key,合適則返回值
return e;
}
return null;
}
關於e.hash == hash在代碼中是否加入問題?詳情看
四、刪
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value); //判斷
}
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) { //map中無值,直接返回null
return null;
}
//計算hash值
int hash = (key == null) ? 0 : hash(key);
//得到桶索引
int i = indexFor(hash, table.length);
//記錄待刪除節點的前一個節點
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)))) {
modCount++;
size--; //數目減1
//在鏈表中的操作
if (prev == e) // 如果恰好是prev,則將下一結點作爲頭結點
table[i] = next;
else
prev.next = next; //否則指向刪除結點的子結點
e.recordRemoval(this);
return e;
}
//不匹配,繼續遍歷
prev = e;
e = next;
}
// 不存在,返回null
return e;
}
五、擴容
1.7的擴容是插入之前之前判斷,而1.8是插入之後再判斷是否需要擴容,不過都是擴容2倍 resize(2 * table.length)。
擴容到新容量 - 擴容容易出現併發問題,線程不安全
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果舊容量已經達到了最大,將閾值設置爲最大值,與1.8相同
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//創建新哈希表
Entry[] newTable = new Entry[newCapacity];
//將原數組中的元素遷移到擴容後的數組中
//死循環就是在這個方法中產生的
transfer(newTable, initHashSeedAsNeeded(newCapacity)); // boolean initHashSeedAsNeeded()判斷是否重新獲取隨機的hashSeed,這個值在hash()中用到
table = newTable;
//更新閾值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
如果hashSeed變了,那麼rehash爲true,否則爲false
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍歷舊錶
for (Entry<K,V> e : table) {
//當桶不爲空
while(null != e) { //循環遍歷鏈表中的entry
Entry<K,V> next = e.next;
//如果hashSeed變了,需要重新計算hash值
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//得到新表中的索引,i的值有可能不同,所以可能和原來的存儲位置不同
int i = indexFor(e.hash, newCapacity);
//將新節點作爲頭節點添加到桶中
//採用鏈頭插入法將e插入i位置,最後得到的鏈表相對於原table正好是頭尾相反的(新值總是插在最前面)
e.next = newTable[i];
newTable[i] = e;
//下一輪循環
e = next;
}
}
}
boolean initHashSeedAsNeeded()判斷是否重新獲取隨機的hashSeed,這個值在hash()中用到。
五、問題
1、爲什麼增刪改查時,判斷時e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))),爲什麼加入e.hash == hash??
有人覺得上面在定位到數組位置之後然後遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當前對象不一致,這種情況,根據Object的hashCode的約定,不能返回當前對象,而應該返回null。
2、爲什麼容量大小設置2^x次冪形式?
計算存放位置的公式爲h & (length-1),如果length的值是2^x,如length爲32,二進制爲0010 0000,length - 1二進制爲0001 1111,此時如果和有序hash進行與運算,總是有序且均勻獲取到下標。從而減少碰撞,即減少hash衝突且值能均勻分佈。如果不是2^x,那麼hash衝突變大,鏈表變長,性能就會下降。
該計算方式等價h & (length-1) == h%length。
link:https://blog.csdn.net/eaphyy/article/details/84386313
3、爲什麼底層用鏈表?
鏈地址法處理hash衝突,形成鏈表,且單向鏈表的速度高於數組。對於hash相同的key,取模獲取的數組下標index肯定也相同,所以此時用鏈表存儲不同的value。當get(key)時,即使獲取的數組下標index相同,比較hash值是否相同而獲取value。
4、爲什麼說HashMap線程不安全?
在擴容時多線程會出現死鏈。
兩個線程A,B同時對HashMap進行resize()操作,在執行transfer方法的while循環時,若此時當前槽上的元素爲a–>b–>null
1.線程A執行到 Entry<K,V> next = e.next;時發生阻塞,此時e=a,next=b
2.線程B完整的執行了整段代碼,此時新表newTable元素爲b–>a–>null
3.線程A繼續執行後面的代碼,執行完一個循環之後,newTable變爲了a<–>b,造成while(e!=null) 一直死循環,CPU飆升
5、多線程下擴容可能會出現數據丟失
同樣在resize的transfer方法上
1.當前線程遷移過程中,其他線程新增的元素有可能落在已經遍歷過的哈希槽上;在遍歷完成之後,table數組引用指向了newTable,這時新增的元素就會丟失,被無情的垃圾回收。
2.如果多個線程同時執行resize,每個線程又都會new Entry[newCapacity],此時這是線程內的局部變量,線程之前是不可見的。遷移完成後,resize的線程會給table線程共享變量,從而覆蓋其他線程的操作,因此在被覆蓋的new table上插入的數據會被丟棄掉。
6、爲什麼hash衝突時,新數據放在鏈表頭部?
頭部快,如果放在其他地方,還需要檢索,鏈表過長,性能底下
7、多個key爲null的情況,hashmap怎麼處理?
對於key爲null的值,默認放在table[0]位置。新增時先判斷原先是否有值,沒有則新建一個entry對象;有則判斷key是否爲null,不爲null,則覆蓋value值。
link:link:https://www.cnblogs.com/liyus/p/9916562.html
六、總結
1、HashMap是基於哈希表的 Map 接口的實現。此實現提供所有可選的映射操作,並允許使用 null 值和 null 鍵。(除了非同步和允許使用 null 之外,HashMap 類與 Hashtable 大致相同。)此類不保證映射的順序,特別是它不保證該順序恆久不變(發生擴容時,元素位置會重新分配)
2、對key進行hash計算,並int i = hashKey % table.length計算存在數組中的下標(下標範圍[0, table.length -1])
3、JDK1.7和JDk1.8
對JDK1.7和JDk1.8中HashMap的相同與不同點做出總結。
首先是相同點:
- 默認初始容量都是16,默認加載因子都是0.75。容量必須是2的指數倍數
- 擴容時都將容量增加1倍,原來的2倍
- 根據hash值得到桶的索引方法一樣,都是i=hash&(cap-1)
- 初始時表爲空,都是懶加載,在插入第一個鍵值對時初始化
- 鍵爲null的hash值爲0,都會放在哈希表的第一個桶中
接下來是不同點,主要是思想上的不同,不再糾結與實現的不同:
- 最爲重要的一點是,底層結構不一樣,1.7是數組+鏈表,1.8則是數組+鏈表+紅黑樹結構
- 主要區別是插入鍵值對的put方法的區別。1.8中會將節點插入到鏈表尾部,而1.7中會將節點作爲鏈表的新的頭節點
- JDk1.8中一個鍵的hash是保持不變的,JDK1.7時resize()時有可能改變鍵的hash值
- rehash時1.8會保持原鏈表的順序,而1.7會顛倒鏈表的順序
- JDK1.8是通過hash&cap==0將鏈表分散,而JDK1.7是通過更新hashSeed來修改hash值達到分散的目的
link:
https://blog.csdn.net/qq_19431333/article/details/61614414
https://blog.csdn.net/xiaokang123456kao/article/details/77503784