Java八股文——集合


1. HashMap

  基本信息:

  • 數據結構:數據+鏈表,數組+鏈表+紅黑樹
    • jdk1.8中,當鏈表大小超過8時,就會轉換為紅黑樹,當小於6時變回鏈表,主要是根據泊松分布,在負載因子默認為0.75的時候,單個hash槽內元素個數為8的概率小於百萬分之一,所以將7作為一個分水嶺,等於7的時候不轉換,大於等於8的時候轉換,小於等於6轉為鏈表
  • 默認大小:16
  • 負載因子:0.75(原因:0.75的負載因子,能讓隨機hash更加滿足0.5的泊松分布

  默認容量為什么是16?

  • 當我們想要往一個hashmap中put元素的時候,需要通過hash方法計算出放到哪個桶中,hash方法是根據key來定位這個K-V在鏈表數組中的位置的,hash的公式:HashCode(key) & (length-1),其實就是取模.
    用位運算來代替取模,主要就是因為位運算的效率較高。例如:X % 2^n = X & (2^n -1),假設n為3,則2^3 = 8 ,表示為二進制就是1000,2^3 - 1 = 7,即0111,此時 X & (2^n -1)就相當於取X的二進制的最后三位數,從二進制角度來看,X / 8相當於 X >> 3 ,此時得到了X / 8 的商,而被移位的部分(后三位)就是 X % 8,也就是余數
    因此,如果保證map的長度是 2^n的話,就可以實現取模運算了
    那么為什么一定是16呢?
    關於這個默認容量的選擇,官方沒有給出相關解釋,應該是一個經驗值,需要在效率和內存使用上權衡,不能太小也不能太大
  • 並且,Hashmap在兩個可能改變容量的地方做了兼容處理,一個是擴容,一個是初始化。
  • 當我們初始化Map且設置了容量時,HashMap不一定會采用傳入的值,而是經過計算,得到一個新值,以提高hash效率,源碼中的算法就是根據用戶傳入的容量值,得到第一個比他大的二次冪返回

  擴容:

·  擴容的閾值是負載因子 * 當前容量。

  1. 創建一個新的Entry數組,長度是原數組的兩倍
  2. rehash:便利原Entry數組,將所有的Entry重新hash到新數組(重新hash是因為長度擴大之后,hash的值可能不同)

  Hashmap是怎么放入數據的?(put方法)

  • 首先判斷key是否是null,是的話hash值就是0,獲得hash值后進行擾動,1.7版本是5次異或4次位移,1.8是一次異或一次位移,然后根據計算出的新hash值找到對應的index,然后找到對應Node/Entity,遍歷鏈表/紅黑樹,遇到hash值相同且equals相同的,則覆蓋,不是則新增,如果節點數大於8就樹化。put完成后,判斷當前長度是否大於閾值,是就擴容
  • 對於鏈表插入,1.7之前是頭插法,從1.8開始變成尾插法,主要是為了解決rehash出現的死循環問題,並且1.7的時候是先擴容再插入,而1.8是先插入后擴容。正常來說,如果先插入,就可能節點需要樹化,會多一次損耗,個人猜測,是由於讀寫問題,hashmap並不是線程安全的,如果先擴容后插入,那么擴容期間是訪問不到新放入的值的,所以先插入,在擴容期間是可以訪問到值的

  為什么需要從頭插法變成尾插法?

  • 在多線程的時候,如果不同的線程同時插入一個map,當達到擴容閾值時,兩個線程同時觸發擴容,而頭插法在循環中會導致某個節點發生循環指向,后續查找元素過程中就會發生死循環

  HashMap線程不安全主要體現在:

  1. 1.7多線程環境下,擴容會造成環形鏈表或數據丟失
  2. 1.8多線程環境下,put方法會發生數據覆蓋的情況

  如何處理HashMap的線程不安全的情況?

  1. 使用Collection.synchronizedMap()創建線程安全的map
    • 實現線程安全的原理:
      SynchronizedMap內部維護了一個普通對象map和排斥鎖mutex,通過該方法創建出map之后,操作map就會以mutex對方法進行加鎖,mutex默認是SynchronizedMap,可以通過構造參數傳入
  2. HashTable
    • 所有數據操作方法都加鎖,效率低不考慮
    • 不允許鍵值為null,HashMap可以
    • 使用安全失敗機制(fail-safe)而HashMap使用快速失敗機制(fail-fast),在安全失敗機制下,如果使用null,會使得無法判斷對應的key是不存在還是為空
  3. ConcurrentHashMap
    • 結構與HashMap相同,但是采用了CAS + synchronized來保證安全性
    • 最大容量:1 << 30
    • PUT操作:
      1. 根據key計算出hashcode
      2. 判斷是否需要初始化
      3. 根據當前key定位的node,如果為空則可以寫入,利用CAS嘗試寫入,失敗則自旋保證成功
      4. 如果當前位置的hashcode == MOVED == -1,則需要擴容
      5. 如果都不滿足,則利用synchronized寫入數據
      6. 如果大於TREEIFY_THRESHOLD則需要轉換為紅黑樹
    • GET操作
      • 直接根據計算出來的hashcode尋址,如果在桶上直接返回,如果是紅黑樹則按照樹的方式獲取,如果不滿足則用鏈表方式遍歷獲取
    • 擴容
      • 1.7版本是基於segment,segment內部維護了HashEntity數組,所以擴容方式是在這個基礎上的,類似HashMap擴容
      • 1.8版本擴容較為復雜,利用了ForwardingNode,先根據機器內核數來分配每個線程能分到的busket數(最小是16),這樣可以做到多線程協助遷移,提升速度,然后根據自己分配的busket數來進行節點遷移,如果為空就放置ForwardingNode,代表遷移完成,如果是非空節點(判斷是不是ForwardingNode,是就結束了),加鎖,鏈路循環進行遷移
    • 為什么在Java8放棄了segment?
      • 由於在創建一個ConcurrentHashMap的時候,segment的數量就已經固定了,當需要進行擴容的時候,變化的是segment的大小,segment繼承了ReentrantLock,如果segment變得很大了,那么鎖的粒度就會變得很大,分段鎖就沒有意義了,每一個段就相當於一個同步的Map
      • 在java8中替換成了Node數組鏈表紅黑樹,並且因為ReentrantLock需要節點繼承AQS來獲得同步支持,會增大內存開銷,因此java8不使用ReentrantLock,改為synchronized,synchronized是jvm直接支持的,能夠在運行的時候進行相應的優化措施,比如鎖粗化,自旋等

2.List

  1. ArrayList

    • 底層使用數組實現,查找與訪問較快,新增和刪除較慢
    • 實現了RandomAccess接口,可以隨機訪問
    • 默認初始容量 10
    • 以1.5倍擴容
    • 調用構造函數時只會初始化數組大小,而size這個變量不會初始化,此時如果調用set方法指定下標設置數據會拋出數組越界異常,因為set方法中是根據size來判斷是否可以設置當前下標的數據
    • 構造方法中,如果設置初始化大小為0,則數組擴容時,大小由0變為1
    • 無參構造方法中,數組默認大小也是0,但擴容時,大小由0變為10
  2. LinkedList

    • 底層使用鏈表實現,新增與刪除較快,查找較慢
    • 內部維護了鏈表長度,以及頭尾節點,獲取長度不需要遍歷
    • 實現了隊列接口,具有隊列的先進先出的功能

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM