2020JAVA集合面試題大全

集合容器概述

什麼是集合

集合框架:用於存儲數據的容器。

集合框架是爲表示和操作集合而規定的一種統一的標準的體系結構。
任何集合框架都包含三大塊內容:對外的接口、接口的實現和對集合運算的算法。

接口:表示集合的抽象數據類型。接口允許我們操作集合時不必關注具體實現,從而達到“多態”。在面向對象編程語言中,接口通常用來形成規範。

實現:集合接口的具體實現,是重用性很高的數據結構。

算法:在一個實現了某個集合框架中的接口的對象身上完成某種有用的計算的方法,例如查找、排序等。這些算法通常是多態的,因爲相同的方法可以在同一個接口被多個類實現時有不同的表現。事實上,算法是可複用的函數。
它減少了程序設計的辛勞。

集合框架通過提供有用的數據結構和算法使你能集中注意力於你的程序的重要部分上,而不是爲了讓程序能正常運轉而將注意力於低層設計上。
通過這些在無關API之間的簡易的互用性,使你免除了爲改編對象或轉換代碼以便聯合這些API而去寫大量的代碼。 它提高了程序速度和質量。
 

集合的特點

集合的特點主要有如下兩點:

  • 對象封裝數據,對象多了也需要存儲。集合用於存儲對象。

  • 對象的個數確定可以使用數組,對象的個數不確定的可以用集合。因爲集合是可變長度的。

集合和數組的區別

  • 數組是固定長度的;集合可變長度的。

  • 數組可以存儲基本數據類型,也可以存儲引用數據類型;集合只能存儲引用數據類型。

  • 數組存儲的元素必須是同一個數據類型;集合存儲的對象可以是不同數據類型。

數據結構:就是容器中存儲數據的方式。

對於集合容器,有很多種。因爲每一個容器的自身特點不同,其實原理在於每個容器的內部數據結構不同。

集合容器在不斷向上抽取過程中,出現了集合體系。在使用一個體系的原則:參閱頂層內容。建立底層對象。

使用集合框架的好處

  •     容量自增長;
  •     提供了高性能的數據結構和算法,使編碼更輕鬆,提高了程序速度和質量;
  •     允許不同 API 之間的互操作,API之間可以來回傳遞集合;
  •     可以方便地擴展或改寫集合,提高代碼複用性和可操作性。
  •     通過使用JDK自帶的集合類,可以降低代碼維護和學習新API成本。

 

常用的集合類有哪些?

 

Map接口和Collection接口是所有集合框架的父接口:

  1.     Collection接口的子接口包括:Set接口和List接口
  2.     Map接口的實現類主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap以及Properties等
  3.     Set接口的實現類主要有:HashSet、TreeSet、LinkedHashSet等
  4.     List接口的實現類主要有:ArrayList、LinkedList、Stack以及Vector等
     

List,Set,Map三者的區別?List、Set、Map 是否繼承自 Collection 接口?List、Map、Set 三個接口存取元素時,各有什麼特點?

 

Java 容器分爲 Collection 和 Map 兩大類,Collection集合的子接口有Set、List、Queue三種子接口。我們比較常用的是Set、List,Map接口不是collection的子接口。

Collection集合主要有List和Set兩大接口

 

  • List:一個有序(元素存入集合的順序和取出的順序一致)容器,元素可以重複,可以插入多個null元素,元素都有索引。常用的實現類有 ArrayList、LinkedList 和 Vector。
  • Set:一個無序(存入和取出順序有可能不一致)容器,不可以存儲重複元素,只允許存入一個null元素,必須保證元素唯一性。Set 接口常用實現類是 HashSet、LinkedHashSet 以及 TreeSet。
     

Map是一個鍵值對集合,存儲鍵、值和之間的映射。 Key無序,唯一;value 不要求有序,允許重複。Map沒有繼承於Collection接口,從Map集合中檢索元素時,只要給出鍵對象,就會返回對應的值對象。

Map 的常用實現類:HashMap、TreeMap、HashTable、LinkedHashMap、ConcurrentHashMap
 

集合框架底層數據結構

 

Collection

    List

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

 

Map

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

哪些集合類是線程安全的?

 

  • vector:就比arraylist多了個同步化機制(線程安全),因爲效率較低,現在已經不太建議使用。在web應用中,特別是前臺頁面,往往效率(頁面響應速度)是優先考慮的。
  • statck:堆棧類,先進後出。
  • hashtable:就比hashmap多了個線程安全。
  • enumeration:枚舉,相當於迭代器。
     

Java集合的快速失敗機制 “fail-fast”?

 

是java集合的一種錯誤檢測機制,當多個線程對集合進行結構上的改變的操作時,有可能會產生 fail-fast 機制。

例如:假設存在兩個線程(線程1、線程2),線程1通過Iterator在遍歷集合A中的元素,在某個時候線程2修改了集合A的結構(是結構上面的修改,而不是簡單的修改集合元素的內容),那麼這個時候程序就會拋出 ConcurrentModificationException 異常,從而產生fail-fast機制。

原因:迭代器在遍歷時直接訪問集合中的內容,並且在遍歷過程中使用一個 modCount 變量。集合在被遍歷期間如果內容發生變化,就會改變modCount的值。每當迭代器使用hashNext()/next()遍歷下一個元素之前,都會檢測modCount變量是否爲expectedmodCount值,是的話就返回遍歷;否則拋出異常,終止遍歷。

解決辦法:

    在遍歷過程中,所有涉及到改變modCount值得地方全部加上synchronized。

    使用CopyOnWriteArrayList來替換ArrayList
 

怎麼確保一個集合不能被修改?

可以使用 Collections. unmodifiableCollection(Collection c) 方法來創建一個只讀集合,這樣改變集合的任何操作都會拋出 Java. lang. UnsupportedOperationException 異常。

示例代碼如下:

 

Collection接口

List接口

迭代器 Iterator 是什麼?

Iterator 接口提供遍歷任何 Collection 的接口。我們可以從一個 Collection 中使用迭代器方法來獲取迭代器實例。迭代器取代了 Java 集合框架中的 Enumeration,迭代器允許調用者在迭代過程中移除元素。

Iterator 怎麼使用?有什麼特點?

Iterator 使用代碼如下:

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

如何邊遍歷邊移除 Collection 中的元素?

邊遍歷邊修改 Collection 的唯一正確方式是使用 Iterator.remove() 方法,如下:

一種最常見的錯誤代碼如下:

運行以上錯誤代碼會報 ConcurrentModificationException 異常。這是因爲當使用 foreach(for(Integer i : list)) 語句時,會自動生成一個iterator 來遍歷該 list,但同時該 list 正在被 Iterator.remove() 修改。Java 一般不允許一個線程在遍歷 Collection 時另一個線程修改它。

 

Iterator 和 ListIterator 有什麼區別?

  • Iterator 可以遍歷 Set 和 List 集合,而 ListIterator 只能遍歷 List。
  • Iterator 只能單向遍歷,而 ListIterator 可以雙向遍歷(向前/後遍歷)。
  • ListIterator 實現 Iterator 接口,然後添加了一些額外的功能,比如添加一個元素、替換一個元素、獲取前面或後面元素的索引位置。
     

遍歷一個 List 有哪些不同的方式?每種方法的實現原理是什麼?Java 中 List 遍歷的最佳實踐是什麼?

遍歷方式有以下幾種:

  •     for 循環遍歷,基於計數器。在集合外部維護一個計數器,然後依次讀取每一個位置的元素,當讀取到最後一個元素後停止。
  •     迭代器遍歷,Iterator。Iterator 是面向對象的一個設計模式,目的是屏蔽不同數據集合的特點,統一遍歷集合的接口。Java 在 Collections 中支持了 Iterator 模式。
  •     foreach 循環遍歷。foreach 內部也是採用了 Iterator 的方式實現,使用時不需要顯式聲明 Iterator 或計數器。優點是代碼簡潔,不易出錯;缺點是隻能做簡單的遍歷,不能在遍歷過程中操作數據集合,例如刪除、替換。
     

最佳實踐:Java Collections 框架中提供了一個 RandomAccess 接口,用來標記 List 實現是否支持 Random Access。

  •     如果一個數據集合實現了該接口,就意味着它支持 Random Access,按位置讀取元素的平均時間複雜度爲 O(1),如ArrayList。
  •     如果沒有實現該接口,表示不支持 Random Access,如LinkedList。

推薦的做法就是,支持 Random Access 的列表可用 for 循環遍歷,否則建議用 Iterator 或 foreach 遍歷。

 

說一下 ArrayList 的優缺點

ArrayList的優點如下:

  • ArrayList 底層以數組實現,是一種隨機訪問模式。ArrayList 實現了 RandomAccess 接口,因此查找的時候非常快。
  • ArrayList 在順序添加一個元素的時候非常方便。

ArrayList 的缺點如下:

  • 刪除元素的時候,需要做一次元素複製操作。如果要複製的元素很多,那麼就會比較耗費性能。
  • 插入元素的時候,也需要做一次元素複製操作,缺點同上。

ArrayList 比較適合順序添加、隨機訪問的場景。

如何實現數組和 List 之間的轉換?

  • 數組轉 List:使用 Arrays. asList(array) 進行轉換。
  • List 轉數組:使用 List 自帶的 toArray() 方法。

代碼示例:

ArrayList 和 LinkedList 的區別是什麼?

  •     數據結構實現:ArrayList 是動態數組的數據結構實現,而 LinkedList 是雙向鏈表的數據結構實現。
  •     隨機訪問效率:ArrayList 比 LinkedList 在隨機訪問的時候效率要高,因爲 LinkedList 是線性的數據存儲方式,所以需要移動指針從前往後依次查找。
  •     增加和刪除效率:在非首尾的增加和刪除操作,LinkedList 要比 ArrayList 效率要高,因爲 ArrayList 增刪操作要影響數組內的其他數據的下標。
  •     內存空間佔用:LinkedList 比 ArrayList 更佔內存,因爲 LinkedList 的節點除了存儲數據,還存儲了兩個引用,一個指向前一個元素,一個指向後一個元素。
  •     線程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保證線程安全;
     

綜合來說,在需要頻繁讀取集合中的元素時,更推薦使用 ArrayList,而在插入和刪除操作較多時,更推薦使用 LinkedList。

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

雙向鏈表也叫雙鏈表,是鏈表的一種,它的每個數據結點中都有兩個指針,分別指向直接後繼和直接前驅。所以,從雙向鏈表中的任意一個結點開始,都可以很方便地訪問它的前驅結點和後繼結點。
 

ArrayList 和 Vector 的區別是什麼?

 

這兩個類都實現了 List 接口(List 接口繼承了 Collection 接口),他們都是有序集合

  •     線程安全:Vector 使用了 Synchronized 來實現線程同步,是線程安全的,而 ArrayList 是非線程安全的。
  •     性能:ArrayList 在性能方面要優於 Vector。
  •     擴容:ArrayList 和 Vector 都會根據實際的需要動態的調整容量,只不過在 Vector 擴容每次會增加 1 倍,而 ArrayList 只會增加 50%。

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

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

插入數據時,ArrayList、LinkedList、Vector誰速度較快?闡述 ArrayList、Vector、LinkedList 的存儲性能和特性?

ArrayList、LinkedList、Vector 底層的實現都是使用數組方式存儲數據。數組元素數大於實際存儲的數據以便增加和插入元素,它們都允許直接按序號索引元素,但是插入元素要涉及數組元素移動等內存操作,所以索引數據快而插入數據慢。

Vector 中的方法由於加了 synchronized 修飾,因此 Vector 是線程安全容器,但性能上較ArrayList差

LinkedList 使用雙向鏈表實現存儲,按序號索引數據需要進行前向或後向遍歷,但插入數據時只需要記錄當前項的前後項即可,所以 LinkedList 插入速度較快
 

多線程場景下如何使用 ArrayList?

ArrayList 不是線程安全的,如果遇到多線程場景,可以通過 Collections 的 synchronizedList 方法將其轉換成線程安全的容器後再使用。例如像下面這樣:

爲什麼 ArrayList 的 elementData 加上 transient 修飾?

ArrayList 中的數組定義如下:

再看一下 ArrayList 的定義:

可以看到 ArrayList 實現了 Serializable 接口,這意味着 ArrayList 支持序列化。transient 的作用是說不希望 elementData 數組被序列化,重寫了 writeObject 實現:

每次序列化時,先調用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然後遍歷 elementData,只序列化已存入的元素,這樣既加快了序列化的速度,又減小了序列化之後的文件大小。

 

List 和 Set 的區別

List , Set 都是繼承自Collection 接口

List 特點:一個有序(元素存入集合的順序和取出的順序一致)容器,元素可以重複,可以插入多個null元素,元素都有索引。常用的實現類有 ArrayList、LinkedList 和 Vector。

Set 特點:一個無序(存入和取出順序有可能不一致)容器,不可以存儲重複元素,只允許存入一個null元素,必須保證元素唯一性。Set 接口常用實現類是 HashSet、LinkedHashSet 以及 TreeSet。

另外 List 支持for循環,也就是通過下標來遍歷,也可以用迭代器,但是set只能用迭代,因爲他無序,無法用下標來取得想要的值。

Set和List對比

Set:檢索元素效率低下,刪除和插入效率高,插入和刪除不會引起元素位置改變。
List:和數組類似,List可以動態增長,查找元素效率高,插入刪除元素效率低,因爲會引起其他元素位置改變

Set接口

說一下 HashSet 的實現原理?

HashSet 是基於 HashMap 實現的,HashSet的值存放於HashMap的key上,HashMap的value統一爲PRESENT,因此 HashSet 的實現比較簡單,相關 HashSet 的操作,基本上都是直接調用底層 HashMap 的相關方法來完成,HashSet 不允許重複的值。
 

HashSet如何檢查重複?HashSet是如何保證數據不可重複的?

向HashSet 中add ()元素時,判斷元素是否存在的依據,不僅要比較hash值,同時還要結合equles 方法比較。
HashSet 中的add ()方法會使用HashMap 的put()方法。

HashMap 的 key 是唯一的,由源碼可以看出 HashSet 添加進去的值就是作爲HashMap 的key,並且在HashMap中如果K/V相同時,會用新的V覆蓋掉舊的V,然後返回舊的V。所以不會重複( HashMap 比較key是否相等是先比較hashcode 再比較equals )。

以下是HashSet 部分源碼:

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

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

==與equals的區別

  1. ==是判斷兩個變量或實例是不是指向同一個內存空間 equals是判斷兩個變量或實例所指向的內存空間的值是不是相同
  2. ==是指對內存地址進行比較 equals()是對字符串的內容進行比較3.==指引用是否相同 equals()指的是值是否相同

HashSet與HashMap的區別

Queue

BlockingQueue是什麼?

Java.util.concurrent.BlockingQueue是一個隊列,在進行檢索或移除一個元素的時候,它會等待隊列變爲非空;當在添加一個元素時,它會等待隊列中的可用空間。BlockingQueue接口是Java集合框架的一部分,主要用於實現生產者-消費者模式。我們不需要擔心等待生產者有可用的空間,或消費者有可用的對象,因爲它都在BlockingQueue的實現類中被處理了。Java提供了集中BlockingQueue的實現,比如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue,、SynchronousQueue等。
 

在 Queue 中 poll()和 remove()有什麼區別?

  • 相同點:都是返回第一個元素,並在隊列中刪除返回的對象。
  • 不同點:如果沒有元素 poll()會返回 null,而 remove()會直接拋出 NoSuchElementException 異常。

代碼示例:

Map接口

說一下 HashMap 的實現原理?

HashMap概述: HashMap是基於哈希表的Map接口的非同步實現。此實現提供所有可選的映射操作,並允許使用null值和null鍵。此類不保證映射的順序,特別是它不保證該順序恆久不變。

HashMap的數據結構: 在Java編程語言中,最基本的結構就是兩種,一個是數組,另外一個是模擬指針(引用),所有的數據結構都可以用這兩個基本結構來構造的,HashMap也不例外。HashMap實際上是一個“鏈表散列”的數據結構,即數組和鏈表的結合體。

HashMap 基於 Hash 算法實現的
 

  • 當我們往Hashmap中put元素時,利用key的hashCode重新hash計算出當前對象的元素在數組中的下標
  • 存儲時,如果出現hash值相同的key,此時有兩種情況。(1)如果key相同,則覆蓋原始值;(2)如果key不同(出現衝突),則將當前的key-value放入鏈表中
  • 獲取時,直接找到hash值對應的下標,在進一步判斷key是否相同,從而找到對應值。
  • 理解了以上過程就不難明白HashMap是如何解決hash衝突的問題,核心就是使用了數組的存儲方式,然後將衝突的key的對象放入鏈表中,一旦發現衝突就在鏈表中做進一步的對比。
     

需要注意Jdk 1.8中對HashMap的實現做了優化,當鏈表中的節點數據超過八個之後,該鏈表會轉爲紅黑樹來提高查詢效率,從原來的O(n)到O(logn)

 

HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底層實現

在Java中,保存數據有兩種比較簡單的數據結構:數組和鏈表。數組的特點是:尋址容易,插入和刪除困難;鏈表的特點是:尋址困難,但插入和刪除容易;所以我們將數組和鏈表結合在一起,發揮兩者各自的優勢,使用一種叫做拉鍊法的方式可以解決哈希衝突。

JDK1.8之前

JDK1.8之前採用的是拉鍊法。拉鍊法:將鏈表和數組相結合。也就是說創建一個鏈表數組,數組中每一格就是一個鏈表。若遇到哈希衝突,則將衝突的值加到鏈表中即可。

JDK1.8之後

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

JDK1.7 VS JDK1.8 比較

JDK1.8主要解決或優化了一下問題:

  1. resize 擴容優化
  2. 引入了紅黑樹,目的是避免單條鏈表過長而影響查詢效率,紅黑樹算法請參考
  3. 解決了多線程死循環問題,但仍是非線程安全的,多線程時可能會造成數據丟失問題。

 

HashMap的put方法的具體流程?

當我們put的時候,首先計算 key的hash值,這裏調用了 hash方法,hash方法實際是讓key.hashCode()與key.hashCode()>>>16進行異或操作,高16bit補0,一個數和0異或不變,所以 hash 函數大概的作用就是:高16bit不變,低16bit和高16bit做了一個異或,目的是減少碰撞。按照函數註釋,因爲bucket數組大小是2的冪,計算下標index = (table.length - 1) & hash,如果不做 hash 處理,相當於散列生效的只有幾個低 bit 位,爲了減少散列的碰撞,設計者綜合考慮了速度、作用、質量之後,使用高16bit和低16bit異或來簡單處理減少碰撞,而且JDK8中用了複雜度 O(logn)的樹結構來提升碰撞下的性能。

putVal方法執行流程圖
 

 

  1. public V put(K key, V value) {
  2.     return putVal(hash(key), key, value, false, true);
  3. }
  4. static final int hash(Object key) {
  5.     int h;
  6.     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  7. }
  8. //實現Map.put和相關方法
  9. final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
  10.                    boolean evict) {
  11.     Node<K,V>[] tab; Node<K,V> p; int n, i;
  12.     // 步驟①:tab爲空則創建
  13.     // table未初始化或者長度爲0,進行擴容
  14.     if ((tab = table) == null || (n = tab.length) == 0)
  15.         n = (tab = resize()).length;
  16.     // 步驟②:計算index,並對null做處理  
  17.     // (n - 1) & hash 確定元素存放在哪個桶中,桶爲空,新生成結點放入桶中(此時,這個結點是放在數組中)
  18.     if ((p = tab[i = (n - 1) & hash]) == null)
  19.         tab[i] = newNode(hash, key, value, null);
  20.     // 桶中已經存在元素
  21.     else {
  22.         Node<K,V> e; K k;
  23.         // 步驟③:節點key存在,直接覆蓋value
  24.         // 比較桶中第一個元素(數組中的結點)的hash值相等,key相等
  25.         if (p.hash == hash &&
  26.             ((k = p.key) == key || (key != null && key.equals(k))))
  27.                 // 將第一個元素賦值給e,用e來記錄
  28.                 e = p;
  29.         // 步驟④:判斷該鏈爲紅黑樹
  30.         // hash值不相等,即key不相等;爲紅黑樹結點
  31.         // 如果當前元素類型爲TreeNode,表示爲紅黑樹,putTreeVal返回待存放的node, e可能爲null
  32.         else if (p instanceof TreeNode)
  33.             // 放入樹中
  34.             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
  35.         // 步驟⑤:該鏈爲鏈表
  36.         // 爲鏈表結點
  37.         else {
  38.             // 在鏈表最末插入結點
  39.             for (int binCount = 0; ; ++binCount) {
  40.                 // 到達鏈表的尾部
  41.                 
  42.                 //判斷該鏈表尾部指針是不是空的
  43.                 if ((e = p.next) == null) {
  44.                     // 在尾部插入新結點
  45.                     p.next = newNode(hash, key, value, null);
  46.                     //判斷鏈表的長度是否達到轉化紅黑樹的臨界值,臨界值爲8
  47.                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
  48.                         //鏈表結構轉樹形結構
  49.                         treeifyBin(tab, hash);
  50.                     // 跳出循環
  51.                     break;
  52.                 }
  53.                 // 判斷鏈表中結點的key值與插入的元素的key值是否相等
  54.                 if (e.hash == hash &&
  55.                     ((k = e.key) == key || (key != null && key.equals(k))))
  56.                     // 相等,跳出循環
  57.                     break;
  58.                 // 用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表
  59.                 p = e;
  60.             }
  61.         }
  62.         //判斷當前的key已經存在的情況下,再來一個相同的hash值、key值時,返回新來的value這個值
  63.         if (e != null) {
  64.             // 記錄e的value
  65.             V oldValue = e.value;
  66.             // onlyIfAbsent爲false或者舊值爲null
  67.             if (!onlyIfAbsent || oldValue == null)
  68.                 //用新值替換舊值
  69.                 e.value = value;
  70.             // 訪問後回調
  71.             afterNodeAccess(e);
  72.             // 返回舊值
  73.             return oldValue;
  74.         }
  75.     }
  76.     // 結構性修改
  77.     ++modCount;
  78.     // 步驟⑥:超過最大容量就擴容
  79.     // 實際大小大於閾值則擴容
  80.     if (++size > threshold)
  81.         resize();
  82.     // 插入後回調
  83.     afterNodeInsertion(evict);
  84.     return null;
  85. }
     

 

1.判斷鍵值對數組table[i]是否爲空或爲null,否則執行resize()進行擴容;

2.根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加,轉向6,如果table[i]不爲空,轉向3;

3.判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value,否則轉向4,這裏的相同指的是hashCode以及equals;

4.判斷table[i] 是否爲treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對,否則轉向5;

 5.遍歷table[i],判斷鏈表長度是否大於8,大於8的話把鏈表轉換爲紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;

6.插入成功後,判斷實際存在的鍵值對數量size是否超多了最大容量threshold,如果超過,進行擴容。


HashMap的擴容操作是怎麼實現的?

1.在jdk1.8中,resize方法是在hashmap中的鍵值對大於閥值時或者初始化時,就調用resize方法進行擴容;

2.每次擴展的時候,都是擴展2倍;

3.擴展後Node對象的位置要麼在原位置,要麼移動到原偏移量兩倍的位置。

在putVal()中,我們看到在這個函數裏面使用到了2次resize()方法,resize()方法表示的在進行第一次初始化時會對其進行擴容,或者當該數組的實際大小大於其臨界值值(第一次爲12),這個時候在擴容的同時也會伴隨的桶上面的元素進行重新分發,這也是JDK1.8版本的一個優化的地方,在1.7中,擴容之後需要重新去計算其Hash值,根據Hash值對其進行分發,但在1.8版本中,則是根據在同一個桶的位置中進行判斷(e.hash & oldCap)是否爲0,重新進行hash分配後,該元素的位置要麼停留在原始位置,要麼移動到原始位置+增加的數組大小這個位置上
 

  1. final Node<K,V>[] resize() {
  2.     Node<K,V>[] oldTab = table;//oldTab指向hash桶數組
  3.     int oldCap = (oldTab == null) ? 0 : oldTab.length;
  4.     int oldThr = threshold;
  5.     int newCap, newThr = 0;
  6.     if (oldCap > 0) {//如果oldCap不爲空的話,就是hash桶數組不爲空
  7.         if (oldCap >= MAXIMUM_CAPACITY) {//如果大於最大容量了,就賦值爲整數最大的閥值
  8.             threshold = Integer.MAX_VALUE;
  9.             return oldTab;//返回
  10.         }//如果當前hash桶數組的長度在擴容後仍然小於最大容量 並且oldCap大於默認值16
  11.         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
  12.                  oldCap >= DEFAULT_INITIAL_CAPACITY)
  13.             newThr = oldThr << 1; // double threshold 雙倍擴容閥值threshold
  14.     }
  15.     // 舊的容量爲0,但threshold大於零,代表有參構造有cap傳入,threshold已經被初始化成最小2的n次冪
  16.     // 直接將該值賦給新的容量
  17.     else if (oldThr > 0) // initial capacity was placed in threshold
  18.         newCap = oldThr;
  19.     // 無參構造創建的map,給出默認容量和threshold 16, 16*0.75
  20.     else {               // zero initial threshold signifies using defaults
  21.         newCap = DEFAULT_INITIAL_CAPACITY;
  22.         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  23.     }
  24.     // 新的threshold = 新的cap * 0.75
  25.     if (newThr == 0) {
  26.         float ft = (float)newCap * loadFactor;
  27.         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
  28.                   (int)ft : Integer.MAX_VALUE);
  29.     }
  30.     threshold = newThr;
  31.     // 計算出新的數組長度後賦給當前成員變量table
  32.     @SuppressWarnings({"rawtypes","unchecked"})
  33.         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶數組
  34.     table = newTab;//將新數組的值複製給舊的hash桶數組
  35.     // 如果原先的數組沒有初始化,那麼resize的初始化工作到此結束,否則進入擴容元素重排邏輯,使其均勻的分散
  36.     if (oldTab != null) {
  37.         // 遍歷新數組的所有桶下標
  38.         for (int j = 0; j < oldCap; ++j) {
  39.             Node<K,V> e;
  40.             if ((e = oldTab[j]) != null) {
  41.                 // 舊數組的桶下標賦給臨時變量e,並且解除舊數組中的引用,否則就數組無法被GC回收
  42.                 oldTab[j] = null;
  43.                 // 如果e.next==null,代表桶中就一個元素,不存在鏈表或者紅黑樹
  44.                 if (e.next == null)
  45.                     // 用同樣的hash映射算法把該元素加入新的數組
  46.                     newTab[e.hash & (newCap - 1)] = e;
  47.                 // 如果e是TreeNode並且e.next!=null,那麼處理樹中元素的重排
  48.                 else if (e instanceof TreeNode)
  49.                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
  50.                 // e是鏈表的頭並且e.next!=null,那麼處理鏈表中元素重排
  51.                 else { // preserve order
  52.                     // loHead,loTail 代表擴容後不用變換下標,見注1
  53.                     Node<K,V> loHead = null, loTail = null;
  54.                     // hiHead,hiTail 代表擴容後變換下標,見注1
  55.                     Node<K,V> hiHead = null, hiTail = null;
  56.                     Node<K,V> next;
  57.                     // 遍歷鏈表
  58.                     do {             
  59.                         next = e.next;
  60.                         if ((e.hash & oldCap) == 0) {
  61.                             if (loTail == null)
  62.                                 // 初始化head指向鏈表當前元素e,e不一定是鏈表的第一個元素,初始化後loHead
  63.                                 // 代表下標保持不變的鏈表的頭元素
  64.                                 loHead = e;
  65.                             else                                
  66.                                 // loTail.next指向當前e
  67.                                 loTail.next = e;
  68.                             // loTail指向當前的元素e
  69.                             // 初始化後,loTail和loHead指向相同的內存,所以當loTail.next指向下一個元素時,
  70.                             // 底層數組中的元素的next引用也相應發生變化,造成lowHead.next.next.....
  71.                             // 跟隨loTail同步,使得lowHead可以鏈接到所有屬於該鏈表的元素。
  72.                             loTail = e;                           
  73.                         }
  74.                         else {
  75.                             if (hiTail == null)
  76.                                 // 初始化head指向鏈表當前元素e, 初始化後hiHead代表下標更改的鏈表頭元素
  77.                                 hiHead = e;
  78.                             else
  79.                                 hiTail.next = e;
  80.                             hiTail = e;
  81.                         }
  82.                     } while ((e = next) != null);
  83.                     // 遍歷結束, 將tail指向null,並把鏈表頭放入新數組的相應下標,形成新的映射。
  84.                     if (loTail != null) {
  85.                         loTail.next = null;
  86.                         newTab[j] = loHead;
  87.                     }
  88.                     if (hiTail != null) {
  89.                         hiTail.next = null;
  90.                         newTab[j + oldCap] = hiHead;
  91.                     }
  92.                 }
  93.             }
  94.         }
  95.     }
  96.     return newTab;
  97. }
     

HashMap是怎麼解決哈希衝突的?

答:在解決這個問題之前,我們首先需要知道什麼是哈希衝突,而在瞭解哈希衝突之前我們還要知道什麼是哈希纔行;

什麼是哈希?

Hash,一般翻譯爲“散列”,也有直接音譯爲“哈希”的,這就是把任意長度的輸入通過散列算法,變換成固定長度的輸出,該輸出就是散列值(哈希值);這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,所以不可能從散列值來唯一的確定輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數

所有散列函數都有如下一個基本特性**:根據同一散列函數計算出的散列值如果不同,那麼輸入值肯定也不同。但是,根據同一散列函數計算出的散列值如果相同,輸入值不一定相同**。
 

什麼是哈希衝突?

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

HashMap的數據結構

在Java中,保存數據有兩種比較簡單的數據結構:數組和鏈表。數組的特點是:尋址容易,插入和刪除困難;鏈表的特點是:尋址困難,但插入和刪除容易;所以我們將數組和鏈表結合在一起,發揮兩者各自的優勢,使用一種叫做鏈地址法的方式可以解決哈希衝突:

這樣我們就可以將擁有相同哈希值的對象組織成一個鏈表放在hash值所對應的bucket下,但相比於hashCode返回的int類型,我們HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要遠小於int類型的範圍,所以我們如果只是單純的用hashCode取餘來獲取對應的bucket這將會大大增加哈希碰撞的概率,並且最壞情況下還會將HashMap變成一個單鏈表,所以我們還需要對hashCode作一定的優化
 

hash()函數

上面提到的問題,主要是因爲如果使用hashCode取餘,那麼相當於參與運算的只有hashCode的低位,高位是沒有起到任何作用的,所以我們的思路就是讓hashCode取值出的高位也參與運算,進一步降低hash碰撞的概率,使得數據分佈更平均,我們把這樣的操作稱爲擾動,在JDK 1.8中的hash()函數如下:
 

這比在JDK 1.7中,更爲簡潔,相比在1.7中的4次位運算,5次異或運算(9次擾動),在1.8中,只進行了1次位運算和1次異或運算(2次擾動)

JDK1.8新增紅黑樹

通過上面的鏈地址法(使用散列表)和擾動函數我們成功讓我們的數據分佈更平均,哈希碰撞減少,但是當我們的HashMap中存在大量數據時,加入我們某個bucket下對應的鏈表有n個元素,那麼遍歷時間複雜度就爲O(n),爲了針對這個問題,JDK1.8在HashMap中新增了紅黑樹的數據結構,進一步使得遍歷複雜度降低至O(logn);
 

總結

簡單總結一下HashMap是使用了哪些方法來有效解決哈希衝突的:

1. 使用鏈地址法(使用散列表)來鏈接擁有相同hash值的數據;
2. 使用2次擾動函數(hash函數)來降低哈希衝突的概率,使得數據分佈更平均;
3. 引入紅黑樹進一步降低遍歷的時間複雜度,使得遍歷更快;

 

能否使用任何類作爲 Map 的 key?

可以使用任何類作爲 Map 的 key,然而在使用之前,需要考慮以下幾點:

  •     如果類重寫了 equals() 方法,也應該重寫 hashCode() 方法。
  •     類的所有實例需要遵循與 equals() 和 hashCode() 相關的規則。
  •     如果一個類沒有使用 equals(),不應該在 hashCode() 中使用它。
  •     用戶自定義 Key 類最佳實踐是使之爲不可變的,這樣 hashCode() 值可以被緩存起來,擁有更好的性能。不可變的類也可以確保 hashCode() 和 equals() 在未來不會改變,這樣就會解決與可變相關的問題了。
     

爲什麼HashMap中String、Integer這樣的包裝類適合作爲K?

答:String、Integer等包裝類的特性能夠保證Hash值的不可更改性和計算準確性,能夠有效的減少Hash碰撞的機率

  •     都是final類型,即不可變性,保證key的不可更改性,不會存在獲取hash值不同的情況
  •     內部已重寫了equals()、hashCode()等方法,遵守了HashMap內部的規範(不清楚可以去上面看看putValue的過程),不容易出現Hash值計算錯誤的情況;
     

如果使用Object作爲HashMap的Key,應該怎麼辦呢?

答:重寫hashCode()equals()方法

    重寫hashCode()是因爲需要計算存儲數據的存儲位置,需要注意不要試圖從散列碼計算中排除掉一個對象的關鍵部分來提高性能,這樣雖然能更快但可能會導致更多的Hash碰撞;
    重寫equals()方法,需要遵守自反性、對稱性、傳遞性、一致性以及對於任何非null的引用值x,x.equals(null)必須返回false的這幾個特性,目的是爲了保證key在哈希表中的唯一性;
 

HashMap爲什麼不直接使用hashCode()處理後的哈希值直接作爲table的下標?

答:hashCode()方法返回的是int整數類型,其範圍爲-(2 ^ 31)~(2 ^ 31 - 1),約有40億個映射空間,而HashMap的容量範圍是在16(初始化默認值)~2 ^ 30,HashMap通常情況下是取不到最大值的,並且設備上也難以提供這麼多的存儲空間,從而導致通過hashCode()計算出的哈希值可能不在數組大小範圍內,進而無法匹配存儲位置;
那怎麼解決呢?

  1. HashMap自己實現了自己的hash()方法,通過兩次擾動使得它自己的哈希值高低位自行進行異或運算,降低哈希碰撞概率也使得數據分佈更平均;
  2. 在保證數組長度爲2的冪次方的時候,使用hash()運算之後的值與運算(&)(數組長度 - 1)來獲取數組下標的方式進行存儲,這樣一來是比取餘操作更加有效率,二來也是因爲只有當數組長度爲2的冪次方時,h&(length-1)纔等價於h%length,三來解決了“哈希值與數組大小範圍不匹配”的問題;
     

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

爲了能讓 HashMap 存取高效,儘量較少碰撞,也就是要儘量把數據分配均勻,每個鏈表/紅黑樹長度大致相同。這個實現就是把數據存到哪個鏈表/紅黑樹中的算法。

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

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

那爲什麼是兩次擾動呢?

答:這樣就是加大哈希值低位的隨機性,使得分佈更均勻,從而提高對應數組存儲下標位置的隨機性&均勻性,最終減少Hash衝突,兩次就夠了,已經達到了高位低位同時參與運算的目的;

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 總是使用2的冪作爲哈希表的大小,後面會介紹到爲什麼是2的冪次方。
  5. 底層數據結構: JDK1.8 以後的 HashMap 在解決哈希衝突時有了較大的變化,當鏈表長度大於閾值(默認爲8)時,將鏈表轉化爲紅黑樹,以減少搜索時間。Hashtable 沒有這樣的機制。
  6. 推薦使用:在 Hashtable 的類註釋可以看到,Hashtable 是保留類不建議使用,推薦在單線程環境下使用 HashMap 替代,如果需要多線程使用則用 ConcurrentHashMap 替代。

如何決定使用 HashMap 還是 TreeMap?

對於在Map中插入、刪除和定位元素這類操作,HashMap是最好的選擇。然而,假如你需要對一個有序的key集合進行遍歷,TreeMap是更好的選擇。基於你的collection的大小,也許向HashMap中添加元素會更快,將map換爲TreeMap進行有序key的遍歷。

HashMap 和 ConcurrentHashMap 的區別

  • ConcurrentHashMap對整個桶數組進行了分割分段(Segment),然後在每一個分段上都用lock鎖進行保護,相對於HashTable的synchronized鎖的粒度更精細了一些,併發性能更好,而HashMap沒有鎖機制,不是線程安全的。(JDK1.8之後ConcurrentHashMap啓用了一種全新的方式實現,利用CAS算法。)
  • HashMap的鍵值對允許有null,但是ConCurrentHashMap都不允許。
     

ConcurrentHashMap 和 Hashtable 的區別?

 

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

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

 

兩者的對比圖

HashTable:

 

JDK1.7的ConcurrentHashMap:

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

 

答:ConcurrentHashMap 結合了 HashMap 和 HashTable 二者的優勢。HashMap 沒有考慮同步,HashTable 考慮了同步的問題。但是 HashTable 在每次同步執行時都要鎖住整個結構。 ConcurrentHashMap 鎖的方式是稍微細粒度的。

ConcurrentHashMap 底層具體實現知道嗎?實現原理是什麼?

JDK1.7

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

在JDK1.7中,ConcurrentHashMap採用Segment + HashEntry的方式進行實現,結構如下:

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

 

附加源碼,有需要的可以看看

插入元素過程(建議去看看源碼):

如果相應位置的Node還沒有初始化,則調用CAS插入相應的數據;

如果相應位置的Node不爲空,且當前該節點不處於移動狀態,則對該節點加synchronized鎖,如果該節點的hash不小於0,則遍歷鏈表更新節點或插入新節點;

  1. 如果該節點是TreeBin類型的節點,說明是紅黑樹結構,則通過putTreeVal方法往紅黑樹中插入節點;如果binCount不爲0,說明put操作對數據產生了影響,如果當前鏈表的個數達到8個,則通過treeifyBin方法轉化爲紅黑樹,如果oldVal不爲空,說明是一次更新操作,沒有對元素個數產生影響,則直接返回舊值;
  2. 如果插入的是一個新節點,則執行addCount()方法嘗試更新元素個數baseCount;
     

輔助工具類

Array 和 ArrayList 有何區別?

  • Array 可以存儲基本數據類型和對象,ArrayList 只能存儲對象。
  • Array 是指定固定大小的,而 ArrayList 大小是自動擴展的。
  • Array 內置方法沒有 ArrayList 多,比如 addAll、removeAll、iteration 等方法只有 ArrayList 有。
     

對於基本類型數據,集合使用自動裝箱來減少編碼工作量。但是,當處理固定大小的基本數據類型的時候,這種方式相對比較慢。

如何實現 Array 和 List 之間的轉換?

  • Array 轉 List: Arrays. asList(array) ;
  • List 轉 Array:List 的 toArray() 方法。

comparable 和 comparator的區別?

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

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

Collection 和 Collections 有什麼區別?

  •     java.util.Collection 是一個集合接口(集合類的一個頂級接口)。它提供了對集合對象進行基本操作的通用接口方法。Collection接口在Java 類庫中有很多具體的實現。Collection接口的意義是爲各種具體的集合提供了最大化的統一操作方式,其直接繼承接口有List與Set。
  •     Collections則是集合類的一個工具類/幫助類,其中提供了一系列靜態方法,用於對集合中元素進行排序、搜索以及線程安全等各種操作。
     

TreeMap 和 TreeSet 在排序時如何比較元素?Collections 工具類中的 sort()方法如何比較元素?

TreeSet 要求存放的對象所屬的類必須實現 Comparable 接口,該接口提供了比較元素的 compareTo()方法,當插入元素時會回調該方法比較元素的大小。TreeMap 要求存放的鍵值對映射的鍵必須實現 Comparable 接口從而根據鍵對元素進 行排 序。

Collections 工具類的 sort 方法有兩種重載的形式,

第一種要求傳入的待排序容器中存放的對象比較實現 Comparable 接口以實現元素的比較;

第二種不強制性的要求容器中的元素必須可比較,但是要求傳入第二個參數,參數是Comparator 接口的子類型(需要重寫 compare 方法實現元素的比較),相當於一個臨時定義的排序規則,其實就是通過接口注入比較元素大小的算法,也是對回調模式的應用(Java 中對函數式編程的支持)。
 

 

 

 

 

 

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