CodeGym /Các khóa học /JAVA 25 SELF /Chẩn đoán và gỡ lỗi chương trình đa luồng

Chẩn đoán và gỡ lỗi chương trình đa luồng

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

1. Thread Dump và phân tích trạng thái luồng

Thread Dump (dump luồng) — là ảnh chụp trạng thái của tất cả các luồng trong ứng dụng tại một thời điểm cụ thể. Nó giống như ảnh chụp tập thể của tất cả các luồng của bạn: ai đang làm gì, ai bị kẹt ở đâu, ai đang chờ ai. Thread Dump — công cụ chính của bạn để tìm deadlock, livelock và các hiện tượng treo khó hiểu khác.

Làm thế nào để lấy Thread Dump?

Qua terminal (jstack):

Nếu bạn có PID của tiến trình Java, hãy chạy:

jstack <PID>

Lệnh sẽ in ra console trạng thái của tất cả các luồng, chỉ ra mỗi luồng đang ở trạng thái nào và đang giữ những monitor (lock) nào.

Qua IDE (IntelliJ IDEA):
Trong menu “Run” → “Show Running List” → chọn tiến trình → “Thread Dump”.

Qua VisualVM hoặc JConsole:
Mở tiến trình, tìm tab “Threads” và chụp ảnh trạng thái.

Ví dụ Thread Dump

Đoạn trích dump:

"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001e0c7800 nid=0x1a48 waiting for monitor entry [0x000000001f00f000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.DeadlockDemo.lambda$main$0(DeadlockDemo.java:25)
    - waiting to lock <0x00000000d6d6baf8> (a java.lang.Object)
    - locked <0x00000000d6d6bb08> (a java.lang.Object)

Ở đây có thể thấy luồng “Thread-1” bị chặn (BLOCKED), đang giữ một monitor nhưng chờ monitor khác. Nếu bạn thấy vài luồng như vậy: luồng này giữ tài nguyên A và chờ B, còn luồng kia giữ B và chờ A — đó là deadlock kinh điển.

Các trạng thái của luồng

Trạng thái Mô tả
RUNNABLE Luồng đang chạy hoặc sẵn sàng chạy
BLOCKED Đang chờ chiếm monitor (lock)
WAITING Đang chờ notify()/notifyAll() (ví dụ, do gọi wait())
TIMED_WAITING Đang chờ có timeout (ví dụ, sleep, wait(timeout))
TERMINATED Luồng đã kết thúc

Lưu ý: trạng thái RUNNABLE không phải lúc nào cũng nghĩa là luồng đang thực thi ngay lúc này — nó chỉ sẵn sàng thực thi (bộ lập lịch JVM có thể chưa chạy nó ngay).

Làm sao biết bạn đang bị deadlock?

Trong dump có vài luồng ở trạng thái BLOCKED, mỗi luồng chờ một monitor đang được giữ bởi luồng khác trong cùng tập đó.

Ở cuối dump jstack thường ghi:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00000000d6d6baf8 (object 0x00000000d6d6baf8, a java.lang.Object),
  which is held by "Thread-2"
"Thread-2":
  waiting to lock monitor 0x00000000d6d6bb08 (object 0x00000000d6d6bb08, a java.lang.Object),
  which is held by "Thread-1"

Nếu các luồng treo lâu ở BLOCKED hoặc WAITING — đó là dấu hiệu cần điều tra.

2. Giám sát và profiling luồng

VisualVM
VisualVM — tiện ích miễn phí có trong hầu hết các JDK. Cho phép kết nối tới tiến trình, xem trạng thái luồng, tạo Thread Dump, xem mức tải CPU, các luồng đang hoạt động và các luồng “treo”.

Tab Threads: cho thấy có bao nhiêu luồng đã tạo, trạng thái của chúng và lịch sử hoạt động.

Thread Dump: nút “Thread Dump” chụp ảnh trạng thái tương tự như jstack.

Java Mission Control và Flight Recorder

Java Mission Control (JMC): công cụ nâng cao để phân tích JVM theo thời gian thực. Giúp khảo sát lock, thời gian thực thi, cấp phát, độ trễ.

Java Flight Recorder (JFR): profiler tích hợp của JVM, thu thập sự kiện về luồng, lock, pause, v.v.

Ví dụ: giám sát lock

Trong VisualVM hoặc JMC bạn có thể thấy rằng:

  • Luồng “A” bị chặn trên đối tượng X.
  • Luồng “B” giữ đối tượng X nhưng đang chờ đối tượng Y.
  • Luồng “C” giữ đối tượng Y nhưng đang chờ đối tượng X.

Đây là vòng khóa lẫn nhau (deadlock) kinh điển.

Cách sử dụng các công cụ này trong thực tế?

  • Chạy ứng dụng với flag -XX:+FlightRecorder (hoặc chỉ cần dùng JDK 11+).
  • Mở JMC, kết nối tới tiến trình, bắt đầu ghi (start recording).
  • Phân tích các “điểm nóng”, những lock kéo dài và mức cạnh tranh giữa các luồng.

3. Logging và tracing

Trong các chương trình đa luồng, gỡ lỗi “ước chừng” sẽ rất đau đầu. Hãy log việc vào/ra các vùng tới hạn (synchronized-khối), thao tác với biến dùng chung, các lần chờ và đánh thức luồng — như vậy bạn sẽ biết ai đã chiếm hoặc nhả tài nguyên và khi nào.

Log như thế nào?

  • Sử dụng các công cụ tiêu chuẩn: java.util.logging, SLF4J, Log4j.
  • Log tên luồng: Thread.currentThread().getName().
  • Log thời gian và ID của luồng.
  • Log sự kiện chiếm/giải phóng lock.

Ví dụ logging

synchronized(lock) {
    System.out.println(Thread.currentThread().getName() + " đã giữ lock");
    // vùng tới hạn
    System.out.println(Thread.currentThread().getName() + " thoát khỏi lock");
}

Sử dụng tên luồng

Hãy đặt tên có ý nghĩa cho các luồng!

Thread t = new Thread(runnable, "MyWorker-1");

Ví dụ tracing bằng logger

import java.util.logging.Logger;

public class Example {
    private static final Logger logger = Logger.getLogger(Example.class.getName());

    public void doWork() {
        logger.info(Thread.currentThread().getName() + " bắt đầu làm việc");
        synchronized (this) {
            logger.info(Thread.currentThread().getName() + " đã vào synchronized");
            // ...
        }
        logger.info(Thread.currentThread().getName() + " đã kết thúc công việc");
    }
}

4. Các thực hành tốt nhất cho chẩn đoán

Giảm thiểu phạm vi lock

Giữ lock trong thời gian ngắn nhất có thể.

Ví dụ không tốt:

synchronized(lock) {
    // I/O kéo dài
    // tính toán phức tạp
    // truy cập DB
    // ... và chỉ sau đó mới thao tác với dữ liệu dùng chung
}

Ví dụ tốt:

// bên ngoài synchronized: I/O kéo dài, tính toán

synchronized(lock) {
    // chỉ thao tác với dữ liệu dùng chung
}

Sử dụng tên luồng

Tên luồng có ý nghĩa giúp tiết kiệm thời gian khi phân tích dump và log.

Viết test cho đa luồng

Dùng JUnit + CountDownLatch để mô phỏng các kịch bản cạnh tranh.

CountDownLatch latch = new CountDownLatch(2);
Runnable task = () -> {
    // ...
    latch.countDown();
};
new Thread(task, "Worker-1").start();
new Thread(task, "Worker-2").start();
latch.await(); // chờ cả hai luồng hoàn thành

Sử dụng try-finally cho ReentrantLock

Lock lock = new ReentrantLock();
lock.lock();
try {
    // vùng tới hạn
} finally {
    lock.unlock();
}

Như vậy bạn sẽ không quên giải phóng lock ngay cả khi có exception. Để tránh vòng khóa lẫn nhau, hãy dùng tryLock() với timeout.

Ghi chú rõ vì sao cần đồng bộ hóa

Các ghi chú “Ở đây cần synchronized vì…” sẽ giúp bạn hiểu lại ý đồ sau một thời gian.

5. Thực hành: phân tích deadlock trong chương trình thử nghiệm

Ví dụ mã có deadlock

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

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1: đã giữ lockA");
                try { Thread.sleep(100); } catch (InterruptedException ignored) {}
                synchronized (lockB) {
                    System.out.println("Thread-1: đã giữ lockB");
                }
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2: đã giữ lockB");
                try { Thread.sleep(100); } catch (InterruptedException ignored) {}
                synchronized (lockA) {
                    System.out.println("Thread-2: đã giữ lockA");
                }
            }
        }, "Thread-2");

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

Cách bắt deadlock

  1. Chạy chương trình — nó sẽ treo.
  2. Lấy thread dump (jstack hoặc qua VisualVM).
  3. Tìm “Thread-1” và “Thread-2” — bạn sẽ thấy mỗi luồng giữ một lock và chờ lock còn lại.
  4. Cuối dump sẽ có mục “Found one Java-level deadlock”.

Cách khắc phục

  • Luôn lấy lock theo cùng một thứ tự.
  • Dùng ReentrantLock với tryLock() và timeout: nếu không lấy được tất cả các lock — hãy nhả ra và thử lại.

6. Lỗi điển hình khi chẩn đoán chương trình đa luồng

Lỗi số 1: Không biết cách đọc thread dump. Người mới thường sợ dump: “Những stack trace và trạng thái kỳ lạ này là gì?” Thực ra chỉ cần biết các trạng thái chính và tìm BLOCKED/WAITING là đã giúp đơn giản hóa việc phân tích.

Lỗi số 2: Bỏ qua tên luồng. Không có tên có ý nghĩa, phân tích dump giống như tìm kim trong đống cỏ khô. Đừng lười đặt tên!

Lỗi số 3: Khối synchronized quá lớn. Nếu bạn đồng bộ hóa những đoạn code lớn, các luồng sẽ thường xuyên chặn nhau — điều này thể hiện qua các BLOCKED xuất hiện thường xuyên trong dump.

Lỗi số 4: Nhầm lẫn giữa RUNNABLE và luồng thực sự đang chạy. RUNNABLE không phải lúc nào cũng “chạy” trên CPU. Bộ lập lịch JVM tự quyết định chạy ai.

Lỗi số 5: Không dùng công cụ giám sát. Nhiều người không biết về VisualVM, JMC, Flight Recorder và khổ sở với println. Hãy dùng công cụ — chúng giúp cuộc sống dễ dàng hơn nhiều.

Lỗi số 6: Không log các thao tác quan trọng. Không có log, gần như không thể biết ai chiếm/nhả lock khi nào.

Lỗi số 7: Cố bắt race condition bằng “mắt thường”. Race không phải lúc nào cũng xuất hiện ngay — hãy dùng test với CountDownLatch, cố tình tạo cạnh tranh qua Thread.yield() và phân tích trạng thái biến dùng chung.

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