java系列【HashMap讓你起飛的一篇】(面試必問)

前言

Hello,大家好!今天的分享的是面試中常問到的集合內容,其中HashMap是集合中的重點,如果是對HashMap感興趣可以直接到跳到這【我是HashMap】,希望這篇內容能給你帶來收穫😀

先上個java集合體系圖(簡略版)
在這裏插入圖片描述

其實,java中的集合分爲存儲單列數據的集合和存儲鍵和值這樣的雙列數據的集合

存儲單列數據的集合:

其中就有list和set,list有序允許重複set無序不允許重複

首先說一下list的家族

ArrayListLinkedListVector就是list的三大實現類

ArrayList

基於數組、有序的、允許重複的list集合,但是線程不同步

LinkedList

基於雙向鏈表,有序,允許重複的list集合,但是線程不同步

Vector

基於數組、有序的、允許重複的list集合,並且線程同步

三者的區別

  • ArrayListVector底層用的數組LinkedList使用的是鏈表`,
  • 數組的優點是查詢指定的比較快,因爲有索引,而增刪改比較慢,因爲數組在內存中是一塊連續的數據集合,每次刪除和添加都會影響索引,所以性能上弱點兒
  • 鏈表是當前元素中存放下一個或者上一個的元素的地址,但是如果你查詢時候需要從開頭一個一個的找,效率比較低,而插入時不需要移動內存,只需要改變引用指向即可,所以鏈表增刪改效率高
  • Vector是線程同步的,其他兩個線程不同步
  • ArrayList每次擴容是其大小的1.5倍,而Vector是其大小的2倍

再來說一下set的家族

HashSetTreeSetLinkedHashSet是set集合的主要三個實現類

  • 但要知道一點的就是如果你把map集合搞懂了,set也就迎刃而解了,因爲這三個其實底層都用到了map集合的實現類
  • HashSet對應着HashMap、TreeSet對應着TreeMap、LinkedHashSet對應LinkedHashMap

HashSet

  • 基於HashMap的Set集合,無序不允許重複且線程是非同步的,底層是哈希表(數組+單鏈表)結構。
  • 保證元素唯一性的原理:判斷元素的hashcode值是否相同。如果相同,還會繼續判斷元素的equals方法,是否爲true
  • HashSet的add()就是往一個HashMap裏面put(),只是key一直不同,而value是一直相同的就是上面的那個僞值PRESENT允許有null值
  • HashSet的值是存儲在一個HashMap的key裏面,而HashMap的key是不能重複的;詳細的請看下面的map集合

LinkedHashSet

  • 基於LinkedHashMap的Set集合,不允許重複底層是哈希表和雙鏈表結構
  • 本身就繼承HashSet,所以它也根據元素hashCode值來決定元素存儲位置,但它同時使用雙鏈表維護元素的次序,這樣使得元素看起來是以插入的順序保存的

TreeSet

  • 基於TreeMap並且不重複的set集合,底層就是二叉樹結構 (即紅黑樹結構,也是一種自平衡的二叉樹),注意底層沒有hash算法
  • 可以對set集合中的元素進行排序,如果是引用數據類型一定要實現Comparable接口重寫compareTo方法,不然會拋出異常。
  • comareTo返回正數,負數,0,都代表着結果爲 正序,倒序,和只有根元素
  • 不重複或者元素唯一性就是 compareTo 方法結果爲0,

這裏面就要說下兩種排序方式了:
1、讓元素自身具有比較性(自然排序): 元素實現Comparable接口,重寫compareTo方法

2、讓容器自身具備比較性(比較器): 定義類去實現Comparator接口,重寫compare(Object,Object)方法,然後作爲TreeSet的構造參數

那他倆之間的區別是什麼嘞?

  • 自然排序 因爲實現了Comparable接口,重寫方法後,這些元素就由內部的排序規則了,比入Integer,String等等
  • 比較器 則是這種自身的排序規則不是我想要的,我想要創建自己的排序規則,但是String是改不了的,所以就有了Comparator接口,來創建外部排序規則

存儲鍵和值的集合

Map 集合可謂是java中相當重要的一個角色了,他涵蓋了list和set,其中最熟悉的就是HashMap了,不過還有Hashtable,TreeMap,LinkedHashMap,當然還有ConCurrentHashMap這個角色,聽我11道來

TreeMap

TreeMap底層是二叉樹(紅黑樹)線程不同步。可以用於給map集合中的鍵進行排序,上面的TreeSet底層就是用的TreeMap,只不過TreeMap是一個K-V集合

Hashtable

Hashtable底層是哈希表數據結構 (數組+鏈表)無序的不可以存入null鍵和null值,線程同步

ConCurrentHashMap

JDK1.7底層實現:

  • 首先將數據分爲一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據時,其他段的數據也能被其他線程訪問。
  • 在JDK1.7中,ConcurrentHashMap採用Segment + HashEntry的方式進行實現,結構如下:
    在這裏插入圖片描述
  • 一個 ConcurrentHashMap 裏包含一個 Segment 數組。Segment 的結構和HashMap類似,是一種數組和鏈表結構,一個 Segment 包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素,每個 Segment 守護着一個HashEntry數組裏的元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment的鎖。
  • 該類包含兩個靜態內部類 HashEntry 和 Segment ;前者用來封裝映射表的鍵值對,後者用來充當鎖的角色;
  • Segment 是一種可重入的鎖 ReentrantLock,每個 Segment 守護一個HashEntry 數組裏得元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment 鎖。

HashMap

  • HashMap底層是哈希表數據結構 (數組+鏈表)無序的可以存入null鍵和null值,線程非同步,jdk1.8 HashMap底層又加入了紅黑樹(why?
  • 因爲HashMap爲面試中重點,這裏多介紹介紹,先附上HashMap的部分源碼圖

// 數組的默認初始長度,java規定hashMap的數組長度必須是2的次方
// 擴展長度時也是當前長度 << 1。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 數組的最大長度
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默認負載因子,當元素個數超過這個比例則會執行數組擴充操作。
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 樹形化閾值,當鏈表節點個大於等於TREEIFY_THRESHOLD - 1時,
// 會將該鏈表換成紅黑樹。
static final int TREEIFY_THRESHOLD = 8;

// 解除樹形化閾值,當鏈表節點小於等於這個值時,會將紅黑樹轉換成普通的鏈表。
static final int UNTREEIFY_THRESHOLD = 6;

// 最小樹形化的容量,即:當內部數組長度小於64時,不會將鏈表轉化成紅黑樹,而是優先擴充數組。
static final int MIN_TREEIFY_CAPACITY = 64;

// 這個就是hashMap的內部數組了,而Node則是鏈表節點對象。
transient Node<K,V>[] table;

// 下面三個容器類成員,作用相同,實際類型爲HashMap的內部類KeySet、Values、EntrySet。
// 他們的作用並不是緩存所有的key或者所有的value,內部並沒有持有任何元素。
// 而是通過他們內部定義的方法,從三個角度(視圖)操作HashMap,更加方便的迭代。
// 關注點分別是鍵,值,映射。
transient Set<K>        keySet;  // AbstractMap的成員
transient Collection<V> values; // AbstractMap的成員
transient Set<Map.Entry<K,V>> entrySet;

// 元素個數,注意和內部數組長度區分開來。
transient int size;

// 再上一篇文章中說過,是容器結構的修改次數,fail-fast機制。
transient int modCount;

// 閾值,超過這個值時擴充數組。 threshold = capacity * loadfactor,具體看上面的靜態常量。
int threshold;

// 裝在因子,具體看上面的靜態常量。
final float loadFactor;

  • 沒有閱讀過源碼的同學可能慌了,沒事~跟着博主走,什麼都會有
  • 根據這個代碼圖,然後給大家溜下HashMap的運行流程和一些內部機制,可能有的地方不是很對,請大家諒解
  • 其實上面就是HashMap1.8版本的 一些重要的成員變量
  • 都知道HashMap底層是數組和鏈表,這個數組 Node<K,V>[] table的初始容量爲16,而容量是以2的次方擴充的爲什麼呢?,一是爲了提高性能使用足夠大的數組,二是爲了能使用位運算代替取模預算效率更高
  • 數組是否需要擴充是通過負載因子判斷的,如果當前元素個數爲數組容量的0.75時,就會擴充數組。這個0.75就是默認的負載因子,可由構造傳入。我們也可以設置大於1的負載因子,這樣數組就不會擴充,犧牲性能,節省內存。
  • 爲了解決碰撞,數組中的元素是單向鏈表類型。當鏈表長度到達一個閾值時(7或8),會將鏈表轉換成紅黑樹提高性能。而當鏈表長度縮小到另一個閾值時(6),又會將紅黑樹轉換回單向鏈表提高性能,這裏是一個平衡點。
  • 對於第三點補充說明,檢查鏈表長度轉換成紅黑樹之前,還會先檢測當前數組數組是否到達一個閾值(64),如果沒有到達這個容量,會放棄轉換,先去擴充數組。所以上面也說了鏈表長度的閾值是7或8,因爲會有一次放棄轉換的操作。
  • HashMap的原理:因爲是Hash表結構,Hash表的做法其實很簡單,就是把Key通過一 個固定的算法函數既所謂的哈希函數轉換成一個整型數字,然後就將該數字對數組長度進行取餘,取餘結果就當作數組的下標,將value存儲在以該數字爲下標 的數組空間裏,而當使用哈希表進行查詢的時候,就是再次使用哈希函數將key轉換爲對應的數組下標,並定位到該空間獲取value,如此一來,就可以充分利用到數組的定位性能進行數據定位
  • 然後根據put()get()這兩個重要的方法來具體分析一下(1.8)

put方法底層實現及分析

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab;
        Node<K,V> p;
        int n, i;
        //判斷當前桶是否爲空
        if ((tab = table) == null || (n = tab.length) == 0)
            //空的就需要初始化(resize 中會判斷是否進行初始化)。
            n = (tab = resize()).length;
        //根據當前 key 的 hashcode 定位到具體的桶中並判斷是否爲空,
        if ((p = tab[i = (n - 1) & hash]) == null)
        	//爲空表明沒有 Hash 衝突就直接在當前位置創建一個新桶即可。    
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //如果當前桶有值( Hash 衝突),那麼就要比較當前桶中的 key、key 的 hashcode 與寫入的 key 是否相等,
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //相等就賦值給 e,然後統一返回。
                e = p;
            //如果當前桶爲紅黑樹,
            else if (p instanceof TreeNode)
            	//那就要按照紅黑樹的方式寫入數據。
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //如果是個鏈表,
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                    	//就需要將當前的 key、value 封裝成一個新節點寫入到當前桶的後面(形成鏈表)。
                        p.next = newNode(hash, key, value, null);
                        //接着判斷當前鏈表的大小是否大於預設的閾值,大於時就要轉換爲紅黑樹。
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果在遍歷過程中找到 key 相同時直接退出遍歷。
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果 e != null 就相當於存在相同的 key,那就需要將值覆蓋。
            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;
    }

總結一下:

  1. 判斷當前桶是否爲空,空的就需要初始化(resize 中會判斷是否進行初始化)。
  2. 根據當前 key 的 hashcode 定位到具體的桶中並判斷是否爲空,爲空表明沒有 Hash 衝突就直接在當前位置創建一個新桶即可。
  3. 如果當前桶有值( Hash 衝突),那麼就要比較當前桶中的 key、key 的 hashcode 與寫入的 key 是否相等,相等就賦值給 e,在第 8 步的時候會統一進行賦值及返回。
  4. 如果當前桶爲紅黑樹,那就要按照紅黑樹的方式寫入數據。
  5. 如果是個鏈表,就需要將當前的 key、value 封裝成一個新節點寫入到當前桶的後面(形成鏈表)。
  6. 接着判斷當前鏈表的大小是否大於預設的閾值,大於時就要轉換爲紅黑樹。
  7. 如果在遍歷過程中找到 key 相同時直接退出遍歷。
  8. 如果 e != null 就相當於存在相同的 key,那就需要將值覆蓋。
  9. 最後判斷是否需要進行擴容。

get方法底層實現及分析

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;
        //首先將 key hash 之後取得所定位的桶。如果桶爲空則直接返回 null 
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否爲查詢的 key,是就直接返回 value。
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //如果第一個不匹配,
            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;
    }

總結一下:

  1. 首先將 key hash 之後取得所定位的桶。
  2. 如果桶爲空則直接返回 null 。
  3. 否則判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否爲查詢的 key,是就直接返回 value。
  4. 如果第一個不匹配,則判斷它的下一個是紅黑樹還是鏈表。
  5. 紅黑樹就按照樹的查找方式返回值。
  6. 不然就按照鏈表的方式遍歷匹配返回值。

接下來給大家說幾個面試中經常問到有關HashMap的問題

那麼HashMap的容量爲什麼要取2的n次冪

  • 因爲如果是2的n次冪 那數據應該是這樣的
    二進制 :10 / 100 / 1000 / 10000 / 100000 / 1000000 / …
    十進制 :2 / 4 / 8 / 16 / 32 / 64 / …
  • 而當他們減1的時候二進制結果是這樣的
    01 / 011 / 0111 / 01111 / 011111 / 0111111 / …
  • 而我門看到的HashMap源碼中計算或分配數據在數組中的位置用的是hash & (n - 1),其實等價於hash % n
  • &這個符號是位運算中的位與,只有當對應位置的數據都爲1時,運算結果也爲1,看如下:
// 例子:就用HashMap默認的初始容量 16 來說  hash  & (16 - 1) ==》 hash % 16
16的二進制爲:1000010000 - 1 =  1111
那結過就成: hash & 1111  ;
這樣能快速得到 hash值二進制的後四位,這後四位就是餘數
  • 總結下:計算機中直接求餘 a % b效率不如位移運算 a &(b - 1),但是這需要b是2的n次冪纔有效,所以爲了存取高效,要儘量較少碰撞,HashMap就這樣做了

HashMap8爲什麼引入紅黑樹

  • 看一下HashMap1.7版本的底層結構圖吧,
    在這裏插入圖片描述
    數組+單鏈表;這其中其實就能看出來當 Hash 衝突嚴重時,這個鏈表會變的很長的話,這樣在查詢時的效率就會越來越低;時間複雜度爲 O(N),所以1.8就引入了紅黑樹。
    在這裏插入圖片描述
  • 那有的就會問了,既然紅黑樹這麼好,爲什麼HashMap不直接採用紅黑樹,而用鏈表呢?
  • 因爲紅黑樹需要進行左旋,右旋操作, 而單鏈表不需要,
    以下都是單鏈表與紅黑樹結構對比。
    如果元素小於8個,紅黑樹查詢成本高,新增成本低
    如果元素大於8個,紅黑樹查詢成本低,新增成本高

hashcode 和 equals的區別

hashCode()方法和equal()方法的作用其實一樣,在Java裏都是用來對比兩個對象是否相等一致,那麼equal()既然已經能實現對比的功能了,爲什麼還要hashCode()呢?

因爲重寫的equal()裏一般比較的比較全面比較複雜,這樣效率就比較低,而利用hashCode()進行對比,則只要生成一個hash值進行比較就可以了,效率很高,那麼hashCode()既然效率這麼高爲什麼還要equal()呢?

  • equal()相等的兩個對象他們的hashCode()肯定相等
  • hashCode()相等的兩個對象他們的equal()不一定相等

所以對於需要大量並且快速的對比的話如果都用equal()去做顯然效率太低,解決方式是,每當需要對比的時候,首先用hashCode()去對比,如果hashCode()不一樣,則表示這兩個對象肯定不相等(也就是不必再用equal()去再對比了),如果hashCode()相同,此時再對比他們的equal(),如果equal()也相同,則表示這兩個對象是真的相同了,這樣既能大大提高了效率也保證了對比的絕對正確性!那麼再回過頭來看看HashMap源碼是不是更清晰了呢

爲什麼 HashMap 中 String、Integer 這樣的包裝類適合作爲 key 鍵

  • String,Integer等這些類都被final修飾,具有不變性;也保證了key的不變性,並且內部重寫了equals和hashCode方法,不容易出現hash計算錯誤等。
  • 所以String,Integer等包裝類的特性保證了Hash值的不可變性和準確性有效減少了hash碰撞
  • 所以作爲HashMap的key一定要重寫equals和hashCode方法

HashMap1.7中的死循環問題

HashMap1.7中的擴容機制實際上就是用一個新的2倍長度的數組來替代舊的數組,使用具體就是resize方法,裏面調用的是transfer方法,這個方法用來轉移數組數據,採用的就是頭插法,但是存在一個問題:併發操作容易在一個桶上形成環形鏈表;這樣當獲取一個不存在的 key 時,計算出的 index 正好是環形鏈表的下標就會出現死循環。

結言:最後,感謝你能夠看到這裏,相信你也是拼搏道路上的追夢人,也歡迎評論區留言 ,喜歡博文可以給個👍呦,更多文章在這裏【jar殼蟲】,後續會有更多分享哦🎈🎈

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章