From f6237d411787bed90d83eec7f7a185766457f6b9 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sun, 8 Nov 2026 18:14:04 +0000 Subject: [PATCH] reflection: Method/Field/MethodHandle/VarHandle demos, module and final-field rules (JEP 500 on JDK 26), JMH call-cost numbers, contract tests Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b6NtPJHrkan4BXhCAM --- README.md | 1 + pom.xml | 1 + reflection/README.md | 58 +++++++++ reflection/output/01-basics.txt | 32 +++++ reflection/output/02-module-rules.txt | 24 ++++ reflection/output/03-final-fields.txt | 47 ++++++++ reflection/output/04-method-handles.txt | 31 +++++ reflection/output/05-call-benchmark.txt | 15 +++ reflection/output/06-quickstart.txt | 4 + reflection/pom.xml | 83 +++++++++++++ reflection/scripts/run-all.sh | 45 +++++++ .../java/com/ankurm/reflection/Account.java | 60 ++++++++++ .../com/ankurm/reflection/CallBenchmark.java | 110 ++++++++++++++++++ .../com/ankurm/reflection/FinalFieldDemo.java | 63 ++++++++++ .../ankurm/reflection/MethodHandlesDemo.java | 95 +++++++++++++++ .../ankurm/reflection/ModuleRulesDemo.java | 37 ++++++ .../com/ankurm/reflection/QuickStart.java | 31 +++++ .../reflection/ReflectionBasicsDemo.java | 72 ++++++++++++ .../reflection/ReflectionContractTest.java | 101 ++++++++++++++++ 19 files changed, 910 insertions(+) create mode 100644 reflection/README.md create mode 100644 reflection/output/01-basics.txt create mode 100644 reflection/output/02-module-rules.txt create mode 100644 reflection/output/03-final-fields.txt create mode 100644 reflection/output/04-method-handles.txt create mode 100644 reflection/output/05-call-benchmark.txt create mode 100644 reflection/output/06-quickstart.txt create mode 100644 reflection/pom.xml create mode 100755 reflection/scripts/run-all.sh create mode 100644 reflection/src/main/java/com/ankurm/reflection/Account.java create mode 100644 reflection/src/main/java/com/ankurm/reflection/CallBenchmark.java create mode 100644 reflection/src/main/java/com/ankurm/reflection/FinalFieldDemo.java create mode 100644 reflection/src/main/java/com/ankurm/reflection/MethodHandlesDemo.java create mode 100644 reflection/src/main/java/com/ankurm/reflection/ModuleRulesDemo.java create mode 100644 reflection/src/main/java/com/ankurm/reflection/QuickStart.java create mode 100644 reflection/src/main/java/com/ankurm/reflection/ReflectionBasicsDemo.java create mode 100644 reflection/src/test/java/com/ankurm/reflection/ReflectionContractTest.java diff --git a/README.md b/README.md index 0cba8ec..da2a30a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ article; each module's own README has that article's version table, quickstart, | [`cow`](cow/) | CopyOnWriteArrayList in Java: When It Wins (JMH) and When It Hurts | | [`sorting`](sorting/) | Java Comparator & Comparable: The Complete Guide | | [`annotation-processor`](annotation-processor/) | Custom Annotations and Annotation Processors in Java: Generate Code at Compile Time | +| [`reflection`](reflection/) | Java Reflection and MethodHandles in 2026: How They Work and What They Cost | ## License diff --git a/pom.xml b/pom.xml index 7f58b4a..4c506d7 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,7 @@ cow sorting annotation-processor + reflection diff --git a/reflection/README.md b/reflection/README.md new file mode 100644 index 0000000..2bee305 --- /dev/null +++ b/reflection/README.md @@ -0,0 +1,58 @@ +# reflection + +Companion code for the ankurm.com post *"Java Reflection and MethodHandles in 2026: How They Work +and What They Cost."* One module in `java-core-examples`. Everything the post explains lives in +the post itself (accordions per section); this module is the runnable evidence. + +## Versions this was built and tested against + +| Component | Version | Notes | +|---|---|---| +| JDK | 25.0.5+8 (Temurin, LTS) | All demos and the JMH run. | +| JDK (comparison only) | 26.0.2.1+1 (Temurin) | Used for the JEP 500 final-field warning in `output/03`. | +| JMH | 1.37 | 3 warmup + 5 measurement iterations, 2 forks. | +| JUnit Jupiter | 5.11.0 | Contract tests - they fail if a documented behaviour changes. | +| Maven | 3.9.11 | | +| Hardware | 2 vCPU Intel Xeon @ 2.30 GHz VM | Shared sandbox: treat ns figures as relative, not absolute. | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn package # compiles, runs the contract tests, builds the JMH jar +java src/main/java/com/ankurm/reflection/QuickStart.java +java -cp target/classes com.ankurm.reflection.ReflectionBasicsDemo +java -cp target/classes com.ankurm.reflection.ModuleRulesDemo +java --add-opens java.base/java.lang=ALL-UNNAMED -cp target/classes com.ankurm.reflection.ModuleRulesDemo +java -cp target/classes com.ankurm.reflection.FinalFieldDemo +java -cp target/classes com.ankurm.reflection.MethodHandlesDemo +java -jar target/benchmarks.jar CallBenchmark +``` + +`scripts/run-all.sh` regenerates every file in `output/` (set `JDK26_HOME` to include the JDK 26 runs). + +## What's in here + +| File | What it shows | +|---|---| +| `Account.java` | The sample class: public API, private method, private/final fields, a throwing method. | +| `QuickStart.java` | Self-contained: one call by `Method`, one by `MethodHandle`. Run with `java QuickStart.java`. | +| `ReflectionBasicsDemo.java` | Inspect, construct, invoke; the five exceptions you will meet; extra stack frames. | +| `ModuleRulesDemo.java` | `setAccessible` on your class vs. a JDK internal; `--add-opens`. | +| `FinalFieldDemo.java` | Final instance / static / record fields; constant-inlining trap; JEP 500 on JDK 26. | +| `MethodHandlesDemo.java` | `invokeExact` vs `invoke`, composition, lookup-time access checks, `VarHandle`. | +| `CallBenchmark.java` | JMH: direct vs `Method` vs `MethodHandle` vs `Field` vs `VarHandle`, static-final vs instance field. | +| `ReflectionContractTest.java` | Eight tests pinning the contracts the post states. | + +## Captured output + +| File | From | +|---|---| +| `output/01-basics.txt` | `ReflectionBasicsDemo` | +| `output/02-module-rules.txt` | `ModuleRulesDemo`, without and with `--add-opens` | +| `output/03-final-fields.txt` | `FinalFieldDemo` on JDK 25, JDK 26, and JDK 26 with `--illegal-final-field-mutation=deny` | +| `output/04-method-handles.txt` | `MethodHandlesDemo` | +| `output/05-call-benchmark.txt` | `CallBenchmark` (non-deterministic; relative ordering is the result) | +| `output/06-quickstart.txt` | `QuickStart` | + +Identity-hash suffixes like `unnamed module @6b95977` change on every run. diff --git a/reflection/output/01-basics.txt b/reflection/output/01-basics.txt new file mode 100644 index 0000000..8bec669 --- /dev/null +++ b/reflection/output/01-basics.txt @@ -0,0 +1,32 @@ +$ java -cp target/classes com.ankurm.reflection.ReflectionBasicsDemo + +== 1. Inspect the class == +class: com.ankurm.reflection.Account + public long balance[] + public void deposit[long] + public String owner[] + private String secret[] + public String[] stack[] + public String toString[] + public static int twice[int] + +== 2. Create and call == +created: Account[alice, 50] +after deposit(25): Account[alice, 75] + +== 3. The exceptions you will meet == +wrong name -> NoSuchMethodException: com.ankurm.reflection.Account.nope() +wrong arg type -> IllegalArgumentException: argument type mismatch +method threw -> InvocationTargetException wrapping java.lang.IllegalArgumentException: over limit: 5000 +private, no opt-in -> IllegalAccessException: class com.ankurm.reflection.ReflectionBasicsDemo cannot access a member of class com.ankurm.reflection.Account with modifiers "private" +after setAccessible(true): secret-of-alice + +== 4. What reflection adds to the stack == +direct call: + com.ankurm.reflection.Account.stack + com.ankurm.reflection.ReflectionBasicsDemo.main +through Method.invoke: + com.ankurm.reflection.Account.stack + jdk.internal.reflect.DirectMethodHandleAccessor.invoke + java.lang.reflect.Method.invoke + com.ankurm.reflection.ReflectionBasicsDemo.main diff --git a/reflection/output/02-module-rules.txt b/reflection/output/02-module-rules.txt new file mode 100644 index 0000000..25d14a2 --- /dev/null +++ b/reflection/output/02-module-rules.txt @@ -0,0 +1,24 @@ +$ java -cp target/classes com.ankurm.reflection.ModuleRulesDemo + +== setAccessible on your own class == +Account.owner = carol + +== setAccessible on a JDK private field == +String lives in module : java.base +we live in module : unnamed module @6b95977 (unnamed=true) +java.lang open to us : false +trySetAccessible : false +setAccessible threw : InaccessibleObjectException + Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @6b95977 + +$ java --add-opens java.base/java.lang=ALL-UNNAMED -cp target/classes com.ankurm.reflection.ModuleRulesDemo + +== setAccessible on your own class == +Account.owner = carol + +== setAccessible on a JDK private field == +String lives in module : java.base +we live in module : unnamed module @3764951d (unnamed=true) +java.lang open to us : true +trySetAccessible : true +setAccessible worked, String.value is a byte[] diff --git a/reflection/output/03-final-fields.txt b/reflection/output/03-final-fields.txt new file mode 100644 index 0000000..a499993 --- /dev/null +++ b/reflection/output/03-final-fields.txt @@ -0,0 +1,47 @@ +$ java -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 25 + +java.version = 25.0.5 + +== 1. final instance field == +owner() now: mallory + +== 2. static final field == +IllegalAccessException: Can not set static final int field com.ankurm.reflection.Account.MAX_DEPOSIT to java.lang.Integer + +== 3. record component == +IllegalAccessException: Can not set final int field com.ankurm.reflection.FinalFieldDemo$Point.x to java.lang.Integer + +== 4. constant inlining: the write 'works' but the getter never notices == +Field.get -> 99 +max() -> 5 + +$ java -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 26 + +java.version = 26.0.2.1 + +== 1. final instance field == +WARNING: Final field owner in class com.ankurm.reflection.Account has been mutated reflectively by class com.ankurm.reflection.FinalFieldDemo in unnamed module @18b4aac2 (file:/tmp/work/java-core-examples/reflection/target/classes/) +WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning +WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled +owner() now: mallory + +== 2. static final field == +IllegalAccessException: Can not set static final int field com.ankurm.reflection.Account.MAX_DEPOSIT to java.lang.Integer + +== 3. record component == +IllegalAccessException: Can not set final int field com.ankurm.reflection.FinalFieldDemo$Point.x to java.lang.Integer + +== 4. constant inlining: the write 'works' but the getter never notices == +Field.get -> 99 +max() -> 5 + +$ java --illegal-final-field-mutation=deny -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 26 + +java.version = 26.0.2.1 + +== 1. final instance field == +Exception in thread "main" java.lang.IllegalAccessException: class com.ankurm.reflection.FinalFieldDemo (in unnamed module @18b4aac2) cannot set final field com.ankurm.reflection.Account.owner (in unnamed module @18b4aac2), unnamed module @18b4aac2 is not allowed to mutate final fields + at java.base/java.lang.reflect.Field.preSetFinal(Field.java:1504) + at java.base/java.lang.reflect.Field.setFinal(Field.java:1447) + at java.base/java.lang.reflect.Field.set(Field.java:909) + at com.ankurm.reflection.FinalFieldDemo.main(FinalFieldDemo.java:30) diff --git a/reflection/output/04-method-handles.txt b/reflection/output/04-method-handles.txt new file mode 100644 index 0000000..a73ab2a --- /dev/null +++ b/reflection/output/04-method-handles.txt @@ -0,0 +1,31 @@ +$ java -cp target/classes com.ankurm.reflection.MethodHandlesDemo + +== 1. Look up once, call many times == +twice(21) = 42 +balance() = 50 +handle type = (Account)long + +== 2. invokeExact is exact; invoke adapts == +invokeExact, wrong return type -> WrongMethodTypeException + handle's method type (Account)long but found (Account)Object +invoke (boxing) = 50 (Long) + +== 3. Handles compose == +bindTo(acct) = 50 +after deposit10 = Account[erin, 60] +balance as text = "60" + +== 4. Access is checked when you look up, not when you call == +findVirtual(private) -> IllegalAccessException + no such method: com.ankurm.reflection.Account.secret()String/invokeVirtual +privateLookupIn -> secret-of-erin +privateLookupIn(String) -> IllegalAccessException + module java.base does not open java.lang to unnamed module @135fbaa4 + +== 5. VarHandle: typed, atomic field access == +get = 60 +CAS(60 -> 100) = true, balance now 100 +CAS(60 -> 200) = false, balance now 100 +getAndAdd(5) = was 100, now 105 +set on final field -> UnsupportedOperationException +array element CAS-able: counts[1] = 7 diff --git a/reflection/output/05-call-benchmark.txt b/reflection/output/05-call-benchmark.txt new file mode 100644 index 0000000..82ec4fb --- /dev/null +++ b/reflection/output/05-call-benchmark.txt @@ -0,0 +1,15 @@ +$ java -jar target/benchmarks.jar CallBenchmark + +openjdk version "25.0.5" 2026-10-20 LTS + +Benchmark Mode Cnt Score Error Units +CallBenchmark.direct avgt 10 0.516 ± 0.069 ns/op +CallBenchmark.field_get avgt 10 0.899 ± 0.051 ns/op +CallBenchmark.methodHandle_instanceField_invokeExact avgt 10 2.600 ± 0.145 ns/op +CallBenchmark.methodHandle_staticFinal_invokeBoxing avgt 10 2.690 ± 0.262 ns/op +CallBenchmark.methodHandle_staticFinal_invokeExact avgt 10 0.476 ± 0.038 ns/op +CallBenchmark.reflection_instanceFieldMethod avgt 10 6.476 ± 1.952 ns/op +CallBenchmark.reflection_lookupEveryCall avgt 10 21.525 ± 1.756 ns/op +CallBenchmark.reflection_staticFinalMethod avgt 10 1.208 ± 0.152 ns/op +CallBenchmark.varHandle_staticFinal_get avgt 10 0.519 ± 0.076 ns/op + diff --git a/reflection/output/06-quickstart.txt b/reflection/output/06-quickstart.txt new file mode 100644 index 0000000..2252fa5 --- /dev/null +++ b/reflection/output/06-quickstart.txt @@ -0,0 +1,4 @@ +$ java src/main/java/com/ankurm/reflection/QuickStart.java + +reflection : Hello, Ankur +MethodHandle : Hello, Ankur diff --git a/reflection/pom.xml b/reflection/pom.xml new file mode 100644 index 0000000..6902750 --- /dev/null +++ b/reflection/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + reflection + reflection + Java reflection, MethodHandles and VarHandles: access rules, module encapsulation, final-field mutation, and JMH call-cost numbers. + + + 1.37 + + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + benchmarks + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + org.openjdk.jmh.Main + + + + + + + + + diff --git a/reflection/scripts/run-all.sh b/reflection/scripts/run-all.sh new file mode 100755 index 0000000..ef5c3d8 --- /dev/null +++ b/reflection/scripts/run-all.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. Needs JDK 25 as JAVA_HOME; JDK26_HOME (optional) adds the +# JEP 500 comparison run. JMH takes a few minutes. +set -euo pipefail +cd "$(dirname "$0")/.." + +mvn -q package + +# env -u keeps the sandbox's JAVA_TOOL_OPTIONS banner out of committed output. +J() { env -u JAVA_TOOL_OPTIONS "$JAVA_HOME/bin/java" "$@"; } + +echo "--- 01: basics ---" +{ echo '$ java -cp target/classes com.ankurm.reflection.ReflectionBasicsDemo'; echo + J -cp target/classes com.ankurm.reflection.ReflectionBasicsDemo 2>&1; } > output/01-basics.txt + +echo "--- 02: module rules ---" +{ echo '$ java -cp target/classes com.ankurm.reflection.ModuleRulesDemo'; echo + J -cp target/classes com.ankurm.reflection.ModuleRulesDemo 2>&1 + echo; echo '$ java --add-opens java.base/java.lang=ALL-UNNAMED -cp target/classes com.ankurm.reflection.ModuleRulesDemo'; echo + J --add-opens java.base/java.lang=ALL-UNNAMED -cp target/classes com.ankurm.reflection.ModuleRulesDemo 2>&1; } > output/02-module-rules.txt + +echo "--- 03: final fields (JDK 25, and JDK 26 if JDK26_HOME is set) ---" +{ echo '$ java -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 25'; echo + J -cp target/classes com.ankurm.reflection.FinalFieldDemo 2>&1 + if [ -n "${JDK26_HOME:-}" ]; then + echo; echo '$ java -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 26'; echo + env -u JAVA_TOOL_OPTIONS "$JDK26_HOME/bin/java" -cp target/classes com.ankurm.reflection.FinalFieldDemo 2>&1 + echo; echo '$ java --illegal-final-field-mutation=deny -cp target/classes com.ankurm.reflection.FinalFieldDemo # JDK 26'; echo + env -u JAVA_TOOL_OPTIONS "$JDK26_HOME/bin/java" --illegal-final-field-mutation=deny -cp target/classes com.ankurm.reflection.FinalFieldDemo 2>&1 || true + fi; } > output/03-final-fields.txt + +echo "--- 04: method handles ---" +{ echo '$ java -cp target/classes com.ankurm.reflection.MethodHandlesDemo'; echo + J -cp target/classes com.ankurm.reflection.MethodHandlesDemo 2>&1; } > output/04-method-handles.txt + +echo "--- 05: JMH call cost (a few minutes) ---" +J -jar target/benchmarks.jar CallBenchmark -rf text -rff /tmp/jmh-reflection.txt > /tmp/jmh-reflection.log 2>&1 +{ echo '$ java -jar target/benchmarks.jar CallBenchmark'; echo + J -version 2>&1 | head -1 + echo; grep -A20 '^Benchmark ' /tmp/jmh-reflection.log | grep -v 'Benchmark result is saved'; } > output/05-call-benchmark.txt + +echo "--- 06: single-file quick start ---" +{ echo '$ java src/main/java/com/ankurm/reflection/QuickStart.java'; echo + J src/main/java/com/ankurm/reflection/QuickStart.java 2>&1; } > output/06-quickstart.txt +echo done diff --git a/reflection/src/main/java/com/ankurm/reflection/Account.java b/reflection/src/main/java/com/ankurm/reflection/Account.java new file mode 100644 index 0000000..904c280 --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/Account.java @@ -0,0 +1,60 @@ +package com.ankurm.reflection; + +/** + * The one class every demo in this module pokes at. It has a public API, a private method, + * a private mutable field, a private final field, a static constant and a method that throws - + * everything reflection has an opinion about. + */ +public class Account { + public static final int MAX_DEPOSIT = 1_000; + + private final String owner; + private long balance; + + public Account(String owner, long balance) { + this.owner = owner; + this.balance = balance; + } + + public Account() { + this("nobody", 0); + } + + public String owner() { + return owner; + } + + public long balance() { + return balance; + } + + public void deposit(long amount) { + if (amount > MAX_DEPOSIT) { + throw new IllegalArgumentException("over limit: " + amount); + } + balance += amount; + } + + private String secret() { + return "secret-of-" + owner; + } + + public static int twice(int x) { + return x * 2; + } + + /** Returns the stack as seen from inside this method - used to show what reflection adds. */ + public String[] stack() { + StackTraceElement[] st = new Throwable().getStackTrace(); + String[] out = new String[Math.min(st.length, 6)]; + for (int i = 0; i < out.length; i++) { + out[i] = st[i].getClassName() + "." + st[i].getMethodName(); + } + return out; + } + + @Override + public String toString() { + return "Account[" + owner + ", " + balance + "]"; + } +} diff --git a/reflection/src/main/java/com/ankurm/reflection/CallBenchmark.java b/reflection/src/main/java/com/ankurm/reflection/CallBenchmark.java new file mode 100644 index 0000000..1aa06df --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/CallBenchmark.java @@ -0,0 +1,110 @@ +package com.ankurm.reflection; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.invoke.VarHandle; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * What does one call cost? Every variant reads Account.balance(). The two things being tested + * are the API and - crucially - whether the JIT can see the handle/Method as a constant + * (static final) or has to treat it as an unknown (instance field). + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +public class CallBenchmark { + + static final Method S_METHOD; + static final MethodHandle S_HANDLE; + static final Field S_FIELD; + static final VarHandle S_VARHANDLE; + + static { + try { + S_METHOD = Account.class.getMethod("balance"); + S_HANDLE = MethodHandles.lookup().findVirtual(Account.class, "balance", + MethodType.methodType(long.class)); + S_FIELD = Account.class.getDeclaredField("balance"); + S_FIELD.setAccessible(true); + S_VARHANDLE = MethodHandles.privateLookupIn(Account.class, MethodHandles.lookup()) + .findVarHandle(Account.class, "balance", long.class); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + + Account acct = new Account("bench", 42); + Method method; + MethodHandle handle; + + @Setup + public void setup() throws Exception { + method = Account.class.getMethod("balance"); + handle = MethodHandles.lookup().findVirtual(Account.class, "balance", + MethodType.methodType(long.class)); + } + + /** What code generation produces: an ordinary call. */ + @Benchmark + public long direct() { + return acct.balance(); + } + + @Benchmark + public Object reflection_staticFinalMethod() throws Exception { + return S_METHOD.invoke(acct); + } + + @Benchmark + public Object reflection_instanceFieldMethod() throws Exception { + return method.invoke(acct); + } + + /** The classic mistake: look the Method up again on every call. */ + @Benchmark + public Object reflection_lookupEveryCall() throws Exception { + return Account.class.getMethod("balance").invoke(acct); + } + + @Benchmark + public long methodHandle_staticFinal_invokeExact() throws Throwable { + return (long) S_HANDLE.invokeExact(acct); + } + + @Benchmark + public long methodHandle_instanceField_invokeExact() throws Throwable { + return (long) handle.invokeExact(acct); + } + + @Benchmark + public Object methodHandle_staticFinal_invokeBoxing() throws Throwable { + return S_HANDLE.invoke(acct); + } + + @Benchmark + public Object field_get() throws Exception { + return S_FIELD.get(acct); + } + + @Benchmark + public long varHandle_staticFinal_get() { + return (long) S_VARHANDLE.get(acct); + } +} diff --git a/reflection/src/main/java/com/ankurm/reflection/FinalFieldDemo.java b/reflection/src/main/java/com/ankurm/reflection/FinalFieldDemo.java new file mode 100644 index 0000000..17b8304 --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/FinalFieldDemo.java @@ -0,0 +1,63 @@ +package com.ankurm.reflection; + +import java.lang.reflect.Field; + +/** + * Section 4: what happens when reflection tries to change a final field. Run it on JDK 25 and + * JDK 26 and compare - JEP 500 (JDK 26) adds a warning for the instance-field case. + */ +public class FinalFieldDemo { + + record Point(int x, int y) {} + + /** The initializer is a compile-time constant, so javac copies 5 into every read of max. */ + static class Limit { + private final int max = 5; + + int max() { + return max; + } + } + + public static void main(String[] args) throws Exception { + System.out.println("java.version = " + System.getProperty("java.version")); + + System.out.println(); + System.out.println("== 1. final instance field =="); + Account acct = new Account("dave", 0); + Field owner = Account.class.getDeclaredField("owner"); + owner.setAccessible(true); + owner.set(acct, "mallory"); + System.out.println("owner() now: " + acct.owner()); + + System.out.println(); + System.out.println("== 2. static final field =="); + Field max = Account.class.getField("MAX_DEPOSIT"); + max.setAccessible(true); + try { + max.set(null, 5); + } catch (IllegalAccessException e) { + System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); + } + + System.out.println(); + System.out.println("== 3. record component =="); + Point p = new Point(1, 2); + Field x = Point.class.getDeclaredField("x"); + x.setAccessible(true); + try { + x.set(p, 99); + } catch (IllegalAccessException e) { + System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage()); + } + + System.out.println(); + System.out.println("== 4. constant inlining: the write 'works' but the getter never notices =="); + Limit limit = new Limit(); + Field f = Limit.class.getDeclaredField("max"); + f.setAccessible(true); + f.set(limit, 99); + System.out.println("Field.get -> " + f.get(limit)); + System.out.println("max() -> " + limit.max()); + } +} diff --git a/reflection/src/main/java/com/ankurm/reflection/MethodHandlesDemo.java b/reflection/src/main/java/com/ankurm/reflection/MethodHandlesDemo.java new file mode 100644 index 0000000..a82e7f0 --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/MethodHandlesDemo.java @@ -0,0 +1,95 @@ +package com.ankurm.reflection; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.invoke.VarHandle; +import java.lang.invoke.WrongMethodTypeException; + +/** Sections 5-6: MethodHandles and VarHandles - same jobs as reflection, checked at lookup time. */ +public class MethodHandlesDemo { + + public static void main(String[] args) throws Throwable { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + Account acct = new Account("erin", 50); + + System.out.println("== 1. Look up once, call many times =="); + MethodHandle twice = lookup.findStatic(Account.class, "twice", + MethodType.methodType(int.class, int.class)); + int r = (int) twice.invokeExact(21); + System.out.println("twice(21) = " + r); + + MethodHandle balance = lookup.findVirtual(Account.class, "balance", + MethodType.methodType(long.class)); + long b = (long) balance.invokeExact(acct); + System.out.println("balance() = " + b); + System.out.println("handle type = " + balance.type()); + + System.out.println(); + System.out.println("== 2. invokeExact is exact; invoke adapts =="); + try { + Object o = (Object) balance.invokeExact(acct); + System.out.println(o); + } catch (WrongMethodTypeException e) { + System.out.println("invokeExact, wrong return type -> " + e.getClass().getSimpleName()); + System.out.println(" " + e.getMessage()); + } + Object boxed = balance.invoke(acct); + System.out.println("invoke (boxing) = " + boxed + " (" + boxed.getClass().getSimpleName() + ")"); + + System.out.println(); + System.out.println("== 3. Handles compose =="); + MethodHandle bound = balance.bindTo(acct); + System.out.println("bindTo(acct) = " + (long) bound.invokeExact()); + MethodHandle deposit = lookup.findVirtual(Account.class, "deposit", + MethodType.methodType(void.class, long.class)); + MethodHandle deposit10 = MethodHandles.insertArguments(deposit, 1, 10L); + deposit10.invokeExact(acct); + System.out.println("after deposit10 = " + acct); + MethodHandle asText = MethodHandles.filterReturnValue(balance, + lookup.findStatic(String.class, "valueOf", MethodType.methodType(String.class, long.class))); + String text = (String) asText.invokeExact(acct); + System.out.println("balance as text = \"" + text + "\""); + + System.out.println(); + System.out.println("== 4. Access is checked when you look up, not when you call =="); + try { + lookup.findVirtual(Account.class, "secret", MethodType.methodType(String.class)); + } catch (IllegalAccessException e) { + System.out.println("findVirtual(private) -> " + e.getClass().getSimpleName()); + System.out.println(" " + e.getMessage()); + } + MethodHandles.Lookup priv = MethodHandles.privateLookupIn(Account.class, lookup); + MethodHandle secret = priv.findVirtual(Account.class, "secret", MethodType.methodType(String.class)); + System.out.println("privateLookupIn -> " + (String) secret.invokeExact(acct)); + try { + MethodHandles.privateLookupIn(String.class, lookup); + } catch (IllegalAccessException e) { + System.out.println("privateLookupIn(String) -> " + e.getClass().getSimpleName()); + System.out.println(" " + e.getMessage()); + } + + System.out.println(); + System.out.println("== 5. VarHandle: typed, atomic field access =="); + VarHandle vh = priv.findVarHandle(Account.class, "balance", long.class); + System.out.println("get = " + (long) vh.get(acct)); + boolean won = vh.compareAndSet(acct, 60L, 100L); + System.out.println("CAS(60 -> 100) = " + won + ", balance now " + acct.balance()); + boolean lost = vh.compareAndSet(acct, 60L, 200L); + System.out.println("CAS(60 -> 200) = " + lost + ", balance now " + acct.balance()); + long prev = (long) vh.getAndAdd(acct, 5L); + System.out.println("getAndAdd(5) = was " + prev + ", now " + acct.balance()); + + VarHandle ownerVh = priv.findVarHandle(Account.class, "owner", String.class); + try { + ownerVh.set(acct, "mallory"); + } catch (UnsupportedOperationException e) { + System.out.println("set on final field -> " + e.getClass().getSimpleName()); + } + + VarHandle arr = MethodHandles.arrayElementVarHandle(int[].class); + int[] counts = new int[3]; + arr.getAndAdd(counts, 1, 7); + System.out.println("array element CAS-able: counts[1] = " + counts[1]); + } +} diff --git a/reflection/src/main/java/com/ankurm/reflection/ModuleRulesDemo.java b/reflection/src/main/java/com/ankurm/reflection/ModuleRulesDemo.java new file mode 100644 index 0000000..ba6a54b --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/ModuleRulesDemo.java @@ -0,0 +1,37 @@ +package com.ankurm.reflection; + +import java.lang.reflect.Field; +import java.lang.reflect.InaccessibleObjectException; + +/** + * Section 3: setAccessible is not a master key. Your own classes are open to you; the JDK's + * internals are not unless the command line says so (--add-opens). + */ +public class ModuleRulesDemo { + + public static void main(String[] args) throws Exception { + System.out.println("== setAccessible on your own class =="); + Field owner = Account.class.getDeclaredField("owner"); + owner.setAccessible(true); + System.out.println("Account.owner = " + owner.get(new Account("carol", 1))); + + System.out.println(); + System.out.println("== setAccessible on a JDK private field =="); + Module javaBase = String.class.getModule(); + Module me = ModuleRulesDemo.class.getModule(); + System.out.println("String lives in module : " + javaBase.getName()); + System.out.println("we live in module : " + me + " (unnamed=" + !me.isNamed() + ")"); + System.out.println("java.lang open to us : " + javaBase.isOpen("java.lang", me)); + + Field value = String.class.getDeclaredField("value"); + System.out.println("trySetAccessible : " + value.trySetAccessible()); + try { + value.setAccessible(true); + Object bytes = value.get("abc"); + System.out.println("setAccessible worked, String.value is a " + bytes.getClass().getSimpleName()); + } catch (InaccessibleObjectException e) { + System.out.println("setAccessible threw : " + e.getClass().getSimpleName()); + System.out.println(" " + e.getMessage()); + } + } +} diff --git a/reflection/src/main/java/com/ankurm/reflection/QuickStart.java b/reflection/src/main/java/com/ankurm/reflection/QuickStart.java new file mode 100644 index 0000000..99cc793 --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/QuickStart.java @@ -0,0 +1,31 @@ +package com.ankurm.reflection; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; + +/** + * The smallest useful tour: one class, called by reflection and by a MethodHandle. + * Self-contained - run it with: java QuickStart.java + */ +public class QuickStart { + + public static class Greeter { + public String greet(String name) { + return "Hello, " + name; + } + } + + public static void main(String[] args) throws Throwable { + Greeter g = new Greeter(); + + Method m = Greeter.class.getMethod("greet", String.class); + System.out.println("reflection : " + m.invoke(g, "Ankur")); + + MethodHandle h = MethodHandles.lookup().findVirtual(Greeter.class, "greet", + MethodType.methodType(String.class, String.class)); + String s = (String) h.invokeExact(g, "Ankur"); + System.out.println("MethodHandle : " + s); + } +} diff --git a/reflection/src/main/java/com/ankurm/reflection/ReflectionBasicsDemo.java b/reflection/src/main/java/com/ankurm/reflection/ReflectionBasicsDemo.java new file mode 100644 index 0000000..9801df3 --- /dev/null +++ b/reflection/src/main/java/com/ankurm/reflection/ReflectionBasicsDemo.java @@ -0,0 +1,72 @@ +package com.ankurm.reflection; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Comparator; + +/** Section 1-2 of the post: inspect a class, call things, and see the exceptions you will meet. */ +public class ReflectionBasicsDemo { + + public static void main(String[] args) throws Exception { + Class c = Account.class; + + System.out.println("== 1. Inspect the class =="); + System.out.println("class: " + c.getName()); + Method[] methods = c.getDeclaredMethods(); + Arrays.sort(methods, Comparator.comparing(Method::getName)); + for (Method m : methods) { + System.out.println(" " + Modifier.toString(m.getModifiers()) + " " + + m.getReturnType().getSimpleName() + " " + m.getName() + + Arrays.toString(m.getParameterTypes())); + } + + System.out.println(); + System.out.println("== 2. Create and call =="); + Constructor ctor = c.getConstructor(String.class, long.class); + Object account = ctor.newInstance("alice", 50L); + System.out.println("created: " + account); + + Method deposit = c.getMethod("deposit", long.class); + deposit.invoke(account, 25L); + System.out.println("after deposit(25): " + account); + + System.out.println(); + System.out.println("== 3. The exceptions you will meet =="); + try { + c.getMethod("nope"); + } catch (NoSuchMethodException e) { + System.out.println("wrong name -> " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + try { + deposit.invoke(account, "twenty"); + } catch (IllegalArgumentException e) { + System.out.println("wrong arg type -> " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + try { + deposit.invoke(account, 5_000L); + } catch (InvocationTargetException e) { + System.out.println("method threw -> " + e.getClass().getSimpleName() + + " wrapping " + e.getCause()); + } + + Method secret = c.getDeclaredMethod("secret"); + try { + secret.invoke(account); + } catch (IllegalAccessException e) { + System.out.println("private, no opt-in -> " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + secret.setAccessible(true); + System.out.println("after setAccessible(true): " + secret.invoke(account)); + + System.out.println(); + System.out.println("== 4. What reflection adds to the stack =="); + Account a = new Account("bob", 0); + System.out.println("direct call:"); + for (String f : a.stack()) System.out.println(" " + f); + System.out.println("through Method.invoke:"); + for (String f : (String[]) c.getMethod("stack").invoke(a)) System.out.println(" " + f); + } +} diff --git a/reflection/src/test/java/com/ankurm/reflection/ReflectionContractTest.java b/reflection/src/test/java/com/ankurm/reflection/ReflectionContractTest.java new file mode 100644 index 0000000..3cd9bbc --- /dev/null +++ b/reflection/src/test/java/com/ankurm/reflection/ReflectionContractTest.java @@ -0,0 +1,101 @@ +package com.ankurm.reflection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.invoke.VarHandle; +import java.lang.invoke.WrongMethodTypeException; +import java.lang.reflect.Field; +import java.lang.reflect.InaccessibleObjectException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +/** Pins the contracts the post claims. If one stops being true on a new JDK, the build fails. */ +class ReflectionContractTest { + + @Test + void methodInvokeWrapsTheRealExceptionInInvocationTargetException() throws Exception { + Method deposit = Account.class.getMethod("deposit", long.class); + InvocationTargetException e = assertThrows(InvocationTargetException.class, + () -> deposit.invoke(new Account(), 5_000L)); + assertEquals(IllegalArgumentException.class, e.getCause().getClass()); + } + + @Test + void privateMethodNeedsSetAccessibleEvenInTheSamePackage() throws Exception { + Method secret = Account.class.getDeclaredMethod("secret"); + assertThrows(IllegalAccessException.class, () -> secret.invoke(new Account("x", 0))); + secret.setAccessible(true); + assertEquals("secret-of-x", secret.invoke(new Account("x", 0))); + } + + @Test + void jdkInternalsAreClosedWithoutAddOpens() throws Exception { + Field value = String.class.getDeclaredField("value"); + assertFalse(value.trySetAccessible()); + assertThrows(InaccessibleObjectException.class, () -> value.setAccessible(true)); + } + + @Test + void staticFinalAndRecordFieldsCannotBeSetEvenAfterSetAccessible() throws Exception { + Field max = Account.class.getField("MAX_DEPOSIT"); + max.setAccessible(true); + assertThrows(IllegalAccessException.class, () -> max.set(null, 5)); + + Field x = FinalFieldDemo.Point.class.getDeclaredField("x"); + x.setAccessible(true); + assertThrows(IllegalAccessException.class, () -> x.set(new FinalFieldDemo.Point(1, 2), 9)); + } + + @Test + void invokeExactRejectsAMismatchedReturnTypeButInvokeAdapts() throws Throwable { + MethodHandle balance = MethodHandles.lookup().findVirtual(Account.class, "balance", + MethodType.methodType(long.class)); + Account a = new Account("a", 7); + assertEquals(7L, (long) balance.invokeExact(a)); + assertThrows(WrongMethodTypeException.class, () -> { + Object o = (Object) balance.invokeExact(a); + }); + assertEquals(7L, balance.invoke(a)); + } + + @Test + void methodHandleAccessIsCheckedAtLookupTime() throws Throwable { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + assertThrows(IllegalAccessException.class, () -> lookup.findVirtual(Account.class, "secret", + MethodType.methodType(String.class))); + MethodHandle secret = MethodHandles.privateLookupIn(Account.class, lookup) + .findVirtual(Account.class, "secret", MethodType.methodType(String.class)); + assertEquals("secret-of-nobody", (String) secret.invokeExact(new Account())); + } + + @Test + void varHandleOnFinalFieldIsReadOnlyAndCasWorksOnPlainField() throws Throwable { + MethodHandles.Lookup priv = MethodHandles.privateLookupIn(Account.class, MethodHandles.lookup()); + VarHandle owner = priv.findVarHandle(Account.class, "owner", String.class); + assertThrows(UnsupportedOperationException.class, () -> owner.set(new Account(), "x")); + + VarHandle bal = priv.findVarHandle(Account.class, "balance", long.class); + Account a = new Account("a", 60); + assertTrue(bal.compareAndSet(a, 60L, 100L)); + assertFalse(bal.compareAndSet(a, 60L, 200L)); + assertEquals(100L, a.balance()); + } + + @Test + void reflectionAddsFramesToTheStack() throws Exception { + Account a = new Account(); + String[] direct = a.stack(); + String[] viaReflection = (String[]) Account.class.getMethod("stack").invoke(a); + // Frame 0 is Account.stack. Frame 1 is the caller: the test directly, an accessor via reflection. + assertEquals(ReflectionContractTest.class.getName() + ".reflectionAddsFramesToTheStack", direct[1]); + assertEquals("jdk.internal.reflect.DirectMethodHandleAccessor.invoke", viaReflection[1]); + assertEquals("java.lang.reflect.Method.invoke", viaReflection[2]); + } +}