An annotation is structured metadata attached to a program element: a class, method, field, parameter, module, record component or type use. It changes nothing by itself. Something has to read it, and the whole design of an annotation comes down to one question: who reads it, and when? The compiler reads @Override. An annotation processor reads Lombok-style or MapStruct-style markers at compile time and generates code. A dependency-injection container reads @Inject at runtime through reflection. A build-time indexer reads annotations straight out of class files without loading a single class.

This article follows an annotation through its whole lifecycle: how you declare one, what retention and target really control, how it is stored in the class file, how reflection and processors read it, and where the sharp edges are. It ends with a worked example that implements the same audit annotation two ways, at runtime and at compile time, so you can see the trade-off directly.

Advertisement

Declaring an annotation type

An annotation type is a special interface declared with @interface. Its methods are called elements; they take no parameters, cannot throw, and may have defaults. The allowed return types are deliberately narrow: primitives, String, Class (optionally parameterised), enums, other annotation types, and one-dimensional arrays of those. Values must be compile-time constants, which is why you cannot write @Timeout(Duration.ofSeconds(5)) and why null is never a legal element value. Frameworks use sentinel defaults such as an empty string or a marker class instead.

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Audited {
    String action();                    // required: no default
    Level level() default Level.INFO;   // optional
    String[] tags() default {};         // arrays allowed, empty default

    enum Level { INFO, SENSITIVE }
}

// Usage
@Audited(action = "transfer", level = Audited.Level.SENSITIVE, tags = {"payments"})
public void transfer(Account from, Account to, long cents) { ... }

An element named value gets shorthand: @Retry(3) means @Retry(value = 3) when it is the only element supplied. A single-element array can drop its braces. Annotations with no elements are marker annotations, and they are more common than you might expect: @FunctionalInterface, @Deprecated without arguments, @Test.

Meta-annotations: retention, target, inheritance, repetition

Annotations on annotation types are called meta-annotations, and four of them decide how yours behaves.

Meta-annotationControlsWhat people get wrong
@RetentionSOURCE, CLASS or RUNTIMEThe default is CLASS, which is invisible to reflection. Forgetting RUNTIME is the most common annotation bug.
@TargetWhich elements may carry itWithout it, the annotation is allowed on most declaration contexts but not on type uses.
@InheritedSubclass sees superclass annotationApplies only to class-level annotations inherited from superclasses; never interfaces, methods or fields.
@RepeatableSame annotation more than onceNeeds a container annotation type; the compiler wraps repeats inside it.

The ElementType enum has grown with the language: TYPE_PARAMETER and TYPE_USE arrived in Java 8, MODULE in Java 9, and RECORD_COMPONENT with records in Java 16. TYPE_USE is the powerful one: it lets an annotation sit on any use of a type, as in a nullness checker's List<@NonNull String>, which is how tools such as the Checker Framework add pluggable type systems without changing the language.

Records add a subtlety. An annotation on a record component is propagated to the generated field, accessor, canonical constructor parameter and component, but only to those contexts its @Target allows. A validation annotation targeted at FIELD alone will not appear on the accessor, so a framework that inspects getters will miss it. See Java records architecture for the full propagation model.

Advertisement

How annotations live in the class file

javac stores annotations as attributes in the class file. RUNTIME annotations go into RuntimeVisibleAnnotations; CLASS annotations go into RuntimeInvisibleAnnotations; SOURCE annotations are dropped entirely. Parameter annotations and type annotations have their own attributes (RuntimeVisibleParameterAnnotations, RuntimeVisibleTypeAnnotations and their invisible twins). Element values are encoded as constant-pool references, which is another reason they must be constants.

Two consequences matter operationally. First, CLASS retention is not useless: bytecode tools, agents and indexers can read those attributes even though reflection cannot. Second, the attributes are parsed lazily. The JVM does not decode a class's annotations until reflection asks for them, after which the result is cached per class. A missing annotation type on the classpath is not an error either: the JVM silently ignores an annotation whose type cannot be loaded, which can make a misconfigured deployment look like it lost its configuration. JVM class loading explains why the loader that defines the annotation type matters here.

Source@Audited on a methodjavacparse, attribute, checkProcessorsrounds, Filer, Messagerround 1..nnew sourcesClass fileannotation attributesSOURCEdiscarded after javacCLASSin file, not reflectiveRUNTIMEvisible to reflectionBytecode scannersClassGraph, Jandex, agentsReflection at runtimegetAnnotation, proxies
An annotation's lifecycle. Processors run inside javac in rounds; retention decides which of three audiences can see the annotation afterwards: nobody, bytecode readers, or reflection too.

Reading annotations at runtime

Reflection exposes annotations through the AnnotatedElement interface, implemented by Class, Method, Field, Constructor, Parameter, Package, Module and RecordComponent. The returned instance implements your annotation interface; in the JDK it is typically a dynamic proxy backed by a map of element values, which is an implementation detail you should not depend on.

Method m = PaymentService.class.getMethod("transfer", Account.class, Account.class, long.class);

Audited a = m.getAnnotation(Audited.class);          // null if absent or not RUNTIME
if (a != null) {
    log.info("action={} level={}", a.action(), a.level());
}

// Repeatable annotations: always use the ByType variants
Role[] roles = m.getAnnotationsByType(Role.class);    // sees direct and container-wrapped repeats

// Declared vs inherited: getDeclared* never looks at superclasses; the non-declared
// variants on Class also return @Inherited annotations from superclasses
Annotation[] own = SomeClass.class.getDeclaredAnnotations();

The distinction between present, directly present and associated matters for repeatable annotations. When a method carries two @Role annotations, the compiler stores one container annotation instead, so getAnnotation(Role.class) returns null. Only getAnnotationsByType looks through the container. Code written before Java 8 that calls getAnnotation quietly breaks the day someone adds a second role.

Performance is usually fine after the first call because of the per-class cache. getAnnotation returns the cached instance, but getAnnotations, getDeclaredAnnotations and accessors for array-valued elements return defensive copies on every call, and looking up the Method object itself is not free. Frameworks resolve annotations once, at startup or on first use, and store the decision in their own metadata: a handler table, an interceptor chain, a compiled validator.

Compile-time annotation processing

Annotation processors are plugins loaded by javac. They see the program as a model of Element and TypeMirror objects, never as loaded classes, and they may report errors, warnings and notes, or generate new source and resource files. They may not modify the classes being compiled; tools that do so, such as Lombok, reach into compiler internals, which is why they break on JDK upgrades more often than standard processors.

@SupportedAnnotationTypes("com.example.Audited")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public final class AuditedProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment round) {
        for (Element e : round.getElementsAnnotatedWith(Audited.class)) {
            if (e.getKind() == ElementKind.METHOD
                    && e.getModifiers().contains(Modifier.PRIVATE)) {
                processingEnv.getMessager().printMessage(
                    Diagnostic.Kind.ERROR, "@Audited methods must not be private", e);
            }
        }
        // generate an index of audited methods once, in the last round
        if (round.processingOver()) writeIndex();
        return false;   // do not claim the annotation; other processors may want it
    }
}

Processing happens in rounds. Round one sees the original sources; any files a processor generates become input to the next round, until a round produces nothing new. Generated files are created through the Filer, which refuses to create the same file twice, so aggregate outputs such as an index should be written once, when processing is over.

Discovery changed in JDK 23. javac used to find processors on the class path automatically. It now runs annotation processing only when processing is configured explicitly, for example with -processor, --processor-path or -proc:full; absent those options the default is -proc:none. JDK 21 and 22 print a note when a build relies on the old implicit behaviour. If your generated code silently disappeared after a JDK upgrade, this is why: declare processors on the processor path in your build tool.

How frameworks use annotations

Large frameworks layer more machinery on top of the language rules. Spring treats annotations as composable: @RestController is itself annotated with @Controller and @ResponseBody, and Spring's merged-annotation support walks meta-annotations, interfaces and superclasses, with explicit attribute aliasing. None of that is Java semantics. Plain reflection on a class annotated with @RestController will not report @Controller as present.

Scanning is the other big cost. Finding every class annotated with @Entity by loading each class and reflecting on it is slow and triggers static initialisers. Scanners such as ClassGraph and Jandex parse the annotation attributes straight out of class files instead, and build-time frameworks go further: Quarkus and Micronaut do the scan at build time and emit plain code, so the running application does little or no reflective discovery. That is also what makes ahead-of-time compilation practical, because GraalVM native images must know every reflectively accessed element in advance.

Worked example: one annotation, two implementations

Suppose every method that moves money must be audited. With a runtime implementation, @Audited has RUNTIME retention and an interceptor wraps annotated methods. A JDK dynamic proxy is enough when services sit behind interfaces:

@SuppressWarnings("unchecked")
static <T> T audited(T target, Class<T> iface, AuditSink sink) {
    return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface},
        (proxy, method, args) -> {
            // look up on the implementation, not the interface method
            Method impl = target.getClass().getMethod(method.getName(), method.getParameterTypes());
            Audited a = impl.getAnnotation(Audited.class);
            if (a == null) return method.invoke(target, args);
            long t0 = System.nanoTime();
            try {
                Object r = method.invoke(target, args);
                sink.record(a.action(), a.level(), "ok", System.nanoTime() - t0);
                return r;
            } catch (InvocationTargetException ex) {
                sink.record(a.action(), a.level(), "error", System.nanoTime() - t0);
                throw ex.getCause();
            }
        });
}

Note the explicit lookup on the implementation class. Method annotations are never inherited, so an annotation placed on the implementation is invisible on the interface's Method object, and vice versa. In production you would cache the per-method decision in a map so the reflective lookup happens once.

With a compile-time implementation, the processor above rejects misuse (private methods, which a proxy could never intercept) and generates an index file listing every audited method. A startup check compares that index with the interceptor's registrations, and a build fails if a new money-moving method lacks the annotation in a package that requires it. The runtime cost drops to zero and mistakes surface in the IDE instead of in an audit review.

Most real systems combine the two: RUNTIME retention for the interceptor, plus a processor for validation. The trade-off is summarised below.

ApproachStrengthCost
Runtime reflectionSimple; works with any library; config can change without rebuildingStartup scanning, reflective calls, errors found late
Annotation processorErrors at compile time; generated code is plain and debuggableBuild complexity; processor must be configured explicitly since JDK 23
Build-time framework indexingFast startup; native-image friendlyFramework lock-in; less dynamic

Failure modes and how to spot them

  • Annotation is null at runtime. Retention is CLASS (the default) or SOURCE. Check the declaration before anything else.
  • Annotation vanishes on a proxy. A CGLIB or ByteBuddy subclass proxy does not carry method annotations, and class annotations only if they are @Inherited. Unwrap to the user class before reflecting.
  • Interface annotations ignored. @Inherited never applies to interfaces. Walk the interfaces yourself, or use a framework utility that does.
  • Second repeat breaks the lookup. Code uses getAnnotation on a repeatable annotation; switch to getAnnotationsByType.
  • Generated sources missing after a JDK upgrade. Implicit processor discovery is off from JDK 23; configure the processor path.
  • Annotation type not on the runtime classpath. The JVM ignores it silently; a startup assertion that expected annotations are present catches this.
  • Slow startup. Classpath scanning loads every class; switch to an index built at compile time or a bytecode-level scanner.
  • Native image fails with missing metadata. Reflective annotation reads need reachability metadata; generate it rather than hand-writing it.

Design guidance and trade-offs

Annotations are good at declaring intent next to code: this method is transactional, this field is required, this class is an HTTP endpoint. They are poor at carrying logic or environment-specific configuration. A timeout that differs between staging and production belongs in configuration, with the annotation naming a key rather than holding the number.

Keep element types simple and defaults explicit. Prefer enums over strings for closed sets so the compiler validates them. Document retention and target on every annotation you publish, because changing retention later is a silent behaviour change for consumers. Resist annotation stacks five deep on one method; when a combination recurs, define a composed annotation in a framework that supports meta-annotations, and in plain Java prefer a small builder or a configuration object. Finally, reserve RUNTIME retention for annotations something actually reads at runtime; everything else should be SOURCE or CLASS, which keeps reflective surface and class-file size down. Annotations combine naturally with other modern language features; generics explains the type-erasure rules that decide what TYPE_USE annotations on type arguments can and cannot tell you at runtime.

What to do next

  1. Grep your codebase for custom @interface declarations and confirm each has explicit @Retention and @Target.
  2. Replace getAnnotation with getAnnotationsByType wherever the annotation is, or might become, repeatable.
  3. Cache reflective annotation lookups per method or class; never do them per request.
  4. If you use annotation processors, declare them on the processor path and add -proc:full or explicit processor options before moving to JDK 23 or later.
  5. Write one small processor that validates your most important annotation's usage rules at compile time.
  6. Measure startup time spent in classpath scanning, and move to a build-time index if it is significant.
Key takeaway: An annotation is inert metadata whose meaning comes entirely from its reader. Decide the reader first: the compiler, a processor, a bytecode scanner or runtime reflection. Then set retention and target to match. Remember the sharp edges: CLASS retention by default, @Inherited only on superclass class annotations, repeatable annotations hidden in containers, and explicit processor configuration from JDK 23. Resolve annotations once and cache the result, and push validation to compile time wherever you can.