Hình dung hai người ở chung nhà. Cả hai mở tủ lạnh, thấy hết sữa, cả hai đi siêu thị — và tối về nhà có hai hộp sữa. Không ai làm sai cả: mỗi người "kiểm tra rồi hành động" đúng quy trình. Cái sai nằm ở khoảng trống giữa lúc kiểm tra và lúc hành động, khoảng mà người kia chen vào được. Gần như mọi lỗi của bộ sưu tập đa luồng đều là biến thể của hai hộp sữa ấy, và bài này đo từng biến thể. Bài 33 của sê-ri mổ xẻ HashMap bên trong. Bài này nói về chuyện xảy ra khi hai luồng cùng chạm vào nó, và về những bộ sưu tập sinh ra để chịu được điều đó.
HashMap hỏng thế nào
Tám luồng, mỗi luồng ghi 20.000 khoá không trùng nhau vào cùng một HashMap:
lần 1: mong đợi 160.000 khoá -> thực tế 79.040 MẤT 80.960
lần 2: mong đợi 160.000 khoá -> thực tế 119.516 MẤT 40.484
lần 3: mong đợi 160.000 khoá -> thực tế 117.299 MẤT 42.701
Một nửa dữ liệu biến mất. Và chú ý ba lần ra ba con số khác nhau — đúng đặc tính điều kiện đua ở bài 68: không hỏng đều đặn, nên không bao giờ tin được vào việc chạy thử.
Nguyên nhân là lúc bảng băm giãn nở: hai luồng cùng thấy bảng đầy, cùng cấp mảng mới, cùng chuyển các mục sang — và một trong hai bản đồ chuyển đó bị ghi đè.
Trên Java 7 còn tệ hơn: giãn nở đồng thời có thể tạo ra vòng lặp trong danh sách liên kết, khiến get() chạy mãi và ăn hết một nhân CPU. Java 8 đổi cách chuyển nên không còn treo, nhưng vẫn mất dữ liệu như trên.
ConcurrentHashMap, cùng phép thử:
lần 1: 160.000 lần 2: 160.000 lần 3: 160.000
synchronizedMap an toàn, nhưng vẫn không đủ
Collections.synchronizedMap bọc mọi phương thức trong synchronized. Mỗi thao tác đơn lẻ là nguyên tử, và nó thật sự không mất dữ liệu.
Nhưng mẫu mã này thì vẫn sai:
if (!m.containsKey(khoa)) {
m.put(khoa, taoGiaTri()); // đắt tiền
}
containsKey rồi put : 20.000 khoá, hàm khởi tạo chạy 20.041 lần <- CHẠY THỪA 41 lần
computeIfAbsent : 20.000 khoá, hàm khởi tạo chạy 20.000 lần (đúng)
Hai thao tác đều nguyên tử, nhưng khoảng giữa chúng thì không. Hai luồng cùng thấy khoá vắng mặt, cùng gọi taoGiaTri(), cùng ghi vào — đúng hai hộp sữa.
41 lần thừa nghe ít, nhưng nếu taoGiaTri() là mở một kết nối, tải một tệp cấu hình, hay khởi tạo một bộ nhớ đệm thì đó là 41 lần làm việc thừa — và tệ hơn, 41 đối tượng khác nhau được tạo ra trong khi mã của bạn giả định chỉ có một.
ConcurrentHashMap cho sẵn các phép nguyên tử đó:
m.computeIfAbsent(khoa, k -> taoGiaTri()); // tạo đúng một lần
m.merge(tu, 1, Integer::sum); // đếm
m.putIfAbsent(khoa, gt); // trả giá trị cũ nếu đã có
m.replace(khoa, cu, moi); // chỉ đổi nếu giá trị hiện tại đúng như mong đợi
m.compute(khoa, (k, v) -> v == null ? 1 : v * 10);
merge để đếm : {a=3, b=2, c=1}
putIfAbsent : 3 (trả giá trị cũ, không ghi đè)
replace có điều kiện: true -> b=20
merge là cách gọn nhất để đếm tần suất, và nó nguyên tử — khác hẳn map.put(k, map.get(k)+1).
Bẫy: computeIfAbsent không được gọi lại chính nó
Ghi nhớ kết quả đệ quy là một trong những cách dùng map tự nhiên nhất:
static long fib(ConcurrentHashMap<Integer,Long> c, int n) {
if (n < 2) return n;
return c.computeIfAbsent(n, k -> fib(c, k-1) + fib(c, k-2));
}
IllegalStateException: Recursive update
số lần ném lỗi: 200/200
Hai trăm lần chạy, hai trăm lần hỏng.
ConcurrentHashMap khoá một phần bảng trong lúc chạy hàm bạn đưa vào, nên nếu hàm ấy lại sửa chính map đó thì có thể tự khoá chính mình. Java 9 thêm phép kiểm để ném lỗi thay vì treo — thà hỏng rõ ràng.
Nghĩa là: hàm truyền vào computeIfAbsent, compute, merge phải ngắn, thuần tuý, và không được chạm vào chính map đó. Với ghi nhớ đệ quy, hãy dùng HashMap thường (nếu một luồng) hoặc tách thành get rồi putIfAbsent.
Vòng lặp trên bộ sưu tập đang bị sửa
ArrayList : ConcurrentModificationException
synchronizedList : ConcurrentModificationException
HashMap : ConcurrentModificationException
CopyOnWriteList : duyệt xong, còn 4 phần tử
ConcurrentHashMap: duyệt xong, còn 2 mục
Bộ sưu tập thường dùng iterator hỏng-sớm: nó đếm số lần cấu trúc bị sửa, và ném ngoại lệ ngay khi phát hiện thay đổi. Chú ý synchronizedList cũng ném — bọc khoá không đổi được điều đó, và đây là chỗ người ta hay tưởng nhầm là đã an toàn.
Các bộ sưu tập đồng thời dùng iterator nhất quán yếu: nó không ném lỗi, nhưng chỉ đảm bảo phản ánh trạng thái tại một thời điểm nào đó. Phần tử thêm vào giữa chừng có thể thấy hoặc không.
Hệ quả cần biết: size() của ConcurrentHashMap chỉ là ước lượng khi đang có luồng ghi, và duyệt hai lần có thể ra hai kết quả. Có mappingCount() trả về long cho map rất lớn.
Nếu bạn cần một ảnh chụp nhất quán thật sự, không bộ sưu tập đồng thời nào cho bạn — phải tự khoá.
Tốc độ
Tám luồng, 80% đọc và 20% ghi, một triệu thao tác:
HashMap + synchronized : 103 ms
Collections.synchronizedMap : 58 ms
ConcurrentHashMap : 45 ms
ConcurrentHashMap nhanh hơn hơn hai lần, và khoảng cách nới rộng khi thêm luồng.
Lý do nằm ở cách khoá: synchronizedMap có một khoá cho cả map — mọi thao tác xếp hàng. Từ Java 8, ConcurrentHashMap khoá ở mức từng ngăn của bảng băm, nên hai luồng chạm vào hai khoá khác nhau thường không đụng nhau chút nào. Đọc thì thậm chí không cần khoá gì, vì các nút được khai volatile.
Bản Java 7 chia bảng thành 16 mảnh cố định; bản mới mịn hơn nhiều và bỏ luôn tham số concurrencyLevel — nếu bạn thấy nó trong mã cũ thì nó chỉ còn là gợi ý về kích thước ban đầu.
CopyOnWriteArrayList: đọc miễn phí, ghi trả đủ
Nó chép toàn bộ mảng mỗi lần ghi. Đó là toàn bộ thiết kế, và toàn bộ đánh đổi:
kích thước 1000 lần ghi so với synchronizedList
10 0,3 micro giây 2,9 lần chậm hơn
100 0,2 micro giây 6,4 lần chậm hơn
1.000 0,8 micro giây 31,5 lần chậm hơn
10.000 11,5 micro giây 260,3 lần chậm hơn
Chi phí ghi tăng tuyến tính theo kích thước danh sách, nên thêm n phần tử vào một danh sách rỗng là O(n²).
Đổi lại, đọc không khoá gì cả. Bảng dưới đo 8 luồng với các tỷ lệ ghi khác nhau:
tỷ lệ ghi CopyOnWrite synchronizedList
0,0% 43 ms 43 ms
0,1% 34 ms 48 ms <- COW thắng
1,0% 23 ms 24 ms <- hoà
10,0% 1349 ms 38 ms <- COW thua 35 lần
Ranh giới nằm quanh 1% ghi. Dưới ngưỡng đó thì CopyOnWriteArrayList có lợi; trên ngưỡng đó nó sụp rất nhanh.
Chỗ nó đúng là những danh sách gần như chỉ đọc: danh sách listener đăng ký lúc khởi động, bảng cấu hình nạp một lần rồi thi thoảng nạp lại, danh sách trắng địa chỉ IP. Đặc điểm chung: ghi vài lần trong cả vòng đời ứng dụng, đọc hàng triệu lần.
Chỗ nó sai là mọi hàng đợi, mọi bộ đệm, mọi danh sách tích luỹ dữ liệu. Với những thứ đó dùng ConcurrentLinkedQueue hoặc BlockingQueue ở bài 73.
Bảng chọn
| Cần | Dùng |
|---|---|
| Map đa luồng | ConcurrentHashMap |
| Map có thứ tự, đa luồng | ConcurrentSkipListMap |
| Set đa luồng | ConcurrentHashMap.newKeySet() |
| Danh sách gần như chỉ đọc | CopyOnWriteArrayList |
| Hàng đợi giữa các luồng | ArrayBlockingQueue / LinkedBlockingQueue có giới hạn |
| Hàng đợi không chặn | ConcurrentLinkedQueue |
| Dữ liệu không đổi sau khi tạo | List.of / Map.of — bất biến thì an toàn sẵn |
Và Collections.synchronizedMap thì gần như không còn lý do tồn tại trong mã mới — nó vừa chậm hơn, vừa không cho phép ghép thao tác nguyên tử, vừa vẫn ném ConcurrentModificationException khi duyệt.
Mẫu số chung
Cả cái bảng chọn ở trên gói một sự thật mà nhiều người bỏ qua: không có "bộ sưu tập an toàn luồng" theo nghĩa phổ quát. An toàn luôn được mua bằng một loại tiền tệ khác nhau — một khoá lớn (mọi thao tác xếp hàng), khoá chia ngăn (chỉ đụng nhau khi trùng ngăn), chép-khi-ghi (đọc miễn phí, ghi trả cả mảng) — và loại nào đáng chọn phụ thuộc vào hình dạng tải: tỷ lệ đọc trên ghi. Cái ngưỡng 1% của CopyOnWriteArrayList chính là điều đó hiện ra thành con số. Hỏi "cấu trúc này có an toàn luồng không" là hỏi sai; câu đúng là "nó tối ưu cho tải đọc-nhiều hay ghi-nhiều".
Đổi ngăn xếp, cùng bài toán:
- Go có
sync.Mapđược thiết kế riêng cho tải đọc-nhiều/chỉ-thêm (hai kho, gần như chép-khi-ghi), và tài liệu nói thẳng nó chỉ hơn mộtmapbọcsync.RWMutexở đúng vài kiểu truy cập — ngoài ra thì không. Cùng "an toàn có hình dạng". Và triết lý gốc của Go né hẳn vấn đề: "chia sẻ bộ nhớ bằng cách giao tiếp" — dùng channel chuyển quyền sở hữu thay vì nhiều luồng cùng ghi một chỗ. - Và có một hướng xoá sạch cả lớp bẫy này: bất biến. Cấu trúc bền vững của Clojure (chia sẻ cấu trúc, an toàn luồng sẵn), mô hình không-chia-sẻ-trạng-thái của Erlang, và quyền sở hữu của Rust biến chính data race thành lỗi biên dịch. Không có trạng thái thay đổi được thì không có gì để bảo vệ.
Nên câu hỏi đúng khi nhiều luồng cùng chạm một cấu trúc không phải "chọn bộ sưu tập an toàn nào" mà là, theo thứ tự: "tôi có thể làm nó bất biến không" (rẻ nhất, dùng List.of/Map.of), nếu không thì "tải của tôi đọc-nhiều hay ghi-nhiều" để chọn đúng loại tiền tệ. Cách rẻ nhất để an toàn luồng vẫn luôn là không có trạng thái thay đổi được.
Muốn bắt lỗi ngay hôm nay: tìm trong dự án mẫu if (!map.containsKey(k)) map.put(k, ...) hoặc if (map.get(k) == null) .... Mỗi chỗ như vậy là một điều kiện đua — đúng hai hộp sữa — kể cả khi map đã được bọc synchronizedMap. Đổi sang computeIfAbsent là sửa một dòng, và nếu phần khởi tạo có tác dụng phụ, bạn vừa chặn được một lỗi rất khó tái hiện.
Ngày mai: ThreadLocal — dùng nó để làm gì, và vì sao quên gọi remove() trong môi trường có pool luồng là một kiểu rò rỉ bộ nhớ rất khó lần ra.