本文討論了 JAVA Collections Framework 背后的目的、Java 集合的工作原理,以及開發人員和程序員如何最大限度地利用 Java 集合。
什么是 Java 集合
盡管 Java 已經過了 25 歲生日,仍然是當今最受歡迎的編程語言之一。超過 100 萬個網站通過某種形式在使用 Java,超過 三分之一 的軟件開發人員的工具箱中有 Java。
Java 在它的整個生命歷程中經歷了重大的演變。一個早期的進步出現在 1998 年,當時 Java 引入了 Collections Framework(Java Collection Framework,JCF),簡化了操作 Java 對象的任務。JCF 為集合提供了標準化的接口和通用方法,減少了編程工作,并提升了 Java 程序的運行速度。
理解 Java 集合和 Java Collections Framework 之間的區別是至關重要的。Java 集合只是表示一組 Java 對象的數據結構。開發人員可以像處理其他數據類型一樣處理集合,執行搜索或操作集合內容等常見任務。
Set 接口(java.util.Set)就是 Java 集合的一個例子。Set 是一種集合,不允許出現重復元素,也不以任何特定的順序存儲元素。Set 接口繼承了 Collection(java.util.Collection)的方法,并且只包含這些方法。
除了集合之外,還有隊列(java.util.Queue)和 Map(java.util.Map)。Map 并不是真正意義上的集合,因為它們沒有 繼承集合接口 ,但開發人員可以像操作集合一樣操作 Map。集合、隊列、列表和 Map 都有后代,比如排序集合(java.util.SortedSet)和可導航 Map(java.util.NavigableMap)。
在使用集合時,開發人員需要熟悉和理解一些特定的集合相關術語。
- 可修改與不可修改——正如這些術語表面上所表明的,不同的集合可能支持也可能不支持修改操作。
- 可變集合與不可變集合——不可變集合在創建后不能被修改。雖然在某些情況下,不可修改的集合仍然可能由于其他代碼的訪問而發生變化,但不可變集合會阻止這種變更。不可變集合是指能夠保證 Collection 對象中不會有任何變更的集合,而不可修改的集合是指不允許“add”或“clear”等修改操作的集合。
- 固定大小與可變大小——這些術語僅與集合的大小有關,與集合是可修改還是可變無關。
- 隨機訪問與順序訪問——如果一個集合允許為每一個元素建立索引,那么它就是可隨機訪問的。在順序訪問集合中,必須通過所有前面的元素到達指定的元素。順序訪問集合更容易擴展,但搜索時間更長。初學者可能會難以理解不可修改集合和不可變集合之間的區別。不可修改集合不一定是不可變的。實際上,不可修改集合通常是可修改集合的包裝器,其他代碼仍然可以訪問和修改被包裝的可修改集合。通常需要使用集合一些時間才能在一定程度上理解不可修改集合和不可變集合。
例如,我們將創建一個可修改的按市值排名前五的加密貨幣列表。你可以使用
java.util.Collections.unmodifiableList()方法創建底層可修改列表的不可修改版本。你仍然可以修改底層列表,它只是被包裝成不可修改列表,但你不能直接修改不可修改的版本。
import java.util.*;
public class UnmodifiableCryptoListExample {
public static void main(String[] args) {
List<String> cryptoList = new ArrayList<>();
Collections.addAll(cryptoList, "BTC", "ETH", "USDT", "USDC", "BNB");
List<String> unmodifiableCryptoList = Collections.unmodifiableList(cryptoList);
System.out.println("Unmodifiable crypto List: " + unmodifiableCryptoList);
// 嘗試在可修改列表中再添加一種加密貨幣,并顯示在不可修改列表中
cryptoList.add("BUSD");
System.out.println("New unmodifiable crypto List with new element:" + unmodifiableCryptoList);
// 嘗試添加并顯示一個額外的加密貨幣到不可修改列表中——unmodifiableCryptoList.add將拋出一個未捕獲的異常,println代碼將無法被執行
unmodifiableCryptoList.add("XRP");
System.out.println("New unmodifiable crypto List with new element:" + unmodifiableCryptoList);
}
}
復制代碼
在運行代碼時,你將看到對底層可修改列表添加的內容顯示為對不可修改列表的修改。
但這與你創建了一個不可變列表并試圖修改底層列表不同。有許多種方法可以基于現有的 可修改列表創建不可變列表 ,下面我們使用 List.copyOf()方法創建了一個不可變列表。
import java.util.*;
public class UnmodifiableCryptoListExample {
public static void main(String[] args) {
List<String> cryptoList = new ArrayList<>();
Collections.addAll(cryptoList, "BTC", "ETH", "USDT", "USDC", "BNB");
List immutableCryptoList = List.copyOf(cryptoList);
System.out.println("Underlying crypto list:" + cryptoList)
System.out.println("Immutable crypto ist: " + immutableCryptoList);
// 嘗試添加更多的加密貨幣到可修改列表,但不可變列表并沒有顯示變化
cryptoList.add("BUSD");
System.out.println("New underlying list:" + cryptoList);
System.out.println("New immutable crypto List:" + immutableCryptoList);
// 嘗試添加并顯示一個新的加密貨幣到不可修改的列表中
immutableCryptoList.add("XRP");
System.out.println("New unmodifiable crypto List with new element:" + immutableCryptoList);
}
}
復制代碼
修改底層的列表后,不可變列表不顯示變更。嘗試修改不可變列表會直接導致拋出
UnsupportedOperationException。
集合與 Java Collections Framework 的關系
在引入 JCF 之前,開發人員可以使用幾個特殊的類,即 Array、Vector 和 Hashtable。但這些類有很大的局限性,除了缺乏公共接口之外,它們還難以擴展。
JCF 提供了一個用于處理集合的通用架構。集合接口包含了幾個不同的組件。
- 公共接口——主要集合類型的表示,包括集合、列表和 Map;
- 實現——集合接口的特定實現,從通用得到特殊的再到抽象的。此外,還有一些與舊的 Array、Vector 和 Hashtable 類相關的遺留實現;
- 算法——用于操作集合的靜態方法;
- 基礎結構——對各種集合接口的底層支持。與之前相比,JCF 為開發人員提供了許多好處。值得注意的是,JCF 降低了開發人員對自己編寫數據結構的需求,從而提高了 Java 編程的效率。
但是,JCF 也從根本上改變了開發人員使用 API 的方式。JCF 通過提供一組新的公共接口來處理不同的 API,簡化了開發人員學習、設計和實現 API 的過程。此外,API 的互操作性也大大提升了。 Eclipse Collections就是一個例子 ,它是一個完全兼容不同 Java 集合類型的開源 Java 集合庫。
由于 JCF 提供了更容易重用代碼的結構,從而進一步提升了開發效率。其結果就是開發時間縮短了,程序質量也得到了提升。
JCF 有一個定義良好的接口層次結構。java.util.Collection 擴展了超接口 Iterable,Collection 有許 多子接口和子類 ,如下所示。
如前所述,集合是唯一性對象的無序容器,而列表是可能包含重復項的有序集合。你可以在列表中的任何位置添加元素,但其他部分仍然保留了順序。
隊列也是集合,元素被添加到一端,并在另一端被刪除。也就是說,它是一種先進先出(FIFO)接口。Deque(雙端隊列)允許從任意一端添加或刪除元素。
使用 Java 集合的方法
JCF 中的每一個接口,包括 java.util.Collection,都提供了特定的方法用于訪問和操作集合的各個元素。集合提供的常用的方法有:
- size()——返回集合中元素的個數;
- add(Collection element) / remove(Collection object)——這些方法用于修改集合的內容。需要注意的是,當集合中有重復元素時,移除只會影響元素的單個實例;
- equals(Collection object)——比較對象與集合是否等價;
- clear()——刪除集合中的所有元素。每個子接口也可以有其他方法。例如,盡管 Set 接口只包含來自 Collection 接口的方法,但 List 接口包含了許多用于訪問特定列表元素的方法。
- get(int index)——返回指定索引位置的元素;
- set(int index, element)——設置指定索引位置的元素;
- remove(int,index)——移除指定索引位置的元素。
Java 集合的性能
隨著集合元素數量的增長,它們可能會出現明顯的性能問題。事實證明, 集合類型的選擇 和集合的相關設計也會極大地影響集合的性能。
隨著需要處理的數據量不斷增加,Java 引入了新的處理集合的方法來提升整體性能。在 2014 年發布的 Java 8 引入了 Streams——旨在簡化和提高批量處理對象的速度。自從推出以來,Streams 已經有了許多 改進 。
需要注意的是,流本身并不是數據結構,而是“對流中的元素進行函數式操作(例如對集合進行 map-reduce 轉換)的類。”
Streams 使用方法管道來處理從數據源(如集合)接收到的數據。Streams 的每一個方法要么是一個中間方法(返回可以進一步處理的流),要么是一個終端方法(在此之后不可能進行其他流處理)。管道中的中間方法是惰性的,也就是說,它們只在必要時才進行求值。
并行執行和 串行執行 都存在于流中。默認情況下,流是串行的。
通過并行處理來提升性能
在 Java 中處理大型集合可能很麻煩。雖然 Streams 簡化了大型集合的處理和編碼工作,但并不總是能保證性能上的提升。事實上,程序員經常發現使用 Streams 反而會 減慢處理速度 。
眾所周知,網站用戶只會等待幾秒鐘的加載時間,然后他們就會離開。因此,為了提供最好的用戶體驗并 維護開發人員 提供高質量產品的聲譽,開發人員必須考慮如何優化大型數據集合的處理。雖然并行處理并不總能保證提高速度,但至少是有希望的。
并行處理,即將處理任務分解為更小的塊并同時執行它們,提供了一種在處理大型集合時減少處理開銷的方法。但是,即使并行流處理簡化了代碼編寫,也會 導致性能下降 。本質上,多線程管理開銷會抵消并行運行線程所帶來的好處。
因為集合不是線程安全的,并行處理可能會導致線程干擾或內存不一致(當并行線程看不到其他線程所做的修改,對相同的數據有不同的視圖時)。Collections Framework 試圖通過使用同步包裝器在并行處理期間防止線程不一致。雖然包裝器可以讓集合變成線程安全的,從而實現更高效的并行處理,但它可能會產生不良的性能影響。具體來說,同步可能會導致線程爭用,從而導致線程執行得更慢或停止執行。
Java 有一個用于集合的元素并行處理函數 Collection.parallelstream。默認的串行處理和并行處理之間的一個顯著區別是,串行處理時總是相同的執行和輸出順序在并行處理時可能會有不同。
因此,在處理順序不影響最終輸出的場景中,并行處理會特別有效。但是,在一個線程的狀態可能會影響另一個線程狀態的場景中,并行處理可能會有問題。
我們來考慮一個簡單的示例,在這個示例中,我們為包含 1000 個客戶創建了一個應收賬款列表。我們想要知道這些客戶中有多少人的應收賬款超過 25000 美元。我們可以按照串行或并行的處理方式檢查這個列表。
import java.util.Random;
import java.util.ArrayList;
import java.util.List;
class Customer {
static int customernumber;
static int receivables;
Customer(int customernumber, int receivables) {
this.customernumber = customernumber;
this.receivables = receivables;
}
public int getCustomernumber() {
return customernumber;
}
public void setCustomernumber(int customernumber) {
this.customernumber = customernumber;
}
public int getReceivables() {
return receivables;
}
public void setReceivables() {
this.receivables = receivables;
}
}
public class ParallelStreamTest {
public static void main( String args[] ) {
Random receivable = new Random();
int upperbound = 1000000;
List < Customer > custlist = new ArrayList < Customer > ();
for (int i = 0; i < upperbound; i++) {
int custnumber = i + 1;
int custreceivable = receivable.nextInt(upperbound);
custlist.add(new Customer(custnumber, custreceivable));
}
long t1 = System.currentTimeMillis();
System.out.println("Sequential Stream count: " + custlist.stream().filter(c ->
c.getReceivables() > 25000).count());
long t2 = System.currentTimeMillis();
System.out.println("Sequential Stream Time taken:" + (t2 - t1));
t1 = System.currentTimeMillis();
System.out.println("Parallel Stream count: " + custlist.parallelStream().filter(c ->
c.getReceivables() > 25000).count());
t2 = System.currentTimeMillis();
System.out.println("Parallel Stream Time taken:" + (t2 - t1));
}
}
復制代碼
代碼執行結果表明,在處理數據集合時, 并行處理 可能會提升性能:
但需要注意的是,每次執行代碼時,你可能獲得不同的結果。在某些情況下,串行處理仍然優于并行處理。
在本例中,我們使用 Java 的原生進程來分割數據和分配線程。
不幸的是,對于上述兩種情況,Java 的原生并行處理并不總是比串行處理更快。實際上,經常會更慢。
例如,并行處理對于鏈表沒有什么用。雖然 ArrayList 很容易被分割成并行處理,但 LinkedList 卻不是這樣的。TreeMap 和 HashSet 介于兩者之間。
Oracle 的 NQ模型 是決定是否使用并行處理的一種方法。在 NQ 模型中,N 表示需要處理的數據元素數量,Q 表示每個數據元素所需的計算量。在 NQ 模型中,計算 N 和 Q 的乘積,數值越大,說明并行處理提高性能的可能性越大。
在使用 NQ 模型時,N 和 Q 之間存在反比關系,即每個元素所需的計算量越高,并行處理的數據集就越小。經驗法則是,對于較低的計算需求,包含 10000 個元素的數據集是使用并行處理的基線。
除此之外,還有其他更高級的方法來優化 Java 集合中的并行處理。例如,高級開發人員可以調整集合中數據元素的分區,以最大化并行處理性能。還有一些 第三方的JCF插件 和替代品可以提升性能。但是,初學者和中級開發人員應該重點了解哪些操作可以從 Java 的原生并行處理特性中受益。
結論
在大數據世界里,想要創建高性能的網頁和應用程序,必須找到改進大量數據處理的方法。Java 提供了內置的集合處理特性幫助開發人員改進數據處理,包括 Collections Framework 和原生并行處理功能。開發人員需要熟悉如何使用這些特性,并了解可以時候可以使用原生特性,什么時候應該使用并行處理。
作者簡介:
Nahla Davies 是一名軟件開發人員和技術作家。在全職從事技術寫作之前,她曾在一家體驗式品牌企業擔任首席程序員,該組織的客戶包括三星、時代華納?.NETflix 和索尼。
原文鏈接:
https://www.infoq.com/articles/java-collections-streams/