容器(一)剖析面試最常見問題之 Java 集合框架

chenyson發表於2020-11-06

轉載自https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/collection/Java%E9%9B%86%E5%90%88%E6%A1%86%E6%9E%B6%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98.md

Java 集合框架

1.1. 集合概述

1.1.1. Java 集合概覽

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

並且,以 Map 結尾的類都實現了 Map 介面。

在這裡插入圖片描述

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

  • Arraylist: Object[]陣列
  • Vector:Object[]陣列
  • LinkedList: 雙向連結串列(JDK1.6 之前為迴圈連結串列,JDK1.7 取消了迴圈)

1.1.3.2. Set

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

1.1.3.3. Map

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

1.1.4. 如何選用集合?

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

當我們只需要存放元素值時,就選擇實現Collection 介面的集合,需要保證元素唯一時選擇實現 Set 介面的集合比如 TreeSet 或 HashSet,不需要就選擇實現 List 介面的比如 ArrayList 或 LinkedList,然後再根據實現這些介面的集合的特點來選用。

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,TreeMap,PriorityQueue 都不是執行緒安全的。解決辦法很簡單,可以使用執行緒安全的集合來代替。

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

  • ConcurrentHashMap: 可以看作是執行緒安全的 HashMap
  • CopyOnWriteArrayList:可以看作是執行緒安全的 ArrayList,在讀多寫少的場合效能非常好,遠遠好於 Vector.
  • ConcurrentLinkedQueue:高效的併發佇列,使用連結串列實現。可以看做一個執行緒安全的 LinkedList,這是一個非阻塞佇列。
  • BlockingQueue: 這是一個介面,JDK 內部通過連結串列、陣列等方式實現了這個介面。表示阻塞佇列,非常適合用於作為資料共享的通道。
  • ConcurrentSkipListMap :跳錶的實現。這是一個Map,使用跳錶的資料結構進行快速查詢。

1.2. Collection 子介面之 List

1.2.1. Arraylist 和 Vector 的區別?

ArrayList 是 List 的主要實現類,底層使用 Object[ ]儲存,適用於頻繁的查詢工作,執行緒不安全
Vector 是 List 的古老實現類,底層使用 Object[ ]儲存,執行緒安全的。

1.2.2. Arraylist 與 LinkedList 區別?

  • 是否保證執行緒安全: ArrayList 和 LinkedList 都是不同步的,也就是不保證執行緒安全;
  • 底層資料結構: Arraylist 底層使用的是 Object 陣列;LinkedList 底層使用的是 雙向連結串列 資料結構(JDK1.6 之前為迴圈連結串列,JDK1.7 取消了迴圈。注意雙向連結串列和雙向迴圈連結串列的區別,下面有介紹到!)
  • 插入和刪除是否受元素位置的影響: ① 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))因為需要先移動到指定位置再插入。
  • 是否支援快速隨機訪問: LinkedList 不支援高效的隨機元素訪問,而 ArrayList 支援。快速隨機訪問就是通過元素的序號快速獲取元素物件(對應於get(int index)方法)。
  • 記憶體空間佔用: 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 的區別

  • 執行緒是否安全: HashMap 是非執行緒安全的,HashTable 是執行緒安全的,因為 HashTable 內部的方法基本都經過synchronized 修飾。(如果你要保證執行緒安全的話就使用 ConcurrentHashMap 吧!);
  • 效率: 因為執行緒安全的問題,HashMap 要比 HashTable 效率高一點。另外,HashTable 基本被淘汰,不要在程式碼中使用它;
  • 對 Null key 和 Null value 的支援: HashMap 可以儲存 null 的 key 和 value,但 null 作為鍵只能有一個,null 作為值可以有多個;HashTable 不允許有 null 鍵和 null 值,否則會丟擲 NullPointerException。
  • 初始容量大小和每次擴充容量大小的不同 : ① 建立時如果不指定容量初始值,Hashtable 預設的初始大小為 11,之後每次擴充,容量變為原來的 2n+1。HashMap 預設的初始化大小為 16。之後每次擴充,容量變為原來的 2 倍。② 建立時如果給定了容量初始值,那麼 Hashtable 會直接使用你給定的大小,而 HashMap 會將其擴充為 2 的冪次方大小(HashMap 中的tableSizeFor()方法保證,下面給出了原始碼)。也就是說 HashMap 總是使用 2 的冪作為雜湊表的大小,後面會介紹到為什麼是 2 的冪次方。
  • 底層資料結構: 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 中的方法。

在這裡插入圖片描述

1.4.3. HashMap 和 TreeMap 區別

TreeMap 和HashMap 都繼承自AbstractMap ,但是需要注意的是TreeMap它還實現了NavigableMap介面和SortedMap 介面。

在這裡插入圖片描述

實現 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 通過 keyhashCode 經過擾動函式處理過後得到 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(resize()) 會造成元素之間會形成一個迴圈連結串列。不過,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:
在這裡插入圖片描述

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

JDK1.7 的 ConcurrentHashMap:

在這裡插入圖片描述

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

JDK1.8 的 ConcurrentHashMap:
在這裡插入圖片描述

Java8 ConcurrentHashMap 儲存結構(圖片來自 javadoop)

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. 查詢,替換操作

i

nt 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()方法·,該方法可以將指定集合包裝成執行緒同步的集合,從而解決多執行緒併發訪問集合時的執行緒安全問題。

我們知道 HashSet,TreeSet,ArrayList,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 開發手冊》對於這個方法有如下描述:

阿里巴巴Java開發手冊-Arrays.asList()方法
在這裡插入圖片描述

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.AbstractList的remove()方法,這樣我們就明白為啥會丟擲UnsupportedOperationException。

public E remove(int index) {
    throw new UnsupportedOperationException();
}

相關文章