Annotations and Reflection

A complete guide to Java annotations: built-in and custom annotations, meta-annotations, and annotation processing. Followed by the Reflection API: inspecting classes at runtime, invoking methods dynamically, and understanding how frameworks use these capabilities.

What Are Annotations?

An annotation is a form of metadata that you attach to a Java element: a class, a method, a field, a parameter, or even another annotation. The annotation itself does not change what the code does. It is a structured note that other tools, the compiler, annotation processors running at build time, or the reflection API at runtime, can read and act upon.

Think of annotations as labels. A label on a box does not change what is inside it, but it tells the person handling the box something important about it. The label @Override tells the compiler to check whether this method genuinely overrides something. The label @Deprecated tells the compiler to warn other developers when they use this element. A custom label like @Route("/users") tells a web framework at runtime which URL should invoke which method.

The power of annotations comes from this separation: the code itself describes the logic, and annotations describe the metadata. Frameworks like Spring, Hibernate, JUnit, and Jackson are built almost entirely on this capability. They eliminate boilerplate by reading annotations at startup or at runtime and configuring behaviour automatically based on what they find.

Built-in Annotations

Java ships with several annotations in the java.lang package that you encounter in virtually every codebase.

The standard java.lang annotations

Each one communicates something different to either the compiler or the developer reading the code.

  • @Override:Compile-time check. Tells the compiler this method must override a method in a superclass or interface. If it does not (due to a typo or signature mismatch), the compiler reports an error rather than silently creating a new unrelated method.
  • @Deprecated:Marks an element as obsolete. The compiler emits a warning whenever deprecated code is used. In Java 9+ you can add @Deprecated(since="9", forRemoval=true) to give more context.
  • @SuppressWarnings:Tells the compiler to silence specific warning categories for the annotated element. Common values: "unchecked" for raw generic types, "deprecation" for intentional use of deprecated code, "unused" for unused variables.
  • @FunctionalInterface:Compile-time check that an interface has exactly one abstract method. Allows lambdas to be used wherever the interface type is expected. The compiler errors if you accidentally add a second abstract method.
  • @SafeVarargs:Applied to methods with generic varargs parameters. Suppresses the "heap pollution" unchecked warning and promises the method body does not perform unsafe operations on the varargs array.

Built-in Annotations in Action

Java

Demonstrating @Override, @Deprecated, @SuppressWarnings, and @FunctionalInterface with practical examples.

Meta-annotations: @Target, @Retention, @Documented, @Inherited

Meta-annotations are annotations that apply to other annotations. When you define a custom annotation, you use meta-annotations to configure its behaviour: where it can be placed, how long it lives, and whether it is inherited by subclasses. They are defined in java.lang.annotation.

@Target restricts where the annotation can appear. Without it, the annotation can be applied anywhere. With it, you specify one or more ElementType constants: TYPE (classes and interfaces), METHOD, FIELD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, ANNOTATION_TYPE, and PACKAGE.

@Retention controls the annotation's lifetime. The three retention policies cover the three places where the annotation can live: SOURCE (discarded by the compiler, exists only in source code), CLASS (kept in the .class file but not loaded into the JVM at runtime, the default), and RUNTIME (kept in the .class file and available through reflection at runtime). If you need to read your annotation using the Reflection API, you must use RUNTIME.

@Documented marks the annotation for inclusion in generated Javadoc. Without it, annotations on a method do not appear in the API documentation. @Inherited allows a class-level annotation to be automatically inherited by subclasses. It only has an effect on class annotations and is not inherited by method or field annotations.

Meta-annotations: @Target and @Retention

Java

Defining what an annotation targets and how long it survives, then verifying with the Reflection API.

Creating Custom Annotations

Defining a custom annotation looks similar to defining an interface, but you use @interface instead of interface. The body contains element declarations that look like abstract methods but are really annotation attributes. Each element can have a default value, declared with the default keyword. Elements without defaults are required at the use site.

Annotation element types are restricted. They must be one of: a primitive type, String, Class (or a parameterized form like Class<?>), an enum type, another annotation type, or a single-dimensional array of any of the above. You cannot use List,Map, or any other arbitrary class. If you need a repeatable annotation, mark it with @Repeatable and define a container annotation that holds an array.

Custom Annotations: Validation Framework Example

Java

Building a mini bean validation system with custom annotations, reading them at runtime to enforce field constraints.

The Reflection API

Reflection is the ability of a program to inspect and manipulate its own structure at runtime. With the java.lang.reflect package, you can examine a class's methods, fields, and constructors without knowing anything about the class at compile time. You can invoke methods by name, read and write fields, and even construct instances of classes whose names are only known as strings.

The entry point to reflection is the Class<T> object. Every class loaded into the JVM has exactly one Class object associated with it. You can get it three ways: compile-time literal ( String.class), instance method ( obj.getClass()), or dynamic loading ( Class.forName("fully.qualified.ClassName")). Once you have the Class object, you can query everything about the class's structure.

Key reflection methods on Class

The get* variants return only public members and include inherited ones. The getDeclared* variants return all access-level members declared in this class only, without inherited members.

  • getFields() / getDeclaredFields():Returns public/all fields. Returns Field objects with metadata about name, type, modifiers, and annotations.
  • getMethods() / getDeclaredMethods():Returns public/all methods. Returns Method objects supporting invoke(), getParameterTypes(), getReturnType(), and getAnnotations().
  • getConstructors() / getDeclaredConstructors():Returns public/all constructors. Returns Constructor objects supporting newInstance().
  • getSuperclass():Returns the Class of the direct superclass, or null for Object.
  • getInterfaces():Returns the Class objects of directly implemented interfaces.
  • getAnnotations() / getDeclaredAnnotations():Returns all annotations (including inherited) / only locally declared annotations on this element.
  • getModifiers():Returns an int bitmask. Use Modifier.isPublic(), Modifier.isStatic(), Modifier.isFinal(), etc. to decode it.

Inspecting Classes at Runtime

Java

Getting the Class object three ways, reading fields, methods, superclasses, interfaces, and annotations.

Invoking Methods and Accessing Private Members

The Method.invoke(instance, args) method calls the underlying method on the given object with the given arguments and returns the result as an Object. For static methods, pass null as the instance. Primitive return types are autoboxed. If the invoked method throws an exception, it is wrapped in an InvocationTargetException; retrieve the original with e.getCause().

Java's access control system applies to reflective access by default. Attempting to invoke a private method or read a private field throws IllegalAccessException. Calling method.setAccessible(true) (or field.setAccessible(true)) bypasses this check. This is a powerful capability that frameworks use to inject dependencies into private fields, deserialize JSON into private members, and test private methods without changing their visibility. It is also why you should not treat private visibility as a security boundary against code running in the same JVM.

Java 9's module system introduced stronger encapsulation: certain packages in the JDK itself are now closed to reflective access by default, which is part of why you see "illegal reflective access" warnings when running older frameworks on newer JVMs. In application code, reflective access across module boundaries requires explicit module configuration.

Invoking Methods, Reading Fields, and setAccessible

Java

Dynamic method invocation, reading and writing private fields, and creating instances reflectively.

Reading Annotations at Runtime

Once you have a Class, Method, Field, or Constructor object, you can check for annotations with isAnnotationPresent(Class) and retrieve them with getAnnotation(Class) or getDeclaredAnnotations(). All of these only work for annotations with @Retention(RUNTIME). Annotations with SOURCE or CLASS retention are simply absent at runtime and the methods return null or empty arrays.

Scanning Annotations at Runtime

Java

A mini test runner that finds @Test methods via reflection and invokes them, mimicking how JUnit discovers tests.

How Frameworks Use Annotations and Reflection

Almost every major Java framework relies on annotations and reflection as core mechanisms. Understanding this connection explains why the framework code looks so different from what you expect to find at runtime.

Frameworks and their annotation patterns

All of these frameworks scan class files at startup (or compile time), find annotated elements, and then configure or generate behaviour automatically.

  • Spring (@Component, @Autowired, @RequestMapping):Scans the classpath for annotated classes, registers them as beans, injects dependencies into @Autowired fields using setAccessible(true), and maps HTTP routes to @RequestMapping methods.
  • Hibernate / JPA (@Entity, @Column, @Id):Reads field annotations to map Java objects to database tables and columns. Generates SQL queries based on the entity graph without any explicit SQL in the application code.
  • JUnit (@Test, @BeforeEach, @ParameterizedTest):Discovers test methods via reflection, invokes them in the correct lifecycle order, and passes parameters for parameterized tests.
  • Jackson (@JsonProperty, @JsonIgnore):Inspects fields and methods during serialization/deserialization to map JSON keys to Java fields, skip ignored fields, and handle type conversions.
  • Lombok (@Data, @Builder, @Getter):An annotation processor that runs at compile time and generates boilerplate methods (getters, setters, builders, equals, hashCode) directly into the compiled bytecode. No runtime reflection needed.

Mini Dependency Injection Container

Java

A simplified Spring-like DI container that uses reflection to instantiate classes and inject annotated dependencies.

Reflection Performance

Reflection is significantly slower than direct method calls for a few reasons. Every call goes through additional layers of the JVM's dispatch mechanism. The JIT compiler cannot inline reflective calls the way it can inline normal method calls. And access control checks happen on every invocation unless you call setAccessible(true), which itself has a one-time cost.

Good frameworks minimize this overhead by doing all reflection work once during startup or the first time a class is used, then caching the Method and Field objects and calling setAccessible(true) once. The per-invocation overhead of a cached reflective call is much smaller than the cost of re-discovering the method on every call. Never call getDeclaredMethod() inside a hot loop.

Quiz - Test Your Knowledge

Ten questions covering the purpose of annotations, built-in annotations, retention policies and targets, creating custom annotations, the Reflection API (Class, Method, Field), setAccessible, reading annotations at runtime, and how frameworks use these capabilities. Read each option carefully before selecting your answer.

Knowledge Check

1. What does @Override actually do at runtime?

2. What does @Retention(RetentionPolicy.RUNTIME) tell the JVM?

3. You define @Target(ElementType.METHOD). Where can this annotation be applied?

4. What is the key difference between clazz.getFields() and clazz.getDeclaredFields()?

5. What does method.setAccessible(true) do?

6. What does Class.forName("com.example.MyClass") do?

7. What is the purpose of @Inherited on a custom annotation?

8. What is annotation processing (APT / AbstractProcessor)?

9. What does method.invoke(instance, args) return if the method returns void?

10. Why is reflection generally slower than direct method calls?