2. Thread Safety and Synchronization

Thread Safety và Synchronization trong Java

Phần này trình bày các khái niệm cốt lõi liên quan đến Thread Safety và các cơ chế đồng bộ hóa trong Java, bao gồm synchronized, intrinsic locks, volatile, và atomic operations. Nội dung được giải thích chi tiết kèm ví dụ minh họa để hỗ trợ việc học chuyên sâu về concurrency.


1. Thread Safety

1.1. Định nghĩa

Thread Safety là đặc tính của một đoạn mã hoặc đối tượng đảm bảo rằng nó hoạt động đúng khi được truy cập đồng thời bởi nhiều thread, không gây ra lỗi hoặc trạng thái không nhất quán.

  • Một lớp hoặc phương thức được coi là thread-safe nếu nó duy trì tính đúng đắn và nhất quán của dữ liệu trong môi trường đa luồng.

  • Thread safety thường được yêu cầu khi nhiều thread truy cập và sửa đổi tài nguyên chung (shared resources) như biến, danh sách, hoặc cơ sở dữ liệu.

1.2. Tại sao cần Thread Safety?

  • Đảm bảo tính đúng đắn: Ngăn ngừa lỗi dữ liệu khi nhiều thread truy cập tài nguyên cùng lúc.

  • Tăng độ tin cậy: Giảm nguy cơ crash hoặc hành vi không xác định trong ứng dụng.

  • Hỗ trợ đa luồng: Cho phép tận dụng sức mạnh của các hệ thống đa nhân mà không gây lỗi.

1.3. Các vấn đề khi không đảm bảo Thread Safety

Khi không có thread safety, các vấn đề phổ biến bao gồm:

Race Condition

  • Race condition xảy ra khi kết quả của chương trình phụ thuộc vào thứ tự thực thi của các thread, dẫn đến hành vi không dự đoán được.

  • Ví dụ: Hai thread cùng tăng giá trị của một biến đếm, nhưng không đồng bộ hóa, dẫn đến việc bỏ sót một số phép tăng.

Ví dụ Race Condition:

public class RaceConditionExample {
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter++; // Không thread-safe
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Counter: " + counter); // Kết quả không phải 2000
    }
}
  • Kết quả: Giá trị của counter thường nhỏ hơn 2000 do các thread ghi đè lên nhau.

Data Inconsistency

  • Data inconsistency xảy ra khi dữ liệu bị sửa đổi nửa vời, dẫn đến trạng thái không hợp lệ.

  • Ví dụ: Một thread đang cập nhật một đối tượng với nhiều trường (fields), nhưng thread khác đọc dữ liệu trước khi cập nhật hoàn tất.

Ví dụ Data Inconsistency:

public class DataInconsistencyExample {
    static class Point {
        int x, y;

        void set(int x, int y) {
            this.x = x; // Thread có thể bị gián đoạn tại đây
            this.y = y;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Point point = new Point();
        Runnable writer = () -> {
            for (int i = 0; i < 1000; i++) {
                point.set(i, i);
            }
        };
        Runnable reader = () -> {
            for (int i = 0; i < 1000; i++) {
                if (point.x != point.y) {
                    System.out.println("Inconsistent: x=" + point.x + ", y=" + point.y);
                }
            }
        };

        Thread t1 = new Thread(writer);
        Thread t2 = new Thread(reader);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}
  • Kết quả: Có thể thấy các giá trị xy không đồng bộ, gây ra trạng thái không nhất quán.


2. Synchronized Keyword

2.1. Cách sử dụng Synchronized

Từ khóa synchronized trong Java đảm bảo rằng chỉ một thread có thể thực thi một phương thức hoặc khối mã tại một thời điểm, sử dụng intrinsic locks (khóa nội tại) của đối tượng.

Synchronized trên Phương thức

  • Khi một phương thức được khai báo là synchronized, chỉ một thread có thể thực thi phương thức đó trên một đối tượng cụ thể.

  • Khóa được lấy trên đối tượng this (cho phương thức instance) hoặc trên lớp (cho phương thức static).

Ví dụ Synchronized Method:

public class SynchronizedMethodExample {
    private int counter = 0;

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

    public static void main(String[] args) throws InterruptedException {
        SynchronizedMethodExample example = new SynchronizedMethodExample();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Counter: " + example.counter); // Kết quả: 2000
    }
}

Synchronized trên Khối mã

  • Sử dụng khối synchronized để khóa trên một đối tượng cụ thể, giúp giảm phạm vi khóa và tăng hiệu suất.

  • Cú pháp: synchronized(object) { ... }.

Ví dụ Synchronized Block:

public class SynchronizedBlockExample {
    private int counter = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            counter++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlockExample example = new SynchronizedBlockExample();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Counter: " + example.counter); // Kết quả: 2000
    }
}

2.2. Hạn chế của Synchronized

  • Hiệu suất: Synchronized có thể gây tranh chấp khóa (lock contention), làm giảm hiệu suất khi nhiều thread cạnh tranh.

  • Khóa toàn bộ: Không linh hoạt, không hỗ trợ khóa đọc-ghi riêng biệt (read-write locks).

  • Khả năng mở rộng: Không phù hợp cho các ứng dụng cần hiệu suất cao với nhiều thread.

  • Không hỗ trợ timeout: Không thể thử lấy khóa với thời gian chờ như tryLock trong ReentrantLock.

  • Nguy cơ deadlock: Nếu không quản lý thứ tự khóa cẩn thận, có thể dẫn đến deadlock.


3. Intrinsic Locks (Monitor Locks)

3.1. Cơ chế Khóa Nội tại

  • Mỗi đối tượng trong Java có một intrinsic lock (hay monitor lock), được sử dụng bởi từ khóa synchronized.

  • Khi một thread muốn thực thi một khối synchronized hoặc phương thức synchronized, nó phải lấy được khóa của đối tượng tương ứng.

  • Các thread khác muốn lấy khóa sẽ bị chặn (blocked) cho đến khi khóa được trả lại.

3.2. Cách Thread Lấy và Trả Khóa

  • Lấy khóa: Thread gọi synchronized và lấy khóa nếu nó đang rảnh. Nếu khóa đang được giữ, thread chuyển sang trạng thái Blocked.

  • Trả khóa: Thread trả khóa khi thoát khỏi khối hoặc phương thức synchronized.

  • Reentrant: Một thread có thể lấy lại khóa mà nó đã giữ (hỗ trợ khóa tái nhập - reentrant locking).

Ví dụ Intrinsic Lock:

public class IntrinsicLockExample {
    private final Object lock = new Object();

    public void doWork() {
        synchronized (lock) {
            System.out.println(Thread.currentThread().getName() + " acquired lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " released lock");
        }
    }

    public static void main(String[] args) {
        IntrinsicLockExample example = new IntrinsicLockExample();
        Runnable task = example::doWork;
        new Thread(task, "Thread-1").start();
        new Thread(task, "Thread-2").start();
    }
}
  • Kết quả: Chỉ một thread được thực thi trong khối synchronized tại một thời điểm, thread kia phải chờ.


4. Volatile Keyword

4.1. Đảm bảo Tính Nhất quán

  • Từ khóa volatile được sử dụng để đánh dấu một biến đảm bảo visibility (khả năng quan sát) và ordering (thứ tự) giữa các thread.

  • Khi một biến được khai báo là volatile:

    • Mọi thay đổi của biến sẽ được ghi trực tiếp vào bộ nhớ chính (main memory), không lưu trong cache của thread.

    • Mọi đọc biến sẽ lấy giá trị mới nhất từ bộ nhớ chính.

4.2. Ứng dụng

  • Trạng thái đơn giản: Dùng cho các biến trạng thái (flags) hoặc biến điều khiển (ví dụ: biến running để dừng thread).

  • Tránh cache thread: Đảm bảo thread đọc giá trị mới nhất của biến.

Ví dụ Volatile:

public class VolatileExample {
    private volatile boolean running = true;

    public void start() {
        new Thread(() -> {
            while (running) {
                System.out.println("Thread is running");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("Thread stopped");
        }).start();
    }

    public void stop() {
        running = false;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        example.start();
        Thread.sleep(2000);
        example.stop();
    }
}
  • Kết quả: Thread dừng khi running được đặt thành false, nhờ volatile đảm bảo giá trị được cập nhật ngay lập tức.

4.3. Hạn chế

  • Không đảm bảo atomicity: volatile chỉ đảm bảo visibility, không ngăn race condition trong các thao tác phức hợp (ví dụ: i++).

  • Hiệu suất: Có thể chậm hơn so với biến thông thường do truy cập trực tiếp bộ nhớ chính.

  • Không thay thế khóa: Không thể dùng thay cho synchronized hoặc Lock trong các tình huống cần đồng bộ hóa phức tạp.


5. Atomic Operations

5.1. Các lớp trong java.util.concurrent.atomic

Gói java.util.concurrent.atomic cung cấp các lớp hỗ trợ thực hiện thao tác atomic (không thể chia cắt) mà không cần khóa, sử dụng cơ chế Compare-And-Swap (CAS). Các lớp phổ biến:

  • AtomicInteger: Thao tác nguyên tử trên số nguyên.

  • AtomicLong: Thao tác nguyên tử trên số dài.

  • AtomicReference: Thao tác nguyên tử trên tham chiếu đối tượng.

  • AtomicBoolean: Thao tác nguyên tử trên giá trị boolean.

5.2. Ứng dụng

  • Thay thế synchronized: Dùng trong các tình huống cần hiệu suất cao, tránh tranh chấp khóa.

  • Cập nhật biến thread-safe: Ví dụ: đếm, cập nhật trạng thái, hoặc quản lý con trỏ.

Ví dụ AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerExample {
    private static AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.incrementAndGet(); // Thao tác nguyên tử
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Counter: " + counter.get()); // Kết quả: 2000
    }
}
  • Kết quả: AtomicInteger đảm bảo thao tác incrementAndGet là thread-safe, không có race condition.

Ví dụ AtomicReference:

import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceExample {
    public static void main(String[] args) throws InterruptedException {
        AtomicReference<String> ref = new AtomicReference<>("Initial");
        Runnable task = () -> {
            ref.compareAndSet("Initial", "Updated");
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Value: " + ref.get()); // Kết quả: Updated
    }
}
  • Kết quả: Chỉ một thread thành công trong việc cập nhật giá trị nhờ compareAndSet.

5.3. Ưu điểm và Hạn chế

  • Ưu điểm:

    • Hiệu suất cao hơn synchronized trong một số trường hợp nhờ CAS.

    • Không cần khóa, giảm tranh chấp.

  • Hạn chế:

    • Phức tạp hơn khi sử dụng cho các thao tác phức hợp.

    • CAS có thể thất bại trong môi trường cạnh tranh cao, dẫn đến vòng lặp retry.


Kết luận

Hiểu và áp dụng đúng Thread Safety và các cơ chế đồng bộ hóa là yếu tố then chốt trong lập trình đa luồng:

  • Thread Safety giúp tránh race condition và data inconsistency.

  • Synchronized cung cấp cách đơn giản để đồng bộ hóa, nhưng có hạn chế về hiệu suất.

  • Intrinsic Locks là nền tảng của synchronized, đảm bảo chỉ một thread truy cập tài nguyên tại một thời điểm.

  • Volatile hữu ích cho visibility, nhưng không thay thế khóa trong các thao tác phức tạp.

  • Atomic Operations cung cấp giải pháp hiệu suất cao cho các thao tác đơn giản, thread-safe.

Phần tiếp theo nên tập trung vào Explicit Locks (ReentrantLock, ReadWriteLock) để hiểu các cơ chế khóa linh hoạt hơn.

Last updated