Cơ chế Lock trong Java: Singleton, volatile, synchronized, Deadlock và In ấn xen kẽ

Từ Singleton mở rộng ra câu hỏi phỏng vấn về Lock

Các điểm kiến thức liên quan:

  • synchronized và khóa
  • Từ khóa volatile
  • Từ khóa final, static

Mẫu Singleton Lazy

Hãy bắt đầu với phiên bản đơn giản nhất của Singleton lazy (không an toàn luồng):

public final class Singleton {
    private static Singleton instance = null;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

Vấn đề: Nếu luồng A và B cùng lúc kiểm tra instance == null, cả hai sẽ tạo ra hai đối tượng riêng biệt.

Giải pháp: Thêm khóa synchronized

Cách đơn giản nhất là đồng bộ hóa toàn bộ phương thức:

public final class Singleton {
    private static Singleton instance = null;
    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

Việc thêm synchronized vào phương thức static tương đương với khóa trên Singleton.class. Mỗi lần gọi phương thức này, thread đều phải xin lock, gây ra overhead không cần thiết.

Tối ưu: Double-Checked Locking

Để giảm overhead, ta thêm một lần kiểm tra instance == null bên ngoài khối synchronized:

public final class Singleton {
    private static Singleton instance = null;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Tuy nhiên, đoạn code trên vẫn có lỗi tiềm ẩn về reordering của JVM. Lệnh instance = new Singleton() không phải là atomic – nó bao gồm cấp phát bộ nhớ, khởi tạo đối tượng, và gán tham chiếu. JVM có thể gán tham chiếu trước khi đối tượng được khởi tạo hoàn chỉnh, dẫn đến thread khác nhìn thấy một đối tượng chưa sẵn sàng.

Giải pháp triệt để: volatile

Từ khóa volatile đảm bảo tính happens-before và ngăn chặn reordering:

public final class Singleton {
    private static volatile Singleton instance = null;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

So sánh synchronized và volatile

  • synchronized: Đảm bảo cả tính nguyên tử (atomicity) và tính nhìn thấy (visibility). Khi vào khối synchronized, JVM sẽ xóa bộ nhớ cache cục bộ và load lại từ main memory; khi thoát, nó đẩy dữ liệu từ cache vào main memory. Tuy nhiên, chi phí khá lớn.
  • volatile: Chỉ áp dụng cho biến, báo hiệu cho JVM biết biến này "không ổn định", mọi thao tác đọc/ghi phải thực hiện trực tiếp trên main memory. Không đảm bảo tính nguyên tử (ví dụ: i++ vẫn không an toàn).

Từ khóa final và static

  • final: Dùng cho class (không thể kế thừa), method (không thể override), biến (không thể thay đổi giá trị). Ví dụ: public static final double PI = 3.14;
  • static: Biến static tồn tại duy nhất trong bộ nhớ, được chia sẻ giữa tất cả các instance. Dùng làm biến dùng chung (shared variable).

Tự xây dựng một Lock đơn giản

Một cơ chế lock cơ bản cần:

  1. Biến owner (volatile): Đánh dấu thread nào đang giữ lock. Nếu khác null nghĩa là đã có người chiếm.
  2. Cơ chế CAS: Đảm bảo việc chiếm lock là nguyên tử. Sử dụng AtomicReference.
  3. Cơ chế chặn và đánh thức thread: Dùng LockSupport.park()/unpark().
  4. Hàng đợi chờ: Lưu các thread đang chờ lock (dùng LinkedBlockingQueue).

Ví dụ code lock đơn giản (phiên bản không công bằng):

import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.LinkedBlockingQueue;

public class SimpleLock {
    private volatile AtomicReference<Thread> owner = new AtomicReference<>();
    private volatile LinkedBlockingQueue<Thread> waiters = new LinkedBlockingQueue<>();

    public void lock() {
        while (!owner.compareAndSet(null, Thread.currentThread())) {
            // Nếu không chiếm được lock, đưa vào hàng đợi và park
            waiters.add(Thread.currentThread());
            LockSupport.park();
            // Khi được đánh thức, cần bỏ thread ra khỏi hàng đợi
            waiters.remove(Thread.currentThread());
        }
    }

    public void unlock() {
        if (owner.compareAndSet(Thread.currentThread(), null)) {
            // Đánh thức tất cả thread đang chờ (non-fair)
            for (Thread waiter : waiters) {
                LockSupport.unpark(waiter);
            }
        }
    }
}

Ví dụ: In ấn xen kẽ A và B

Cách 1: Dùng synchronized + wait/notify

public class AlternatePrintSync {
    private static boolean turnA = true;
    private static final Object lock = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock) {
                while (true) {
                    if (turnA) {
                        System.out.print("A");
                        turnA = false;
                        lock.notify();
                    } else {
                        try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                    }
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (lock) {
                while (true) {
                    if (!turnA) {
                        System.out.print("B");
                        turnA = true;
                        lock.notify();
                    } else {
                        try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                    }
                }
            }
        }).start();
    }
}

Cách 2: Dùng ReentrantLock + Condition

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

public class AlternatePrintReentrant {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        Condition condA = lock.newCondition();
        Condition condB = lock.newCondition();

        new Thread(() -> {
            lock.lock();
            try {
                for (int i = 0; i < 10; i++) {
                    System.out.print("A");
                    condB.signal();
                    condA.await();
                }
                condB.signal(); // Đánh thức lần cuối
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        }).start();

        new Thread(() -> {
            lock.lock();
            try {
                for (int i = 0; i < 10; i++) {
                    System.out.print("B");
                    condA.signal();
                    condB.await();
                }
                condA.signal();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        }).start();
    }
}

Ví dụ: Deadlock

Deadlock xảy ra khi mỗi thread giữ một lock và chờ lock kia:

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread1: giữ lock1");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock2) {
                    System.out.println("Thread1: giữ lock2");
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread2: giữ lock2");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lock1) {
                    System.out.println("Thread2: giữ lock1");
                }
            }
        }).start();
    }
}

Cách phòng tránh deadlock: Tránh vòng lặp chờ (circular wait). Có thể áp dụng thuật toán Banker để cấp phát tài nguyên trước, hoặc luôn khóa theo một thứ tự cố định.

Ba cách tạo Thread trong Java

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ExecutionException;

public class ThreeWaysToCreateThread {
    public static void main(String[] args) throws Exception {
        // Cách 1: extends Thread
        new MyThread().start();

        // Cách 2: implements Runnable
        new Thread(new MyRunnable()).start();

        // Cách 3: implements Callable (có kết quả trả về)
        FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
        new Thread(futureTask).start();
        String result = futureTask.get();
        System.out.println("Kết quả: " + result);
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread từ extends Thread");
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Thread từ implements Runnable");
    }
}

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "Callable trả về kết quả";
    }
}

So sánh:

Tiêu chíextends Threadimplements Runnableimplements Callable
Đa kế thừaKhông (Java đơn kế thừa)Có (có thể implement thêm)
Chia sẻ tài nguyênKhó (mỗi thread một instance)Dễ (cùng Runnable object)Dễ (cùng Callable object)
Giá trị trả vềKhôngKhôngCó (thông qua Future)
Ném ngoại lệKhông được khai báo throwsKhông được khai báo throwsCó thể throws Exception
Dùng với ExecutorServiceKhông trực tiếpexecute()submit()

Thẻ: Java singleton volatile synchronized deadlock

Đăng vào ngày 28 tháng 5 lúc 13:29