Java集合框架面試題(詳解)

Arraylist 與 LinkedList 異同

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

下面再總結一下 list 的遍歷方式選擇:

  • 實現了RandomAccess接口的list,優先選擇普通for循環 ,其次foreach,
  • 未實現RandomAccess接口的ist, 優先選擇iterator遍歷(foreach遍歷底層也是通過iterator實現的),大size的數據,千萬不要使用普通for循環

補充:數據結構基礎之雙向鏈表

雙向鏈表也叫雙鏈表,是鏈表的一種,它的每個數據結點中都有兩個指針,分別指向直接後繼和直接前驅。所以,從雙向鏈表中的任意一個結點開始,都可以很方便地訪問它的前驅結點和後繼結點。一般我們都構造雙向循環鏈表,如下圖所示,同時下圖也是LinkedList 底層使用的是雙向循環鏈表數據結構。

ArrayList 與 Vector 區別

Vector類的所有方法都是同步的。可以由兩個線程安全地訪問一個Vector對象、但是一個線程訪問Vector的話代碼要在同步操作上耗費大量的時間。

Arraylist不是同步的,所以在不需要保證線程安全時時建議使用Arraylist。

HashMap的底層實現

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之前的內部結構

JDK1.8之後

相比於之前的版本, JDK1.8之後在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲8)時,將鏈表轉化爲紅黑樹,以減少搜索時間。

JDK1.8之後的HashMap底層數據結構

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

推薦閱讀:

HashMap 和 Hashtable 的區別

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

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

    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;
    }

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的冪次方。

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

在多線程下,進行 put 操作會導致 HashMap 死循環,原因在於 HashMap 的擴容 resize()方法。由於擴容是新建一個數組,複製原數據到數組。由於數組下標掛有鏈表,所以需要複製鏈表,但是多線程操作有可能導致環形鏈表。複製鏈表過程如下:
以下模擬2個線程同時擴容。假設,當前 HashMap 的空間爲2(臨界值爲1),hashcode 分別爲 0 和 1,在散列地址 0 處有元素 A 和 B,這時候要添加元素 C,C 經過 hash 運算,得到散列地址爲 1,這時候由於超過了臨界值,空間不夠,需要調用 resize 方法進行擴容,那麼在多線程條件下,會出現條件競爭,模擬過程如下:

線程一:讀取到當前的 HashMap 情況,在準備擴容時,線程二介入

線程二:讀取 HashMap,進行擴容

線程一:繼續執行

這個過程爲,先將 A 複製到新的 hash 表中,然後接着複製 B 到鏈頭(A 的前邊:B.next=A),本來 B.next=null,到此也就結束了(跟線程二一樣的過程),但是,由於線程二擴容的原因,將 B.next=A,所以,這裏繼續複製A,讓 A.next=B,由此,環形鏈表出現:B.next=A; A.next=B

注意:jdk1.8已經解決了死循環的問題。

HashSet 和 HashMap 區別

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

HashSet 和 HashMap 區別

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,競爭會越來越激烈效率越低。

兩者的對比圖:

圖片來源:http://www.cnblogs.com/chengxiao/p/6842045.html

HashTable:

JDK1.7的ConcurrentHashMap:

JDK1.8的ConcurrentHashMap(TreeBin: 紅黑二叉樹節點
Node: 鏈表節點):

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

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的鎖。

JDK1.8 (上面有示意圖)

ConcurrentHashMap取消了Segment分段鎖,採用CAS和synchronized來保證併發安全。數據結構跟HashMap1.8的結構類似,數組+鏈表/紅黑二叉樹。

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

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

Collection

1. List

  • Arraylist: Object數組
  • Vector: Object數組
  • LinkedList: 雙向鏈表(JDK1.6之前爲循環鏈表,JDK1.7取消了循環)
    詳細可閱讀JDK1.7-LinkedList循環鏈表優化

2. Set

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

Map

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

推薦閱讀:

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