CodeGym /Các khóa học /JAVA 25 SELF /synchronized, volatile: cú pháp, cách sử dụng

synchronized, volatile: cú pháp, cách sử dụng

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

1. Từ khóa synchronized: để làm gì và dùng thế nào

Trong Java, từ khóa synchronized giống như tấm biển “Đang bận!” trên cửa nhà vệ sinh: khi một luồng đang ở bên trong “vùng tới hạn”, các luồng khác lịch sự chờ đến lượt mình. Chỉ khi luồng đầu tiên rời đi, luồng kế tiếp mới có thể vào và thực thi mã của nó.

Cú pháp: khối và phương thức

Khối đồng bộ hóa

synchronized (object) {
    // vùng tới hạn
}
  • object — là bất kỳ đối tượng nào mà bạn muốn “gắn khóa”. Khi một luồng đang thực thi khối này, các luồng khác cũng muốn vào khối với cùng đối tượng sẽ phải chờ.

Phương thức đồng bộ hóa

public synchronized void increment() {
    // vùng tới hạn
}
  • Ở đây “khóa” được đặt trên chính đối tượng (this). Nghĩa là tại một thời điểm chỉ một luồng có thể thực thi bất kỳ phương thức được đồng bộ hóa nào của đối tượng này.

Phương thức tĩnh được đồng bộ hóa

public static synchronized void foo() {
    // vùng tới hạn
}
  • Ở đây khóa diễn ra ở cấp lớp (ClassName.class), không phải một đối tượng cụ thể.

Hoạt động bên dưới như thế nào

Khi một luồng vào khối hay phương thức đồng bộ hóa, nó sẽ chiếm “monitor” của đối tượng (hoặc của lớp đối với phương thức tĩnh). Nếu monitor đã bận — luồng phải chờ. Ngay khi monitor được giải phóng, luồng tiếp theo có thể vào.

2. Ví dụ: tăng bộ đếm có và không có đồng bộ hóa

Không dùng đồng bộ hóa

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
public class CounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Giá trị cuối cùng: " + counter.getCount());
    }
}

Giá trị kỳ vọng: 2000
Giá trị thực tế: có thể nhỏ hơn (ví dụ, 1995, 1987...), và mỗi lần chạy lại có “bất ngờ” riêng.

Tại sao? Vì phép toán count++ không nguyên tử: nó được chia thành ba bước — đọc giá trị, tăng lên, ghi lại. Nếu hai luồng làm điều này đồng thời, chúng có thể “ghi đè” kết quả của nhau.

Giải pháp: synchronized

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

Giờ chỉ một luồng mỗi lần có thể thực thi phương thức increment(). Giá trị cuối cùng luôn là 2000.

Phương án thay thế: khối đồng bộ hóa

public class Counter {
    private int count = 0;

    public void increment() {
        synchronized (this) {
            count++;
        }
    }
}

Kết quả sẽ giống nhau. Bạn có thể chỉ đồng bộ hóa phần cần thiết thay vì cả phương thức.

3. Giới thiệu về “monitor của đối tượng”

Monitor là “khóa” được tích hợp trong mỗi đối tượng trong Java. Khi bạn viết synchronized(object), luồng cố gắng “khóa” đối tượng đó. Nếu khóa đang rảnh — luồng sẽ nhận được, nếu không — nó chờ đến lượt. Khi luồng rời khỏi khối, khóa được giải phóng.

Quan trọng! Nếu bạn đồng bộ trên các đối tượng khác nhau — các luồng sẽ không chờ nhau. Vì vậy, rất quan trọng là chọn đúng đối tượng để đồng bộ hóa.

Các phương thức tĩnh được đồng bộ hóa

Đôi khi tài nguyên dùng chung không phải là một đối tượng riêng lẻ mà là thứ dùng chung cho mọi thể hiện của lớp (ví dụ, biến tĩnh). Trong trường hợp này, cần đồng bộ ở cấp lớp.

public class StaticCounter {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static int getCount() {
        return count;
    }
}

Điều này tương đương với:

public static void increment() {
    synchronized (StaticCounter.class) {
        count++;
    }
}

Monitor được đặt trên đối tượng lớp (Class), không phải trên thể hiện cụ thể.

4. Từ khóa volatile: là gì và dùng để làm gì

Vấn đề về khả năng hiển thị giữa các luồng

Trong Java, mỗi luồng có thể đệm (cache) giá trị biến để tăng tốc. Điều này có nghĩa là nếu một luồng thay đổi biến, luồng khác có thể “không thấy”, tiếp tục đọc giá trị từ cache cục bộ của nó. Điều này đặc biệt quan trọng với các cờ mà các luồng dùng để báo hiệu cho nhau.

volatile hoạt động như thế nào

Nếu một biến được khai báo là volatile, điều đó có nghĩa là:

  • Mọi luồng luôn đọc và ghi nó trực tiếp ở bộ nhớ chính, bỏ qua cache.
  • Bất kỳ thay đổi nào của biến đều trở nên nhìn thấy ngay lập tức với tất cả các luồng.

Nhưng! Các thao tác với volatile tự thân không nguyên tử (trừ việc đọc/ghi đơn giản các kiểu nguyên thủy như boolean, int, v.v.). Nếu bạn làm điều gì đó phức tạp hơn phép gán — cần đồng bộ hóa.

Ví dụ: cờ kết thúc công việc

public class Worker extends Thread {
    private volatile boolean running = true;

    public void run() {
        while (running) {
            // làm gì đó hữu ích
        }
        System.out.println("Luồng đã kết thúc");
    }

    public void shutdown() {
        running = false;
    }
}
Worker w = new Worker();
w.start();
// ... sau một thời gian
w.shutdown();

Nếu không có volatile, luồng có thể “không thấy” sự thay đổi của cờ và lặp vô tận (đặc biệt trên hệ thống đa nhân). Với volatile — mọi thứ hoạt động như mong đợi.

5. Giới hạn của volatile: không nguyên tử

Nhiều người mới nghĩ: “Nếu khai báo volatile cho int, thì có thể viết count++ và khỏi lo”. Tiếc là không phải vậy:

private volatile int count = 0;

public void increment() {
    count++;
}

Sai! Phép toán count++ vẫn không nguyên tử — gồm ba bước: (1) đọc, (2) tăng, (3) ghi lại. Nếu hai luồng đồng thời đọc cùng một giá trị, cả hai sẽ tăng nó và cả hai ghi cùng kết quả — một lần tăng sẽ “bị mất”.

Kết luận: volatile chỉ đảm bảo khả năng hiển thị thay đổi, nhưng không bảo vệ khỏi tranh chấp khi thực hiện các thao tác phức tạp.

6. Khi nào dùng synchronized, và khi nào — volatile

  • volatile — khi bạn có một cờ đơn giản (ví dụ, boolean) mà một luồng ghi, luồng khác đọc. Ví dụ: kết thúc luồng, báo hiệu sự kiện.
  • synchronized — khi cần đảm bảo tính nguyên tử cho các thao tác phức tạp (ví dụ, tăng, thay đổi nhiều biến, làm việc với cấu trúc dữ liệu).

Bảng ghi nhớ

Kịch bản volatile synchronized
Truyền tín hiệu giữa các luồng
Phép toán nguyên tử (tăng)
Nhiều bước trong vùng tới hạn
Chỉ cần khả năng hiển thị thay đổi

Các lỗi thường gặp khi dùng synchronizedvolatile

Lỗi №1: Đồng bộ trên đối tượng không đúng. Nếu bạn đồng bộ trên biến cục bộ hoặc trên các đối tượng khác nhau ở mỗi luồng — sẽ không có sự bảo vệ nào.

Object lock = new Object();
synchronized (lock) {
    // ...
}

Nếu mỗi luồng tạo lock riêng — vô ích. Cần một điểm đồng bộ chung, một đối tượng chung cho mọi luồng.

Lỗi №2: Kỳ vọng tính nguyên tử từ volatile. volatile đảm bảo khả năng hiển thị, không phải tính nguyên tử. Các thao tác như count++ vẫn không an toàn nếu thiếu đồng bộ hóa.

Lỗi №3: Đồng bộ hóa một vùng mã quá lớn. Nếu bạn đồng bộ cả phương thức trong khi chỉ cần một dòng, bạn đã chặn các luồng khác một cách không cần thiết và làm giảm hiệu năng. Hãy cố gắng thu nhỏ “vùng tới hạn”.

Lỗi №4: Quên đồng bộ “tĩnh” cho dữ liệu tĩnh. Nếu bạn có biến tĩnh mà lại đồng bộ trên this thì sẽ không có tác dụng. Với dữ liệu tĩnh, cần đồng bộ ở cấp lớp: synchronized(ClassName.class).

Lỗi №5: Đồng bộ trên literal chuỗi. Đồng bộ trên chuỗi là nguy hiểm vì các literal giống nhau được JVM intern. Bạn có thể vô tình nhận cùng một khóa dùng chung cho các phần khác nhau của chương trình.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION