国产片侵犯亲女视频播放_亚洲精品二区_在线免费国产视频_欧美精品一区二区三区在线_少妇久久久_在线观看av不卡

服務器之家:專注于服務器技術及軟件下載分享
分類導航

PHP教程|ASP.NET教程|JAVA教程|ASP教程|

服務器之家 - 編程語言 - JAVA教程 - Java HashMap的工作原理

Java HashMap的工作原理

2020-04-08 14:14mrr JAVA教程

這篇文章主要介紹了Java HashMap的工作原理的相關資料,需要的朋友可以參考下

大部分Java開發者都在使用Map,特別是HashMap。HashMap是一種簡單但強大的方式去存儲和獲取數據。但有多少開發者知道HashMap內部如何工作呢?幾天前,我閱讀了java.util.HashMap的大量源代碼(包括Java 7 和Java 8),來深入理解這個基礎的數據結構。在這篇文章中,我會解釋java.util.HashMap的實現,描述Java 8實現中添加的新特性,并討論性能、內存以及使用HashMap時的一些已知問題。

內部存儲

Java HashMap類實現了Map<K, V>接口。這個接口中的主要方法包括:

?
1
2
3
4
V put(K key, V value)
V get(Object key)
V remove(Object key)
Boolean containsKey(Object key)

HashMap使用了一個內部類Entry<K, V>來存儲數據。這個內部類是一個簡單的鍵值對,并帶有額外兩個數據:

一個指向其他入口(譯者注:引用對象)的引用,這樣HashMap可以存儲類似鏈接列表這樣的對象。
一個用來代表鍵的哈希值,存儲這個值可以避免HashMap在每次需要時都重新生成鍵所對應的哈希值。
下面是Entry<K, V>在Java 7下的一部分代碼:

?
1
2
3
4
5
6
7
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}

HashMap將數據存儲到多個單向Entry鏈表中(有時也被稱為桶bucket或者容器orbins)。所有的列表都被注冊到一個Entry數組中(Entry<K, V>[]數組),這個內部數組的默認長度是16。

下面這幅圖描述了一個HashMap實例的內部存儲,它包含一個nullable對象組成的數組。每個對象都連接到另外一個對象,這樣就構成了一個鏈表。

Java HashMap的工作原理

所有具有相同哈希值的鍵都會被放到同一個鏈表(桶)中。具有不同哈希值的鍵最終可能會在相同的桶中。

當用戶調用 put(K key, V value) 或者 get(Object key) 時,程序會計算對象應該在的桶的索引。然后,程序會迭代遍歷對應的列表,來尋找具有相同鍵的Entry對象(使用鍵的equals()方法)。

對于調用get()的情況,程序會返回值所對應的Entry對象(如果Entry對象存在)。

對于調用put(K key, V value)的情況,如果Entry對象已經存在,那么程序會將值替換為新值,否則,程序會在單向鏈表的表頭創建一個新的Entry(從參數中的鍵和值)。

桶(鏈表)的索引,是通過map的3個步驟生成的:

首先獲取鍵的散列碼。

程序重復散列碼,來阻止針對鍵的糟糕的哈希函數,因為這有可能會將所有的數據都放到內部數組的相同的索引(桶)上。
程序拿到重復后的散列碼,并對其使用數組長度(最小是1)的位掩碼(bit-mask)。這個操作可以保證索引不會大于數組的大小。你可以將其看做是一個經過計算的優化取模函數。

下面是生成索引的源代碼:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// the "rehash" function in JAVA 7 that takes the hashcode of the key
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// the "rehash" function in JAVA 8 that directly takes the key
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// the function that returns the index from the rehashed hash
static int indexFor(int h, int length) {
return h & (length-1);
}

為了更有效地工作,內部數組的大小必須是2的冪值。讓我們看一下為什么:

假設數組的長度是17,那么掩碼的值就是16(數組長度-1)。16的二進制表示是0…010000,這樣對于任何值H來說,“H & 16”的結果就是16或者0。這意味著長度為17的數組只能應用到兩個桶上:一個是0,另外一個是16,這樣不是很有效率。但是如果你將數組的長度設置為2的冪值,例如16,那么按位索引的工作變成“H & 15”。15的二進制表示是0…001111,索引公式輸出的值可以從0到15,這樣長度為16的數組就可以被充分使用了。例如:

如果H = 952,它的二進制表示是0..01110111000,對應的索引是0…01000 = 8
如果H = 1576,它的二進制表示是0..011000101000,對應的索引是0…01000 = 8
如果H = 12356146,它的二進制表示是0..0101111001000101000110010,對應的索引是0…00010 = 2
如果H = 59843,它的二進制表示是0..01110100111000011,它對應的索引是0…00011 = 3
這種機制對于開發者來說是透明的:如果他選擇一個長度為37的HashMap,Map會自動選擇下一個大于37的2的冪值(64)作為內部數組的長度。

自動調整大小

在獲取索引后,get()、put()或者remove()方法會訪問對應的鏈表,來查看針對指定鍵的Entry對象是否已經存在。在不做修改的情況下,這個機制可能會導致性能問題,因為這個方法需要迭代整個列表來查看Entry對象是否存在。假設內部數組的長度采用默認值16,而你需要存儲2,000,000條記錄。在最好的情況下,每個鏈表會有125,000個Entry對象(2,000,000/16)。get()、remove()和put()方法在每一次執行時,都需要進行125,000次迭代。為了避免這種情況,HashMap可以增加內部數組的長度,從而保證鏈表中只保留很少的Entry對象。

當你創建一個HashMap時,你可以通過以下構造函數指定一個初始長度,以及一個loadFactor:

?
1
2
3
</pre>
public HashMap(int initialCapacity, float loadFactor)
<pre>

如果你不指定參數,那么默認的initialCapacity的值是16, loadFactor的默認值是0.75。initialCapacity代表內部數組的鏈表的長度。

當你每次使用put(…)方法向Map中添加一個新的鍵值對時,該方法會檢查是否需要增加內部數組的長度。為了實現這一點,Map存儲了2個數據:

Map的大小:它代表HashMap中記錄的條數。我們在向HashMap中插入或者刪除值時更新它。

閥值:它等于內部數組的長度*loadFactor,在每次調整內部數組的長度時,該閥值也會同時更新。

在添加新的Entry對象之前,put(…)方法會檢查當前Map的大小是否大于閥值。如果大于閥值,它會創建一個新的數組,數組長度是當前內部數組的兩倍。因為新數組的大小已經發生改變,所以索引函數(就是返回“鍵的哈希值 & (數組長度-1)”的位運算結果)也隨之改變。調整數組的大小會創建兩個新的桶(鏈表),并且將所有現存Entry對象重新分配到桶上。調整數組大小的目標在于降低鏈表的大小,從而降低put()、remove()和get()方法的執行時間。對于具有相同哈希值的鍵所對應的所有Entry對象來說,它們會在調整大小后分配到相同的桶中。但是,如果兩個Entry對象的鍵的哈希值不一樣,但它們之前在同一個桶上,那么在調整以后,并不能保證它們依然在同一個桶上。

Java HashMap的工作原理

這幅圖片描述了調整前和調整后的內部數組的情況。在調整數組長度之前,為了得到Entry對象E,Map需要迭代遍歷一個包含5個元素的鏈表。在調整數組長度之后,同樣的get()方法則只需要遍歷一個包含2個元素的鏈表,這樣get()方法在調整數組長度后的運行速度提高了2倍。

線程安全

如果你已經非常熟悉HashMap,那么你肯定知道它不是線程安全的,但是為什么呢?例如假設你有一個Writer線程,它只會向Map中插入已經存在的數據,一個Reader線程,它會從Map中讀取數據,那么它為什么不工作呢?

因為在自動調整大小的機制下,如果線程試著去添加或者獲取一個對象,Map可能會使用舊的索引值,這樣就不會找到Entry對象所在的新桶。

在最糟糕的情況下,當2個線程同時插入數據,而2次put()調用會同時出發數組自動調整大小。既然兩個線程在同時修改鏈表,那么Map有可能在一個鏈表的內部循環中退出。如果你試著去獲取一個帶有內部循環的列表中的數據,那么get()方法永遠不會結束。

HashTable提供了一個線程安全的實現,可以阻止上述情況發生。但是,既然所有的同步的CRUD操作都非常慢。例如,如果線程1調用get(key1),然后線程2調用get(key2),線程2調用get(key3),那么在指定時間,只能有1個線程可以得到它的值,但是3個線程都可以同時訪問這些數據。

從Java 5開始,我們就擁有一個更好的、保證線程安全的HashMap實現:ConcurrentHashMap。對于ConcurrentMap來說,只有桶是同步的,這樣如果多個線程不使用同一個桶或者調整內部數組的大小,它們可以同時調用get()、remove()或者put()方法。在一個多線程應用程序中,這種方式是更好的選擇。

鍵的不變性

為什么將字符串和整數作為HashMap的鍵是一種很好的實現?主要是因為它們是不可變的!如果你選擇自己創建一個類作為鍵,但不能保證這個類是不可變的,那么你可能會在HashMap內部丟失數據。

我們來看下面的用例:

你有一個鍵,它的內部值是“1”。

你向HashMap中插入一個對象,它的鍵就是“1”。

HashMap從鍵(即“1”)的散列碼中生成哈希值。

Map在新創建的記錄中存儲這個哈希值。

你改動鍵的內部值,將其變為“2”。

鍵的哈希值發生了改變,但是HashMap并不知道這一點(因為存儲的是舊的哈希值)。

你試著通過修改后的鍵獲取相應的對象。

Map會計算新的鍵(即“2”)的哈希值,從而找到Entry對象所在的鏈表(桶)。

情況1: 既然你已經修改了鍵,Map會試著在錯誤的桶中尋找Entry對象,沒有找到。

情況2: 你很幸運,修改后的鍵生成的桶和舊鍵生成的桶是同一個。Map這時會在鏈表中進行遍歷,已找到具有相同鍵的Entry對象。但是為了尋找鍵,Map首先會通過調用equals()方法來比較鍵的哈希值。因為修改后的鍵會生成不同的哈希值(舊的哈希值被存儲在記錄中),那么Map沒有辦法在鏈表中找到對應的Entry對象。

下面是一個Java示例,我們向Map中插入兩個鍵值對,然后我修改第一個鍵,并試著去獲取這兩個對象。你會發現從Map中返回的只有第二個對象,第一個對象已經“丟失”在HashMap中:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MutableKeyTest {
public static void main(String[] args) {
class MyKey {
Integer i;
public void setI(Integer i) {
this.i = i;
}
public MyKey(Integer i) {
this.i = i;
}
@Override
public int hashCode() {
return i;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof MyKey) {
return i.equals(((MyKey) obj).i);
} else
return false;
}
}
Map<MyKey, String> myMap = new HashMap<>();
MyKey key1 = new MyKey(1);
MyKey key2 = new MyKey(2);
myMap.put(key1, "test " + 1);
myMap.put(key2, "test " + 2);
// modifying key1
key1.setI(3);
String test1 = myMap.get(key1);
String test2 = myMap.get(key2);
System.out.println("test1= " + test1 + " test2=" + test2);
}
}

上述代碼的輸出是“test1=null test2=test 2”。如我們期望的那樣,Map沒有能力獲取經過修改的鍵 1所對應的字符串1。

Java 8 中的改進

在Java 8中,HashMap中的內部實現進行了很多修改。的確如此,Java 7使用了1000行代碼來實現,而Java 8中使用了2000行代碼。我在前面描述的大部分內容在Java 8中依然是對的,除了使用鏈表來保存Entry對象。在Java 8中,我們仍然使用數組,但它會被保存在Node中,Node中包含了和之前Entry對象一樣的信息,并且也會使用鏈表:

下面是在Java 8中Node實現的一部分代碼:

?
1
2
3
4
5
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;

那么和Java 7相比,到底有什么大的區別呢?好吧,Node可以被擴展成TreeNode。TreeNode是一個紅黑樹的數據結構,它可以存儲更多的信息,這樣我們可以在O(log(n))的復雜度下添加、刪除或者獲取一個元素。下面的示例描述了TreeNode保存的所有信息:

?
1
2
3
4
5
6
7
8
9
10
11
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
final int hash; // inherited from Node<K,V>
final K key; // inherited from Node<K,V>
V value; // inherited from Node<K,V>
Node<K,V> next; // inherited from Node<K,V>
Entry<K,V> before, after;// inherited from LinkedHashMap.Entry<K,V>
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;

紅黑樹是自平衡的二叉搜索樹。它的內部機制可以保證它的長度總是log(n),不管我們是添加還是刪除節點。使用這種類型的樹,最主要的好處是針對內部表中許多數據都具有相同索引(桶)的情況,這時對樹進行搜索的復雜度是O(log(n)),而對于鏈表來說,執行相同的操作,復雜度是O(n)。

如你所見,我們在樹中確實存儲了比鏈表更多的數據。根據繼承原則,內部表中可以包含Node(鏈表)或者TreeNode(紅黑樹)。Oracle決定根據下面的規則來使用這兩種數據結構:

- 對于內部表中的指定索引(桶),如果node的數目多于8個,那么鏈表就會被轉換成紅黑樹。

- 對于內部表中的指定索引(桶),如果node的數目小于6個,那么紅黑樹就會被轉換成鏈表。

Java HashMap的工作原理

這張圖片描述了在Java 8 HashMap中的內部數組,它既包含樹(桶0),也包含鏈表(桶1,2和3)。桶0是一個樹結構是因為它包含的節點大于8個。

內存開銷

JAVA 7

使用HashMap會消耗一些內存。在Java 7中,HashMap將鍵值對封裝成Entry對象,一個Entry對象包含以下信息:

指向下一個記錄的引用
一個預先計算的哈希值(整數)
一個指向鍵的引用
一個指向值的引用

此外,Java 7中的HashMap使用了Entry對象的內部數組。假設一個Java 7 HashMap包含N個元素,它的內部數組的容量是CAPACITY,那么額外的內存消耗大約是:

sizeOf(integer)* N + sizeOf(reference)* (3*N+C)

其中:

整數的大小是4個字節

引用的大小依賴于JVM、操作系統以及處理器,但通常都是4個字節。

這就意味著內存總開銷通常是16 * N + 4 * CAPACITY字節。

注意:在Map自動調整大小后,CAPACITY的值是下一個大于N的最小的2的冪值。

注意:從Java 7開始,HashMap采用了延遲加載的機制。這意味著即使你為HashMap指定了大小,在我們第一次使用put()方法之前,記錄使用的內部數組(耗費4*CAPACITY字節)也不會在內存中分配空間。

JAVA 8

在Java 8實現中,計算內存使用情況變得復雜一些,因為Node可能會和Entry存儲相同的數據,或者在此基礎上再增加6個引用和一個Boolean屬性(指定是否是TreeNode)。

如果所有的節點都只是Node,那么Java 8 HashMap消耗的內存和Java 7 HashMap消耗的內存是一樣的。

如果所有的節點都是TreeNode,那么Java 8 HashMap消耗的內存就變成:

?
1
N * sizeOf(integer) + N * sizeOf(boolean) + sizeOf(reference)* (9*N+CAPACITY )

在大部分標準JVM中,上述公式的結果是44 * N + 4 * CAPACITY 字節。

性能問題

非對稱HashMap vs 均衡HashMap

在最好的情況下,get()和put()方法都只有O(1)的復雜度。但是,如果你不去關心鍵的哈希函數,那么你的put()和get()方法可能會執行非常慢。put()和get()方法的高效執行,取決于數據被分配到內部數組(桶)的不同的索引上。如果鍵的哈希函數設計不合理,你會得到一個非對稱的分區(不管內部數據的是多大)。所有的put()和get()方法會使用最大的鏈表,這樣就會執行很慢,因為它需要迭代鏈表中的全部記錄。在最壞的情況下(如果大部分數據都在同一個桶上),那么你的時間復雜度就會變為O(n)。

下面是一個可視化的示例。第一張圖描述了一個非對稱HashMap,第二張圖描述了一個均衡HashMap。

Java HashMap的工作原理

skewedHashmap

在這個非對稱HashMap中,在桶0上運行get()和put()方法會很花費時間。獲取記錄K需要花費6次迭代。

Java HashMap的工作原理

在這個均衡HashMap中,獲取記錄K只需要花費3次迭代。這兩個HashMap存儲了相同數量的數據,并且內部數組的大小一樣。唯一的區別是鍵的哈希函數,這個函數用來將記錄分布到不同的桶上。

下面是一個使用Java編寫的極端示例,在這個示例中,我使用哈希函數將所有的數據放到相同的鏈表(桶),然后我添加了2,000,000條數據。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Test {
public static void main(String[] args) {
class MyKey {
Integer i;
public MyKey(Integer i){
this.i =i;
}
@Override
public int hashCode() {
return 1;
}
@Override
public boolean equals(Object obj) {
}
}
Date begin = new Date();
Map <MyKey,String> myMap= new HashMap<>(2_500_000,1);
for (int i=0;i<2_000_000;i++){
myMap.put( new MyKey(i), "test "+i);
}
Date end = new Date();
System.out.println("Duration (ms) "+ (end.getTime()-begin.getTime()));
}
}

我的機器配置是core i5-2500k @ 3.6G,在java 8u40下需要花費超過45分鐘的時間來運行(我在45分鐘后停止了進程)。如果我運行同樣的代碼, 但是我使用如下的hash函數:

?
1
2
3
4
5
@Override
public int hashCode() {
int key = 2097152-1;
return key+2097152*i;
}

運行它需要花費46秒,和之前比,這種方式好很多了!新的hash函數比舊的hash函數在處理哈希分區時更合理,因此調用put()方法會更快一些。如果你現在運行相同的代碼,但是使用下面的hash函數,它提供了更好的哈希分區:

?
1
2
3
4
@Override
public int hashCode() {
return i;
}

現在只需要花費2秒!

我希望你能夠意識到哈希函數有多重要。如果在Java 7上面運行同樣的測試,第一個和第二個的情況會更糟(因為Java 7中的put()方法復雜度是O(n),而Java 8中的復雜度是O(log(n))。

在使用HashMap時,你需要針對鍵找到一種哈希函數,可以將鍵擴散到最可能的桶上。為此,你需要避免哈希沖突。String對象是一個非常好的鍵,因為它有很好的哈希函數。Integer也很好,因為它的哈希值就是它自身的值。

調整大小的開銷

如果你需要存儲大量數據,你應該在創建HashMap時指定一個初始的容量,這個容量應該接近你期望的大小。

如果你不這樣做,Map會使用默認的大小,即16,factorLoad的值是0.75。前11次調用put()方法會非常快,但是第12次(16*0.75)調用時會創建一個新的長度為32的內部數組(以及對應的鏈表/樹),第13次到第22次調用put()方法會很快,但是第23次(32*0.75)調用時會重新創建(再一次)一個新的內部數組,數組的長度翻倍。然后內部調整大小的操作會在第48次、96次、192次…..調用put()方法時觸發。如果數據量不大,重建內部數組的操作會很快,但是數據量很大時,花費的時間可能會從秒級到分鐘級。通過初始化時指定Map期望的大小,你可以避免調整大小操作帶來的消耗。

但這里也有一個缺點:如果你將數組設置的非常大,例如2^28,但你只是用了數組中的2^26個桶,那么你將會浪費大量的內存(在這個示例中大約是2^30字節)。

結論

對于簡單的用例,你沒有必要知道HashMap是如何工作的,因為你不會看到O(1)、O(n)以及O(log(n))之間的區別。但是如果能夠理解這一經常使用的數據結構背后的機制,總是有好處的。另外,對于Java開發者職位來說,這是一道典型的面試問題。

對于大數據量的情況,了解HashMap如何工作以及理解鍵的哈希函數的重要性就變得非常重要。

我希望這篇文章可以幫助你對HashMap的實現有一個深入的理解。

延伸 · 閱讀

精彩推薦
Weibo Article 1 Weibo Article 2 Weibo Article 3 Weibo Article 4 Weibo Article 5 Weibo Article 6 Weibo Article 7 Weibo Article 8 Weibo Article 9 Weibo Article 10 Weibo Article 11 Weibo Article 12 Weibo Article 13 Weibo Article 14 Weibo Article 15 Weibo Article 16 Weibo Article 17 Weibo Article 18 Weibo Article 19 Weibo Article 20 Weibo Article 21 Weibo Article 22 Weibo Article 23 Weibo Article 24 Weibo Article 25 Weibo Article 26 Weibo Article 27 Weibo Article 28 Weibo Article 29 Weibo Article 30 Weibo Article 31 Weibo Article 32 Weibo Article 33 Weibo Article 34 Weibo Article 35 Weibo Article 36 Weibo Article 37 Weibo Article 38 Weibo Article 39 Weibo Article 40
主站蜘蛛池模板: 日本在线网 | 日韩综合| 奇米av在线 | 自拍偷拍专区 | 一区二区日本 | 这里只有精品视频在线 | 欧洲亚洲一区 | 一级片在线播放 | 亚洲精品福利 | 91精品国产综合久久久蜜臀粉嫩 | 久久99精品久久久久久国产越南 | 国产成年人在线观看 | 香蕉久久精品视频 | 久久精品六| 日日干夜夜操 | 久久这里有精品视频 | 久久精品美女 | 日本在线一区 | 亚洲视频在线观看免费 | 日韩中文字幕在线 | www日本视频 | 中文字幕视频在线免费 | 久久久久国产一级毛片高清片 | 欧美激情网址 | 在线免费观看激情视频 | 亚洲第1页| 欧美一区二区三区男人的天堂 | 国产大片一区 | 欧美一区二区三区在线观看视频 | 久久首页 | 亚洲乱码国产乱码精品精的特点 | 久久久婷婷| 亚洲一区二区精品在线观看 | 欧美日一区二区 | 久久久国产一级 | 亚洲人成网站999久久久综合 | 亚洲午夜免费视频 | 一区二区在线 | 一级毛片免费完整视频 | 日韩成人免费视频 | 天天天天干|