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

Explore
MikeDaDev's Blog
in java

java reflection

Tung Bui Thanh (TungDaDev)12 mins read

Java reflection.webp

Trong thế giới Java, Java Reflection API (java.lang.reflect) là con dao hai lưỡi. Hầu hết các framework lớn đều đứng trên nó:

  • Spring Framework: dùng Reflection để làm Inversion of Control (IoC), Dependency Injection (DI) và Dynamic Proxies (AOP).
  • Hibernate / JPA: quét metadata, map bảng trong database quan hệ sang Java Entity mà không cần sinh code bằng tay.
  • Jackson / Gson: tự serialize/deserialize JSON thành Java Object.

Nhưng với hệ thống cần độ trễ cực thấp, hoặc app build theo kiểu cloud native với GraalVM Native Image, Reflection lại là thứ gây rắc rối nhất:

  • Tại sao gọi method.invoke() chậm hơn gọi hàm bình thường?
  • Cơ chế Inflation của HotSpot JVM làm gì sau 15 lần gọi reflection, và vì sao từ JDK 18 nó không còn nữa?
  • Tại sao từ JDK 16/17, field.setAccessible(true) lại ném InaccessibleObjectException?
  • MethodHandles (Java 7+) thay Reflection thế nào để chạy nhanh ngang gọi bytecode trực tiếp?

Để trả lời thì phải nhìn từ bên trong HotSpot ra tới chuyện biên dịch AOT.


# cơ chế hotspot inflation: từ jni native đến sinh bytecode động

Khi bạn gọi một method qua Reflection:

Method method = targetClass.getMethod("processOrder", OrderContext.class);
method.invoke(targetInstance, context);

Từ JDK 17 trở về trước, JVM không xử lý cố định một kiểu. Nó chia làm hai giai đoạn, gọi là Reflection Inflation:

# tại sao hotspot jvm phải làm vậy?

  1. Lần 1 đến 15 (Native JNI Accessor): nếu một method chỉ được gọi 1-2 lần lúc khởi động thì sinh bytecode Java ra chỉ tốn bộ nhớ và thời gian biên dịch mà chẳng được gì. Nên JVM dùng luôn C++ stub qua JNI để gọi.
  2. Từ lần 16 (Bytecode Inflation): khi method reflection đã thành "hot-path" (vượt ngưỡng -Dsun.reflect.inflationThreshold=15), JVM thấy chi phí chuyển qua lại giữa C++ và Java qua JNI quá đắt. Nó lặng lẽ sinh một class Java mới trong RAM (ví dụ jdk.internal.reflect.GeneratedMethodAccessor1) để gọi hàm trực tiếp.

NOTE

Ngưỡng inflation mặc định là 15. Trên JDK 17 trở về trước, bạn có thể chỉnh ngưỡng bằng -Dsun.reflect.inflationThreshold=<n>, hoặc dùng -Dsun.reflect.noInflation=true để bỏ qua giai đoạn JNI, sinh bytecode accessor ngay từ lần gọi đầu.

Từ JDK 18 thì khác: reflection chạy trên method handle.

JEP 416 (JDK 18) viết lại java.lang.reflect.Method, Constructor và Field để chạy trên java.lang.invoke method handle. Method.invoke() giờ đi qua một DirectMethodHandleAccessor bọc method handle, không còn sinh GeneratedMethodAccessorN và cũng không còn cơ chế đếm 15 lần như trên (native accessor chỉ còn dùng rất sớm lúc JVM khởi động, khi hạ tầng method handle chưa sẵn sàng). JDK 18–21 vẫn cho quay về bản cũ bằng -Djdk.reflect.useDirectMethodHandle=false; từ JDK 22 bản cũ bị gỡ hẳn, cờ này hết tác dụng. Các cờ sun.reflect.* ở trên vì thế chỉ có ý nghĩa với JDK 17 trở về trước.

Lợi ích phụ của bản mới: nếu đối tượng Method được giữ trong một hằng static final, JIT có cơ hội tối ưu xuyên qua lời gọi reflection tốt hơn. Còn khi Method là biến thường (trường hợp phổ biến trong framework), các vấn đề ở phần dưới vẫn còn nguyên.


# vì sao reflection làm hỏng tối ưu của jit compiler?

Nhiều người nghĩ sau khi có GeneratedMethodAccessor thì Reflection nhanh ngang code viết tay. Không phải vậy.

Reflection làm hỏng 3 kiểu tối ưu quan trọng của C2 JIT Compiler:

# phá vỡ method inlining

Method Inlining là tối ưu quan trọng nhất của JVM: chép nguyên thân hàm con vào hàm cha, bỏ luôn chi phí tạo stack frame, lưu register và nhảy địa chỉ. Gọi qua Method.invoke() thì method đích là động: cùng một chỗ gọi invoke() trong framework có thể dẫn tới hàng trăm method khác nhau, JIT không biết chắc lúc runtime sẽ gọi vào đâu, nên trong đa số trường hợp không inline được. Ngoại lệ là khi Method là hằng số mà JIT nhìn thấy được (xem ghi chú JEP 416 ở trên).

# chi phí autoboxing & mảng object[] tạm

Chữ ký của invoke là:

public Object invoke(Object obj, Object... args)

Mỗi lần bạn truyền tham số kiểu nguyên thủy (int, long), JVM phải:

  • Tạo object boxing (Integer.valueOf(x)).
  • Tạo một mảng new Object[n] trên heap để chứa tham số.
  • Với 1,000,000 lời gọi Reflection/giây, cả triệu mảng tạm này làm đầy Eden Space nhanh hơn, GC chạy thường xuyên hơn. Escape analysis đôi khi khử được các object tạm này, nhưng chỉ khi JIT inline được cả chuỗi gọi, mà đó lại chính là thứ reflection hay làm hỏng.

# rào cản module system của java 9: setAccessible(true) hết xài được

Trước Java 9, dân Java hay dùng Reflection như chìa vạn năng để mở bất kỳ field private nào:

Field field = String.class.getDeclaredField("value");
field.setAccessible(true); // 🔴 HOẠT ĐỘNG TRÊN JAVA 8, CHỈ CẢNH BÁO TRÊN 9-15, BỊ CHẶN TỪ JDK 16/17!

# strong encapsulation của jpms (java platform module system)

Java 9 (JEP 261) đưa vào module system, nhưng để không làm vỡ cả hệ sinh thái, JDK 9 tới 15 vẫn mặc định cho code trên classpath reflection vào bên trong JDK, chỉ in cảnh báo "Illegal reflective access" (chế độ --illegal-access=permit). JDK 16 (JEP 396) đổi mặc định sang chặn, và JDK 17 (JEP 403) bỏ luôn tuỳ chọn --illegal-access. Từ đó ranh giới giữa các module được JVM canh rất chặt:

  • Một class chỉ dùng Reflection đọc được field private của class khác khi và chỉ khi module chứa class đó cho phép bằng opens trong module-info.java.
  • Cố tình đụng vào class nội bộ của JDK (java.base) là JVM ném lỗi:
    java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module

WARNING

Cờ JVM --add-opens java.base/java.lang=ALL-UNNAMED vẫn là cách chính thức để mở một package cho reflection, nhưng nên coi là giải pháp tạm: mỗi cờ như vậy là một chỗ app phụ thuộc vào chi tiết nội bộ của JDK, lên phiên bản mới là có thể vỡ. JDK còn tiếp tục siết theo hướng "integrity by default": từ JDK 26 (JEP 500), dùng reflection để sửa field final (kể cả sau setAccessible(true)) sẽ in cảnh báo, và một bản tương lai sẽ chặn hẳn nếu không bật --enable-final-field-mutation.


# methodhandles (java 7/8+): nhanh như gọi trực tiếp

Để thay Reflection đã cũ và để các ngôn ngữ động chạy tốt trên JVM, Java đưa vào package java.lang.invoke với MethodHandles.

MethodHandle là một tham chiếu tới method có kiểu chặt chẽ (strongly typed), chạy ở mức bytecode tương đương lệnh invokevirtual hoặc invokestatic:

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
 
public class MethodHandleBenchmark {
 
    public static class AccountService {
        public String transfer(String from, String to, double amount) {
            return "SUCCESS: " + amount;
        }
    }
 
    private static final MethodHandle TRANSFER_HANDLE;
 
    static {
        try {
            MethodHandles.Lookup lookup = MethodHandles.lookup();
            MethodType methodType = MethodType.methodType(String.class, String.class, String.class, double.class);
            // Tìm kiếm MethodHandle một lần duy nhất lúc khởi động
            TRANSFER_HANDLE = lookup.findVirtual(AccountService.class, "transfer", methodType);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }
 
    public String executeTransfer(AccountService service, String from, String to, double amount) throws Throwable {
        // invokeExact: Thực thi với tốc độ tương đương gọi hàm trực tiếp!
        // Hoàn toàn không tốn mảng Object[], không Autoboxing!
        return (String) TRANSFER_HANDLE.invokeExact(service, from, to, amount);
    }
}

# tại sao methodhandle.invokeExact() ăn đứt reflection?

  1. Không boxing, không mảng args: tham số đi thẳng qua register của CPU.
  2. JIT inline được: nếu MethodHandle là static final, C2 JIT coi nó là hằng số (Constant Folding) và inline thẳng code của hàm đích vào chỗ gọi.
  3. Kiểm tra quyền một lần: quyền truy cập được kiểm tra một lần lúc tìm handle (lookup.findVirtual(...)), dựa trên quyền của class đã tạo Lookup. Gọi handle về sau không kiểm lại nữa, trong khi method.invoke() vẫn phải kiểm quyền ở mỗi lần gọi (dù có cache).

TIP

Để MethodHandle trong static final: muốn JIT bật được Type Speculation và inline trọn vẹn cho MethodHandle thì luôn lưu MethodHandle trong hằng số static final.


# reflection thời graalvm native image & cloud native

Với cloud native, build app Java thành file thực thi native (AOT - Ahead-Of-Time Compilation) bằng GraalVM Native Image được lợi khá nhiều:

  • Khởi động trong vài chục mili-giây, thay vì vài giây như app Spring Boot chạy trên JVM thông thường.
  • RAM thường chỉ tốn vài chục MB cho một service nhỏ.

Con số cụ thể tuỳ app, nhưng chênh lệch thường là một bậc độ lớn.

# reflection và graalvm aot không hợp nhau

GraalVM dựa trên Closed-World Assumption: lúc build file native, nó phân tích tĩnh toàn bộ code để bỏ hết class/method không dùng tới (Dead Code Elimination).

Nếu code của bạn gọi:

Class<?> clazz = Class.forName(readClassNameFromConfig());

Trình biên dịch AOT không đoán được lúc runtime class nào sẽ được gọi. Thế là GraalVM bỏ class đó khỏi bản build, và lên Production thì crash với ClassNotFoundException. (Nếu tham số là hằng số, ví dụ Class.forName("com.app.OrderPayload"), GraalVM tự nhận ra được lúc phân tích tĩnh; rắc rối là ở những chỗ tên class hay tên method chỉ biết lúc chạy.)

Cách xử lý là khai báo metadata cho GraalVM biết trước. Bản cũ dùng reflect-config.json; từ GraalVM for JDK 23, các file này gộp thành reachability-metadata.json. Không cần viết tay từ đầu: chạy app trên JVM kèm tracing agent (-agentlib:native-image-agent=config-output-dir=...) để nó ghi lại các lời gọi reflection thật, rồi rà lại.

# java hiện đại đang đi về đâu

  1. Từ reflection lúc runtime sang làm việc lúc build: Micronaut và MapStruct dùng JSR 269 Annotation Processors để sinh code Java thuần ngay lúc compile, nên gần như không cần reflection lúc chạy. Quarkus đi đường khác: các extension của nó xử lý metadata và sinh bytecode lúc build (dựa trên index Jandex), để phần việc còn lại lúc runtime ít nhất có thể.
  2. Spring Boot 3 AOT Hints: khi buộc phải dùng Reflection, Spring Boot 3 yêu cầu đăng ký qua RuntimeHints, rồi gắn registrar vào một class cấu hình bằng @ImportRuntimeHints(MyRuntimeHints.class):
    public class MyRuntimeHints implements RuntimeHintsRegistrar {
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            hints.reflection().registerType(OrderPayload.class,
                MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                MemberCategory.INVOKE_DECLARED_METHODS);
        }
    }

# bảng so sánh

Tiêu chíJava Reflection (Method.invoke)MethodHandles (invokeExact)Compile-time Generation (MapStruct/Lombok)
Cách chạyJNI → Bytecode Accessor (JDK ≤ 17); method handle (JDK 18+)Direct Bytecode Invocation StubSinh code Java thuần lúc compile
Tốc độ🐢 Chậm hơn gọi trực tiếp nhiều lần⚡ Gần ngang gọi trực tiếp (khi static final)🚀 Nhanh nhất (như code viết tay)
JIT inline được không🔴 Không🟢 Có (khi là static final)🟢 Có
Tạo rác (GC Churn)🔴 Mảng Object[] + Autoboxing🟢 Không🟢 Không
Chạy với GraalVM AOT🔴 Phải khai báo metadata (agent sinh hộ)🟡 Cần khai báo hint🟢 Chạy luôn, không cần làm gì
Kiểm tra kiểu🔴 Runtime (IllegalArgumentException)🟢 Chặt lúc runtime (invokeExact)🟢 Kiểm tra hết lúc compile

# tổng kết

Reflection là thứ đứng sau phần lớn framework Java doanh nghiệp suốt hai chục năm. Dùng nó thì cứ dùng, miễn là biết mình đang trả giá gì cho sự tiện lợi đó:

  • Đừng dùng Reflection trong các vòng lặp xử lý giao dịch tần suất cao (hot-path).
  • Cache lại các object Method, Field, đừng gọi getDeclaredFields() đi gọi lại.
  • Dùng MethodHandles khi cần gọi hàm động mà vẫn muốn nhanh gần như gọi trực tiếp.
  • Chuyển dần sang annotation processing lúc compile để sẵn sàng cho cloud native và GraalVM AOT.

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 😎 👍🏻 🚀 🔥.