Java · lesson 64 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also 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
- Create a
@Cacheableannotation with anint ttlSeconds()element defaulting to 300. Put it on two methods — one withttlSeconds = 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.
- Remove the
@Retentionline fromCacheableand 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.
- Add an
@Check(expected = ArithmeticException.class)style element to the mini test runner: a test passes if it throws that exception. Test1 / 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.
- Allow
@Timedfrom the@Targetexample 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.