Command Palette

Search for a command to run...

PHASE 15Advanced Java 5+ ~31 min· topic 1 of 6

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.

Main.javawhole filejava
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.

Main.javawhole filejava
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.

Retention: who gets to see the notediagram
Rendering diagram…

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.

Main.javawhole filejava
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.

BuilderProcessor.javawhole filejava
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
    }
}
terminal
$ javac -processorpath build/processor -d out src/com/shop/*.java
── expected output ──
src/com/shop/Pricing.java:5: error: @GenerateBuilder only works on classes
public interface Pricing {
^
1 error

Try it yourself

  1. 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 print no @Route, skipped, because the default retention is CLASS and reflection can't see it.

  2. 2

    Add a route

    Add @Route(path = "/users/{id}", method = "DELETE") public String delete() { return "user deleted"; } to UserController. Predict where it appears (methods are sorted by name, so create, delete, helper, list), then run.

  3. 3

    Put an annotation in the wrong place

    Put @Route(path = "/x") on the UserController class itself. The compiler rejects it because of @Target(ElementType.METHOD). Then add ElementType.TYPE to the target (@Target({ElementType.METHOD, ElementType.TYPE})) and it compiles.

Code & diagrams

A custom annotation read at runtime Java 8+ New tab

This is a web framework's router in miniature: find annotated methods, read their elements, call them.

Sign in to run this example in your browser.

Expected output

POST /users -> create() returns user created
helper: no @Route, skipped
GET /users -> list() returns all users
Retention decides who can see an annotation Java 5+ New tab
Sign in to run this example in your browser.

Expected output

@SourceOnly at runtime:  false
@InClassFile at runtime: false
@AtRuntime at runtime:   true
annotations reflection sees: 1
@Repeatable, @Inherited and @Deprecated's elements Java 9+ New tab

Two @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.

Sign in to run this example in your browser.

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: true
Every legal element typejava
import 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().

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Route.path()" because the return value of "java.lang.reflect.Method.getAnnotation(java.lang.Class)" is null
at Main.main(Main.java:5)

Break #2

A null default

Write String path() default null; inside the annotation.

terminal
$ java Main.java
── what you'll see ──
Main.java:4: error: element value must be a constant expression
@interface Route { String path() default null; }
^
1 error
error: compilation failed

Break #3

Leave out a required element

Write @Route with no path on a method, when path has no default.

terminal
$ java Main.java
── what you'll see ──
Main.java:3: error: annotation @Route is missing a default value for the element 'path'
class C { @Route public void list() {}
^
1 error
error: compilation failed

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, RUNTIME annotations live in RuntimeVisibleAnnotations (and RuntimeVisibleParameterAnnotations, RuntimeVisibleTypeAnnotations) attributes; CLASS ones in the RuntimeInvisible… versions. javap -v Main.class shows them. The JVM parses them lazily, the first time reflection asks, and caches the result per class.

  • ▸

    The object returned by getAnnotation is a java.lang.reflect.Proxy backed by AnnotationInvocationHandler; the element values are cloned for arrays on every call, so route.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, throws and new. They're read through AnnotatedType (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. 1

    An annotation is written @Name or @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. 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, and null is never allowed, not even as a default.

  3. 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 .class file but invisible to reflection; this is the default) or RUNTIME (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. 4

    At runtime, frameworks read RUNTIME annotations through reflection (Topic 15.2): method.getAnnotation(Route.class) returns an object whose element methods give back the values, or null when the annotation is absent. That's how JUnit finds @Test methods and how Spring finds @GetMapping handlers. Forgetting @Retention(RUNTIME) is the most common bug when writing your own: everything compiles, and reflection finds nothing.

  5. 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. 6

    Two more meta-annotations matter: **@Inherited makes 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 with getAnnotationsByType.

Explain it without notes

01

What are the three retention policies, and when would you use each?

02

How does a framework like JUnit or Spring use annotations?

03

What types are allowed for annotation elements, and why can't a default be null?

04

What is an annotation processor and what can it not do?

Practice

01

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.

02

Write an @Command("name") annotation and dispatch the string "greet" to the method annotated @Command("greet") in a Commands class.

03

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 @Scheduled method.

  • ↔

    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.