Câu hỏi phỏng vấn Java Collections
Bộ câu hỏi này harvest từ khoá Java OO & Functional và Java Internals — mỗi câu có link bài học mổ tới tận source JDK, không dừng ở đáp án thuộc lòng.
01
Khi nào bạn chọn ArrayList, khi nào chọn LinkedList?
JuniorThực tế là gần như luôn chọn
ArrayList—LinkedListchỉ thắng đúng một tình huống hẹp là thêm/xoá ở đầu list, mà tình huống đóArrayDequecòn làm tốt hơn. Lý do nằm ở memory layout:ArrayListgiữ element trên một mảng liền kề nên CPU load một cache line 64 byte là có sẵn 8 reference, cònLinkedListbọc mỗi element trong mộtNoderải rác heap, mỗi lần đinextlà một cache miss. Kèm theo đó mỗi node tốn khoảng 40 byte so với 8 byte một reference củaArrayList, tức gấp 5 lần bộ nhớ và tạo áp lực GC tương ứng. Ngay cả chèn giữa, nơi Big-O ngang nhau,ArrayListthường thắng vìSystem.arraycopylà JIT intrinsic ánh xạ thẳng sang lệnh copy bộ nhớ của CPU, trong khiLinkedListphải duyệt tới vị trí đó trước. Bẫy hay gặp nhất là gọilist.get(i)trong vòng lặp trênLinkedList— mỗigetlà O(n) nên cả vòng lặp thành O(n²).Trả lời thuộc bảng Big-O kiểu "LinkedList chèn O(1) nên nhanh hơn" mà không biết Big-O không tính chi phí mỗi bước: cache miss và việc cấp phát node mới mới là thứ quyết định thực tế.02
ArrayList hết chỗ thì chuyện gì xảy ra bên trong?
JuniorArrayListgiữ một mảngObject[] elementData; khisizechạmelementData.length,grow()cấp một mảng mới rồiArrays.copyOftoàn bộ phần tử sang. Capacity mới tính bằngoldCapacity + (oldCapacity >> 1), tức tăng 1.5 lần bằng một phép dịch bit chứ không phải nhân số thực. Mảng đầu tiên chỉ được cấp ở lầnadd()đầu tiên vớiDEFAULT_CAPACITY = 10— list vừa khởi tạo mà chưa dùng thì không tốn bộ nhớ mảng. JDK source không ghi chính thức vì sao chọn 1.5x thay vì 2x; lập luận được trích dẫn nhiều nhất là bộ nhớ: lãng phí trung bình khoảng 17% thay vì 25%, và vì 1.5 nhỏ hơn tỉ lệ vàng φ xấp xỉ 1.618 nên tổng các block đã giải phóng đủ chỗ cho block mới, allocator tái sử dụng được. Mỗi lần copy là O(n) nhưng trung bình vẫn là O(1) amortized, và biết trước số phần tử thìnew ArrayList<>(n)xoá sạch mọi lần grow.Nói "ArrayList tăng gấp đôi" — gấp đôi làVectorvàHashMap, cònArrayListtăng 1.5x; nhiều người cũng không biết mảng 10 phần tử chỉ được cấp ở lầnadd()đầu tiên chứ không phải lúcnew.03
Giải thích cho tôi HashMap lưu và tìm một entry như thế nào.
JuniorHashMaplà một mảng bucketNode<K,V>[] tablecó length luôn là luỹ thừa của 2. Khiput, nó lấykey.hashCode()rồi trộn bit cao xuống thấp bằngh ^ (h >>> 16), sau đó tính chỉ số bucket bằng(table.length - 1) & hash— phép AND bit rẻ hơn chia lấy dư và cho cùng kết quả nhờ capacity là luỹ thừa của 2. Bucket trống thì gắnNodemới vào là xong, đó là O(1) thật; bucket đã có entry thì duyệt linked list trong bucket, sohash(một phép so int rất rẻ) trước rồi mới gọiequals().getđi đúng đường đó: tính bucket, duyệt chain, trả value khi khớp cả hash lẫn equals. Khisizevượtthreshold = capacity × 0.75, table nhân đôi và phân bố lại toàn bộ entry.Dừng ở "HashMap dùng hashCode để lưu" mà không nói được bucket index tính ra sao, cũng không biết vì sao capacity bắt buộc là luỹ thừa của 2.04
Vì sao class dùng làm key của HashMap phải override cả equals lẫn hashCode?
JuniorVì
HashMapdùng hai thứ cho hai việc khác nhau:hashCodeđể tìm ra bucket,equalsđể tìm đúng key bên trong bucket đó. Không override thì bản mặc định củaObjectso sánh theo identity, nênmap.getvới một object mới cùng nội dung sẽ trảnulldù bạn vừaputnó vào. Ràng buộc bắt buộc là hai objectequalsnhau thì phải có cùnghashCode; chiều ngược lại không cần đúng, hai object khác nhau vẫn được phép trùng hash và khi đó chúng nằm chung một bucket. Cách an toàn là override cả hai trên cùng bộ field để hợp đồng không bị lệch. Bẫy nặng hơn là key mutable: nếuhashCodephụ thuộc field bị sửa sau khiput, entry kẹt lại ở bucket cũ nêngettrảnullvà entry đó vĩnh viễn không tìm lại được — nên dùng fieldfinalhoặcrecordlàm key.Chỉ đọc thuộc "equals bằng nhau thì hashCode bằng nhau" mà không nói được hai method dùng ở hai bước khác nhau của quá trình tra cứu, và không nhắc gì tới rủi ro key mutable.05
HashSet lưu dữ liệu bằng cấu trúc gì bên dưới?
JuniorHashSetkhông có logic lưu trữ riêng — bên trong nó giữ mộtHashMap<E, Object>và mọiadd(e)thực chất làmap.put(e, PRESENT)vớiPRESENTlà một object hằng dùng chung. Vì vậy mọi đặc tính củaHashSetđều thừa hưởng từHashMap:add/contains/removelà O(1) trung bình, key phải cóequalsvàhashCodeđúng, và thứ tự iterate không xác định. Cùng kiểu quan hệ đó,LinkedHashSetbọcLinkedHashMapnên giữ thứ tự chèn, cònTreeSetbọcTreeMapnên sorted và mọi thao tác là O(log n). Một khác biệt hay bị hỏi thêm:HashSetcho phép một phần tửnull, cònTreeSetthì không vì nó phải gọicompareTo.Trả lời "HashSet là tập hợp không chứa phần tử trùng" rồi dừng — mất luôn cơ hội giải thích vì sao Set lại cần equals/hashCode y hệt Map.06
Vì sao xoá phần tử trong vòng for-each lại ném ConcurrentModificationException?
Juniorfor-eachchỉ là cú pháp đường choIterator, vàArrayListđếm mọi thay đổi cấu trúc bằng biếnmodCount. Lúc tạo iterator, nó chụpexpectedModCount = modCount; mỗi lầnnext()gọicheckForComodification()để so hai giá trị, lệch là némConcurrentModificationException. Gọilist.remove(o)đi đường ngoài làmmodCounttăng mà iterator không biết nên vòng kế tiếp là nổ — ngoại lệ tinh vi là xoá đúng phần tử kế cuối, khi đóhasNext()trả false nên vòng lặp thoát sớm và bug lọt lưới. Ngược lạiit.remove()thì iterator tự gán lạiexpectedModCount = modCountnên an toàn. Cách viết nên dùng làlist.removeIf(...), vìArrayListoverrideremoveIfbằng thuật toán hai lượt vớiBitSetchạy O(n), trong khiit.remove()thủ công phải dịch mảng ở mỗi lần xoá. Lưu ý fail-fast chỉ là công cụ phát hiện bug: Javadoc ghi rõ nó là best-effort chứ không phải bảo đảm thread-safety.Chỉ nói "không được sửa collection khi đang duyệt" mà không giải thích được modCount/expectedModCount, và nhầm fail-fast là một cơ chế thread-safe.07
Comparable và Comparator khác nhau chỗ nào?
JuniorComparablenằm bên trong class và định nghĩa thứ tự tự nhiên duy nhất quacompareTo, cònComparatorlà hàm so sánh bên ngoài nên một class có bao nhiêu cách sắp xếp cũng được.Comparatorlà@FunctionalInterfacenên viết được dạng lambda hoặc method reference, và có fluent API kiểuComparator.comparingInt(User::getAge).thenComparing(User::getName).reversed(). DùngComparablekhi class có thứ tự hiển nhiên nhưIntegerhayLocalDate; dùngComparatorkhi cần nhiều tiêu chí hoặc khi class thuộc thư viện ngoài không sửa được. Cả hai phải giữ cùng một hợp đồng: đối xứng dấu, bắc cầu, và khuyến nghị mạnh là nhất quán vớiequals. Lỗi kinh điển là viếtreturn this.value - other.value— phép trừinttràn số khi gặpInteger.MIN_VALUE, nên luôn dùngInteger.comparehoặcDouble.compare.Trả lời đúng định nghĩa nhưng không nêu được hợp đồng của compareTo, và vẫn viếta - btrongcompareTo— đó là bug tràn số chỉ lộ ra ở giá trị biên nên test thường không bắt được.08
Khi nào bạn chọn TreeMap thay cho HashMap?
MidChọn
TreeMapkhi cần thứ tự: duyệt key theo thứ tự sắp xếp, truy vấn khoảng bằngsubMap/headMap/tailMap, hoặc tìm lân cận bằngceilingKey/floorKey. Bên dưới nó là cây đỏ đen tự cân bằng nên mọi thao tác là O(log n) kể cả worst case, khácHashMapvốn O(1) trung bình nhưng suy biến khi hash xấu. Giá phải trả là bộ nhớ và tốc độ: mỗi entry củaTreeMapmang thêm ba con trỏleft/right/parentcùng một cờ màu, và việc nhảy con trỏ giữa các node gây cache miss nên lookup thường chậm hơnHashMapkhoảng 2 đến 10 lần.TreeMapcũng không nhận keynullvì phải gọicompareTo, trong khiHashMapcho phép một keynull. Nếu chỉ cần giữ thứ tự chèn chứ không cần sắp xếp thìLinkedHashMapnhẹ hơnTreeMap: vẫn O(1) nhưHashMap, chỉ trả thêm hai con trỏprev/nextmỗi entry.Nói "TreeMap có sắp xếp còn HashMap thì không" rồi dừng — bỏ mất phần đắt giá là range query, worst case ổn định, và cái giá cache miss của cây.09
Java 8 thay đổi gì trong HashMap để chống hash collision, và vì sao ngưỡng là 8?
MidJava 8 (JEP 180) cho phép biến một bucket thành cây đỏ đen khi chain quá dài, đưa worst case lookup từ O(n) xuống O(log n). Điều kiện là kép: chain phải đạt
TREEIFY_THRESHOLD = 8và table phải có từMIN_TREEIFY_CAPACITY = 64bucket trở lên; table nhỏ hơn thìHashMapgọiresize()vì nhân đôi số bucket để phân tán key rẻ hơn là dựng cây. Ngưỡng 8 lấy từ phân phối Poisson tham số khoảng 0.5 với load factor 0.75 — xác suất một bucket chứa đúng 8 entry nhỏ hơn một phần mười triệu, nên chạm ngưỡng gần như chắc chắn là hash bị lạm dụng chứ không phải workload bình thường. Chiều ngược lại dùngUNTREEIFY_THRESHOLD = 6để tạo hysteresis, tránh dựng rồi phá cây liên tục khi size dao động quanh ngưỡng. Đây là phòng thủ chống hash-collision DoS chứ không phải tối ưu: attacker vẫn ép được O(log n) nên tầng giới hạn số key trong request và rate limiting vẫn phải đứng trước.Chỉ nhớ mỗi con số 8 mà quên điều kiện 64 — nói "cứ đủ 8 phần tử là bucket thành cây" là sai, dưới 64 bucket thì HashMap chọn resize.10
HashMap resize thế nào, và vì sao nói get là O(1) chỉ đúng ở mức amortized?
MidResize kích hoạt khi
sizevượtthreshold = capacity × 0.75: cấp một table mới gấp đôi rồi phân bố lại toàn bộ entry. Java 8 phân bố lại rất rẻ vì nhân đôi capacity chỉ thêm đúng một bit vào bitmask chỉ số — kiểm tra(hash & oldCap)bằng 0 thì entry ở nguyên bucket cũ, khác 0 thì sang bucketcũ + oldCap, không phải gọi lạihashCode()lần nào. Nhưng cáiputnào chạm ngưỡng thì chính nó gánh cả O(n) rehash, nên O(1) là con số trung bình trên cả chuỗi thao tác chứ không phải bảo đảm cho từng thao tác. Với đường đi nhạy độ trễ, đó là lý do phải pre-size bằngHashMap.newHashMap(n)từ Java 19, hoặcnew HashMap<>((int)(n / 0.75 + 1))ở bản cũ hơn. Truyền thẳngnlàm initial capacity là bẫy, vì threshold khi đó chỉ bằngn × 0.75nên map vẫn resize trước khi chứa đủnentry.Nói "HashMap tự resize khi đầy" rồi dừng, và lúc tối ưu thì truyền thẳng số entry mong đợi vào constructor mà quên chia cho load factor.11
Vì sao
MidList<Integer>không gán được choList<Number>, trong khiInteger[]lại gán được choNumber[]?Generics của Java là invariant:
List<Integer>vàList<Number>là hai kiểu độc lập, dùIntegerlà subtype củaNumber. Nếu cho phép gán, bạn sẽaddđược mộtDoublevào list thực chất chỉ chứaInteger, và cú nổClassCastExceptionxảy ra tận lúc đọc ra — đúng thứ generics sinh ra để chặn. Mảng thì ngược lại, covariant từ Java 1.0 khi chưa có generics, nên gánNumber[] nums = intsbiên dịch được vànums[0] = 3.14némArrayStoreExceptionlúc chạy. Nói cách khác cùng một lỗi: mảng bắt ở runtime bằng arraystore check tốn chi phí mỗi lần ghi, còn generics bắt ngay lúc biên dịch và không tốn gì lúc chạy. Khi thật sự cần nhận mọi list số, ký hiệu đúng là wildcardList<? extends Number>— nhận được cảList<Integer>lẫnList<Double>nhưng chỉ đọc, không ghi.Trả lời "vì Java quy định thế" — người phỏng vấn chờ bạn dựng lại ví dụ add mộtDoublevàoList<Integer>để chỉ ra invariance là lựa chọn thiết kế đổi lấy an toàn kiểu ngay tại compile time.12
PECS là gì? Cho tôi một ví dụ trong JDK.
MidPECS là Producer Extends, Consumer Super: tham số mà bạn lấy dữ liệu ra thì khai wildcard
extends, tham số mà bạn đẩy dữ liệu vào thì khaisuper. List khaiextendschỉ đọc được, vì compiler không biết kiểu thật làIntegerhayDoublenên cấm mọiaddtrừnull; list khaisuperthì ngược lại,addmộtTluôn hợp lệ nhưng lấy phần tử ra chỉ chắc chắn được kiểuObject. Ví dụ chuẩn trong JDK là chữ kýCollections.copy(List<? super T> dest, List<? extends T> src)— nguồnsrcsản xuất nên dùngextends, đíchdesttiêu thụ nên dùngsuper, còn type parameter<T>làm cầu nối để compiler chứng minh mọi thứ đọc từsrcđều ghi được vàodest. Effective Java Item 31 khuyên API public dùng wildcard cho mềm dẻo, còn bên trong thì chuyển về type parameter qua một helper method.Đọc thuộc chữ viết tắt nhưng không giải thích được vì sao? extendslại cấmadd— đó mới là phần chứng tỏ bạn hiểu cơ chế chứ không học vẹt.13
Type erasure là gì, và nó chặn bạn làm những gì?
MidCompiler xoá type parameter khi biên dịch:
Tbị thay bằngObject, hoặc bằng bound nếu khaiT extends Number, rồi tự chèn cast ở mọi chỗ caller lấy giá trị ra. Hệ quả trực tiếp là runtime không còn thông tin kiểu: không viết đượcnew T(),obj instanceof T,new T[10], và không cho generic trênThrowable. Hai methodprocess(List<String>)vàprocess(List<Integer>)cũng không cùng tồn tại được vì erase ra cùng một chữ ký. Kẽ hở đi kèm là heap pollution: qua một reference raw type có thể nhétIntegervàoList<String>, vàClassCastExceptionnổ ở dòngget()chứ không phải dòng gây lỗi. Workaround quen thuộc là truyềnClass<T>token rồi dùngtype.isInstance/type.cast, hoặcSupplier<T>thay chonew T(); lý do Java chọn erasure là để code viết trước Java 5 chạy tiếp mà không phải biên dịch lại.Trả lời "generics bị xoá lúc runtime" rồi hết — không nêu được hệ quả cụ thể lẫn lý do lịch sử là tương thích ngược, vốn cũng là lý do Java khác C#.14
List.of, List.copyOf và Collections.unmodifiableList khác nhau thế nào?
MidList.of(...)(Java 9, JEP 269) tạo list bất biến thật, không có collection mutable nào bên dưới và không nhận phần tửnull.List.copyOf(coll)sao chép sang một list bất biến độc lập nên sửa collection nguồn sau đó không ảnh hưởng gì; nếu đầu vào vốn đã bất biến thì nó trả lại chính reference đó thay vì copy.Collections.unmodifiableListchỉ là lớp bọc chỉ-đọc trên list gốc — ai còn giữ reference gốc vẫnaddđược và view phản ánh thay đổi đó ngay, nên nó không phải bất biến. Vì vậy pattern bảo vệ state là defensive copy ngay trong constructor bằngList.copyOf, và Effective Java Item 50 nhấn mạnh phải copy trước rồi mới validate để tránh lỗ hổng TOCTOU. Hai cách đầu chỉ bất biến ở tầng cấu trúc: list chứa object mutable thì nội dung của object đó vẫn sửa được, đây là shallow immutability.CoiCollections.unmodifiableListlà immutable — nó chỉ chặn người cầm wrapper chứ không chặn người còn giữ list gốc, và đây là bug aliasing kinh điển trong code production.15
Làm sao dựng một LRU cache chỉ bằng thư viện chuẩn của Java?
MidDùng
LinkedHashMapvới constructor ba tham sốnew LinkedHashMap<>(16, 0.75f, true): cờaccessOrder = truekhiến mỗi lầngethayputđẩy entry vừa chạm xuống cuối danh sách liên kết, nên đầu danh sách luôn là entry ít dùng gần đây nhất. Sau đó overrideremoveEldestEntrytrảsize() > maxSize— hook này được gọi ngay sau mỗiput, trảtruethì entry đầu bị evict, thế là xong LRU trong vài dòng. Cạm bẫy lớn nhất là thread-safety: vớiaccessOrder = true,getkhông còn là thao tác chỉ đọc mà sửa con trỏprev/nextcủa danh sách bên trong. Vì vậy không được dùng read lock củaReentrantReadWriteLockchoget— nhiều thread đọc song song sẽ phá cấu trúc danh sách, hậu quả là dữ liệu sai hoặc CPU treo 100% vì duyệt phải một vòng lặp, nên mọi thao tác phải đi qua một lock ghi duy nhất. Hệ thống chịu tải cao thì nên dùng thư viện như Caffeine thay vì tự chế, vì nó tối ưu sẵn cho đa luồng và có cả TTL.Trả lời "tự viết HashMap cộng doubly-linked list" mà không biết JDK đã bày sẵnLinkedHashMap(accessOrder=true), và gần như ai cũng quên rằnggetở chế độ này thực chất là một thao tác ghi.
Topic kế cùng track: Câu hỏi phỏng vấn Java OOP →
Trả lời trôi chảy bắt đầu từ hiểu cơ chế
Mỗi câu ở trên đều có bài học đứng sau. Học tuần tự cả khoá Java OO & Functional để không chỉ trả lời được, mà giải thích được vì sao.