第二彈!「Java面試題精華集」1w字的Java集合框架篇(2020最新版)!

本文已經收錄進我的 79K Star 的 Java 開源項目 JavaGuide:https://github.com/Snailclimb/JavaGuide (「Java學習+面試指南」一份涵蓋大部分Java程序員所需要掌握的核心知識。)

相關文章:

  1. 「Java面試題精華集」1w字的Java集合框架篇(2020最新版)附PDF版 !

1. 剖析面試最常見問題之 Java 集合框架

1.1. 集合概述

1.1.1. Java 集合概覽

從下圖可以看出,在 Java 中除了以 Map 結尾的類之外, 其他類都實現了 Collection 接口。

並且,以 Map 結尾的類都實現了 Map 接口。

image

1.1.2. 說說 List,Set,Map 三者的區別?

  • List(對付順序的好幫手): 存儲的元素是有序的、可重複的。
  • Set(注重獨一無二的性質): 存儲的元素是無序的、不可重複的。
  • Map(用 Key 來搜索的專家): 使用鍵值對(kye-value)存儲,類似於數學上的函數 y=f(x),“x”代表 key,"y"代表 value,Key 是無序的、不可重複的,value 是無序的、可重複的,每個鍵最多映射到一個值。

1.1.3. 集合框架底層數據結構總結

先來看一下 Collection 接口下面的集合。

1.1.3.1. List

  • ArraylistObject[]數組
  • VectorObject[]數組
  • LinkedList: 雙向鏈表(JDK1.6 之前爲循環鏈表,JDK1.7 取消了循環)

1.1.3.2. Set

  • HashSet(無序,唯一): 基於 HashMap 實現的,底層採用 HashMap 來保存元素
  • LinkedHashSetLinkedHashSetHashSet 的子類,並且其內部是通過 LinkedHashMap 來實現的。有點類似於我們之前說的 LinkedHashMap 其內部是基於 HashMap 實現一樣,不過還是有一點點區別的
  • TreeSet(有序,唯一): 紅黑樹(自平衡的排序二叉樹)

再來看看 Map 接口下面的集合。

1.1.3.3. Map

  • HashMap: JDK1.8 之前 HashMap 由數組+鏈表組成的,數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的(“拉鍊法”解決衝突)。JDK1.8 以後在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲 8)(將鏈表轉換成紅黑樹前會判斷,如果當前數組的長度小於 64,那麼會選擇先進行數組擴容,而不是轉換爲紅黑樹)時,將鏈表轉化爲紅黑樹,以減少搜索時間
  • LinkedHashMapLinkedHashMap 繼承自 HashMap,所以它的底層仍然是基於拉鍊式散列結構即由數組和鏈表或紅黑樹組成。另外,LinkedHashMap 在上面結構的基礎上,增加了一條雙向鏈表,使得上面的結構可以保持鍵值對的插入順序。同時通過對鏈表進行相應的操作,實現了訪問順序相關邏輯。詳細可以查看:《LinkedHashMap 源碼詳細分析(JDK1.8)》
  • Hashtable: 數組+鏈表組成的,數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的
  • TreeMap: 紅黑樹(自平衡的排序二叉樹)

1.1.4. 如何選用集合?

主要根據集合的特點來選用,比如我們需要根據鍵值獲取到元素值時就選用 Map 接口下的集合,需要排序時選擇 TreeMap,不需要排序時就選擇 HashMap,需要保證線程安全就選用 ConcurrentHashMap

當我們只需要存放元素值時,就選擇實現Collection 接口的集合,需要保證元素唯一時選擇實現 Set 接口的集合比如 TreeSetHashSet,不需要就選擇實現 List 接口的比如 ArrayListLinkedList,然後再根據實現這些接口的集合的特點來選用。

1.1.5. 爲什麼要使用集合?

當我們需要保存一組類型相同的數據的時候,我們應該是用一個容器來保存,這個容器就是數組,但是,使用數組存儲對象具有一定的弊端,
因爲我們在實際開發中,存儲的數據的類型是多種多樣的,於是,就出現了“集合”,集合同樣也是用來存儲多個數據的。

數組的缺點是一旦聲明之後,長度就不可變了;同時,聲明數組時的數據類型也決定了該數組存儲的數據的類型;而且,數組存儲的數據是有序的、可重複的,特點單一。
但是集合提高了數據存儲的靈活性,Java 集合不僅可以用來存儲不同類型不同數量的對象,還可以保存具有映射關係的數據

1.1.6. Iterator 迭代器

1.1.6.1. 迭代器 Iterator 是什麼?

public interface Iterator<E> {
    //集合中是否還有元素
    boolean hasNext();
    //獲得集合中的下一個元素
    E next();
    ......
}

Iterator 對象稱爲迭代器(設計模式的一種),迭代器可以對集合進行遍歷,但每一個集合內部的數據結構可能是不盡相同的,所以每一個集合存和取都很可能是不一樣的,雖然我們可以人爲地在每一個類中定義 hasNext()next() 方法,但這樣做會讓整個集合體系過於臃腫。於是就有了迭代器。

迭代器是將這樣的方法抽取出接口,然後在每個類的內部,定義自己迭代方式,這樣做就規定了整個集合體系的遍歷方式都是 hasNext()next()方法,使用者不用管怎麼實現的,會用即可。迭代器的定義爲:提供一種方法訪問一個容器對象中各個元素,而又不需要暴露該對象的內部細節。

1.1.6.2. 迭代器 Iterator 有啥用?

Iterator 主要是用來遍歷集合用的,它的特點是更加安全,因爲它可以確保,在當前遍歷的集合元素被更改的時候,就會拋出 ConcurrentModificationException 異常。

1.1.6.3. 如何使用?

我們通過使用迭代器來遍歷 HashMap,演示一下 迭代器 Iterator 的使用。


Map<Integer, String> map = new HashMap();
map.put(1, "Java");
map.put(2, "C++");
map.put(3, "PHP");
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
  Map.Entry<Integer, String> entry = iterator.next();
  System.out.println(entry.getKey() + entry.getValue());
}

1.1.7. 有哪些集合是線程不安全的?怎麼解決呢?

我們常用的 Arraylist ,LinkedList,Hashmap,HashSet,TreeSet,TreeMapPriorityQueue 都不是線程安全的。解決辦法很簡單,可以使用線程安全的集合來代替。

如果你要使用線程安全的集合的話, java.util.concurrent 包中提供了很多併發容器供你使用:

  1. ConcurrentHashMap: 可以看作是線程安全的 HashMap
  2. CopyOnWriteArrayList:可以看作是線程安全的 ArrayList,在讀多寫少的場合性能非常好,遠遠好於 Vector.
  3. ConcurrentLinkedQueue:高效的併發隊列,使用鏈表實現。可以看做一個線程安全的 LinkedList,這是一個非阻塞隊列。
  4. BlockingQueue: 這是一個接口,JDK 內部通過鏈表、數組等方式實現了這個接口。表示阻塞隊列,非常適合用於作爲數據共享的通道。
  5. ConcurrentSkipListMap :跳錶的實現。這是一個Map,使用跳錶的數據結構進行快速查找。

1.2. Collection 子接口之 List

1.2.1. Arraylist 和 Vector 的區別?

  1. ArrayList 是 List 的主要實現類,底層使用 Object[ ]存儲,適用於頻繁的查找工作,線程不安全 ;
  2. Vector 是 List 的古老實現類,底層使用 Object[ ]存儲,線程安全的。

1.2.2. Arraylist 與 LinkedList 區別?

  1. 是否保證線程安全: ArrayListLinkedList 都是不同步的,也就是不保證線程安全;
  2. 底層數據結構: Arraylist 底層使用的是 Object 數組LinkedList 底層使用的是 雙向鏈表 數據結構(JDK1.6 之前爲循環鏈表,JDK1.7 取消了循環。注意雙向鏈表和雙向循環鏈表的區別,下面有介紹到!)
  3. 插入和刪除是否受元素位置的影響:ArrayList 採用數組存儲,所以插入和刪除元素的時間複雜度受元素位置的影響。 比如:執行add(E e)方法的時候, ArrayList 會默認在將指定的元素追加到此列表的末尾,這種情況時間複雜度就是 O(1)。但是如果要在指定位置 i 插入和刪除元素的話(add(int index, E element))時間複雜度就爲 O(n-i)。因爲在進行上述操作的時候集合中第 i 和第 i 個元素之後的(n-i)個元素都要執行向後位/向前移一位的操作。 ② LinkedList 採用鏈表存儲,所以對於add(E e)方法的插入,刪除元素時間複雜度不受元素位置的影響,近似 O(1),如果是要在指定位置i插入和刪除元素的話((add(int index, E element)) 時間複雜度近似爲o(n))因爲需要先移動到指定位置再插入。
  4. 是否支持快速隨機訪問: LinkedList 不支持高效的隨機元素訪問,而 ArrayList 支持。快速隨機訪問就是通過元素的序號快速獲取元素對象(對應於get(int index)方法)。
  5. 內存空間佔用: ArrayList 的空 間浪費主要體現在在 list 列表的結尾會預留一定的容量空間,而 LinkedList 的空間花費則體現在它的每一個元素都需要消耗比 ArrayList 更多的空間(因爲要存放直接後繼和直接前驅以及數據)。

1.2.2.1. 補充內容:雙向鏈表和雙向循環鏈表

雙向鏈表: 包含兩個指針,一個 prev 指向前一個節點,一個 next 指向後一個節點。

另外推薦一篇把雙向鏈表講清楚的文章:https://juejin.im/post/5b5d1a9af265da0f47352f14

雙向鏈表

雙向循環鏈表: 最後一個節點的 next 指向 head,而 head 的 prev 指向最後一個節點,構成一個環。

雙向循環鏈表

1.2.2.2. 補充內容:RandomAccess 接口

public interface RandomAccess {
}

查看源碼我們發現實際上 RandomAccess 接口中什麼都沒有定義。所以,在我看來 RandomAccess 接口不過是一個標識罷了。標識什麼? 標識實現這個接口的類具有隨機訪問功能。

binarySearch() 方法中,它要判斷傳入的 list 是否 RamdomAccess 的實例,如果是,調用indexedBinarySearch()方法,如果不是,那麼調用iteratorBinarySearch()方法

    public static <T>
    int binarySearch(List<? extends Comparable<? super T>> list, T key) {
        if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
            return Collections.indexedBinarySearch(list, key);
        else
            return Collections.iteratorBinarySearch(list, key);
    }

ArrayList 實現了 RandomAccess 接口, 而 LinkedList 沒有實現。爲什麼呢?我覺得還是和底層數據結構有關!ArrayList 底層是數組,而 LinkedList 底層是鏈表。數組天然支持隨機訪問,時間複雜度爲 O(1),所以稱爲快速隨機訪問。鏈表需要遍歷到特定位置才能訪問特定位置的元素,時間複雜度爲 O(n),所以不支持快速隨機訪問。,ArrayList 實現了 RandomAccess 接口,就表明了他具有快速隨機訪問功能。 RandomAccess 接口只是標識,並不是說 ArrayList 實現 RandomAccess 接口才具有快速隨機訪問功能的!

1.2.3. 說一說 ArrayList 的擴容機制吧

詳見筆主的這篇文章:通過源碼一步一步分析 ArrayList 擴容機制

1.3. Collection 子接口之 Set

1.3.1. comparable 和 Comparator 的區別

  • comparable 接口實際上是出自java.lang包 它有一個 compareTo(Object obj)方法用來排序
  • comparator接口實際上是出自 java.util 包它有一個compare(Object obj1, Object obj2)方法用來排序

一般我們需要對一個集合使用自定義排序時,我們就要重寫compareTo()方法或compare()方法,當我們需要對某一個集合實現兩種排序方式,比如一個 song 對象中的歌名和歌手名分別採用一種排序方法的話,我們可以重寫compareTo()方法和使用自制的Comparator方法或者以兩個 Comparator 來實現歌名排序和歌星名排序,第二種代表我們只能使用兩個參數版的 Collections.sort().

1.3.1.1. Comparator 定製排序

        ArrayList<Integer> arrayList = new ArrayList<Integer>();
        arrayList.add(-1);
        arrayList.add(3);
        arrayList.add(3);
        arrayList.add(-5);
        arrayList.add(7);
        arrayList.add(4);
        arrayList.add(-9);
        arrayList.add(-7);
        System.out.println("原始數組:");
        System.out.println(arrayList);
        // void reverse(List list):反轉
        Collections.reverse(arrayList);
        System.out.println("Collections.reverse(arrayList):");
        System.out.println(arrayList);

        // void sort(List list),按自然排序的升序排序
        Collections.sort(arrayList);
        System.out.println("Collections.sort(arrayList):");
        System.out.println(arrayList);
        // 定製排序的用法
        Collections.sort(arrayList, new Comparator<Integer>() {

            @Override
            public int compare(Integer o1, Integer o2) {
                return o2.compareTo(o1);
            }
        });
        System.out.println("定製排序後:");
        System.out.println(arrayList);

Output:

原始數組:
[-1, 3, 3, -5, 7, 4, -9, -7]
Collections.reverse(arrayList):
[-7, -9, 4, 7, -5, 3, 3, -1]
Collections.sort(arrayList):
[-9, -7, -5, -1, 3, 3, 4, 7]
定製排序後:
[7, 4, 3, 3, -1, -5, -7, -9]

1.3.1.2. 重寫 compareTo 方法實現按年齡來排序

// person對象沒有實現Comparable接口,所以必須實現,這樣纔不會出錯,纔可以使treemap中的數據按順序排列
// 前面一個例子的String類已經默認實現了Comparable接口,詳細可以查看String類的API文檔,另外其他
// 像Integer類等都已經實現了Comparable接口,所以不需要另外實現了
public  class Person implements Comparable<Person> {
    private String name;
    private int age;

    public Person(String name, int age) {
        super();
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    /**
     * T重寫compareTo方法實現按年齡來排序
     */
    @Override
    public int compareTo(Person o) {
        if (this.age > o.getAge()) {
            return 1;
        }
        if (this.age < o.getAge()) {
            return -1;
        }
        return 0;
    }
}

    public static void main(String[] args) {
        TreeMap<Person, String> pdata = new TreeMap<Person, String>();
        pdata.put(new Person("張三", 30), "zhangsan");
        pdata.put(new Person("李四", 20), "lisi");
        pdata.put(new Person("王五", 10), "wangwu");
        pdata.put(new Person("小紅", 5), "xiaohong");
        // 得到key的值的同時得到key所對應的值
        Set<Person> keys = pdata.keySet();
        for (Person key : keys) {
            System.out.println(key.getAge() + "-" + key.getName());

        }
    }

Output:

5-小紅
10-王五
20-李四
30-張三

1.3.2. 無序性和不可重複性的含義是什麼

1、什麼是無序性?無序性不等於隨機性 ,無序性是指存儲的數據在底層數組中並非按照數組索引的順序添加 ,而是根據數據的哈希值決定的。

2、什麼是不可重複性?不可重複性是指添加的元素按照 equals()判斷時 ,返回 false,需要同時重寫 equals()方法和 HashCode()方法。

1.3.3. 比較 HashSet、LinkedHashSet 和 TreeSet 三者的異同

HashSet 是 Set 接口的主要實現類 ,HashSet 的底層是 HashMap,線程不安全的,可以存儲 null 值;

LinkedHashSet 是 HashSet 的子類,能夠按照添加的順序遍歷;

TreeSet 底層使用紅黑樹,能夠按照添加元素的順序進行遍歷,排序的方式有自然排序和定製排序。

1.4. Map 接口

1.4.1. HashMap 和 Hashtable 的區別

  1. 線程是否安全: HashMap 是非線程安全的,HashTable 是線程安全的,因爲 HashTable 內部的方法基本都經過synchronized 修飾。(如果你要保證線程安全的話就使用 ConcurrentHashMap 吧!);
  2. 效率: 因爲線程安全的問題,HashMap 要比 HashTable 效率高一點。另外,HashTable 基本被淘汰,不要在代碼中使用它;
  3. 對 Null key 和 Null value 的支持: HashMap 可以存儲 null 的 key 和 value,但 null 作爲鍵只能有一個,null 作爲值可以有多個;HashTable 不允許有 null 鍵和 null 值,否則會拋出 NullPointerException。
  4. 初始容量大小和每次擴充容量大小的不同 : ① 創建時如果不指定容量初始值,Hashtable 默認的初始大小爲 11,之後每次擴充,容量變爲原來的 2n+1。HashMap 默認的初始化大小爲 16。之後每次擴充,容量變爲原來的 2 倍。② 創建時如果給定了容量初始值,那麼 Hashtable 會直接使用你給定的大小,而 HashMap 會將其擴充爲 2 的冪次方大小(HashMap 中的tableSizeFor()方法保證,下面給出了源代碼)。也就是說 HashMap 總是使用 2 的冪作爲哈希表的大小,後面會介紹到爲什麼是 2 的冪次方。
  5. 底層數據結構: JDK1.8 以後的 HashMap 在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲 8)(將鏈表轉換成紅黑樹前會判斷,如果當前數組的長度小於 64,那麼會選擇先進行數組擴容,而不是轉換爲紅黑樹)時,將鏈表轉化爲紅黑樹,以減少搜索時間。Hashtable 沒有這樣的機制。

HashMap 中帶有初始容量的構造函數:

    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);
    }
     public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

下面這個方法保證了 HashMap 總是使用 2 的冪作爲哈希表的大小。

    /**
     * Returns a power of two size for the given target capacity.
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

1.4.2. HashMap 和 HashSet 區別

如果你看過 HashSet 源碼的話就應該知道:HashSet 底層就是基於 HashMap 實現的。(HashSet 的源碼非常非常少,因爲除了 clone()writeObject()readObject()是 HashSet 自己不得不實現之外,其他方法都是直接調用 HashMap 中的方法。

HashMap HashSet
實現了 Map 接口 實現 Set 接口
存儲鍵值對 僅存儲對象
調用 put()向 map 中添加元素 調用 add()方法向 Set 中添加元素
HashMap 使用鍵(Key)計算 Hashcode HashSet 使用成員對象來計算 hashcode 值,對於兩個對象來說 hashcode 可能相同,所以 equals()方法用來判斷對象的相等性,

1.4.3. HashMap 和 TreeMap 區別

TreeMapHashMap 都繼承自AbstractMap ,但是需要注意的是TreeMap它還實現了NavigableMap接口和SortedMap 接口。

image

實現 NavigableMap 接口讓 TreeMap 有了對集合內元素的搜索的能力。

實現SortMap接口讓 TreeMap 有了對集合中的元素根據鍵排序的能力。默認是按 key 的升序排序,不過我們也可以指定排序的比較器。示例代碼如下:

/**
 * @author shuang.kou
 * @createTime 2020年06月15日 17:02:00
 */
public class Person {
    private Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }


    public static void main(String[] args) {
        TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}

輸出:

person1
person4
person2
person3

可以看出,TreeMap 中的元素已經是按照 Person 的 age 字段的升序來排列了。

上面,我們是通過傳入匿名內部類的方式實現的,你可以將代碼替換成 Lambda 表達式實現的方式:

TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
  int num = person1.getAge() - person2.getAge();
  return Integer.compare(num, 0);
});

綜上,相比於HashMap來說 TreeMap 主要多了對集合中的元素根據鍵排序的能力以及對集合內元素的搜索的能力。

1.4.4. HashSet 如何檢查重複

當你把對象加入HashSet時,HashSet 會先計算對象的hashcode值來判斷對象加入的位置,同時也會與其他加入的對象的 hashcode 值作比較,如果沒有相符的 hashcode,HashSet 會假設對象沒有重複出現。但是如果發現有相同 hashcode 值的對象,這時會調用equals()方法來檢查 hashcode 相等的對象是否真的相同。如果兩者相同,HashSet 就不會讓加入操作成功。(摘自我的 Java 啓蒙書《Head fist java》第二版)

hashCode()與 equals()的相關規定:

  1. 如果兩個對象相等,則 hashcode 一定也是相同的
  2. 兩個對象相等,對兩個 equals 方法返回 true
  3. 兩個對象有相同的 hashcode 值,它們也不一定是相等的
  4. 綜上,equals 方法被覆蓋過,則 hashCode 方法也必須被覆蓋
  5. hashCode()的默認行爲是對堆上的對象產生獨特值。如果沒有重寫 hashCode(),則該 class 的兩個對象無論如何都不會相等(即使這兩個對象指向相同的數據)。

==與 equals 的區別

對於基本類型來說,== 比較的是值是否相等;

對於引用類型來說,== 比較的是兩個引用是否指向同一個對象地址(兩者在內存中存放的地址(堆內存地址)是否指向同一個地方);

對於引用類型(包括包裝類型)來說,equals 如果沒有被重寫,對比它們的地址是否相等;如果 equals()方法被重寫(例如 String),則比較的是地址裏的內容。

1.4.5. HashMap 的底層實現

1.4.5.1. JDK1.8 之前

JDK1.8 之前 HashMap 底層是 數組和鏈表 結合在一起使用也就是 鏈表散列HashMap 通過 key 的 hashCode 經過擾動函數處理過後得到 hash 值,然後通過 (n - 1) & hash 判斷當前元素存放的位置(這裏的 n 指的是數組的長度),如果當前位置存在元素的話,就判斷該元素與要存入的元素的 hash 值以及 key 是否相同,如果相同的話,直接覆蓋,不相同就通過拉鍊法解決衝突。

所謂擾動函數指的就是 HashMap 的 hash 方法。使用 hash 方法也就是擾動函數是爲了防止一些實現比較差的 hashCode() 方法 換句話說使用擾動函數之後可以減少碰撞。

JDK 1.8 HashMap 的 hash 方法源碼:

JDK 1.8 的 hash 方法 相比於 JDK 1.7 hash 方法更加簡化,但是原理不變。

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位異或
      // >>>:無符號右移,忽略符號位,空位都以0補齊
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

對比一下 JDK1.7 的 HashMap 的 hash 方法源碼.

static int hash(int h) {
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).

    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

相比於 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能會稍差一點點,因爲畢竟擾動了 4 次。

所謂 “拉鍊法” 就是:將鏈表和數組相結合。也就是說創建一個鏈表數組,數組中每一格就是一個鏈表。若遇到哈希衝突,則將衝突的值加到鏈表中即可。

jdk1.8之前的內部結構-HashMap

1.4.5.2. JDK1.8 之後

相比於之前的版本, JDK1.8 之後在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲 8)(將鏈表轉換成紅黑樹前會判斷,如果當前數組的長度小於 64,那麼會選擇先進行數組擴容,而不是轉換爲紅黑樹)時,將鏈表轉化爲紅黑樹,以減少搜索時間。

jdk1.8之後的內部結構-HashMap

TreeMap、TreeSet 以及 JDK1.8 之後的 HashMap 底層都用到了紅黑樹。紅黑樹就是爲了解決二叉查找樹的缺陷,因爲二叉查找樹在某些情況下會退化成一個線性結構。

1.4.6. HashMap 的長度爲什麼是 2 的冪次方

爲了能讓 HashMap 存取高效,儘量較少碰撞,也就是要儘量把數據分配均勻。我們上面也講到了過了,Hash 值的範圍值-2147483648 到 2147483647,前後加起來大概 40 億的映射空間,只要哈希函數映射得比較均勻鬆散,一般應用是很難出現碰撞的。但問題是一個 40 億長度的數組,內存是放不下的。所以這個散列值是不能直接拿來用的。用之前還要先做對數組的長度取模運算,得到的餘數才能用來要存放的位置也就是對應的數組下標。這個數組下標的計算方法是“ (n - 1) & hash”。(n 代表數組長度)。這也就解釋了 HashMap 的長度爲什麼是 2 的冪次方。

這個算法應該如何設計呢?

我們首先可能會想到採用%取餘的操作來實現。但是,重點來了:“取餘(%)操作中如果除數是 2 的冪次則等價於與其除數減一的與(&)操作(也就是說 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 並且 採用二進制位操作 &,相對於%能夠提高運算效率,這就解釋了 HashMap 的長度爲什麼是 2 的冪次方。

1.4.7. HashMap 多線程操作導致死循環問題

主要原因在於併發下的 Rehash 會造成元素之間會形成一個循環鏈表。不過,jdk 1.8 後解決了這個問題,但是還是不建議在多線程下使用 HashMap,因爲多線程下使用 HashMap 還是會存在其他問題比如數據丟失。併發環境下推薦使用 ConcurrentHashMap 。

詳情請查看:https://coolshell.cn/articles/9606.html

1.4.8. HashMap 有哪幾種常見的遍歷方式?

HashMap 的 7 種遍歷方式與性能分析!

1.4.9. ConcurrentHashMap 和 Hashtable 的區別

ConcurrentHashMap 和 Hashtable 的區別主要體現在實現線程安全的方式上不同。

  • 底層數據結構: JDK1.7 的 ConcurrentHashMap 底層採用 分段的數組+鏈表 實現,JDK1.8 採用的數據結構跟 HashMap1.8 的結構一樣,數組+鏈表/紅黑二叉樹。Hashtable 和 JDK1.8 之前的 HashMap 的底層數據結構類似都是採用 數組+鏈表 的形式,數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的;
  • 實現線程安全的方式(重要):在 JDK1.7 的時候,ConcurrentHashMap(分段鎖) 對整個桶數組進行了分割分段(Segment),每一把鎖只鎖容器其中一部分數據,多線程訪問容器裏不同數據段的數據,就不會存在鎖競爭,提高併發訪問率。 到了 JDK1.8 的時候已經摒棄了 Segment 的概念,而是直接用 Node 數組+鏈表+紅黑樹的數據結構來實現,併發控制使用 synchronized 和 CAS 來操作。(JDK1.6 以後 對 synchronized 鎖做了很多優化) 整個看起來就像是優化過且線程安全的 HashMap,雖然在 JDK1.8 中還能看到 Segment 的數據結構,但是已經簡化了屬性,只是爲了兼容舊版本;② Hashtable(同一把鎖) :使用 synchronized 來保證線程安全,效率非常低下。當一個線程訪問同步方法時,其他線程也訪問同步方法,可能會進入阻塞或輪詢狀態,如使用 put 添加元素,另一個線程不能使用 put 添加元素,也不能使用 get,競爭會越來越激烈效率越低。

兩者的對比圖:

HashTable:

HashTable全表鎖

http://www.cnblogs.com/chengxiao/p/6842045.html>

JDK1.7 的 ConcurrentHashMap:

JDK1.7的ConcurrentHashMap

http://www.cnblogs.com/chengxiao/p/6842045.html>

JDK1.8 的 ConcurrentHashMap:

JDK1.8 的 ConcurrentHashMap

JDK1.8 的 ConcurrentHashMap 不在是 Segment 數組 + HashEntry 數組 + 鏈表,而是 Node 數組 + 鏈表 / 紅黑樹。不過,Node 只能用於鏈表的情況,紅黑樹的情況需要使用 TreeNode。當衝突鏈表達到一定長度時,鏈表會轉換成紅黑樹。

1.4.10. ConcurrentHashMap 線程安全的具體實現方式/底層具體實現

1.4.10.1. JDK1.7(上面有示意圖)

首先將數據分爲一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據時,其他段的數據也能被其他線程訪問。

ConcurrentHashMap 是由 Segment 數組結構和 HashEntry 數組結構組成

Segment 實現了 ReentrantLock,所以 Segment 是一種可重入鎖,扮演鎖的角色。HashEntry 用於存儲鍵值對數據。

static class Segment<K,V> extends ReentrantLock implements Serializable {
}

一個 ConcurrentHashMap 裏包含一個 Segment 數組。Segment 的結構和 HashMap 類似,是一種數組和鏈表結構,一個 Segment 包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素,每個 Segment 守護着一個 HashEntry 數組裏的元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment 的鎖。

1.4.10.2. JDK1.8 (上面有示意圖)

ConcurrentHashMap 取消了 Segment 分段鎖,採用 CAS 和 synchronized 來保證併發安全。數據結構跟 HashMap1.8 的結構類似,數組+鏈表/紅黑二叉樹。Java 8 在鏈表長度超過一定閾值(8)時將鏈表(尋址時間複雜度爲 O(N))轉換爲紅黑樹(尋址時間複雜度爲 O(log(N)))

synchronized 只鎖定當前鏈表或紅黑二叉樹的首節點,這樣只要 hash 不衝突,就不會產生併發,效率又提升 N 倍。

1.5. Collections 工具類

Collections 工具類常用方法:

  1. 排序
  2. 查找,替換操作
  3. 同步控制(不推薦,需要線程安全的集合類型時請考慮使用 JUC 包下的併發集合)

1.5.1. 排序操作

void reverse(List list)//反轉
void shuffle(List list)//隨機排序
void sort(List list)//按自然排序的升序排序
void sort(List list, Comparator c)//定製排序,由Comparator控制排序邏輯
void swap(List list, int i , int j)//交換兩個索引位置的元素
void rotate(List list, int distance)//旋轉。當distance爲正數時,將list後distance個元素整體移到前面。當distance爲負數時,將 list的前distance個元素整體移到後面

1.5.2. 查找,替換操作

int binarySearch(List list, Object key)//對List進行二分查找,返回索引,注意List必須是有序的
int max(Collection coll)//根據元素的自然順序,返回最大的元素。 類比int min(Collection coll)
int max(Collection coll, Comparator c)//根據定製排序,返回最大元素,排序規則由Comparatator類控制。類比int min(Collection coll, Comparator c)
void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素。
int frequency(Collection c, Object o)//統計元素出現次數
int indexOfSubList(List list, List target)//統計target在list中第一次出現的索引,找不到則返回-1,類比int lastIndexOfSubList(List source, list target).
boolean replaceAll(List list, Object oldVal, Object newVal), 用新元素替換舊元素

1.5.3. 同步控制

Collections 提供了多個synchronizedXxx()方法·,該方法可以將指定集合包裝成線程同步的集合,從而解決多線程併發訪問集合時的線程安全問題。

我們知道 HashSetTreeSetArrayList,LinkedList,HashMap,TreeMap 都是線程不安全的。Collections 提供了多個靜態方法可以把他們包裝成線程同步的集合。

最好不要用下面這些方法,效率非常低,需要線程安全的集合類型時請考慮使用 JUC 包下的併發集合。

方法如下:

synchronizedCollection(Collection<T>  c) //返回指定 collection 支持的同步(線程安全的)collection。
synchronizedList(List<T> list)//返回指定列表支持的同步(線程安全的)List。
synchronizedMap(Map<K,V> m) //返回由指定映射支持的同步(線程安全的)Map。
synchronizedSet(Set<T> s) //返回指定 set 支持的同步(線程安全的)set。

1.6. 其他重要問題

1.6.1. 什麼是快速失敗(fail-fast)?

快速失敗(fail-fast) 是 Java 集合的一種錯誤檢測機制。在使用迭代器對集合進行遍歷的時候,我們在多線程下操作非安全失敗(fail-safe)的集合類可能就會觸發 fail-fast 機制,導致拋出 ConcurrentModificationException 異常。 另外,在單線程下,如果在遍歷過程中對集合對象的內容進行了修改的話也會觸發 fail-fast 機制。

注:增強 for 循環也是藉助迭代器進行遍歷。

舉個例子:多線程下,如果線程 1 正在對集合進行遍歷,此時線程 2 對集合進行修改(增加、刪除、修改),或者線程 1 在遍歷過程中對集合進行修改,都會導致線程 1 拋出 ConcurrentModificationException 異常。

爲什麼呢?

每當迭代器使用 hashNext()/next()遍歷下一個元素之前,都會檢測 modCount 變量是否爲 expectedModCount 值,是的話就返回遍歷;否則拋出異常,終止遍歷。

如果我們在集合被遍歷期間對其進行修改的話,就會改變 modCount 的值,進而導致 modCount != expectedModCount ,進而拋出 ConcurrentModificationException 異常。

注:通過 Iterator 的方法修改集合的話會修改到 expectedModCount 的值,所以不會拋出異常。

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

好吧!相信大家已經搞懂了快速失敗(fail-fast)機制以及它的原理。

我們再來趁熱打鐵,看一個阿里巴巴手冊相關的規定:

有了前面講的基礎,我們應該知道:使用 Iterator 提供的 remove 方法,可以修改到 expectedModCount 的值。所以,纔不會再拋出ConcurrentModificationException 異常。

1.6.2. 什麼是安全失敗(fail-safe)呢?

明白了快速失敗(fail-fast)之後,安全失敗(fail-safe)我們就很好理解了。

採用安全失敗機制的集合容器,在遍歷時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷。所以,在遍歷過程中對原集合所作的修改並不能被迭代器檢測到,故不會拋 ConcurrentModificationException 異常。

1.6.3. Arrays.asList()避坑指南

最近使用Arrays.asList()遇到了一些坑,然後在網上看到這篇文章:Java Array to List Examples 感覺挺不錯的,但是還不是特別全面。所以,自己對於這塊小知識點進行了簡單的總結。

1.6.3.1. 簡介

Arrays.asList()在平時開發中還是比較常見的,我們可以使用它將一個數組轉換爲一個 List 集合。

String[] myArray = { "Apple", "Banana", "Orange" };
List<String> myList = Arrays.asList(myArray);
//上面兩個語句等價於下面一條語句
List<String> myList = Arrays.asList("Apple","Banana", "Orange");

JDK 源碼對於這個方法的說明:

/**
 *返回由指定數組支持的固定大小的列表。此方法作爲基於數組和基於集合的API之間的橋樑,與           Collection.toArray()結合使用。返回的List是可序列化並實現RandomAccess接口。
 */
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

1.6.3.2. 《阿里巴巴 Java 開發手冊》對其的描述

Arrays.asList()將數組轉換爲集合後,底層其實還是數組,《阿里巴巴 Java 開發手冊》對於這個方法有如下描述:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-QyCTYWHB-1593343041094)(https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-6/阿里巴巴Java開發手-Arrays.asList()]方法.png)

1.6.3.3. 使用時的注意事項總結

傳遞的數組必須是對象數組,而不是基本類型。

Arrays.asList()是泛型方法,傳入的對象必須是對象數組。

int[] myArray = { 1, 2, 3 };
List myList = Arrays.asList(myArray);
System.out.println(myList.size());//1
System.out.println(myList.get(0));//數組地址值
System.out.println(myList.get(1));//報錯:ArrayIndexOutOfBoundsException
int [] array=(int[]) myList.get(0);
System.out.println(array[0]);//1

當傳入一個原生數據類型數組時,Arrays.asList() 的真正得到的參數就不是數組中的元素,而是數組對象本身!此時 List 的唯一元素就是這個數組,這也就解釋了上面的代碼。

我們使用包裝類型數組就可以解決這個問題。

Integer[] myArray = { 1, 2, 3 };

使用集合的修改方法:add()remove()clear()會拋出異常。

List myList = Arrays.asList(1, 2, 3);
myList.add(4);//運行時報錯:UnsupportedOperationException
myList.remove(1);//運行時報錯:UnsupportedOperationException
myList.clear();//運行時報錯:UnsupportedOperationException

Arrays.asList() 方法返回的並不是 java.util.ArrayList ,而是 java.util.Arrays 的一個內部類,這個內部類並沒有實現集合的修改方法或者說並沒有重寫這些方法。

List myList = Arrays.asList(1, 2, 3);
System.out.println(myList.getClass());//class java.util.Arrays$ArrayList

下圖是java.util.Arrays$ArrayList的簡易源碼,我們可以看到這個類重寫的方法有哪些。

  private static class ArrayList<E> extends AbstractList<E>
        implements RandomAccess, java.io.Serializable
    {
        ...

        @Override
        public E get(int index) {
          ...
        }

        @Override
        public E set(int index, E element) {
          ...
        }

        @Override
        public int indexOf(Object o) {
          ...
        }

        @Override
        public boolean contains(Object o) {
           ...
        }

        @Override
        public void forEach(Consumer<? super E> action) {
          ...
        }

        @Override
        public void replaceAll(UnaryOperator<E> operator) {
          ...
        }

        @Override
        public void sort(Comparator<? super E> c) {
          ...
        }
    }

我們再看一下java.util.AbstractListremove()方法,這樣我們就明白爲啥會拋出UnsupportedOperationException

public E remove(int index) {
    throw new UnsupportedOperationException();
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章