synchronized keyword: Chốt bảo vệ tài nguyên số cho Gen Z
Chào các bạn dev Gen Z! Anh Creyt đây. Hôm nay chúng ta sẽ cùng "flex" kiến thức về một từ khóa nghe có vẻ "cổ lỗ sĩ" nhưng lại cực kỳ "chất" trong Java: synchronized. Nghe tên đã thấy mùi "công nghệ cao" rồi đúng không? Đừng lo, anh sẽ "bóc phốt" nó dễ hiểu như "ăn kẹo" vậy.
synchronized là gì và tại sao chúng ta cần nó?
Trong thế giới lập trình, đặc biệt là khi các ứng dụng của chúng ta ngày càng "multitask" (đảm nhận nhiều việc cùng lúc), khái niệm đa luồng (multi-threading) trở nên quan trọng hơn bao giờ hết. Tưởng tượng thế này: bạn và mấy đứa bạn đang chơi game online, cùng muốn mở một "rương kho báu huyền thoại" (Legendary Loot Chest) duy nhất. Ai cũng click "mở" liên tục. Nếu không có một "cơ chế" nào đó để quản lý, chuyện gì sẽ xảy ra?
- Thằng A click, rương chuẩn bị mở.
- Thằng B click, rương cũng chuẩn bị mở.
- Thằng C click, rương cũng chuẩn bị mở.
Cuối cùng, có khi rương bị mở 3 lần, hoặc tệ hơn là dữ liệu bị "loạn xạ ngậu", không biết ai là người mở thật sự, vật phẩm rơi ra có đúng không, số lượng vật phẩm trong rương có bị trừ chính xác không. Đó chính là Race Condition – một cuộc đua tranh giành tài nguyên mà kết quả không được định trước, phụ thuộc vào tốc độ thực thi của các "tay đua" (các luồng).
synchronized trong Java chính là "người giữ chìa khóa" hay "bảo vệ" của cái "rương kho báu" đó. Khi một luồng (thread) muốn truy cập vào một tài nguyên (ví dụ, một phương thức hay một khối code) được bảo vệ bởi synchronized, nó phải lấy được "chìa khóa" trước. Chỉ một luồng duy nhất có thể giữ "chìa khóa" tại một thời điểm. Luồng nào có chìa khóa thì mới được vào "khu vực cấm". Các luồng khác muốn vào phải "xếp hàng" chờ đợi. Khi luồng đó hoàn thành công việc và "trả chìa khóa", luồng tiếp theo trong hàng mới được phép vào.
synchronized giúp chúng ta giải quyết hai vấn đề cốt lõi của đa luồng:
- Atomicity (Tính nguyên tử): Đảm bảo một thao tác (hoặc một chuỗi thao tác) được thực hiện hoàn chỉnh mà không bị gián đoạn bởi luồng khác. Hoặc là nó hoàn thành tất cả, hoặc không làm gì cả. Giống như bạn rút tiền ở ATM vậy, không bao giờ có chuyện bạn rút 1 triệu mà hệ thống chỉ trừ 500k rồi "treo" cả.
- Visibility (Tính hiển thị): Đảm bảo rằng những thay đổi mà một luồng thực hiện trên một biến sẽ được các luồng khác nhìn thấy ngay lập tức. Không có chuyện luồng A thay đổi giá trị, mà luồng B vẫn thấy giá trị cũ "từ đời nảo đời nào".
Code Ví Dụ: Từ hỗn loạn đến trật tự
Để dễ hình dung, chúng ta sẽ xây dựng một ví dụ đơn giản: một ứng dụng quản lý số dư tài khoản ngân hàng. Ai cũng muốn rút tiền, nhưng phải đảm bảo số dư không bị âm và các giao dịch phải chính xác.
Scenario: Ngân hàng số và tài khoản chung
Chúng ta có một tài khoản Balance và nhiều người dùng (các luồng) cùng lúc muốn rút tiền từ tài khoản này.
Vấn đề (Không có synchronized - Race Condition):
Nếu không có synchronized, khi nhiều luồng cùng gọi phương thức withdraw, có thể xảy ra tình huống số dư bị sai lệch hoặc thậm chí là âm, gây ra "bug" lớn.
class Account {
private int balance;
public Account(int initialBalance) {
this.balance = initialBalance;
}
public int getBalance() {
return balance;
}
public void withdraw(int amount) {
if (balance >= amount) {
// Giả lập một chút độ trễ để tăng khả năng xảy ra race condition
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
balance -= amount;
System.out.println(Thread.currentThread().getName() + " rút " + amount + ". Số dư còn lại: " + balance);
} else {
System.out.println(Thread.currentThread().getName() + " không đủ tiền để rút " + amount + ". Số dư hiện tại: " + balance);
}
}
}
public class RaceConditionDemo {
public static void main(String[] args) throws InterruptedException {
Account account = new Account(1000);
Runnable withdrawTask = () -> {
account.withdraw(100);
};
// Tạo 10 luồng cùng rút tiền
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(withdrawTask, "User-" + (i + 1));
threads[i].start();
}
for (Thread thread : threads) {
thread.join(); // Chờ tất cả các luồng hoàn thành
}
System.out.println("\nSố dư cuối cùng của tài khoản: " + account.getBalance());
// Kết quả có thể không phải là 0, thậm chí là số âm!
}
}
Khi chạy đoạn code trên, rất có thể bạn sẽ thấy số dư cuối cùng không phải là 0 như mong đợi (1000 - 10 * 100 = 0), mà có thể là 100, 200, hoặc thậm chí là -100 nếu các luồng chen ngang nhau khi kiểm tra số dư và thực hiện giao dịch.
Giải pháp (Với synchronized):
Bây giờ, chúng ta sẽ "bảo vệ" phương thức withdraw bằng synchronized. Có hai cách chính:
-
synchronizedmethod: Khóa toàn bộ phương thức. Khi một luồng gọi phương thức này, nó sẽ lấy khóa của đối tượngAccount. Không luồng nào khác có thể gọi bất kỳ phương thứcsynchronizednào khác trên cùng đối tượngAccountđó cho đến khi luồng hiện tại hoàn thành.class AccountSynchronizedMethod { private int balance; public AccountSynchronizedMethod(int initialBalance) { this.balance = initialBalance; } public int getBalance() { return balance; } // Khóa toàn bộ phương thức public synchronized void withdraw(int amount) { if (balance >= amount) { try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } balance -= amount; System.out.println(Thread.currentThread().getName() + " rút " + amount + ". Số dư còn lại: " + balance); } else { System.out.println(Thread.currentThread().getName() + " không đủ tiền để rút " + amount + ". Số dư hiện tại: " + balance); } } } public class SynchronizedMethodDemo { public static void main(String[] args) throws InterruptedException { AccountSynchronizedMethod account = new AccountSynchronizedMethod(1000); Runnable withdrawTask = () -> { account.withdraw(100); }; Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(withdrawTask, "User-" + (i + 1)); threads[i].start(); } for (Thread thread : threads) { thread.join(); } System.out.println("\nSố dư cuối cùng của tài khoản: " + account.getBalance()); // Kết quả sẽ luôn là 0! } } -
synchronizedblock: Khóa một khối code cụ thể. Bạn có thể chỉ định đối tượng nào sẽ được dùng làm "khóa". Điều này linh hoạt hơn khi bạn chỉ muốn bảo vệ một phần nhỏ của phương thức, thay vì khóa toàn bộ.class AccountSynchronizedBlock { private int balance; // Có thể dùng 'this' hoặc một đối tượng khóa riêng biệt private final Object lock = new Object(); public AccountSynchronizedBlock(int initialBalance) { this.balance = initialBalance; } public int getBalance() { return balance; } public void withdraw(int amount) { // Khóa chỉ khối code quan trọng synchronized (lock) { // Hoặc synchronized (this) { if (balance >= amount) { try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } balance -= amount; System.out.println(Thread.currentThread().getName() + " rút " + amount + ". Số dư còn lại: " + balance); } else { System.out.println(Thread.currentThread().getName() + " không đủ tiền để rút " + amount + ". Số dư hiện tại: " + balance); } } } } public class SynchronizedBlockDemo { public static void main(String[] args) throws InterruptedException { AccountSynchronizedBlock account = new AccountSynchronizedBlock(1000); Runnable withdrawTask = () -> { account.withdraw(100); }; Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(withdrawTask, "User-" + (i + 1)); threads[i].start(); } for (Thread thread : threads) { thread.join(); } System.out.println("\nSố dư cuối cùng của tài khoản: " + account.getBalance()); // Kết quả sẽ luôn là 0! } }
Trong cả hai ví dụ với synchronized, bạn sẽ thấy kết quả cuối cùng luôn đúng là 0. Điều này chứng tỏ synchronized đã hoạt động hiệu quả, đảm bảo tính nguyên tử và hiển thị của các giao dịch.
Mẹo từ Anh Creyt: Dùng synchronized sao cho 'đỉnh'
- Khi nào dùng? Khi bạn có shared mutable state (dữ liệu được nhiều luồng chia sẻ và có thể thay đổi). Nếu dữ liệu chỉ đọc, hoặc mỗi luồng có bản sao riêng, thì không cần
synchronized. - Cẩn thận Deadlock! Đây là "ác mộng" của đa luồng. Tưởng tượng hai luồng cùng cần hai tài nguyên khác nhau, nhưng mỗi luồng đã giữ một tài nguyên và chờ tài nguyên còn lại. Cả hai sẽ "ôm nhau" chờ mãi mãi. Để tránh deadlock, hãy cố gắng lấy các khóa theo một thứ tự nhất quán, hoặc sử dụng các cơ chế khóa phức tạp hơn từ gói
java.util.concurrent.locks. - Performance là một yếu tố:
synchronizedcó một chút overhead (chi phí hiệu suất) vì nó phải quản lý hàng đợi và chuyển đổi ngữ cảnh. Đừng lạm dụng nó. Chỉ khóa những phần code thực sự cần thiết. "Lock ít nhất có thể, nhưng đủ để an toàn." synchronizedtrênstaticmethod: Khi bạn dùngsynchronizedtrên một phương thức static, nó sẽ khóa trên đối tượngClass(ví dụ:Account.class), chứ không phải trên một instance cụ thể. Điều này có nghĩa là chỉ một luồng có thể thực thi bất kỳ phương thức staticsynchronizednào của class đó tại một thời điểm.- Alternatives (Giải pháp thay thế): Khi cần sự linh hoạt cao hơn hoặc hiệu suất tốt hơn trong các tình huống phức tạp, hãy khám phá:
java.util.concurrent.locks.Lockinterface: Cung cấp các tính năng khóa nâng cao hơn như thử khóa không chặn (tryLock()), khóa đọc/ghi riêng biệt (ReentrantReadWriteLock).java.util.concurrent.atomicpackage: Cung cấp các lớp nhưAtomicInteger,AtomicLong,AtomicReferenceđể thực hiện các thao tác nguyên tử trên một biến đơn lẻ mà không cần khóa toàn bộ khối code, thường hiệu quả hơnsynchronizedcho các trường hợp đơn giản.
synchronized trong thế giới thực: Không chỉ là lý thuyết
synchronized không chỉ là lý thuyết "sách vở" đâu, nó xuất hiện "nhan nhản" trong các hệ thống "khủng" mà bạn đang dùng hàng ngày:
- Hồ bơi kết nối Database (Connection Pool): Khi nhiều người dùng truy cập website cùng lúc, mỗi request cần một kết nối database. Connection pool quản lý một số lượng kết nối hữu hạn.
synchronized(hoặc các cơ chế khóa tương tự) được dùng để đảm bảo chỉ có một luồng được "lấy" hoặc "trả" một kết nối tại một thời điểm, tránh việc hai luồng cùng lấy một kết nối hoặc trả về một kết nối đã bị hỏng. - Hệ thống cache: Các hệ thống cache như Redis, Memcached (hoặc các cache nội bộ ứng dụng) cần đảm bảo khi nhiều luồng cùng cố gắng cập nhật hoặc đọc dữ liệu cache, dữ liệu luôn nhất quán và không bị lỗi.
synchronizedcó thể được dùng để bảo vệ các thao tác ghi vào cache. - Thống kê truy cập website/ứng dụng: Đếm số lượt xem bài viết, số người online, lượt tải xuống. Đây là những con số được nhiều luồng cùng lúc cập nhật.
synchronizedđảm bảo các thao tác tăng/giảm số đếm là nguyên tử, tránh sai số. - Hệ thống đặt vé/đặt phòng: Khi bạn đặt vé máy bay, vé xem phim, hoặc phòng khách sạn, hệ thống phải đảm bảo mỗi ghế/phòng chỉ được đặt một lần.
synchronized(hoặc các cơ chế khóa cấp cao hơn như trong database transaction) là tối quan trọng để tránh "overbooking" (đặt quá số lượng).
Thử nghiệm và Nên dùng cho Case nào?
Anh Creyt đã "thử nghiệm" qua rất nhiều "trận chiến" đa luồng rồi, và đây là lời khuyên chân thành:
-
Nên dùng
synchronizedkhi:- Bạn cần giải quyết các vấn đề đa luồng đơn giản, như bảo vệ một phương thức hoặc một khối code nhỏ mà không cần quá nhiều tùy chỉnh.
- Bạn muốn một giải pháp "built-in" của Java, dễ hiểu và ít lỗi nếu dùng đúng.
- Bạn không cần các tính năng nâng cao như thử khóa không chặn hoặc khóa đọc/ghi riêng biệt.
- Đây là "công cụ" đầu tiên bạn nên nghĩ đến khi đối mặt với race condition cơ bản.
-
Khi nào nên cân nhắc các lựa chọn khác:
- Khi hiệu suất là cực kỳ quan trọng và
synchronizedtạo ra nút thắt cổ chai lớn (bottleneck). - Khi bạn cần sự linh hoạt hơn, như có thể thử lấy khóa và nếu không được thì làm việc khác (non-blocking lock acquisition).
- Khi bạn có nhiều hoạt động đọc và ít hoạt động ghi,
ReentrantReadWriteLock(từ góijava.util.concurrent.locks) có thể cung cấp hiệu suất tốt hơn bằng cách cho phép nhiều luồng đọc đồng thời. - Khi bạn chỉ cần thao tác nguyên tử trên một biến đơn lẻ (ví dụ: tăng/giảm một số), các lớp
Atomic(nhưAtomicInteger) thường nhanh và hiệu quả hơnsynchronized.
- Khi hiệu suất là cực kỳ quan trọng và
Nhớ nhé, synchronized là một "vũ khí" mạnh mẽ nhưng cũng cần được sử dụng một cách khôn ngoan. Hiểu rõ bản chất và mục đích của nó sẽ giúp bạn viết code đa luồng an toàn và "ổn áp" hơn rất nhiều. Chúc các bạn code "mượt"!
Thuộc Series: Java – OOP
Bài giảng này được tự động xuất bản ngẫu nhiên từ thư viện kiến thức. Đừng quên đón xem các Từ khoá Hướng Dẫn tiếp theo nhé!