Back to Blog
Java

Java Annotations: Syntax, Retention, and Runtime Use

java annotations: Understand how Java annotations work: declaration syntax, retention policies, runtime reflection, and practical guidance for creating custom annotati...

javaannotationsreflectioncustom-annotationsretention-policy
Illustration of a Java class blueprint with annotation tags attached and a magnifying glass representing runtime reflection

Java annotations are metadata attached to declarations in source code. They do not change the behavior of the annotated code by themselves; instead, they carry information that a compiler, framework, or runtime can read and act on. A method annotated with @Override is not executed differently because of the annotation. The compiler uses it to verify that the method actually overrides a superclass method and reports an error when it does not.

Annotations can appear on types, methods, fields, parameters, local variables, and even on other annotations. Their placement is controlled by the @Target meta-annotation, which is declared on the annotation type itself. When you see @Deprecated on a class or @Autowired on a field, you are looking at metadata that influences tooling or framework behavior, not the direct execution of the code.

Built-In Annotations You Will Encounter

The Java language ships with several built-in annotations that appear in almost any project. The most common are @Override, @Deprecated, and @SuppressWarnings. The compiler treats these specially.

@Override tells the compiler that the annotated method overrides a method from a superclass or implements an interface method. If no such method exists, the compiler raises an error. @Deprecated marks an element as no longer recommended for use, and the compiler emits a warning when other code references it. @SuppressWarnings suppresses compiler warnings for the annotated element, such as unchecked casts or unused variables.

Beyond these, the standard library provides annotations used by the runtime and by tools. @FunctionalInterface tells the compiler that an interface is intended to have exactly one abstract method, which is required for use in lambda expressions. @SafeVarargs suppresses warnings about unsafe varargs usage in generic methods.

Retention Policies Control the Annotation Lifecycle

A key decision when creating an annotation is its retention policy. The @Retention meta-annotation determines how long the annotation is kept:

RetentionPolicyStored in class fileVisible at runtimeTypical use
SOURCENoNoCode generators, source-level tools
CLASSYesNoDefault; compile-time tools
RUNTIMEYesYesFrameworks, reflection-based processing

Most frameworks that read annotations at runtime, such as Spring or JUnit, require RUNTIME retention. If you write a custom annotation and forget the retention policy, it defaults to CLASS, which means reflection will not see it. This is one of the most common mistakes when defining custom annotations.

Declaring a Custom Annotation

Declaring an annotation type uses the @interface keyword. The declaration looks like an interface, but the members are called elements, not methods, and they cannot have parameters or throw exceptions.

import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface LogExecution { String level() default "INFO"; boolean includeArgs() default false; }

This annotation can be placed on methods. It has two elements: level, which defaults to "INFO", and includeArgs, which defaults to false. When you apply it, you can provide values for either element:

@LogExecution(level = "DEBUG", includeArgs = true) public void processOrder(Order order) { // business logic }

Elements must be of a primitive type, String, Class, an enum, another annotation, or an array of those. They cannot be arbitrary objects. This restriction exists because annotation values are stored as constants in the class file and resolved at compile time.

Reading Annotations at Runtime with Reflection

An annotation with RUNTIME retention is visible through reflection. The java.lang.reflect.AnnotatedElement interface provides methods such as isAnnotationPresent, getAnnotation, and getAnnotations. Since Class, Method, Field, and Parameter all implement AnnotatedElement, you can inspect annotations on any of these declarations.

Method method = OrderService.class.getMethod("processOrder", Order.class); if (method.isAnnotationPresent(LogExecution.class)) { LogExecution config = method.getAnnotation(LogExecution.class); String level = config.level(); boolean includeArgs = config.includeArgs(); // build logging behavior from the annotation values }

This pattern is how many frameworks work internally. A framework scans classes or methods, reads annotations, and wires behavior based on the values found. When you write your own annotation processor, you are responsible for the same scanning logic, either at startup time or lazily when a method is first invoked.

Runtime Cost and Performance Considerations

Reflection-based annotation lookup is not free. Calling getAnnotation on a method repeatedly, for example inside a hot loop that processes every request, repeats the lookup work each time. The cost is small for a single call, but it becomes measurable when the same annotated method is invoked thousands of times per second.

The practical solution is to cache the annotation lookup result. Once you have read the annotation values, store them in a map keyed by the Method or Class object, and reuse them on subsequent calls. This avoids repeated reflection calls while keeping the behavior identical.

private final Map<Method, LogExecution> cache = new ConcurrentHashMap<>(); LogExecution config = cache.computeIfAbsent(method, m -> m.getAnnotation(LogExecution.class));

Caching is particularly important in frameworks that process many annotated methods during startup. Reading annotations once at initialization and storing the resolved configuration avoids reflection overhead during request handling.

Common Mistakes and Design Constraints

Several mistakes appear regularly when developers create custom annotations. The most common is forgetting @Retention(RetentionPolicy.RUNTIME) and then wondering why getAnnotation returns null. Another is placing an annotation on the wrong target, which produces a compile-time error unless the target is broadened with @Target.

A design constraint worth respecting: annotations should carry declarative metadata, not logic. If you find yourself writing complex code inside an annotation processor, consider whether a regular class or a configuration object would be clearer. Annotations are most maintainable when they describe intent in a compact, readable form, and the processing logic stays in a dedicated component.

Also note that annotation elements are fixed at compile time. You cannot compute a value dynamically inside an annotation, and you cannot use an instance field of a class as an element value. If you need runtime-dependent configuration, a regular configuration object is the right tool, not an annotation.

java annotations: Practical Usage and Code Examples | RYUSLOG DEV