Skip to content

Covariant Returns and Bridge Methods — Professional

What? What a tech lead does with bridges day-to-day: spotting them in code review, knowing when Mockito or Spring will handle them for you and when they won't, mentoring engineers through javap-aware debugging, and writing the rare ArchUnit/JOL checks that protect framework code. How? You don't use bridges, but you must recognise them on sight. The job is to keep the team from spending an afternoon chasing a "method not intercepted" or "annotation missing" bug when the cause is a synthetic forwarder.


1. The reviewer's checklist

When reviewing code that uses generics or covariant returns, run a four-item mental check:

  1. Is reflection involved? If the PR calls Class.getDeclaredMethods(), getMethod, or getMethods, ask: "Does this filter bridges?" If not, request a Method.isBridge() check.
  2. Is AOP involved? If the class is a Spring bean and there's a @Transactional, @Async, @Cacheable, or custom aspect, ask: "Did you test this through both the strongly typed and the raw/generic-parent reference?" If not, ask for a test using Comparable c = (Comparable) bean; style call.
  3. Is annotation processing involved? Annotation processors should iterate getDeclaredMethods() and explicitly check m.isBridge() or m.isSynthetic(). Catch these in review.
  4. Is the override correct in source? Verify @Override is present. The compiler adds bridges for you; manually writing the erased signature creates a real method that shadows the bridge and breaks polymorphism.

A short rejection comment for #1:

Class.getDeclaredMethods() returns synthetic bridge methods generated by javac for generic and covariant-return overrides. Filter with Method.isBridge() or you will register both the real method and the bridge, producing duplicate handlers. See [org.springframework.core.BridgeMethodResolver] for the reference pattern.

Reviewers who consistently apply this catch 90% of bridge-related bugs before they ship.


2. Mockito's automatic handling — what to trust

Mockito since 2.x routes calls through bridges correctly in normal use. The mock proxy is a subclass; when you call a mocked method on a typed reference, the call resolves to the real method's slot, and Mockito records the invocation there. The bridge forwards via invokevirtual so a separate "bridge invocation" doesn't usually appear.

What you can rely on:

  • when(mock.method(args)).thenReturn(x) on a strongly typed reference works regardless of bridges.
  • verify(mock).method(args) works regardless of bridges.
  • @Spy on a class with covariant returns works.

What you cannot rely on:

  • Calls made through a raw reference or via reflection. Mockito captures the dispatched method; the bridge is the dispatched method for raw calls, and the matcher logic differs subtly across versions.
  • Stubbing a method that exists only as a bridge (i.e., the real method is on a parent generic class). Use the typed reference of the subclass, not the raw parent.
  • Pre-3.0 Mockito with mock-maker-inline. Older inline mocking has known bridge issues. Pin to 3.12+ or 4.x.

Mentoring move: when a junior reports "Mockito stubs but the call goes to the real method", first ask them to print the runtime class of the reference and the result of Method.isBridge() on the resolved method. That converts a guess into a 30-second diagnosis.


3. Spring AOP — when to trust the framework, when to assert

Spring's BridgeMethodResolver.findBridgedMethod(method) is the canonical fix and has been integrated into Spring's AOP and annotation-detection code paths since 4.x. In ordinary use you don't think about it.

You do need to think about it when:

  • You write a custom MethodInterceptor or BeanPostProcessor that walks targetClass.getDeclaredMethods(). Always pass each method through BridgeMethodResolver.findBridgedMethod before reading annotations.
  • You use a non-Spring AOP library (AspectJ in LTW mode, ByteBuddy, ASM-based instrumentation). Most are fine, but each has edge cases. Pin and test.
  • You write framework infrastructure that introspects beans for declarative contracts (your own @RetryWith, your own metrics annotation). The same caveat applies: always resolve bridges before reading annotations.

A regression test that protects this in your codebase:

@Test
void aspect_runs_on_generic_method_via_raw_reference() {
    GenericService<User> typed = applicationContext.getBean(GenericService.class);
    @SuppressWarnings("rawtypes")
    GenericService raw = typed;

    raw.process(new User("a"));     // call through the bridge

    verify(metrics, times(1)).recordProcessed();   // aspect ran
}

If this test fails, your aspect doesn't follow bridges. Add the resolver call.


4. Reading bytecode in PR review

You don't need to inspect every class file. But for changes to:

  • A class implementing Comparable<T>, Iterable<T>, Function<T,R>, or any custom generic interface,
  • A class with covariant return overrides,
  • Framework infrastructure (annotation processors, reflection scanners, custom AOP),

ask the PR author to attach javap -p -v output in the description for the affected classes. It takes 30 seconds, gives you an immediate window into the bridges generated, and creates institutional memory: a year later, someone modifying the class can compare bytecode before/after.

A reviewer aphorism: if a generic class's bytecode changes between PRs and the reviewer hasn't seen javap output, the review missed half the change.


5. Mentoring javap-aware debugging

When a developer is stuck on:

  • "Why is my annotation not being picked up?"
  • "Why does Mockito stub but the real method runs?"
  • "Why does my AOP advice fire twice?"
  • "Why does Jackson serialise this field weirdly?"

Walk them through this sequence:

  1. javap -p -v TheClass and look for ACC_BRIDGE.
  2. For each bridge, identify which parent's signature it satisfies.
  3. List the framework's reflection code path. Is it filtering bridges?
  4. If not, fix at the framework call (or open an issue if it's a third-party library).

This is faster than reading the framework's source. The bytecode tells you what exists; the framework tells you what the framework does with what exists. Bridges are an existence problem, not a behaviour problem.


6. ArchUnit for framework safety

For codebases that develop reflection-heavy infrastructure, an ArchUnit rule can fail the build when someone writes getDeclaredMethods() without filtering bridges:

@ArchTest
ArchRule no_unfiltered_getDeclaredMethods = noClasses()
    .that().resideInAPackage("..framework..")
    .should().callMethodWhere(
        target(name("getDeclaredMethods"))
            .and(target(owner(Class.class))))
    .because("Filter Method.isBridge() before iterating, or use a helper that does.");

This is heavy-handed but appropriate for the layer that ships frameworks to other teams. Pair it with a MethodUtils.realMethods(clazz) helper that filters bridges, so the rule has an idiomatic alternative.

For application code that just calls business methods, this check is overkill — most code doesn't reflect at all.


7. JOL for layout questions you'll occasionally need

JOL (Java Object Layout) lets you confirm that bridges don't show up in instance layout. They don't — bridges are methods, not fields. But framework engineers will occasionally ask: "Is the bridge taking memory per instance?" The answer is no:

System.out.println(ClassLayout.parseClass(Score.class).toPrintable());

Score has only its int value field. Bridges live in the class's method table in metaspace, contributing a fixed cost per class, not per instance. For a class with a couple of bridges, this is a few hundred bytes in metaspace — negligible at any reasonable class count.

The reverse question — how much metaspace do bridges consume? — is only worth asking in extreme cases (millions of dynamically generated classes, e.g. ORM proxy generation). There, bridges are dwarfed by the regular methods anyway.


8. Communicating the topic to junior engineers

Three sticky teaching moves:

Move 1: "Show me the bytecode." Whenever a junior asks "why does this method appear twice?" or "why does reflection see two compareTos?", run javap -p -v together. Don't explain in the abstract.

Move 2: "The compiler keeps two contracts." The source contract (your typed method) and the JVM contract (the erased parent's descriptor). Bridges are the translation layer.

Move 3: "Filter bridges or resolve through them." Whenever they write code that reflects on methods, the choice is binary: hide bridges (m.isBridge() filter) or follow them to the real method (BridgeMethodResolver-style walk). There is no third option.

Once a junior internalises these three, they'll see bridges in every generic class and never be surprised by them again.


9. Quick rules

  • In code review, demand Method.isBridge() filtering on every getDeclaredMethods() call in framework code.
  • Trust Mockito and Spring AOP in normal use; assert with regression tests on custom infrastructure.
  • Ask for javap -p -v output in PRs that touch generic classes or covariant returns.
  • Use Spring's BridgeMethodResolver pattern (or equivalent) whenever you read annotations off methods reflectively.
  • ArchUnit guards are appropriate for framework layers; overkill for application code.
  • When a junior is debugging "annotation missing" or "AOP not running", javap -p -v is the first tool, not the last.
  • Bridges live in metaspace, not on the heap — instance memory is unaffected.

10. What's next

Topic File
JLS §8.4.5, JVMS §4.6, ACC_BRIDGE specification.md
Ten realistic bugs caused by bridge methods find-bug.md
Bridge invocation cost, JIT inlining optimize.md
Hands-on exercises tasks.md
Interview Q&A interview.md

Cross-references: dispatch mechanics in ../01-jvm-method-dispatch/ explain why the JVM matches by descriptor and therefore why bridges exist; ../02-vtable-and-itable/ explains the slot layout that bridges plug into.


Memorize this: your job at the senior/lead level isn't to handcraft bridges — it's to make sure the team's reflection-heavy code paths either filter bridges or resolve through them, and to spot the missing filter in PRs before it ships as a "missing annotation" bug.