1. 程式人生 > >執行緒安全Map比較

執行緒安全Map比較

如何執行緒安全的使用HashMap

2016年09月02日 13:37:32

在週二面試時,一面的面試官有問到HashMap是否是執行緒安全的,如何線上程安全的前提下使用HashMap,其實也就是HashMapHashtableConcurrentHashMapsynchronized Map的原理和區別。當時有些緊張只是簡單說了下HashMap不是執行緒安全的;Hashtable執行緒安全,但效率低,因為是Hashtable是使用synchronized的,所有執行緒競爭同一把鎖;而ConcurrentHashMap不僅執行緒安全而且效率高,因為它包含一個segment陣列,將資料分段儲存,給每一段資料配一把鎖,也就是所謂的鎖分段技術。當時忘記了synchronized Map和解釋一下HashMap為什麼執行緒不安全。面試結束後問了下面試官哪裡有些不足,面試官說上面這個問題的回答算過關,但可以在深入一些或者自己動手嘗試一下。so~~~雖然拿到了offer,但還是再整理一下,不能得過且過啊。

為什麼HashMap是執行緒不安全的

總說HashMap是執行緒不安全的,不安全的,不安全的,那麼到底為什麼它是執行緒不安全的呢?要回答這個問題就要先來簡單瞭解一下HashMap原始碼中的使用的儲存結構(這裡引用的是Java 8的原始碼,與7是不一樣的)和它的擴容機制

HashMap的內部儲存結構

下面是HashMap使用的儲存結構:



 

1

2

3

4

5

6

7

8

transient Node<K,V>[] table;

 

static class Node<K,V> 

implements Map.Entry<K,V> {

        final int hash;

        final K key;

        V value;

        Node<K,V> next;

}

 


 

可以看到HashMap內部儲存使用了一個Node陣列(預設大小是16),而Node類包含一個型別為Node的next的變數,也就是相當於一個連結串列,所有hash值相同(即產生了衝突)的key會儲存到同一個連結串列裡,大概就是下面圖的樣子(順便推薦個線上畫圖的網站Creately)。
HashMap內部儲存結果HashMap內部儲存結果

需要注意的是,在Java 8中如果hash值相同的key數量大於指定值(預設是8)時使用平衡樹來代替連結串列,這會將get()方法的效能從O(n)提高到O(logn)。具體的可以看我的另一篇部落格Java 8中HashMap和LinkedHashMap如何解決衝突

HashMap的自動擴容機制

HashMap內部的Node陣列預設的大小是16,假設有100萬個元素,那麼最好的情況下每個hash桶裡都有62500個元素,這時get(),put(),remove()等方法效率都會降低。為了解決這個問題,HashMap提供了自動擴容機制,當元素個數達到陣列大小loadFactor後會擴大陣列的大小,在預設情況下,陣列大小為16,loadFactor為0.75,也就是說當HashMap中的元素超過16\0.75=12時,會把陣列大小擴充套件為2*16=32,並且重新計算每個元素在新陣列中的位置。如下圖所示(圖片來源,權侵刪)。
自動擴容

自動擴容

從圖中可以看到沒擴容前,獲取EntryE需要遍歷5個元素,擴容之後只需要2次。

為什麼執行緒不安全

個人覺得HashMap在併發時可能出現的問題主要是兩方面,首先如果多個執行緒同時使用put方法新增元素,而且假設正好存在兩個put的key發生了碰撞(hash值一樣),那麼根據HashMap的實現,這兩個key會新增到陣列的同一個位置,這樣最終就會發生其中一個執行緒的put的資料被覆蓋。第二就是如果多個執行緒同時檢測到元素個數超過陣列大小*loadFactor,這樣就會發生多個執行緒同時對Node陣列進行擴容,都在重新計算元素位置以及複製資料,但是最終只有一個執行緒擴容後的陣列會賦給table,也就是說其他執行緒的都會丟失,並且各自執行緒put的資料也丟失。
關於HashMap執行緒不安全這一點,《Java併發程式設計的藝術》一書中是這樣說的:

HashMap在併發執行put操作時會引起死迴圈,導致CPU利用率接近100%。因為多執行緒會導致HashMap的Node連結串列形成環形資料結構,一旦形成環形資料結構,Node的next節點永遠不為空,就會在獲取Node時產生死迴圈。

哇塞,聽上去si不si好神奇,居然會產生死迴圈。。。。google了一下,才知道死迴圈並不是發生在put操作時,而是發生在擴容時。詳細的解釋可以看下面幾篇部落格:

如何執行緒安全的使用HashMap

瞭解了HashMap為什麼執行緒不安全,那現在看看如何執行緒安全的使用HashMap。這個無非就是以下三種方式:

  • Hashtable
  • ConcurrentHashMap
  • Synchronized Map

例子:



 

1

2

3

4

5

6

7

8

//Hashtable

Map<String, String> hashtable = new Hashtable<>();

 

//synchronizedMap

Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());

 

//ConcurrentHashMap

Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();

 


 

依次來看看。

Hashtable

先稍微吐槽一下,為啥命名不是HashTable啊,看著好難受,不管了就裝作它叫HashTable吧。這貨已經不常用了,就簡單說說吧。HashTable原始碼中是使用synchronized來保證執行緒安全的,比如下面的get方法和put方法:



 

1

2

3

4

5

6

public synchronized V get(Object key) {

       // 省略實現

    }

public synchronized V put(K key, V value) {

    // 省略實現

    }

 


 

所以當一個執行緒訪問HashTable的同步方法時,其他執行緒如果也要訪問同步方法,會被阻塞住。舉個例子,當一個執行緒使用put方法時,另一個執行緒不但不可以使用put方法,連get方法都不可以,好霸道啊!!!so~~,效率很低,現在基本不會選擇它了。

ConcurrentHashMap

ConcurrentHashMap(以下簡稱CHM)是JUC包中的一個類,Spring的原始碼中有很多使用CHM的地方。之前已經翻譯過一篇關於ConcurrentHashMap的部落格,如何在java中使用ConcurrentHashMap,裡面介紹了CHM在Java中的實現,CHM的一些重要特性和什麼情況下應該使用CHM。需要注意的是,上面部落格是基於Java 7的,和8有區別,在8中CHM摒棄了Segment(鎖段)的概念,而是啟用了一種全新的方式實現,利用CAS演算法,有時間會重新總結一下。

SynchronizedMap

看了一下原始碼,SynchronizedMap的實現還是很簡單的。



 

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

35

36

37

38

39

40

41

42

43

44

45

46

// synchronizedMap方法

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {

       return new SynchronizedMap<>(m);

   }

// SynchronizedMap類

private static class SynchronizedMap<K,V>

       implements Map<K,V>, Serializable {

       private static final long serialVersionUID = 1978198479659022715L;

 

       private final Map<K,V> m;     // Backing Map

       final Object      mutex;        // Object on which to synchronize

 

       SynchronizedMap(Map<K,V> m) {

           this.m = Objects.requireNonNull(m);

           mutex = this;

       }

 

       SynchronizedMap(Map<K,V> m, Object mutex) {

           this.m = m;

           this.mutex = mutex;

       }

 

       public int size() {

           synchronized (mutex) {return m.size();}

       }

       public boolean isEmpty() {

           synchronized (mutex) {return m.isEmpty();}

       }

       public boolean containsKey(Object key) {

           synchronized (mutex) {return m.containsKey(key);}

       }

       public boolean containsValue(Object value) {

           synchronized (mutex) {return m.containsValue(value);}

       }

       public V get(Object key) {

           synchronized (mutex) {return m.get(key);}

       }

 

       public V put(K key, V value) {

           synchronized (mutex) {return m.put(key, value);}

       }

       public V remove(Object key) {

           synchronized (mutex) {return m.remove(key);}

       }

       // 省略其他方法

   }

 


 

從原始碼中可以看出呼叫synchronizedMap()方法後會返回一個SynchronizedMap類的物件,而在SynchronizedMap類中使用了synchronized同步關鍵字來保證對Map的操作是執行緒安全的。

效能對比

這是要靠資料說話的時代,所以不能只靠嘴說CHM快,它就快了。寫個測試用例,實際的比較一下這三種方式的效率(原始碼來源),下面的程式碼分別通過三種方式建立Map物件,使用ExecutorService來併發執行5個執行緒,每個執行緒新增/獲取500K個元素。



 

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

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

public class CrunchifyConcurrentHashMapVsSynchronizedMap {

 

    public final static int THREAD_POOL_SIZE = 5;

 

    public static Map<String, Integer> crunchifyHashTableObject = null;

    public static Map<String, Integer> crunchifySynchronizedMapObject = null;

    public static Map<String, Integer> crunchifyConcurrentHashMapObject = null;

 

    public static void main(String[] args) throws InterruptedException {

 

        // Test with Hashtable Object

        crunchifyHashTableObject = new Hashtable<>();

        crunchifyPerformTest(crunchifyHashTableObject);

 

        // Test with synchronizedMap Object

        crunchifySynchronizedMapObject = Collections.synchronizedMap(new HashMap<String, Integer>());

        crunchifyPerformTest(crunchifySynchronizedMapObject);

 

        // Test with ConcurrentHashMap Object

        crunchifyConcurrentHashMapObject = new ConcurrentHashMap<>();

        crunchifyPerformTest(crunchifyConcurrentHashMapObject);

 

    }

 

    public static void crunchifyPerformTest(final Map<String, Integer> crunchifyThreads) throws InterruptedException {

 

        System.out.println("Test started for: " + crunchifyThreads.getClass());

        long averageTime = 0;

        for (int i = 0; i < 5; i++) {

 

            long startTime = System.nanoTime();

            ExecutorService crunchifyExServer = Executors.newFixedThreadPool(THREAD_POOL_SIZE);

 

            for (int j = 0; j < THREAD_POOL_SIZE; j++) {

                crunchifyExServer.execute(new Runnable() {

                    @SuppressWarnings("unused")

                    @Override

                    public void run() {

 

                        for (int i = 0; i < 500000; i++) {

                            Integer crunchifyRandomNumber = (int) Math.ceil(Math.random() * 550000);

 

                            // Retrieve value. We are not using it anywhere

                            Integer crunchifyValue = crunchifyThreads.get(String.valueOf(crunchifyRandomNumber));

 

                            // Put value

                            crunchifyThreads.put(String.valueOf(crunchifyRandomNumber), crunchifyRandomNumber);

                        }

                    }

                });

            }

 

            // Make sure executor stops

            crunchifyExServer.shutdown();

 

            // Blocks until all tasks have completed execution after a shutdown request

            crunchifyExServer.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);

 

            long entTime = System.nanoTime();

            long totalTime = (entTime - startTime) / 1000000L;

            averageTime += totalTime;

            System.out.println("2500K entried added/retrieved in " + totalTime + " ms");

        }

        System.out.println("For " + crunchifyThreads.getClass() + " the average time is " + averageTime / 5 " ms\n");

    }

}

 


 

測試結果:



 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

Test started forclass java.util.Hashtable

2500K entried added/retrieved in 2018 ms

2500K entried added/retrieved in 1746 ms

2500K entried added/retrieved in 1806 ms

2500K entried added/retrieved in 1801 ms

2500K entried added/retrieved in 1804 ms

For class java.util.Hashtable the average time is 1835 ms

 

Test started forclass java.util.Collections$SynchronizedMap

2500K entried added/retrieved in 3041 ms

2500K entried added/retrieved in 1690 ms

2500K entried added/retrieved in 1740 ms

2500K entried added/retrieved in 1649 ms

2500K entried added/retrieved in 1696 ms

For class java.util.Collections$SynchronizedMap the average time is 1963 ms

 

Test started forclass java.util.concurrent.ConcurrentHashMap

2500K entried added/retrieved in 738 ms

2500K entried added/retrieved in 696 ms

2500K entried added/retrieved in 548 ms

2500K entried added/retrieved in 1447 ms

2500K entried added/retrieved in 531 ms

For class java.util.concurrent.ConcurrentHashMap the average time is 792 ms

 


 

這個就不用廢話了,CHM效能是明顯優於Hashtable和SynchronizedMap的,CHM花費的時間比前兩個的一半還少,哈哈,以後再有人問就可以甩資料了。