Lời mở đầu
Ngày 22 tháng 09 năm 2022, JDK19 được phát hành. Điểm nổi bật nhất của phiên bản này là hỗ trợ luồng ảo, từ đó gia đình luồng nhẹ có thêm một thành viên đắc lực. Luồng ảo giúp JVM thoát khỏi sự ràng buộc của việc lập lịch luồng thông qua hệ điều hành, để JVM tự lập lịch luồng. Thực ra, từ những ngày đầu, Sun từng triển khai cơ chế JVM lập lịch luồng trong máy ảo trên hệ điều hành Solaris, nhưng do cân nhắc về độ phức tạp và khả năng bảo trì, cuối cùng đều quay về mô hình hệ điều hành lập lịch luồng.
“长安归来锦衣客,昨日城南起新宅。” (Khách áo gấm từ Trường An trở về, hôm qua dựng nhà mới ở phía nam thành.) Nhìn lại chặng đường đã qua, các khái niệm về đa luồng khiến người ta hoa cả mắt, những bài giải thích liên quan trên mạng cũng nhiều không kể xiết, nhưng tôi vẫn luôn cảm thấy thiếu một góc nhìn tổng thể. Vì vậy, tôi đã hệ thống lại lịch sử phát triển của đa luồng trong Java, hy vọng sẽ giúp bạn nắm vững kiến thức về đa luồng.
Bài viết này không đề cập những gì:
1 Không trình bày chi tiết nguyên lý triển khai của một số điểm kỹ thuật, không phân tích mã nguồn, không vẽ hình; nếu tìm thấy khái niệm hay công nghệ mình quan tâm trong bài này, bạn có thể tự tìm kiếm 2 Không trình bày các thư viện và framework hỗ trợ xử lý đồng thời, như Quasar、Akka、Guava, v.v.
Bài viết này nói về điều gì
1 Trình bày lịch sử phát triển của đa luồng trong JDK 2 Trình bày chức năng, nguyên lý và bối cảnh của một số điểm kỹ thuật trong quá trình phát triển, cũng như những vấn đề chúng giải quyết 3 Trình bày quan điểm của tác giả về một số điểm kỹ thuật, hoan nghênh những người có quan điểm khác thảo luận trong phần bình luận
Cột mốc
Như thường lệ, trước tiên hãy xem một bảng thống kê. Bảng tổng hợp các khái niệm cốt lõi liên quan đến luồng qua các thế hệ JDK. Ở đây, xin đưa ra một phép ví von có thể chưa thật thích hợp: có thể đối chiếu sự phát triển của đa luồng với ô tô, lần lượt trải qua thời đại số sàn (JDK1.4 trở xuống), thời đại số tự động (JDK5-JDK18), thời đại tự lái (JDK19 trở đi). Phép ví von này chỉ nhằm cho độc giả biết rằng từ JDK5, ta có thể điều khiển đa luồng thoải mái hơn; từ JDK19, điều đó còn vượt xa sự thoải mái đơn thuần, mang đến bước nhảy vọt về chất cho hiệu năng của các dịch vụ có cường độ IO cao.
| Thời đại | phiên bản | Thời gian đăng | Khái niệm cốt lõi |
|---|---|---|---|
| Số tay | JDK1.0 | 1996-01-23 | Thread và Runnable |
| Số tay | JDK1.2 | 1998-12-04 | ThreadLocal、Collections |
| Số tự động | JDK1.5/5.0 | 2004-09-30 | Làm rõ mô hình bộ nhớ Java, đưa vào gói lập trình đồng thời |
| Số tự động | JDK1.6/6.0 | 2006-12-11 | Tối ưu hóa synchronized |
| Số tự động | JDK1.7/7.0 | 2011-07-28 | Framework Fork/Join |
| Số tự động | JDK1.8/8.0 | 2014-03-18 | CompletableFuture、Stream |
| Số tự động | JDK1.9/9.0 | 2014-09-08 | Cải thiện cơ chế tranh chấp khóa |
| Số tự động | JDK10 | 2018-03-21 | Luồng-Kiểm soát cục bộ |
| Số tự động | JDK15 | 2020-09-15 | Vô hiệu hóa và ngừng dùng khóa thiên lệch |
| Lái xe tự động | JDK19 | 2022-09-22 | Luồng ảo |

Thời đại số tay
Tôi gọi thời kỳ JDK1.4 trở xuống là thời đại đa luồng “số tay”, cũng gọi là thời đại đa luồng nguyên thủy. Việc thao tác với luồng còn tương đối sơ khai, chưa có nhóm luồng để sử dụng. Các nhà phát triển phải tự viết công cụ để tránh lãng phí tài nguyên do tạo luồng thường xuyên và tự khóa tài nguyên dùng chung. Cũng chính thời kỳ này đã ươm mầm cho nhiều framework đa luồng xuất sắc, trong đó framework nổi tiếng nhất đã được JDK5.0 tiếp nhận.
JDK 1.0 Thread và Runnable
Phiên bản JDK1.0 ra mắt vào tháng 1 năm 1996 đã xác lập mô hình luồng cơ bản nhất của Java ngay từ đầu. Qua những lần sửa đổi, bổ sung về sau, mô hình luồng này không có thay đổi thực chất, có thể nói đây là một thiết kế tốt có tính kế thừa. Chiếm quyền và hợp tác là hai phương thức lập lịch tiến trình/luồng phổ biến. Hệ điều hành rất thích hợp sử dụng phương thức chiếm quyền để lập lịch các tiến trình của mình:nó phân bổ lát thời gian cho các tiến trình khác nhau, có thể thu hồi tài nguyên của tiến trình không phản hồi trong thời gian dài, thậm chí buộc dừng tiến trình đó. Phương thức hợp tác đòi hỏi tiến trình tự giác, chủ động giải phóng tài nguyên. Với cách lập lịch này, một luồng chạy quá lâu có thể khiến mọi luồng khác cần CPU bị ”bỏ đói”. Máy ảo Java hotspot sử dụng cách lập lịch chiếm quyền, vì vậy ngôn ngữ Java đã áp dụng lập lịch luồng chiếm quyền ngay từ đầu. Trong JDK 1.0, cách tạo luồng chủ yếu là kế thừa lớp Thread hoặc triển khai giao diện Runnable, khởi động luồng qua phương thức start của đối tượng;mã cần xử lý song song được đặt trong phương thức run, còn việc phối hợp và giao tiếp giữa các luồng sử dụng những phương thức đơn giản và thô bạo như stop/resume/suspend.

Giải thích các khái niệm stop/resume/suspend như thế nào? Tức là luồng chính có thể trực tiếp gọi các phương thức dừng, tạm dừng và tiếp tục của luồng con. Nếu hồi nhỏ bạn từng dùng máy nghe nhạc cá nhân, trên đó có ba nút: dừng, tạm dừng, tiếp tục. Hãy tưởng tượng bạn đang nghe cùng lúc 3 máy nghe nhạc, ba máy chính là ba luồng con, còn bạn là luồng chính, bạn có thể tùy ý điều khiển việc bật, dừng của ba thiết bị này.
Cơ chế này có một vấn đề chí mạng là dễ xảy ra deadlock. Nguyên nhân là khi luồng A đã khóa một tài nguyên mà chưa giải phóng, nó bị luồng chính tạm dừng (phương thức suspend không giải phóng khóa). Lúc này nếu luồng B muốn chiếm tài nguyên đó, nó chỉ có thể đợi luồng A tiếp tục thực thi(resume)rồi giải phóng tài nguyên; nếu không, nó sẽ mãi không lấy được tài nguyên, dẫn đến deadlock.
JDK 1.2
Cơ chế stop/resume/suspend thô bạo đã bị cấm sử dụng trong phiên bản này, thay vào đó là cách phối hợp hoạt động giữa nhiều luồng bằng wait/notify/sleep. Đáng chú ý là trong phiên bản này, các đối tượng nguyên tử AtomicityXXX đã được thiết kế, chủ yếu nhằm giải quyết tính không nguyên tử của i++. Việc bổ sung ThreadLocal và Collections làm phong phú thêm cách sử dụng đa luồng; nhờ hai công nghệ này, người viết gọi đây là thời đại tăng áp của Java.
ThreadLocal
ThreadLocal là một giải pháp dùng cơ chế không khóa để nhiều luồng chia sẻ các đối tượng không an toàn luồng. Nó không thể giải quyết những vấn đề như “tăng, giảm số dư tài khoản ngân hàng hoặc hàng tồn kho”, mà phù hợp với việc dùng các bản sao của những lớp mang tính chất “công cụ” để thực thi an toàn các phương thức “công cụ”. Ví dụ điển hình là SimpleDateFormat, kết nối cơ sở dữ liệu, v.v. Điều đáng nói là thiết kế của nó rất khéo léo. Hãy tưởng tượng nếu bạn thiết kế, cách nghĩ đơn giản thường là: duy trì một Map toàn cục an toàn luồng trong ThreadLocal, với key là luồng và value là đối tượng dùng chung. Thiết kế như vậy có một nhược điểm là rò rỉ bộ nhớ, vì Map này sẽ phình to vô hạn khi ngày càng có nhiều luồng tham gia. Muốn giải quyết rò rỉ bộ nhớ thì phải dọn dẹp Map này khi luồng kết thúc, mà như vậy lại phải tăng cường khả năng của GC, rõ ràng tỷ lệ chi phí và lợi ích không phù hợp. Vì thế, ThreadLocal được thiết kế sao cho Map do chính Thread nắm giữ, thay vì ThreadLocal. key là biến ThreadLocal, value là giá trị. Mỗi Thread đặt các ThreadLoacl mà nó sử dụng vào đó (tất nhiên thiết kế này còn có những vấn đề phát sinh khác, không bàn ở đây; bạn nào quan tâm có thể tự tìm kiếm).
Collections
Lớp tiện ích Collections được thiết kế trong phiên bản này, bao bọc một số tập hợp an toàn luồng như SynchronizedList. Vào thời chỉ có những tập hợp an toàn luồng như Hashtable, Vector, Stack, sự xuất hiện của nó cũng mang ý nghĩa thời đại. Ý tưởng cơ bản của tiện ích Collections là giúp bạn bọc các tập hợp không an toàn luồng thành tập hợp an toàn luồng, nhờ vậy bạn không cần tốn nhiều thời gian nâng cấp, chỉnh sửa mã hiện có, chỉ cần dùng phương thức được cung cấp để khởi tạo tập hợp lúc tạo nó. Điều này khá giống công nghệ tăng áp của ô tô, tăng công suất và mô-men xoắn của động cơ trong khi dung tích động cơ không đổi. Thời đại tăng áp của Java đã đến ^\_^
Thời đại số tự động
JDK 5.0
Thêm gói xử lý đồng thời
Doug Lea, tên tiếng Trung là 道格·利(Đạo Cách · Lợi). Ông là giảng viên đại học tại Mỹ, một nhân vật tầm cỡ cao thủ, J.U.C chính là do ông tạo ra. Trước JDK1.5, chúng ta chỉ có thể dùng synchronized để kiểm soát việc truy cập đồng thời vào mã đồng bộ, khi đó hiệu năng của synchronized chưa được tối ưu tốt nên còn kém, việc điều khiển luồng cũng chỉ có thể dùng các phương thức wait và notify của Object. Lúc này Doug Lea đã gửi đề xuất JSR-166 lên JCP, trước khi gửi JSR-166, Doug Lea đã sử dụng mã có chức năng tương tự gói J.U.C trong hơn ba năm, những đoạn mã đó chính là nguyên mẫu của J.U.C.
J.U.C cung cấp một số nhóm công cụ lớn như đối tượng nguyên tử, khóa và bộ tiện ích, nhóm luồng, bộ chứa an toàn luồng. Nhà phát triển có thể linh hoạt sử dụng bất kỳ tính năng nào để xây dựng sản phẩm của mình, từ dùng ReentrantLock xây dựng framework nền tảng đến trực tiếp dùng công cụ hoặc bộ chứa có sẵn để viết mã nghiệp vụ. Nhìn từ góc độ lịch sử, vào năm 2004, J.U.C hoàn toàn xứng đáng được gọi là “sản phẩm công nghệ tiên tiến”. Nó đã đóng góp rất lớn vào việc phổ biến Java. Thời đại hộp số tự động của Java đã đến; cũng như ô tô số tự động giúp việc lái xe dễ tiếp cận hơn, J.U.C giúp lập trình viên sử dụng đa luồng dễ dàng hơn rất nhiều. Đây là một sản phẩm mở ra cả một thời đại.
Tất nhiên, J.U.C cũng có một số khiếm khuyết:
Tiêu tốn nhiều tài nguyên CPU:Nếu CAS quay vòng không thành công trong thời gian dài, CPU sẽ phải chịu chi phí xử lý rất lớn.
Giải pháp: Trong JUC, một số chỗ giới hạn số lần quay vòng CAS, chẳng hạn SynchronousQueue thuộc BlockingQueue.
Vấn đề ABA:Nếu một giá trị ban đầu là A, chuyển thành B rồi lại trở về A, khi CAS kiểm tra sẽ thấy không có thay đổi, nhưng thực tế nó đã thay đổi; đó chính là vấn đề ABA. Trong phần lớn trường hợp, vấn đề ABA không ảnh hưởng đến tính đúng đắn của chương trình khi chạy đồng thời.
Giải pháp:Thêm số phiên bản cho mỗi biến, mỗi lần thay đổi thì tăng 1, tức A —> B —> A trở thành 1A —> 2B —> 3A. Java cung cấp AtomicStampedReference để giải quyết. AtomicStampedReference bọc bộ [E,Integer] để gắn dấu phiên bản(stamp)cho đối tượng, qua đó tránh vấn đề ABA.
Chỉ có thể bảo đảm thao tác nguyên tử trên một biến dùng chung:Cơ chế CAS chỉ bảo đảm thao tác nguyên tử trên một biến, chứ không bảo đảm tính nguyên tử của toàn bộ khối mã.
Giải pháp:Ví dụ, nếu cần bảo đảm 3 biến được cập nhật cùng nhau một cách nguyên tử, thì buộc phải dùng Synchronized. Cũng có thể cân nhắc dùng AtomicReference để bao bọc nhiều biến, qua đó xử lý trường hợp có nhiều biến dùng chung.
Nắm rõ mô hình bộ nhớ Java
Phiên bản JDK này đã xác định lại rõ ràng mô hình bộ nhớ Java, trước đó, các mô hình bộ nhớ phổ biến bao gồm mô hình bộ nhớ nhất quán tuần tự và mô hình happens-before. Với mô hình nhất quán tuần tự, thứ tự thực thi chương trình hoàn toàn trùng khớp với thứ tự thể hiện trong mã. Điều này rất khó bảo đảm trên các CPU hiện đại có nhiều lõi và tối ưu hóa việc thực thi lệnh. Hơn nữa, việc bảo đảm tính nhất quán tuần tự hạn chế nghiêm trọng khả năng tối ưu hóa mã của JVM trong thời gian chạy.
Tuy nhiên, quan hệ xảy-ra-trước(Happens-before)do đặc tả JSR 133 của phiên bản này quy định khiến thứ tự thực thi lệnh trở nên linh hoạt:
Trong cùng một luồng, theo thứ tự thực thi mã (tức thứ tự ngữ nghĩa của mã), thao tác trước xảy ra trước thao tác sau Thao tác mở khóa một đối tượng monitor xảy ra trước thao tác khóa tiếp theo trên cùng đối tượng monitor đó Thao tác ghi vào trường volatile xảy ra trước thao tác đọc trường đó về sau Thao tác start đối với luồng (gọi phương thức start() của đối tượng luồng) xảy ra trước mọi thao tác khác của luồng này Mọi thao tác trong một luồng xảy ra trước việc bất kỳ luồng nào khác gọi phương thức join() trên luồng đó Nếu thao tác A xảy ra trước B, thao tác B xảy ra trước C, thì thao tác A xảy ra trước C
Về phân bổ bộ nhớ, việc tách riêng bộ nhớ làm việc của từng luồng khỏi bộ nhớ chính càng tạo cho JVM nhiều dư địa để tối ưu hóa việc thực thi lệnh trong luồng. Các biến trong bộ nhớ chính có thể được sao chép vào bộ nhớ làm việc của luồng để xử lý riêng; sau khi thực thi xong, kết quả có thể được ghi trở lại bộ nhớ chính vào một thời điểm nào đó: Nhưng làm thế nào để bảo đảm tính nhất quán dữ liệu giữa các luồng? Giải pháp mà JLS (Java Language Specification) đưa ra là mặc định không bảo đảm dữ liệu nhất quán tại mọi thời điểm, nhưng có thể đạt được tính nhất quán dữ liệu bằng cách sử dụng các từ khóa có ngữ nghĩa được tăng cường là synchronized, volatile và final.
Tối ưu hóa synchronized trong JDK 6.0
Từ khóa synchronized, được ví như “người con trưởng của nước Cộng hòa”, đã bị ReentrantLock lấn át ở phiên bản 5.0. Phiên bản này nhất định phải gỡ lại một ván, vì vậy JDK 6.0 đã thực hiện một số tối ưu cho khóa, như quay chờ khóa, loại bỏ khóa, gộp khóa, khóa nhẹ, khóa thiên lệch, v.v. Lần tối ưu này là một cách thể hiện quan niệm “quản lý tinh vi”. Trước khi tối ưu, đối tượng được synchronized khóa chỉ có hai trạng thái:không khóa, có khóa(khóa nặng). Sau khi tối ưu, khóa có tổng cộng 4 trạng thái, theo cấp độ từ thấp đến cao lần lượt là:không khóa, khóa thiên lệch, khóa nhẹ, khóa nặng. Các trạng thái này dần nâng cấp theo tình hình tranh chấp, nhưng không thể hạ cấp, mục đích là nâng cao hiệu suất lấy và giải phóng khóa(người viết cho rằng thực ra là quá phức tạp, khiến các nhà phát triển JVM chùn bước).
Lần tối ưu này giúp synchronized nở mày nở mặt, từ đó không còn cho phép ai nói hiệu năng của nó kém ReentrantLock nữa. Nhưng chuyện hay còn ở phía sau, khóa thiên vị đã bị loại bỏ trong JDK 15(─.─||). Theo tôi, synchronized chịu thiệt vì nó chỉ là một từ khóa, JVM phụ trách các thao tác bên dưới, còn cách khóa nào phù hợp khi ứng dụng đặt khóa thì JVM phải “đoán”. ReentrantLock thì khác, nó giao thẳng việc này cho lập trình viên xử lý, bạn muốn công bằng thì dùng khóa công bằng, muốn không công bằng thì dùng khóa không công bằng. Xét ở cấp độ thiết kế, đây có thể coi là một cách lười biếng, nhưng đồng thời cũng là sự linh hoạt.
Khung Fork/Join của JDK 7.0
Fork/Join cũng ra đời như một sản phẩm tương đối tiên tiến, với ưu thế cốt lõi là hỗ trợ phân rã tác vụ theo cách đệ quy, đồng thời hợp nhất kết quả của các tác vụ. Tuy nhiên, đây là một công nghệ vừa quen vừa lạ: quen vì nó được ứng dụng ở nhiều nơi, chẳng hạn CompletableFuture và Stream sẽ được đề cập tiếp theo trong JDK8.0; lạ vì dường như chúng ta ít sử dụng nó trong quá trình phát triển nghiệp vụ.
Thậm chí có người còn cảm thấy nó chẳng mấy hữu dụng. Quan điểm của người viết là, nếu bạn làm phát triển phục vụ yêu cầu nghiệp vụ, thì nó chẳng mấy hữu dụng, vì hầu như không dùng đến, các tình huống có khối lượng dữ liệu lớn đã có bộ công cụ kho dữ liệu, còn các tình huống khác có thể thay bằng nhóm luồng; nếu bạn làm phát triển liên quan đến việc viết khung phần mềm trung gian, thì nó có ích, có lẽ sẽ có lúc dùng đến. Trên Internet tiếng Trung rất ít người đặt nghi vấn về công nghệ này, nhưng ở nước ngoài đã có người thảo luận, ai quan tâm có thể truy cập trực tiếp để đọc Framework Fork-Join trong Java có bị lỗi không?
JDK 8.0
Việc phát hành phiên bản này có ý nghĩa mở ra một thời đại mới đối với Java, đến mức hiện nay hơn một nửa các chương trình Java đang chạy trên toàn thế giới sử dụng phiên bản này. Tuy nhiên, các cập nhật liên quan đến đa luồng không mang tính đột phá như JDK5.0. Ngoài việc bổ sung một số đối tượng nguyên tử, hai cập nhật nổi bật nhất của phiên bản này là những mục dưới đây.
CompletableFuture
Trên mạng có rất nhiều bài giới thiệu về CompletableFuture, phần lớn nói về nguyên lý và cách sử dụng. Nhưng tôi vẫn luôn không hiểu một điều: khi đã có nhiều công cụ nhóm luồng như vậy, vì sao CompletableFuture vẫn xuất hiện, nó giải quyết điểm vướng mắc nào? Thế mạnh cốt lõi của nó rốt cuộc là gì? Tôi tin rằng nếu từng suy nghĩ về điều này, bạn cũng sẽ đặt ra câu hỏi ấy; không sao, tôi đã tìm được câu trả lời giúp bạn.
Kết luận:Thế mạnh cốt lõi của CompletableFuture là Điều phối tác vụ。CompletableFuture kế thừa các đặc tính của giao diện Future, có thể thực thi tác vụ đồng thời cùng những khả năng khác, nhưng các khả năng ấy đều có phương án thay thế. Riêng khả năng điều phối tác vụ của nó thì không thể thay thế;các API cốt lõi như xây dựng chuỗi tác vụ, hợp nhất kết quả tác vụ đều được thiết kế để điều phối tác vụ. Vì vậy, việc JDK đưa khung này vào phiên bản đó chủ yếu nhằm giải quyết nhu cầu điều phối tác vụ ngày càng nhức nhối trong phát triển nghiệp vụ.
Cuối cùng nói thêm một câu, bên dưới CompletableFuture sử dụng khung Fork/Join để triển khai.
Stream
《Clean Architecture》 từng đề cập đến ba mô hình lập trình: lập trình có cấu trúc(lập trình hướng thủ tục), lập trình hướng đối tượng, lập trình hàm. Stream là một biểu hiện của lập trình hàm trong ngôn ngữ Java. Theo tôi, muốn tiến từ lập trình viên sơ cấp lên trung cấp thì nhất định phải làm chủ Stream. Khi mới tiếp xúc với Stream, chắc chắn bạn sẽ rất bỡ ngỡ, nhưng sau khi quen thuộc, bạn sẽ mở ra một hướng tư duy mới về cách lập trình. Các lập trình viên thường nhầm lẫn ba khái niệm: lập trình hàm, Stream và biểu thức Lambda, luôn cho rằng cả ba đều nói về cùng một thứ. Sau đây là cách hiểu của tôi:
•Lập trình hàm là một tư tưởng lập trình, được áp dụng trong nhiều ngôn ngữ lập trình
•Stream là một tính năng mới của JDK8.0, cũng có thể hiểu là một khái niệm mới được tạo ra nhằm đáp ứng tư tưởng lập trình hàm; thông qua Stream, có thể thực hiện lập trình hàm trên các lớp tập hợp
•Biểu thức Lambda(lambda expression)là một hàm ẩn danh, giúp biểu đạt lập trình hàm ngắn gọn và hiệu quả hơn
Vậy nói nhiều như thế, Stream có quan hệ gì với đa luồng?Các phương thức song song liên quan trong Stream được triển khai bên dưới bằng framework Fork/Join. Trong 《Effective Java》 có một lời khuyên liên quan là “thận trọng khi sử dụng tính năng song song của Stream”, bởi mọi tác vụ song song đều chạy trong một pool Fork/Join dùng chung; một pipeline chạy bất thường có thể làm giảm hiệu năng của những phần khác không liên quan.
JDK 9.0
Cải thiện cơ chế tranh chấp khóa
Tranh chấp khóa hạn chế hiệu năng của nhiều ứng dụng Java đa luồng. Cơ chế tranh chấp khóa mới cải thiện hiệu năng bộ giám sát đối tượng Java và đã được kiểm chứng bằng nhiều phép đo chuẩn(như Volano), những phép đo này có thể ước lượng thông lượng tối đa của JVM. Trên thực tế, cơ chế tranh chấp khóa mới đã đạt kết quả xuất sắc trong 22 phép đo chuẩn khác nhau. Nếu cơ chế mới được áp dụng trong Java 9, hiệu năng ứng dụng sẽ tăng đáng kể. Giải thích đơn giản là khi nhiều luồng tranh chấp khóa, trước khi tối ưu: các luồng đến sau đều chờ khóa theo cùng một quy trình tiêu chuẩn. Sau khi tối ưu: khi JVM nhận ra một số tình huống có thể tối ưu, nó cho phép luồng đến sau trực tiếp tranh giành khóa theo kiểu “làn VIP”.
Để xem giải thích chi tiết, vui lòng tham khảo: Giải thích về khóa có tranh chấp – cách tiếp cận từ góc độ hiệu năng
Luồng phản ứng
Luồng phản ứng (Reactive Streams) là một tiêu chuẩn xử lý luồng dữ liệu bất đồng bộ bằng cơ chế áp lực ngược không chặn, cung cấp một tập hợp tối thiểu các giao diện, phương thức và giao thức để mô tả những thao tác và thực thể cần thiết.
Áp lực ngược không chặn là gì? 背压 là cách gọi tắt của back pressure, nói đơn giản là bên sản xuất đẩy dữ liệu cho bên tiêu thụ; khi bên tiêu thụ không xử lý nổi, nó thông báo cho bên sản xuất, lúc này bên sản xuất giảm tốc độ sản xuất. Cách đơn giản nhất để triển khai cơ chế này là chặn, tức trả trực tiếp dữ liệu về áp lực ngay khi đẩy dữ liệu. Triển khai theo cách không chặn làm tăng độ phức tạp trong thiết kế, đồng thời nâng cao hiệu năng. PS: Tôi thấy từ 背压 được dịch chưa hay, không thể suy nghĩa chỉ từ mặt chữ. Dùng 反压 (áp lực phản hồi) có tốt hơn không ^\_^
Để giải quyết vấn đề bên tiêu thụ có thể sụp đổ do chịu áp lực tài nguyên quá lớn (pressure), cần kiểm soát tốc độ của luồng dữ liệu, tức kiểm soát lưu lượng (flow control), nhằm ngăn luồng dữ liệu nhanh làm quá tải đích nhận. Vì vậy cần có áp lực ngược, tức back pressure, và bên sản xuất cùng bên tiêu thụ cần tương tác thông qua một cơ chế áp lực ngược. Cơ chế này phải được triển khai theo cách bất đồng bộ, không chặn; nếu đồng bộ và chặn, bên sản xuất phải chờ trong lúc bên tiêu thụ xử lý dữ liệu, gây ra vấn đề về hiệu năng.
Luồng phản ứng(Reactive Streams)đưa ra tiêu chuẩn để thực hiện cơ chế áp lực ngược không chặn bằng cách định nghĩa một tập hợp các thực thể, giao diện và phương thức tương tác. Các bên thứ ba tuân theo tiêu chuẩn này để triển khai giải pháp cụ thể, phổ biến có Reactor, RxJava, Akka Streams, Ratpack, v.v.
JDK 10 Quản lý cục bộ theo luồng
Safepoint và những hạn chế của nó:
Safepoint là một cơ chế trong Hotspot JVM khiến tất cả ứng dụng dừng lại. Để thực hiện một số công việc ở tầng thấp, JVM phải thực hiện Stop The World, khiến tất cả luồng ứng dụng dừng lại. Nhưng không thể thô bạo dừng trực tiếp, mà sẽ gửi tín hiệu chỉ thị cho luồng ứng dụng để báo rằng đã đến lúc dừng. Khi luồng ứng dụng chạy đến một điểm Safepoint, nó sẽ tuân theo chỉ thị và phản hồi. Đó cũng là lý do cơ chế này được gọi là Safepoint. Việc thêm chữ safe nhằm nhấn mạnh rằng JVM sắp thực hiện một số công việc an toàn trên phạm vi toàn cục, nên điểm này được thêm chữ safe.
Các hoạt động an toàn toàn cục bao gồm: 1、Tạm dừng để thu gom rác 2、Hủy tối ưu hóa mã (Code deoptimization). 3、Xả bộ nhớ đệm mã. 4、Khi định nghĩa lại tệp lớp (Class redefinition, chẳng hạn cập nhật nóng hoặc instrumentation). 5、Thu hồi khóa thiên lệch (Biased lock revocation). 6、Các thao tác gỡ lỗi khác nhau (chẳng hạn: kiểm tra deadlock hoặc xuất stacktrace, v.v.).
Tuy nhiên, bản thân việc đưa tất cả các luồng đến safepoint gần nhất để dừng lại đã cần khá nhiều thời gian. Hơn nữa, bắt tất cả các luồng dừng lại có phải là quá hấp tấp và độc đoán hay không. Vì vậy, Java10 đã đưa vào một cách không cần dừng tất cả các luồng, đó là cơ chế bắt tay cục bộ theo luồng (Thread Local Handshake).
Ví dụ, sau đây là những trường hợp có thể xử lý mà không cần dừng tất cả các luồng: 1、Thu hồi khóa thiên vị. Việc này chỉ cần dừng một luồng là có thể thu hồi khóa thiên vị, không cần dừng tất cả các luồng. 2、Giảm tổng tác động đến độ trễ của VM từ các loại truy vấn phục vụ giám sát và chẩn đoán khác nhau, ví dụ việc lấy dấu vết ngăn xếp của tất cả các luồng trên một VM có nhiều luồng Java có thể là thao tác chậm. 3、Lấy mẫu dấu vết ngăn xếp an toàn hơn bằng cách giảm sự phụ thuộc vào tín hiệu(signals). 4、Sử dụng kỹ thuật đồng bộ Dekker bất đối xứng để loại bỏ một số hàng rào bộ nhớ thông qua cơ chế bắt tay với các luồng Java. Ví dụ, “mã đánh dấu thẻ có điều kiện”(conditional card mark code)được sử dụng trong G1 và CMS sẽ không còn cần đến thứ gọi là “hàng rào bộ nhớ”. Như vậy, “hàng rào ghi(write barrier)” do G1 phát ra có thể được tối ưu hóa, và những nhánh cố gắng tránh “hàng rào bộ nhớ” cũng có thể được xóa bỏ.
JDK 15 vô hiệu hóa và đánh dấu ngừng sử dụng khóa thiên vị
Tại sao lại loại bỏ khóa thiên lệch?Mức cải thiện hiệu năng mà khóa thiên lệch từng mang lại trước đây nay đã không còn rõ rệt như vậy. Những ứng dụng được hưởng lợi từ khóa thiên lệch thường sử dụng API tập hợp Java đời đầu(JDK 1.1), các API này(Hashtable và Vector)đều đồng bộ hóa mỗi lần truy cập. JDK 1.2 đưa vào các tập hợp không đồng bộ hóa cho tình huống đơn luồng(HashMap và ArrayList), còn JDK 1.5 cung cấp các cấu trúc dữ liệu đồng thời có hiệu năng cao hơn cho tình huống đa luồng. Điều này có nghĩa là nếu cập nhật mã để sử dụng các lớp mới hơn, những ứng dụng từng hưởng lợi từ khóa thiên lệch do đồng bộ hóa không cần thiết có thể đạt mức cải thiện hiệu năng lớn. Ngoài ra, những ứng dụng được xây dựng xoay quanh hàng đợi của nhóm luồng và các luồng làm việc thường hoạt động tốt hơn khi tắt khóa thiên lệch.
Dưới đây là cách triển khai bằng API sử dụng Hashtable và Vector: java.lang.Classloader sử dụng Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL sử dụng Hashtable java.net.URConnection sử dụng Hashtable java.util.ZipOutputStream sử dụng Vector javax.management.timer.TimerMBean có Vector trong giao diện
Thời đại lái xe tự động
Luồng ảo đưa Java bước vào thời đại tự lái. Nhiều ngôn ngữ có công nghệ tương tự “luồng ảo”, như Go, C#, Erlang, Lua; họ gọi đó là “coroutine”. Lần này Java không thêm bất kỳ từ khóa nào, thậm chí không đưa vào khái niệm mới nào. So với goroutine và coroutine, luồng ảo dễ hiểu hơn nhiều, nhìn tên là gần như biết nó làm gì.
Luồng ảo JDK 19
Mô hình luồng trong Java truyền thống tương ứng 1:1 với luồng của hệ điều hành, chi phí tạo và chuyển đổi luồng rất lớn, bị giới hạn bởi hệ điều hành nên chỉ có thể tạo số lượng hữu hạn. Khi mức đồng thời rất lớn, không thể tạo một luồng cho mỗi yêu cầu. Sử dụng nhóm luồng có thể giảm nhẹ vấn đề, nhóm luồng giảm chi phí tạo luồng, nhưng cũng không thể tăng số lượng luồng. Giả sử mức đồng thời là 2000, nhóm luồng chỉ có 1000 luồng, thì cùng một thời điểm chỉ có thể xử lý 1000 yêu cầu, còn 1000 yêu cầu chưa thể xử lý, có thể từ chối chúng, hoặc cho chúng chờ đến khi có luồng được giải phóng.
Trước luồng ảo, giải pháp được dùng là phong cách bất đồng bộ. Đã có nhiều framework triển khai lập trình đồng thời theo phong cách bất đồng bộ (như Reactor của Spring5), đạt tính sẵn sàng cao hơn thông qua việc dùng chung luồng. Nguyên lý là dùng chung luồng để giảm chuyển đổi luồng, giảm tài nguyên tiêu hao, đồng thời tránh chặn; chỉ sử dụng luồng khi chương trình thực thi, còn khi chương trình cần chờ thì không chiếm luồng. Phong cách bất đồng bộ quả thực mang lại nhiều cải thiện, nhưng cũng có nhược điểm. Phần lớn framework bất đồng bộ đều dùng cách viết nối chuỗi, chia chương trình thành nhiều bước, mỗi bước có thể được thực thi trên một luồng khác nhau. Bạn không thể tiếp tục sử dụng các API lập trình đồng thời quen thuộc như ThreadLocal, nếu không có thể xảy ra lỗi. Phong cách lập trình cũng thay đổi rất nhiều, phức tạp hơn nhiều so với kiểu lập trình truyền thống, tốn nhiều công sức học hỏi và có thể còn phải cải tạo nhiều mô-đun hiện có trong dự án để thích ứng với mô hình bất đồng bộ.
Nguyên lý triển khai luồng ảo khá giống một số framework bất đồng bộ, cũng chia sẻ luồng, nên đương nhiên không cần tạo pool. Khi sử dụng, bạn có thể coi luồng ảo là nguồn tài nguyên dồi dào vô hạn, muốn tạo bao nhiêu thì tạo bấy nhiêu, không cần lo xảy ra vấn đề. Không chỉ vậy, luồng ảo còn hỗ trợ gỡ lỗi và được các công cụ giám sát liên quan đến Java hỗ trợ, điều này rất quan trọng. Luồng ảo sẽ giảm đáng kể lượng bộ nhớ mà chương trình của bạn sử dụng; mọi ứng dụng nặng về IO, chẳng hạn máy chủ web, đều có thể tăng mạnh thông lượng IO trên cùng điều kiện phần cứng. Trước đây, với 1G bộ nhớ, có thể phục vụ đồng thời 1000 lượt truy cập; sau khi dùng luồng ảo, theo tuyên bố chính thức, có thể dễ dàng xử lý 100 triệu lượt đồng thời. Việc có đáp ứng được trong từng tình huống nghiệp vụ cụ thể hay không vẫn cần kiểm thử tải, nhưng nếu tính dè dặt hơn thì 10 trăm nghìn có lẽ vẫn dễ dàng đạt được, mà bạn không cần trả thêm bất kỳ chi phí nào cho việc này, thậm chí có thể không cần sửa mã. Bởi vì luồng ảo cho phép bạn giữ nguyên phong cách lập trình truyền thống, tức mô hình mỗi yêu cầu một luồng, sử dụng luồng ảo giống như sử dụng luồng thông thường, chương trình chỉ cần thay đổi rất ít. Luồng ảo cũng không đưa vào cú pháp mới, có thể nói chi phí học và chuyển đổi cực thấp.
Điều đáng nhắc đến là tầng bên dưới của luồng ảo vẫn sử dụng khung Fork/Join.