Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

câu hỏi phỏng vấn java senior

Tung Bui Thanh (TungDaDev)14 mins read

Java interview.webp

Phỏng vấn vị trí Senior, Staff hoặc Principal Software Engineer ở các công ty công nghệ lớn thì ít khi còn hỏi câu đố thuật toán đơn giản hay cú pháp Stream Java 8 cơ bản. Mấy câu đó chủ yếu kiểm tra trí nhớ của Junior.

Các vòng phỏng vấn cao cấp hay xoáy vào hiểu cách máy tính chạy bên dưới (Mechanical Sympathy), đánh giá rủi ro của hệ thống phân tán, và xử lý sự cố khi tải lên hàng trăm nghìn QPS.

Bài này tổng hợp 5 chủ đề chuyên sâu hay được dùng để phân loại ứng viên.


# false sharing và bí mật cache line 64-byte

# câu hỏi phỏng vấn

Tại sao hai biến volatile hoàn toàn độc lập, do hai luồng trên hai lõi CPU khác nhau truy cập, lại có thể làm throughput của cả hệ thống sụt 80%? Giải quyết thế nào ở tầng kiến trúc?

# giải thích kỹ thuật

RAM không nạp từng byte lên CPU mà nạp theo khối cố định gọi là Cache Line (thường 64 bytes).

Giao thức giữ cache nhất quán ở phần cứng (Hardware Cache Coherence Protocol, ví dụ MESI: Modified, Exclusive, Shared, Invalid) làm việc trên cả Cache Line 64 bytes:

  1. Giả sử valueA và valueB nằm cạnh nhau trên Heap, cùng rơi vào một Cache Line 64-byte.
  2. Core 1 cập nhật valueA. Để giữ nhất quán, Core 1 phải giành quyền sở hữu độc quyền (trạng thái Modified) cho cả Cache Line, tức là bản sao Cache Line đó trên L1/L2 của Core 2 bị vô hiệu (Invalidate).
  3. Core 2 đọc hoặc ghi valueB thì dính Cache Miss, phải chờ kéo Cache Line về lại (từ cache của Core 1 qua L3/interconnect, chậm hơn L1 hàng chục lần). Rồi tới lượt Core 1 bị y như vậy. Cache Line cứ bị giằng qua giằng lại giữa hai core.
  4. Đây là False Sharing (chia sẻ giả): hai biến không chia sẻ dữ liệu logic nào nhưng vẫn đụng nhau ở tầng Cache Line của CPU.

# cách khắc phục

Dùng Cache Line Padding (đệm thêm biến giả để đẩy các biến thật sang Cache Line riêng) hoặc annotation nội bộ của JVM. Không phải biến nào cũng cần làm vậy: chỉ đáng padding cho vài biến bị nhiều thread ghi liên tục (counter, con trỏ head/tail của queue), và nên xác nhận bằng benchmark (JMH) hoặc perf c2c trước khi đệm.

// Kỹ thuật Cache Line Padding thủ công (Kiểu LMAX Disruptor)
public class PaddedAtomicLong {
    // 56 bytes padding trước
    public volatile long p1, p2, p3, p4, p5, p6, p7;
    // Biến thật cần bảo vệ
    public volatile long value = 0L;
    // 56 bytes padding sau để cô lập hoàn toàn biến 'value' vào riêng 1 Cache Line
    public volatile long p8, p9, p10, p11, p12, p13, p14;
}
 
// Hoặc dùng @Contended của HotSpot: Java 8 là sun.misc.Contended; Java 9+ là annotation dưới đây,
// cần --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED khi compile
// và cờ -XX:-RestrictContended khi chạy (không có cờ thì annotation bị bỏ qua với code ngoài JDK)
public class ModernCounter {
    @jdk.internal.vm.annotation.Contended
    public volatile long head = 0L;
 
    @jdk.internal.vm.annotation.Contended
    public volatile long tail = 0L;
}

Một điểm hay bị hỏi vặn: JVM không cam kết giữ thứ tự field theo thứ tự khai báo, nên padding kiểu "khai báo 7 biến trước, 7 biến sau" trong cùng một class có thể không như ý. Vì vậy LMAX Disruptor đặt padding ở các class cha/con (LhsPadding → Value → RhsPadding): HotSpot xếp field của class cha trước field của class con. Bản thân JDK cũng dùng @Contended cho Thread (các field của ThreadLocalRandom), Striped64 (nền của LongAdder) và ConcurrentHashMap.CounterCell.


# bẫy carrier pinning trong virtual threads

# câu hỏi phỏng vấn

Tại sao ứng dụng chuyển sang Java 21 Virtual Threads lại tụt thông lượng mạnh và độ trễ P99 tăng vọt khi đọc ghi CSDL trong khối synchronized?

# giải thích kỹ thuật

Virtual Threads chạy theo kiểu cộng tác (Cooperative Scheduling): hàng triệu Virtual Threads được ánh xạ (mounted) lên một số ít Carrier OS Threads (mặc định bằng số lõi CPU).

Khi một Virtual Thread bị chặn I/O (Blocking I/O), runtime của Java tháo (unmount) nó khỏi Carrier Thread để Carrier Thread phục vụ Virtual Thread khác.

Nhưng có hai trường hợp Java không unmount được Virtual Thread (gọi là Carrier Pinning):

  1. Thao tác chặn I/O nằm trong khối hoặc method synchronized.
  2. Lời gọi hàm nằm trong một native frame (qua JNI).

Hậu quả trên Production: Virtual Thread bị ghim (pinned) vào Carrier OS Thread trong lúc chờ I/O từ CSDL thì Carrier Thread đó bị kẹt hẳn. Chỉ cần vài chục request đồng thời vướng vào khối synchronized là cả nhóm Carrier Threads trong ForkJoinPool tê liệt, và hàng trăm nghìn Virtual Threads khác bị đói CPU (Starvation).

# cách khắc phục

  1. Thay synchronized bằng java.util.concurrent.locks.ReentrantLock (ReentrantLock cho phép Virtual Thread unmount an toàn khi bị chặn).
  2. Chạy ứng dụng với cờ chẩn đoán của JVM để tìm chỗ bị ghim (Java 21–23; từ Java 24 cờ này bị bỏ, dùng sự kiện JFR jdk.VirtualThreadPinned, sự kiện này có từ Java 21):
    -Djdk.tracePinnedThreads=full

NOTE

Từ Java 24 (JEP 491), synchronized không còn ghim virtual thread nữa. Pinning vẫn còn với native frame (JNI, FFM upcall) và khi block trong class initializer. Hệ thống chạy Java 21 LTS thì vẫn nên rà lại và chuyển các đoạn synchronized có I/O sang ReentrantLock.


# hikariCP connection pool starvation và deadlock

# câu hỏi phỏng vấn

Một tác vụ xử lý đơn hàng mở Transaction chính, rồi gọi bất đồng bộ qua CompletableFuture để ghi log kế toán, cũng xin Connection từ cùng một Connection Pool. Tại sao hệ thống chạy ổn ở 50 QPS nhưng deadlock cả hệ thống khi tải chạm 500 QPS?

# giải thích kỹ thuật

Lỗi kinh điển này tên là Parent-Child Connection Pool Deadlock:

  1. Giả sử HikariCP cấu hình tối đa 10 kết nối.
  2. Dưới tải cao, 10 request cha (Parent Request) cùng lúc chiếm hết 10 kết nối trong pool để bắt đầu Transaction.
  3. Vẫn giữ kết nối và chưa commit, mỗi luồng cha submit một tác vụ con (Child Task) sang ThreadPool khác rồi gọi future.join() để chờ kết quả.
  4. Tác vụ con xin thêm 1 kết nối CSDL từ HikariCP, nhưng pool đã cạn (active = 10, idle = 0), nên nó phải đứng chờ (Wait Queue).
  5. Luồng cha không nhả kết nối vì đang chờ tác vụ con xong. Tác vụ con không xong được vì đang chờ kết nối mà luồng cha đang giữ. Cả hệ thống Deadlock cho tới khi hết timeout.

# cách khắc phục

  1. Công thức tính kích thước Pool an toàn: nếu một luồng nghiệp vụ cần tối đa M kết nối đồng thời và có T luồng thực thi, kích thước tối thiểu của Pool để tránh deadlock là:

    PoolSize_min = T * (M - 1) + 1

    Ví dụ: với T = 10 luồng cha và mỗi luồng sinh thêm 1 luồng con cùng xin connection (M = 2), Pool tối thiểu phải là 10 * (2 - 1) + 1 = 11 kết nối.

  2. Tách Connection Pools (Pool Segregation): đừng dùng chung một Connection Pool cho tác vụ quan trọng (OLTP) và tác vụ phụ (Auditing, Logging, Batching).

  3. Không giữ DB Connection khi chờ I/O bên ngoài: commit hoặc đóng kết nối CSDL trước khi gọi tác vụ bất đồng bộ hoặc REST API của bên thứ ba.

TIP

Nên nhớ: đưa các tác vụ ghi log, kiểm toán, gửi notification ra ngoài transaction database chính. Dùng Outbox Pattern hoặc Event Bus để transaction database đóng càng nhanh càng tốt.


# stream api vs chi phí allocation trên hot path

# câu hỏi phỏng vấn

Tại sao các hệ thống độ trễ siêu thấp (Low-Latency Trading Engines) hay gateway xử lý 500,000 QPS lại cấm dùng Java Stream API trên các đường dẫn quan trọng (Hot Paths)?

# giải thích kỹ thuật

Stream API giúp code dễ đọc và gọn hơn nhiều. Đổi lại là cái giá về cấp phát bộ nhớ (Allocation Churn) và chi phí gián tiếp (Indirection Overhead):

  1. Nhiều Garbage trên Young Generation: mỗi pipeline của Stream sinh hàng loạt đối tượng ngắn hạn: Spliterator, các stage ReferencePipeline (Head, StatelessOp...), Sink, lambda có capture biến, cùng collection kết quả. Nhân với 500,000 QPS, HotSpot phải dọn hàng triệu đối tượng mỗi giây, GC dễ thành cổ chai. Đôi khi JIT inline được cả pipeline và Escape Analysis xoá bớt allocation, nhưng đó là may chứ không phải cam kết, nên hệ thống low-latency không dựa vào nó.
  2. Cản trở JIT Inlining và Loop Unrolling: vòng lặp for truyền thống là cấu trúc đơn giản nhất để JIT (C2 Compiler) áp dụng Vectorization (SIMD) và unroll. Còn chuỗi lời gọi hàm ảo (megamorphic virtual calls) của Stream làm branch prediction tốn hơn và JIT khó tối ưu mã máy.
  3. Autoboxing ngầm: không dùng Stream nguyên thủy (IntStream, LongStream) mà dùng Stream<Integer> thì sinh hàng triệu đối tượng Integer (mỗi cái 16 bytes với compressed oops, chưa kể tham chiếu 4-8 bytes trỏ tới nó; chỉ -128..127 là lấy từ cache) chỉ để bọc một số 4 bytes.
// Đường dẫn hiệu năng cao: Zero Allocation Hot Path
public static long calculateTotalVolume(long[] trades) {
    long total = 0;
    // JIT Compiler có thể vectorize vòng lặp này thành lệnh SIMD (SSE/AVX, tuỳ CPU),
    // không sinh ra bất kỳ 1 byte Garbage nào trên Heap!
    for (int i = 0; i < trades.length; i++) {
        total += trades[i];
    }
    return total;
}

# thiết kế idempotency cho giao dịch phân tán

# câu hỏi phỏng vấn

Trong hệ thống thanh toán phân tán, một API trừ tiền nhận cùng một Idempotency-Key từ Client gửi đồng thời (Concurrent Request) qua 2 luồng do Network Retry. Làm sao để tài khoản chỉ bị trừ tiền đúng một lần mà không dính Race Condition hay Deadlock ở tầng CSDL?

# giải pháp thiết kế

  1. Chặn ở tầng lưu trữ bằng Unique Constraint: tạo bảng idempotency_records với khóa duy nhất UNIQUE(idempotency_key). Chèn trùng sẽ bị unique index của CSDL chặn ngay (Spring dịch lỗi này thành DataIntegrityViolationException). Điều quan trọng là bản ghi idempotency và lệnh UPDATE trừ tiền phải nằm trong cùng một transaction: tốt nhất là INSERT bản ghi PROCESSING trước, trừ tiền, rồi cập nhật thành COMPLETED kèm response, commit một lần. Request trùng sẽ bị chặn ngay ở câu INSERT (với PostgreSQL thì nó chờ transaction kia commit rồi mới báo trùng).
  2. Distributed Mutex với Redis: dùng lệnh nguyên tử SET key token NX PX 5000 để hai luồng không cùng chạy nghiệp vụ trừ tiền một lúc. Lock này chỉ là lớp giảm tải, không phải lớp bảo đảm: nếu Req 1 bị GC pause hay DB chậm quá 5 giây, lock hết hạn và Req 2 vẫn lọt vào. Lúc đó chỉ unique constraint ở bước 1 cứu được bạn. Nhớ xoá lock bằng script so sánh token để không xoá nhầm lock của request khác.
  3. State Machine 3 trạng thái:
    • PROCESSING: đang xử lý giao dịch. Request thứ hai đến thì nhận HTTP 409 Conflict hoặc phải chờ kết quả.
    • COMPLETED: giao dịch thành công. Trả về đúng bản JSON kết quả ban đầu, không chạy lại logic nghiệp vụ.
    • FAILED: giao dịch thất bại. Cho phép retry hoặc giải phóng key.

# vài lời khuyên khi phỏng vấn Senior

Khi vào buổi phỏng vấn cấp Senior hoặc Staff:

  1. Đừng vội code: làm rõ yêu cầu phi chức năng (Non-functional requirements) trước: QPS mong muốn, SLA độ trễ P99, khả năng chịu lỗi mạng (Partition tolerance).
  2. Nói tới phần cứng và hệ điều hành: nhắc Cache Lines, Context Switches, I/O Blocks, Garbage Collection Pauses để cho thấy bạn hiểu thứ nằm dưới code.
  3. Nêu rõ các đánh đổi (Trade-offs): giải pháp nào cũng có cái giá, đừng gọi nó là "hoàn hảo". Mọi quyết định là cân bằng giữa chi phí phát triển, độ phức tạp vận hành, thông lượng và tính nhất quán.

Tài liệu tham khảo:


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.