Topic 15.1
Annotations
In one line
An annotation is a label such as @Override or @Test that you attach to a class, method, field or parameter. It doesn't change what the code does by itself; the compiler, an annotation processor or a framework reads it (at compile time or, with RUNTIME retention, through reflection) and acts on it.
Think of it like this
Think of the sticky notes and stamps on a parcel. "FRAGILE", "THIS WAY UP", "PRIORITY". The note doesn't change what's inside the box. It's information for whoever handles the box: the sorting machine reads the barcode, the delivery driver reads "FRAGILE". An annotation is a sticky note on your code, and the compiler, a test runner or a framework like Spring is the handler that reads it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Annotation
- A label starting with @ placed on a piece of code. It carries information for tools; it doesn't run anything on its own.
- @interface
- The keyword that declares a new annotation, the way class declares a new class.
- Element
- A named value inside an annotation, such as path in @Route(path = "/users"). It is declared like a method with no parameters.
- Meta-annotation
- An annotation placed on another annotation's declaration to control it, such as @Retention or @Target.
- Retention
- How long an annotation is kept: only in the source, also in the .class file, or all the way to the running program.
- Target
- The kinds of places an annotation is allowed on: classes, methods, fields, parameters and so on.
- Annotation processor
- A plug-in that javac runs during compilation. It reads annotations and can generate new code or report errors.
- Marker annotation
- An annotation with no elements at all, like @Override. Its presence alone is the message.
Step by step
01Using annotations that already exist
You have used annotations since your first class: @Override on toString(). The compiler reads it and refuses to compile if the method doesn't actually override anything, which catches typos like tostring() that would otherwise silently create a new method.
@Deprecated(since = "2.0", forRemoval = true) (the two elements were added in Java 9) marks an API as on its way out: every caller gets a compiler warning, a stronger one when forRemoval is true. @SuppressWarnings("unchecked") silences one named warning, and should sit on the smallest possible declaration.
class Money {
@Override
public String toString() { return "Money"; } // compiler checks this overrides Object.toString
@Deprecated(since = "2.0", forRemoval = true)
public double asDouble() { return 0.0; } // callers get a removal warning
}02Declaring your own annotation
@interface Route { String path(); String method() default "GET"; } declares an annotation with two elements. path has no default, so every use must supply it; method is optional.
If an annotation has a single element named value, users can drop the name: @Role("admin") means @Role(value = "admin"). An array element with one item can drop the braces: @SuppressWarnings("unchecked") is short for @SuppressWarnings({"unchecked"}).
Behind the scenes @interface creates an interface that extends java.lang.annotation.Annotation. Elements are its abstract methods, which is why they're written with parentheses.
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME) // keep it until the program runs
@Target(ElementType.METHOD) // only allowed on methods
@interface Route {
String path(); // required element
String method() default "GET"; // optional, has a default
}03Retention: who gets to see the note
SOURCE annotations are thrown away by javac after it has used them, so they cost nothing at runtime. CLASS annotations are written into the .class file (tools that read bytecode can see them) but the JVM doesn't load them for reflection. RUNTIME annotations are stored in a RuntimeVisibleAnnotations attribute of the class file and loaded on demand when reflection asks for them.
If you leave @Retention off, you get CLASS, which is almost never what you want for a custom annotation you plan to read with reflection.
04Target: where the note may go
@Target(ElementType.METHOD) makes the compiler reject @Route on a class or field with the error annotation interface not applicable to this kind of declaration. Without @Target, an annotation is allowed on almost every declaration.
TYPE_USE (Java 8) is different from the others: it lets an annotation sit on any *use* of a type, such as List<@NonNull String> or (@NonNull Object) x. Checker tools such as the Checker Framework and NullAway use type annotations for null-safety analysis.
05Reading annotations with reflection
Class, Method, Field, Constructor and Parameter all implement AnnotatedElement, which offers getAnnotation(Type.class), isAnnotationPresent(Type.class), getAnnotations() and getAnnotationsByType(Type.class).
The object you get back is a JDK-generated dynamic proxy that implements your annotation interface. Calling route.path() on it returns the value stored in the class file. Two annotation objects with the same element values are equals, and their hashCode follows a rule fixed by the Annotation interface.
Method m = UserController.class.getDeclaredMethod("create");
Route r = m.getAnnotation(Route.class); // null if absent or not RUNTIME
if (r != null) {
System.out.println(r.method() + " " + r.path()); // POST /users
}06@Inherited and @Repeatable
@Inherited on an annotation declaration means a subclass reports the annotation of its superclass when you call getAnnotation on the subclass. It works only for annotations on classes; annotations on methods, or on interfaces a class implements, are never inherited this way.
@Repeatable(Roles.class) lets you write @Role("admin") @Role("support"). The compiler actually stores one @Roles({@Role("admin"), @Role("support")}). So getAnnotation(Role.class) returns null when there are two, while getAnnotationsByType(Role.class) looks through the container and returns both.
07Annotation processing at compile time
An annotation processor extends javax.annotation.processing.AbstractProcessor. javac discovers it on the processor path (through META-INF/services/javax.annotation.processing.Processor) and calls its process method in rounds: round 1 sees your sources, and if the processor generates new source files with the Filer, they are compiled and processed in round 2, and so on until no new files appear.
Processors see the code as a model (Element, TypeMirror), not as reflection objects, because the classes don't exist yet. They report problems with Messager, which turns them into ordinary compiler errors pointing at the right line. This is how MapStruct writes mapper implementations and how Dagger builds a dependency graph with zero runtime reflection.
import java.util.Set;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.Diagnostic;
@SupportedAnnotationTypes("com.shop.GenerateBuilder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment round) {
for (TypeElement ann : annotations) {
for (Element e : round.getElementsAnnotatedWith(ann)) {
if (e.getKind() != ElementKind.CLASS) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR, "@GenerateBuilder only works on classes", e);
continue;
}
// processingEnv.getFiler().createSourceFile(...) would write OrderBuilder.java here
}
}
return true; // these annotations are claimed: no other processor needs to see them
}
}Try it yourself
- 1
Remove the retention
In "A custom annotation read at runtime", delete the line
@Retention(RetentionPolicy.RUNTIME). Predict the output before running. All three methods now printno @Route, skipped, because the default retention isCLASSand reflection can't see it. - 2
Add a route
Add
@Route(path = "/users/{id}", method = "DELETE") public String delete() { return "user deleted"; }toUserController. Predict where it appears (methods are sorted by name, socreate,delete,helper,list), then run. - 3
Put an annotation in the wrong place
Put
@Route(path = "/x")on theUserControllerclass itself. The compiler rejects it because of@Target(ElementType.METHOD). Then addElementType.TYPEto the target (@Target({ElementType.METHOD, ElementType.TYPE})) and it compiles.
Code & diagrams
This is a web framework's router in miniature: find annotated methods, read their elements, call them.
Expected output
POST /users -> create() returns user created
helper: no @Route, skipped
GET /users -> list() returns all usersExpected output
@SourceOnly at runtime: false
@InClassFile at runtime: false
@AtRuntime at runtime: true
annotations reflection sees: 1Two @Role annotations are stored as one @Roles container, so getAnnotation(Role.class) finds nothing. @Audited is @Inherited; @Roles isn't, so the subclass has no roles.
Expected output
roles by type: [admin, support]
getAnnotation(Role): null
container size: 2
RefundService @Audited: true
RefundService roles: 0
oldPay deprecated since 2.0, for removal: trueimport java.lang.annotation.*;
enum Priority { LOW, HIGH }
@Retention(RetentionPolicy.RUNTIME)
@interface Job {
int retries() default 3; // primitive
String name(); // String
Class<?> handler() default Object.class; // Class
Priority priority() default Priority.LOW;// enum
Deprecated note() default @Deprecated; // another annotation
String[] tags() default {}; // 1-D array of any of the above
// Integer boxed(); // error: invalid type for annotation interface element
// String owner() default null; // error: element value must be a constant expression
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget @Retention(RUNTIME)
Declare @interface Route { String path(); } with no @Retention, then call m.getAnnotation(Route.class).path().
Break #2
A null default
Write String path() default null; inside the annotation.
Break #3
Leave out a required element
Write @Route with no path on a method, when path has no default.
Myth vs fact
Myth
Annotations make code do things.
Fact
An annotation is inert data. Something else (the compiler, a processor, a framework using reflection) must read it and act. With no reader, an annotation does nothing at all.
Myth
Every annotation is visible through reflection.
Fact
Only RUNTIME-retention ones. The default, CLASS, is invisible to getAnnotation, and SOURCE annotations such as @Override don't even reach the class file.
Myth
@Inherited makes annotations on overridden methods carry over.
Fact
@Inherited only affects annotations on classes, looked up from a subclass. Method annotations and interface annotations are never inherited through it.
Myth
Lombok is a normal annotation processor.
Fact
Standard processors can only generate new files. Lombok modifies the compiler's internal syntax tree through non-public javac APIs, which is why it needs updates for new JDK releases.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
In bytecode,
RUNTIMEannotations live inRuntimeVisibleAnnotations(andRuntimeVisibleParameterAnnotations,RuntimeVisibleTypeAnnotations) attributes;CLASSones in theRuntimeInvisible…versions.javap -v Main.classshows them. The JVM parses them lazily, the first time reflection asks, and caches the result per class. - ▸
The object returned by
getAnnotationis ajava.lang.reflect.Proxybacked byAnnotationInvocationHandler; the element values are cloned for arrays on every call, soroute.tags()allocates a fresh array each time. Hot code should read annotations once at startup and cache what it needs, which is what Spring and JUnit do. - ▸
Type annotations (
ElementType.TYPE_USE, Java 8, JSR 308) can appear on any type use, including generics, casts,throwsandnew. They're read throughAnnotatedType(method.getAnnotatedReturnType()), and they power pluggable type systems such as nullness checkers. - ▸
Annotation processors run inside
javac, so they speed up runtime at the cost of build complexity. The trend in modern frameworks (Micronaut, Quarkus, Dagger, and Spring's AOT mode for GraalVM native images) is to move annotation reading from runtime reflection to build time, cutting startup time and memory.
Remember this
- 1
An annotation is written
@Nameor@Name(element = value)in front of a declaration. Java ships with a few:@Override(the compiler checks the method really overrides something),@Deprecated(warn anyone who uses this),@SuppressWarnings("unchecked")(silence a named compiler warning),@FunctionalInterface(the compiler checks there's exactly one abstract method, Topic 10.2) and@SafeVarargs. Frameworks add many more:@Test,@Autowired,@Entity,@JsonProperty. - 2
You declare your own annotation with **
@interface. Its body lists elements**, which look like methods with no parameters:String path();. An element can have a default (String method() default "GET";). Element types are restricted to primitives,String,Class, enums, other annotations, and one-dimensional arrays of those. Values must be compile-time constants, andnullis never allowed, not even as a default. - 3
Two meta-annotations (annotations on annotations) decide how yours behaves. **
@Retention** says how long it lives:SOURCE(dropped by the compiler, for example@Override),CLASS(written into the.classfile but invisible to reflection; this is the default) orRUNTIME(kept and readable while the program runs). **@Target** says where it may be placed:TYPE,METHOD,FIELD,PARAMETER,CONSTRUCTOR,TYPE_USE(Java 8),MODULE(Java 9),RECORD_COMPONENT(Java 16) and others. - 4
At runtime, frameworks read
RUNTIMEannotations through reflection (Topic 15.2):method.getAnnotation(Route.class)returns an object whose element methods give back the values, ornullwhen the annotation is absent. That's how JUnit finds@Testmethods and how Spring finds@GetMappinghandlers. Forgetting@Retention(RUNTIME)is the most common bug when writing your own: everything compiles, and reflection finds nothing. - 5
At compile time, an annotation processor (a class that plugs into
javac) reads annotations and can generate new source files, report errors or warnings, and validate code before it ever runs. Lombok, MapStruct, Dagger, AutoValue, Immutables and Micronaut work this way. Processors can create new files but the official API can't edit existing ones; generating code at build time also means no reflection cost at startup. - 6
Two more meta-annotations matter: **
@Inheritedmakes a class-level annotation apply to subclasses too (it has no effect on methods or interfaces), and@Repeatable(Java 8) lets you write the same annotation twice on one element; the compiler wraps the copies in a container annotation**, and you read them withgetAnnotationsByType.
Explain it without notes
What are the three retention policies, and when would you use each?
How does a framework like JUnit or Spring use annotations?
What types are allowed for annotation elements, and why can't a default be null?
What is an annotation processor and what can it not do?
Practice
Write a @Max(int value) annotation for fields, and a method validate(Object o) that uses reflection to print every field whose int value is greater than its @Max.
Write an @Command("name") annotation and dispatch the string "greet" to the method annotated @Command("greet") in a Commands class.
Write a @Slow marker annotation and print how many of a class's methods carry it.
Trade-offs
- ↔
Runtime annotations plus reflection are flexible and need no build step, but they cost startup time and hide control flow: you can't see from the code who calls an
@Scheduledmethod. - ↔
Compile-time processing gives fast startup and errors at build time, but adds build complexity and generated code that you must understand when debugging.
- ↔
Annotations keep configuration next to the code it configures; external configuration (XML, YAML) can change without recompiling. Most frameworks now favour annotations with external overrides.
Done when you can
Done when you can declare an annotation with required and default elements and the right retention and target.
Done when you can read annotations through reflection and explain why getAnnotation can return null.
Done when you can explain @Inherited and @Repeatable, including the container annotation.
Done when you can describe what an annotation processor does and name tools built on one.
Done when you know which element types are legal and why null is never allowed.