首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 綜合教學

【Java 每日一題】ArrayList 多線程賣票:線程不安全的經典演示與修復

     編輯:Java 綜合教學

今日題目

package Dec2016;
import java.util.ArrayList;
import java.util.List;

public class Ques1214 {
    public static void main(String[] args) {
        final List tickets = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            tickets.add("高鐵票-" + i);
        }

        // 10 個線程窗口賣票
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    System.out.println(Thread.currentThread().getId() + "-" + tickets.remove(0));
                }
            }).start();
        }
    }
}

問題:程序輸出結果是什麼?能否實現各窗口賣的票不衝突?

運行結果分析

預期行為 (單線程)

依次輸出:1-高鐵票-0, 1-高鐵票-1 ... 1-高鐵票-999

實際多線程行為 (必現錯誤)

  1. IndexOutOfBoundsException:多線程同時通過 size() > 0 隱性檢查 (while(true) 無顯式檢查),同時 remove(0) 導致下標越界
  2. 賣重複票 / 丟票:線程 A 讀取 index 0,線程 B 也讀取 index 0 (A 尚未 remove),雙方賣同一張票
  3. 數據競態:ArrayList 非線程安全,內部 elementData 數組、size 字段無同步保護

根因:ArrayList 非線程安全操作

// ArrayList.remove(int index) 簡化源碼
public E remove(int index) {
    rangeCheck(index);           // 1. 檢查下標
    modCount++;                  // 2. 修改計數
    E oldValue = elementData[index]; // 3. 獲取值
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index + 1, elementData, index, numMoved); // 4. 數組移位
    elementData[--size] = null;  // 5. 清理末尾、更新 size
    return oldValue;
}
// 多線程並發執行上述步驟 → 數據錯亂、size 不一致、數組越界

四種修復方案對比

方案 1:Collections.synchronizedList (鎖住整個列表)

List tickets = Collections.synchronizedList(new ArrayList<>());
// 使用時必須手動同步迭代/複合操作
synchronized (tickets) {
    if (!tickets.isEmpty()) {
        System.out.println(Thread.currentThread().getId() + "-" + tickets.remove(0));
    }
}
// 缺點:鎖粒度粗、擴容時複製數組開銷大、迭代需手動同步

方案 2:CopyOnWriteArrayList (讀多寫少、弱一致性)

List tickets = new CopyOnWriteArrayList<>();
// 寫入時複製底層數組,讀取無鎖
// 缺點:寫入開銷極大 (複製數組)、僅保證最終一致性 (可能讀到舊數據)、內存佔用雙倍

方案 3:手動 synchronized / ReentrantLock (靈活控制)

final Object lock = new Object();
// 或 final Lock lock = new ReentrantLock();

new Thread(() -> {
    while (true) {
        String ticket;
        synchronized (lock) { // 或 lock.lock()/unlock()
            if (tickets.isEmpty()) break;
            ticket = tickets.remove(0);
        }
        if (ticket != null) System.out.println(Thread.currentThread().getId() + "-" + ticket);
    }
}).start();
// 優點:精確控制臨界區、性能優於方案1
// 缺點:代碼侵入性強、易死鎖、需自行維護鎖對象

方案 4:阻塞隊列 BlockingQueue (生產者消費者模式,最推薦)

// 初始化有界隊列,預裝票據
BlockingQueue ticketQueue = new LinkedBlockingQueue<>(1000);
for (int i = 0; i < 1000; i++) {
    ticketQueue.offer("高鐵票-" + i);
}

// 消費者線程
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        try {
            while (true) {
                String ticket = ticketQueue.take(); // 隻要有票就取走,無票阻塞等待
                System.out.println(Thread.currentThread().getId() + "-" + ticket);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }).start();
}
// 優點:線程安全、內置阻塞等待、無需額外同步、天然支持生產消費模型
// 缺點:需預先裝入隊列、動態補票需額外邏輯

方案選型指南

場景推薦方案理由
簡單任務、低併發、快速原型方案 1 synchronizedList代碼改動最小
讀多寫少、可容忍弱一致性方案 2 CopyOnWriteArrayList讀無鎖、遍歷安全
需精確控制、避免長時間持鎖方案 3 手動鎖靈活、性能可控
生產消費模型、高併發、需阻塞等待方案 4 BlockingQueue ⭐線程安全、解耦、模型天然匹配

進階思考:如何實現「動態補票」?

// 使用 LinkedBlockingQueue 無界版本 + 獨立補票線程
BlockingQueue ticketQueue = new LinkedBlockingQueue<>();

// 補票線程 (模擬後台系統推票)
new Thread(() -> {
    int ticketId = 1000;
    while (!Thread.currentThread().isInterrupted()) {
        try {
            ticketQueue.put("高鐵票-" + ticketId++); // 隊列滿會阻塞 (無界不會滿)
            Thread.sleep(100); // 控制補票速率
        } catch (InterruptedException e) { break; }
    }
}).start();

總結

  • ArrayList 絕對不能 在多線程環境下直接用於複合操作 (檢查-然後執行)
  • 賣票/搶單/任務分發等「取出並處理」場景,阻塞隊列是領域模型的最佳映射
  • 理解 synchronized、Lock、CopyOnWrite、BlockingQueue 的適用邊界,是併發編程從「能跑」到「穩健」的關鍵
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved