Skip to content

Java Concurrency Deep Dive

By Nhân Nguyễn on Apr 19, 2026

Java Concurrency Deep Dive

Chào bạn! Nếu bạn đang chuẩn bị cho một vị trí Senior Java Developer hoặc Tech Lead, có lẽ chẳng có chủ đề nào “nóng” và xuất hiện dày đặc trong phỏng vấn như Java Concurrency. Nó không đơn thuần là việc bạn có thể viết synchronized hay tạo Thread; đó là câu chuyện về cách bạn thiết kế một hệ thống tài chính không bị mất tiền, cách bạn tối ưu một API đang chậm như rùa, và cách bạn ngăn cả một cụm microservices sụp đổ chỉ vì một luồng bị kẹt.

Trong bài viết này, tôi sẽ cùng bạn đi qua một case study thực tế từ một ngân hàng, mổ xẻ cơ chế hoạt động bên trong, đưa ra các giải pháp “chuẩn production”, và cuối cùng là một khung trả lời phỏng vấn để bạn tự tin vượt qua vòng technical. Hãy bắt đầu!


1. Khi Khách Hàng Mất Tiền: Bài Toán “Duplicate Transaction”

Hãy tưởng tượng bạn là kỹ sư backend tại X Bank. Một buổi sáng đẹp trời ngày 15 tháng 3, hệ thống báo động: một khách hàng đã bị trừ tiền hai lần cho cùng một giao dịch chuyển khoản 50 triệu đồng. Lỗi nghiêm trọng nhất trong lĩnh vực tài chính đã xảy ra.

Đội ngũ kỹ thuật lập tức trace log và phát hiện một kịch bản kinh điển:

10:23:45.001 - Thread-A: BEGIN transfer accountId=12345, amount=50000000
10:23:45.002 - Thread-B: BEGIN transfer accountId=12345, amount=50000000  ← Mobile app tự động retry do mạng lag
10:23:45.010 - Thread-A: SELECT balance = 200,000,000
10:23:45.011 - Thread-B: SELECT balance = 200,000,000  ← Cả hai cùng đọc số dư trước khi ai kịp ghi
10:23:45.050 - Thread-A: UPDATE balance = 150,000,000 → COMMIT
10:23:45.051 - Thread-B: UPDATE balance = 150,000,000 → COMMIT  ← MẤT 50 TRIỆU!

Trước khi đi sâu vào giải pháp, hãy tự hỏi bản thân ba câu hỏi then chốt:

  1. Đây là Race Condition hay Deadlock? Sự khác biệt cốt lõi là gì?
  2. Nếu chỉ dùng synchronized trong Java, vấn đề có được giải quyết không?
  3. Nếu hệ thống chạy trên 3 instance song song (horizontal scaling), synchronized còn hiệu quả không?

(Gợi ý: Đáp án nằm ở phần 3.2. Nhưng hãy thử tự trả lời trước khi lướt xuống nhé).


2. Mental Model: Điều Gì Thực Sự Xảy Ra Bên Dưới?

Để xử lý concurrency một cách bài bản, bạn cần một mô hình tư duy đúng về cách JVM và OS tương tác.

2.1. Thread Không Phải Là “Đơn Vị Công Việc”

Thread thực chất là đơn vị lập lịch của hệ điều hành (OS Scheduler). Vấn đề nảy sinh từ cách bộ nhớ được chia sẻ trong một tiến trình Java:

JVM Process
├── Heap (Shared Memory — NGUỒN CƠN CỦA MỌI VẤN ĐỀ)
│   ├── Object instances (ví dụ: Account object)
│   └── Static fields
└── Per-Thread (Private Memory — AN TOÀN MẶC ĐỊNH)
    ├── Stack (Local variables, method calls)
    ├── Program Counter
    └── Native Stack

Phép so sánh thực tế: Heap giống như két sắt chung của ngân hàng. Stack giống như ví tiền riêng trong túi áo mỗi nhân viên. Race Condition xảy ra khi hai nhân viên cùng mở két, cùng nhìn thấy 10 tỷ trong đó, và cùng rút 5 tỷ mà không hề báo cho nhau biết.

2.2. Race Condition vs. Deadlock: Kẻ Cắp và Kẻ Cướp

Đây là hai khái niệm dễ gây nhầm lẫn nhất. Hãy phân biệt chúng qua bảng sau:

Tiêu chíRace Condition (Kẻ Cắp Vô Hình)Deadlock (Kẻ Cướp Công Khai)
Định nghĩaNhiều luồng cùng truy cập dữ liệu dùng chung, kết quả phụ thuộc vào may rủi (thứ tự chạy của CPU).Các luồng nắm giữ tài nguyên và chờ đợi lẫn nhau mãi mãi.
Hậu quảData Corruption (Sai số dư, double charge). Rất khó phát hiện.System Freeze (Ứng dụng treo cứng, timeout).
Cách phát hiệnBug ngắt quãng, khó tái hiện. Cần review code kỹ hoặc dùng static analysis.Thread Dump hiển thị trạng thái BLOCKED hoặc WAITING.
Ví dụ Banking2 luồng cùng đọc balance = 100, cùng trừ 20 -> ghi 80 (mất 20).Luồng A giữ khóa tài khoản X, chờ khóa tài khoản Y. Luồng B giữ khóa Y, chờ khóa X.

Điều kiện để Race Condition xảy ra:

  1. Shared Mutable Data: Có dữ liệu dùng chung có thể thay đổi.
  2. Multiple Threads: Có từ 2 luồng trở lên.
  3. At Least One Writer: Ít nhất một luồng thực hiện thao tác ghi.

Mẹo phỏng vấn: Muốn hết Race Condition, chỉ cần phá vỡ một trong ba điều kiện trên.

2.3. Thread Pool: Đừng Chỉ Dùng Executors Rồi Bỏ Đấy

ThreadPoolExecutor là trái tim của bất kỳ ứng dụng Java high-performance nào. Cơ chế hoạt động của nó không hề đơn giản:

Task Queue (Hàng đợi)                Worker Threads
        │                                    │
        ▼                                    ▼
[ New Task ] ─────► [ Core Threads đang bận? ] ──────► [ Queue còn chỗ? ]
        │                                    │                         │
        │                                    ▼                         ▼
        │                         [Đợi trong Queue]       [Tạo thêm Thread tới maxPoolSize]
        │                                                                  │
        └─────────────────────────────── Nếu vượt quá Max + Queue đầy ────► [ Rejected Handler ]

4 Tham Số Vàng Của ThreadPoolExecutor:

  • corePoolSize: Số lính “thường trực” luôn sẵn sàng.
  • maxPoolSize: Số lính tối đa có thể huy động khi quá tải.
  • keepAliveTime: Lính “tạm thời” (vượt quá core) nhàn rỗi bao lâu thì bị cho về vườn.
  • workQueue: Loại hàng đợi sử dụng. Đây là nơi sinh ra cạm bẫy OOM.

2.4. CompletableFuture: Từ Đồng Bộ Sang Phản Ứng (Reactive)

CompletableFuture là “vũ khí bí mật” cho lập trình bất đồng bộ trong Java. Nó biến việc gọi nhiều API tuần tự (tốn thời gian tổng) thành song song (chỉ tốn thời gian của API chậm nhất).

Hãy nhớ sự khác biệt sống còn này:

  • thenApply(): Chạy trên cùng thread với tác vụ trước đó (nếu tác vụ trước đã hoàn thành).
  • thenApplyAsync(): Luôn chạy trên một thread khác từ pool (tốn chi phí chuyển đổi ngữ cảnh nhưng không block caller).

3. Production-Grade Implementation: Viết Code Như Một Kỹ Sư Trưởng

Sau đây là những đoạn code thực chiến bạn nên đưa vào dự án của mình.

3.1. Cấu Hình Thread Pool Chuẩn Cho Banking Service

Anti-pattern: ExecutorService pool = Executors.newFixedThreadPool(10);   Tại sao sai? Hàng đợi LinkedBlockingQueue không giới hạn của nó sẽ nuốt chửng bộ nhớ khi hệ thống quá tải, dẫn đến OutOfMemoryError và ứng dụng chết đứng.

@Configuration
public class ThreadPoolConfig {

    @Bean("transferExecutor")
    public Executor transferExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // IO-bound task: Rule of thumb = N_CPU * 2
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        
        // Hàng đợi CÓ GIỚI HẠN để sớm phát hiện bottleneck
        executor.setQueueCapacity(200);
        
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("transfer-"); // Cực kỳ quan trọng khi debug log
        
        // Backpressure: Khi queue đầy, chính HTTP thread sẽ chạy task -> Làm chậm input
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // Chờ các giao dịch đang dang dở hoàn thành trước khi shutdown server
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        
        executor.initialize();
        return executor;
    }
}

3.2. Giải Pháp Chống “Duplicate Transaction” Bằng Database Lock

Synchronized không thể cứu bạn trong môi trường multi-instance (microservices). Giải pháp đúng đắn là sử dụng Pessimistic Lock ở tầng Database hoặc Distributed Lock (Redis). Đây là cách triển khai với Spring Data JPA:

@Service
@Slf4j
public class TransferService {

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public TransferResult transfer(TransferRequest request) {
        
        // 1. Idempotency Check TRƯỚC để giảm áp lực lock (Bài học từ chủ đề #33)
        Optional<Transaction> existing = transactionRepository
                .findByIdempotencyKey(request.getIdempotencyKey());
        if (existing.isPresent()) {
            return TransferResult.fromExisting(existing.get());
        }

        // 2. Sắp xếp ID để lock theo thứ tự cố định -> Chống Deadlock
        Long firstId = Math.min(request.getFromAccountId(), request.getToAccountId());
        Long secondId = Math.max(request.getFromAccountId(), request.getToAccountId());

        // 3. SELECT ... FOR UPDATE (Pessimistic Lock)
        Account fromAccount = accountRepository.findByIdWithLock(firstId)
                .orElseThrow(() -> new AccountNotFoundException(firstId));
        Account toAccount = accountRepository.findByIdWithLock(secondId)
                .orElseThrow(() -> new AccountNotFoundException(secondId));

        // 4. Validate business logic
        if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
            throw new InsufficientBalanceException();
        }

        // 5. Thực hiện ghi
        fromAccount.debit(request.getAmount());
        toAccount.credit(request.getAmount());
        
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
        
        // 6. Lưu transaction log
        Transaction tx = Transaction.builder()
                .idempotencyKey(request.getIdempotencyKey())
                .status(TransactionStatus.COMPLETED)
                .build();
        transactionRepository.save(tx);

        return TransferResult.success(tx);
    }
}

// Repository Interface
public interface AccountRepository extends JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE) 
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdWithLock(@Param("id") Long id);
}

3.3. Pattern Tăng Tốc API: Parallel Enrichment

Thay vì gọi CustomerService (300ms) -> TransactionService (200ms) -> CreditService (150ms) = 650ms, hãy chạy song song. Thời gian phản hồi giảm xuống chỉ còn 300ms.

@Service
public class CustomerDashboardService {

    private final Executor dashboardExecutor;

    public CustomerDashboard getDashboard(Long customerId) {
        // Gọi 3 service SONG SONG
        CompletableFuture<CustomerInfo> customerFuture = CompletableFuture
                .supplyAsync(() -> customerService.getCustomer(customerId), dashboardExecutor)
                .exceptionally(ex ->
                    log.error("Customer service down", ex);
                    return CustomerInfo.empty(); // Graceful degradation
                });

        CompletableFuture<List<Transaction>> txFuture = CompletableFuture
                .supplyAsync(() -> transactionService.getRecentTransactions(customerId), dashboardExecutor)
                .orTimeout(2, TimeUnit.SECONDS) // Không chờ đợi vô hạn
                .exceptionally(ex -> Collections.emptyList());

        // Đợi tất cả hoàn thành (kể cả khi một vài tác vụ lỗi)
        CompletableFuture.allOf(customerFuture, txFuture).join();

        return CustomerDashboard.builder()
                .customer(customerFuture.join())
                .transactions(txFuture.join())
                .build();
    }
}

4. Trade-offs và Anti-patterns: Những Cái Bẫy Trên Đường

Tình huốngGiải pháp tối ưuLý do Trade-off
Single JVM, ít conflictsynchronized hoặc ReentrantLockĐơn giản, overhead rất thấp.
Đọc nhiều, ghi ítReadWriteLockCho phép nhiều Reader chạy song song, chỉ block Writer.
Counter đơn giảnAtomicInteger / AtomicLongDùng CAS (Compare-And-Swap) của CPU, nhanh hơn khóa (lock-free).
Multi-JVM (Microservices)Redis Distributed Lock / DB Locksynchronized chỉ có hiệu lực trong phạm vi 1 máy ảo JVM.
Workflow phức tạp, AsyncCompletableFutureQuản lý pipeline và lỗi tuyệt vời.

Top 3 Anti-patterns Gây Chết Người

1.  Gọi .get() hoặc .join() trong HTTP Request Thread:

    // ❌ SAI: Block thread của Servlet Container -> Giết chết Throughput
    @GetMapping("/data")
    public Data getData() {
        return future.get(); 
    }
    
    // ✅ ĐÚNG: Trả về CompletableFuture cho Spring MVC tự xử lý Async
    @GetMapping("/data")
    public CompletableFuture<Data> getData() {
        return service.getDataAsync();
    }

2.  Shared Mutable State Trong Stateless Bean:

    // ❌ SAI: Spring Bean là Singleton -> Biến instance là shared state
    @Service
    public class Calculator {
        private BigDecimal result; // Race Condition!
    }
    
    // ✅ ĐÚNG: Luôn dùng local variables
    @Service
    public class Calculator {
        public BigDecimal calc() {
            BigDecimal result = BigDecimal.ZERO; // Stack memory -> Thread-safe
            return result;
        }
    }

3.  Quên .remove() Đối Với ThreadLocal:     Khi sử dụng Thread Pool, các Thread được tái sử dụng. Nếu bạn không remove() dữ liệu trong ThreadLocal (ví dụ: UserContext), Request B có thể vô tình đọc được dữ liệu nhạy cảm của Request A trước đó. Luôn dùng try-finally để dọn dẹp.


5. Khung Trả Lời Phỏng Vấn (Interview Framework)

Khi được hỏi về Concurrency trong một buổi phỏng vấn Senior, đừng chỉ trả lời định nghĩa. Hãy dùng cấu trúc Tầng 1 - Tầng 2 - Tầng 3 để thể hiện chiều sâu kiến thức.

Tầng 1: Bề Nổi (Junior có thể biết)

Hỏi: synchronizedReentrantLock khác nhau thế nào?   Trả lời: synchronized do JVM quản lý, dễ dùng nhưng không có timeout và không thể ngắt quãng. ReentrantLock linh hoạt hơn với tryLock(timeout)lockInterruptibly(). Trong hệ thống tài chính, tôi ưu tiên ReentrantLock để có thể thiết lập timeout tránh deadlock vô hạn.

Tầng 2: Đào Sâu (Phân biệt Mid vs Senior)

Hỏi: Tại sao volatile không đủ để bảo vệ count++?   Trả lời: volatile chỉ giải quyết vấn đề Visibility (nhìn thấy giá trị mới nhất từ Main Memory). Nhưng count++ gồm 3 bước không nguyên tử (Read-Modify-Write). Nếu Thread A và B cùng đọc giá trị 5, cùng tăng lên 6 và ghi vào, kết quả cuối cùng là 6 (mất 1 lần tăng). Để giải quyết, phải dùng AtomicInteger với cơ chế CAS (lock-free nhưng nguyên tử).

Tầng 3: Kiến Trúc (Senior/Lead)

Hỏi: Thiết kế hệ thống xử lý 10,000 giao dịch thanh toán đồng thời, yêu cầu không mất tiền, không trùng lặp, P99 latency < 2s.   Cách tiếp cận: 1.  Clarify: Có multi-region không? External Gateway timeout bao lâu? 2.  Bottleneck: Không phải CPU mà là Database Connection PoolExternal API Rate Limit. 3.  Design:     - Ingress: Nginx -> API Gateway (Spring WebFlux hoặc Servlet Async).     - Queue: Kafka/RabbitMQ để tách biệt nhận request và xử lý.     - Processing: Worker Pool xử lý, kiểm tra Idempotency Key (Redis/Database).     - Locking: Sử dụng Pessimistic Lock ở DB cho tài khoản giá trị cao, Optimistic Lock (version) cho tài khoản ít bị truy cập đồng thời.     - Circuit Breaker: Dùng Resilience4j cho external gateway.


6. Checklist Tự Đánh Giá Trình Độ

Bạn đã thực sự thành thạo Java Concurrency chưa? Hãy thử giải thích 5 điều sau mà không cần nhìn tài liệu:

  • 1. Phân biệt Race Condition và Deadlock: Cho ví dụ trong thực tế và cách fix.
  • 2. Cơ chế Thread Pool: Vẽ được flow Core -> Queue -> Max -> Reject và giải thích tại sao Executors.newCachedThreadPool() dễ gây OOM.
  • 3. volatile vs synchronized vs Atomic: Giải thích sự khác biệt giữa Visibility và Atomicity.
  • 4. CompletableFuture Pipeline: Viết code gọi 3 service song song, có timeout riêng cho từng service, một service lỗi không làm fail cả request.
  • 5. Banking Concurrency Solution: Trình bày lý do synchronized thất bại trên Cloud, và cách phối hợp Idempotency Key + Pessimistic DB Lock.

Lời Kết

Java Concurrency không đáng sợ nếu bạn hiểu rõ bản chất của vấn đề là Shared Mutable State. Hãy luôn tự hỏi: “Nếu có 1000 người cùng nhấn nút này cùng lúc, điều gì sẽ xảy ra?“. Chúc bạn luôn viết ra những dòng code thread-safe và vững vàng trong mọi kỳ phỏng vấn khó nhằn!

Hãy kết nối

Nếu bạn quan tâm tới việc hợp tác, có câu hỏi về bài viết, hay chỉ đơn giản muốn chuyện trò về backend — cứ ping mình nhé.