Reflection and Annotations — Practice Tasks¶
Eight exercises that force the parts of reflection and annotations that bite in production: caching, MethodHandle, VarHandle, JPMS, annotation processing, and the design of custom annotations that drive behaviour. Each task names the smell, gives the constraints, and lists the acceptance criteria you can check yourself.
Work each task in three passes: (1) read the snippet and decide which tool is right — reflection, MethodHandle, VarHandle, or a processor; (2) sketch the API surface before writing implementation; (3) measure once the code works — a passing test plus a JMH benchmark for hot-path tasks.
Task 1 — Tiny JSON serialiser using reflection¶
Build a 50-line JSON serialiser. The goal is not to compete with Jackson — it is to internalise the scan + cache + call pattern every serialiser uses.
Requirements:
- Walk
getClass().getDeclaredFields(). Skipstaticfields. Handletransientby ignoring (your call, but document it). - Support:
String(quoted), primitives and their wrappers (raw),null(literal), nested objects (recursive), andCollection<?>andMap<String,?>(with String keys). - Use
setAccessible(true)to read non-public fields. - Cache the field list per class in a
ClassValue<List<Field>>.
Acceptance criteria.
- A test serialises a
record User(String name, int age, List<String> tags) { }to{"name":"Ada","age":36,"tags":["math","logic"]}. - Adding 10 000 distinct users with different ages reuses the same cached
Field[](verify by countingcomputeValueinvocations). - A second test passes an unsupported type (e.g.,
LocalDate) and gets a clearIllegalArgumentExceptionmentioning the field name. - The serialiser is safe to call from multiple threads concurrently.
Task 2 — Custom @Cached annotation + simple caching interceptor¶
Design an annotation that marks methods whose result should be cached, and write a CachingProxy that wraps an object and reads the annotation.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Cached {
long ttlMillis() default 60_000;
}
public interface Prices {
@Cached(ttlMillis = 5_000)
BigDecimal priceFor(String sku);
}
Requirements:
- The proxy implements the same interface as its target (use
Proxy.newProxyInstanceor write a small generated class). - Reads
@Cachedfrom the method; caches results by argument list (useList.of(args)as the key). - Returns the cached value if not expired; otherwise calls the real method and stores.
- Reject cases where the proxy can't be built (target's class is
final, method is notinterface).
Acceptance criteria.
- Calling
priceFor("ABC")twice within 5 s invokes the real method once. - Calling
priceFor("XYZ")thenpriceFor("ABC")invokes the real method twice (different keys). - A method without
@Cachedis always delegated. - The proxy unwraps
InvocationTargetExceptioncorrectly — if the real method throws, the proxy throws the same exception.
Task 3 — Replace reflective dispatch with MethodHandle¶
You have a working Method.invoke-based dispatcher:
public final class EventBus {
private final Map<Class<?>, List<Map.Entry<Object, Method>>> handlers = new HashMap<>();
public void register(Object listener) {
for (Method m : listener.getClass().getDeclaredMethods()) {
if (m.getParameterCount() != 1) continue;
handlers.computeIfAbsent(m.getParameterTypes()[0], k -> new ArrayList<>())
.add(Map.entry(listener, m));
}
}
public void publish(Object event) throws Throwable {
for (var entry : handlers.getOrDefault(event.getClass(), List.of())) {
entry.getValue().invoke(entry.getKey(), event);
}
}
}
Migrate it to MethodHandle. Measure the difference with JMH.
Requirements:
- Replace
MethodwithMethodHandlein the storage map. - Use
MethodHandles.publicLookup().unreflect(m)thenmh.bindTo(listener)so each entry stores a bound handle taking only the event. - Use
invokeExactwhen possible — wrap the event in the exact type expected.
Acceptance criteria.
- A correctness test registers a listener and publishes an event; the handler runs.
- A JMH benchmark shows the
MethodHandleversion at least 2× faster than theMethod.invokeversion on a 1 M-event run. - Exceptions thrown by handlers propagate cleanly — no
InvocationTargetExceptionwrapping in the caller's stack trace. - The bound handles survive across thousands of
publishcalls; no per-call allocation.
Task 4 — VarHandle for compare-and-set on a lock-free counter¶
Implement a StripedCounter using VarHandle for lock-free atomic updates on an int[] of stripes.
public final class StripedCounter {
private final int[] stripes;
public StripedCounter(int numStripes) { stripes = new int[numStripes]; }
public void increment() { /* TODO: pick a stripe by ThreadLocalRandom, CAS-update */ }
public long sum() { /* TODO: read all stripes with acquire semantics */ }
}
Requirements:
- Obtain a
VarHandleforint[]element access viaMethodHandles.arrayElementVarHandle(int[].class). incrementusesgetAndAdd(or a CAS loop withcompareAndSet).sumreads each stripe withgetAcquire(so it observes recent updates without forcing fullvolatilesemantics).- The class is thread-safe with no
synchronized, noAtomictypes.
Acceptance criteria.
- A correctness test runs 16 threads, each calling
increment100 000 times;sumreturns 1 600 000. - A JMH benchmark shows higher throughput than an equivalent
LongAdderonly marginally (your goal is correctness;LongAdderis already striped CAS). - The class size is under 30 lines.
Task 5 — Runtime validation via annotations¶
Design an annotation @Range(min, max) and a validator that reads it off int and long fields.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Range {
long min();
long max();
}
public record Loan(@Range(min = 100, max = 1_000_000) long amountCents,
@Range(min = 1, max = 60) int termMonths) { }
Requirements:
- Validator scans
getDeclaredFields(), finds@Range, reads the value, checks bounds. - Returns a
List<Violation>describing each failure with field name, value, and bounds. - Caches the per-class field list (use
ClassValue). - Works on records — careful with private final fields, you need
setAccessible(true).
Acceptance criteria.
new Validator().validate(new Loan(50, 12))returns one violation onamountCents.new Validator().validate(new Loan(500_000, 12))returns no violations.- The validator handles non-numeric fields by ignoring them (no
@Rangemeans no check). - A 1 M-call benchmark shows the cache is effective: second-and-later calls are >10× faster than the first.
Task 6 — Debug a JPMS-blocked reflection call¶
You inherit a serialiser that worked on Java 8:
public final class StructLogger {
public String render(Object o) throws Exception {
StringBuilder sb = new StringBuilder("{");
for (Field f : o.getClass().getDeclaredFields()) {
f.setAccessible(true); // (*)
sb.append(f.getName()).append("=").append(f.get(o)).append(", ");
}
return sb.append('}').toString();
}
}
// Usage:
new StructLogger().render(java.time.Instant.now());
On Java 17 it fails:
java.lang.reflect.InaccessibleObjectException: Unable to make field private final long
java.time.Instant.seconds accessible: module java.base does not "opens java.time" to
unnamed module @5e91993f
Requirements:
- Identify exactly which package needs to be opened and to whom.
- Add the minimum
--add-opensJVM flag that makes the code work. - Then propose a better fix that doesn't require opening any JDK package.
Acceptance criteria.
- You can recite the three valid responses: (1)
--add-opens java.base/java.time=ALL-UNNAMED; (2) make the consumer modular and userequires java.base; opens java.base/java.time;(the latter is impossible —java.basecannot be re-opened from outside, and you should articulate why); (3) don't reflect intojava.timeat all — use its documented API. - The "better fix" treats JDK types specially: check
o instanceof Instantand delegate totoString(); for non-JDK objects, use the reflective path but only on packages the consumer controls. - A test confirms the better fix works without any
--add-opensflag.
Task 7 — Cache reflection lookups for a hot path¶
You profile an event processor and find this hot loop:
public final class FieldExtractor {
public Object extract(Object event, String fieldName) throws Exception {
Field f = event.getClass().getDeclaredField(fieldName); // (*)
f.setAccessible(true); // (**)
return f.get(event);
}
}
The flame graph shows 25% of CPU time inside Class.getDeclaredField and Field.copy.
Requirements:
- Build a cache keyed by
(Class<?>, String fieldName)mapping to aMethodHandle(getter). - Use
ClassValue<Map<String, MethodHandle>>so the cache scopes to the JVM lifetime and unloads with the class. - Use
MethodHandles.lookup().unreflectGetter(field)to get a getter handle. - Call with
invokeExactwhere the field type allows.
Acceptance criteria.
- A correctness test extracts a field; matches the original behaviour.
- A JMH benchmark on a 1 M-event run shows at least 10× improvement over the original (cold reflection per call).
- The cache survives across instances of
FieldExtractor(it'sstatic). - Adding a new event type for the first time pays the lookup cost once; subsequent calls hit the cached handle.
Task 8 — Benchmark reflection vs MethodHandle vs LambdaMetafactory¶
Build a JMH harness with four benchmarks invoking the same method int square(int):
- Direct call.
Method.invokewith cachedMethod.MethodHandle.invokeExactwith cachedstatic finalhandle.Function<Integer, Integer>produced byLambdaMetafactory(you'll need to lower the primitive intoInteger).
Requirements:
- Each benchmark has the same input and same output.
- Warm-up for at least 5 iterations × 1 second.
- Measure on JDK 17 and JDK 21 (or whatever pair you have). Note the difference around JEP 416 (JDK 18).
- Include
-prof gcto track allocation rates per benchmark.
Acceptance criteria.
- A short report (in comments or a
BENCHMARK.md) listing the four numbers and the GC allocation rates. - The expected ordering:
direct < methodHandle ≤ lambdaMetafactory < reflectionCached. - The allocation rate is ~0 for
direct,methodHandle, andlambdaMetafactory; non-zero forreflectionCached(the argument array). - You can articulate why
LambdaMetafactoryis roughly equal toMethodHandlehere (it generates a class whoseapplybody calls the same handle).
Validation¶
| Task | How to verify the fix |
|---|---|
| 1 | toJson(new User(...)) returns the documented string; cache hit count grows logarithmically with class count. |
| 2 | Calling a @Cached method twice returns the same instance; calling an un-@Cached method always delegates. |
| 3 | JMH shows ≥2× throughput on the MethodHandle version; no InvocationTargetException in stack traces. |
| 4 | 16 threads × 100 000 increments sum to exactly 1 600 000 across many runs. |
| 5 | The validator reports a violation for out-of-range, zero allocations on the cached path. |
| 6 | The "better fix" works with no --add-opens flags; reading a LocalDate field via the original path requires the flag. |
| 7 | Hot loop shows ≥10× improvement on JMH; Class.getDeclaredField no longer in the flame graph. |
| 8 | The expected ordering holds; -prof gc shows the allocation profile you predicted. |
Worked solution sketch — Task 7 (cached field extraction)¶
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public final class CachingFieldExtractor {
private static final ClassValue<Map<String, MethodHandle>> CACHE =
new ClassValue<>() {
@Override
protected Map<String, MethodHandle> computeValue(Class<?> c) {
MethodHandles.Lookup lookup = MethodHandles.lookup();
Map<String, MethodHandle> getters = new HashMap<>();
for (Field f : c.getDeclaredFields()) {
if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
try {
f.setAccessible(true);
getters.put(f.getName(), lookup.unreflectGetter(f));
} catch (IllegalAccessException ignored) {
// package-private / module-closed; skip
}
}
return Map.copyOf(getters);
}
};
public Object extract(Object event, String fieldName) throws Throwable {
MethodHandle getter = CACHE.get(event.getClass()).get(fieldName);
if (getter == null) {
throw new IllegalArgumentException(
"no such field: " + fieldName + " on " + event.getClass());
}
return getter.invoke(event); // not invokeExact: return type is Object
}
}
Three things to notice:
ClassValueinstead ofConcurrentHashMap<Class<?>, ...>. This gives the JDK's purpose-built per-class cache with correct unloading semantics. Don't ever cache byClass<?>in a long-livedHashMapif you care about class unloading.setAccessible(true)inside the cache builder. Done once perField, then theMethodHandlekeeps the access intact. The hot path never callssetAccessible.invokerather thaninvokeExact. Because the return type varies per field, the signature isn't known statically. The cost is small here (a single type cast on the boxed return); for a primitive-only field, useinvokeExactwith a specialised cache.
The cache turns "look up the field every call" (microseconds per call on the cold path) into "hash a string" (nanoseconds). On a 10 M-event run that's the difference between a CPU-bound and a memory-bound bottleneck.
Memorize this: reflection problems do not show up as compile errors. They show up as null annotations (wrong retention), wrong receivers (wrong loader), wrapped exceptions (InvocationTargetException), JPMS rejections (InaccessibleObjectException), or hot loops at 1/30th the throughput of a direct call. Each task above gives you one of those failures. If, after the fix, the next plausible production change costs you one line instead of ten, you have applied the right tool — MethodHandle, VarHandle, ClassValue, ServiceLoader, or an annotation processor.