CodeGym /Các khóa học /JAVA 25 SELF /Phân tích các lỗi điển hình khi đồng bộ hóa

Phân tích các lỗi điển hình khi đồng bộ hóa

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Quên unlock/release: cái bẫy cho người bất cẩn

Một trong những lỗi xảo quyệt nhất khi dùng các công cụ đồng bộ hiện đại như ReentrantLock hoặc Semaphore là quên gọi unlock() hoặc release(). Nếu bạn không giải phóng khóa, các luồng khác sẽ chờ nó được giải phóng... mãi mãi. Chương trình sẽ treo, và bạn sẽ ngồi nhìn màn hình rất lâu, cố hiểu vì sao chẳng có gì xảy ra.

Xem ví dụ với ReentrantLock:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        // Ôi! Quên unlock() — giờ thì tất cả sẽ bị treo!
        count++;
    }
}

Trông có vẻ vô hại, nhưng nếu gọi increment() nhiều lần từ các luồng khác nhau, sau lần gọi đầu tiên các luồng còn lại sẽ chờ việc giải phóng khóa vô thời hạn.

Để tránh tình huống này, hãy dùng cấu trúc try-finally:

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

Giờ đây, ngay cả khi xảy ra ngoại lệ ở giữa phương thức, khóa vẫn được đảm bảo giải phóng.

Giống như ai đó vào nhà vệ sinh (khóa cửa từ bên trong), rồi quên mở cửa và chui ra bằng cửa sổ. Những người khác sẽ đứng chờ cho đến khi người đó ra... Đừng làm vậy!

2. Đồng bộ trên đối tượng không phù hợp: “Ôi, khóa sai chỗ!”

Trong Java, từ khóa synchronized có thể khóa truy cập vào một đối tượng nào đó. Nhưng nếu bạn chọn sai đối tượng để khóa, việc đồng bộ sẽ không hoạt động như kỳ vọng.

Lỗi số 1: đồng bộ trên biến cục bộ

public void doSomething() {
    Object lock = new Object();
    synchronized (lock) {
        // Mỗi lần là một đối tượng mới — không hề có đồng bộ!
        // Các luồng không chờ nhau.
        // Vùng tới hạn không được bảo vệ!
    }
}

Ở đây, mỗi luồng tạo ra đối tượng lock của riêng mình. Kết quả là chẳng có khóa thực sự nào xảy ra — các luồng cùng lúc đi vào vùng tới hạn.

Đúng:

private final Object lock = new Object();

public void doSomething() {
    synchronized (lock) {
        // Giờ tất cả các luồng dùng cùng một đối tượng lock
        // và thực sự phải chờ nhau.
    }
}

Lỗi số 2: đồng bộ trên literal chuỗi

public void doSomething() {
    synchronized ("lock") {
        // Các literal chuỗi được intern: các phần khác nhau của chương trình
        // có thể vô tình đồng bộ trên cùng một chuỗi!
    }
}

Kết luận:
Chỉ đồng bộ trên các đối tượng private, được tạo riêng cho mục đích này và không được dùng ở nơi nào khác.

3. Deadlock (bế tắc): “Tôi chờ bạn — bạn chờ tôi, và cả hai đều đứng im”

Deadlock (vế tắc) là điều kinh điển. Hai (hoặc nhiều) luồng lần lượt chiếm các khóa khác nhau và chờ nhau, cho đến khi chương trình đứng im.

Ví dụ:

public class DeadlockExample {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void method1() {
        synchronized (lockA) {
            // Chờ một chút cho “đủ độ” của thí nghiệm
            try { Thread.sleep(50); } catch (InterruptedException e) {}
            synchronized (lockB) {
                // ...
            }
        }
    }

    public void method2() {
        synchronized (lockB) {
            try { Thread.sleep(50); } catch (InterruptedException e) {}
            synchronized (lockA) {
                // ...
            }
        }
    }
}

Nếu một luồng gọi method1(), và luồng khác — method2(), thì luồng thứ nhất sẽ giữ lockA và chờ lockB, còn luồng thứ hai — ngược lại. Kết quả là cả hai sẽ chờ nhau mãi mãi.

Làm sao tránh?

  • Luôn lấy khóa theo cùng một thứ tự ở tất cả các luồng.
  • Giảm thiểu số lượng khóa được giữ đồng thời.
  • Dùng công cụ chẩn đoán (ví dụ, jstack) nếu chương trình bị treo.

Phép so sánh:
Giống như hai người gặp nhau trong hành lang hẹp, mỗi người chỉ chịu nhường đường nếu người kia nhường trước. Cuối cùng cả hai đứng chờ cho tới khi có ai đó chịu nhường.

4. Đồng bộ hóa quá mức: “Cẩn thận quá còn hơn thiếu?” — không phải lúc nào cũng đúng!

Đôi khi, vì sợ lỗi, lập trình viên đồng bộ mọi thứ. Hậu quả là hiệu năng giảm, mà chẳng được lợi gì.

Ví dụ:

public synchronized void add(int value) {
    // Ở đây chỉ có một dòng, không cần đồng bộ!
    System.out.println("Đã thêm: " + value);
}

Trong trường hợp này, không cần đồng bộ: in ra màn hình bằng System.out.println vốn đã thread-safe, và bản thân phương thức không làm việc với tài nguyên dùng chung.

Khi nào điều này gây hại?
Nếu bạn đồng bộ các phương thức được gọi thường xuyên nhưng không cần bảo vệ, bạn sẽ làm giảm mạnh hiệu năng chương trình. Các luồng phải xếp hàng, trong khi lẽ ra có thể chạy song song.

Thực hành tốt nhất:
Chỉ đồng bộ những gì thật sự cần thiết. Vùng tới hạn nên nhỏ nhất có thể.

5. Sử dụng volatile sai cách: “Có tính nhìn thấy, nhưng không có tính nguyên tử!”

Bộ sửa đổi volatile trong Java đảm bảo rằng thay đổi của biến sẽ được mọi luồng nhìn thấy. Nhưng nó không đảm bảo tính nguyên tử của các thao tác.

Lỗi:

private volatile int counter = 0;

public void increment() {
    counter++; // Không nguyên tử!
}

Toán tử counter++ gồm đọc giá trị, tăng lên và ghi lại. Nếu hai luồng đồng thời chạy đoạn mã này, giá trị cuối cùng có thể nhỏ hơn mong đợi.

Đúng:
Để có thao tác nguyên tử, hãy dùng synchronized, AtomicInteger hoặc các lớp an toàn với luồng khác.

import java.util.concurrent.atomic.AtomicInteger;

private final AtomicInteger counter = new AtomicInteger();

public void increment() {
    counter.incrementAndGet();
}

Khi nào dùng volatile?
Cho các cờ đơn giản (ví dụ: “kết thúc chạy”), khi không cần tính nguyên tử.

1
Khảo sát/đố vui
, cấp độ , bài học
Không có sẵn
Đồng bộ hóa luồng
Đồng bộ hóa luồng
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION