Java 集合就這麼簡單-附面試題

集合有兩個大接口:Collection 和 Map,本文重點來講解集合中另一個常用的集合類型 Map。

以下是 Map 的繼承關係圖:

enter image description here

Map 簡介

Map 常用的實現類如下:

  • Hashtable:Java 早期提供的一個哈希表實現,它是線程安全的,不支持 null 鍵和值,因爲它的性能不如 ConcurrentHashMap,所以很少被推薦使用。

  • HashMap:最常用的哈希表實現,如果程序中沒有多線程的需求,HashMap 是一個很好的選擇,支持 null 鍵和值,如果在多線程中可用 ConcurrentHashMap 替代。

  • TreeMap:基於紅黑樹的一種提供順序訪問的 Map,自身實現了 key 的自然排序,也可以指定 Comparator 來自定義排序。

  • LinkedHashMap:HashMap 的一個子類,保存了記錄的插入順序,可在遍歷時保持與插入一樣的順序。

Map 常用方法

常用方法包括:put、remove、get、size 等,所有方法如下圖:

enter image description here

使用示例,請參考以下代碼:

Map hashMap = new HashMap();
// 增加元素
hashMap.put("name", "老王");
hashMap.put("age", "30");
hashMap.put("sex", "你猜");
// 刪除元素
hashMap.remove("age");
// 查找單個元素
System.out.println(hashMap.get("age"));
// 循環所有的 key
for (Object k : hashMap.keySet()) {
    System.out.println(k);
}
// 循環所有的值
for (Object v : hashMap.values()) {
    System.out.println(v);
}

以上爲 HashMap 的使用示例,其他類的使用也是類似。

HashMap 數據結構

HashMap 底層的數據是數組被成爲哈希桶,每個桶存放的是鏈表,鏈表中的每個節點,就是 HashMap 中的每個元素。在 JDK 8 當鏈表長度大於等於 8 時,就會轉成紅黑樹的數據結構,以提升查詢和插入的效率。

HashMap 數據結構,如下圖:

enter image description here

HashMap 重要方法

1)添加方法:put(Object key, Object value)

執行流程如下:

  • 對 key 進行 hash 操作,計算存儲 index;

  • 判斷是否有哈希碰撞,如果沒碰撞直接放到哈希桶裏,如果有碰撞則以鏈表的形式存儲;

  • 判斷已有元素的類型,決定是追加樹還是追加鏈表,當鏈表大於等於 8 時,把鏈表轉換成紅黑樹;

  • 如果節點已經存在就替換舊值;

  • 判斷是否超過閥值,如果超過就要擴容。

源碼及說明:

public V put(K key, V value) {
    // 對 key 進行 hash()
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
  // 對 key 進行 hash() 的具體實現
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // tab爲空則創建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 計算 index,並對 null 做處理
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 節點存在
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            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) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 寫入
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 超過load factor*current capacity,resize
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

put() 執行流程圖如下:

enter image description here

2)獲取方法:get(Object key)

執行流程如下:

  • 首先比對首節點,如果首節點的 hash 值和 key 的 hash 值相同,並且首節點的鍵對象和 key 相同(地址相同或 equals 相等),則返回該節點;

  • 如果首節點比對不相同、那麼看看是否存在下一個節點,如果存在的話,可以繼續比對,如果不存在就意味着 key 沒有匹配的鍵值對。

源碼及說明:

public V get(Object key) {
  Node<K,V> e;
  return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 該方法是 Map.get 方法的具體實現
* 接收兩個參數
* @param hash key 的 hash 值,根據 hash 值在節點數組中尋址,該 hash 值是通過 hash(key) 得到的
* @param key key 對象,當存在 hash 碰撞時,要逐個比對是否相等
* @return 查找到則返回鍵值對節點對象,否則返回 null
*/
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) {
        if (first.hash == hash && // 首先比對首節點,如果首節點的 hash 值和 key 的 hash 值相同,並且首節點的鍵對象和 key 相同(地址相同或 equals 相等),則返回該節點
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first; // 返回首節點
 
        // 如果首節點比對不相同、那麼看看是否存在下一個節點,如果存在的話,可以繼續比對,如果不存在就意味着 key 沒有匹配的鍵值對    
        if ((e = first.next) != null) {
            // 如果存在下一個節點 e,那麼先看看這個首節點是否是個樹節點
            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)))) // 比對時還是先看 hash 值是否相同、再看地址或 equals
                    return e; // 如果當前節點e的鍵對象和key相同,那麼返回 e
            } while ((e = e.next) != null); // 看看是否還有下一個節點,如果有,繼續下一輪比對,否則跳出循環
        }
    }
    return null; // 在比對完了應該比對的樹節點 或者全部的鏈表節點 都沒能匹配到 key,那麼就返回 null

相關面試題

1.Map 常見實現類有哪些?

答:Map 的常見實現類如下列表:

  • Hashtable:Java 早期提供的一個哈希表實現,它是線程安全的,不支持 null 鍵和值,因爲它的性能不如 ConcurrentHashMap,所以很少被推薦使用;

  • HashMap:最常用的哈希表實現,如果程序中沒有多線程的需求,HashMap 是一個很好的選擇,支持 null 鍵和值,如果在多線程中可用 ConcurrentHashMap 替代;

  • TreeMap:基於紅黑樹的一種提供順序訪問的 Map,自身實現了 key 的自然排序,也可以指定的 Comparator 來自定義排序;

  • LinkedHashMap:HashMap 的一個子類,保存了記錄的插入順序,可在遍歷時保持與插入一樣的順序。

2.使用 HashMap 可能會遇到什麼問題?如何避免?

答:HashMap 在併發場景中可能出現死循環的問題,這是因爲 HashMap 在擴容的時候會對鏈表進行一次倒序處理,假設兩個線程同時執行擴容操作,第一個線程正在執行 B→A 的時候,第二個線程又執行了 A→B ,這個時候就會出現 B→A→B 的問題,造成死循環。
解決的方法:升級 JDK 版本,在 JDK 8 之後擴容不會再進行倒序,因此死循環的問題得到了極大的改善,但這不是終極的方案,因爲 HashMap 本來就不是用在多線程版本下的,如果是多線程可使用 ConcurrentHashMap 替代 HashMap。

3.以下說法正確的是?

A:Hashtable 和 HashMap 都是非線程安全的
B:ConcurrentHashMap 允許 null 作爲 key
C:HashMap 允許 null 作爲 key
D:Hashtable 允許 null 作爲 key
答:C
題目解析:Hashtable 是線程安全的,ConcurrentHashMap 和 Hashtable 是不允許 null 作爲鍵和值的。

4.TreeMap 怎麼實現根據 value 值倒序?

答:使用 Collections.sort(list, new Comparator<Map.Entry<String, String>>() 自定義比較器實現,先把 TreeMap 轉換爲 ArrayList,在使用 Collections.sort() 根據 value 進行倒序,完整的實現代碼如下。

TreeMap<String, String> treeMap = new TreeMap();
treeMap.put("dog", "dog");
treeMap.put("camel", "camel");
treeMap.put("cat", "cat");
treeMap.put("ant", "ant");
// map.entrySet() 轉成 List
List<Map.Entry<String, String>> list = new ArrayList<>(treeMap.entrySet());
// 通過比較器實現比較排序
Collections.sort(list, new Comparator<Map.Entry<String, String>>() {
  public int compare(Map.Entry<String, String> m1, Map.Entry<String, String> m2) {
    return m2.getValue().compareTo(m1.getValue());
  }
});
// 打印結果
for (Map.Entry<String, String> item : list) {
  System.out.println(item.getKey() + ":" + item.getValue());
}

程序執行結果:

dog:dogcat:catcamel:camelant:ant

5.以下哪個 Set 實現了自動排序?

A:LinedHashSet
B:HashSet
C:TreeSet
D:AbstractSet

答:C

6.以下程序運行的結果是什麼?

Hashtable hashtable = new Hashtable();
hashtable.put("table", null);
System.out.println(hashtable.get("table"));

答:程序執行報錯:java.lang.NullPointerException。Hashtable 不允許 null 鍵和值。

7.HashMap 有哪些重要的參數?用途分別是什麼?

答:HashMap 有兩個重要的參數:容量(Capacity)和負載因子(LoadFactor)。

  • 容量(Capacity):是指 HashMap 中桶的數量,默認的初始值爲 16。

  • 負載因子(LoadFactor):也被稱爲裝載因子,LoadFactor 是用來判定 HashMap 是否擴容的依據,默認值爲 0.75f,裝載因子的計算公式 = HashMap 存放的 KV 總和(size)/ Capacity。

8.HashMap 和 Hashtable 有什麼區別?

答:HashMap 和 Hashtable 區別如下:

  • Hashtable 使用了 synchronized 關鍵字來保障線程安全,而 HashMap 是非線程安全的;

  • HashMap 允許 K/V 都爲 null,而 Hashtable K/V 都不允許 null;

  • HashMap 繼承自 AbstractMap 類;而 Hashtable 繼承自 Dictionary 類。

9.什麼是哈希衝突?

答:當輸入兩個不同值,根據同一散列函數計算出相同的散列值的現象,我們就把它叫做碰撞(哈希碰撞)。

10.有哪些方法可以解決哈希衝突?

答:哈希衝突的常用解決方案有以下 4 種。

  • 開放定址法:當關鍵字的哈希地址 p=H(key)出現衝突時,以 p 爲基礎,產生另一個哈希地址 p1,如果 p1 仍然衝突,再以 p 爲基礎,產生另一個哈希地址 p2,循環此過程直到找出一個不衝突的哈希地址,將相應元素存入其中。

  • 再哈希法:這種方法是同時構造多個不同的哈希函數,當哈希地址 Hi=RH1(key)發生衝突時,再計算 Hi=RH2(key),循環此過程直到找到一個不衝突的哈希地址,這種方法唯一的缺點就是增加了計算時間。

  • 鏈地址法:這種方法的基本思想是將所有哈希地址爲 i 的元素構成一個稱爲同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第 i 個單元中,因而查找、插入和刪除主要在同義詞鏈中進行。鏈地址法適用於經常進行插入和刪除的情況。

  • 建立公共溢出區:將哈希表分爲基本表和溢出表兩部分,凡是和基本表發生衝突的元素,一律填入溢出表。

11.HashMap 使用哪種方法來解決哈希衝突(哈希碰撞)?

答:HashMap 使用鏈表和紅黑樹來解決哈希衝突,詳見本文 put() 方法的執行過程。

12.HashMap 的擴容爲什麼是 2^n ?

答:這樣做的目的是爲了讓散列更加均勻,從而減少哈希碰撞,以提供代碼的執行效率。

13.有哈希衝突的情況下 HashMap 如何取值?

答:如果有哈希衝突,HashMap 會循環鏈表中的每項 key 進行 equals 對比,返回對應的元素。相關源碼如下:

do {
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k)))) // 比對時還是先看 hash 值是否相同、再看地址或 equals
        return e; // 如果當前節點 e 的鍵對象和 key 相同,那麼返回 e
} while ((e = e.next) != null); // 看看是否還有下一個節點,如果有,繼續下一輪比對,否則跳出循環

14.以下程序會輸出什麼結果?

class Person {
    private Integer age;
    public boolean equals(Object o) {
        if (o == null || !(o instanceof Person)) {
            return false;
        } else {
            return this.getAge().equals(((Person) o).getAge());
        }
    }
    public int hashCode() {
        return age.hashCode();
    }
    public Person(int age) {
        this.age = age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public Integer getAge() {
        return age;
    }
    public static void main(String[] args) {
        HashMap<Person, Integer> hashMap = new HashMap<>();
        Person person = new Person(18);
        hashMap.put(person, 1);
        System.out.println(hashMap.get(new Person(18)));
    }
}

答:1
題目解析:因爲 Person 重寫了 equals 和 hashCode 方法,所有 person 對象和 new Person(18) 的鍵值相同,所以結果就是 1。

15.爲什麼重寫 equals() 時一定要重寫 hashCode()?

答:因爲 Java 規定,如果兩個對象 equals 比較相等(結果爲 true),那麼調用 hashCode 也必須相等。如果重寫了 equals() 但沒有重寫 hashCode(),就會與規定相違背,比如以下代碼(故意註釋掉 hashCode 方法):

class Person {
    private Integer age;
    public boolean equals(Object o) {
        if (o == null || !(o instanceof Person)) {
            return false;
        } else {
            return this.getAge().equals(((Person) o).getAge());
        }
    }
//    public int hashCode() {
//        return age.hashCode();
//    }
    public Person(int age) {
        this.age = age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public Integer getAge() {
        return age;
    }
    public static void main(String[] args) {
        Person p1 = new Person(18);
        Person p2 = new Person(18);
        System.out.println(p1.equals(p2));
        System.out.println(p1.hashCode() + " : " + p2.hashCode());
    }
}

執行的結果:

true21685669 : 2133927002

如果重寫 hashCode() 之後,執行的結果是:

true18 : 18

這樣就符合了 Java 的規定,因此重寫 equals() 時一定要重寫 hashCode()。

16.HashMap 在 JDK 7 多線程中使用會導致什麼問題?

答:HashMap 在 JDK 7 中會導致死循環的問題。因爲在 JDK 7 中,多線程進行 HashMap 擴容時會導致鏈表的循環引用,這個時候使用 get() 獲取元素時就會導致死循環,造成 CPU 100% 的情況。

17.HashMap 在 JDK 7 和 JDK 8 中有哪些不同?

答:HashMap 在 JDK 7 和 JDK 8 的主要區別如下。

  • 存儲結構:JDK 7 使用的是數組 + 鏈表;JDK 8 使用的是數組 + 鏈表 + 紅黑樹。

  • 存放數據的規則:JDK 7 無衝突時,存放數組;衝突時,存放鏈表;JDK 8 在沒有衝突的情況下直接存放數組,有衝突時,當鏈表長度小於 8 時,存放在單鏈表結構中,當鏈表長度大於 8 時,樹化並存放至紅黑樹的數據結構中。

  • 插入數據方式:JDK 7 使用的是頭插法(先將原位置的數據移到後 1 位,再插入數據到該位置);JDK 8 使用的是尾插法(直接插入到鏈表尾部/紅黑樹)。

總結

通過本文可以瞭解到:

  • Map 的常用實現類 Hashtable 是 Java 早期的線程安全的哈希表實現;

  • HashMap 是最常用的哈希表實現,但它是非線程安全的,可使用 ConcurrentHashMap 替代;

  • TreeMap 是基於紅黑樹的一種提供順序訪問的哈希表實現;

  • LinkedHashMap 是 HashMap 的一個子類,保存了記錄的插入順序,可在遍歷時保持與插入一樣的順序。

HashMap 在 JDK 7 可能在擴容時會導致鏈表的循環引用而造成 CPU 100%,HashMap 在 JDK 8 時數據結構變更爲:數組 + 鏈表 + 紅黑樹的存儲方式,在沒有衝突的情況下直接存放數組,有衝突,當鏈表長度小於 8 時,存放在單鏈表結構中,當鏈表長度大於 8 時,樹化並存放至紅黑樹的數據結構中。

在下方公衆號【架構師修煉】菜單中可自行獲取專屬架構視頻資料,無套路分享,包括不限於 java架構、python系列、人工智能系列、架構系列,以及最新面試、小程序、大前端均無私奉獻,你會感謝我的哈!

往期精選

分佈式數據之緩存技術,一起來揭開其神祕面紗

分佈式數據複製技術,今天就教你真正分身術

數據分佈方式之哈希與一致性哈希,我就是個神算子

分佈式存儲系統三要素,掌握這些就離成功不遠了

想要設計一個好的分佈式系統,必須搞定這個理論

分佈式通信技術之發佈訂閱,乾貨滿滿

分佈式通信技術之遠程調用:RPC

消息隊列Broker主從架構詳細設計方案,這一篇就搞定主從架構

消息中間件路由中心你會設計嗎,不會就來學學

消息隊列消息延遲解決方案,跟着做就行了

秒殺系統每秒上萬次下單請求,我們該怎麼去設計

【分佈式技術】分佈式系統調度架構之單體調度,非掌握不可

CDN加速技術,作爲開發的我們真的不需要懂嗎?

煩人的緩存穿透問題,今天教就你如何去解決

分佈式緩存高可用方案,我們都是這麼幹的

每天百萬交易的支付系統,生產環境該怎麼設置JVM堆內存大小

你的成神之路我已替你鋪好,沒鋪你來捶我

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