由多線程引起的map取值為null的分析


昨天寫了一個多線程的程序,卻發現了一個很奇特的問題,就是我的map對象明明put了,可是get的時候竟然會取到null,而且嘗試多次,有時候成功,有時候取到null,並不確定。

程序代碼如下:

public class ThreadLocal {
    private static Map<Thread, Integer> map;

    public static void main(String[] args) {
        map = new HashMap<Thread, Integer>();
        for (int i = 0; i < 2; i++) {
            new Thread(new Runnable() {

                public void run() {
                    int data = new Random().nextInt();
                    map.put(Thread.currentThread(), data);
                    System.out.println(Thread.currentThread() + ", data:"
                            + data);
                    new A().show();
                    new B().show();
                }
            }).start();
        }
    }

    static class A {
        public void show() {
            System.out.println(Thread.currentThread() + "調用A, data:" + map.get(Thread.currentThread()));
        }
    }

    static class B {
        public void show() {
            System.out.println(Thread.currentThread() + "調用B, data:" + map.get(Thread.currentThread()));
        }
    }
}

運行結果如下:

Thread[Thread-0,5,main], data:1164116165
Thread[Thread-1,5,main], data:196549485
Thread[Thread-0,5,main]調用A, data:null
Thread[Thread-1,5,main]調用A, data:196549485
Thread[Thread-0,5,main]調用B, data:null
Thread[Thread-1,5,main]調用B, data:196549485

 

我實在想不明白,我明明已經put了,為什么取不到呢?今天我查了資料,並大概看了看源碼,大致理解了,這是由HashMap的非線程安全特性引起的。具體源碼分析如下所示:

先看看get方法:

public V get(Object key) {  

        if (key == null)  

            return getForNullKey();  

        int hash = hash(key.hashCode());  

        // indexFor方法取得key在table數組中的索引,table數組中的元素是一個鏈表結構,遍歷鏈表,取得對應key的value  

        for (Entry e = table[indexFor(hash, table.length)]; e != null; e = e.next) {  

            Object k;  

            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  

                return e.value;  

        }  

        return null; 
}

再看看put方法:

public V put(K key, V value) {  

        if (key == null)  

            return putForNullKey(value);  

        int hash = hash(key.hashCode());  

        int i = indexFor(hash, table.length);  

        for (Entry e = table[i]; e != null; e = e.next) {  

            Object k;  

            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  

                V oldValue = e.value;  

                e.value = value;  

                e.recordAccess(this);  

                return oldValue;  

            }  

        }  

        modCount++;  

        // 若之前沒有put進該key,則調用該方法  

        addEntry(hash, key, value, i);  

        return null;  
}

那我們再看看addEntry里面的實現:

void addEntry(int hash, K key, V value, int bucketIndex) {  

        Entry e = table[bucketIndex];  

        table[bucketIndex] = new Entry(hash, key, value, e);  

        if (size++ >= threshold)  

            resize(2 * table.length);  
}

map里的元素個數(size)大於一個閾值(threshold)時,map將自動擴容,容量擴大到原來的2倍;
閾值(threshold)是怎么計算的?如下源碼: 

threshold = (int)(capacity * loadFactor);

閾值 = 容量 X 負載因子;容量默認為16,負載因子(loadFactor)默認是0.75; map擴容后,要重新計算閾值;當元素個數大於新的閾值時,map再自動擴容;
以默認值為例,閾值=16*0.75=12,當元素個數大於12時就要擴容;那剩下的4(如果內部形成了Entry鏈則大於4)個數組位置還沒有放置對象就要擴容,豈不是浪費空間了?
這是時間和空間的折中考慮;loadFactor過大時,map內的數組使用率高了,內部極有可能形成Entry鏈,影響查找速度;loadFactor過小時,map內的數組使用率舊低,不過內部不會生成Entry鏈,或者生成的Entry鏈很短,由此提高了查找速度,不過會占用更多的內存;所以可以根據實際硬件環境和程序的運行狀態來調節loadFactor。

繼續resize方法:

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[] newTable = new Entry[newCapacity]; //初始化一個新的Entry數組
transfer(newTable); //!!將數據轉移到新的Entry數組里
table
= newTable; //HashMap的table屬性引用新的Entry數組 threshold = (int) (newCapacity * loadFactor); //修改閾值
}

resize里面重新new一個Entry數組,其容量就是舊容量的2倍,這時候,需要重新根據hash方法將舊數組分布到新的數組中,也就是其中的transfer方法:

void transfer(Entry[] newTable) {  

        Entry[] src = table;  //src引用了舊的Entry數組
int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組
Entry e
= src[j]; //取得舊Entry數組的每個元素
if (e != null) { src[j] = null; //釋放舊Entry數組的對象引用(for循環后,舊的Entry數組不再引用任何對象) do { Entry next = e.next; int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置 e.next = newTable[i]; //標記[1] newTable[i] = e; //將元素放在數組上 e = next; //訪問下一個Entry鏈上的元素
}
while (e != null); } }
}
注釋標記[1]處,將newTable[i]的引用賦給了e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在鏈表的頭部位置;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發生了hash沖突的話);
indexFor()是計算每個元素在數組中的位置,源碼: 
static int indexFor(int h, int length) {
    return h & (length-1); //位AND計算
 }
這樣,在舊數組中同一條Entry鏈上的元素,通過重新計算索引位置后,有可能被放到了新數組的不同位置上;
例如,舊數組容量為16,對象A的hash值是4,對象B的hash值是20,對象C的hash值是36;
通過indexFor()計算后,A、B、C對應的數組索引位置分別為4,4,4; 說明這3個對象在數組的同一位置上,形成了Entry鏈;
舊數組擴容后容量為16*2,重新計算對象所在的位置索引,A、B、C對應的數組索引位置分別為4,20,4; B對象已經被放到別處了;  

所以,resize時,HashMap使用新數組代替舊數組,對原有的元素根據hash值重新就算索引位置,重新安放所有對象;resize是耗時的操作。
 
 
說回null的問題,在這個方法里,將舊數組賦值給src,遍歷src,當src的元素非null時,就將src中的該元素置null,即將舊數組中的元素置null了,也就是這一句:
if (e != null) {  
        src[j] = null;  
}
此時若有get方法訪問這個key,它取得的還是舊數組,當然就取不到其對應的value了。
 
 
下面,我們重現一下場景:
Java代碼 
import java.util.HashMap;  
import java.util.Map;  
public class TestHashMap {  
    public static void main(String[] args) {  
        final Map map = new HashMap(4, 0.5f);  
        new Thread(){  
            public void run() {  
                while(true) {   
                    System.out.println(map.get("name1"));  
                    try {  
                        Thread.sleep(1000);  
                    } catch (InterruptedException e) {  
                        e.printStackTrace();  
                    }  
                }  
            }  
        }.start();  
        for(int i=0; i<3; i++) {  
            map.put("name" + i, "value" + i);  
        }  
Debug上面這段程序,在map.put處設置斷點,然后跟進put方法中,當i=2的時候就會發生resize操作,在transfer將元素置null處停留片刻,此時線程打印的值就變成null了。
 
 
其它可能由未同步HashMap導致的問題:
1、多線程put后可能導致get死循環(主要問題在於put的時候transfer方法循環將舊數組中的鏈表移動到新數組)
2、多線程put的時候可能導致元素丟失(主要問題出在addEntry方法的new Entry(hash, key, value,e),如果兩個線程都同時取得了e,則他們下一個元素都是e,然后賦值給table元素的時候有一個成功有一個丟失)
 
 
總結:HashMap在並發程序中會產生許多微妙的問題,難以從表層找到原因。所以使用HashMap出現了違反直覺的現象,那么可能就是並發導致的了。最簡單的解決辦法就是采用線程安全的HashTable。
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM