Thực hành đa luồng tốt nhất: bản Java — quy ước thời đại virtual thread

· · Đa luồng, Java, Ứng dụng nghiệp vụ, Điều tra lỗi, Thiết kế

«Chúng tôi muốn song song hóa một việc batch nghiệp vụ bằng Java.» «Bộ nhớ đệm dùng chung trong ứng dụng web Spring thỉnh thoảng bị hỏng.» «Chúng tôi thừa kế một ứng dụng Swing cũ đầy new Thread.» — Java đã gắn đa luồng vào ngôn ngữ từ JDK 1.0, trưởng thành thành hộp công cụ java.util.concurrent, và với virtual thread ở JDK 21 đã viết lại một lần nữa sự hiểu biết thông thường của lập trình đồng thời. Chính vì bộ công cụ phong phú đến thế, công cụ bạn chọn trở thành chất lượng thiết kế.

Bài viết này là bản Java của loạt đa luồng trong thực tế. Dành cho nhà phát triển viết hệ thống nghiệp vụ, việc batch và ứng dụng máy chủ bằng Java, nó ánh xạ các nguyên tắc thiết kế đa luồng — đừng bao giờ tạo thread trực tiếp, giảm trạng thái dùng chung có thể đổi, kỷ luật khóa, thiết kế cách dừng trước mọi thứ khác — lên công cụ của Java (nhắm chủ yếu bản LTS, JDK 21 trở lên), và gom các lựa chọn thời đại virtual thread cùng các bẫy riêng của Java, dựa trên nguồn sơ cấp tính đến tháng 8 năm 2026. Được viết để đọc độc lập. Cùng nguyên tắc áp dụng cho ngôn ngữ khác nằm ở các bài đồng hành «bản .NET», «bản C++» và «bản C».

1. Kết luận trước

  • Đừng viết new Thread trong mã nghiệp vụ — cùng quy tắc đó áp dụng ở Java. Giao tác vụ cho ExecutorService và để thư viện quản lý vòng đời thread.1
  • Đưa tác vụ chủ yếu chờ I/O sang virtual thread. Virtual thread, chính thức từ JDK 21, dùng một cái mỗi tác vụ và không bao giờ được gom vào pool. Giới hạn đồng thời bằng Semaphore, không bằng kích thước pool.23
  • Virtual thread là công cụ thông lượng, không phải công cụ làm tính toán nhanh hơn. Song song hóa CPU-bound vẫn là việc của thread nền tảng cỡ khoảng số lõi — pool cố định hoặc parallel stream, như trước.3
  • Khóa trên đối tượng khóa private final, hoặc ReentrantLock riêng. synchronized(this) và khóa trên đối tượng phơi ra công khai có thể đụng mã ngoài. Nếu cần chiếm có thời hạn (tryLock), dùng ReentrantLock.
  • JDK 21-23 có vấn đề chặn trong khối synchronized ghim virtual thread; JDK 24 (JEP 491) đã sửa. Phân biệt cảnh báo cũ với thực tế hiện nay.34
  • volatile bảo đảm tầm nhìn và thứ tự, không phải tính nguyên tử. Dùng AtomicInteger / LongAdder cho bộ đếm, và khóa cho trạng thái ghép.5
  • Dừng hợp tác qua interruption là cách đúng duy nhất để dừng thread. Thread.stop / suspend / resume giờ ném UnsupportedOperationException. Đừng nuốt InterruptedException — khôi phục trạng thái hoặc ném lại.6
  • Dừng ExecutorService bằng mẫu hai giai đoạn: shutdown → awaitTermination → shutdownNow. shutdownNow là best-effort (cài đặt chuẩn làm việc qua interruption), nên giả định tác vụ của bạn đáp interruption.1
  • UI của Swing thuộc riêng EDT (Event Dispatch Thread). Yêu cầu cập nhật từ thread khác qua SwingUtilities.invokeLater.7

2. Vì sao đa luồng khó — điều kiện đua, khóa chết, và mô hình bộ nhớ

Đun các vấn đề đa luồng đưa vào, bất kể ngôn ngữ, còn hai loại.

Điều kiện đua là lỗi mà kết quả phụ thuộc thứ tự nhiều thread tới một đoạn mã. Ví dụ cổ điển là bộ đếm dùng chung: biểu thức đơn count++ thực ra tách thành ba bước — đọc, cộng, ghi lại. Nếu hai thread vào ba bước này cùng lúc, lần tăng của một thread bị ghi đè và mất khi thread kia ghi lại. Kết quả đổi mỗi lần chạy, và không cách nào đoán được kết quả nào.

Thread BBiến dùng chung countThread AThread BBiến dùng chung countThread Acount = 10Hai lần tăng đã xảy ra,nhưng count = 11 — lần tăng của Thread A bị mấtĐọc (10)Đọc (10)Cộng cục bộ (11)Cộng cục bộ (11)Ghi lại (11)Ghi lại (11)

Hình 1: Điều kiện đua cổ điển trong đó lần tăng trên bộ đếm dùng chung bị mất. Nếu thread khác xen vào ba bước của count++, lần ghi lại sau cùng ghi đè lần kia

Khóa chết là trạng thái hai thread mỗi bên chờ khóa bên kia đang giữ, nên không ai tiến được. Thread A giữ khóa 1 và chờ khóa 2; thread B giữ khóa 2 và chờ khóa 1 — chỉ vậy đủ để cả hai dừng mãi mãi.

chờ giải phóng khóa 2chờ giải phóng khóa 1Thread Ađang giữ khóa 1Thread Bđang giữ khóa 2

Hình 2: Chờ vòng của khóa chết. Khoảnh khắc các mũi tên chờ tạo vòng, mọi thread trong vòng đó dừng mãi mãi

Cả hai phụ thuộc thời điểm. Một sự xen kẽ chỉ hiện một lần trong hàng chục nghìn lần chạy trên máy phát triển có thể xảy ra mỗi ngày trên máy chủ sản xuất với số lõi và tải khác. «Ngừng tái hiện khi gắn trình gỡ lỗi» và «biến mất khi thêm ghi nhật ký» đều vì quan sát đổi thời điểm — hành vi cổ điển của lỗi đua. Đó chính là lý do mọi nguyên tắc trong bài này chỉ về hướng giảm chỗ cần đồng bộ, trước khi lo đồng bộ cho đúng.

2.1. Tiền đề riêng của Java — mô hình bộ nhớ và happens-before

Trên đó, điều riêng của Java là cách dữ liệu dùng chung được nhìn được định nghĩa bởi quan hệ happens-before trong Java Memory Model (JMM).

Đọc và ghi biến dùng chung không đồng bộ không trở thành «hành vi không xác định» kiểu C++, nhưng có thể hợp pháp dẫn tới giá trị cũ tiếp tục được thấy, hoặc các lần ghi xuất hiện lệch thứ tự. Lỗi nhất quán bộ nhớ mà «một vòng đang nhìn cờ boolean, nhưng giá trị thread khác đổi không bao giờ hiện» là hành vi JMM cho phép, không phải lỗi JVM.5 Công cụ chống điều này là các cơ chế tạo happens-before — synchronized, volatile, và các lớp trong java.util.concurrent. Dùng bộ sưu tập đồng thời đúng thì thư viện bảo đảm «quan hệ happens-before giữa thao tác cập nhật và lần lấy sau».8

Nói cách khác, hướng dẫn thực tế của Java có thể gói lại thế này: đừng khôn với biến dùng chung thô. Dùng công cụ trong java.util.concurrent để chia sẻ, và để thư viện tạo happens-before.

3. Cách tạo thread — ExecutorService và virtual thread

3.1. Tách tác vụ khỏi cách chạy

ExecutorService là thứ mang nguyên tắc «đừng tự tạo thread» ở Java. Nó tách việc (Runnable / Callable) khỏi cách thực thi — bao nhiêu thread, hàng đợi nào — và để việc tạo, tái sử dụng, hủy thread cho thư viện.1

Từ JDK 21 trở đi, chọn cách chạy trở thành lựa chọn nhị phân đơn giản.23

Chủ yếu chờ I/Ogọi HTTP, DB, tệpTính toán CPU-boundTác vụ muốn chạy đồng thờiĐiều gì dẫn dắt tác vụ?Virtual threadExecutors.newVirtualThreadPerTaskExecutormột cái mỗi tác vụ, không bao giờ gom poolPool cố định thread nền tảngExecutors.newFixedThreadPool - khoảng bằng số lõihoặc parallel streamGiới hạn đồng thời tới dịch vụ ngoàibằng Semaphore, không bằng kích thước pool

Hình 3: Chọn cách thực thi việc từ JDK 21. Kẻ đường trước — «đổi cách chờ I/O, song song hóa cho CPU» — rồi giao việc I/O-bound cho virtual thread và việc CPU-bound cho pool thông thường

Có một lưu ý phía CPU-bound. Executors.newFixedThreadPool giới hạn số thread, nhưng hàng đợi của nó không giới hạn. Trong dịch vụ sống lâu mà việc gửi luôn vượt xử lý, chỉ thread bị giới hạn bằng số lõi — tác vụ chất trong hàng đợi, và dữ liệu của chúng, tiếp tục ăn bộ nhớ. Ở kiểu bố trí đó, hoặc dùng ThreadPoolExecutor trực tiếp để cấu hình hàng đợi có biên cộng chính sách từ chối, hoặc đặt kiểm soát nhận vào như Semaphore phía gửi để có thể tạo áp lực ngược (cùng nguyên tắc với thảo luận hàng đợi ở mục 4).

3.2. Đừng dùng sai virtual thread

Virtual thread là thread nhẹ tách khỏi thread OS: trong thao tác chặn của JDK (I/O, khóa, ngủ và tương tự trong thư viện chuẩn) chúng nhả thread OS, vì thế một JVM có thể chạy hàng triệu. Tuy vậy, chúng không nhả cho mọi kiểu chặn. Nếu virtual thread chặn khi chạy mã gốc (JNI) hoặc hàm ngoài, nó vẫn bị ghim vào thread mang. Điều JDK 24 (JEP 491, dưới đây) sửa là việc ghim do synchronized; việc ghim ở biên gốc vẫn còn, nên chất số lớn virtual thread bằng thao tác chặn lâu qua trình điều khiển JNI hoặc API thiết bị sẽ cạn thread mang. Nhưng như hướng dẫn chính thức nhấn mạnh, chúng không phải «thread nhanh hơn». Tốc độ thực thi mã không đổi — thứ chúng cung cấp là quy mô (thông lượng).3

Có ba kỷ luật khi dùng.3

  1. Đừng gom vào pool. Virtual thread rẻ và dùng một lần; «số tác vụ = số virtual thread» là trạng thái đúng. Đưa virtual thread vào newFixedThreadPool là sai — dùng dạng try (var executor = Executors.newVirtualThreadPerTaskExecutor()).
  2. Giới hạn đồng thời bằng Semaphore. Diễn đạt ràng buộc như «nhiều nhất 10 kết nối đồng thời tới API ngoài» bằng semaphore, không bằng kích thước pool.
  3. Đừng dùng cho việc CPU-bound. Thread nền tảng cỡ khoảng số lõi vẫn là công cụ đúng để song song hóa tính toán, như trước.

Lưu ý rằng thứ chạy trong virtual thread là mã đồng bộ thông thường. Thay vì viết lại mã như async/await của .NET, triết lý thiết kế sau virtual thread là cho bạn chạy mã thẳng «một thread mỗi yêu cầu», không đổi, ở quy mô khổng lồ.2

3.3. Hiểu lầm thường gặp — «không cần pool» chỉ áp dụng cho virtual thread

Đừng đọc kỷ luật «đừng gom vào pool» thành «Java không có pool thread (hoặc nó kém hiệu quả)». Thực tế ngược lại: pool của Java là phần chín của thư viện chuẩn từ JDK 5 (2004). Pool đa năng cấu hình tinh ThreadPoolExecutor (tạo qua các nhà máy Executors), ForkJoinPool ăn cắp việc (thể hiện chung commonPool() là đích thực thi mặc định của parallel stream và CompletableFuture), và ScheduledThreadPoolExecutor cho chạy định kỳ — những thứ này vẫn là nhân vật chính cho việc CPU-bound.

Pool về bản chất là tối ưu dựa trên «tạo và giữ thread OS đắt, nên dùng lại». Virtual thread xóa tiền đề này bằng cách đưa chi phí tạo gần không, nên không còn lý do dùng lại — hiểu chính xác không phải pool trở nên kém hiệu quả, mà thread đã nhẹ đủ để tối ưu pool không cần thiết. Và dưới virtual thread, bộ lập lịch JDK chạy một tập thread mang (thread OS) số lượng khoảng số lõi, như ForkJoinPool ăn cắp việc.2 Nói cách khác, hình «xử lý khối lượng việc đồng thời khổng lồ bằng pool nhỏ thread OS» được giữ; chỉ việc quản lý pool đó chuyển từ tay nhà phát triển sang JVM. Công bằng mà nói Java tới cùng đích với async/await của .NET, thứ trả thread về pool tại điểm await, mà không đổi hình mã.

4. Giảm trạng thái dùng chung có thể đổi — phân vùng, bất biến, bộ sưu tập đồng thời, và hàng đợi

Tranh chấp chỉ sinh khi có cả «nhiều thread» và «dữ liệu dùng chung có thể đổi». Số thread do yêu cầu quyết định, nên thứ thiết kế cắt được là phần dùng chung. Có ba họ kỹ thuật — phân vùng, làm bất biến, và chuyển dữ liệu — và đây là cách viết chúng bằng Java.

Phân vùng. Với tổng hợp song song, thay vì mỗi thread ghi vào biến tổng dùng chung, để mỗi thread dựng kết quả bộ phận rồi gộp cuối. reduce / collect của parallel stream cung cấp đúng hình này như khung, và LongAdder nói dưới đây cũng là cài đặt chiến lược phân vùng — nội bộ tách thành ô để trải tranh chấp, và cộng khi đọc. Giảm tần suất ghi vào trạng thái dùng chung đến trước khi viết đồng bộ cho đúng.

Làm bất biến. Dựng dữ liệu bằng record và bộ sưu tập bất biến (List.copyOf / Map.copyOf) không bao giờ được viết lại sau khi tạo, rồi chia sẻ được mà không đồng bộ. Với cấu hình và dữ liệu gốc, mẫu chuẩn: khi cần thay, dựng đối tượng mới và đổi tham chiếu volatile. Tuy nhiên «trông chỉ đọc» và «là bất biến» là hai việc khác. Bộ truy cập của record trả tham chiếu thô của thành phần, và bản sao của List.copyOf cũng nông (không nhân đôi đối tượng phần tử), nên nếu phần tử có thể đổi, ai giữ bí danh cũng viết lại nội dung được, và tranh chấp còn. An toàn chia sẻ không đồng bộ chỉ khi toàn đồ thị đối tượng — kể cả phần tử — là bất biến. Nếu có phần tử có thể đổi, hoặc đưa bản sao sâu hoặc đẩy phần tử về record / kiểu bất biến luôn.

Dùng thao tác ghép trên bộ sưu tập đồng thời. Với mẫu «tạo nếu thiếu, rồi chèn» của ConcurrentHashMap, dùng computeIfAbsent. Phương thức này thực thi cả lần gọi một cách nguyên tử, và nếu khóa vắng, hàm ánh xạ được gọi đúng một lần trong lần gọi đó.8 Bảo đảm khác ConcurrentDictionary.GetOrAdd của .NET (nhà máy có thể chạy hơn một lần dưới tranh chấp) — điểm người chuyển giữa hai ngôn ngữ dễ lẫn. Tuy nhiên không phải «đúng một lần suốt đời khóa». Nếu hàm trả null hoặc ném, không có ánh xạ được đăng ký, và hàm chạy lại ở lần gọi sau (cũng vậy nếu mục bị xóa sau đăng ký). Nếu khởi tạo không chịu được tác dụng phụ trùng, thiết kế để hàm thành công và trả khác null. Giá của việc nguyên tử là một số cập nhật từ thread khác bị chặn trong lúc tính, nên giữ hàm ánh xạ ngắn, và đừng bao giờ cập nhật cùng bản đồ từ trong hàm (cập nhật đệ quy bị phát hiện có thể ném IllegalStateException).8

// Mẫu chuẩn của bộ đếm tần suất: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

Chuyển dữ liệu qua hàng đợi. Đưa dòng dữ liệu giữa các thread qua BlockingQueue. Với ArrayBlockingQueue được cho dung lượng, put chặn khi đầy, tạo áp lực ngược tự nhiên — cùng hình với kênh có biên của bản .NET. Ngay cả thời đại virtual thread, thiết kế kẻ ranh rõ giữa nhà sản xuất và người tiêu thụ vẫn hiệu quả.

5. Kỷ luật khóa — synchronized và ReentrantLock

5.1. Khóa trên gì, và đừng làm gì khi đang giữ khóa

Nghĩ đơn vị khóa không phải «đoạn mã» mà là «dữ liệu». Ánh xạ một đối tượng khóa cho mỗi tập dữ liệu có thể đổi muốn bảo vệ, và lấy cùng khóa đó tại mọi chỗ chạm dữ liệu đó — thực tế lỗi đua thường là ánh xạ này đã vỡ ở đâu đó. Tránh synchronized(this)synchronized(SomeClass.class), vì mã ngoài có thể khóa cùng đối tượng; thay vào đó, ghép dữ liệu muốn bảo vệ một-một với private final Object lock = new Object(); không bao giờ phơi ra ngoài.

Hai kỷ luật nữa trên đó. Thứ nhất, đừng làm gì chậm, hay gì chạm thế giới ngoài, khi đang giữ khóa. I/O, gọi listener, hoặc chạy mã không biết trong khi vẫn giữ khóa vừa kéo dài thời gian giữ vừa rủi ro bên được gọi cố lấy khóa khác, tạo chờ vòng từ hình 2. Thứ hai, cố định thứ tự chiếm nhiều khóa. Nơi lấy hai khóa trở lên, đặt quy tắc mọi thread lấy cùng thứ tự, và với chỗ không bảo đảm thứ tự được, chuẩn bị đường «bỏ và thử lại nếu không được» bằng tryLock(timeout) nói dưới đây.

synchronized đủ cho «loại trừ ngắn và đơn giản». Chuyển sang ReentrantLock khi cần những điều sau.

  • Chiếm có thời hạn qua tryLock(timeout) (đổi treo vĩnh viễn thành thất bại có thể ghi nhật ký và xử lý)
  • Chính sách công bằng, nhiều Condition, hoặc khi muốn tách chiếm và nhả khóa sang phương thức khác

Khi dùng ReentrantLock, đừng phá mẫu try ngay sau lock()unlock() trong khối finally (Java không có tương đương RAII của C++, nên mẫu này là toàn bộ kỷ luật).

5.2. Virtual thread và việc ghim — điều đổi ở JDK 24

Khi virtual thread mới được đưa vào (JDK 21-23), có ràng buộc chặn trong khối synchronized ghim virtual thread vào thread OS (không nhả thread OS được, mất lợi ích quy mô), và người ta khuyên thay chỗ chặn thường xuyên hoặc lâu bằng ReentrantLock.3 Ràng buộc đó được gỡ khi JEP 491 của JDK 24 viết lại cài đặt monitor, và synchronized không còn ghim virtual thread.4 Nếu bạn ở JDK 24 trở lên, thay synchronized máy móc như biện pháp chống ghim không còn cần. Đáng kiểm xem hướng dẫn cũ hơn của tổ chức còn kẹt ở cảnh báo thời JDK 21 không.

5.3. Nguyên tử và volatile nằm đâu

Cập nhật nguyên tử một biến do AtomicInteger / AtomicLong / AtomicReference đảm nhận (hoặc, với thống kê chỉ tăng ở tần suất cao, LongAdder chống tranh chấp). volatile bảo đảm tầm nhìn và thứ tự (happens-before), không phải tính nguyên tử của thao tác ghép.5 Cùng kết luận với bản .NET và C++ cũng đứng ở Java: dùng nguyên tử cho cờ và giá trị đơn, khóa cho trạng thái ghép, và đừng cố để volatile làm một mình.

6. Thiết kế cách dừng — interruption như ngôn ngữ chung

6.1. Phép tắc của interrupt

Dừng và hủy ở Java thống nhất quanh interruption. t.interrupt() đặt trạng thái interrupt của thread đích, và nếu đích bị chặn trong sleep / wait / join hoặc tương tự, nó ném InterruptedException để đánh thức ngay (lúc đó trạng thái interrupt bị xóa).6 Thread.stop / suspend / resume, cơ chế cưỡng bức của quá khứ, về bản chất không an toàn, nên gọi chúng giờ ra UnsupportedOperationException.6

Tự kết thúc đượcKhông kết thúc được - ví dụ trong thư việnNgười gọi dừng nó - gọi t.interruptTrạng thái interrupt được đặtThread đang tính:thăm dò Thread.interrupted trong vòngBị chặn trong sleep / wait / join:InterruptedException chạy và đánh thức ngaytrạng thái bị xóaDọn và tự kết thúcCatch làm gì?Thread.currentThread.interruptkhôi phục trạng thái và để lại tín hiệu

Hình 4: Dừng hợp tác qua interruption. Nuốt InterruptedException làm tín hiệu dừng biến mất — một khi bắt, lựa chọn là «kết thúc» hoặc «khôi phục»

Chỉ một kỷ luật cần nhớ trong thực tế: đừng viết mã bắt InterruptedException rồi không làm gì. Nếu kết thúc được trong trách nhiệm của mình, kết thúc ở đó; nếu không, khôi phục trạng thái bằng Thread.currentThread().interrupt() và chuyển tín hiệu cho người gọi (xem FAQ).

6.2. Tắt hai giai đoạn của ExecutorService

API tắt của ExecutorService ngồi trên mô hình interruption. shutdown() ngừng nhận tác vụ mới và để tác vụ đã gửi chạy đến hoàn tất; shutdownNow() cố dừng tác vụ đang chạy. Như đặc tả giao diện đây là best-effort, và được ghi rõ rằng cài đặt chuẩn (như ThreadPoolExecutor) thường hủy qua Thread.interrupt() — nghĩa là tác vụ không đáp interruption sẽ không dừng ngay cả với shutdownNow, và nếu dùng cài đặt Executor tùy chỉnh, cần xem tài liệu nó hủy thế nào (có gửi interrupt không).1 Mẫu dừng chuẩn tài liệu chính thức chỉ là mẫu hai giai đoạn sau.1

/** Đúng khi tắt xong. Đừng tiến tới giải phóng tài nguyên dùng chung khi còn sai. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Giai đoạn 1: ngừng nhận tác vụ mới và chờ hoàn tất
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Giai đoạn 2: yêu cầu hủy. Tác vụ bị hạ trước khi chạy
            // được trả về, nên đánh dấu Future của chúng đã hủy để đánh thức người gọi bị chặn ở get()
            pool.shutdownNow().forEach(r -> {
                if (r instanceof Future<?> f) f.cancel(false);
            });
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                System.err.println("Pool did not terminate");
                return false;           // Tắt chưa xong. Báo ở dạng phân biệt được với thành công
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // Khôi phục cả trạng thái interrupt của thread này
        return false;                   // Đường này cũng có thể để tắt chưa hoàn tất
    }
}
xong trong hạnhết giờhoàn tấtvẫn chưa xongshutdownngừng nhận tác vụ mớiawaitTerminationchờ hoàn tấtTắt xongshutdownNowgửi interrupt tới tác vụ đang chạycó đáp hay không tùy tác vụawaitTerminationchờ lạiGhi là bất thườngnghi tác vụ bỏ qua interrupt

Hình 5: Tắt hai giai đoạn của ExecutorService. Thiết kế theo giai đoạn «chờ lịch sự → yêu cầu qua interruption → nếu vẫn chưa xong, quan sát như bất thường»

Có một giới hạn khi hủy các RunnableshutdownNow() trả. Thứ trở về là đối tượng ngồi trong hàng đợi thực thi — với submit thường đó chính là FutureTask giao cho người gọi, nhưng với tác vụ gửi qua bao như ExecutorCompletionService, đó là bao trong hàng đợi, đối tượng khác với Future của người gọi. Ở cấu hình đó, việc hủy ở trên sẽ không hoàn tất Future của người gọi, nên thiết kế giữ danh sách Future của riêng lúc gửi và hủy chúng khi tắt (hoặc trả tác vụ bị hạ về chủ).

close() (AutoCloseable), có từ JDK 19, gói «gọi shutdown và chờ hoàn tất» thành dạng viết được bằng try-with-resources, và try (var executor = ...) kết hợp newVirtualThreadPerTaskExecutor của virtual thread là hình cơ bản hiện đại.1 Tuy nhiên close() không thay mẫu hai giai đoạn ở trên. Vì nó chờ hoàn tất không thời hạn, nếu dù chỉ một tác vụ không đáp interruption hoặc không bao giờ xong, thread cố đóng nó chặn mãi mãi. Đó là công cụ phù hợp phạm vi tác vụ hữu hạn và được bảo đảm chạy đến hoàn tất (gửi ngay đó, chờ ngay đó); với chỗ như đường tắt ứng dụng, nơi muốn luôn xong trong thời gian có biên, dùng mẫu hai giai đoạn có thời hạn. Hủy tác vụ đơn lẻ cũng qua interruption, bằng Future.cancel(true).

7. Thread UI — EDT của Swing

Ứng dụng máy tính để bàn, bất kể ngôn ngữ hay khung, theo quy tắc UI thuộc riêng thread quản lý nó. Ở Swing, thread riêng đó là Event Dispatch Thread (EDT): phương thức thành phần Swing, theo quy tắc, không an toàn luồng, và chạm từ nhiều thread mời nhiễu thread và lỗi nhất quán bộ nhớ. Yêu cầu cập nhật màn hình từ thread khác qua SwingUtilities.invokeLater tới EDT, và ngược lại, vì chạy thao tác lâu trên EDT đóng băng UI, đẩy việc nặng ra thread thợ qua SwingWorker hoặc tương tự.7 JavaFX theo cùng hình: cập nhật UI được yêu cầu trên thread ứng dụng qua Platform.runLater.

8. Xác minh và gỡ lỗi — dump thread như vũ khí

Không thể kỳ vọng kiểm thử tìm lỗi đua. Kiểm thử thường đếm lần chạy mà tranh chấp tình cờ không xảy ra là thành công. Nghĩ phòng thủ ba lớp.

Tuyến đầu là thiết kế. Khi rà, kiểm bằng bảng: dữ liệu có thể đổi nào được chia sẻ, khóa nào bảo vệ từng mục (ánh xạ mục 5.1), thứ tự chiếm khóa có duy nhất không, khối catch nào đang nuốt InterruptedException không, và đường dừng (shutdown/interruption) có tới mọi tác vụ không.

Thứ hai, dùng dump thread cho tốt. Java có công cụ chuẩn bắt «trạng thái mọi thread tại khoảnh khắc đóng băng này»: jstack (hoặc jcmd <pid> Thread.print) in dấu vết ngăn xếp, và tùy chọn -l thêm thông tin khóa.9 Lưu ý định dạng dump truyền thống này dành cho thread nền tảng; không gồm virtual thread của ứng dụng. Khi lần yêu cầu bị chặn ở cấu hình dùng virtual thread (mục 3), dùng jcmd <pid> Thread.dump_to_file -format=json <file>, thứ dump được cả virtual thread.2 Thủ tục cơ bản điều tra treo là lấy hai ba dump cách nhau vài giây và đối chiếu mỗi thread nhàn chờ khóa nào, và ai đang giữ khóa đó. Nếu ghi nhật ký hết giờ tryLock(timeout) (mục 5.2), còn có thể tự động hóa cò lấy dump.

Thứ ba, lắc dưới tải. Kiểm thử căng — chạy lâu với song song nhiều hơn số lõi, xáo thứ tự xử lý, chèn trễ nhân tạo — là cách thực tế tăng khả năng kéo «trúng» điều kiện đua trên máy phát triển. Chạy ít nhất một kiểm thử với khối lượng dữ liệu và số thread quy mô sản xuất trước phát hành.

9. Đồng thời Java đang đi đâu — Structured Concurrency

Nhìn nhanh nửa bước phía trước, để kết. Xây trên giả định virtual thread, Structured Concurrency (StructuredTaskScope) — thứ coi nhiều tác vụ con là một đơn vị việc và cấu trúc lan truyền thất bại cùng hủy — đang phát triển và, tính đến tháng 8 năm 2026, vẫn là tính năng xem trước. Nó được sửa thành dạng API dựa trên StructuredTaskScope.open() ở lần xem trước thứ năm của JDK 25 (JEP 505), và tiếp thành lần xem trước thứ sáu (JEP 525) ở JDK 26 hiện tại.1011 Trong khi đó Scoped Values, chia sẻ ngữ cảnh bất biến giải quyết vấn đề của ThreadLocal, đã được chốt ở JDK 25.12 Các nguyên tắc bài này — biên tác vụ rõ, chia sẻ bất biến, dừng hợp tác — cũng thẳng hướng các API mới này đang đi.

10. Tóm tắt — danh sách kiểm của Java

  1. Còn new Thread nào trong mã nghiệp vụ không (đã dựng trên ExecutorService / virtual thread chưa)?
  2. Việc I/O-bound và CPU-bound được đưa sang cơ chế thực thi khác nhau chưa (nhánh hình 3)?
  3. Virtual thread không bị gom pool, và đồng thời được giới hạn bằng Semaphore chưa?
  4. Dữ liệu dùng chung bất biến (record / List.copyOf), hay dựng trên công cụ của java.util.concurrent?
  5. Không có synchronized(this) hay khóa trên đối tượng phơi ra công khai?
  6. Đang dùng thao tác ghép của ConcurrentHashMap (computeIfAbsent, v.v.) và giữ hàm ánh xạ ngắn?
  7. Không kỳ vọng tính nguyên tử từ volatile (bộ đếm dùng lớp Atomic / LongAdder)?
  8. Không có dù một khối catch nuốt InterruptedException?
  9. Dừng ExecutorService theo mẫu hai giai đoạn có thời hạn (và chỗ dùng close() giới hạn phạm vi bảo đảm hoàn tất tác vụ)?
  10. Cập nhật UI Swing/JavaFX được gom trên EDT / thread ứng dụng?

Java là một trong những ngôn ngữ được trang bị công cụ đồng thời tốt nhất, và sự xuất hiện của virtual thread đã mở đường mở rộng mã đồng bộ thẳng mà không viết lại. Đó chính là lý do nắm đúng sự phân công giữa các công cụ — cái nào cho thông lượng, cái nào cho loại trừ, và tín hiệu dừng là gì — là bản chất thiết kế đa luồng ở Java.

Bài viết liên quan

Lĩnh vực tư vấn liên quan

KomuraSoft LLC đảm nhận rà soát thiết kế đa luồng cho hệ thống nghiệp vụ và xử lý batch Java, điều tra nguyên nhân gốc các khiếm khuyết liên quan đồng thời như trạng thái dùng chung bị hỏng và triệu chứng «thỉnh thoảng không dừng / treo» (phân tích dump thread), và tư vấn kỹ thuật về việc áp dụng virtual thread.

Liên kết tham khảo

  1. Oracle, ExecutorService (Java SE 21 & JDK 21 API). Về việc shutdown() để tác vụ đã gửi chạy đến hoàn tất đồng thời dừng gửi mới; về shutdownNow() cố dừng tác vụ đang chạy và trả danh sách tác vụ đang chờ thực thi, dù cài đặt điển hình hủy qua Thread.interrupt(), không bảo đảm ngoài best-effort, nên tác vụ không đáp interruption sẽ không kết thúc; về chờ hoàn tất bằng awaitTermination; về close() (AutoCloseable, từ Java 19) gọi shutdown và chờ hoàn tất, dùng được với try-with-resources; và về tắt hai giai đoạn shutdown → awaitTermination → shutdownNow được chỉ như ví dụ dùng.  2 3 4 5 6

  2. OpenJDK, JEP 444: Virtual Threads. Về việc virtual thread trở thành tính năng chính thức ở JDK 21; về chúng là thread nhẹ giảm mạnh công sức viết, bảo trì và quan sát ứng dụng đồng thời thông lượng cao; về triết lý thiết kế mở rộng mã đồng bộ thẳng «một yêu cầu, một thread» không đổi; về bộ lập lịch virtual thread của JDK là ForkJoinPool ăn cắp việc chạy chế độ FIFO, với song song mặc định bằng số bộ xử lý sẵn có; và về định dạng dump thread mới gồm virtual thread được thêm dưới dạng jcmd Thread.dump_to_file (văn bản thường và JSON), trong khi dump thread truyền thống không gồm virtual thread.  2 3 4 5

  3. Oracle Java SE Core Libraries, Virtual Threads. Về việc virtual thread là thread nhẹ do thời gian chạy Java cài đặt và nhả thread OS trong I/O chặn; về chúng là tính năng cho quy mô (thông lượng) chứ không tốc độ (độ trễ), và không phù hợp xử lý nặng CPU; về không bao giờ gom virtual thread vào pool và dùng một cái mỗi tác vụ (newVirtualThreadPerTaskExecutor); về dùng Semaphore chứ không pool thread để giới hạn đồng thời; về chặn trong synchronized tính đến JDK 21 gây ghim vào thread OS, nên khuyên thay chỗ thường xuyên hoặc lâu bằng ReentrantLock; và về phát hiện việc ghim bằng -Djdk.tracePinnedThreads.  2 3 4 5 6 7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Về việc cài đặt monitor của JVM được viết lại cho JDK 24 để hỗ trợ virtual thread, nên chặn trong khối hoặc phương thức synchronized không còn ghim virtual thread vào thread mang; và về việc đó nghĩa là biện pháp thời JDK 21-23 «thay synchronized bằng ReentrantLock» về nguyên tắc không còn cần.  2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. Về lỗi nhất quán bộ nhớ sinh khi nhiều thread có góc nhìn không nhất quán về cùng dữ liệu; về chìa khóa tránh chúng là quan hệ happens-before (bảo đảm ghi bộ nhớ của một câu lệnh nhìn thấy được từ câu lệnh khác); và về synchronized, volatile, và Thread.start / join, cùng các thứ khác, tạo happens-before.  2 3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). Về việc Thread.stop / suspend / resume về bản chất không an toàn (khóa được giải phóng ở trạng thái không nhất quán và đối tượng hỏng trở nên nhìn thấy; suspend có thể mời khóa chết), khiến chúng bị loại bỏ để xóa, và giờ ném UnsupportedOperationException khi gọi; về interrupt() đặt trạng thái interrupt và đánh thức thread bị chặn trong sleep / wait / join bằng cách ném InterruptedException (xóa trạng thái interrupt); và về khác biệt cách interrupted() và isInterrupted() đối xử trạng thái đó.  2 3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. Về mã xử lý sự kiện Swing chạy trên Event Dispatch Thread (EDT); về hầu hết phương thức đối tượng Swing không an toàn luồng, nên gọi từ nhiều thread mời nhiễu thread và lỗi nhất quán bộ nhớ, nghĩa là truy cập thành phần Swing theo quy tắc nên làm trên EDT; về yêu cầu tác vụ trên EDT từ thread khác qua SwingUtilities.invokeLater / invokeAndWait; và về tác vụ chạy trên EDT cần kết thúc nhanh.  2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Về việc cả lần gọi phương thức computeIfAbsent thực thi nguyên tử, với hàm ánh xạ được gọi đúng một lần khi khóa vắng; về một số thao tác cập nhật từ thread khác bị chặn trong lúc tính, nên nên giữ ngắn và đơn giản; về hàm ánh xạ bị cấm sửa chính bản đồ này, với cập nhật đệ quy bị phát hiện ra IllegalStateException; và về thao tác lấy (get) không chặn, với quan hệ happens-before giữa cập nhật cho một khóa và lần lấy sau.  2 3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). Về việc jstack in dấu vết ngăn xếp (tên lớp, tên phương thức, số dòng) của mọi thread trong tiến trình Java chỉ định; về tùy chọn -l bật hiển thị chi tiết gồm thông tin khóa thêm; và về việc dùng cùng công cụ chẩn đoán khác như jcmd. 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Về API structured concurrency coi nhóm tác vụ con liên quan là một đơn vị việc và cấu trúc lan truyền lỗi cùng hủy; về StructuredTaskScope được sửa thành dạng mở qua phương thức nhà máy tĩnh (open); và về nó, tính đến JDK 25, là lần xem trước thứ năm chưa phải tính năng đã chốt. 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Về structured concurrency tiếp tục là lần xem trước thứ sáu ở JDK 26 nữa — tức là, tính đến tháng 8 năm 2026, vẫn là tính năng xem trước trong JDK hiện tại, cần bật tính năng xem trước để dùng. 

  12. OpenJDK, JEP 506: Scoped Values. Về Scoped Values được chốt ở JDK 25; và về chúng là cơ chế chia sẻ dữ liệu ngữ cảnh bất biến an toàn và hiệu quả trong và giữa các thread, cung cấp lời giải cho vấn đề của ThreadLocal (tính có thể đổi, quản lý vòng đời, và chi phí thừa kế). 

Các bài viết gần đây có cùng thẻ để tìm hiểu sâu hơn những chủ đề lân cận.

Các trang này đặt chủ đề trong bối cảnh rộng hơn của dịch vụ và quyết định.

Câu hỏi thường gặp

Các câu hỏi thường gặp khi tư vấn về chủ đề của bài viết.

Đã có virtual thread thì còn cần pool thread (ExecutorService) nữa không?
Tùy trường hợp dùng. Virtual thread là cơ chế để chạy số lượng rất lớn tác vụ bị chi phối bởi chờ I/O; chúng không làm mã nhanh hơn, chúng nâng thông lượng. Với công việc I/O-bound, dùng một virtual thread mỗi tác vụ (Executors.newVirtualThreadPerTaskExecutor) và đừng bao giờ gom virtual thread vào pool. Ngược lại, để song song hóa tính toán làm đầy CPU, một pool thread nền tảng giới hạn khoảng bằng số lõi (hoặc parallel stream) vẫn là công cụ đúng, như trước. Và nếu muốn giới hạn số kết nối đồng thời tới dịch vụ ngoài, khuyến nghị thời đại virtual thread là giới hạn bằng Semaphore chứ không bằng kích thước pool.
Nên dùng synchronized hay ReentrantLock?
Với loại trừ ngắn và đơn giản, synchronized là đủ và mã vẫn gọn. Chọn ReentrantLock khi cần các khả năng như chiếm có thời hạn qua tryLock, chính sách công bằng, hoặc nhiều Condition. Có lưu ý lịch sử khi kết hợp với virtual thread: JDK 21-23 có vấn đề là chặn trong khối synchronized ghim virtual thread vào thread OS của nó, nên người ta khuyên thay các chỗ chặn thường xuyên hoặc lâu bằng ReentrantLock. JDK 24 (JEP 491) viết lại cài đặt monitor và gỡ ràng buộc này. Từ JDK 24 trở đi không cần thay synchronized vì lý do ghim.
Thêm volatile có làm thứ gì đó an toàn luồng không?
Không. volatile của Java tạo quan hệ happens-before giữa ghi biến đó và đọc nó, bảo đảm tầm nhìn (rằng ghi mới nhất được các thread khác thấy) và thứ tự, nhưng không bảo đảm tính nguyên tử của thao tác ghép như «đọc, tính, ghi lại». Tăng bộ đếm volatile int bằng ++ từ nhiều thread thì các phép cộng bị mất. Dùng AtomicInteger / AtomicLong (hoặc LongAdder cho tổng hợp tần suất cao) cho bộ đếm, và khóa khi bảo vệ nhiều biến cùng lúc. volatile gần như chỉ phù hợp trong tình huống như cờ trạng thái đơn giản — nơi một thread ghi và các thread khác chỉ đọc.
Bắt InterruptedException rồi bỏ qua được không?
Không. Interruption là tín hiệu chuẩn của Java để dừng và hủy, và nuốt nó tạo ra một thread sẽ không dừng. Khi InterruptedException được ném, trạng thái interrupt đã bị xóa, nên nếu không tự hoàn thành công việc được, hãy khôi phục trạng thái bằng Thread.currentThread().interrupt() để để lại tín hiệu cho người gọi, hoặc ném lại ngoại lệ nguyên trạng. Khối catch rỗng không làm gì là nguyên nhân cổ điển của lỗi mà shutdown không có hiệu lực hoặc shutdownNow bị bỏ qua.
Không thể dừng thread bằng Thread.stop sao?
Không, không thể. Thread.stop về bản chất không an toàn (giải phóng khóa trong trạng thái không nhất quán, lộ đối tượng hỏng cho thread khác), nên đã bị loại bỏ lâu, và gọi nó trong Java hiện tại ném UnsupportedOperationException. Điều tương tự áp dụng cho Thread.suspend / resume. Cách hợp lệ duy nhất để dừng thread là dừng hợp tác qua interruption. Nếu dùng ExecutorService, shutdown chỉ ngừng nhận tác vụ mới và chờ hoàn tất — không gửi interrupt tới tác vụ đang chạy. Thứ cố dừng tác vụ đang chạy là shutdownNow, và đó là best-effort (cài đặt chuẩn làm việc qua interruption).

Hồ sơ tác giả

Trang giới thiệu tác giả bài viết.

Go Komura

Đại diện của KomuraSoft LLC

Chuyên về phát triển phần mềm Windows, tư vấn kỹ thuật và điều tra lỗi, đặc biệt trong các dự án có hệ thống hiện hữu và lỗi khó tái hiện.

Liên kết công khai

Quay lại blog