Tôi đã viết mã bằng Java kể từ phiên bản beta đầu tiên; thậm chí hồi đó, các luồng nằm ở đầu danh sách các tính năng yêu thích của tôi. Java là ngôn ngữ đầu tiên giới thiệu hỗ trợ luồng trong chính ngôn ngữ đó; đó là một quyết định gây tranh cãi hồi đó.
Trong thập kỷ qua, mọi ngôn ngữ đều chạy đua để bao gồm async/await và thậm chí Java cũng có một số hỗ trợ của bên thứ ba cho điều đó … Nhưng Java đã chạy theo hình chữ chi thay vì chạy theo hình chữ chi và giới thiệu các luồng ảo vượt trội hơn nhiều (dự án Loom) . Bài đăng này không phải là về điều đó.
Tôi nghĩ điều đó thật tuyệt vời và chứng tỏ sức mạnh cốt lõi của Java. Không chỉ là một ngôn ngữ mà còn là một nền văn hóa. Một nền văn hóa cân nhắc thay đổi thay vì chạy theo xu hướng thời thượng.
Trong bài đăng này, tôi muốn xem lại các cách cũ để thực hiện phân luồng trong Java. Tôi đã quen với synchronized
, wait
, notify
, v.v. Nhưng đã lâu rồi kể từ khi chúng là cách tiếp cận ưu việt để phân luồng trong Java.
tôi là một phần của vấn đề; Tôi vẫn quen với những cách tiếp cận này và cảm thấy khó làm quen với một số API đã có từ Java 5. Đó là một thói quen. t
Có nhiều API tuyệt vời để làm việc với các chuỗi mà tôi thảo luận trong các video ở đây, nhưng tôi muốn nói về các khóa cơ bản nhưng quan trọng.
Một sự miễn cưỡng mà tôi gặp phải khi rời khỏi đồng bộ hóa là các lựa chọn thay thế không tốt hơn nhiều. Động lực chính để rời bỏ nó ngày hôm nay là tại thời điểm này, đồng bộ hóa có thể kích hoạt tính năng ghim chỉ trong Loom, điều này không lý tưởng.
JDK 21 có thể sửa lỗi này (khi Loom chuyển sang GA), nhưng vẫn nên bỏ nó đi.
Thay thế trực tiếp cho đồng bộ hóa là ReentrantLock. Thật không may, ReentrantLock có rất ít lợi thế so với đồng bộ hóa, vì vậy lợi ích của việc di chuyển là không rõ ràng nhất.
Trên thực tế, nó có một nhược điểm lớn; để hiểu được điều đó, hãy xem xét một ví dụ. Đây là cách chúng tôi sẽ sử dụng được đồng bộ hóa:
synchronized(LOCK) { // safe code } LOCK.lock(); try { // safe code } finally { LOCK.unlock(); }
Nhược điểm đầu tiên của ReentrantLock
là tính dài dòng. Chúng tôi cần khối thử vì nếu một ngoại lệ xảy ra trong khối, khóa sẽ vẫn còn. Tay cầm được đồng bộ hóa liền mạch cho chúng tôi.
Có một mẹo mà một số người sử dụng để bọc khóa bằng AutoClosable
trông gần giống như sau:
public class ClosableLock implements AutoCloseable { private final ReentrantLock lock; public ClosableLock() { this.lock = new ReentrantLock(); } public ClosableLock(boolean fair) { this.lock = new ReentrantLock(fair); } @Override public void close() throws Exception { lock.unlock(); } public ClosableLock lock() { lock.lock(); return this; } public ClosableLock lockInterruptibly() throws InterruptedException { lock.lock(); return this; } public void unlock() { lock.unlock(); } }
Lưu ý rằng tôi không triển khai giao diện Khóa lý tưởng. Đó là bởi vì phương thức lock trả về triển khai có thể đóng tự động thay vì void
.
Khi chúng tôi làm điều đó, chúng tôi có thể viết mã ngắn gọn hơn như sau:
try(LOCK.lock()) { // safe code }
Tôi thích giảm mức độ chi tiết, nhưng đây là một khái niệm có vấn đề vì dùng thử tài nguyên được thiết kế cho mục đích dọn dẹp và chúng tôi sử dụng lại các khóa. Nó đang gọi close, nhưng chúng ta sẽ gọi lại phương thức đó trên cùng một đối tượng.
Tôi nghĩ có thể tốt hơn nếu mở rộng cú pháp thử với tài nguyên để hỗ trợ giao diện khóa. Nhưng cho đến khi điều đó xảy ra, đây có thể không phải là một thủ thuật đáng giá.
Lý do lớn nhất để sử dụng ReentrantLock
là hỗ trợ Loom. Các ưu điểm khác thì tốt, nhưng không có ưu điểm nào là “tính năng sát thủ”.
Chúng ta có thể sử dụng nó giữa các phương thức thay vì trong một khối liên tục. Đây có lẽ là một ý tưởng tồi vì bạn muốn giảm thiểu khu vực khóa và lỗi có thể là một vấn đề. Tôi không coi tính năng đó là một lợi thế.
Nó có tùy chọn của sự công bằng. Điều này có nghĩa là nó sẽ phục vụ luồng đầu tiên dừng ở khóa trước. Tôi đã cố gắng nghĩ về một trường hợp sử dụng thực tế không phức tạp trong đó điều này sẽ quan trọng và tôi đang vẽ khoảng trống.
Nếu bạn đang viết một bộ lập lịch phức tạp với nhiều luồng liên tục được xếp hàng đợi trên một tài nguyên, thì bạn có thể tạo ra tình huống trong đó một luồng bị “bỏ đói” do các luồng khác tiếp tục đến. Nhưng những tình huống như vậy có thể được phục vụ tốt hơn bởi các tùy chọn khác trong gói đồng thời .
Có lẽ tôi đang thiếu một cái gì đó ở đây ...
lockInterruptibly()
cho phép chúng ta ngắt một luồng trong khi nó đang chờ khóa. Đây là một tính năng thú vị, nhưng một lần nữa, khó có thể tìm thấy một tình huống nào mà nó thực sự tạo ra sự khác biệt.
Nếu bạn viết mã phải rất nhạy để ngắt, bạn sẽ cần sử dụng API lockInterruptibly()
để đạt được khả năng đó. Nhưng trung bình bạn sử dụng phương thức lock()
trong bao lâu?
Có những trường hợp cạnh mà điều này có thể quan trọng nhưng không phải là điều mà hầu hết chúng ta sẽ gặp phải, ngay cả khi thực hiện mã đa luồng nâng cao.
Một cách tiếp cận tốt hơn nhiều là ReadWriteReentrantLock
. Hầu hết các tài nguyên tuân theo nguyên tắc đọc thường xuyên và ít thao tác ghi. Vì việc đọc một biến là an toàn cho luồng, nên không cần khóa trừ khi chúng ta đang trong quá trình ghi vào biến.
Điều này có nghĩa là chúng tôi có thể tối ưu hóa việc đọc đến mức cao nhất trong khi làm cho các thao tác ghi chậm hơn một chút.
Giả sử đây là trường hợp sử dụng của bạn, bạn có thể tạo mã nhanh hơn nhiều. Khi làm việc với khóa đọc-ghi, chúng ta có hai khóa; một khóa đọc như chúng ta có thể thấy trong hình ảnh sau đây. Nó cho phép nhiều chủ đề thông qua và thực sự là "miễn phí cho tất cả".
Khi chúng ta cần ghi vào biến, chúng ta cần có khóa ghi như chúng ta có thể thấy trong hình ảnh sau. Chúng tôi cố gắng yêu cầu khóa ghi, nhưng vẫn có các chuỗi vẫn đọc từ biến nên chúng tôi phải đợi.
Khi các chuỗi được đọc xong, tất cả quá trình đọc sẽ bị chặn và thao tác ghi chỉ có thể xảy ra từ một chuỗi duy nhất như được thấy trong hình ảnh sau. Khi chúng tôi giải phóng khóa ghi, chúng tôi sẽ quay lại tình huống “miễn phí cho tất cả” trong hình ảnh đầu tiên.
Đây là một mẫu mạnh mẽ mà chúng ta có thể tận dụng để tạo các bộ sưu tập nhanh hơn nhiều. Một danh sách được đồng bộ hóa điển hình rất chậm. Nó đồng bộ hóa trên tất cả các hoạt động, đọc hoặc ghi. Chúng tôi có một CopyOnWriteArrayList đọc nhanh, nhưng mọi thao tác ghi đều rất chậm.
Giả sử bạn có thể tránh trả về các trình vòng lặp từ các phương thức của mình, bạn có thể đóng gói các thao tác danh sách và sử dụng API này.
Ví dụ: trong đoạn mã sau, chúng tôi hiển thị danh sách tên ở dạng chỉ đọc, nhưng sau đó khi cần thêm tên, chúng tôi sử dụng khóa ghi. Điều này có thể làm tốt hơn các danh sách synchronized
một cách dễ dàng:
private final ReadWriteLock LOCK = new ReentrantReadWriteLock(); private Collection<String> listOfNames = new ArrayList<>(); public void addName(String name) { LOCK.writeLock().lock(); try { listOfNames.add(name); } finally { LOCK.writeLock().unlock(); } } public boolean isInList(String name) { LOCK.readLock().lock(); try { return listOfNames.contains(name); } finally { LOCK.readLock().unlock(); } }
Điều đầu tiên chúng ta cần hiểu về StampedLock
là nó không được đăng nhập lại. Giả sử chúng ta có khối này:
synchronized void methodA() { // … methodB(); // … } synchronized void methodB() { // … }
Điều này sẽ làm việc. Kể từ khi đồng bộ hóa là reentrant. Chúng tôi đã giữ khóa, vì vậy việc truy cập methodB()
từ methodA()
sẽ không bị chặn. Điều này cũng hoạt động với ReentrantLock giả sử chúng ta sử dụng cùng một khóa hoặc cùng một đối tượng được đồng bộ hóa.
StampedLock
trả về một tem mà chúng tôi sử dụng để giải phóng khóa. Do đó, nó có một số giới hạn. Nhưng nó vẫn rất nhanh và mạnh mẽ. Nó cũng bao gồm tem đọc và ghi mà chúng ta có thể sử dụng để bảo vệ tài nguyên được chia sẻ.
Nhưng không giống như ReadWriteReentrantLock,
nó cho phép chúng tôi nâng cấp khóa. Tại sao chúng ta cần phải làm điều đó?
Hãy nhìn vào phương thức addName()
trước đó… Điều gì sẽ xảy ra nếu tôi gọi nó hai lần với “Shai”?
Vâng, tôi có thể sử dụng Set... Nhưng đối với điểm của bài tập này, giả sử rằng chúng ta cần một danh sách... Tôi có thể viết logic đó với ReadWriteReentrantLock
:
public void addName(String name) { LOCK.writeLock().lock(); try { if(!listOfNames.contains(name)) { listOfNames.add(name); } } finally { LOCK.writeLock().unlock(); } }
Điều này thật tệ. Tôi đã “trả tiền” cho một khóa ghi chỉ để kiểm tra contains()
trong một số trường hợp (giả sử có nhiều bản sao). Chúng ta có thể gọi isInList(name)
trước khi lấy khóa ghi. Sau đó, chúng tôi sẽ:
Trong cả hai trường hợp chộp lấy, chúng ta có thể phải xếp hàng đợi, và có thể không đáng để gặp thêm rắc rối.
Với một StampedLock
, chúng ta có thể cập nhật khóa đọc thành khóa ghi và thực hiện thay đổi ngay tại chỗ nếu cần thiết như sau:
public void addName(String name) { long stamp = LOCK.readLock(); try { if(!listOfNames.contains(name)) { long writeLock = LOCK.tryConvertToWriteLock(stamp); if(writeLock == 0) { throw new IllegalStateException(); } listOfNames.add(name); } } finally { LOCK.unlock(stamp); } }
Đó là một tối ưu hóa mạnh mẽ cho những trường hợp này.
Tôi đề cập đến nhiều chủ đề tương tự trong loạt video ở trên; kiểm tra nó ra, và cho tôi biết những gì bạn nghĩ.
Tôi thường tiếp cận các bộ sưu tập đồng bộ mà không cần suy nghĩ kỹ. Điều đó đôi khi có thể hợp lý, nhưng đối với hầu hết, nó có thể không tối ưu. Bằng cách dành một chút thời gian với các nguyên mẫu liên quan đến luồng, chúng tôi có thể cải thiện đáng kể hiệu suất của mình.
Điều này đặc biệt đúng khi xử lý Loom khi tranh chấp cơ bản nhạy cảm hơn nhiều. Hãy tưởng tượng các thao tác đọc mở rộng quy mô trên 1 triệu luồng đồng thời… Trong những trường hợp đó, tầm quan trọng của việc giảm tranh chấp khóa lớn hơn nhiều.
Bạn có thể nghĩ, tại sao các bộ sưu tập synchronized
không thể sử dụng ReadWriteReentrantLock
hoặc thậm chí là StampedLock
?
Đây là một vấn đề vì diện tích bề mặt của API quá lớn nên khó có thể tối ưu hóa nó cho trường hợp sử dụng chung. Đó là nơi kiểm soát các nguyên mẫu cấp thấp có thể tạo ra sự khác biệt giữa thông lượng cao và mã chặn.