Java Arraylist在多線程環(huán)境下的問題與解決方案
一、ArrayList 的線程不安全性
ArrayList 的所有方法都沒有進行同步控制,多個線程同時添加、刪除、修改同一個 ArrayList 實例時,會導(dǎo)致:
- 數(shù)據(jù)不一致:例如兩個線程同時添加元素,可能導(dǎo)致 size 值錯誤,甚至覆蓋彼此的數(shù)據(jù)。
- 數(shù)組越界異常:內(nèi)部數(shù)組擴容時,多個線程同時操作可能導(dǎo)致
ArrayIndexOutOfBoundsException。 - ConcurrentModificationException:當一個線程正在迭代,另一個線程修改了列表結(jié)構(gòu)(如添加或刪除元素)時,會快速失敗拋出該異常。
List<Integer> list = new ArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> list.add(1));
}
executor.shutdown();
// 結(jié)果:可能拋出異常,或最終 size 不等于 1000
二、為什么它是線程不安全的
ArrayList 是線程不安全的,根本原因在于其內(nèi)部實現(xiàn)沒有對共享數(shù)據(jù)的并發(fā)訪問進行任何同步控制,導(dǎo)致多線程同時修改時會出現(xiàn)數(shù)據(jù)競爭。
2.1 內(nèi)部數(shù)據(jù)結(jié)構(gòu)
ArrayList 底層是一個 Object 數(shù)組(elementData)和一個 int 類型的 size 字段,用于記錄實際元素個數(shù):

所有修改操作如 add、remove都會直接操作這個數(shù)組和 size。
2.2 并發(fā)添加時的競態(tài)條件
假設(shè)兩個線程同時執(zhí)行 list.add(e),該方法大致步驟如下:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 檢查是否需要擴容
elementData[size++] = e; // 插入元素并 size 自增
return true;
}
1. 擴容檢查
ensureCapacityInternal(size + 1)會讀取當前 size,判斷數(shù)組是否已滿。- 如果兩個線程同時發(fā)現(xiàn)數(shù)組還有空間(比如 size=5,容量=10),它們都會認為無需擴容,然后繼續(xù)向下執(zhí)行。
- 但若兩個線程同時發(fā)現(xiàn)需要擴容,它們可能各自進行擴容操作,最終只有一個數(shù)組被保留,另一個線程使用的數(shù)組可能被覆蓋,導(dǎo)致數(shù)據(jù)丟失。
2. 數(shù)組越界異常
更常見的情況是:兩個線程在擴容后都準備插入元素:
- 線程 A 執(zhí)行
elementData[size] = e時,size 還是舊值(比如 5); - 線程 B 也執(zhí)行同樣的操作,可能線程 A 還沒更新 size,線程 B 仍然使用舊 size(5)寫入同一位置,覆蓋了線程 A 的數(shù)據(jù);
- 或者線程 B 在寫入前,線程 A 已經(jīng)更新 size 為 6,線程 B 再寫入時使用的索引 5 已經(jīng)合法,但最終 size 可能只增加了一次,導(dǎo)致少計一個元素。
- 極端情況:如果兩個線程同時寫入同一個數(shù)組下標,并且數(shù)組已滿且都觸發(fā)了擴容,可能最終寫入時使用的數(shù)組引用不一致,導(dǎo)致
ArrayIndexOutOfBoundsException。
3. size 的非原子操作
size++ 實際上分為三步:讀取 size → 加 1 → 寫回 size。多線程環(huán)境下,這些步驟可能交錯執(zhí)行,導(dǎo)致:
- 兩個線程都讀取到 size=5,各自加 1 后寫回 6,最終 size 為 6,但實際插入了兩個元素,丟失一次更新。
- 最終
size值小于實際元素個數(shù),或數(shù)組中有空位(null),后續(xù)操作可能出現(xiàn)問題。
三、實際測試
我們看一段如下的java代碼
/**
* 演示 ArrayList 多線程并發(fā)問題
*/
@Test
public void testArrayListConcurrencyIssue() throws InterruptedException {
List<Integer> list = new ArrayList<>();
int threadCount = 5;
CountDownLatch latch = new CountDownLatch(threadCount);
// 創(chuàng)建 5 個線程,每個線程添加 1000 個元素
for (int i = 0; i < threadCount; i++) {
final int threadId = i;
new Thread(() -> {
try {
for (int j = 0; j < 1000; j++) {
list.add(threadId * 1000 + j);
}
} catch (Exception e) {
System.err.println("線程異常:" + e);
} finally {
latch.countDown();
}
}).start();
}
latch.await();
System.out.println("預(yù)期大小:" + (threadCount * 1000));
System.out.println("實際大?。? + list.size());
System.out.println("丟失元素:" + (threadCount * 1000 - list.size()));
if (list.size() < threadCount * 1000) {
System.out.println("? 檢測到線程安全問題:數(shù)據(jù)丟失!");
}
}
其運行結(jié)果如下:

四、解決方案
關(guān)于ArrayList多線程并發(fā)解決方案還是很多的,但是各有優(yōu)缺點,我們來一個一個介紹。
4.1 Collections.synchronizedList 同步包裝器
它是將普通 ArrayList 轉(zhuǎn)換為線程安全版本最直接的手段。synchronizedList 的核心設(shè)計思路是 裝飾器模式。它并沒有重新實現(xiàn)一個 List,而是將原有的 ArrayList 包裹起來,然后在每個方法的實現(xiàn)上都加上 synchronized 代碼塊,通過同一把 互斥鎖 來保證線程安全。
// Collections類中的靜態(tài)內(nèi)部類
static class SynchronizedList<E> extends SynchronizedCollection<E> implements List<E> {
final List<E> list; // 被包裝的原始ArrayList
SynchronizedList(List<E> list, Object mutex) {
super(list, mutex); // 將mutex(鎖對象)傳給父類
this.list = list;
}
public E get(int index) {
synchronized (mutex) { // 獲取鎖
return list.get(index); // 調(diào)用原ArrayList的方法
} // 釋放鎖
}
public void add(int index, E element) {
synchronized (mutex) { // 獲取鎖
list.add(index, element); // 調(diào)用原ArrayList的方法
} // 釋放鎖
}
// ... 其他所有方法都是同樣的模式
}
優(yōu)點
- 使用簡單:一行代碼就能把非線程安全的
ArrayList包裝成線程安全的。 - 強一致性:由于每次操作都加鎖,你能獲得 強一致性 的數(shù)據(jù)視圖(只要操作完成,其他線程立即可見)。
- 兼容性好:返回的 List 實現(xiàn)了
RandomAccess接口(如果原 List 實現(xiàn)了),所以隨機訪問的性能和ArrayList一樣好。
缺點
- 性能瓶頸:相當于將并發(fā)操作強制變成了串行操作。在并發(fā)量高的時候,這就是一個巨大的性能瓶頸。
- 粗粒度鎖:鎖的范圍是整個 List 對象,無法進行并發(fā)讀?。ㄗx寫互斥,讀讀也互斥)。
同時它也有著大量的其他問題,例如復(fù)合操作不具備原子性。即使使用了 synchronizedList,下面這段代碼在多線程環(huán)境下仍然是錯誤的:
List<String> list = Collections.synchronizedList(new ArrayList<>());
// ... 假設(shè)list中已經(jīng)有了一些元素
// 在多線程環(huán)境下執(zhí)行這段代碼
if (!list.contains("a")) { // 檢查操作(已加鎖)
list.add("b"); // 添加操作(已加鎖)
}
contains 和 add 雖然是兩個原子操作,但組合在一起就不是原子操作了。在 contains 檢查通過后、add 執(zhí)行之前,可能有另一個線程插進來添加了該元素,導(dǎo)致最終重復(fù)添加。
此時你需要手動使用同一個鎖對象來保證復(fù)合操作的原子性:
// 正確做法:使用list對象本身作為鎖,鎖住整個操作塊
synchronized (list) {
if (!list.contains("特定元素")) {
list.add("特定元素");
}
}
因為 synchronizedList 內(nèi)部使用的是 this 作為鎖,所以外部用 synchronized (list) 可以保證與內(nèi)部方法使用的是同一把鎖。這個是多線程的知識點,如果有不會的可以翻翻我寫的關(guān)于多線程的文章。
其次的問題就是遍歷時需要手動加鎖。當使用迭代器遍歷 synchronizedList 時,必須在外層加鎖。
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 錯誤示例:會拋出 ConcurrentModificationException
// 因為迭代器遍歷期間,另一個線程可能修改了list
for (String item : list) {
// 處理item
}
// 正確示例
synchronized (list) {
for (String item : list) { // 在鎖的保護下遍歷
// 處理item
}
}
這是因為 SynchronizedList 的 iterator() 方法本身并沒有加鎖,它返回的迭代器在遍歷過程中,如果有其他線程修改了 List,依然會觸發(fā)快速失敗機制。
綜上,如果在使用場景是讀寫比例均衡,或需要強一致性的場景,可以考慮使用Collections.synchronizedList,但是需要記住在遍歷或者任何復(fù)合操作的情況下,都需要手動加鎖來保證原子性。
/**
* 使用 Collections.synchronizedList 解決并發(fā)問題
*/
@Test
public void testSynchronizedList() throws InterruptedException {
List<Integer> list = Collections.synchronizedList(new ArrayList<>());
int threadCount = 10;
int opsPerThread = 50000;
CountDownLatch latch = new CountDownLatch(threadCount);
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
final int threadId = i;
new Thread(() -> {
try {
for (int j = 0; j < opsPerThread; j++) {
synchronized (list) {
list.add(threadId * opsPerThread + j);
}
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
long endTime = System.currentTimeMillis();
System.out.println("預(yù)期大?。? + (threadCount * opsPerThread));
System.out.println("實際大小:" + list.size());
System.out.println("執(zhí)行時間:" + (endTime - startTime) + "ms");
if (list.size() == threadCount * opsPerThread) {
System.out.println("? synchronizedList 保證線程安全!");
}
}

4.2 CopyOnWriteArrayList 并發(fā)集合
這是Java并發(fā)包(JUC)中專門為讀多寫極少場景量身定做的一個方法。它的設(shè)計思想非常巧妙,采用了不變性和寫時復(fù)制策略,徹底解決了并發(fā)沖突的問題。
CopyOnWriteArrayList 的核心思想非常簡單直觀:每當需要對列表進行修改(增、刪、改)時,不直接修改原始數(shù)組,而是先復(fù)制一份快照,在快照上修改,修改完成后再將原數(shù)組的引用指向這個新的數(shù)組。
public class CopyOnWriteArrayList<E> {
// 關(guān)鍵:使用 volatile 修飾的數(shù)組,保證修改后對其他線程的可見性
private transient volatile Object[] array;
// 添加元素的方法
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // 寫操作必須加鎖,防止并發(fā)修改時復(fù)制出多個副本
try {
Object[] elements = getArray(); // 獲取當前數(shù)組
int len = elements.length;
// 核心:復(fù)制一個新數(shù)組(長度+1)
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e; // 在新數(shù)組上執(zhí)行添加操作
setArray(newElements); // 將新數(shù)組設(shè)為當前數(shù)組
return true;
} finally {
lock.unlock();
}
}
// 讀取元素的方法(沒有加鎖)
public E get(int index) {
return get(getArray(), index); // 直接從當前數(shù)組中獲取
}
// 返回當前數(shù)組的快照
final Object[] getArray() {
return array;
}
}
這種設(shè)計帶來的兩個核心特性:
- 讀操作無鎖:讀線程永遠不需要加鎖,因為它們訪問的是當前時刻的數(shù)組快照。即使此時有寫線程正在復(fù)制新數(shù)組,也完全不影響讀線程訪問舊數(shù)組。
- 數(shù)據(jù)弱一致性:迭代器一旦被創(chuàng)建,它遍歷的就是創(chuàng)建時刻的那個數(shù)組快照。遍歷過程中,其他線程對列表的修改(即使已經(jīng)提交)對當前迭代器是不可見的。這被稱為 "弱一致性" 。
優(yōu)點
- 極高的讀并發(fā):讀操作完全不阻塞,也不互斥。這在讀多寫少的場景下,性能遠超
Collections.synchronizedList。 - 迭代安全:永遠不會拋出
ConcurrentModificationException。因為迭代器操作的是獨立的數(shù)組快照。
缺點
- 內(nèi)存開銷:每次修改都要復(fù)制整個數(shù)組。如果列表很大(比如上萬元素),頻繁復(fù)制會造成巨大的內(nèi)存壓力(老數(shù)組和正在構(gòu)建的新數(shù)組同時存在于內(nèi)存中),甚至引發(fā)頻繁的GC。
- 數(shù)據(jù)延遲:寫線程修改數(shù)據(jù)后,并不能保證讀線程立即看到最新數(shù)據(jù)。因為讀線程訪問的可能還是舊的數(shù)組快照。不過由于
volatile變量的語義,這個"不可見"的時間窗口非常短(寫完成后,后續(xù)的讀操作一定能看到)。 - 不適合寫頻繁場景:如果寫操作較多,復(fù)制數(shù)組的開銷會急劇上升,性能可能反而不如
synchronizedList。
綜上,如果你的業(yè)務(wù)要求嚴格的實時一致性(比如支付扣款后的余額查詢),CopyOnWriteArrayList 就不適合了。
| 特性 | CopyOnWriteArrayList | Collections.synchronizedList |
|---|---|---|
| 實現(xiàn)原理 | 空間換時間:寫時復(fù)制,讀寫分離 | 時間換安全:所有操作串行化 |
| 讀鎖 | 無鎖 | 有鎖(讀讀互斥) |
| 寫鎖 | 有鎖(用ReentrantLock控制) | 有鎖 |
| 內(nèi)存占用 | 高(每次寫創(chuàng)建新數(shù)組) | 低 |
| 數(shù)據(jù)一致性 | 弱一致性(迭代器快照) | 強一致性(鎖保護) |
| 迭代異常 | 永不拋出 ConcurrentModificationException | 遍歷期間如有修改會拋出異常 |
| 最佳場景 | 讀多寫極少(配置、白名單、監(jiān)聽器列表) | 讀寫均衡,或需要強一致性的場景 |
/**
* 演示使用 CopyOnWriteArrayList 解決并發(fā)問題
*/
@Test
public void testThreadSafeList() throws InterruptedException {
List<Integer> list = new CopyOnWriteArrayList<>();
int threadCount = 10;
int opsPerThread = 50000;
CountDownLatch latch = new CountDownLatch(threadCount);
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
final int threadId = i;
new Thread(() -> {
try {
for (int j = 0; j < opsPerThread; j++) {
list.add(threadId * opsPerThread + j);
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
long endTime = System.currentTimeMillis();
System.out.println("預(yù)期大?。? + (threadCount * opsPerThread));
System.out.println("實際大?。? + list.size());
System.out.println("執(zhí)行時間:" + (endTime - startTime) + "ms");
if (list.size() == threadCount * opsPerThread) {
System.out.println("? 線程安全,數(shù)據(jù)完整!");
}else {
System.out.println("? 檢測到線程安全問題:數(shù)據(jù)不完整!");
}
}

通過上述代碼可以看到,同樣是50萬量級的數(shù)據(jù),使用CopyOnWriteArrayList的運行插入的時間是Collections.synchronizedList好幾百倍,所以我們一定要注意使用場景的問題。
我們再來用一個讀多寫少的場景對比一下
/**
* 讀多寫少場景:synchronizedList vs CopyOnWriteArrayList
*/
@Test
public void testReadHeavyScenario() throws InterruptedException {
int threadCount = 10;
int writeCount = 1000;
int readCount = 1000000;
System.out.println("=== synchronizedList 讀多寫少 ===");
long syncTime = testSynchronizedList(threadCount, writeCount, readCount);
System.out.println("\n=== CopyOnWriteArrayList 讀多寫少 ===");
long cowTime = testCopyOnWriteArrayList(threadCount, writeCount, readCount);
System.out.println("\n=== 性能對比 ===");
System.out.println("synchronizedList: " + syncTime + "ms");
System.out.println("CopyOnWriteArrayList: " + cowTime + "ms");
System.out.println("CopyOnWriteArrayList " + (syncTime > cowTime ? "更快" : "更慢") +
",提升了 " + String.format("%.2f", (double)(syncTime - cowTime) / syncTime * 100) + "%");
}
private long testSynchronizedList(int threadCount, int writeCount, int readCount) throws InterruptedException {
List<Integer> list = Collections.synchronizedList(new ArrayList<>());
CountDownLatch latch = new CountDownLatch(threadCount);
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
final int threadId = i;
new Thread(() -> {
try {
for (int j = 0; j < writeCount; j++) {
synchronized (list) {
list.add(threadId * writeCount + j);
}
}
for (int j = 0; j < readCount; j++) {
synchronized (list) {
if (!list.isEmpty()) {
list.get(list.size() - 1);
}
}
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
long endTime = System.currentTimeMillis();
System.out.println("寫+讀執(zhí)行時間:" + (endTime - startTime) + "ms,寫大?。? + list.size());
return endTime - startTime;
}
private long testCopyOnWriteArrayList(int threadCount, int writeCount, int readCount) throws InterruptedException {
List<Integer> list = new CopyOnWriteArrayList<>();
CountDownLatch latch = new CountDownLatch(threadCount);
long startTime = System.currentTimeMillis();
for (int i = 0; i < threadCount; i++) {
final int threadId = i;
new Thread(() -> {
try {
for (int j = 0; j < writeCount; j++) {
list.add(threadId * writeCount + j);
}
for (int j = 0; j < readCount; j++) {
if (!list.isEmpty()) {
list.get(list.size() - 1);
}
}
} finally {
latch.countDown();
}
}).start();
}
latch.await();
long endTime = System.currentTimeMillis();
System.out.println("寫+讀執(zhí)行時間:" + (endTime - startTime) + "ms,寫大?。? + list.size());
return endTime - startTime;
}

上面的程序是十個線程,寫入1萬,讀100萬,可以看到在讀百萬量級數(shù)據(jù)的時候,用CopyOnWriteArrayList的時間幾乎是提升了40倍。
以上就是Java Arraylist在多線程環(huán)境下的問題與解決方案的詳細內(nèi)容,更多關(guān)于Java Arraylist線程不安全性的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
從原理到實戰(zhàn)詳解Java如何實現(xiàn)數(shù)據(jù)庫讀寫分離
隨著用戶量和數(shù)據(jù)量的爆發(fā)式增長,數(shù)據(jù)庫往往成為系統(tǒng)中最先出現(xiàn)性能瓶頸的環(huán)節(jié),本文全面解析數(shù)據(jù)庫讀寫分離技術(shù),從核心原理到實戰(zhàn)應(yīng)用,有需要的小伙伴可以了解下2026-06-06
SpringBoot實現(xiàn)動態(tài)配置及項目打包部署上線功能
本文講解的是如何使用Spring動態(tài)配置文件,實現(xiàn)不同環(huán)境不同配置,靈活切換配置文件;以及講述了如何使用?Maven?打包,然后上傳至Linux服務(wù)器進行部署,對SpringBoot打包部署上線過程感興趣的朋友一起看看吧2022-10-10
復(fù)雜JSON字符串轉(zhuǎn)換為Java嵌套對象的實現(xiàn)
這篇文章主要介紹了復(fù)雜JSON字符串轉(zhuǎn)換為Java嵌套對象的實現(xiàn),具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-09-09
hashMap擴容時應(yīng)該注意這些死循環(huán)問題
今天給大家?guī)淼氖顷P(guān)于Java的相關(guān)知識,文章圍繞著hashMap擴容時的死循環(huán)問題展開,文中有非常詳細的介紹及代碼示例,需要的朋友可以參考下2021-06-06
Java?guava框架LoadingCache及CacheBuilder本地小容量緩存框架總結(jié)
Guava?Cache本地緩存框架主要是一種將本地數(shù)據(jù)緩存到內(nèi)存中,但數(shù)據(jù)量并不能太大,否則將會占用過多的內(nèi)存,本文給大家介紹Java?guava框架?LoadingCache及CacheBuilder?本地小容量緩存框架總結(jié),感興趣的朋友一起看看吧2023-12-12
JFileChooser實現(xiàn)對選定文件夾內(nèi)圖片自動播放和暫停播放實例代碼
這篇文章主要介紹了JFileChooser實現(xiàn)對選定文件夾內(nèi)圖片自動播放和暫停播放實例代碼,需要的朋友可以參考下2017-04-04

