Analysing code can often be a tedious task: you have to deal with (often) obscure libraries, meta-models, specifications, and an ever-evolving language that continuously piles on new features. While I was testing my research tool that automatically detects ad-hoc features from static code analysis, I stumbled upon an unexpected outcome that stumped me:

Basically, my program associated the class PackageGhost with a method that was only defined in its superclass Package, which is problematic since, in the context of ad-hoc feature detection, a class is only associated with methods that come from its subclasses. I was quick to blame myself for letting such a (seemingly) obvious bug appear in my code, as I thought I messed up the implementation of the 'reverse-inheritance' relation. However, further investigation showed that the bug may be coming from the library I was relying on to analyse my class hierarchies.
The Ptidej tool suite
To build my 'reverse-inheritance' relation and create my concept lattice, I need to perform a static analysis of the code under study (to retrieve the class hierarchy). For this task, I use the Ptidej tool suite, which offers an interface that allows for the creation of a meta-model from which I can retrieve all the information I need. I was certain I had found my culprit, since the examination of this meta-model showed that every public method of Package was duplicated in PackageGhost. However, something was not adding up.
This behaviour was inconsistent, and methods weren't duplicated every time: for instance, this didn't happen with interfaces. To really understand what was happening, we have to dig deeper. To create the meta-model, I was calling the method generateModelFromClassFilesDirectory(String filePath), which, as you would have guessed, looks at class files to generate a model.
Looking at the class file level
In short, a Java class file contains Java bytecode, created by the compiler, meant to be executed on the Java Virtual Machine (JVM). Several options exist to understand what is happening at this level. I personally found the terminal command javap -v to be useful for getting the 'metadata' (such as the specific class file version) from a specific class file, but for decompilation it might be more straightforward to just look for some online tool.
When decompiling PackageGhost.class, something unexpected appeared: every public method defined in Package was replicated in PackageGhost.class. What was even more suprising, is that these methods were tagged as being 'bridge method' and 'synthetic method'.
Bridge and Synthetic Methods
From the Java Virtual Machine Specification (JVMS), we can read, in section 4.6: "ACC_Bridge [...] A bridge method, generated by the compiler." and "ACC_Synthetic [...] Declared synthetic; not present in the source code."
Basically, this tells us that the compiler specifically adds these methods to PackageGhost.class. There is already a well-documented case that involves bridges: generics and type erasure.
Dealing with erasure
A bit of history to start: generics were not always part of the Java language. If you've dealt with some legacy code (cough Ptidej cough), you've probably already seen raw collection types (i.e., something like new ArrayList()). However, the problem was that collections could accept any type and the compiler would not prevent us from doing illegal operations.
To catch those errors at compile-time, generics were introduced in JDK 5.0. However, to make legacy code compatible on newer JDKs (and probably for many other reasons), type erasure was also introduced. In short, it discards type information when compiling classes, and adds type casts when necessary to preserve type safety, such that generics are treated like any other elements.
This creates a new problem for polymorphism. Let's consider the code below:
package foo;
public interface GenericInterface<T> {
public void foo(T v1, T v2);
}package foo;
public class NonGenericImplementation implements GenericInterface<String>{
@Override
public void foo(String v1, String v2) {
}
}Because of type erasure, the method from NonGenericImplementation will not override foo from GenericInterface. To solve this issue and preserve the polymorphism of generic types, the compiler will generate a bridge method that can be seen in the classfile:
/* Decompiler 4ms, total 58ms, lines 13 */
package foo;
public class NonGenericImplementation implements GenericInterface<String> {
public void foo(String v1, String v2) {
}
// $FF: synthetic method
// $FF: bridge method
public void foo(Object var1, Object var2) {
this.foo((String)var1, (String)var2);
}
}However, type erasure is not what I was dealing with, so I had to dig deeper and find some behaviour that had very little documentation.
Reflection in Java
The suspicion at the lab was that my case was due to a mismatch (somehow) between the Java Language Specification (JLS) and the JVMS. The reason why, is that it seemed like bridge methods always seemed to appear in PackageGhost, regardless of the version of the javac compiler (I tested as far back as JDK 1.8 !). However, after extensive readings of both specifications, this doesn't seem to be the reason. After poking around a bit with ChatGPT, it led me to the source of the problem: the bug report JDK-6342411.
The report shows a very similar context and highlights something that I didn't mention yet: the superclass Package of PackageGhost is non-public, while PackageGhost is public. Now, this generally would not be particularly interesting, but the bug report shows that, in very old Java versions (the bug report is from 2005!), the public methods inherited by PackageGhost, from its non-public superclasses, would not be accessible via reflection, and that bridge methods were added as a workaround. Basically, the problem exists because the reflection package doesn't follow the JLS accessibility rules, and checks the access based on the declaring type and not the reference type. Explained in simpler terms, it means that, for reflection to work, the method has to be visible from the point of view of the class where reflection happens.
The bug report points to a specific code snippet added for the workaround:
else if (impl == meth
&& impl.owner != origin
&& (impl.flags() & FINAL) == 0
&& (meth.flags() & (ABSTRACT|PUBLIC)) == PUBLIC
&& (origin.flags() & PUBLIC) > (impl.owner.flags() & PUBLIC)) {
// this is to work around a horrible but permanent
// reflection design error.
addBridge(pos, meth, impl, origin, bridges);
}Which is part of the following method:
void addBridgeIfNeeded(DiagnosticPosition pos,
Symbol sym,
ClassSymbol origin,
ListBuffer<JCTree> bridges) {
if (sym.kind == MTH &&
sym.name != names.init &&
(sym.flags() & (PRIVATE | STATIC)) == 0 &&
(sym.flags() & SYNTHETIC) != SYNTHETIC &&
sym.isMemberOf(origin, types)) {
MethodSymbol meth = (MethodSymbol)sym;
MethodSymbol bridge = meth.binaryImplementation(origin, types);
MethodSymbol impl = meth.implementation(origin, types, true);
if (bridge == null ||
bridge == meth ||
(impl != null && !bridge.owner.isSubClass(impl.owner, types))) {
// No bridge was added yet.
if (impl != null && bridge != impl && isBridgeNeeded(meth, impl, origin.type)) {
addBridge(pos, meth, impl, origin, bridges);
} else if (impl == meth
&& impl.owner != origin
&& (impl.flags() & FINAL) == 0
&& (meth.flags() & (ABSTRACT|PUBLIC)) == PUBLIC
&& (origin.flags() & PUBLIC) > (impl.owner.flags() & PUBLIC)) {
// this is to work around a horrible but permanent
// reflection design error.
addBridge(pos, meth, impl, origin, bridges);
}
}
}
}This piece of code is still part of the compiler to this day and can be found in the TransTypes class, alongside the humorous comment about reflection.
What this means for us
Now that we've uncovered a new use case for bridge method, we can better anticipate when they will emerge in class files and be prepared for it. Thankfully, since Java 24, the ClassFile API allows us to easily detect such methods within the Ptidej tool suite:
public static boolean isSyntheticBridgeMethod(
final ExtendedMethodInfo extendedMethod) {
final int bitMaskSyntheticBridge = java.lang.classfile.ClassFile.ACC_BRIDGE
| java.lang.classfile.ClassFile.ACC_SYNTHETIC;
return ((extendedMethod.getVisibility()
& bitMaskSyntheticBridge) == bitMaskSyntheticBridge);
}Since the accessibility flags described in the JVMS act like a bit mask, we can simply check if they are present in our input, assuming that getVisibility() returns the sum of all flags.
What this means for Java
What is interesting is that this behaviour (adding bridges to allow for reflection) is not consistent. For instance, when writing a non-public interface that provides a public default method, the public classes that implement that interface won't have bridges added by the compiler. In that case, the same problem arises as in JDK-6342411: basic reflection cannot be used to access the inherited method (when the class using reflection is in a different package, again because reflection does not check access based on the reference type).
I raised this issue to the OpenJDK team here. It seems like this problem emerges in many similar cases. However, the project seems reluctant to fix this inconsistency. For now, the methods where bridges are absent can still be invoked using MethodHandles.lookup().findVirtual(accessibleClass, methodName, methodType) , which gives a MethodHandle object to manipulate our method.