10. Advanced Runtime and Language Features
Everything so far has assumed the compiler knows your types. This chapter covers the two features that let code work with types it has never seen: reflection, which reads and manipulates classes at runtime, and annotations, which attach machine-readable metadata to your declarations. Together they are the engine behind JUnit, Spring, Jackson, Hibernate, and almost every framework you will use, so understanding them turns "framework magic" into ordinary code.
<a id="101-reflection"></a>
10.1 Reflection
This subchapter covers how the JVM exposes its own type information to running code, how to read and manipulate members that the compiler would never let you touch, and when the power is worth its considerable cost.
The Class Object and Runtime Introspection
Normally you write new ArrayList<>() because you knew at compile time that
you wanted an ArrayList. But consider the problem JUnit has: it must find and
run your test methods without anyone ever writing a line of code that names
them. Or the problem Jackson has: it must turn arbitrary JSON into an instance
of a class that did not exist when Jackson was compiled. Neither problem can be
solved with ordinary method calls, because the types involved are unknown until
the program is already running.
Reflection is the JVM's answer. It is the ability of a program to examine and modify its own structure at runtime. The word comes from the image of code looking at itself in a mirror.
The mechanism rests on one idea: when a class loader loads a class, the JVM
builds a permanent in-memory description of it and hands you a handle to that
description. The handle is an object of type java.lang.Class. Think of it as
the blueprint kept in the building's lobby. The building is the running object;
the blueprint tells you what rooms it has, without you having to walk through
them.
Employee.java --javac--> Employee.class --ClassLoader--> Class object
(source) (bytecode) (in metaspace)
|
+--------------------+--------------------+-----------------+
| | | |
getDeclaredFields() getDeclaredMethods() getSuperclass() getInterfaces()
| | | |
Field[] Method[] Class<?> Class<?>[]
There is exactly one Class object per type per class loader, so identity
comparison with == is safe and is the idiomatic way to test types
reflectively.
There are three ways to obtain one, and the difference between them matters.
public class ClassObjectDemo {
public static void main(String[] args) throws Exception {
// 1. Class literal: resolved by the compiler, needs the type by name.
// Does NOT trigger static initializers.
Class<String> fromLiteral = String.class;
// 2. From an instance: gives the DYNAMIC type, not the declared one.
Object o = "hello"; // declared type is Object
Class<?> fromInstance = o.getClass(); // but this is String
// 3. From a String: the entry point every framework uses, because the
// name can come from a config file. DOES initialize the class.
Class<?> fromName = Class.forName("java.util.ArrayList");
System.out.println(fromLiteral == fromInstance); // true: one per type
System.out.println(fromName.getName()); // java.util.ArrayList
System.out.println(fromName.getSimpleName()); // ArrayList
}
}
Class.forName throws a checked ClassNotFoundException, which is exactly the
right design: the name was a string, so it might be wrong.
The naming methods differ in ways that surprise people, especially for arrays and nested types.
| Expression | getName() | getSimpleName() |
|---|---|---|
String.class | java.lang.String | String |
int.class | int | int |
int[].class | [I | int[] |
String[].class | [Ljava.lang.String; | String[] |
Map.Entry.class | java.util.Map$Entry | Entry |
Use getCanonicalName() when you want the form a programmer would type
(java.util.Map.Entry), and getName() when you need the form a class loader
accepts.
Beyond names, a Class object answers structural questions:
Class<?> c = java.util.ArrayList.class;
c.getSuperclass(); // class java.util.AbstractList
c.getInterfaces(); // [List, RandomAccess, Cloneable, Serializable]
c.getPackageName(); // java.util
c.isInterface(); // false
c.isEnum(); // false
c.isArray(); // false
c.isRecord(); // false (Java 16+)
Modifier.toString(c.getModifiers()); // "public"
// Runtime type tests, the reflective equivalents of instanceof and a cast
Object value = new java.util.ArrayList<String>();
List.class.isInstance(value); // true, like (value instanceof List)
List.class.isAssignableFrom(c); // true, like "an ArrayList IS-A List"
List<?> narrowed = List.class.cast(value); // checked cast, no unchecked warning
Two limits are worth internalizing now.
Generics are erased. There is no List<String>.class, and
new ArrayList<String>().getClass() is just ArrayList. Generic type
arguments survive only where they were written into the class file as
declarations, such as a field's or method's signature, which is why the next
topic can still recover them.
Class literals do not initialize. Touching Config.class does not run
Config's static block; Class.forName("Config") does. If you need the handle
without the side effects, use the three-argument form:
// Load and link, but do NOT run static initializers
Class<?> c = Class.forName("com.example.Config", false,
ClassObjectDemo.class.getClassLoader());
Key Takeaways
- Reflection lets running code inspect and manipulate types that were unknown at compile time.
- Every loaded type has exactly one
Classobject per class loader, so==is a valid comparison.- Get it three ways:
Type.class,instance.getClass(), andClass.forName(String).getClass()returns the dynamic type, which is why it ignores the declared variable type.Class.forNameinitializes the class; a class literal does not.getName,getSimpleName, andgetCanonicalNamediffer for arrays and nested classes.isInstanceandisAssignableFromare the reflective forms ofinstanceofand assignment compatibility.- Generic type arguments are erased from objects, so
List<String>.classdoes not exist.
🧪 Practice
- Write a method
describe(Object o)that prints the object's class name, its superclass chain up toObject, and every interface it implements.- Add a
static { System.out.println("init"); }block to a class, then show that a class literal does not print it butClass.forNamedoes.- Write a method that takes a
Stringclass name from the command line and prints whether it is an interface, an enum, a record, or a plain class.- Interview: Why does
getClass()on a variable declared asObjectreturnString? (Hint: which of the two types exists at runtime?)
Inspecting Fields, Methods, and Constructors
A Class object is a directory. The entries in it live in java.lang.reflect
and come in three flavours that mirror what you can declare in a class body:
Field, Method, and Constructor. Method and Constructor share a parent,
Executable, because both take parameters and can throw.
The first thing to learn is the pair of prefixes, because choosing wrong is the single most common reflection bug.
| Method | Access levels returned | Inherited members? |
|---|---|---|
getFields() | public only | Yes |
getDeclaredFields() | All, including private | No |
getMethods() | public only | Yes |
getDeclaredMethods() | All, including private | No |
getConstructors() | public only | Not applicable |
getDeclaredConstructors() | All | Not applicable |
Read it as: getDeclared* means "written in this exact class, whatever its
visibility"; the plain form means "publicly usable on this type, wherever it
came from". Neither one alone gives you "every field including inherited
private ones", so frameworks walk the superclass chain themselves.
import java.lang.reflect.*;
import java.util.Arrays;
class Employee {
private String name;
protected int age;
public double salary;
private static final String COMPANY = "Acme";
public Employee() { }
private Employee(String name) { this.name = name; }
public String getName() { return name; }
private void raise(double percent) { salary *= 1 + percent / 100; }
}
public class Inspect {
public static void main(String[] args) throws Exception {
Class<?> c = Employee.class;
// Every field declared here, at any access level
for (Field f : c.getDeclaredFields()) {
System.out.printf("%-20s %-8s %s%n",
Modifier.toString(f.getModifiers()), // "private static final"
f.getType().getSimpleName(), // String, int, double
f.getName());
}
// Only public fields, but inherited ones too
System.out.println(Arrays.toString(c.getFields())); // [public double Employee.salary]
// Look up one member by name and parameter types
Method raise = c.getDeclaredMethod("raise", double.class);
System.out.println(raise.getReturnType()); // void
System.out.println(raise.getParameterCount()); // 1
Constructor<?> ctor = c.getDeclaredConstructor(String.class);
System.out.println(ctor.getParameterTypes()[0]); // class java.lang.String
}
}
Lookup by name uses the exact signature. getDeclaredMethod("raise", double.class) finds nothing if the parameter is Double, because boxing does
not apply to reflective lookup. A missing member raises
NoSuchMethodException or NoSuchFieldException.
Modifiers arrive as an int bitmask, decoded through Modifier:
int mods = field.getModifiers();
Modifier.isPrivate(mods); // true
Modifier.isStatic(mods); // true
Modifier.isFinal(mods); // true
Modifier.isTransient(mods); // false
Modifier.toString(mods); // "private static final"
Recovering generic types. Erasure removes generics from objects, but not
from declarations. Field, method, and superclass signatures keep theirs in the
class file, so reflection can read them through the getGeneric* methods, which
return java.lang.reflect.Type rather than Class.
class Repository {
Map<String, List<Integer>> index;
}
Field f = Repository.class.getDeclaredField("index");
System.out.println(f.getType()); // interface java.util.Map (erased)
// The generic view keeps the type arguments
ParameterizedType pt = (ParameterizedType) f.getGenericType();
System.out.println(pt.getActualTypeArguments()[0]); // class java.lang.String
System.out.println(pt.getActualTypeArguments()[1]); // java.util.List<java.lang.Integer>
This is precisely how Jackson knows to deserialize into
Map<String, List<Integer>> rather than a raw Map.
Parameter names are erased by default. Parameter.getName() returns
arg0, arg1, and so on unless the class was compiled with javac -parameters. Frameworks that bind by parameter name, including Spring MVC,
either require that flag or fall back to an annotation that states the name
explicitly.
Breaking encapsulation. Reading a private field's metadata is always
allowed; reading its value is not, until you ask:
Employee e = new Employee();
Field name = Employee.class.getDeclaredField("name");
name.setAccessible(true); // suppress the access check
name.set(e, "Ada"); // now legal despite being private
System.out.println(name.get(e)); // Ada
setAccessible(true) is the line that makes reflection feel like cheating, and
since Java 9 the module system limits it. Inside a named module, a package must
be explicitly opened for this to work:
// module-info.java
module com.example.app {
opens com.example.model; // to everyone, for reflection
opens com.example.entity to org.hibernate.orm.core; // to one module
}
Reflecting into JDK internals such as java.lang has been denied by default
since Java 16 and throws InaccessibleObjectException. The escape hatch is a
launch flag, and needing it is a sign you are relying on something you should
not:
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
Your own classes on the classpath live in the unnamed module, which is open by default, so ordinary application reflection keeps working.
Two more rules that bite in practice. final fields can be set reflectively
only when they are instance fields and the field is made accessible; static final fields, especially ones the compiler inlined as constants, cannot be
changed meaningfully. And records are shallowly immutable by design: their
fields reject reflective writes outright.
Key Takeaways
Field,Method, andConstructorinjava.lang.reflectmirror what a class body can declare.getDeclaredXmeans "declared here, any visibility";getXmeans "public, including inherited".- Neither prefix returns inherited private members, so walk the superclass chain yourself.
- Reflective lookup matches exact parameter types, with no boxing or widening.
- Decode
getModifiers()with theModifierhelper methods.getGenericTyperecovers type arguments from declarations, which erasure leaves in the class file.- Parameter names need
javac -parametersto survive compilation.setAccessible(true)bypasses access control, subject to moduleopensrules since Java 9.- Record fields and
static finalconstants resist reflective mutation.
🧪 Practice
- Write
printAllFields(Class<?> c)that lists fields of a class and of every superclass, marking which class each came from.- Given a class with a
List<String>field and aMap<Integer, String>field, print the actual type arguments of each without hard-coding them.- Write a method that finds every
privatemethod in a class whose name starts withvalidateand prints its parameter types.- Interview: Your framework needs to read a private field of a class in a named module and gets
InaccessibleObjectException. What are the two proper fixes? (Hint: one belongs to the module being read, the other to the command line.)
Dynamic Instantiation and Invocation
Inspection is read-only. The second half of reflection is acting: creating objects and calling methods chosen at runtime. This is what turns introspection into a framework.
Creation goes through Constructor, not through the deprecated
Class.newInstance(). The old method was removed from favour because it
propagated the constructor's checked exceptions without declaring them, which
defeats the compiler's exception checking.
import java.lang.reflect.*;
public class Dynamic {
public static void main(String[] args) throws Exception {
Class<?> c = Class.forName("Employee");
// Public no-arg constructor: the common framework case
Object emp = c.getDeclaredConstructor().newInstance();
// A private constructor, with an argument
Constructor<?> ctor = c.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
Object ada = ctor.newInstance("Ada");
// Call a private instance method
Method raise = c.getDeclaredMethod("raise", double.class);
raise.setAccessible(true);
raise.invoke(ada, 10.0); // first arg is the receiver
// Call a static method: receiver is null
Method valueOf = Integer.class.getMethod("valueOf", String.class);
Object boxed = valueOf.invoke(null, "42");
System.out.println(boxed); // 42
// Read and write a field
Field salary = c.getDeclaredField("salary");
salary.setAccessible(true);
salary.setDouble(ada, 50_000);
System.out.println(salary.getDouble(ada)); // 50000.0
}
}
invoke and newInstance erase the return type to Object, so every result
needs a cast, and a wrong cast fails only at runtime. That loss of type safety
is the real price of reflection, more than the speed cost.
Exception wrapping is the detail everyone trips on. If the invoked method throws, reflection does not let that exception escape directly. It wraps it:
try {
method.invoke(target, args);
} catch (InvocationTargetException e) {
// e is the wrapper; e.getCause() is what the method ACTUALLY threw
Throwable actual = e.getCause();
throw new IllegalStateException("call failed: " + method.getName(), actual);
} catch (IllegalAccessException e) {
// The member was not accessible; setAccessible was needed
throw new IllegalStateException(e);
}
Forgetting getCause() produces the notorious stack trace that says only
InvocationTargetException and hides the real NullPointerException three
frames down.
Here is the whole toolkit in one small but genuinely useful piece of code: a mapper that builds an object out of a string-keyed map, which is the core of every CSV and form binder you have used.
import java.lang.reflect.*;
import java.util.*;
public final class SimpleMapper {
public static <T> T fromMap(Class<T> type, Map<String, String> data) {
try {
// Requires a no-arg constructor, possibly non-public
Constructor<T> ctor = type.getDeclaredConstructor();
ctor.setAccessible(true);
T instance = ctor.newInstance();
for (Field f : type.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue; // skip constants
String raw = data.get(f.getName());
if (raw == null) continue; // absent key
f.setAccessible(true);
f.set(instance, convert(f.getType(), raw));
}
return instance;
} catch (ReflectiveOperationException e) {
throw new IllegalArgumentException("cannot map to " + type.getName(), e);
}
}
// Reflective set() needs the boxed form for primitives; the JVM unboxes it
private static Object convert(Class<?> target, String raw) {
if (target == String.class) return raw;
if (target == int.class || target == Integer.class) return Integer.valueOf(raw);
if (target == double.class || target == Double.class) return Double.valueOf(raw);
if (target == boolean.class|| target == Boolean.class) return Boolean.valueOf(raw);
throw new IllegalArgumentException("unsupported type " + target);
}
}
Note ReflectiveOperationException: it is the common supertype of
ClassNotFoundException, NoSuchMethodException, NoSuchFieldException,
IllegalAccessException, and InstantiationException, and catching it once is
much cleaner than five catch blocks.
Cost. Reflection is slower than a direct call, but the numbers are widely exaggerated. What actually costs is the lookup, not the call.
| Operation | Rough relative cost |
|---|---|
| Direct method call (JIT-inlined) | 1 |
Cached Method.invoke with accessibility set | 2 to 5 |
getDeclaredMethod on every call | 50 to 100 |
Class.forName on every call | 100 and up |
The rule follows directly: resolve reflectively once, cache the Method or
Field, then reuse it. Every serious framework builds a
Map<Class<?>, Metadata> at startup for exactly this reason.
For hot paths where even cached invoke is too slow, java.lang.invoke offers
method handles, which the JIT can inline like ordinary calls:
import java.lang.invoke.*;
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType signature = MethodType.methodType(String.class); // returns String
MethodHandle getName = lookup.findVirtual(Employee.class, "getName", signature);
Employee emp = new Employee(); // note the DECLARED type, see below
String n = (String) getName.invokeExact(emp); // strongly typed, JIT-friendly
Method handles are harder to use, and invokeExact is the reason: it compares
the handle's type against the static types at the call site, not the runtime
types. Declaring the receiver as Object, the way newInstance returns it,
throws WrongMethodTypeException even though the object really is an
Employee:
Object loose = new Employee();
String bad = (String) getName.invokeExact(loose); // WrongMethodTypeException
String ok = (String) getName.invoke(loose); // invoke adapts the types
Use invokeExact when you control the static types and want maximum speed, and
invoke when you need the looser conversion behaviour. Either way, method
handles are the right tool when reflection sits inside a loop that runs millions
of times.
When not to reflect. Reflection defeats the compiler, breaks refactoring tools, hides dependencies from readers, and can be blocked by the module system or a security policy. Reach for it only when the type genuinely is not known until runtime. The alternatives, in order of preference:
| Need | Prefer |
|---|---|
| Call one of several known behaviours | An interface plus polymorphism |
| Plug in implementations by config | ServiceLoader or a factory map |
| Generate boilerplate from annotations | An annotation processor at compile time |
| Invoke unknown members in a hot loop | MethodHandle / VarHandle |
| Truly arbitrary user-supplied types | Reflection, with cached lookups |
Key Takeaways
- Create objects with
getDeclaredConstructor().newInstance(), not the deprecatedClass.newInstance().Method.invoke(receiver, args)takesnullas the receiver for static methods.- Exceptions thrown by the target are wrapped in
InvocationTargetException; always unwrap withgetCause().- Catch
ReflectiveOperationExceptionto cover the whole family in one block.- Reflective results are typed
Object, so you trade compile-time safety for flexibility.- Lookup dominates the cost, so cache
MethodandFieldobjects instead of re-resolving them.MethodHandlefromjava.lang.invokeis the fast alternative for hot paths.- Prefer interfaces,
ServiceLoader, or annotation processing whenever the type is actually known.
🧪 Practice
- Extend
SimpleMapperwith atoMap(Object)method that produces aMap<String, String>from any object's non-static fields.- Write a method that invokes a method named on the command line, and prints the real exception message when the target throws. Verify that without
getCause()the message is useless.- Benchmark one million calls to a getter three ways: directly, through a cached
Method, and through aMethodlooked up each iteration. Report the ratios.- Interview: A colleague uses reflection to call a method whose name is a compile-time constant. What should you suggest instead, and what does the codebase gain? (Hint: think about what a rename refactor does to each version.)
<a id="102-annotations"></a>
10.2 Annotations
This subchapter covers metadata: how to attach it to declarations, how to decide who is allowed to see it, and how to write the runtime code that acts on it.
Built-In Annotations
Some information about code is not code. "This method replaces one in the superclass." "Do not use this any more." "Yes, I know this cast is unchecked, and I have checked it myself." Before annotations existed, that information went into comments, naming conventions, or separate XML files, where nothing could verify it and nothing kept it in sync.
An annotation is metadata attached to a declaration in a form the compiler and the runtime can read. The analogy is a luggage tag. It is not part of the suitcase and it does not change what is inside, but the handling system reads it and behaves differently because of it.
Annotations do not, by themselves, do anything. Something must read them: the compiler, an annotation processor at build time, or reflective code at runtime. An annotation nobody reads is an expensive comment.
Java ships a handful in java.lang, and all of them are read by the compiler.
| Annotation | Applies to | What the compiler does |
|---|---|---|
@Override | Methods | Errors if nothing is being overridden |
@Deprecated | Any declaration | Warns at each use site |
@SuppressWarnings | Any declaration | Silences the named warnings |
@FunctionalInterface | Interfaces | Errors unless exactly one abstract method |
@SafeVarargs | Certain methods, constructors | Silences generic varargs warnings |
@Override is the one you will use most. It is optional, and that is
exactly why it is valuable: it converts a silent mistake into a compile error.
class Animal {
public void speak() { }
}
class Dog extends Animal {
@Override
public void speak() { } // fine
@Override
public void speek() { } // COMPILE ERROR: typo caught immediately
// Without @Override this would silently become a new, never-called method.
}
It also works for implementing an interface method, and using it there is good practice for the same reason.
@Deprecated marks something that still works but should not be used in new
code. Since Java 9 it carries two elements that turn a vague warning into
actionable information.
public class LegacyApi {
/**
* @deprecated Use {@link #fetch(Query)} instead; this form cannot paginate.
*/
@Deprecated(since = "2.4", forRemoval = true) // forRemoval escalates the warning
public List<Row> fetch(String sql) { return List.of(); }
}
Note the pair: the @Deprecated annotation tells the compiler, and the
@deprecated Javadoc tag tells the human what to use instead. Write both.
forRemoval = true produces a stronger "removal" warning rather than an
ordinary deprecation warning.
@SuppressWarnings is a promise, not a fix. You are telling the compiler
you have verified something it cannot verify.
public class Warnings {
@SuppressWarnings("unchecked") // I verified the list really holds Strings
public List<String> readList(Object source) {
return (List<String>) source; // unavoidable unchecked cast
}
@SuppressWarnings({"unchecked", "rawtypes"}) // several at once
public void legacyBridge(List raw) { }
}
Common values are unchecked, deprecation, rawtypes, serial, and
unused. The discipline that matters: apply it to the narrowest possible
declaration, ideally a single local variable, never a whole class. A
class-level suppression hides the warnings you wanted to see next month.
@FunctionalInterface documents intent and enforces it, which protects your
lambdas from a future maintainer.
@FunctionalInterface
public interface Validator<T> {
boolean test(T value); // the single abstract method
// Allowed: default and static methods do not count against the limit
default Validator<T> negate() { return v -> !test(v); }
}
Add a second abstract method and the interface itself fails to compile, rather than breaking every lambda that used it.
@SafeVarargs addresses a narrow but real problem: a varargs parameter of a
generic type creates an array of a generic type, which is unsound in general, so
the compiler warns at the declaration. If your method only reads the array and
never leaks it, the warning is noise.
@SafeVarargs // allowed on static, final, or private
public static <T> List<T> listOf(T... items) { // and on constructors
return new ArrayList<>(Arrays.asList(items)); // only reads items
}
It is restricted to methods that cannot be overridden, because a subclass could otherwise break the promise.
Beyond these, java.lang.annotation holds the meta-annotations covered later,
and the ecosystem supplies thousands more: @Test and @BeforeEach from JUnit,
@Entity and @Column from Jakarta Persistence, @Component and
@Autowired from Spring. They are all ordinary annotations plus ordinary code
that reads them, which is what the rest of this subchapter builds.
Key Takeaways
- An annotation is machine-readable metadata on a declaration; it changes nothing by itself.
- Something must read it: the compiler, a build-time processor, or reflection.
@Overrideturns a misspelled override into a compile error, so use it always.@Deprecated(since, forRemoval)pairs with the@deprecatedJavadoc tag naming the replacement.@SuppressWarningsis a claim you verified something; scope it as narrowly as possible.@FunctionalInterfaceenforces exactly one abstract method, protecting existing lambdas.@SafeVarargsonly applies where the method cannot be overridden.
🧪 Practice
- Write a subclass that misspells an overridden method name, compile it without
@Override, then add the annotation and compare the outcomes.- Deprecate a method with
sinceandforRemoval = true, call it from another class, and record the exact compiler warning text.- Take a class with a class-level
@SuppressWarnings("unchecked")and push the suppression down to the smallest declarations that need it.- Interview: Why is
@Overrideoptional when the compiler could infer everything it checks? (Hint: what does overloading look like next to overriding?)
Defining Custom Annotations
Once you see that annotations plus a reader equal a framework, writing your own
becomes routine. The declaration looks like an interface with an @ in front,
because that is literally what it is: @interface declares an interface that
extends java.lang.annotation.Annotation.
public @interface Author {
String name(); // required: no default given
String date() default "unknown"; // optional
int revision() default 1;
String[] reviewers() default {}; // an array element, empty by default
}
The members look like methods but behave like named fields with defaults. That is why they are called elements, and why the reader gets their values by calling them.
@Author(name = "Ada Lovelace", date = "1843-10-01", reviewers = {"Babbage"})
public class AnalyticalEngine { }
// revision was omitted, so it is 1
Three pieces of syntax sugar are worth knowing, because published annotations use them constantly.
// 1. A single element named exactly "value" can be written without its name
public @interface Retry { int value(); }
@Retry(3) // shorthand for @Retry(value = 3)
void flakyCall() { }
// 2. A single-element array can drop the braces
public @interface Roles { String[] value(); }
@Roles("ADMIN") // same as @Roles({"ADMIN"})
@Roles({"ADMIN", "AUDITOR"}) // several still need braces
void deleteAccount() { }
// 3. No elements at all makes a MARKER annotation, and parentheses are optional
public @interface Experimental { }
@Experimental
class NewParser { }
Element types are restricted, because the values must be storable in a class file as constants. The permitted set is:
| Allowed | Example element declaration |
|---|---|
| Primitives | int max(); |
String | String message(); |
Class or Class<?> | Class<?> handler(); |
| Enum types | Level level(); |
| Another annotation type | Validation check(); |
| One-dimensional arrays of the above | String[] tags(); |
Notably absent: null as a default, arbitrary objects, and nested arrays. The
standard workaround for "no value" is a sentinel such as "", -1, or a
dedicated enum constant.
import java.lang.annotation.*;
public enum Severity { LOW, MEDIUM, HIGH }
@Retention(RetentionPolicy.RUNTIME)
public @interface Issue {
String id();
Severity severity() default Severity.MEDIUM; // enum element
Class<? extends Throwable> expected() default Throwable.class; // Class element
String owner() default ""; // "" stands in for absent
}
@Issue(id = "JAVA-118", severity = Severity.HIGH, expected = TimeoutException.class)
class RetryingClient { }
Repeating annotations. By default a declaration may carry each annotation
once. To allow several, mark the annotation @Repeatable and supply a container
annotation whose value() is an array of it.
import java.lang.annotation.*;
@Repeatable(Schedules.class) // points at its container
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Schedule {
String cron();
}
@Retention(RetentionPolicy.RUNTIME) // the container needs matching
@Target(ElementType.METHOD) // retention and targets
public @interface Schedules {
Schedule[] value(); // must be named value
}
public class Jobs {
@Schedule(cron = "0 0 * * *")
@Schedule(cron = "0 12 * * *") // legal only because of @Repeatable
public void reconcile() { }
}
The compiler quietly rewrites the two @Schedule annotations into one
@Schedules. That detail leaks into the reader, which must use
getAnnotationsByType(Schedule.class) rather than getAnnotation, as the last
topic shows.
Inheritance. Annotations are not inherited unless you say so, and even then only in one narrow case, covered next.
Key Takeaways
- Declare an annotation with
@interface; it is an interface extendingAnnotation.- Members are elements: they look like methods, and
defaultmakes them optional.- An element named
valuecan be written without its name.- A single-element array can omit its braces.
- No elements at all makes a marker annotation.
- Element types are limited to primitives,
String,Class, enums, annotations, and one-dimensional arrays of those.nullcannot be a default, so use a sentinel such as""or-1.@Repeatableneeds a container annotation whosevalue()is an array of the repeatable type.
🧪 Practice
- Define
@Benchmarkwith elementsiterations(default 100) andwarmup(default 10), then annotate three methods using all three call styles: defaults only, one element, both.- Define
@Columnwithname, aboolean nullabledefaulting totrue, and aClass<?> converterdefaulting to a no-op converter class.- Make a
@Tagannotation repeatable, annotate one class with three tags, and confirm it compiles.- Interview: Why can an annotation element not have a
nulldefault, and what do real libraries do instead? (Hint: where are element values stored?)
Retention Policies and Targets
An annotation without meta-annotations is nearly useless, and the reason is specific: by default an annotation is discarded before your program can read it. Understanding why requires following an annotation through the build.
Java code passes through three stages, and an annotation can be visible in any
prefix of them. @Retention chooses how far it travels.
stage: SOURCE (.java) CLASS FILE (.class) RUNTIME (in the JVM)
-------------- ------------------- -------------------
SOURCE visible -> DISCARDED by javac -> absent
CLASS visible -> stored -> not readable reflectively
RUNTIME visible -> stored -> readable via reflection
default when @Retention is omitted: CLASS
| Policy | Survives to | Who reads it | Example |
|---|---|---|---|
SOURCE | Compilation only | The compiler, annotation processors, linters | @Override, @SuppressWarnings, Lombok's @Data |
CLASS | The class file | Bytecode tools, not reflection | Nullability annotations read by static analysers |
RUNTIME | The running JVM | Reflection, therefore frameworks | @Test, @Entity, @Autowired |
The practical rule is short: if reflective code must see it, it needs
@Retention(RetentionPolicy.RUNTIME). The default of CLASS is the single
most common cause of "my annotation processing code finds nothing", because
getAnnotation returns null without any error.
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME) // REQUIRED for the runtime reader below
public @interface Audited { }
class Ledger {
@Audited void post() { }
}
// Ledger.class.getDeclaredMethod("post").isAnnotationPresent(Audited.class)
// true with RUNTIME, false with CLASS or SOURCE
@Target restricts where the annotation may legally appear. Omitting it
allows almost every declaration, which invites misuse; stating it turns a
misplaced annotation into a compile error.
@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface Transactional { }
class Service {
@Transactional void save() { } // fine
@Transactional String name; // COMPILE ERROR: FIELD is not a target
}
The full set of ElementType constants:
| Constant | Legal position |
|---|---|
TYPE | Class, interface, enum, record, annotation type |
FIELD | Instance and static fields, enum constants |
METHOD | Method declarations |
PARAMETER | Method and constructor parameters |
CONSTRUCTOR | Constructor declarations |
LOCAL_VARIABLE | Local variables |
ANNOTATION_TYPE | Other annotation declarations, making it a meta-annotation |
PACKAGE | A package-info.java declaration |
TYPE_PARAMETER | A generic parameter, as in <@Nullable T> |
TYPE_USE | Any use of a type, including casts and new |
MODULE | A module-info.java declaration |
TYPE_USE is the widest and enables annotations in positions that look unusual
at first:
@Target(ElementType.TYPE_USE)
@Retention(RetentionPolicy.RUNTIME)
@interface NonNull { }
// All of these become legal with TYPE_USE
@NonNull String name = "Ada";
List<@NonNull String> names = new ArrayList<>();
String s = (@NonNull String) value;
Two smaller meta-annotations complete the set.
@Inherited makes an annotation visible on subclasses through
getAnnotation. Its scope is narrower than the name suggests: it applies only
to class inheritance. Annotations on interfaces are never inherited by
implementing classes, and annotations on methods are never inherited by
overriding methods.
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface Monitored { }
@Monitored class BaseJob { }
class NightlyJob extends BaseJob { }
// NightlyJob.class.isAnnotationPresent(Monitored.class)
// true with @Inherited, false without it
// Note: getDeclaredAnnotations() on NightlyJob is empty either way
@Documented includes the annotation in the generated Javadoc of anything
it is applied to. It costs nothing and should be on any annotation that forms
part of your public API.
A complete, production-shaped annotation declaration therefore stacks four meta-annotations:
import java.lang.annotation.*;
@Documented // show up in Javadoc
@Retention(RetentionPolicy.RUNTIME) // readable by our reflective reader
@Target({ElementType.TYPE, ElementType.METHOD}) // classes and methods only
@Inherited // subclasses of an annotated class count
public @interface Cacheable {
String region() default "default";
long ttlSeconds() default 300;
}
Key Takeaways
@Retentiondecides whether an annotation survives to the class file and to runtime.- The default is
CLASS, which reflection cannot read, so runtime annotations must sayRUNTIMEexplicitly.- A missing
RUNTIMEretention fails silently:getAnnotationjust returnsnull.SOURCEretention suits compiler checks, linters, and annotation processors.@Targetrestricts legal positions and converts misuse into a compile error.TYPE_USEallows annotations on any appearance of a type, including generic arguments and casts.@Inheritedworks only down a class hierarchy, never from interfaces or onto overriding methods.@Documentedputs the annotation in the Javadoc of what it annotates.
🧪 Practice
- Define the same annotation twice with
CLASSandRUNTIMEretention, then show that only one is found byisAnnotationPresent.- Give an annotation
@Target(ElementType.METHOD)and deliberately place it on a field; record the compiler error.- Build a three-level hierarchy where the top class carries an
@Inheritedannotation, and print whatgetAnnotations()andgetDeclaredAnnotations()each return at every level.- Interview: Your custom annotation compiles and reads well, but your reflective scanner never finds it. What is the first thing you check? (Hint: what is the default retention?)
Processing Annotations at Runtime
This topic joins the two halves of the chapter. Reflection can see declarations; annotations mark which declarations matter. Put them together and you have written a framework.
The reading API lives on every reflective element, because Class, Field,
Method, Constructor, Parameter, and Package all implement
AnnotatedElement.
| Method | Returns |
|---|---|
isAnnotationPresent(X.class) | boolean, the cheap filter |
getAnnotation(X.class) | The instance, or null |
getAnnotations() | All present, including @Inherited ones |
getDeclaredAnnotations() | Only those written on this element itself |
getAnnotationsByType(X.class) | An array, and the only correct call for @Repeatable |
The returned object is a proxy implementing your annotation interface, so you read element values as method calls:
Method m = Jobs.class.getDeclaredMethod("reconcile");
if (m.isAnnotationPresent(Cacheable.class)) {
Cacheable c = m.getAnnotation(Cacheable.class);
System.out.println(c.region() + " for " + c.ttlSeconds() + "s"); // element access
}
// Repeatable: getAnnotation would return null, because the compiler wrapped them
for (Schedule s : m.getAnnotationsByType(Schedule.class)) {
System.out.println(s.cron());
}
A complete example: a validation framework. Two annotations and one reader, which is the whole of Jakarta Bean Validation in miniature.
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface NotBlank {
String message() default "must not be blank";
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Range {
int min();
int max();
String message() default "out of range";
}
class User {
@NotBlank(message = "username is required")
private String username;
@Range(min = 18, max = 120)
private int age;
User(String username, int age) { this.username = username; this.age = age; }
}
public final class Validator {
public static List<String> validate(Object target) {
List<String> errors = new ArrayList<>();
Class<?> type = target.getClass();
// Walk the hierarchy so inherited private fields are covered too
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
for (Field f : c.getDeclaredFields()) {
f.setAccessible(true);
try {
checkField(f, f.get(target), errors);
} catch (IllegalAccessException e) {
throw new IllegalStateException("cannot read " + f.getName(), e);
}
}
}
return errors;
}
private static void checkField(Field f, Object value, List<String> errors) {
NotBlank notBlank = f.getAnnotation(NotBlank.class);
if (notBlank != null) {
if (!(value instanceof String s) || s.isBlank()) { // pattern matching
errors.add(f.getName() + ": " + notBlank.message());
}
}
Range range = f.getAnnotation(Range.class);
if (range != null && value instanceof Integer n) {
if (n < range.min() || n > range.max()) {
errors.add(f.getName() + ": " + range.message()
+ " [" + range.min() + ".." + range.max() + "]");
}
}
}
public static void main(String[] args) {
System.out.println(validate(new User(" ", 9)));
// [username: username is required, age: out of range [18..120]]
System.out.println(validate(new User("ada", 36))); // []
}
}
Nothing in User calls the validator, and nothing in the validator names
User. That decoupling is the entire point of annotation-driven design.
A second example: a test runner, which is JUnit reduced to forty lines and shows the invocation side.
import java.lang.annotation.*;
import java.lang.reflect.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test {
Class<? extends Throwable> expected() default None.class;
// A sentinel class, since annotations cannot default to null
final class None extends Throwable { private None() { } }
}
public final class MiniRunner {
public static void run(Class<?> testClass) throws Exception {
Object suite = testClass.getDeclaredConstructor().newInstance();
int passed = 0, failed = 0;
for (Method m : testClass.getDeclaredMethods()) {
Test t = m.getAnnotation(Test.class);
if (t == null) continue; // not a test method
m.setAccessible(true);
try {
m.invoke(suite);
if (t.expected() != Test.None.class) {
System.out.println("FAIL " + m.getName()
+ ": expected " + t.expected().getSimpleName());
failed++;
} else {
passed++;
}
} catch (InvocationTargetException e) {
Throwable actual = e.getCause(); // the REAL failure
if (t.expected().isInstance(actual)) {
passed++; // the expected exception
} else {
System.out.println("FAIL " + m.getName() + ": " + actual);
failed++;
}
}
}
System.out.printf("%d passed, %d failed%n", passed, failed);
}
}
Note the two details that come straight from earlier topics: the sentinel
None class standing in for a null default, and getCause() unwrapping
InvocationTargetException so an expected exception can be matched.
Practical cautions.
- Scan once, at startup. Walking every field of every class on every request
is the classic annotation-framework performance bug. Build a
Map<Class<?>, List<FieldRule>>when the application boots and reuse it. - Reflection sees only loaded classes. Finding all annotated classes in a
package requires scanning the classpath, which the JDK does not offer. Real
frameworks use Spring's
ClassPathScanningCandidateComponentProvider, Reflections, or ClassGraph, or they generate an index at build time. - Respect the module system. Reading annotations is always allowed, but
reading private field values needs the package
opens. - Do not put behaviour in annotations. An annotation holds constants. The policy belongs in the reader, which is what makes the same annotation usable by a validator, a documentation generator, and a schema exporter.
Runtime reflection is not the only option, and for many jobs it is the worst one. Compare the three places annotation-driven work can happen:
| Approach | When it runs | Cost at runtime | Errors surface | Typical use |
|---|---|---|---|---|
| Reflection | Every run | Highest | At runtime | Spring, Jackson, JUnit |
Annotation processor (javax.annotation.processing) | At compile time | None | At compile time | Lombok, Dagger, MapStruct, immutables |
| Bytecode generation or weaving | Build or load time | Near zero | Build time | AspectJ, mocking libraries |
An annotation processor reads SOURCE-retention annotations during
compilation and generates real source files, so the output is ordinary code the
compiler checks. That is why Dagger can do dependency injection with no runtime
reflection at all, and why native-image builds prefer it. The trade is
flexibility: the processor cannot see anything decided after the build.
Choose runtime reflection when the configuration genuinely varies per deployment, and compile-time processing when it does not.
Key Takeaways
Class,Field,Method,Constructor, andParameterall implementAnnotatedElement, which is where the reading API lives.- Use
isAnnotationPresentto filter cheaply, thengetAnnotationto read element values as method calls.getAnnotationsByTypeis mandatory for@Repeatableannotations, because the compiler wraps them in a container.- Annotation instances are proxies; element values are constants fixed at compile time.
- Walk the superclass chain to reach inherited private fields.
- Cache the result of annotation scanning at startup rather than rescanning per call.
- The JDK cannot enumerate annotated classes in a package; that needs a classpath scanner or a build-time index.
- Keep policy in the reader, not the annotation, so several readers can share one annotation.
- Annotation processors do the same work at compile time with no runtime cost and earlier errors.
🧪 Practice
- Add a
@MaxLength(int value)rule to the validator above, including a default message, and verify it reports the field name.- Extend
MiniRunnerto honour@BeforeEachand@AfterEachannotations, running the marked method around every test.- Rewrite the validator so it builds its rules into a
Map<Class<?>, List<Field>>on first use and reuses them on later calls. Measure the difference over ten thousand validations.- Write an
@Injectannotation and a tiny container that instantiates a class by reflection and fills every@Injectfield from aMap<Class<?>, Object>registry.- Interview: Spring can inject dependencies at runtime and Dagger does it at compile time, both driven by annotations. What does each approach buy, and what does it cost? (Hint: think about startup time, error timing, and configuration that is only known after deployment.)
<a id="11-memory-model-and-object-lifecycle"></a>