annotations
retention, targets, and what an annotation actually is at runtime.
- chapter
- chapter 11.02
- reading time
- 8 min read
an annotation is metadata stuck onto a declaration. it does nothing on its own: something has to read it. that's javac, an annotation processor, or a framework using reflection while your program runs. @Retention decides which of those three gets to see it, and @Target decides where you're allowed to put it.
and at runtime, the annotation object you get back is a dynamic proxy. no, really.
the picture
you know how @Test makes JUnit run a method, but deleting the import makes it not compile and deleting the annotation makes the test silently vanish? the annotation didn't run anything. JUnit went looking for it.
think of sticky notes on a printed document. a note doesn't change a word of the text; it's for whoever reads the document next. and notes come in three kinds of glue: some fall off when the editor's done with the draft (SOURCE), some survive into the printed copy but nobody reading the book notices them (CLASS), and some stay on forever where anyone can read them (RUNTIME).
declaring one looks like an interface with an @ on it, because that's exactly what it is:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Route {
String path(); // a required element
String method() default "GET"; // optional, has a default
}javap agrees: Route compiles to interface Route extends java.lang.annotation.Annotation with an ACC_ANNOTATION flag. element values have to be things the compiler can write into a class file: primitives, String, Class literals, enum constants, other annotations, and arrays of those. no null, no new, no method calls.
how long it survives
- Retain.javakeptyour source@Run("hi") void m()
- javackeptcompiler + processorsseen, and written out
- Retain.classkepton diskRuntimeVisibleAnnotations
- running JVMkeptreflectionm.getAnnotation(Run.class).value() → "hi"
void m();
descriptor: ()V
Code:
0: return
RuntimeVisibleAnnotations:
0: #74(#43=s#75)
Run(
value="hi"
)for frameworks that look at your code while it runs: @Deprecated, @FunctionalInterface, JUnit's @Test, Spring's @Autowired, JPA's @Entity.
javap output for the same method with all three annotations on it.the trap is the middle one. CLASS is the default. write an annotation without @Retention, and it makes it into the class file and then reflection pretends it doesn't exist.
reading them at runtime
here's the core of every annotation-driven framework, in about fifteen lines: a test runner that finds @Test methods, calls them, and checks the result.
import java.lang.annotation.*;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test {
String expect();
}
class MathTests {
@Test(expect = "4") String addition() { return String.valueOf(2 + 2); }
@Test(expect = "0.3") String floats() { return String.valueOf(0.1 + 0.2); }
String helper() { return "not a test"; }
}
void main() throws Exception {
Method[] methods = MathTests.class.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName)); // the order is unspecified
var tests = new MathTests();
for (Method m : methods) {
Test t = m.getAnnotation(Test.class);
if (t == null) continue;
String got = (String) m.invoke(tests);
IO.println((got.equals(t.expect()) ? "pass " : "FAIL ") + m.getName() + " → " + got);
}
}pass addition → 4 FAIL floats → 0.30000000000000004
this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.
(0.1 + 0.2 failing is the second-best joke on this page.) JUnit is this plus a thousand features: reflection to find the methods, getAnnotation to read the metadata, invoke to run them.
notice the sort. getDeclaredMethods() promises no order at all, which is why JUnit doesn't promise one either, and why tests that depend on running in file order eventually break.
what an annotation object actually is
m.getAnnotation(Test.class) returned something you could call t.expect() on. but Test is an interface, and you never wrote a class that implements it. so what is t?
import java.lang.annotation.*;
import java.lang.reflect.Proxy;
@Retention(RetentionPolicy.RUNTIME)
@interface Route {
String path();
String method() default "GET";
}
class Api {
@Route(path = "/users") void list() {}
}
void main() throws Exception {
Route r = Api.class.getDeclaredMethod("list").getAnnotation(Route.class);
IO.println(r);
IO.println(r.method() + " " + r.path());
IO.println(r.getClass().getSimpleName());
IO.println(Proxy.isProxyClass(r.getClass()));
IO.println(Proxy.getInvocationHandler(r).getClass().getName());
}@Main.Route(method="GET", path="/users") GET /users $Proxy1 true sun.reflect.annotation.AnnotationInvocationHandler
this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.
a dynamic proxy. the first time you ask, the JVM parses the bytes of the RuntimeVisibleAnnotations attribute into a map of element names to values, and wraps it in a generated class that implements Route. calling r.path() goes through the proxy's handler, which looks "path" up in the map.
r points at. the arrows are references. the map is filled with the defaults first, then the values you wrote, which is why toString printed method before path.that also explains a few things people find odd. annotations are immutable (there's nothing to set). equals compares element values, not identity (the handler implements it that way). and every element read is a map lookup through a proxy, so frameworks read annotations once at startup and cache what they found.
where they can go, and the meta-annotations
@Target takes a list of ElementTypes. jdk 25 has twelve:
- ├──
- │ ├── TYPEclasses, interfaces, enums, records, annotations
- │ ├── FIELD
- │ ├── METHOD
- │ ├── PARAMETER
- │ ├── CONSTRUCTOR
- │ ├── LOCAL_VARIABLE
- │ ├── ANNOTATION_TYPEmeta-annotations
- │ ├── PACKAGEin package-info.java
- │ ├── TYPE_PARAMETER<@Foo T>, java 8
- │ ├── MODULEin module-info.java, java 9
- │ └── RECORD_COMPONENTjava 16
- └──
- └── TYPE_USEList<@NonNull String>, java 8
ElementType values in jdk 25, and the java version that added the later ones. the amber one is different in kind: it annotates a use of a type, not a declaration.TYPE_USE is the odd one out: it annotates a type wherever a type appears, not a declaration. that's what nullness checkers hang off (code quality tools): List<@Nullable String> says something different from @Nullable List<String>.
annotations on annotations are called meta-annotations, and there are five that matter:
| meta-annotation | what it does |
|---|---|
@Retention | how long it survives: SOURCE, CLASS (default), RUNTIME |
@Target | where it may appear. leave it off and it fits almost any declaration |
@Documented | show it in the javadoc of whatever it annotates |
@Inherited | a class annotation that subclasses also report. classes only |
@Repeatable | allowed more than once in the same place. needs a container annotation |
the last two both have a trap, and i checked both on jdk 25:
one annotation on a record component lands in four places+
record User(@Json String name) has one place to write the annotation and four declarations javac generates from it: the private field, the accessor method, the canonical constructor's parameter, and the record component itself. javac copies the annotation to every one of those that its @Target allows. with no @Target, @Json showed up on all four. an annotation with @Target(ElementType.FIELD) showed up only on the field. so if your JSON library reads accessors and your annotation is field-only, it'll never see it. pick the target deliberately.
- an annotation does nothing. javac, a processor or a framework has to go and read it.
SOURCEis for the compiler,CLASS(the default) for bytecode tools,RUNTIMEfor reflection.- forget
@Retention(RUNTIME)and reflection quietly finds nothing. - at runtime an annotation is a proxy over a map of its element values.
@Inheritedis classes only. repeated annotations hide inside a container: usegetAnnotationsByType.