Skip to main content
Java · lesson 64 of 66
Open the Java page →
Java intermediate Lesson 64 of 66

Also part of: JUnit

Java Annotations: Custom Types and Meta-Information

Create custom annotations, understand retention policies and targets, and use reflection to read annotation metadata at runtime.

By SkillByExample · Updated

An annotation attaches metadata to a class, method, field or parameter. It does nothing on its own: the compiler, a build-time processor, or code using reflection has to read it. Every example here is a single file run with the JDK 21 source launcher (java File.java).

@Override catches mistakes at compile time

@Override tells the compiler you intend to override a superclass method. If no such method exists — a typo, a wrong parameter type — compilation fails.

public class OverrideDemo {
    public static void main(String[] args) {
        new Child().sayHello();
    }
}

class Parent {
    void sayHello() { System.out.println("Hello from parent"); }
}

class Child extends Parent {
    @Override
    void sayHelo() { System.out.println("Hi from child"); }
}
$ java OverrideDemo.java
OverrideDemo.java:12: error: method does not override or implement a method from a supertype
    @Override
    ^
1 error
error: compilation failed

Without @Override, this compiles silently and sayHello() prints the parent’s message, because sayHelo is just a new method. With the annotation, the typo is caught; fixing the name to sayHello makes it print Hi from child.

@Deprecated and @SuppressWarnings

@Deprecated makes the compiler warn at every use site. @SuppressWarnings with the matching category ("deprecation", or "removal" for forRemoval = true) silences it in one deliberate place.

public class DeprecatedDemo {
    public static void main(String[] args) {
        Legacy.oldMethod();
    }
}

class Legacy {
    @Deprecated(since = "2.0", forRemoval = true)
    static void oldMethod() { System.out.println("old"); }
}
$ javac -Xlint:all DeprecatedDemo.java
DeprecatedDemo.java:3: warning: [removal] oldMethod() in Legacy has been deprecated and marked for removal
        Legacy.oldMethod();
              ^
1 warning
$ java DeprecatedDemo
old

A warning does not stop compilation, so the program still runs. Putting @SuppressWarnings("removal") on main removes the warning — that string names the warning category javac printed in brackets.

Defining a custom annotation

A custom annotation is declared with @interface. Its elements look like methods without bodies and can have defaults. @Retention(RUNTIME) keeps it in the class file so reflection can read it.

import java.lang.annotation.*;
import java.lang.reflect.Method;

public class LogDemo {
    public static void main(String[] args) throws Exception {
        Method m = Service.class.getMethod("processOrder", String.class);
        LogExecution log = m.getAnnotation(LogExecution.class);
        System.out.println("level=" + log.level() + " timing=" + log.timing());
        new Service().processOrder("ORD-123");
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface LogExecution {
    String level() default "INFO";
    boolean timing() default false;
}

class Service {
    @LogExecution(timing = true)
    public void processOrder(String id) { System.out.println("Processing " + id); }
}
$ java LogDemo.java
level=INFO timing=true
Processing ORD-123

level was never written at the use site, so the default "INFO" came back. Elements without a default are required: leaving one out is a compile error.

Retention decides who can see it

Change only the retention and the same reflection call finds nothing.

import java.lang.annotation.*;

public class RetentionDemo {
    public static void main(String[] args) {
        System.out.println("Source:  " + Target.class.getAnnotation(Src.class));
        System.out.println("Class:   " + Target.class.getAnnotation(Cls.class));
        System.out.println("Runtime: " + Target.class.getAnnotation(Rt.class));
    }
}

@Retention(RetentionPolicy.SOURCE)  @interface Src {}
@Retention(RetentionPolicy.CLASS)   @interface Cls {}
@Retention(RetentionPolicy.RUNTIME) @interface Rt {}

@Src @Cls @Rt
class Target {}
$ java RetentionDemo.java
Source:  null
Class:   null
Runtime: @Rt()

All three annotations are on the class, but only RUNTIME survives into the running JVM. SOURCE is dropped by the compiler; CLASS is written to the .class file for tools but not loaded for reflection. CLASS is the default, which is why a custom annotation without @Retention is invisible at runtime — a common first bug.

@Target restricts where it can go

import java.lang.annotation.*;

public class TargetDemo {
    public static void main(String[] args) {
        System.out.println("compiled");
    }
}

@Target(ElementType.METHOD)
@interface Timed {}

@Timed
class Report {}
$ java TargetDemo.java
TargetDemo.java:12: error: annotation interface not applicable to this kind of declaration
@Timed
^
1 error
error: compilation failed

@Timed only makes sense on methods, so the compiler rejects it on a class. List several targets with @Target({ElementType.METHOD, ElementType.TYPE}) when an annotation fits more than one place.

Repeatable annotations

Mark an annotation @Repeatable(Container.class) to use it more than once on one element. getAnnotationsByType hides the container and returns every repeat.

import java.lang.annotation.*;

public class ScheduleDemo {
    public static void main(String[] args) throws Exception {
        var run = Task.class.getMethod("run");
        for (Schedule s : run.getAnnotationsByType(Schedule.class)) {
            System.out.println(s.day() + " at " + s.time());
        }
        System.out.println(run.getAnnotation(Schedule.class));
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Schedules.class)
@interface Schedule { String time(); String day() default "any"; }

@Retention(RetentionPolicy.RUNTIME)
@interface Schedules { Schedule[] value(); }

class Task {
    @Schedule(time = "09:00", day = "Monday")
    @Schedule(time = "14:00", day = "Friday")
    public void run() {}
}
$ java ScheduleDemo.java
Monday at 09:00
Friday at 14:00
null

The last line is null: the compiler stored the two repeats inside one @Schedules, so asking for a single @Schedule with getAnnotation finds nothing. Use getAnnotationsByType for repeatable annotations.

Annotations drive frameworks: a tiny test runner

This is how JUnit works at its core: scan a class for methods carrying an annotation, then invoke each one and record the result.

import java.lang.annotation.*;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;

public class MiniRunner {
    public static void main(String[] args) throws Exception {
        Method[] methods = CalculatorTests.class.getDeclaredMethods();
        Arrays.sort(methods, Comparator.comparing(Method::getName)); // reflection order is unspecified
        for (Method m : methods) {
            if (!m.isAnnotationPresent(Check.class)) continue;
            try {
                m.invoke(new CalculatorTests());
                System.out.println("PASS " + m.getName());
            } catch (Exception e) {
                System.out.println("FAIL " + m.getName() + ": " + e.getCause().getMessage());
            }
        }
    }
}

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Check {}

class CalculatorTests {
    @Check void addition()  { if (2 + 2 != 4) throw new AssertionError("2+2"); }
    @Check void division()  { if (7 / 2 != 3.5) throw new AssertionError("7/2 was " + 7 / 2); }
    void helper()           { throw new IllegalStateException("never called"); }
}
$ java MiniRunner.java
PASS addition
FAIL division: 7/2 was 3

helper() never runs because it has no @Check. invoke wraps whatever the method throws in an InvocationTargetException, which is why the message comes from e.getCause(). The sort matters: getDeclaredMethods() returns methods in no guaranteed order.

Practice

  1. Create a @Cacheable annotation with an int ttlSeconds() element defaulting to 300. Put it on two methods — one with ttlSeconds = 60, one without — and print each method’s TTL.
Solution
import java.lang.annotation.*;

public class CacheDemo {
    public static void main(String[] args) throws Exception {
        for (String name : new String[] {"forecast", "history"}) {
            Cacheable c = Weather.class.getMethod(name).getAnnotation(Cacheable.class);
            System.out.println(name + " ttl=" + c.ttlSeconds());
        }
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Cacheable { int ttlSeconds() default 300; }

class Weather {
    @Cacheable(ttlSeconds = 60) public String forecast() { return "sunny"; }
    @Cacheable                  public String history()  { return "rain"; }
}
$ java CacheDemo.java
forecast ttl=60
history ttl=300

@Cacheable with no parentheses is legal when every element has a default.

  1. Remove the @Retention line from Cacheable and run it again. Explain the result.
Solution
import java.lang.annotation.*;

public class CacheNoRetention {
    public static void main(String[] args) throws Exception {
        Cacheable c = Weather.class.getMethod("forecast").getAnnotation(Cacheable.class);
        System.out.println("annotation = " + c);
        System.out.println(c.ttlSeconds());
    }
}

@Target(ElementType.METHOD)
@interface Cacheable { int ttlSeconds() default 300; }

class Weather {
    @Cacheable(ttlSeconds = 60) public String forecast() { return "sunny"; }
}
$ java CacheNoRetention.java
annotation = null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Cacheable.ttlSeconds()" because "<local1>" is null
	at CacheNoRetention.main(CacheNoRetention.java:7)

The default retention is CLASS, which is not visible to reflection, so getAnnotation returns null and the next call throws. The message says <local1> rather than c because the source launcher compiles without local-variable debug info; javac -g would name the variable.

  1. Add an @Check(expected = ArithmeticException.class) style element to the mini test runner: a test passes if it throws that exception. Test 1 / 0.
Solution
import java.lang.annotation.*;
import java.lang.reflect.Method;

public class ExpectRunner {
    public static void main(String[] args) throws Exception {
        for (String name : new String[] {"divideByZero", "noThrow"}) {
            Method m = Tests.class.getDeclaredMethod(name);
            Class<? extends Throwable> expected = m.getAnnotation(Check.class).expected();
            try {
                m.invoke(new Tests());
                System.out.println("FAIL " + name + ": nothing thrown");
            } catch (java.lang.reflect.InvocationTargetException e) {
                boolean ok = expected.isInstance(e.getCause());
                System.out.println((ok ? "PASS " : "FAIL ") + name + ": " + e.getCause());
            }
        }
    }
}

@Retention(RetentionPolicy.RUNTIME)
@interface Check { Class<? extends Throwable> expected(); }

class Tests {
    @Check(expected = ArithmeticException.class) void divideByZero() { int x = 1 / 0; }
    @Check(expected = ArithmeticException.class) void noThrow()      { int x = 1 / 1; }
}
$ java ExpectRunner.java
PASS divideByZero: java.lang.ArithmeticException: / by zero
FAIL noThrow: nothing thrown

Annotation elements can be Class objects, which is exactly how JUnit 4’s @Test(expected = ...) was built.

  1. Allow @Timed from the @Target example on both methods and classes, then annotate both and print what reflection sees on each.
Solution
import java.lang.annotation.*;

public class TargetFixed {
    public static void main(String[] args) throws Exception {
        System.out.println(Report.class.isAnnotationPresent(Timed.class));
        System.out.println(Report.class.getMethod("build").isAnnotationPresent(Timed.class));
    }
}

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@interface Timed {}

@Timed
class Report {
    @Timed public void build() {}
}
$ java TargetFixed.java
true
true

ElementType.TYPE covers classes, interfaces, enums and records. @Retention(RUNTIME) had to be added too, or both lines would print false.

Frequently Asked Questions

What is the difference between SOURCE, CLASS, and RUNTIME retention?
SOURCE annotations are discarded by the compiler (like @Override). CLASS annotations are recorded in the .class file but not retained at runtime, and CLASS is the default when no @Retention is given. RUNTIME annotations are stored in the .class file and available for reflection — needed for framework annotations like JUnit's @Test.
When should I use @Target on a custom annotation?
Almost always. @Target specifies where an annotation can be used — fields, methods, types, parameters and so on. With it, the compiler rejects an annotation placed somewhere it makes no sense; without it, the annotation is allowed on nearly every declaration.
How do repeatable annotations work?
An annotation marked @Repeatable can be used several times on the same element. The compiler wraps the repeats into a container annotation (for example @Schedules holding several @Schedule), and getAnnotationsByType returns them all as an array.
Do annotations change what Java code does?
Not by themselves. An annotation is metadata; it changes behaviour only when something reads it — the compiler for @Override, an annotation processor at build time, or a framework using reflection at runtime, such as JUnit finding methods marked @Test.