Command Palette

Search for a command to run...

[Advanced Java] Best Practice, Tối Ưu Hiệu Năng và Chuẩn Bị Phỏng Vấn Java

"Best practice và chuẩn bị phỏng vấn" là chủ đề dễ viết cho có nhất trong lập trình: năm mươi gạch đầu dòng ai cũng đọc rồi, cộng hai mươi câu hỏi kèm một câu trả lời học thuộc. Bài này đi theo một quy tắc chặt hơn. Mọi khẳng định có thể chứng minh được thì đều được chứng minh, kèm chương trình và output của nó; khẳng định nào không chứng minh được thì ghi rõ là nhận định cá nhân và viết ngắn.

Quy tắc đó quyết định hình dạng của cả bài. Phần performance báo cáo số byte allocate, số lần gọi equals(), số lần gọi compile() và số câu SQL chứ không phải millisecond, vì những con số đó lặp lại được còn một lần bấm giờ thì không. Checklist code review là một danh sách câu hỏi, mỗi câu gọi tên đúng defect mà nó bắt được. Và phần phỏng vấn lấy đúng những câu người ta hay trả lời bằng một câu học thuộc rồi trả lời lại bằng transcript.

Terminal card với ba output thật: Integer 128 == 128 là false, 50.014.999 lần gọi equals, 501 câu SQL

Mọi chương trình, error message và con số bên dưới đều được compile và chạy trên OpenJDK 21.0.6 (arm64). Không có gì viết theo trí nhớ, và không có gì ở đây là một con số bấm giờ.

Đo, đừng đoán: vì sao suốt series này không có một con số benchmark nào

Suốt cả series chưa từng có một khẳng định kiểu "nhanh hơn 3 lần" kèm số millisecond. Đó là chủ ý, và đây là chỗ duy nhất giải thích lý do.

Một con số millisecond chỉ có nghĩa khi đi kèm phần cứng, version JVM, các flag, lúc đó máy đang chạy gì khác, và đã chạy bao nhiêu vòng trước khi bắt đầu đo. Bỏ đi một trong những thứ đó là con số hết lặp lại được — nghĩa là người đọc không kiểm tra được, và chính bạn tháng sau cũng không. Một con số đếm thì không dính vấn đề đó. Số object một vòng lặp allocate, số lần equals() chạy, số câu SQL một request bắn đi: những con số này giống nhau trên laptop và trên máy CI đang tải nặng, giống nhau hôm nay và sang năm.

Một vòng lặp nanoTime thực ra đang đo cái gì

Microbenchmark quen thuộc trông thế này: lấy System.nanoTime(), chạy method một triệu lần, lấy nanoTime() lần nữa, rồi chia. Đây là method được đem ra đo.

Java
public class Bench {
    static int work(int[] a) {
        int s = 0;
        for (int v : a) s += v * 31;
        return s;
    }
 
    public static void main(String[] args) {
        int[] a = new int[1000];
        for (int i = 0; i < a.length; i++) a[i] = i;
        long sink = 0;
        for (int i = 0; i < 200_000; i++) sink += work(a);
        System.out.println("sink=" + sink);
    }
}

Chạy nó với log của JIT compiler bật lên rồi lọc đúng một method đó. Cột timestamp đã bị cắt bỏ, vì bài này không công bố số thời gian:

Bash
java -XX:+PrintCompilation Bench 2>&1 \
  | grep 'Bench::work' | sed -E 's/^[[:space:]]*[0-9]+[[:space:]]+//'
Text
6 %     3       Bench::work @ 10 (38 bytes)
7       3       Bench::work (38 bytes)
8 %     4       Bench::work @ 10 (38 bytes)
6 %     3       Bench::work @ 10 (38 bytes)   made not entrant
9       4       Bench::work (38 bytes)
7       3       Bench::work (38 bytes)   made not entrant

Đọc kỹ dòng đó. Cùng một method 38 byte được compile ở tier 3, rồi compile lại ở tier 4, rồi bản tier 3 bị vứt đi — made not entrant nghĩa là vậy. Dấu % đánh dấu on-stack replacement, tức là bản compile được nhét thẳng vào một vòng lặp đang chạy dở. Nên một vòng bấm giờ quanh method này đã đo interpreter, rồi output của một compiler, rồi output của một compiler khác, và lấy trung bình tất cả thành một con số.

Đó mới là vấn đề đầu tiên. Những vấn đề còn lại tệ hơn, vì chúng im lặng:

  • Dead code elimination. Nếu không ai dùng kết quả, JIT có quyền xóa luôn phần tính toán. Vòng lặp của bạn khi đó đang đo một vòng lặp rỗng.
  • Constant folding. Nếu input là hằng số lúc compile, cả phép tính có thể bị thay bằng kết quả của nó.
  • GC. Một lần collect rơi đúng vào khoảng đang đo sẽ bị tính vào vòng lặp nào đang chạy lúc đó.
  • Mọi thứ khác trên máy. Một process khác, một container khác, một bản build chạy nền.

JMH — Java Microbenchmark Harness, do chính dự án OpenJDK làm — tồn tại chính vì danh sách trên. Nó fork một JVM mới, chạy các vòng warm-up không tính vào kết quả, đẩy kết quả qua blackhole để không bị loại bỏ, và báo cáo kèm sai số chứ không phải một con số trần trụi. Nếu bạn thật sự cần một con số thời gian thì đó là công cụ đúng. Bài này không trích kết quả JMH nào, vì bài này không công bố số thời gian.

Những công cụ thắng trực giác

Hai cái sau đây phủ gần hết trường hợp thật, và không cái nào là đồng hồ bấm giờ.

jcmd nói chuyện trực tiếp với một JVM đang chạy. Chỉ nó vào process đang có vấn đề và hỏi trên heap đang có gì:

Bash
jcmd <pid> GC.class_histogram
Text
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         47872       21779960  [B (java.base@21.0.6)
   2:         21020         672640  java.util.HashMap$Node (java.base@21.0.6)
   3:         27791         666984  java.lang.String (java.base@21.0.6)
   4:          1535         189696  java.lang.Class (java.base@21.0.6)
   5:           322         182664  [Ljdk.internal.vm.FillerElement; (java.base@21.0.6)
   6:           264         155456  [Ljava.util.HashMap$Node; (java.base@21.0.6)
   7:          1116         131144  [Ljava.lang.Object; (java.base@21.0.6)

Hai mươi mốt nghìn instance HashMap$Node và hai mươi mốt megabyte byte array. Không ai phải đoán cả: process đang giữ một map chứa byte array, và câu hỏi tiếp theo là ai đang giữ cái map đó. jcmd <pid> Thread.print làm đúng việc tương tự cho một tình huống treo, còn jcmd <pid> JFR.start ghi lại profile Flight Recorder khi bạn cần biết call site chứ không chỉ con số.

Công cụ thứ hai là một counter bạn tự đặt vào chương trình của mình. HotSpot có theo dõi mỗi thread đã allocate bao nhiêu byte, và expose ra qua phần mở rộng com.sun.management của ThreadMXBean:

Java
import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
 
public class Alloc {
    static final ThreadMXBean BEAN = (ThreadMXBean) ManagementFactory.getThreadMXBean();
 
    public static long bytes() { return BEAN.getCurrentThreadAllocatedBytes(); }
}

Lấy counter trước và sau một đoạn việc, hiệu số chính là lượng rác đoạn việc đó sinh ra. Nó không bị ảnh hưởng bởi process khác, không cần warm-up, và — như phần dưới cho thấy — lặp lại chính xác đến từng byte.

Sáu performance trap, cái nào cũng đếm được

Sáu cái này phổ biến, có thật, và mỗi cái đều có một counter chỉ đúng tên nó. Hình bên dưới là đường đi chẩn đoán: cái bạn quan sát được, counter biến quan sát đó thành con số, và con số đó chỉ vào đâu.

Triệu chứng, counter và nguyên nhân xếp thành ba cột, có mũi tên từ mỗi quan sát tới counter gọi đúng tên trap

Mỗi trap bên dưới đều kết thúc bằng cùng một câu hỏi — làm sao biết đây đúng là vấn đề của mình? — vì đoán mò xem mình đang dính trap nào mới là sai lầm thật sự. Sửa nhầm cái khác mất một ngày và không thay đổi gì cả.

1. Ghép String trong vòng lặp

String là immutable, nên s = s + x không thể append được. Nó dựng một string mới chứa toàn bộ phần trước cộng thêm mẩu mới, rồi vứt bản cũ đi. Làm việc đó trong vòng lặp thì khối lượng công việc tăng theo bình phương độ dài kết quả.

Java
import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
 
public class ConcatTrap {
    static final ThreadMXBean B = (ThreadMXBean) ManagementFactory.getThreadMXBean();
 
    static String withConcat(int n) {
        String s = "";
        for (int i = 0; i < n; i++) s = s + "row" + i + ";";
        return s;
    }
 
    static String withBuilder(int n) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) sb.append("row").append(i).append(';');
        return sb.toString();
    }
 
    public static void main(String[] args) {
        int n = 20_000;
        long a = B.getCurrentThreadAllocatedBytes();
        String s1 = withConcat(n);
        long b = B.getCurrentThreadAllocatedBytes();
        String s2 = withBuilder(n);
        long c = B.getCurrentThreadAllocatedBytes();
 
        System.out.println("same result      : " + s1.equals(s2));
        System.out.println("result length    : " + s1.length());
        System.out.println("concat  allocated: " + (b - a) + " bytes");
        System.out.println("builder allocated: " + (c - b) + " bytes");
        System.out.println("ratio            : " + ((b - a) / (c - b)) + "x");
    }
}
Text
same result      : true
result length    : 168890
concat  allocated: 1629562776 bytes
builder allocated: 759224 bytes
ratio            : 2146x

Hai string giống hệt nhau, dài 168.890 ký tự. Một bên tốn 1,63 GB allocation, bên kia tốn 759 KB. Con số đó giống nhau đến từng byte ở mọi lần chạy, và đó chính là lý do nó đáng để trích ra.

Cần nói chính xác compiler sửa được gì và không sửa được gì. javac trên Java 21 compile s + "row" + i + ";" thành một invokedynamic duy nhất gọi vào StringConcatFactory, nên phần ghép trong một câu lệnh vốn đã hiệu quả:

Text
12: invokedynamic #9,  0   // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;

Cái nó không sửa được là câu lệnh đó chạy 20.000 lần, và mỗi lần chạy phải copy lại toàn bộ string đã tích lũy. Trap nằm ở vòng lặp, không nằm ở dấu cộng. Ghép hai ba giá trị trong một câu lệnh thì hoàn toàn ổn, và luôn ổn.

Làm sao biết đây là vấn đề của mình? Đặt allocation counter quanh method đang nghi ngờ, hoặc xem jcmd GC.class_histogram thấy số [B khổng lồ nhưng live set lại nhỏ — rất nhiều rác, sinh ra rồi vứt đi ngay.

2. Chọn sai collection cho kiểu truy cập

List.contains là quét tuyến tính. Set.contains là một lần tra hash. Khác biệt đó không lộ ra khi review vì hai dòng code đọc lên y hệt nhau. Gắn counter vào equals() thì không thể bỏ sót được nữa.

Java
import java.util.*;
 
public class CollectionTrap {
    static long equalsCalls = 0;
 
    record Sku(String code) {
        @Override public boolean equals(Object o) {
            equalsCalls++;
            return o instanceof Sku s && s.code.equals(code);
        }
        @Override public int hashCode() { return code.hashCode(); }
    }
 
    public static void main(String[] args) {
        int size = 10_000, lookups = 1_000;
        List<Sku> list = new ArrayList<>();
        for (int i = 0; i < size; i++) list.add(new Sku("SKU-" + i));
        Set<Sku> set = new HashSet<>(list);
 
        List<Sku> wanted = new ArrayList<>();
        for (int i = 0; i < lookups; i++) wanted.add(new Sku("SKU-" + (i * 7 % size)));
 
        equalsCalls = 0;
        int hitsList = 0;
        for (Sku s : wanted) if (list.contains(s)) hitsList++;
        long listCalls = equalsCalls;
 
        equalsCalls = 0;
        int hitsSet = 0;
        for (Sku s : wanted) if (set.contains(s)) hitsSet++;
        long setCalls = equalsCalls;
 
        System.out.println("hits            : list=" + hitsList + " set=" + hitsSet);
        System.out.println("ArrayList equals: " + listCalls);
        System.out.println("HashSet   equals: " + setCalls);
        System.out.println("ratio           : " + (listCalls / setCalls) + "x");
    }
}
Text
hits            : list=1000 set=1000
ArrayList equals: 3497500
HashSet   equals: 1000
ratio           : 3497x

Cùng ra 1.000 kết quả. Một bên phải hỏi 3.497.500 câu để có chúng; bên kia hỏi đúng 1.000 câu, mỗi lần tra một câu. Và khoảng cách còn giãn ra theo dữ liệu: nó là tích của số lần tra và kích thước list, nên catalogue lớn gấp mười thì công việc cũng gấp mười.

Làm sao biết đây là vấn đề của mình? Cắm một counter vào equals() vài giây, hoặc mở profiler thấy equalsindexOf nằm trên đỉnh ở một endpoint nóng. Dấu hiệu trong code là contains, indexOf hoặc remove(Object) gọi trên một List bên trong vòng lặp.

3. Autoboxing trong hot loop

Long là object. long thì không. Cộng dồn vào nhầm loại là allocate một lần mỗi vòng.

Java
static long boxed(int n) {
    Long sum = 0L;                 // the trap: one Long per iteration
    for (int i = 0; i < n; i++) sum += i;
    return sum;
}
 
static long primitive(int n) {
    long sum = 0L;
    for (int i = 0; i < n; i++) sum += i;
    return sum;
}
Text
same result       : true (499999500000)
Long   allocated  : 24000056 bytes
long   allocated  : 0 bytes
bytes per iteration: 24

Hai mươi bốn byte mỗi vòng, một triệu vòng, 24 MB rác — còn bản primitive thì allocate đúng bằng không. Bytecode cho thấy vì sao: mỗi sum += i là một longValue() để unbox, một phép cộng, rồi một Long.valueOf để box lại kết quả.

Text
13: invokevirtual #13  // Method java/lang/Long.longValue:()J
16: iload_2
17: i2l
18: ladd
19: invokestatic  #7   // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;

Chuyện y hệt xảy ra với một counter Map<String, Integer> tăng dần trong vòng lặp, và với một List<Integer> dùng như buffer số. Nó không xảy ra với các giá trị nhỏ của Integer, Short, ByteCharacter, vì những kiểu đó có cache — và đó chính là chủ đề câu phỏng vấn đầu tiên bên dưới.

Làm sao biết đây là vấn đề của mình? Allocation counter quanh vòng lặp, hoặc một histogram bị java.lang.Long / java.lang.Integer chiếm đầu bảng. Lưu ý đây là vấn đề rác, không phải vấn đề live heap: các object chết ngay lập tức nên heap không phình lên.

4. Cache không bao giờ evict

Một HashMap chỉ có thêm mà không bao giờ bớt thì không phải cache. Nó là memory leak mang một cái tên dễ nghe, và nó fail dứt khoát hơn mọi trap khác ở đây.

Java
import java.util.*;
 
public class CacheLeak {
    // A "cache" that never evicts is a Map field that only ever grows.
    static final Map<String, byte[]> CACHE = new HashMap<>();
    static int inserted = 0;
 
    public static void main(String[] args) {
        try {
            for (int i = 0; ; i++) {
                CACHE.computeIfAbsent("page-" + i, k -> new byte[16 * 1024]);
                inserted++;
            }
        } catch (OutOfMemoryError e) {
            CACHE.clear();                      // free the heap before printing
            System.out.println("entries cached before it died: " + inserted);
            System.out.println(e);
        }
    }
}
Bash
java -Xmx64m CacheLeak
Text
entries cached before it died: 3938
java.lang.OutOfMemoryError: Java heap space

Khoảng 3.900 entry, mỗi entry 16 KB, và process biến mất. Con số chính xác lệch vài đơn vị giữa các lần chạy; kết cục thì không đổi. Lưu ý bắt OutOfMemoryError ở đây là thủ thuật để minh họa chứ không phải lời khuyên — chính dòng CACHE.clear() ngay sau đó mới làm cho việc in ra được cái gì đó trở nên khả thi.

Cách sửa không phải là tăng heap. Cách sửa là đặt giới hạn và một policy evict. LinkedHashMap có sẵn cơ chế đó:

Java
static final int MAX = 500;
static final Map<String, byte[]> CACHE = new LinkedHashMap<>(16, 0.75f, true) {
    @Override protected boolean removeEldestEntry(Map.Entry<String, byte[]> e) {
        return size() > MAX;
    }
};
Text
inserted : 1000000
cache size: 500
survived  : yes, same -Xmx64m

Một triệu lần insert dưới đúng heap 64 MB đó, kết thúc với 500 entry. Tham số thứ ba true bắt map sắp xếp theo thứ tự truy cập, nên entry bị đẩy ra là entry lâu chưa dùng nhất. Trong một service thật bạn sẽ hay dùng Caffeine hoặc cache abstraction của framework hơn, vì chúng có thêm giới hạn kích thước, expiry theo thời gian và số liệu thống kê — nhưng tính chất quan trọng vẫn là tính chất được trình bày ở đây: có một mức trần.

Làm sao biết đây là vấn đề của mình? Heap tăng dần và không tụt về mức nền kể cả sau một lần full GC, và jcmd GC.class_histogram cho thấy số HashMap$Node rất lớn. Một bản heap dump sau đó sẽ chỉ đúng field đang giữ nó.

5. Compile lại cùng một regex ở mỗi lần gọi

Compile một regular expression là dựng một state machine nhỏ. Làm việc đó trong vòng lặp là dựng lại cỗ máy ấy mỗi lần. Cái này chứng minh được theo hai đường: đếm số lần gọi, và đọc source của JDK.

Java
import java.util.regex.Pattern;
 
public class RegexTrap {
    static int compiles = 0;                          // instrumented wrapper
 
    static Pattern compile(String regex) {            // every compile goes through here
        compiles++;
        return Pattern.compile(regex);
    }
 
    static final String RE = "^[A-Z]{2}-\\d{4}-[a-z]+$";
    static final Pattern CACHED = compile(RE);        // compiled once, at class init
 
    static boolean recompiling(String s) { return compile(RE).matcher(s).matches(); }
    static boolean cached(String s) { return CACHED.matcher(s).matches(); }
    // ... 10,000 inputs through each, with the allocation counter around both loops
}
Text
compiles after class init : 1
matches (all three)       : 10000 / 10000 / 10000
compiles after loop 1     : 10001
compiles after loop 2     : 10001
allocated, recompiling    : 16269544 bytes
allocated, cached Pattern : 2080000 bytes
allocated, String.matches : 16000000 bytes

Counter nói rất rõ: vòng lặp ngây thơ compile pattern 10.000 lần, còn vòng lặp dùng static final Pattern compile thêm đúng không lần nào — lần compile duy nhất nó cần đã xảy ra một lần lúc class được khởi tạo. Cùng kết quả, gần gấp tám lần allocation.

Dòng thứ ba quan trọng vì String.matches trông rất vô hại. Nó không phải một đường đi khác; nó chính là đường đi đó. Trích từ String.java trong chính src.zip của JDK:

Java
public boolean matches(String regex) {
    return Pattern.matches(regex, this);
}

và từ Pattern.java:

Java
public static boolean matches(String regex, CharSequence input) {
    Pattern p = Pattern.compile(regex);
    Matcher m = p.matcher(input);
    return m.matches();
}

Pattern.compile ở mọi lần gọi, theo đúng thiết kế. String.matchesString.split trong vòng lặp là hai chỗ trap này hay nấp.

Làm sao biết đây là vấn đề của mình? Allocation counter, hoặc profiler chỉ ra thời gian nằm ở việc dựng Pattern$Node. Khi review, dấu hiệu là bất kỳ Pattern.compile, .matches( hay .split( nào không nằm trên một field static final.

6. Một query cho mỗi parent row

Vấn đề N+1 không phải vấn đề của ORM — ORM chỉ làm cho việc viết nhầm ra nó dễ hơn thôi. JDBC thuần có đúng hình dạng đó, và một proxy đếm sẽ làm hình dạng ấy hiện ra.

Java
import java.lang.reflect.*;
import java.sql.*;
 
public class NPlusOne {
    static int statements = 0;
 
    /** Counts every statement this connection is asked to prepare. */
    static Connection counting(Connection real) {
        return (Connection) Proxy.newProxyInstance(
                Connection.class.getClassLoader(),
                new Class<?>[]{Connection.class},
                (proxy, m, args) -> {
                    if (m.getName().equals("prepareStatement")) statements++;
                    return m.invoke(real, args);
                });
    }
    // ... load 5 customers, then their orders one customer at a time
}
Text
N+1 version   : 6 statements, 20 order rows
join version  : 1 statements, 20 order rows

Sáu câu lệnh cho năm customer, một câu cho bản join. Code Java ở bản đầu đọc lên rất trôi chảy — một vòng lặp qua customer, một query nằm bên trong — còn database thì nhìn thấy sáu lượt đi về. Tăng số dòng của bảng cha lên thì hình dạng đúng như cái tên của nó:

Text
5 customers -> 6 statements
50 customers -> 51 statements
500 customers -> 501 statements

Đó là toàn bộ mối nguy của trap này. Nó vô hình trên mười dòng dữ liệu trong fixture test và là một sự cố production trên năm mươi nghìn dòng.

Làm sao biết đây là vấn đề của mình? Đếm số statement mỗi request. Một proxy trên Connection như trên, hoặc spring.jpa.show-sql rồi đếm số dòng log, hoặc statement log của chính database. Nếu con số tăng theo số dòng của một kết quả trước đó thì bạn đã tìm ra.

Checklist code review bắt defect, không bắt style

Chuyện format không đáng để lại comment; một formatter giải quyết xong. Cái đáng để lại comment là loại defect compile sạch, pass hết những test bạn đang có, rồi hành xử tệ ở một chỗ bạn không nhìn tới. Hình dưới đặt tám câu hỏi như vậy lên đúng giai đoạn defect sẽ lộ ra nếu không ai hỏi.

Tám câu hỏi review đặt theo giai đoạn defect lộ ra: javac, một unit test, production khi tải nặng, hoặc không bao giờ

Hình dạng của bức tranh đó chính là lập luận. Một trong tám câu có compiler đứng sau. Một câu fail ngay lần đầu một test bình thường chạm tới. Ba câu chờ tải nặng hoặc chờ dữ liệu lớn. Và ba câu không bao giờ throw cái gì cả — chúng lặng lẽ cho ra kết quả sai, nên chỉ có người đọc diff mới bắt được.

Bốn defect, chạy thật

Một key mà hash của nó thay đổi được. equalshashCode ở đây nhất quán với nhau, nên một buổi review chỉ kiểm tra "hai cái này có khớp nhau không" sẽ cho qua. Field dùng để tính hash lại mutable, và chỉ cần thế là đủ.

Java
static final class Order {
    int id;                                   // not final: that is the defect
    Order(int id) { this.id = id; }
    @Override public boolean equals(Object o) { return o instanceof Order x && x.id == id; }
    @Override public int hashCode() { return Integer.hashCode(id); }
}
Text
contains before the change : true
contains after the change  : false
remove(o) succeeded        : false
entries still in the set   : 1

Set vẫn đang giữ object đó, và set không tìm lại được nó nữa — không bằng contains, không bằng remove, kể cả bằng đúng cái reference đã được thêm vào. Entry đó vĩnh viễn không với tới được. Không có exception nào được throw, không bao giờ. Câu hỏi review là field dùng để tính hash này có bao giờ thay đổi không?, và cách sửa là final.

Một comparator không phải quan hệ thứ tự toàn phần. Hai biến thể của cùng một defect, và không cái nào bị test trên dữ liệu nhỏ bắt được.

Java
List<Integer> v = List.of(Integer.MIN_VALUE, 1, Integer.MAX_VALUE, -1, 0);
subtraction.sort((a, b) -> a - b);      // overflows, silently
compare.sort(Integer::compare);
Text
(a, b) -> a - b   : [-1, 0, 1, 2147483647, -2147483648]
Integer::compare  : [-2147483648, -1, 0, 1, 2147483647]
n=100 : sorted, no complaint
n=1000 : sorted, no complaint
n=10000 : IllegalArgumentException: Comparison method violates its general contract!

Comparator dùng phép trừ đặt Integer.MAX_VALUE trước Integer.MIN_VALUE và không báo lỗi gì: a - b bị overflow. Còn comparator không transitive ở nửa sau sắp 100 phần tử rồi 1.000 phần tử im re, đến 10.000 mới throw — vì TimSort chỉ phát hiện khi một bất biến của bước merge thật sự vỡ, mà muốn vậy thì phải đủ dữ liệu. Bug comparator là loại bug nằm chờ đúng kích thước dữ liệu production của bạn.

Một catch làm mất cause, và một Optional dùng làm field.

Java
catch (NumberFormatException e) { throw new IllegalStateException("bad config"); }
catch (NumberFormatException e) { throw new IllegalStateException("bad config", e); }
Text
thrown : java.lang.IllegalStateException: bad config
cause  : null
thrown : java.lang.IllegalStateException: bad config
cause  : java.lang.NumberFormatException: For input string: "x1"
serialise: java.io.NotSerializableException: java.util.Optional

Dạng thứ nhất là dạng bạn sẽ gặp lúc 3 giờ sáng: một stack trace nói rằng config sai và không nói gì về giá trị nào sai hay vì sao. Thêm đúng một argument là giữ được exception gốc. Dòng cuối là chuyện Optional làm field: Optional cố tình không implement Serializable, nên một class serializable mà giữ một Optional sẽ fail ngay khi có thứ gì ghi nó ra. Optional là một return type; một field thì nên là giá trị nullable bình thường hoặc một collection rỗng.

Tám câu hỏi, và lỗi mà mỗi câu bắt được

Hỏi khi reviewNó bắt được gìNó lộ ra như thế nào
finally block nào return hoặc throw không?finally nuốt mất exception đang bayMất lỗi thật trong im lặng; javac -Xlint:finally có cảnh báo
Optional đó là field hay là return type?Optional không SerializableNotSerializableException ngay lần ghi đầu tiên
Comparator này có transitive, và trả 0 khi hai phần tử bằng nhau?Quan hệ thứ tự toàn phần bị hỏngSắp sai trong im lặng, rồi IllegalArgumentException khi dữ liệu lớn
Cache này có giới hạn không, và cái gì evict khỏi nó?Một Map chỉ có phình raHeap leo dần, rồi OutOfMemoryError
Mọi lock có được lấy theo cùng một thứ tự toàn cục?Một vòng lock giữa hai threadProcess ngừng phản hồi, không có exception nào
hashCode có nhất quán với equals, và key có mutate được?Một entry trở nên không với tới đượccontains trả false cho object mà set đang giữ
catch này có truyền exception gốc làm cause không?Stack trace bị cắt cụtBạn debug nhầm tầng mất một tiếng
Method @Transactional này có được gọi từ ngoài bean của nó?Proxy bị bỏ qua do self-invocationKhông transaction nào bắt đầu; không có gì rollback

Hai câu nữa cũng thuộc danh sách này nhưng đã được các bài trước lo, nên ở đây không chạy lại: mọi stream duyệt thư mục có được đóng không (một Files.list không đóng sẽ giữ một file descriptor cho tới khi process hết descriptor), và một Stream có được tiêu thụ đúng một lần không (thao tác terminal thứ hai sẽ throw IllegalStateException).

Không câu nào ở trên là chuyện style. Câu nào cũng là một defect đi thẳng lên production.

Chuẩn bị phỏng vấn Java: một bản đồ, không phải kịch bản

Câu hỏi phỏng vấn không ngẫu nhiên, nhưng cũng không phải một danh sách đánh số. Chúng gom theo mảng, và mảng nào được hỏi thì tùy vị trí — phần đó là nhận định. Còn từng mảng thật sự đòi hỏi cái gì thì không phải nhận định; đó là kiến thức.

Các chủ đề phỏng vấn Java gom thành tám mảng trên hai dải: mảng hỏi ở gần như mọi buổi phỏng vấn và mảng thêm vào cho vị trí không còn junior

Hãy ôn theo một bản đồ như vậy thay vì ôn theo danh sách câu hỏi, vì câu hỏi đuổi mới là chỗ quyết định buổi phỏng vấn. "HashMap khác Hashtable chỗ nào" có câu trả lời học thuộc; "vậy khi hai key trùng bucket thì chuyện gì xảy ra" thì không.

Phần còn lại của mục này lấy những câu người ta hay trả lời bằng một câu duy nhất và trả lời lại bằng một chương trình. Một câu trả lời có transcript mạnh hơn một câu trả lời có sự tự tin, và chạy thử một lần cũng chính là cách để nhớ nó.

Vì sao == và equals bất đồng về Integer ở mốc 128

Câu người ta học thuộc là "Java cache các Integer nhỏ". Đây là phần chứng minh, và phần làm nó thú vị.

Java
public class IntegerCache {
    public static void main(String[] args) {
        Integer a = 127, b = 127;
        Integer c = 128, d = 128;
        System.out.println("127 == 127      : " + (a == b));
        System.out.println("128 == 128      : " + (c == d));
        System.out.println("128.equals(128) : " + c.equals(d));
        System.out.println("valueOf identity: " + (Integer.valueOf(128) == Integer.valueOf(128)));
    }
}
Text
127 == 127      : true
128 == 128      : false
128.equals(128) : true
valueOf identity: false

Giờ tới câu hỏi đuổi mà gần như không ai chuẩn bị sẵn. Mốc đó không phải một quy tắc của ngôn ngữ — nó là kích thước một cái cache, và nó chỉnh được:

Bash
java -XX:AutoBoxCacheMax=1000 IntegerCache
Text
127 == 127      : true
128 == 128      : true
128.equals(128) : true
valueOf identity: true

Cùng source code, cùng JDK, khác kết quả. Autoboxing đi qua Integer.valueOf, hàm này trả về một instance dùng chung cho các giá trị nằm trong cache và một object mới cho giá trị nằm ngoài; cận trên của cache đó mặc định là 127 và được đặt bằng một flag. Nhân tiện, flag này chỉ tác dụng với Integer:

Text
Integer 128 : false
Long    128 : false
Short   128 : false
Character128: false
Boolean     : true

Long, ShortCharacter giữ nguyên cache cố định của chúng; Boolean chỉ có hai giá trị nên luôn trả về đúng hai object đó. Vậy câu trả lời thật cho câu hỏi này không phải "giới hạn là 127" mà là "đừng bao giờ so sánh kiểu boxed bằng ==, vì việc nó chạy đúng hay không không phải là tính chất của code bạn viết".

Một hashCode làm cho có tốn bao nhiêu, tính bằng số lần gọi equals

"hashCode trả hằng số thì hợp lệ nhưng tệ" là nửa câu học thuộc. Nửa thú vị là tệ tới mức nào, và cái đó đếm được: cắm counter vào equals rồi so một hash tử tế với return 42.

Java
static final class Bad {
    final int id;
    Bad(int id) { this.id = id; }
    @Override public boolean equals(Object o) {
        equalsCalls++;
        return o instanceof Bad b && b.id == id;
    }
    @Override public int hashCode() { return 42; }   // legal, and ruinous
}
Text
size          : good=10000 bad=10000
equals on add : good=0  bad=50116881
equals on get : good=10000  bad=50014999

Mười nghìn phần tử. Với hash tử tế, việc dựng set gọi equals đúng không lần nào — các hash khác nhau rơi vào các bucket khác nhau nên chẳng có gì để so — còn tra lại từng phần tử gọi đúng 10.000 lần, mỗi phần tử một lần. Với hash hằng số, đúng khối lượng công việc đó tốn hơn năm mươi triệu phép so sánh ở mỗi chiều.

Lý do là contract không hề bị vi phạm nên không có gì phàn nàn cả: object bằng nhau vẫn có hash code bằng nhau. HashSet chỉ đơn giản thoái hóa thành quét tuyến tính, vì mọi entry nằm chung một bucket. Đó mới là câu trả lời đáng nói — không phải "nó chậm hơn" mà "nó lặng lẽ biến lookup O(1) của bạn về lại O(n), và phần kiểm tra contract sẽ không bắt được".

String có immutable không, và string pool liên quan gì tới chuyện đó

Immutability và string pool là hai chuyện khác nhau nhưng hay bị trả lời gộp làm một. Tách chúng ra rất dễ nếu bạn có output này trong đầu.

Text
literal  == literal : true
literal  == new     : false
literal  == intern  : true
literal  == folded  : true
literal  == runtime : false
equals everywhere   : true
toUpperCase changed : SHOP, new object = true
no-op toUpperCase   : same object returned = true
substring(0)        : same object returned = true
concat("")          : same object returned = true

Khối đầu là string pool: hai literal giống nhau là cùng một object, new String("shop") thì cố tình không phải, intern() đưa bạn về lại object trong pool, "sh" + "op" bị javac fold thành một literal, còn cũng phép ghép đó nhưng dựng từ biến lúc runtime lại là object mới. equals đúng ở mọi dòng, và đó chính là điểm mấu chốt — identity và equality là hai câu hỏi khác nhau.

Bốn dòng cuối là immutability, và trong đó có một chi tiết đáng có. "shop".toUpperCase() trả về object mới vì có thứ thay đổi. "SHOP".toUpperCase(), "shop".substring(0)"shop".concat("") đều trả về đúng object cũ, vì không có gì thay đổi và JDK được phép trả thẳng this. Tối ưu đó chỉ đúng đắn được vì class này immutable — và đó là cách mạnh nhất để nói immutability thật sự đem lại điều gì.

synchronized compile ra cái gì

Ai cũng biết synchronized là khóa. Ít người biết hai dạng của nó compile ra hai thứ hoàn toàn khác nhau, và javap giải quyết chuyện đó bằng một lệnh. Class như sau:

Java
public class SyncShape {
    private final Object lock = new Object();
    private int n;
 
    synchronized void method() { n++; }
 
    void block() {
        synchronized (lock) { n++; }
    }
}

Synchronized method không chứa một lệnh khóa nào cả:

Text
  synchronized void method();
    Code:
       0: aload_0
       1: dup
       2: getfield      #13    // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #13    // Field n:I
      10: return
Text
  synchronized void method();
    descriptor: ()V
    flags: (0x0020) ACC_SYNCHRONIZED

Cái khóa nằm ở một flag trong access modifier của method, và JVM lấy monitor khi vào, nhả khi ra — kể cả khi ra bằng exception — như một phần của việc gọi method. Synchronized block thì ngược lại: là lệnh thật, kèm một exception table với nhiệm vụ duy nhất là mở khóa trên đường đi ra.

Text
  void block();
    Code:
       0: aload_0
       1: getfield      #7     // Field lock:Ljava/lang/Object;
       4: dup
       5: astore_1
       6: monitorenter
       7: aload_0
       ...
      17: aload_1
      18: monitorexit
      19: goto          27
      22: astore_2
      23: aload_1
      24: monitorexit
      25: aload_2
      26: athrow
      27: return
    Exception table:
       from    to  target type
           7    19    22   any
          22    25    22   any

Hai lệnh monitorexit cho một monitorenter: một trên đường đi bình thường, một trong handler bắt mọi thứ rồi throw lại sau khi đã mở khóa. Đó cũng là câu trả lời cụ thể cho "một synchronized method khóa trên object nào?" — trên instance nếu là instance method, trên object Class nếu là static method, vì đó là thứ JVM đưa cho monitor khi nó thấy cái flag kia.

finally có luôn chạy không

Câu trả lời học thuộc là có. Câu trả lời đúng là "gần như luôn luôn, và đây là hai ngoại lệ, một trong hai là bug bạn nên đi tìm khi review".

Java
static int normal() {
    try { return 1; } finally { System.out.println("  finally ran"); }
}
 
static int swallows() {
    try { throw new IllegalStateException("the real cause"); }
    finally { return 2; }          // discards the exception entirely
}
 
static void exits() {
    try { System.exit(0); } finally { System.out.println("  never printed"); }
}
Text
  finally ran
normal()    -> 1
swallows()  -> 2
exits():

Ba kết quả trong một transcript. normal() cho thấy trường hợp bình thường: finally chạy sau khi giá trị trả về đã được tính và trước khi method thật sự return. swallows() trả về 2 và IllegalStateException bốc hơi — một return bên trong finally vứt bỏ mọi thứ đang bay, kể cả exception. Còn exits() không in gì sau dòng tiêu đề của nó và chương trình dừng ngay tại đó; dòng ngay sau lời gọi cũng không chạy, vì System.exit không unwind stack.

javac sẽ nói cho bạn biết về trường hợp thứ hai nếu bạn hỏi nó:

Bash
javac -Xlint:finally FinallyFacts.java
Text
FinallyFacts.java:8: warning: [finally] finally clause cannot complete normally
        finally { return 2; }          // discards the exception entirely
                            ^
1 warning

Các đường thoát còn lại là Runtime.halt, JVM crash, và mất điện. Ngoài ra tất cả — kể cả thread bị interrupt hay một Error đang lan lên — đều chạy finally.

this bên trong lambda trỏ vào cái gì

"Lambda chỉ là viết tắt của anonymous class" là câu trả lời sai, và một dòng output đủ để bác bỏ.

Java
Runnable anon = new Runnable() {
    @Override public void run() {
        System.out.println("anonymous this : " + this.getClass().getName());
    }
};
Runnable lambda = () ->
        System.out.println("lambda this    : " + this.getClass().getName() + ", name=" + name);
Text
anonymous this : LambdaThis$1
lambda this    : LambdaThis, name=outer
lambda object  : LambdaThis$$Lambda/0x000000c001000c20

Bên trong anonymous class, this là instance của chính anonymous class đó. Bên trong lambda, this là object bao ngoài — lambda không có this riêng và không mở ra một scope mới, đó cũng là lý do nó đọc thẳng được name và là lý do lambda không thể shadow một biến của method bao ngoài.

Tên class cho thấy nửa còn lại của khác biệt. LambdaThis$1 là một class file thật; type của lambda được LambdaMetafactory dựng lúc runtime và không có class file nào cả. Compile source rồi đếm thứ nằm trên đĩa:

Text
LambdaThis$1.class
LambdaThis.class

Hai file cho một class chứa một anonymous class và một lambda. Phần hex trong tên lambda khác nhau ở mỗi lần chạy, một gợi ý tốt rằng không có gì trên đĩa sinh ra nó.

Stream.toList() có nhận null không

Câu này phân biệt người đã dùng Stream.toList() với người mới đọc về nó. Có ba cách collect ra list và chúng khác nhau theo hai chiều.

Text
Stream.toList() with null    : [a, null, b]
its class                    : java.util.ImmutableCollections$ListN
Stream.toList() then add      : java.lang.UnsupportedOperationException
Collectors.toUnmodifiableList : java.lang.NullPointerException
Collectors.toList() class    : java.util.ArrayList
Collectors.toList() then add  : ok
after add                    : [a, null, b, c]

Stream.toList() (Java 16) trả về một list không sửa được nhưng cho phép null. Collectors.toUnmodifiableList() (Java 10) trả về một list không sửa được và không cho phép null — nó throw NullPointerException ngay lúc collect. Collectors.toList() cho bạn một ArrayList mutable bình thường và không hứa hẹn gì về kiểu của nó trong specification.

Vậy "dùng toList() đi, ngắn hơn" là đúng nhưng thiếu. Tóm tắt đúng là: toList() không sửa được và chịu null, toUnmodifiableList() không sửa được và từ chối null, còn Collectors.toList() là cái duy nhất bạn add vào được.

Nói gì khi bạn không biết

Phần này là nhận định, không phải đo đạc, và nó ngắn. Hãy nói là bạn không biết, rồi nói bạn sẽ tìm ra bằng cách nào — và nói cụ thể: "tôi sẽ kiểm bằng javap", "tôi sẽ đếm số statement bằng show-sql", "tôi sẽ lấy class histogram bằng jcmd". Đó là câu trả lời tốt hơn một bài đọc thuộc sai, và cũng đúng là câu bạn sẽ nói với đồng nghiệp. Người phỏng vấn nào trừ điểm câu đó thì đang cho bạn biết một thông tin hữu ích về công việc.

Lời khuyên nghề nghiệp, ghi rõ là quan điểm cá nhân

Không có gì trong mục này được đo đạc. Đây là quan điểm, hình thành từ việc quan sát điều gì phân biệt người càng ngày càng giỏi hơn với người dừng lại, và bạn nên cân nhắc nó với đúng trọng lượng đó.

Đọc source của JDK. Nó đi kèm ngay trong JDK, ở lib/src.zip, và IDE sẽ mở được nếu bạn trỏ vào đó. Câu trả lời về String.matches phía trên mất ba mươi giây để xác nhận, còn không thì đã là một phỏng đoán. ArrayList, HashMap, Optional, AbstractQueuedSynchronizer — đó là Java bình thường do những người cẩn thận viết, và đọc chúng là cách nhanh nhất để thôi coi thư viện chuẩn là phép thuật.

Học SQL cho tử tế. Vấn đề performance của Java nằm trong database nhiều hơn nằm trong Java. ORM bỏ giúp bạn phần code map từng dòng; nó không bỏ giúp bạn việc đọc execution plan, biết index làm gì, hay nhận ra một request vừa bắn đi 501 câu lệnh. Đây là kỹ năng có tỷ suất sinh lợi cao nhất trong những kỹ năng nằm cạnh Java.

Biết vì sao hơn biết là gì. Biết HashMap là O(1) thì giá trị thấp; biết cái gì làm nó thành O(n) — và biết một hashCode hằng số tốn năm mươi triệu phép so sánh, vì bạn đã đếm — thì giá trị rất cao. Loại kiến thức thứ nhất trả lời được một bài kiểm tra. Loại thứ hai sống sót qua một sự cố production, và nó chuyển được sang ngôn ngữ tiếp theo bạn học.

FAQ

Java có chậm không?

Không theo nghĩa mà câu hỏi ngụ ý. Thời gian khởi động chậm hơn một native binary và mỗi object đều có header, nhưng JIT của HotSpot compile code nóng ra machine code với thông tin profile mà một static compiler không có. Những vấn đề performance bạn thật sự gặp là những vấn đề trong bài này: allocate trong vòng lặp, sai cấu trúc dữ liệu, và database — không phải bản thân ngôn ngữ.

Có nên dùng JMH cho mọi câu hỏi về performance?

Không. Dùng nó khi bạn thật sự cần so hai cách cài đặt của cùng một đoạn code nhỏ. Với câu hỏi "vì sao endpoint này chậm", một profiler, một class histogram và một con số đếm statement đưa bạn tới đích nhanh hơn, và chúng đo hệ thống thật chứ không đo một vòng lặp nhân tạo.

Ghép String bằng dấu + có luôn tệ không?

Không. Trong một câu lệnh, nó compile thành một invokedynamic duy nhất và hoàn toàn ổn. Trong vòng lặp thì nó là bậc hai, vì mỗi vòng phải copy lại toàn bộ phần đã tích lũy. Có vòng lặp thì dùng StringBuilder; một câu lệnh thì dấu + là đủ.

Nên học thuộc bao nhiêu câu hỏi phỏng vấn Java?

Không câu nào, nếu học thuộc theo kiểu học câu chữ. Chọn các mảng trong bản đồ chủ đề và với mỗi mảng, chạy phần chứng minh đúng một lần — mốc cache của Integer, output javap của synchronized, counter equals cho một hashCode tệ. Mười phút tự chạy đáng giá hơn một tiếng đọc đáp án, và nó sống sót qua câu hỏi đuổi.

Stream.toList() khác Collectors.toList() ở đâu?

Stream.toList() trả về một list không sửa được nhưng cho phép phần tử null. Collectors.toList() trả về một list mutable — trên OpenJDK 21 là ArrayList, dù specification không hứa gì về kiểu. Nếu bạn cần vừa không sửa được vừa từ chối null thì đó là Collectors.toUnmodifiableList(), nó throw NullPointerException khi gặp phần tử null.

finally block có luôn được thực thi không?

Nó chạy ở mọi đường ra bình thường lẫn bất thường khỏi try. Nó không chạy nếu JVM dừng trước — System.exit, Runtime.halt, một cú crash. Và cẩn thận với return bên trong finally: nó lặng lẽ vứt bỏ exception đang lan lên. Bật javac -Xlint:finally để được cảnh báo về trường hợp đó.

Tìm memory leak trong một ứng dụng Java thế nào?

Xác nhận trước đã: heap phải không quay về mức nền sau một lần full GC. Sau đó jcmd <pid> GC.class_histogram để tìm class đang tích lũy, rồi một bản heap dump — jcmd <pid> GC.heap_dump file.hprof — mở bằng Eclipse MAT hoặc VisualVM để xem field nào đang giữ reference. Trong thực tế câu trả lời thường là một collection chỉ có thêm vào mà không có bớt ra.

Nên trả lời thế nào khi người phỏng vấn hỏi về điểm yếu của mình trong Java?

Trả lời bằng một điều cụ thể và có thật, rồi nói bạn đã làm gì với nó. "Tôi chưa làm với virtual thread cho tới gần đây, nên tôi đọc JEP và port thử một service nhỏ" là một câu trả lời thật. Một điểm yếu giả được tập trước thì không, và người phỏng vấn đã nghe nó rồi.

Kết luận

Quy tắc mà bài này đi theo cũng là thứ đáng mang đi nhất từ nó: hãy diễn đạt một khẳng định về performance bằng một con số đếm, không phải một khoảng thời gian. Allocation byte, số lần gọi equals(), số lần gọi compile(), số câu SQL — 1,63 GB so với 759 KB, 3.497.500 phép so sánh so với 1.000, 501 câu lệnh so với 1 — con số nào cũng lặp lại được trên một cái máy đang bận, và không con số nào cần tới đồng hồ bấm giờ. Đoán xem mình đang dính trap nào mới là sai lầm thật sự; cái counter gọi đúng tên nó luôn tồn tại, và thường chỉ cách bạn hai dòng code.

Bài này cũng khép lại cả hai course. Java Basics bắt đầu từ Java là gì và vì sao nó cần một JVM, rồi đi qua chu trình compile và chạy, variable và data type, operator và casting, string, input, câu điều kiện và vòng lặp, array một chiều và hai chiều, method cùng parameter, overload, scope và recursion, rồi phần lõi hướng đối tượng — class và object, constructor, this, staticfinal, encapsulation, inheritance, polymorphism, abstract class và interface — rồi exception, những collection đầu tiên, đọc ghi file, một project console, và các thói quen clean code làm cho tất cả phần còn lại đọc được.

Advanced Java tiếp nối từ đó: bốn nguyên lý OOP ở mức sâu hơn, nested và anonymous class, enum có hành vi riêng, generics và type erasure, SOLID cùng các design pattern bạn gặp trong codebase thật; collections framework một cách đầy đủ với Set, Map, DequePriorityQueue, iterator, comparator và các tiện ích Collections; stream, lambda, functional interface và Optional; concurrency từ thread và synchronized qua executor, CompletableFuture và deadlock; I/O và NIO, JSON, JDBC và connection pooling; JUnit 5, Mockito, debug và logging; Maven và Gradle, layered architecture, Spring Boot, REST và Spring Data JPA; và cuối cùng là một sales API hoàn chỉnh được dựng và test từ đầu tới cuối.

Cụ thể, sau bảy mươi lăm bài: bạn thiết kế được một cây class và bảo vệ được lựa chọn giữa interface và abstract class; chọn được collection từ kiểu truy cập và nói được nó tốn gì; viết được một stream pipeline và biết khi nào một vòng lặp thường lại rõ hơn; chạy được công việc trên thread pool mà không làm hỏng state dùng chung, và nhận ra được deadlock từ một thread dump; đọc ghi được file và JSON; nói chuyện được với database qua JDBC hoặc JPA và nhìn thấy SQL nó bắn ra; test được toàn bộ bằng JUnit và Mockito; build bằng Maven hoặc Gradle; và đặt tất cả sau một REST API Spring Boot. Đó là bộ đồ nghề của một Java developer đang đi làm, không phải của người mới bắt đầu.

Điều không có ở đây cũng đáng nói thẳng. Bảy mươi lăm bài không biến ai thành senior — chuyện đó đến từ việc bảo trì chính code bạn viết một năm trước, trong một hệ thống có người dùng thật và dữ liệu thật. Series này không có phần tiếp theo. Cái đến tiếp theo là một project của riêng bạn, source của JDK mỗi khi có gì làm bạn bất ngờ, và đúng thói quen mà cả loạt bài này được dựng lên từ đó: khi chưa chắc, hãy chạy nó và đọc output.

Bài viết liên quan

[Advanced Java] Nested class, Inner class, Local class và Anonymous class trong Java

Static nested, inner, local và anonymous class trong Java trên OpenJDK 21: field synthetic this$0 nhìn thấy bằng javap, Outer.this và outer.new Inner(), memory leak do inner class gây ra, luật effectively final khi capture, class file Outer$1, và so sánh cụ thể giữa anonymous class với lambda.

[Advanced Java] ExecutorService và Thread Pool trong Java

ExecutorService và thread pool trên OpenJDK 21: bảy argument của ThreadPoolExecutor, vì sao queue không giới hạn khiến maximumPoolSize vô dụng, default bị giấu sau mỗi factory của Executors, submit khác execute và exception mà submit nuốt mất, Callable, Future, cancel, invokeAll và invokeAny, trình tự shutdown đúng, và cách chọn số thread một cách trung thực.

[Advanced Java] Deadlock, Livelock trong Java và cách phòng tránh

Deadlock và livelock trong Java trên OpenJDK 21: bốn điều kiện Coffman, một deadlock tái hiện được mọi lần, cách chẩn đoán bằng thread dump thật từ jstack và ThreadMXBean.findDeadlockedThreads, phòng tránh bằng lock ordering toàn cục và ReentrantLock.tryLock kèm back-off, deadlock trong thread pool, livelock, starvation và cái giá của fair lock.

[Advanced Java] Maven và Gradle: Quản lý Dependency và Build Project Java

Maven và Gradle cho project Java: pom.xml và build.gradle, coordinates, layout chuẩn, lifecycle của Maven so với task graph của Gradle, transitive dependency với một version conflict thật được hai tool resolve khác nhau, scope và configuration, exclude và pin version, chạy JUnit 5 từ build, jar chạy được, và wrapper.