Chuyển tới nội dung chính

Giới thiệu Multithreading

Mục tiêu bài học

Sau bài này, bạn sẽ:

  • Hiểu được khái niệm Process, Thread và sự khác biệt giữa chúng
  • Nắm được vòng đời (lifecycle) của một Thread trong Java
  • Phân biệt được Concurrency và Parallelism
  • Nhận biết được Race Condition và tác hại của nó
  • Hiểu được các thách thức cơ bản khi lập trình đa luồng

Process vs Thread

Process

Process (tiến trình) là một chương trình đang chạy, có không gian bộ nhớ riêng biệt:

  • Mỗi process có vùng nhớ heap, stack, code segment riêng
  • Các process độc lập với nhau, không chia sẻ bộ nhớ
  • Chi phí tạo và chuyển đổi giữa các process cao
  • Giao tiếp giữa các process phức tạp (IPC - Inter-Process Communication)

Thread

Thread (luồng) là đơn vị thực thi nhỏ nhất trong một process:

  • Nhiều threads trong cùng process chia sẻ heap memory
  • Mỗi thread có stack riêng
  • Chi phí tạo và chuyển đổi giữa threads thấp hơn process
  • Giao tiếp giữa threads dễ dàng (shared memory)
Process
├── Thread 1 (có stack riêng)
├── Thread 2 (có stack riêng)
└── Thread 3 (có stack riêng)
└── Tất cả chia sẻ: Heap, Code, Data
mẹo

Một Java application luôn có ít nhất 1 thread: main thread (thực thi method main()).

Tại sao cần Multithreading?

1. Tăng hiệu suất (Performance)

  • Parallelism: Thực thi đồng thời trên nhiều CPU cores
  • Concurrency: Tận dụng thời gian chờ I/O (file, network, database)

2. Responsive UI

  • UI thread xử lý giao diện, worker threads xử lý tác vụ nặng
  • Tránh ứng dụng bị "đơ" khi xử lý công việc lâu

3. Resource Sharing

  • Threads chia sẻ bộ nhớ, giảm overhead so với processes

4. Scalability

  • Tận dụng tốt hệ thống multi-core hiện đại
Amdahl's Law

Amdahl's Law cho biết giới hạn của việc tăng tốc khi tăng số cores: Speedup = 1 / (S + P/N) trong đó S là phần tuần tự (serial), P là phần song song (parallel), N là số cores. Ngay cả với vô số cores, phần tuần tự vẫn giới hạn tốc độ tối đa.

Concurrency vs Parallelism

Khái niệmÝ nghĩaĐiều kiện
ConcurrencyNhiều tasks tiến triển đồng thời (interleaved - xen kẽ)Có thể trên 1 core (context switching - chuyển đổi ngữ cảnh)
ParallelismNhiều tasks thực thi đồng thời thực sựCần nhiều cores

Ví dụ:

  • Concurrency: 1 người nấu ăn vừa luộc trứng, vừa chiên thịt (chuyển đổi liên tục)
  • Parallelism: 2 người cùng nấu ăn, mỗi người một món
// Concurrency: Một CPU xử lý nhiều threads
Thread 1: ████░░░░████░░░░
Thread 2: ░░░░████░░░░████
Time: ────────────────→

// Parallelism: Nhiều CPUs xử lý cùng lúc
CPU 1 - Thread 1: ████████████████
CPU 2 - Thread 2: ████████████████
Time: ────────────────→

Thread Lifecycle

Một thread trong Java trải qua các trạng thái sau:

        NEW

│ start() called

RUNNABLE ←─────────────────────────────────┐
│ │
│ waiting for monitor lock │ lock acquired, notify(), notifyAll()
↓ │
BLOCKED ──────────────────────────────────┘
│ │
│ wait(), join(), LockSupport.park() │ notify(), notifyAll()
↓ │
WAITING ─────────────────────────────────→┘
│ │
│ sleep(), wait(timeout), join(timeout)│ timeout expired
↓ │
TIMED_WAITING ─────────────────────────────→──┘

│ run() completes or throws exception

TERMINATED

Chú thích các chuyển đổi trạng thái:

  • NEW → RUNNABLE: Gọi start() khiến thread sẵn sàng chạy
  • RUNNABLE → BLOCKED: Thread chờ synchronized lock (preemptive scheduling - lập lịch ưu tiên)
  • RUNNABLE → WAITING: Gọi wait(), join(), LockSupport.park() - chờ vô thời hạn
  • RUNNABLE → TIMED_WAITING: Gọi sleep(), wait(timeout), join(timeout) - chờ có giới hạn
  • WAITING/TIMED_WAITING → RUNNABLE: notify(), notifyAll(), hoặc hết timeout
  • RUNNABLE → TERMINATED: Method run() kết thúc hoặc ném exception

Các trạng thái chi tiết

1. NEW

Thread được tạo nhưng chưa gọi start()

Thread t = new Thread(() -> System.out.println("Hello"));
// t đang ở trạng thái NEW

2. RUNNABLE

Thread đã gọi start(), đang chạy hoặc sẵn sàng chạy (chờ CPU)

t.start(); // t chuyển sang RUNNABLE
cảnh báo

Gọi start() chỉ làm thread sẵn sàng, không đảm bảo thread chạy ngay lập tức. Thread scheduler của JVM sử dụng time-slicing (chia lát thời gian) để quyết định khi nào thread được CPU.

3. BLOCKED

Thread đang chờ để có được monitor lock (synchronized)

synchronized (obj) {
// Thread khác đang giữ lock của obj
// Thread này sẽ ở trạng thái BLOCKED
}

4. WAITING

Thread chờ vô thời hạn cho đến khi thread khác thông báo

// Cách 1: Object.wait() (cần gọi trong synchronized block)
synchronized (obj) {
obj.wait(); // WAITING cho đến khi obj.notify()
}

// Cách 2: Thread.join()
t.join(); // WAITING cho đến khi thread t kết thúc

5. TIMED_WAITING

Thread chờ trong khoảng thời gian nhất định

Thread.sleep(1000);           // TIMED_WAITING 1 giây
obj.wait(5000); // TIMED_WAITING tối đa 5 giây
t.join(2000); // TIMED_WAITING tối đa 2 giây

6. TERMINATED

Thread đã hoàn thành method run()

// Sau khi run() kết thúc, không thể start() lại thread

Race Conditions

Race condition xảy ra khi nhiều threads truy cập và thay đổi cùng một dữ liệu đồng thời, dẫn đến kết quả không mong muốn.

Vấn đề

public class Counter {
private int count = 0;

public void increment() {
count++; // KHÔNG thread-safe!
}

public int getCount() {
return count;
}
}
Tại sao count++ không thread-safe?

count++ thực chất là 3 bước:

  1. Đọc giá trị hiện tại: temp = count
  2. Tăng giá trị: temp = temp + 1
  3. Ghi lại: count = temp

Nếu 2 threads thực hiện đồng thời, có thể xảy ra:

count = 0
Thread 1: đọc count (0) → tính toán (1) → ghi (1)
Thread 2: đọc count (0) → tính toán (1) → ghi (1)
Kết quả: count = 1 (sai! đáng ra phải là 2)

Phân tích JVM Bytecode của count++

Để hiểu sâu hơn, hãy xem count++ được biên dịch thành bytecode như thế nào:

getfield count    // Bước 1: Đọc giá trị count từ heap vào stack
iconst_1 // Bước 2: Đẩy hằng số 1 vào stack
iadd // Bước 3: Cộng hai giá trị trên stack
putfield count // Bước 4: Ghi kết quả từ stack về heap

Vấn đề: Giữa bất kỳ 2 bytecode nào, thread scheduler có thể chuyển sang thread khác (context switch). Ví dụ:

Thread 1: getfield count (đọc 0)
Thread 1: iconst_1
[CONTEXT SWITCH]
Thread 2: getfield count (đọc 0)
Thread 2: iconst_1
Thread 2: iadd (tính 0+1=1)
Thread 2: putfield count (ghi 1)
[CONTEXT SWITCH]
Thread 1: iadd (tính 0+1=1)
Thread 1: putfield count (ghi 1)
Kết quả: count = 1 (mất 1 lần tăng!)

Đây chính là lý do tại cấp độ JVM và phần cứng, count++ không phải là atomic operation (không nguyên tử).

Ví dụ đầy đủ: Race Condition

public class RaceConditionDemo {
private static int counter = 0;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter++; // Race condition!
}
});

Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter++; // Race condition!
}
});

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

t1.join(); // Chờ t1 kết thúc
t2.join(); // Chờ t2 kết thúc

System.out.println("Counter: " + counter);
// Kết quả không ổn định: có thể là 15234, 18992, 20000...
// Đáng ra phải là 20000!
}
}

Kết quả chạy nhiều lần

Run 1: Counter: 17843
Run 2: Counter: 19234
Run 3: Counter: 20000
Run 4: Counter: 18756
Run 5: Counter: 19999
cảnh báo

Kết quả không ổn định! Đây là dấu hiệu của race condition. Mỗi lần chạy có thể cho kết quả khác nhau.

Ba thuộc tính của Concurrent Correctness

Để viết chương trình đa luồng chính xác, bạn cần đảm bảo 3 thuộc tính cơ bản. Hiểu 3 thuộc tính này là nền tảng để giải quyết MỌI vấn đề concurrency.

1. Atomicity (Tính nguyên tử)

Định nghĩa: Một operation được thực hiện hoàn toàn hoặc không thực hiện gì cả, không có trạng thái trung gian.

Vấn đề:

count++;  // KHÔNG atomic! Gồm 3 bước: read, modify, write

Như đã phân tích ở trên, count++ có 4 bytecode instructions. Nếu thread bị preempt (bị gián đoạn) giữa các instruction, kết quả sẽ sai.

Ví dụ atomic operations:

  • int x = 10; - Gán giá trị (atomic trên primitive types ≤ 32-bit)
  • AtomicInteger.incrementAndGet() - Atomic increment
  • synchronized block - Toàn bộ block là atomic

Ví dụ NON-atomic operations:

  • count++, count--, count += 5
  • long x = 123L; trên JVM 32-bit (cần 2 lần ghi)
  • Check-then-act: if (x == 0) x = 1;

2. Visibility (Tính khả kiến)

Định nghĩa: Khi thread A thay đổi một biến, thread B phải thấy được thay đổi đó.

Vấn đề: Mỗi thread có CPU cache riêng. Thay đổi của thread A có thể chỉ nằm trong cache, chưa được flush về main memory.

public class VisibilityProblem {
private static boolean stop = false; // ⚠️ Không có visibility guarantee!

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
int count = 0;
while (!stop) { // Thread có thể cache giá trị stop = false
count++;
}
System.out.println("Stopped at: " + count);
});

worker.start();
Thread.sleep(1000);
stop = true; // Thay đổi này có thể KHÔNG visible cho worker thread!
System.out.println("Main set stop = true");
}
}
// Worker thread có thể chạy mãi mãi vì nó đọc cached value của stop!

Giải pháp:

  • volatile boolean stop - Đảm bảo read/write trực tiếp từ main memory
  • synchronized - Flush cache khi vào/ra block
  • AtomicBoolean - Visibility + atomicity

3. Ordering (Tính thứ tự)

Định nghĩa: Các instructions được thực thi theo thứ tự đúng như trong code, không bị hoán đổi (reorder).

Vấn đề: Compiler và CPU có thể reorder instructions để tối ưu hiệu suất, miễn là không thay đổi kết quả trong single-threaded context.

public class ReorderingProblem {
private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1; // Instruction 1
x = b; // Instruction 2
});

Thread t2 = new Thread(() -> {
b = 1; // Instruction 3
y = a; // Instruction 4
});

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

System.out.println("x=" + x + ", y=" + y);
}
}
// Kết quả có thể là: (0,0), (0,1), (1,0), hoặc (1,1)
// Nếu CPU reorder, có thể x=0 và y=0 xảy ra!

Giải pháp:

  • volatile - Ngăn reordering quanh volatile variables
  • synchronized - Thiết lập happens-before relationship
  • final fields - Đảm bảo được khởi tạo trước khi object visible

Tóm tắt 3 thuộc tính

Thuộc tínhVấn đềGiải pháp chính
AtomicityOperation bị gián đoạn giữa chừngsynchronized, AtomicXxx, locks
VisibilityThread không thấy thay đổi của thread khácvolatile, synchronized
OrderingInstructions bị reorder bởi compiler/CPUvolatile, synchronized, final
mẹo

Trong các bài tiếp theo (Synchronization, Volatile, Locks), chúng ta sẽ học cách giải quyết từng vấn đề này. Hãy luôn nhớ 3 thuộc tính này khi debug lỗi concurrency!

Tại sao cần học Multithreading?

1. Ứng dụng hiện đại cần concurrency

  • Web servers: xử lý nhiều requests đồng thời
  • Database systems: nhiều queries song song
  • Desktop apps: responsive UI
  • Games: render, AI, physics chạy song song

2. Hardware hiện đại là multi-core

  • CPU hiện nay có 4, 8, 16+ cores
  • Nếu chỉ dùng 1 thread → lãng phí tài nguyên

3. Cải thiện hiệu suất

// Tuần tự: 10 giây
processFile1(); // 5s
processFile2(); // 5s

// Song song: 5 giây
Thread t1 = new Thread(() -> processFile1());
Thread t2 = new Thread(() -> processFile2());
t1.start(); t2.start();

4. Các framework/library yêu cầu kiến thức multithreading

  • Spring Boot: request handling
  • Android: AsyncTask, Handler, Coroutines
  • JavaFX: UI thread vs background tasks

Thách thức của Multithreading

Những vấn đề cần đối mặt
  1. Race conditions: Dữ liệu không nhất quán
  2. Deadlocks: Threads chờ nhau mãi mãi
  3. Starvation: Thread không bao giờ được CPU
  4. Livelock: Threads liên tục đổi trạng thái nhưng không tiến triển
  5. Visibility issues: Thread không thấy thay đổi của thread khác
  6. Performance overhead: Context switching, synchronization
  7. Debugging khó: Lỗi không tái hiện được (non-deterministic)
OCP Exam Tips

Thread States:

  • Biết rõ 6 trạng thái: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
  • start() vs run(): Gọi run() trực tiếp KHÔNG tạo thread mới, chỉ chạy method bình thường
  • sleep() KHÔNG giải phóng lock (nếu đang trong synchronized block)

Thread Lifecycle:

  • Gọi start() 2 lần trên cùng thread → IllegalThreadStateException
  • Một thread chỉ có thể start() MỘT LẦN DUY NHẤT
  • Thread TERMINATED không thể restart

Thread Methods:

  • Thread.yield(): Chỉ là hint cho scheduler, có thể bị bỏ qua
  • join(): Thread hiện tại chờ thread khác kết thúc
  • interrupt(): Đánh dấu interrupted flag, không dừng thread ngay lập tức

Concurrency Issues:

  • count++ KHÔNG phải atomic operation
  • Visibility problems: Dùng volatile hoặc synchronized
  • Race condition: Xảy ra khi nhiều threads truy cập shared mutable data mà không đồng bộ

Tóm tắt

Khái niệmGiải thích
ProcessChương trình đang chạy, có bộ nhớ riêng
ThreadĐơn vị thực thi trong process, chia sẻ bộ nhớ
ConcurrencyNhiều tasks tiến triển đồng thời (có thể trên 1 core)
ParallelismNhiều tasks thực thi đồng thời (cần nhiều cores)
Race ConditionKết quả không xác định do nhiều threads truy cập shared data

Bài tập

Bài 1: Thread States

Viết chương trình tạo thread và in ra trạng thái của nó sau mỗi thao tác:

  • Sau khi new Thread()
  • Sau khi start()
  • Trong khi sleep()
  • Sau khi hoàn thành

Bài 2: Race Condition

  1. Chạy RaceConditionDemo ở trên nhiều lần, ghi lại kết quả
  2. Giải thích tại sao kết quả không giống nhau mỗi lần
  3. Dự đoán: Nếu tăng số lần loop lên 100,000 thì sai số sẽ tăng hay giảm?

Bài 3: Concurrency vs Parallelism

Viết chương trình:

  • Tạo 4 threads, mỗi thread đếm từ 1 đến 1,000,000
  • So sánh thời gian chạy trên máy có 2 cores vs 4 cores
  • Có thấy sự khác biệt không? Tại sao?

Next Steps

Trong bài tiếp theo, chúng ta sẽ học cách tạo và quản lý threads với 3 cách:

  1. Extends Thread class
  2. Implements Runnable interface
  3. Implements Callable với Future

Và các methods quan trọng: start(), sleep(), join(), interrupt()...

Đọc thêm

Tài liệu chính thức:

Sách tham khảo:

  • Head First Java (3rd Edition) - Chapter 15: Make a Connection (Threads)
  • Java Concurrency in Practice - Brian Goetz (Chapter 1-2)

Bài học tiếp theo: