Java集合(5)一 HashMap與HashSet

引言

HashMap<K,V>和TreeMap<K,V>都是從鍵映射到值的一組對象,不一樣的是,HashMap<K,V>是無序的,而TreeMap<K,V>是有序的,相應的他們在數據結構上區別也很大。java

HashMap<K,V>在鍵的數據結構上採用了數組,而值在數據結構上採用了鏈表或紅黑樹這兩種數據結構。 HashSet<K,V>同HashMap<K,V>的關係與TreeSet<E>同TreeMap<K,V>的關係相似,在內部實現上也是使用了HashMap<K,V>的鍵集,這點咱們一樣經過HashSet<K,V>的構造函數能夠發現。因此在文章中只會詳細解說HashMap<K,V>,對HashSet<K,V>就不作分析。node

public HashSet() {
    map = new HashMap<>();
}

public HashSet(int initialCapacity) {
    map = new HashMap<>(initialCapacity);
}
複製代碼

框架結構

HashMap<K,V>在繼承結構上和TreeMap<K,V>相似,都繼承自AbstractMap<K,V>,同時也都實現了Map<K,V>接口,因此在功能上區別不大,不一樣的是實現功能的底層數據結構。同時因爲HashMap<K,V>是無序的,沒有繼承自SortedMap<K,V>,相應的少了一些根據順序查找的功能。 算法

哈希

在分析HashMap<K,V>的具體實現以前,先來看下什麼是哈希? 哈希又叫「散列」,是將任意對象或者數據根據指定的哈希算法運算以後,輸出一段固定長度的數據,經過這段固定長度的數據來做爲這個對象或者數據的特徵,這就是哈希。這句話可能比較繞口,舉個例子。數組

在一篇文章中有10000個單詞,須要查找這10000個單詞中是否存在「hello」這個單詞,最直觀的辦法固然是遍歷這個數組,每一個單詞跟「hello」進行比較,最壞的狀況下可能要比較10000次才能找到須要的結果,若是這個數組無限大,那要比較的次數就會無限上升。那有沒有更快速的查找途徑呢? 答案就是哈希表。首先將這10000個單詞根據一種指定的哈希算法計算出每一個單詞的哈希值,而後將這些哈希值映射到一個長度爲100的數組內,若是映射足夠均勻的話大概數組的每一個值對應100個單詞,這樣咱們在查找的時候只須要計算出「hello」的哈希值對應在數組中的索引,而後遍歷這個位置中對應的100個單詞便可。當映射的數組足夠大,好比10000,哈希算法足夠好,映射一對一,每一個哈希值都不相同,這樣理論上最優能夠在一次查找就得道想到的結果,最壞的查找次數就是數組的每一個位置所對應的單詞數。這樣相比較直接遍歷數組要快速的多。數據結構

哈希能夠大大提升查找指定元素的效率,但受限於哈希算法的好壞。一個好的哈希算法能夠將元素均勻分佈在固定長度的數組中,相應的若是算法不夠好,對性能就會產生很大影響。app

那有沒有一個算法可讓任意一個給定的元素,都輸出一個惟一的哈希值呢?答案是暫時沒有發現這樣的算法。若是不能每一個元素都對應到一個惟一的哈希值,就會產生多個元素對應到一個哈希值的狀況,這種狀況就叫「哈希衝突」。框架

哈希衝突

下圖中經過一個簡單的哈希算法,每一個單詞取首字母哈希時,air和airport哈希值同樣就產生了哈希衝突。 仍是用以前的例子,當10000個單詞存放於一個長度爲100的數組中時,若是哈希算法足夠好,單詞分佈的足夠均勻,每一個哈希值就會對應100個左右的元素,也就是每一個位置會發生100次左右的哈希衝突。儘管咱們能夠經過提升數組長度來減少衝突的機率,好比將100變爲10000,這樣有可能會一個元素對應一個哈希值。但若是須要存儲的單詞量足夠大的狀況下,不管數組多大均可能不夠用,同時不少時候內存或者硬盤也不可能無限擴大。哈希算法也不能保證2個不一樣元素的哈希值必定不相同,這時哈希衝突就不可避免,就須要想辦法來解決哈希衝突。 通常解決哈希衝突有兩種通用的辦法:拉鍊法和開放定址法。 拉鍊法顧名思義就是將同一位置出現衝突的全部元素組成一個鏈表,每出現一次衝突,就將新的元素放置在鏈表末尾。當經過元素的哈希值查找到指定位置時會返回一個鏈表,再經過循環鏈表來查找徹底相等的元素。 開放定址法就是當衝突出現時,直接去尋找下一個空的散列地址,將值存入其中便可。當散列數組足夠大,總會有空的地址,空地址不夠用時,能夠擴大數組容量。 在HashMap<K,V>中使用的是第一種的拉鍊法。函數

構造函數

在HashMap<K,V>中有幾個重要字段。 Node<K,V>[] table,這個數組用來存儲哈希值以及哈希值對應的元素,又叫哈希桶數組。 loadFactor是默認的填充因子,當哈希桶數組中存儲的元素達到填充因子乘以哈希桶數組總大小時就須要擴大哈希桶數組的容量。好比桶數組長度爲16當存儲的數量達到16*0.75=12時則要擴大哈希桶數組的容量。通常取默認的填充因子DEFAULT_LOAD_FACTOR = 0.75,不須要更改。源碼分析

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    //默認填充因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //哈希桶數組
    transient Node<K,V>[] table;

    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);
        this.loadFactor = loadFactor;
        //臨界值(第一次臨界值爲轉換後的容量大小)
        this.threshold = tableSizeFor(initialCapacity);
    }

    //默認填充因子 threshold(第一次臨界值爲轉換後的容量大小)
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    //默認填充因子 threshold臨界值爲0
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
}
複製代碼

在構造函數中有個tableSizeFor方法,這個方法是用來將輸入的容量轉換爲2的整數次冪,這樣不管輸入的數值是多少,咱們都會獲得一個2的整數次冪長度的哈希桶數組。好比輸入13,返回16,輸入120返回128。性能

static final int tableSizeFor(int cap) {
    //避免出現輸入8變成16這種狀況
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    //低位全變爲1以後,進行n + 1能夠將低位全變爲0,獲得2的冪次方
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
複製代碼

經過對輸入的參數001x xxxx xxxx xxxx位移1位後0001 xxxx xxxx xxxx與原值進行或運算,獲得0011 xxxx xxxx xxxx,最高位的1與低一位都變爲1。 位移2位後0000 11xx xxxx xxxx與原值0011 xxxx xxxx xxxx進行或運算,獲得0011 11xx xxxx xxxx,最高2位的1與低2位都變爲1。 位移4位後0000 0011 11xx xxxx與原值0011 11xx xxxx xxxx進行或運算,獲得0011 1111 11xx xxxx,最高4位的1與低4位都變爲1。 位移8位後0000 0000 0011 1111與原值0011 1111 11xx xxxx進行或運算,獲得0011 1111 1111 1111,最高8位的1與低8位都變爲1。 位移16位相似。結果就是從最高位開始全部後面的位都變爲了1。而後n + 1,獲得0100 0000 0000 0000。 能夠看下面的例子: 當輸入13時: 當輸入118時: 這裏要注意n = cap - 1,爲何要對輸入參數減一,是爲了不輸入2的冪次方時容量會翻倍,好比輸入8時若是不進行減一的操做,最終會輸出16,讀者能夠自行測試。

哈希值

那爲何必定要用2的整數次冪來初始化哈希桶數組的長度呢?這就要說到哈希值的計算問題。 在HashMap<K,V>中計算元素的哈希值代碼以下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼

在這段代碼就是用來獲取哈希值的,其中首先獲取了key的hashCode,這個hashCode若是元素有從新實現hashCode函數則會使用本身實現的hashCode,在沒有本身實現時,hashCode函數大部分狀況下會返回元素在內存中的地址,但也不是絕對的,須要根據各個JVM的內在實現來判斷,但大部分實現就算沒直接使用內存地址,也和內存地址存在必定的關聯。

在獲取到key的hashCode以後將hashCode的值的低16位和hashCode的高16位進行異或運算,這就是這個函數很是巧妙的地方,異或運算會同時採用高16位和低16位全部的特徵,這樣就大大增長了低位的隨機性,在取索引的時候tab[(n - 1) & hash],將包含全部特徵的哈希值和哈希桶長度減1進行與運行,能夠獲得哈希桶長度的低位值。

使用2的整數次冪能夠很方便的經過tab[(n - 1) & hash]獲取到哈希桶所須要的低位值,因爲低位和高位進行了異或運算,保留了高低位的特徵,也就減小了哈希值衝突的可能性。這就是爲何這裏會使用2的整數次冪來初始化哈希桶數組長度的緣由。

添加元素

經過HashMap<K,V>在添加元素的過程,能夠發現HashMap<K,V>使用了數組+鏈表+紅黑樹的方式來存儲數據。

當添加元素過程當中出現哈希衝突時會在衝突的位置採用拉鍊法生成一個鏈表來存儲衝突的數據,若是同一位置衝突的數據量大於8則會將哈希桶數組擴容或將鏈表轉換成紅黑樹來存儲數據。同時,在每次添加完數據後,都會檢查哈希桶數據的容量,超出臨界值時會擴容。

對紅黑樹不太理解的能夠查看前兩篇文章。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //哈希桶數組爲空時,經過resize初始化哈希桶數組
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //哈希值所對應的位置爲空,表明不會產生衝突,直接賦值便可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //產生哈希衝突
        Node<K,V> e; K k;
        //若是哈希值相等,而且key也相等,則直接覆蓋值
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //p爲紅黑樹 使用紅黑樹邏輯進行添加(能夠查看TreeMap)
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //p爲鏈表
        else {
            for (int binCount = 0; ; ++binCount) {
                //查找到鏈表末尾未發現相等元素則直接添加到末尾
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //鏈表長度大於8時,擴容哈希桶數組或將鏈表轉換成紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //遍歷鏈表過程當中存在相等元素則直接覆蓋value
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //覆蓋value
        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;
}

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //哈希桶數組小於64則擴容哈希桶數組
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        //將全部Node<K,V>節點類型的鏈表轉換成TreeNode<K,V>節點類型的鏈表
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        //將TreeNode<K,V>鏈表轉換成紅黑樹
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
複製代碼

擴容

添加元素的過程當中,如下2種狀況會出現擴容:單個哈希桶存儲超過8個元素會檢查哈希桶數組,若是整個哈希桶數組容量小於64則會進行擴容;在每次添加完元素後也會檢查整個哈希桶數組容量,超過臨界值也會進行擴容。擴容源碼分析以下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //哈希桶數組已經初始化 則直接向左位移1位 至關於擴容一倍
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //向左位移1位 擴容一倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //哈希桶數組未初始化 而且已經初始化容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //哈希桶數組未初始化 而且未初始化容量 則使用默認容量DEFAULT_INITIAL_CAPACITY
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //初始化哈希桶數組容量以及臨界值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //初始化哈希桶數組
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //將全部元素拷貝到新哈希桶數組中
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //不存在哈希衝突,從新計算哈希值並拷貝
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //衝突結構爲紅黑樹
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //衝突結構爲鏈表
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //擴容後最高位爲0,則不須要移動到新的位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        //擴容後最高位爲1,則須要移動
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //對哈希值改變的節點移動到新的位置
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
複製代碼

在擴容的過程當中,有一個很是巧妙的地方,由於擴容後每一個元素的哈希值須要從新計算並放入新的哈希桶數組中,在哈希值計算的過程當中,因爲是乘以2來擴容的,也就是整數次冪。

這樣在每次擴容後會多使用一位特徵,這樣當多使用的這一位特徵爲0時((e.hash & oldCap) == 0),哈希值實際上是沒有變化的,就不須要移動,這一位特徵爲1時,只須要將位置移動舊的容量大小的便可(newTab[j + oldCap] = hiHead),這樣就能夠減小移動元素的次數。紅黑樹和鏈表結構都是如此。

查找元素

明白HashMap<K,V>的插入以及擴容原理,再來看查找就很是容易理解了,只是簡單的經過在鏈表或者紅黑樹中查找到相等的值便可。

在查找中一個值是不是咱們須要的值,首先是經過hash來判斷,若是hash相等再經過==或者equals來來判斷。

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;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //哈希值相等,而且key也相等,則返回查找到的值
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //哈希值存在衝突,第一個不是要找的key
        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;
}
複製代碼

刪除元素

刪除元素時首先查找到須要的元素,其次根據查找到元素的數據結構來分別進行刪除。

public V remove(Object key) {
    Node<K,V> e;
    //計算哈希值
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        //哈希值相等,而且key也相等,則node查找到的節點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        //哈希值存在衝突,第一個不是要找的key
        else if ((e = p.next) != null) {
            //衝突結構爲紅黑樹
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            //衝突結構爲鏈表
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                            (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                                (value != null && value.equals(v)))) {
            //節點爲紅黑樹節點,按紅黑樹邏輯刪除
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            //節點爲桶中第一個節點
            else if (node == p)
                tab[index] = node.next;
            //節點爲後續節點
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
複製代碼

HashMap的Key

講解完整個HashMap的實現,咱們能夠發現大部分狀況下影響HashMap性能最核心的地方仍是在哈希算法上面。儘管理論上HashMap在添加、刪除和查找上的時間複雜度均可以達到O(1),但在實際應用過程當中還受到不少因素影響,有時候時間複雜度爲O(1)的HashMap可能比,時間複雜度爲O(log n)的TreeMap性能更差,緣由就在哈希算法上面。

若是使用一個對象默認的哈希算法,前面咱們說過,大部分JVM哈希算法的實現都和內存地址有直接關係,爲了減少碰撞的機率,可能哈希算法極其複雜,複雜到影響效率的程度。因此在實際使用過程當中,須要儘可能使用簡單類型來做爲HashMap的Key,好比int,這樣在進行哈希時能夠大大縮短哈希的時間。若是使用本身實現的哈希算法,在使用前須要先測試哈希算法的效率,減少對HashMap性能的影響。

總結

Java集合系列到這裏就結束了,整個系列從集合總體框架說到了幾個經常使用的集合類,固然還有不少沒有說到的地方,好比Queue,Stack,LinkHashMap等等。雖然這是對本身Java學習過程當中的總結,但也但願這個集合系列對你們理解Java集合有必定幫助,若是文章中有錯誤、疑問或者須要完善地方,但願你們不吝指出。接下來打算對java.util.concurrent包下的內容作一個系列進行系統總結,有什麼建議也能夠留言給我。

相關文章
相關標籤/搜索