Doug Lea大師的佳作CopyOnWriteArrayList,用不好能坑死你!

JavaBuild發表於2024-05-19

一、寫在開頭

我們在學習集合或者說容器的時候瞭解到,很多集合並非執行緒安全的,在併發場景下,為了保障資料的安全性,誕生了併發容器,廣為人知的有ConcurrentHashMap、ConcurrentLinkedQueue、BlockingQueue等,那你們知道ArrayList也有自己對應的併發容器嘛?

作為使用頻率最高的集合類之一,ArrayList執行緒不安全,我們在併發環境下使用,一般要輔以手動上鎖、或者透過Collections.synchronizedList()轉一手,為了解決這一問題,Doug Lea(道格.利)大師為我們提供了它的併發類——CopyOnWriteArrayList

二、認知CopyOnWriteArrayList

image

CopyOnWriteArrayList 是java.util.concurrent的併發類,執行緒安全,遵循寫時複製的原則(CopyOnWrite),什麼意思呢?就是我們在對列表進行增刪改時,會先建立一個列表的副本,在副本中完成增刪改操作後,再將副本替換原列表,整個過程舊的列表並沒有鎖定,因此原來的讀取操作仍可繼續。

看到這裡細心的同學應該已經發現了它的“弊端”了,先賦值副本,寫完再替換,這是有時間差的,沒錯,這就是CopyOnWrite的延時更新策略,我們在發生寫的同時,不阻塞讀,但讀取的只是舊列表中的資料,直到引用替換完成,可以保證資料的最終一致性,無法保證實時性。

三、實現原理(原始碼)

我們來看一下CopyOnWriteArrayList底層的原始碼實現,首先在內部維護了一個陣列,用volatile關鍵字修飾,保證了資料的記憶體可見性

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

讀取:get()方法

public E get(int index) {
    return get(getArray(), index);
}
/**
 * Gets the array.  Non-private so as to also be accessible
 * from CopyOnWriteArraySet class.
 */
final Object[] getArray() {
    return array;
}
private E get(Object[] a, int index) {
    return (E) a[index];
}

這段原始碼沒什麼,很好理解,就是普通的讀取陣列的操作,這也能看出CopyOnWriteArrayList的讀是不阻塞的。

新增:add()方法

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
	  //1. 使用Lock,保證寫執行緒在同一時刻只有一個
    lock.lock();
    try {
		//2. 獲取舊陣列引用
        Object[] elements = getArray();
        int len = elements.length;
		//3. 建立新的陣列,並將舊陣列的資料複製到新陣列中
        Object[] newElements = Arrays.copyOf(elements, len + 1);
		//4. 往新陣列中新增新的資料
		newElements[len] = e;
		//5. 將舊陣列引用指向新的陣列
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}
final void setArray(Object[] a) {
    array = a;
}

透過這段原始碼,我們就能夠感知到前面描述的實現原理了,首先,新增元素時,內部透過可重入鎖進行鎖定,說明寫時會獨佔,然後,再將原陣列賦值到一個新陣列中,最後,將舊陣列的引用指向新陣列。

四、使用注意事項,用不好坑死你

對於CopyOnWriteArrayList的日常使用,和ArrayList幾乎一模一樣,在這裡就不用過多介紹了,但它的使用還是需要注意的,雖然可以保證執行緒安全,但因其特性所致,僅適應於讀多寫少的併發環境,對於頻繁寫入或者寫入的物件較大,一定不要使用CopyOnWriteArrayList容器,不然會坑死你的!

【舉個例子】

之前在這篇文章中:[EasyExcel匯入匯出百萬資料量]
採用了CopyOnWriteArrayList,以此來保證在多執行緒寫入資料庫時的執行緒安全,由於寫入的excel檔案中有100萬的資料量,再匯入的時候非常之慢,用了514秒!

image

核心實現程式碼如下,具體內容實現可去看那篇文章哈

@Slf4j
@Service
public class EasyExcelImportHandler implements ReadListener<User> {
    /*成功資料*/
    private final CopyOnWriteArrayList<User> successList = new CopyOnWriteArrayList<>();
    /*單次處理條數*/
    private final static int BATCH_COUNT = 20000;
    @Resource
    private ThreadPoolExecutor threadPoolExecutor;
    @Resource
    private UserMapper userMapper;



    @Override
    public void invoke(User user, AnalysisContext analysisContext) {
        if(StringUtils.isNotBlank(user.getName())){
            successList.add(user);
            return;
        }
        if(successList.size() >= BATCH_COUNT){
            log.info("讀取資料:{}", successList.size());
            saveData();
        }

    }

    /**
     * 採用多執行緒讀取資料
     */
    private void saveData() {
        List<List<User>> lists = ListUtil.split(successList, 20000);
        CountDownLatch countDownLatch = new CountDownLatch(lists.size());
        for (List<User> list : lists) {
            threadPoolExecutor.execute(()->{
                try {
                    userMapper.insertSelective(list.stream().map(o -> {
                        User user = new User();
                        user.setName(o.getName());
                        user.setId(o.getId());
                        user.setPhoneNum(o.getPhoneNum());
                        user.setAddress(o.getAddress());
                        return user;
                    }).collect(Collectors.toList()));
                } catch (Exception e) {
                    log.error("啟動執行緒失敗,e:{}", e.getMessage(), e);
                } finally {
                    //執行完一個執行緒減1,直到執行完
                    countDownLatch.countDown();
                }
            });
        }
        // 等待所有執行緒執行完
        try {
            countDownLatch.await();
        } catch (Exception e) {
            log.error("等待所有執行緒執行完異常,e:{}", e.getMessage(), e);
        }
        // 提前將不再使用的集合清空,釋放資源
        successList.clear();
        lists.clear();
    }

    /**
     * 所有資料讀取完成之後呼叫
     * @param analysisContext
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {
        //讀取剩餘資料
        if(CollectionUtils.isNotEmpty(successList)){
            log.info("讀取資料:{}條",successList.size());
            saveData();
        }
    }
}

而將這段程式碼中的CopyOnWriteArrayList換為ArrayList。

/*成功資料*/
// private final CopyOnWriteArrayList<User> successList = new CopyOnWriteArrayList<>();
private final List<User> successList =  new ArrayList<>();

匯入100萬資料量的耗時,直接從分鐘降為秒級,由此可見CopyOnWriteArrayList在寫入大物件時的效能非常之差!

image

五、總結

透過以上的學習,我們進行總結:CopyOnWriteArrayList的優勢在於可以保證執行緒安全的同時,不阻塞讀操作,但是這僅限於讀多寫少的情況;

在寫多讀少的情況下,或者寫入的物件佔用內容較大時,不建議使用CopyOnWriteArrayList;CopyOnWrite 容器只能保證資料的最終一致性,不能保證資料的實時一致性。所以如果你希望寫入的的資料,馬上能讀到,請不要使用 CopyOnWrite 容器,最好透過 ReentrantReadWriteLock 自定義一個的列表。

六、結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

相關文章