Method reference và invokedynamic — lambda bên dưới bytecode
4 dạng method reference với ::, khi nào nên dùng thay lambda, và cơ chế invokedynamic + LambdaMetafactory — vì sao lambda không phải anonymous inner class, capturing vs non-capturing lambda khác gì về allocation.
TL;DR: Method reference (::) là cú pháp thay lambda khi lambda chỉ forward sang một method có sẵn — 4 dạng: static method, instance method của object cụ thể, instance method của kiểu tuỳ ý, và constructor. Bên dưới, lambda không compile thành anonymous inner class: compiler sinh instruction invokedynamic (hạ tầng JSR 292), đến runtime LambdaMetafactory mới sinh class ẩn trong memory và link qua MethodHandle. Hệ quả thực dụng: lambda non-capturing được tái dùng như singleton (zero-allocation), còn lambda capturing tạo instance mới mỗi lần đi qua dòng khai báo — đáng chú ý trong vòng lặp lớn. Hiểu cơ chế này giúp bạn đọc bytecode bằng javap, lý giải hiệu năng, và tránh ngộ nhận "lambda chỉ là syntax sugar của inner class".
Ở bài 01 bạn đã viết lambda và biết compiler map nó vào functional interface qua target type. Bài này trả lời hai câu hỏi tiếp theo: (1) khi lambda chỉ gọi một method có sẵn, có cách viết nào gọn hơn không — method reference; (2) lambda thực sự compile thành cái gì — không phải anonymous inner class như nhiều người nghĩ, mà là invokedynamic, một trong những thay đổi bytecode đáng kể nhất của JVM hiện đại.
1. Method reference — :: cho lambda ngắn
Khi lambda chỉ gọi 1 method có sẵn và truyền argument thẳng vào, đổi sang method reference gọn hơn:
// Lambda dai
Function<String, Integer> len = s -> s.length();
// Method reference
Function<String, Integer> len = String::length;
Hai dòng tương đương về semantics — compiler tự biến method reference thành lambda tương ứng.
2. 4 dạng method reference
| Dạng | Cú pháp | Ví dụ | Tương đương lambda |
|---|---|---|---|
| Static method | ClassName::staticMethod | Integer::parseInt | s -> Integer.parseInt(s) |
| Instance method của object cụ thể | instance::method | System.out::println | s -> System.out.println(s) |
| Instance method của kiểu tuỳ ý | ClassName::instanceMethod | String::length | s -> s.length() |
| Constructor | ClassName::new | ArrayList::new | () -> new ArrayList() |
Dạng 2 và 3 dễ nhầm nhất. Phân biệt:
System.out::println:System.outlà instance có sẵn (biến static). Methodprintlnchạy trên instance đó. Lambda:s -> System.out.println(s).String::length:Stringlà class, chưa có instance. Compiler hiểu: instance sẽ là argument đầu tiên của lambda. Lambda:s -> s.length()—slà instance, method gọi trên nó.
Cách nhớ: nếu chỗ đầu :: là class name, argument đầu lambda đóng vai instance. Nếu là instance (biến), method gọi trên instance đó, argument lambda là argument method.
Constructor reference (ClassName::new) còn một điểm thú vị: cùng cú pháp nhưng kiểu được suy theo target type. ArrayList::new là Supplier<List<String>> khi target không có tham số, nhưng là Function<Integer, ArrayList<String>> khi target nhận 1 int — compiler chọn constructor overload ArrayList(int initialCapacity) tương ứng.
3. Khi nào nên dùng method reference?
Nên khi lambda chỉ là forward đến 1 method:
stream.map(s -> s.toUpperCase()); // Co the thay
stream.map(String::toUpperCase); // Ngan, intent ro
Không nên khi lambda làm nhiều hơn:
stream.map(s -> "[" + s + "]"); // Giu lambda - method reference khong viet duoc
stream.map(s -> s.length() + 10); // Giu lambda
Đừng cố chuyển mọi lambda thành method reference. Code đọc được quan trọng hơn ngắn.
4. Lambda bên dưới — invokedynamic, không phải inner class
Một câu hỏi nhiều dev thắc mắc: lambda có phải là anonymous inner class viết tắt? Không. Java 8 sử dụng một cơ chế hoàn toàn khác ở cấp độ bytecode.
Nếu là inner class (Cách tiếp cận truyền thống)
Nếu Java dịch lambda thành anonymous inner class, mỗi lambda biểu diễn trong code sẽ compile thành một file .class vật lý riêng biệt trên đĩa cứng:
MyApp$1.class // cho lambda thu 1
MyApp$2.class // cho lambda thu 2
Việc này dẫn đến những hệ quả tiêu cực:
- Phình to bộ nhớ (Metaspace Footprint): Classloader buộc phải nạp hàng nghìn class vật lý này vào JVM Metaspace, tiêu tốn bộ nhớ đáng kể.
- Tốn chi phí khởi tạo: Mỗi khi tạo một lambda instance, JVM phải thực hiện cấp phát bộ nhớ trên Heap và chạy constructor của anonymous class đó, tăng gánh nặng cho Garbage Collector (GC).
Thực tế: invokedynamic (JSR 292)
Để giải quyết vấn đề này, kể từ Java 8, compiler sử dụng instruction invokedynamic (indy) — hạ tầng được giới thiệu từ JSR 292 (Java 7, ban đầu phục vụ dynamic language trên JVM). Thay vì tạo ra class vật lý tại thời điểm compile, Java hoãn việc định nghĩa class thực tế đảm nhận logic của lambda cho đến runtime.
Quá trình này diễn ra như sau:
- Compile-time: Compiler sinh ra một chỉ thị
invokedynamictrỏ tới một bootstrap method (phương thức mồi) nằm trong JDK làLambdaMetafactory.metafactory. Logic thực tế của body lambda được tách ra thành một private method (thường là static) ngay trong chính class khai báo nó. - Runtime (Lần đầu tiên chạy qua): JVM thực thi bootstrap method
LambdaMetafactory.metafactory. Phương thức này sinh động một class ẩn trong bộ nhớ (anonymous class ở mức JVM, không có file.classvật lý) thực thi functional interface đích, đồng thời trả về mộtCallSitechứa liên kết trực tiếp (MethodHandle) đến private method chứa logic lambda. - Runtime (Các lần chạy sau): JVM bỏ qua bước bootstrap và gọi thẳng qua
MethodHandleđã được link.
Vai trò cốt lõi của LambdaMetafactory.metafactory:
- Tối ưu hóa inlining: Nhờ sử dụng
MethodHandlethay cho cơ chế reflection truyền thống, JIT Compiler có thể inline logic của lambda trực tiếp vào nơi gọi, loại bỏ chi phí gián tiếp (indirection overhead). - Giảm classloader footprint: Do các class thực thi lambda được sinh động dưới dạng lightweight anonymous class ở runtime, không qua quy trình nạp class file truyền thống, footprint của classloader và Metaspace giảm đáng kể.
Tự kiểm chứng: chạy javap -p -c trên class có lambda, bạn sẽ thấy instruction invokedynamic cùng tham chiếu LambdaMetafactory trong constant pool — không có file MyApp$1.class nào được sinh ra.
5. Capturing vs non-capturing lambda — khác biệt allocation
Hiệu năng thực tế của lambda phụ thuộc lớn vào việc nó có tham chiếu đến các biến ở scope bên ngoài hay không:
- Non-capturing lambda (zero-allocation):
- Là lambda không tham chiếu đến bất kỳ biến local hay instance field nào ở scope bên ngoài.
- Cơ chế:
LambdaMetafactorykhởi tạo lambda này dưới dạng một static singleton instance duy nhất. Mọi lượt chạy qua vị trí này trong suốt vòng đời ứng dụng đều tái sử dụng chung một đối tượng trên Heap — zero-allocation, thân thiện với Garbage Collector.
- Capturing lambda (heap allocation):
- Là lambda tham chiếu (capture) ít nhất một biến từ scope bên ngoài.
- Cơ chế: Vì lambda cần mang theo trạng thái của các biến được capture, JVM tạo một instance mới trên Heap mỗi lần đi qua dòng khai báo lambda để lưu các giá trị capture (tương tự truyền tham số vào constructor). Điều này phát sinh chi phí cấp phát bộ nhớ và tăng áp lực GC khi chạy trong các vòng lặp lớn.
// Non-capturing: cung 1 instance moi vong lap
for (int i = 0; i < 1_000_000; i++) {
Runnable r = () -> System.out.println("hi"); // singleton, zero-allocation
}
// Capturing: 1 instance MOI moi vong lap
for (int i = 0; i < 1_000_000; i++) {
final int snapshot = i;
Runnable r = () -> System.out.println(snapshot); // 1 trieu allocation
}
Ưu tiên viết non-capturing lambda khi có thể. Nếu bắt buộc phải capture trạng thái trong hot loop, cân nhắc chuyển cấu trúc hoặc truyền tham số tường minh để tận dụng cơ chế tái sử dụng instance của JVM. Với code thường, đừng tối ưu sớm — allocation của capturing lambda rất rẻ so với I/O hay DB call.
6. Pitfall tổng hợp
❌ Nhầm 1: Method reference nhầm chỗ — method trả void đưa vào map.
list.stream().map(s -> s.toUpperCase()); // Lambda
list.stream().map(String::toUpperCase); // Method ref - OK
list.stream().map(System.out::println); // Loi! Lambda tra void, map can R
✅ Dùng forEach(System.out::println) thay map cho print.
❌ Nhầm 2: Cố ép mọi lambda thành method reference.
stream.map(s -> "[" + s + "]"); // Khong co method nao tuong duong de reference
✅ Method reference chỉ áp dụng khi lambda forward nguyên vẹn sang 1 method. Lambda có thêm logic — giữ lambda.
❌ Nhầm 3: Tưởng lambda là anonymous inner class viết tắt.
Runnable r = () -> System.out.println(this); // this = outer class, KHONG phai "lambda instance"
✅ Lambda compile qua invokedynamic, không sinh file .class riêng, this inherit từ scope ngoài (xem bài 01). Anonymous inner class mới có this riêng.
7. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §15.13 Method Reference — 4 dạng method reference chi tiết, rule chọn overload.
- LambdaMetafactory — class JDK sinh implementation lambda ở runtime.
- JSR 292: Supporting Dynamically Typed Languages — spec gốc của
invokedynamicvàMethodHandle(Java 7). - Translation of Lambda Expressions — Brian Goetz — tài liệu thiết kế chính thức giải thích vì sao JDK team chọn invokedynamic thay vì inner class.
Ghi chú: Tài liệu của Brian Goetz (architect Java language) phân tích các phương án bị loại (inner class, MethodHandle thuần) và lý do chọn invokedynamic: "binary compatibility" — chiến lược dịch lambda có thể thay đổi ở các JDK sau mà không cần recompile code cũ.
8. Tóm tắt
- Method reference
::— 4 dạng: static (Integer::parseInt), instance cụ thể (System.out::println), instance tuỳ ý (String::length), constructor (ArrayList::new). - Phân biệt nhanh: trước
::là class name → argument đầu lambda đóng vai instance; là biến/instance → method chạy trên biến đó. - Chỉ dùng method reference khi lambda forward nguyên vẹn sang 1 method — code đọc được quan trọng hơn ngắn.
- Lambda compile thành
invokedynamic+ bootstrapLambdaMetafactory.metafactory, không phải anonymous inner class — không có file.classriêng, class ẩn sinh ở runtime. invokedynamicthuộc hạ tầng JSR 292 (Java 7); Java 8 tái dụng nó cho lambda.- Non-capturing lambda = static singleton, zero-allocation. Capturing lambda = instance mới mỗi lần tạo — đáng chú ý trong vòng lặp lớn.
- Kiểm chứng bằng
javap -p -c: thấyinvokedynamicvà private method chứa body lambda.
9. Tự kiểm tra
Q1Method reference String::length tương đương lambda nào, và khác System.out::println ở điểm gì?▸
String::length tương đương lambda nào, và khác System.out::println ở điểm gì?String::length tương đương (String s) -> s.length() — kiểu Function<String, Integer>.
Điểm khác biệt then chốt: String là class name, không phải instance. Compiler hiểu method reference dạng này là "instance method của kiểu tuỳ ý" — argument đầu của lambda đóng vai instance để gọi method trên.
System.out::println: System.out là instance cụ thể (static field `out` của `System` class). Method println gọi trên instance đó, argument lambda là argument của println.
Phân biệt nhanh: chỗ trước :: là class name → argument đầu lambda đóng vai instance. Là biến/instance → method chạy trên biến đó, lambda argument là method argument.
Q2Vì sao Java 8 dùng invokedynamic cho lambda thay vì compile thành anonymous inner class?▸
Anonymous inner class tốn:
- 1 file
.classriêng cho mỗi lambda ở compile time → tốn metaspace, tốn JIT compilation time. - 1 instance heap mỗi lần tạo — kể cả lambda stateless (không capture).
Lambda dùng phổ biến hơn nhiều (stream, callback, listener) — tối ưu cần thiết.
invokedynamic (JSR 292) để JVM quyết định runtime: lần đầu chạy, sinh implementation class qua LambdaMetafactory; cache lại. Lambda stateless tái dùng single instance — không tốn heap mỗi lần tạo. Lambda capturing mới tạo instance mới.
Kết quả: code với 1000 lambda chạy nhanh hơn, dùng ít memory hơn code với 1000 anonymous class. javap -p -c xem bytecode sẽ thấy invokedynamic + LambdaMetafactory.
Q3Vì sao cùng một method reference ArrayList::new lúc là Supplier<List<String>>, lúc lại là Function<Integer, ArrayList<String>>?▸
ArrayList::new lúc là Supplier<List<String>>, lúc lại là Function<Integer, ArrayList<String>>?Constructor reference không tự mang kiểu — giống lambda, nó được suy từ target type. ArrayList có nhiều constructor overload: ArrayList() và ArrayList(int initialCapacity).
Khi target type là Supplier<List<String>> (SAM get() không tham số), compiler chọn constructor không tham số. Khi target type là Function<Integer, ArrayList<String>> (SAM apply(Integer)), compiler chọn overload nhận int.
Cơ chế chọn overload này đặc tả trong JLS §15.13.1 — cùng quy tắc resolution như gọi method bình thường, nhưng "argument" lấy từ signature của SAM thay vì từ code tường minh.
Q4Capturing và non-capturing lambda khác nhau thế nào về allocation? Vì sao điều này đáng quan tâm trong vòng lặp lớn?▸
Non-capturing (không đụng biến ngoài scope): LambdaMetafactory tạo đúng 1 singleton instance, mọi lần chạy qua dòng khai báo đều tái dùng — zero-allocation.
Capturing (đọc biến local/field bên ngoài): mỗi lần đi qua dòng khai báo, JVM phải tạo instance mới trên heap để chứa snapshot các giá trị capture — như gọi constructor với tham số.
Trong vòng lặp 1 triệu iteration, capturing lambda sinh 1 triệu object ngắn hạn → tăng tần suất GC young-gen. Với code thường điều này không đáng kể (GC hiện đại dọn object ngắn hạn rất rẻ), nhưng trong hot-path đo được bằng profiler thì nên cân nhắc đưa lambda ra ngoài vòng lặp hoặc bỏ capture.
Q5Vì sao không viết được method reference cho lambda s -> "[" + s + "]"?▸
s -> "[" + s + "]"?Method reference chỉ là "con trỏ" tới một method có sẵn, với argument được forward nguyên vẹn. Lambda s -> "[" + s + "]" không gọi method nào — nó thực hiện phép nối chuỗi với literal, tức là có logic riêng không tồn tại dưới dạng method.
Muốn dùng method reference, phải tự tạo method chứa logic đó: static String bracket(String s) { return "[" + s + "]"; } rồi reference MyClass::bracket. Đáng làm khi logic tái dùng nhiều chỗ hoặc cần unit test riêng; với logic 1 dòng dùng 1 chỗ, giữ lambda inline dễ đọc hơn.
Bài tiếp theo: Stream basics — pipeline lazy và terminal operation
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên