一、寫在開頭
我們在學習集合或者說容器的時候瞭解到,很多集合並非執行緒安全的,在併發場景下,為了保障資料的安全性,誕生了併發容器,廣為人知的有ConcurrentHashMap、ConcurrentLinkedQueue、BlockingQueue等,那你們知道ArrayList也有自己對應的併發容器嘛?
作為使用頻率最高的集合類之一,ArrayList執行緒不安全,我們在併發環境下使用,一般要輔以手動上鎖、或者透過Collections.synchronizedList()轉一手,為了解決這一問題,Doug Lea(道格.利)大師為我們提供了它的併發類——CopyOnWriteArrayList
。
二、認知CopyOnWriteArrayList
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秒!
核心實現程式碼如下,具體內容實現可去看那篇文章哈
@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在寫入大物件時的效能非常之差!
五、總結
透過以上的學習,我們進行總結:CopyOnWriteArrayList的優勢在於可以保證執行緒安全的同時,不阻塞讀操作,但是這僅限於讀多寫少的情況;
在寫多讀少的情況下,或者寫入的物件佔用內容較大時,不建議使用CopyOnWriteArrayList;CopyOnWrite 容器只能保證資料的最終一致性,不能保證資料的實時一致性。所以如果你希望寫入的的資料,馬上能讀到,請不要使用 CopyOnWrite 容器,最好透過 ReentrantReadWriteLock 自定義一個的列表。
六、結尾彩蛋
如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!