2_Java集合容器面試題

集合容器

1. 集合容器概述

什麼是集合

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

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

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

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

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

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

集合的特點

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

  • 對象封裝數據,對象多了也需要存儲。集合用於存儲對象。
  • 對象的個數確定可以使用數組,對象的個數不確定的可以用集合。因爲集合是可變長度的。

集合和數組的區別

  • 數組是固定長度的;集合可變長度的。
  • 數組可以存儲基本數據類型,也可以存儲引用數據類型;集合只能存儲引用數據類型。
  • 數組存儲的元素必須是同一個數據類型;集合存儲的對象可以是不同數據類型。

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

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

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

使用集合框架的好處

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

常用的集合類有哪些?

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

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

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

img

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

  1. List
  • Arraylist: Object數組
  • Vector: Object數組
  • 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 在上面結構的基礎上,增加了一條雙向鏈表,使得上面的結構可以保持鍵值對的插入順序。同時通過對鏈表進行相應的操作,實現了訪問順序相關邏輯。
  • HashTable: 數組+鏈表組成的,數組是 HashMap 的主體,鏈表則是主要爲了解決哈希衝突而存在的
    TreeMap: 紅黑樹(自平衡的排序二叉樹)

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

  • vector:就比arraylist多了個同步化機制(線程安全),因爲效率較低,現在已經不太建議使用。在web應用中,特別是前臺頁面,往往效率(頁面響應速度)是優先考慮的。
  • stack:堆棧類,先進後出。
  • 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

Collection接口

List接口

迭代器 Iterator 是什麼?

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

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

Iterator 使用代碼如下:

List<String> list = new ArrayList<>();
Iterator<String> it = list. iterator();
while(it. hasNext()){
  String obj = it. next();
  System. out. println(obj);
}

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

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

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

Iterator<Integer> it = list.iterator();
while(it.hasNext()){
   *// do something*
   it.remove();
}

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

for(Integer i : list){
   list.remove(i)
}

運行以上錯誤代碼會報 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。

說一下 ArrayList 的優缺點

ArrayList的優點如下:

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

ArrayList 的缺點如下:

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

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

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

  • 數組轉 List:使用 Arrays. asList(array) 進行轉換。
  • List 轉數組:使用 List 自帶的 toArray() 方法。
// list to array
List<String> list = new ArrayList<String>();
list.add("123");
list.add("456");
list.toArray();

// array to list
String[] array = new String[]{"123","456"};
Arrays.asList(array);

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

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

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

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

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

List<String> synchronizedList = Collections.synchronizedList(list);
synchronizedList.add("aaa");
synchronizedList.add("bbb");

for (int i = 0; i < synchronizedList.size(); i++) {
    System.out.println(synchronizedList.get(i));
}

關鍵字transient

Java語言的關鍵字,變量修飾符,如果用transient聲明一個實例變量,當對象存儲時,它的值不需要維持。換句話來說就是,用transient關鍵字標記的成員變量不參與序列化過程。

作用
Java的serialization提供了一種持久化對象實例的機制。當持久化對象時,可能有一個特殊的對象數據成員,我們不想用serialization機制來保存它。爲了在一個特定對象的一個域上關閉serialization,可以在這個域前加上關鍵字transient。當一個對象被序列化的時候,transient型變量的值不包括在序列化的表示中,然而非transient型的變量是被包括進去的。

java中的transient關鍵字詳解

https://blog.csdn.net/qq_44543508/article/details/103232007?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522158881707219195239831309%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=158881707219195239831309&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2blogfirst_rank_v2~rank_v25-1

transient關鍵字的主要作用就是讓某些被transient關鍵字修飾的成員屬性變量不被序列化

1、何謂序列化?

專業術語定義的序列化:

Java提供了一種對象序列化的機制。用一個字節序列可以表示一個對象,該字節序列包含該對象的數據、對象的類型和對象中存儲的屬性等信息。字節序列寫出到文件之後,相當於文件中持久保存了一個對象的信息。反之,該字節序列還可以從文件中讀取回來,重構對象,對它進行反序列化。對象的數據、對象的類型和對象中存儲的數據信息,都可以用來在內存中創建對象。

在这里插å¥å›¾ç‰‡æè¿°

2、爲何要序列化?

Java中對象的序列化指的是將對象轉換成以字節序列的形式來表示,這些字節序列包含了對象的數據和信息,一個序列化後的對象 可以被寫到數據庫或文件中,也可用於 網絡傳輸,一般當我們使用 緩存cache(內存空間不夠有可能會本地存儲到硬盤)或 遠程調用rpc(網絡傳輸)的時候,經常需要讓我們的實體類實現Serializable接口,目的就是爲了讓其可序列化。

什麼時候需要用到序列化?
a、數據持久化:比如一個電商平臺,有數萬個用戶併發訪問的時候會產生數萬個session 對象,這個時候內存的壓力是很大的。我們可以把session對象序列化到硬盤中,需要時在反序列化,減少內存壓力。
b、網絡傳輸:我們將系統拆分成多個服務之後,服務之間傳輸對象,不管是何種類型的數據,都必須要轉成二進制流來傳輸,接受方收到後再轉爲數據對象。

在開發過程中要使用transient關鍵字的例子:

**如果一個用戶有一些密碼等信息,爲了安全起見,不希望在網絡操作中被傳輸,這些信息對應的變量就可以加上transient關鍵字。**換句話說,這個字段的生命週期僅存於調用者的內存中而不會寫到磁盤裏持久化。

在開發過程中不需要transient關鍵字修飾的例子:

1、類中的字段值可以根據其它字段推導出來。
2、看具體業務需求,哪些字段不想被序列化;

不知道各位有木有想過爲什麼要不被序列化呢?其實主要是爲了節省存儲空間。優化程序

記得之前看HashMap源碼的時候,發現有個字段是用transient修飾的,我覺得還是有道理的,確實沒必要對這個modCount字段進行序列化,因爲沒有意義,modCount主要用於判斷HashMap是否被修改(像put、remove操作的時候,modCount都會自增),對於這種變量,一開始可以爲任何值,0當然也是可以(new出來、反序列化出來、或者克隆clone出來的時候都是爲0的),沒必要持久化其值。

當然,序列化後的最終目的是爲了反序列化,恢復成原先的Java對象,要不然序列化後幹嘛呢,就像買菜一樣,用塑料袋包裹最後還是爲了方便安全到家再去掉塑料袋,所以序列化後的字節序列都是可以恢復成Java對象的,這個過程就是反序列化。

3、序列化與transient的使用

1、需要做序列化的對象的類,必須實現序列化接口:Java.lang.Serializable 接口一個標誌接口,沒有任何抽象方法),Java 中大多數類都實現了該接口,比如:String,Integer類等,不實現此接口的類將不會使任何狀態序列化或反序列化,會拋NotSerializableException異常 。
2、底層會判斷,如果當前對象是 Serializable 的實例,才允許做序列化,Java對象 instanceof Serializable 來判斷。

3、在 Java 中使用對象流ObjectOutputStream來完成序列化以及ObjectInputStream流反序列化

ObjectOutputStream:通過 writeObject()方法做序列化操作

ObjectInputStream:通過 readObject() 方法做反序列化操作

4、該類的所有屬性必須是可序列化的。如果有一個屬性不需要可序列化的,則該屬性必須註明是瞬態的,使用transient 關鍵字修飾。

在这里插å¥å›¾ç‰‡æè¿°

由於字節嘛所以肯定要涉及流的操作,也就是對象流也叫序列化流ObjectOutputstream,下面進行多種情況分析序列化的操作代碼!

3.1、沒有實現Serializable接口進行序列化情況
package TransientTest;
import java.io.*;

class UserInfo {  //================================注意這裏沒有實現Serializable接口
    private String name;
    private transient String password;

    public UserInfo(String name,String psw) {
        this.name = name;
        this.password=psw;
    }

    @Override
    public String toString() {
        return "UserInfo{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

public class TransientDemo {
    public static void main(String[] args) {

        UserInfo userInfo=new UserInfo("老王","123");
        System.out.println("序列化之前信息:"+userInfo);

        try {
            ObjectOutputStream output=new ObjectOutputStream(new FileOutputStream("userinfo.txt"));
            output.writeObject(new UserInfo("老王","123"));
            output.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

運行結果

在这里插å¥å›¾ç‰‡æè¿°

3.2、實現Serializable接口序列化情況

當我們加上實現Serializable接口再運行會發現,項目中出現的userinfo.txt文件內容是這樣的:

在这里插å¥å›¾ç‰‡æè¿°

3.3、普通序列化情況
package TransientTest;
import java.io.*;

class UserInfo implements Serializable{  //第一步實現Serializable接口
    private String name;
    private String password;//都是普通屬性==============================

    public UserInfo(String name,String psw) {
        this.name = name;
        this.password=psw;
    }

    @Override
    public String toString() {
        return "UserInfo{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

public class TransientDemo {
    public static void main(String[] args) throws ClassNotFoundException {

        UserInfo userInfo=new UserInfo("程序員老王","123");
        System.out.println("序列化之前信息:"+userInfo);

        try {
            ObjectOutputStream output=new ObjectOutputStream(new FileOutputStream("userinfo.txt")); //第二步開始序列化操作
            output.writeObject(new UserInfo("程序員老王","123"));
            output.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            ObjectInputStream input=new ObjectInputStream(new FileInputStream("userinfo.txt"));//第三步開始反序列化操作
            Object o = input.readObject();//ObjectInputStream的readObject方法會拋出ClassNotFoundException
            System.out.println("序列化之後信息:"+o);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

運行結果:

序列化之前信息:UserInfo{name='程序員老王', password='123'}
序列化之後信息:UserInfo{name='程序員老王', password='123'}
3.4、transient序列化情況
package TransientTest;
import java.io.*;

class UserInfo implements Serializable{  //第一步實現Serializable接口
    private String name;
    private transient String password; //特別注意:屬性由transient關鍵字修飾===========

    public UserInfo(String name,String psw) {
        this.name = name;
        this.password=psw;
    }

    @Override
    public String toString() {
        return "UserInfo{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

public class TransientDemo {
    public static void main(String[] args) throws ClassNotFoundException {

        UserInfo userInfo=new UserInfo("程序員老王","123");
        System.out.println("序列化之前信息:"+userInfo);

        try {
            ObjectOutputStream output=new ObjectOutputStream(new FileOutputStream("userinfo.txt")); //第二步開始序列化操作
            output.writeObject(new UserInfo("程序員老王","123"));
            output.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            ObjectInputStream input=new ObjectInputStream(new FileInputStream("userinfo.txt"));//第三步開始反序列化操作
            Object o = input.readObject();//ObjectInputStream的readObject方法會拋出ClassNotFoundException
            System.out.println("序列化之後信息:"+o);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

運行結果:

序列化之前信息:UserInfo{name='程序員老王', password='123'}
序列化之後信息:UserInfo{name='程序員老王', password='null'}

特別注意結果,添加transient修飾的屬性值爲默認值null!如果被transient修飾的屬性爲int類型,那它被序列化之後值一定是0,當然各位可以去試試,這能說明什麼呢?說明被標記爲transient的屬性在對象被序列化的時候不會被保存(或者說變量不會持久化)

3.5、static序列化情況
package TransientTest;
import java.io.*;

class UserInfo implements Serializable{  //第一步實現Serializable接口
    private String name;
    private static String password; //特別注意:屬性由static關鍵字修飾==============

    public UserInfo(String name, String psw) {
        this.name = name;
        this.password=psw;
    }

    @Override
    public String toString() {
        return "UserInfo{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

public class TransientDemo {
    public static void main(String[] args) throws ClassNotFoundException {

        UserInfo userInfo=new UserInfo("程序員老王","123");
        System.out.println("序列化之前信息:"+userInfo);

        try {
            ObjectOutputStream output=new ObjectOutputStream(new FileOutputStream("userinfo.txt")); //第二步開始序列化操作
            output.writeObject(new UserInfo("程序員老王","123"));
            output.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

        try {
            ObjectInputStream input=new ObjectInputStream(new FileInputStream("userinfo.txt"));//第三步開始反序列化操作
            Object o = input.readObject();//ObjectInputStream的readObject方法會拋出ClassNotFoundException
            System.out.println("序列化之後信息:"+o);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

運行結果:

序列化之前信息:UserInfo{name='程序員老王', password='123'}
序列化之後信息:UserInfo{name='程序員老王', password='123'}

這個時候,你就會錯誤的認爲static修飾的也被序列化了,其實不然!明明取出null(默認值)就可以說明不會被序列化,這裏明明沒有變成默認值,爲何還要說static不會被序列化呢?

**實際上,反序列化後類中static型變量name的值實際上是當前JVM中對應static變量的值,這個值是JVM中的並不是反序列化得出的。**也就是說被static修飾的變量並沒有參與序列化!但是咱也不能口說無憑啊,是的,那我們就來看兩個程序對比一下就明白了!

第一個程序:這是一個沒有被static修飾的name屬性程序:

package Thread;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

class UserInfo implements Serializable {
    private String name;
    private transient String psw;

    public UserInfo(String name, String psw) {
        this.name = name;
        this.psw = psw;
    }

    public  String getName() {
        return name;
    }

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

    public String getPsw() {
        return psw;
    }

    public void setPsw(String psw) {
        this.psw = psw;
    }

    public String toString() {
        return "name=" + name + ", psw=" + psw;
    }
}
public class TestTransient {
    public static void main(String[] args) {
        UserInfo userInfo = new UserInfo("程序員老過", "456");
        System.out.println(userInfo);
        try {
            // 序列化,被設置爲transient的屬性沒有被序列化
            ObjectOutputStream o = new ObjectOutputStream(new FileOutputStream("UserInfo.txt"));
            o.writeObject(userInfo);
            o.close();
        } catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
        try {
            //在反序列化之前改變name的值 =================================注意這裏的代碼
            userInfo.setName("程序員老改");
            // 重新讀取內容
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("UserInfo.txt"));
            UserInfo readUserInfo = (UserInfo) in.readObject();
            //讀取後psw的內容爲null
            System.out.println(readUserInfo.toString());
        } catch (Exception e) {
            // TODO: handle exception
            e.printStackTrace();
        }
    }
}

運行結果:

name=程序員老過, psw=456
name=程序員老改, psw=null
3.6、final序列化情況

對於final關鍵字來講,final變量將直接通過值參與序列化,至於代碼程序我就不再貼出來了,大家可以試着用final修飾驗證一下!

主要注意的是final 和transient可以同時修飾同一個變量,結果也是一樣的,對transient沒有影響,這裏主要提一下,希望各位以後在開發中遇到這些情況不會滿頭霧水!

4、java類中serialVersionUID作用

既然提到了transient關鍵字就不得不提到序列化,既然提到了序列化,就不得不提到serialVersionUID了,它是啥呢?基本上有序列化就會存在這個serialVersionUID。

在这里插å¥å›¾ç‰‡æè¿°

serialVersionUID適用於Java的序列化機制。**簡單來說,Java的序列化機制是通過判斷類的serialVersionUID來驗證版本一致性的。在進行反序列化時,JVM會把傳來的字節流中的serialVersionUID與本地相應實體類的serialVersionUID進行比較,如果相同就認爲是一致的,可以進行反序列化,否則就會出現序列化版本不一致的異常,即是InvalidCastException,**在開發中有時候可寫可不寫,建議最好還是寫上比較好。

5、transient關鍵字小結

1、變量被transient修飾,變量將不會被序列化。
2、transient關鍵字只能修飾變量,而不能修飾方法和類。
3、被static關鍵字修飾的變量不參與序列化,一個靜態static變量不管是否被transient修飾,均不能被序列化。
4、final變量值參與序列化,final transient同時修飾變量,final不會影響transient,一樣不會參與序列化第二點需要注意的是:本地變量是不能被transient關鍵字修飾的。變量如果是用戶自定義類變量,則該類需要實現Serializable接口

第三點需要注意的是:反序列化後類中static型變量的值實際上是當前JVM中對應static變量的值,這個值是JVM中的並不是反序列化得出的。

結語:被transient關鍵字修飾導致不被序列化,其優點是可以節省存儲空間。優化程序!隨之而來的是會導致被transient修飾的字段會重新計算,初始化!

ArrayList中elementData爲什麼被transient修飾

https://www.jianshu.com/p/14876ef38721

在閱讀ArrayList源碼時,發現保存元素的數組 elementData 使用 transient 修飾,該關鍵字聲明數組默認不會被序列化。

    /**
     * The array buffer into which the elements of the ArrayList are stored.
     * The capacity of the ArrayList is the length of this array buffer. Any
     * empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
     * will be expanded to DEFAULT_CAPACITY when the first element is added.
     */
    // Android-note: Also accessed from java.util.Collections
    transient Object[] elementData; // non-private to simplify nested class access

那麼在序列化後,ArrayList裏面的元素數組保存的數據不就完全丟失了嗎?
深入研究代碼後發現事實上,並不會,ArrayList提供了兩個用於序列化和反序列化的方法,
readObject和writeObject

    /**
     * Save the state of the <tt>ArrayList</tt> instance to a stream (that
     * is, serialize it).
     *
     * @serialData The length of the array backing the <tt>ArrayList</tt>
     *             instance is emitted (int), followed by all of its elements
     *             (each an <tt>Object</tt>) in the proper order.
     */
    private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException{
        // Write out element count, and any hidden stuff
        int expectedModCount = modCount;
        s.defaultWriteObject();

        // Write out size as capacity for behavioural compatibility with clone()
        s.writeInt(size);

        // Write out all elements in the proper order.
        for (int i=0; i<size; i++) {
            s.writeObject(elementData[i]);
        }

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

    /**
     * Reconstitute the <tt>ArrayList</tt> instance from a stream (that is,
     * deserialize it).
     */
    private void readObject(java.io.ObjectInputStream s)
        throws java.io.IOException, ClassNotFoundException {
        elementData = EMPTY_ELEMENTDATA;

        // Read in size, and any hidden stuff
        s.defaultReadObject();

        // Read in capacity
        s.readInt(); // ignored

        if (size > 0) {
            // be like clone(), allocate array based upon size not capacity
            ensureCapacityInternal(size);

            Object[] a = elementData;
            // Read in all elements in the proper order.
            for (int i=0; i<size; i++) {
                a[i] = s.readObject();
            }
        }
    }

ArrayList在序列化的時候會調用writeObject,直接將size和element寫入ObjectOutputStream;
反序列化時調用readObject,從ObjectInputStream獲取size和element,再恢復到elementData。

爲什麼不直接用elementData來序列化,而採用上面的方式來實現序列化呢?

原因在於elementData是一個緩存數組,默認size爲10,對ArrayList進行add操作當空間不足時,

會對ArrayList進行擴容。通常擴容的倍數爲1.5倍。

    /**
     * Increases the capacity to ensure that it can hold at least the
     * number of elements specified by the minimum capacity argument.
     *
     * @param minCapacity the desired minimum capacity
     */
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

所以elementData數組會預留一些容量,等容量不足時再擴充容量,那麼有些空間可能就沒有實際存儲元素,採用上面的方式來實現序列化時,就可以保證只序列化實際存儲的那些元素,而不是整個數組,從而節省空間和時間

序列化的時候是怎麼調用writeObject和readObject的

奇怪了?儘管writeObject和readObject被外部類調用但事實上這是兩個private的方法。並且它們既不存在於java.lang.Object,也沒有在Serializable中聲明。那麼ObjectOutputStream如何使用它們的呢?

序列化時需要使用 ObjectOutputStream 的 writeObject() 將對象轉換爲字節流並輸出。而 writeObject() 方法在傳入的對象存在 writeObject() 的時候會去反射調用該對象的 writeObject() 來實現序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法,原理類似。

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);

我們以ObjectInputStream爲例,大體梳理一下調用流程,感興趣的同學可以跟着讀一下源碼

首先,反序列化時會調用readObject() -> Object obj = readObject0(false) -> readObject0 -> return checkResolve(readOrdinaryObject(unshared)) -> readOrdinaryObject -> readSerialData(obj, desc);

然後readSerialData會調用slotDesc.invokeReadObject(obj, this)
這裏調用ObjectStreamClass的invokeReadObject(Object obj, ObjectInputStream in)
裏面的readObjectMethod.invoke(obj, new Object[]{ in });

這顯然是一個通過反射進行的方法調用,那麼readObjectMethod是什麼方法?
readObjectMethod = getPrivateMethod(cl, "readObject",new Class[] { ObjectInputStream.class },Void.TYPE); writeObjectMethod = getPrivateMethod(cl, "writeObject",new Class[] { ObjectOutputStream.class },Void.TYPE);

可以看到writeObjectMethod也在這裏
getPrivateMethod方法如下:

    /**
     * Returns non-static private method with given signature defined by given
     * class, or null if none found.  Access checks are disabled on the
     * returned method (if any).
     */
    private static Method getPrivateMethod(Class<?> cl, String name,
                                           Class<?>[] argTypes,
                                           Class<?> returnType)
    {
        try {
            Method meth = cl.getDeclaredMethod(name, argTypes);
            meth.setAccessible(true);
            int mods = meth.getModifiers();
            return ((meth.getReturnType() == returnType) &&
                    ((mods & Modifier.STATIC) == 0) &&
                    ((mods & Modifier.PRIVATE) != 0)) ? meth : null;
        } catch (NoSuchMethodException ex) {
            return null;
        }
    }

到這裏我們就大概上明白了,ObjectInputStream會通過反射的形式,調用private的readObject方法

其實在java集合框架中,還有很多中集合都採用了這種方式,修飾數據集合數組,比如
CopyOnWriteArrayList
private transient volatile Object[] elements;
HashMap
transient Node[] table;
HashSet
private transient HashMap map;

究其原因,都是爲了保證只序列化實際存儲的那些元素,而不是整個數組,從而節省空間和時間。

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 部分源碼:

private static final Object PRESENT = new Object();
private transient HashMap<E,Object> map;

public HashSet() {
    map = new HashMap<>();
}

public boolean add(E e) {
    // 調用HashMap的put方法,PRESENT是一個至始至終都相同的虛值
	return map.put(e, PRESENT)==null;
}

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

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

HashSet與HashMap的區別

HashMap HashSet
實現了Map接口 實現Set接口
存儲鍵值對 僅存儲對象
調用put()向map中添加元素 調用add()方法向Set中添加元素
HashMap使用鍵(Key)計算Hashcode HashSet使用成員對象來計算hashcode值,對於兩個對象來說hashcode可能相同,所以equals()方法用來判斷對象的相等性,如果兩個對象不同的話,那麼返回false
HashMap相對於HashSet較快,因爲它是使用唯一的鍵獲取對象 HashSet較HashMap來說比較慢

Queue

BlockingQueue是什麼?

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

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

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

代碼示例:

Queue<String> queue = new LinkedList<String>();
queue. offer("string"); // add
System. out. println(queue. poll());
System. out. println(queue. remove());
System. out. println(queue. size());

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.7中HashMap数据结构

JDK1.8之後

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

jdk1.8中HashMap数据结构

JDK1.7 VS JDK1.8 比較

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

  1. resize 擴容優化
  2. 引入了紅黑樹,目的是避免單條鏈表過長而影響查詢效率,紅黑樹算法請參考
  3. 解決了多線程死循環問題,但仍是非線程安全的,多線程時可能會造成數據丟失問題。
不同 JDK 1.7 JDK 1.8
存儲結構 數組 + 鏈表 數組 + 鏈表 + 紅黑樹
初始化方式 單獨函數:inflateTable() 直接集成到了擴容函數resize()
hash值計算方式 擾動處理 = 9次擾動 = 4次位運算 + 5次異或運算 擾動處理 = 2次擾動 = 1次位運算 + 1次異或運算
存放數據的規則 無衝突時,存放數組;衝突時,存放鏈表 無衝突時,存放數組;衝突 & 鏈表長度 < 8:存放單鏈表;衝突 & 鏈表長度 > 8:樹化並存放紅黑樹
插入數據方式 頭插法(先講原位置的數據移到後1位,再插入數據到該位置) 尾插法(直接插入到鏈表尾部/紅黑樹)
擴容後存儲位置的計算方式 全部按照原來方法進行計算(即hashCode ->> 擾動函數 ->> (h&length-1)) 按照擴容後的規律計算(即擴容後的位置=原位置 or 原位置 + 舊容量)

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方法執行流程圖

putVal方法执行流程图

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

//實現Map.put和相關方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 步驟①:tab爲空則創建 
    // table未初始化或者長度爲0,進行擴容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步驟②:計算index,並對null做處理  
    // (n - 1) & hash 確定元素存放在哪個桶中,桶爲空,新生成結點放入桶中(此時,這個結點是放在數組中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已經存在元素
    else {
        Node<K,V> e; K k;
        // 步驟③:節點key存在,直接覆蓋value 
        // 比較桶中第一個元素(數組中的結點)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 將第一個元素賦值給e,用e來記錄
                e = p;
        // 步驟④:判斷該鏈爲紅黑樹 
        // hash值不相等,即key不相等;爲紅黑樹結點
        // 如果當前元素類型爲TreeNode,表示爲紅黑樹,putTreeVal返回待存放的node, e可能爲null
        else if (p instanceof TreeNode)
            // 放入樹中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 步驟⑤:該鏈爲鏈表 
        // 爲鏈表結點
        else {
            // 在鏈表最末插入結點
            for (int binCount = 0; ; ++binCount) {
                // 到達鏈表的尾部
                
                //判斷該鏈表尾部指針是不是空的
                if ((e = p.next) == null) {
                    // 在尾部插入新結點
                    p.next = newNode(hash, key, value, null);
                    //判斷鏈表的長度是否達到轉化紅黑樹的臨界值,臨界值爲8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //鏈表結構轉樹形結構
                        treeifyBin(tab, hash);
                    // 跳出循環
                    break;
                }
                // 判斷鏈表中結點的key值與插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循環
                    break;
                // 用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表
                p = e;
            }
        }
        //判斷當前的key已經存在的情況下,再來一個相同的hash值、key值時,返回新來的value這個值
        if (e != null) { 
            // 記錄e的value
            V oldValue = e.value;
            // onlyIfAbsent爲false或者舊值爲null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替換舊值
                e.value = value;
            // 訪問後回調
            afterNodeAccess(e);
            // 返回舊值
            return oldValue;
        }
    }
    // 結構性修改
    ++modCount;
    // 步驟⑥:超過最大容量就擴容 
    // 實際大小大於閾值則擴容
    if (++size > threshold)
        resize();
    // 插入後回調
    afterNodeInsertion(evict);
    return null;
}

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

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

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

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

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

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

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

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

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

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

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

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;//oldTab指向hash桶數組
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {//如果oldCap不爲空的話,就是hash桶數組不爲空
        if (oldCap >= MAXIMUM_CAPACITY) {//如果大於最大容量了,就賦值爲整數最大的閥值
            threshold = Integer.MAX_VALUE;
            return oldTab;//返回
        }//如果當前hash桶數組的長度在擴容後仍然小於最大容量 並且oldCap大於默認值16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 雙倍擴容閥值threshold
    }
    // 舊的容量爲0,但threshold大於零,代表有參構造有cap傳入,threshold已經被初始化成最小2的n次冪
    // 直接將該值賦給新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 無參構造創建的map,給出默認容量和threshold 16, 16*0.75
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的threshold = 新的cap * 0.75
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 計算出新的數組長度後賦給當前成員變量table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶數組
    table = newTab;//將新數組的值複製給舊的hash桶數組
    // 如果原先的數組沒有初始化,那麼resize的初始化工作到此結束,否則進入擴容元素重排邏輯,使其均勻的分散
    if (oldTab != null) {
        // 遍歷新數組的所有桶下標
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 舊數組的桶下標賦給臨時變量e,並且解除舊數組中的引用,否則就數組無法被GC回收
                oldTab[j] = null;
                // 如果e.next==null,代表桶中就一個元素,不存在鏈表或者紅黑樹
                if (e.next == null)
                    // 用同樣的hash映射算法把該元素加入新的數組
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果e是TreeNode並且e.next!=null,那麼處理樹中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是鏈表的頭並且e.next!=null,那麼處理鏈表中元素重排
                else { // preserve order
                    // loHead,loTail 代表擴容後不用變換下標,見注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表擴容後變換下標,見注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍歷鏈表
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向鏈表當前元素e,e不一定是鏈表的第一個元素,初始化後loHead
                                // 代表下標保持不變的鏈表的頭元素
                                loHead = e;
                            else                                
                                // loTail.next指向當前e
                                loTail.next = e;
                            // loTail指向當前的元素e
                            // 初始化後,loTail和loHead指向相同的內存,所以當loTail.next指向下一個元素時,
                            // 底層數組中的元素的next引用也相應發生變化,造成lowHead.next.next.....
                            // 跟隨loTail同步,使得lowHead可以鏈接到所有屬於該鏈表的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向鏈表當前元素e, 初始化後hiHead代表下標更改的鏈表頭元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍歷結束, 將tail指向null,並把鏈表頭放入新數組的相應下標,形成新的映射。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

HashMap是怎麼解決哈希衝突的

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

什麼是哈希?

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

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

什麼是哈希衝突?

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

HashMap的數據結構

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

img

這樣我們就可以將擁有相同哈希值的對象組織成一個鏈表放在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()函數如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 與自己右移16位進行異或運算(高低位異或)
}

JDK1.8新增紅黑樹

img

通過上面的鏈地址法(使用散列表)和擾動函數我們成功讓我們的數據分佈更平均,哈希碰撞減少,但是當我們的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,通過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 有什麼區別?

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

如何決定使用 HashMap 還是 TreeMap?

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

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:

img

JDK1.7的ConcurrentHashMap:

img

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

img

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

img

  • 該類包含兩個靜態內部類 HashEntry 和 Segment ;前者用來封裝映射表的鍵值對,後者用來充當鎖的角色;
  • Segment 是一種可重入的鎖 ReentrantLock,每個 Segment 守護一個HashEntry 數組裏得元素,當對 HashEntry 數組的數據進行修改時,必須首先獲得對應的 Segment 鎖。

JDK1.8

JDK1.8中,放棄了Segment臃腫的設計,取而代之的是採用Node + CAS + Synchronized來保證併發安全進行實現,synchronized只鎖定當前鏈表或紅黑二叉樹的首節點,這樣只要hash不衝突,就不會產生併發,效率又提升N倍。

img

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

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;                   // no lock when adding to empty bin
}

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

if (fh >= 0) {
    binCount = 1;
    for (Node<K,V> e = f;; ++binCount) {
        K ek;
        if (e.hash == hash &&
            ((ek = e.key) == key ||
             (ek != null && key.equals(ek)))) {
            oldVal = e.val;
            if (!onlyIfAbsent)
                e.val = value;
            break;
        }
        Node<K,V> pred = e;
        if ((e = e.next) == null) {
            pred.next = new Node<K,V>(hash, key, value, null);
            break;
        }
    }
}
  • 如果該節點是TreeBin類型的節點,說明是紅黑樹結構,則通過putTreeVal方法往紅黑樹中插入節點;如果binCount不爲0,說明put操作對數據產生了影響,如果當前鏈表的個數達到8個,則通過treeifyBin方法轉化爲紅黑樹,如果oldVal不爲空,說明是一次更新操作,沒有對元素個數產生影響,則直接返回舊值;
  • 如果插入的是一個新節點,則執行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 接口以實現元素的比較;

通過treeifyBin方法轉化爲紅黑樹,如果oldVal不爲空,說明是一次更新操作,沒有對元素個數產生影響,則直接返回舊值;

  • 如果插入的是一個新節點,則執行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 中對函數式編程的支持)。
undefined
undefined
undefined
undefined

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