Java Annotations
An annotation is a piece of metadata you attach to your code — a class, method, field, parameter, or even another annotation — that does not change what the code does by itself, but can be read by the compiler, by tools, or by your own program at runtime. Annotations power huge parts of the Java ecosystem: frameworks like Spring, Hibernate, and JUnit are almost entirely driven by annotations such as @Autowired, @Entity, and @Test. Understanding how annotations are declared, targeted, retained, and read is essential to understanding how modern Java frameworks work under the hood.
Overview: How Annotations Work
Syntactically, an annotation is just @ followed by the annotation’s name, optionally with elements in parentheses, e.g. @Override or @SuppressWarnings("unchecked"). Annotations are themselves a special kind of interface, declared with @interface instead of interface. When you write your own annotation type, the compiler generates a class file for it just like any other interface, and instances of it are created behind the scenes whenever the annotation is attached somewhere and needs to be inspected.
What happens to an annotation after compilation depends entirely on its retention policy, declared with the meta-annotation @Retention:
SOURCE— the annotation is discarded by the compiler and never makes it into the.classfile. Useful for annotations that only matter to source-level tools, like@Overrideor@SuppressWarnings.CLASS— the annotation is written into the.classfile (so bytecode tools can see it) but is not loaded into memory by the JVM’s reflection API at runtime. This is the default if you don’t specify a retention at all.RUNTIME— the annotation is written into the.classfile and kept available to the reflection API, so your running program can query it with methods likegetAnnotation(). This is the policy frameworks use to discover annotated classes and methods dynamically.
Annotations by themselves are inert — they do not add behavior. Something has to read them: the javac compiler (for @Override, @Deprecated), an annotation processor at build time (for code generation, e.g. Lombok or Dagger), or your own code using java.lang.reflect at runtime. A class full of unread annotations behaves exactly as if they weren’t there.
Syntax
[meta-annotations]
@interface AnnotationName {
ElementType elementName() default defaultValue;
ElementType anotherElement();
}
// Usage:
@AnnotationName(elementName = value, anotherElement = value2)
class OrMethodOrField { }
| Piece | Meaning |
|---|---|
@interface |
Declares a new annotation type (compiles to an interface extending java.lang.annotation.Annotation). |
| element methods | Look like abstract methods with no parameters; their return type must be a primitive, String, Class, an enum, another annotation, or an array of one of these. |
default |
Optional — gives an element a fallback value so callers can omit it. |
elements without default |
Required — every use of the annotation must supply a value for them. |
Several built-in meta-annotations configure how your custom annotation behaves:
| Meta-annotation | Purpose |
|---|---|
@Retention(RetentionPolicy...) |
How long the annotation survives: SOURCE, CLASS, or RUNTIME. |
@Target(ElementType...) |
Restricts where the annotation may legally be placed (TYPE, METHOD, FIELD, PARAMETER, CONSTRUCTOR, etc.). Omitting it allows the annotation almost anywhere. |
@Documented |
Includes the annotation in generated Javadoc. |
@Inherited |
Lets a class-level annotation be picked up by subclasses automatically. |
@Repeatable |
Allows the same annotation to be applied more than once to the same element. |
Examples
Example 1: Built-in annotations
Java ships with several annotations you have probably already used without thinking of them as annotations: @Override, @Deprecated, and @SuppressWarnings.
import java.util.ArrayList;
import java.util.List;
public class Main {
@Deprecated
static void oldMethod() {
System.out.println("This method is deprecated");
}
@Override
public String toString() {
return "Main instance";
}
public static void main(String[] args) {
oldMethod();
@SuppressWarnings("unchecked")
List<String> list = (List<String>) (List<?>) new ArrayList<Object>();
list.add("hello");
System.out.println(list.get(0));
Main m = new Main();
System.out.println(m.toString());
}
}
Output:
This method is deprecated
hello
Main instance
@Deprecated tells the compiler (and your IDE) to warn callers that a method is obsolete — it does not stop the method from running. @Override tells the compiler to verify that a method really does override a superclass method, catching typos like a mismatched signature at compile time. @SuppressWarnings silences a specific compiler warning (here, the unchecked cast) for the annotated element only. All three have SOURCE retention — they never appear in the compiled .class file at all.
Example 2: A custom RUNTIME annotation read with reflection
This is the pattern testing frameworks like JUnit use: define an annotation, scan a class for methods that carry it, and invoke them.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Test {
String description() default "no description";
int priority() default 1;
}
public class Main {
@Test(description = "Checks addition works", priority = 2)
public void testAddition() {
System.out.println("Running addition test");
}
@Test
public void testSubtraction() {
System.out.println("Running subtraction test");
}
public static void main(String[] args) throws Exception {
Main instance = new Main();
Method[] methods = Main.class.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::getName));
for (Method method : methods) {
if (method.isAnnotationPresent(Test.class)) {
Test test = method.getAnnotation(Test.class);
System.out.println("Found test: " + method.getName());
System.out.println(" Description: " + test.description());
System.out.println(" Priority: " + test.priority());
method.invoke(instance);
}
}
}
}
Output:
Found test: testAddition
Description: Checks addition works
Priority: 2
Running addition test
Found test: testSubtraction
Description: no description
Priority: 1
Running subtraction test
The @interface Test declaration defines two elements, description and priority, both with defaults, so testSubtraction can use the bare @Test form. Because Test is marked @Retention(RetentionPolicy.RUNTIME), it survives into the running program, where getDeclaredMethods(), isAnnotationPresent(), and getAnnotation() can discover and read it — this is exactly how a unit-testing library finds which methods to run without you ever calling them directly.
Example 3: Annotating a class with required elements
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.TYPE)
@interface Config {
String owner();
int version() default 1;
}
@Config(owner = "billing-team", version = 3)
class PaymentService {
void process() {
System.out.println("Processing payment");
}
}
public class Main {
public static void main(String[] args) {
Config config = PaymentService.class.getAnnotation(Config.class);
if (config != null) {
System.out.println("Owner: " + config.owner());
System.out.println("Version: " + config.version());
}
new PaymentService().process();
}
}
Output:
Owner: billing-team
Version: 3
Processing payment
Here @Target(ElementType.TYPE) restricts @Config to classes, interfaces, and enums. Because owner has no default, every use of @Config must supply it — omitting it is a compile-time error, which is a useful way to enforce that certain metadata is never forgotten.
Under the Hood
When javac compiles a class, each annotation with CLASS or RUNTIME retention is written into the RuntimeVisibleAnnotations or RuntimeInvisibleAnnotations attribute of the class file, attached to the class, field, or method it decorates. SOURCE-retained annotations never reach this stage — the compiler consumes them and throws them away.
At runtime, when you call method.getAnnotation(Test.class), the JVM does not have a real object sitting in memory waiting to be returned. Instead, it dynamically generates a proxy object that implements the annotation interface (Test, in Example 2), backed by the values stored in the class file’s annotation attribute. Calling test.description() on that proxy simply looks up the stored value and returns it. This is why annotation types can only declare abstract, no-argument methods — the JVM needs to be able to synthesize an implementation for you automatically; it cannot run arbitrary logic you might otherwise write in a method body.
Common Mistakes
Mistake 1: Forgetting @Retention(RUNTIME) and being surprised reflection finds nothing. If you omit @Retention entirely, the default is CLASS — the annotation is compiled into the class file but invisible to reflection. The code below compiles and runs fine, but silently fails to find the annotation:
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
import java.lang.reflect.Method;
@Target(ElementType.METHOD)
@interface Loggable {
}
public class Main {
@Loggable
public void save() {
System.out.println("Saving...");
}
public static void main(String[] args) throws Exception {
Method method = Main.class.getMethod("save");
Loggable annotation = method.getAnnotation(Loggable.class);
System.out.println("Annotation found: " + (annotation != null));
}
}
Output:
Annotation found: false
The fix is to add @Retention(RetentionPolicy.RUNTIME) to the @interface Loggable declaration, exactly as done for Test and Config in the examples above — then getAnnotation() returns a real instance instead of null.
Mistake 2: Using an annotation somewhere its @Target doesn’t allow. If @Target is restricted to METHOD, applying the annotation to a field is a genuine compile error, not a warning:
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)
@interface Positive {
}
class Account {
@Positive
private double balance;
}
This fails with something like “annotation type not applicable to this kind of declaration”, because @Positive is only permitted on methods. The fix is to widen the target, e.g. @Target({ElementType.METHOD, ElementType.FIELD}), so the annotation legally covers every place you intend to use it.
Best Practices
- Always set an explicit
@Retentionon custom annotations — relying on theCLASSdefault is a common source of “why does reflection return null” bugs. - Set the narrowest
@Targetthat makes sense; it prevents accidental misuse and documents intent. - Give every element a sensible
defaultunless it truly must be supplied every time — this keeps call sites readable. - Use
@Documentedon public API annotations so their presence shows up in generated Javadoc. - Prefer built-in annotations (
@Override,@Deprecated,@FunctionalInterface) over hand-rolled alternatives — they get compiler support your own annotations can’t replicate. - Remember annotations do nothing on their own; if you write one, you (or a library) must also write the reflection or annotation-processing code that reads it.
Practice Exercises
- Define a
@RUNTIME-retained annotation@MinValue(int value())targeting fields, apply it to a field, then write reflection code that reads its value and prints it. - Create an annotation
@Author(String name(), String date() default "unknown")targetingTYPE, apply it to two different classes, and write a loop that prints each class’s author usinggetAnnotation(). - Modify Example 2 so that
@Testhas a boolean elementenabled() default true, and skip invoking any method whereenabledisfalse. What output do you expect if you add a third test method withenabled = false?
Summary
- Annotations are metadata declared with
@interfaceand attached with@Name(...); by themselves they change nothing — something must read them. @Retentioncontrols how long an annotation survives: SOURCE (compiler only), CLASS (in the class file, not reflectable), or RUNTIME (reflectable while the program runs).@Targetrestricts which kinds of declarations an annotation may be placed on, and violating it is a compile-time error.- At runtime,
getAnnotation()returns a JVM-generated proxy backed by data stored in the class file — not a hand-written object. - Built-in annotations like
@Override,@Deprecated, and@SuppressWarningsare all SOURCE-retained and consumed entirely by the compiler. - Forgetting
RUNTIMEretention is the most common reason custom annotations “don’t work” when read via reflection.
