Article

Still there, still broken: two methods that only looked inherited

In two releases of very popular Java libraries, a method disappeared from a class but stayed available through a superclass. The source code of callers still compiles. Code that was already compiled fails. Here is why, with the error messages from real clients.

Where these come from

For our report on semantic versioning, we compared thousands of consecutive releases on Maven Central with two tools and looked by hand at every case where the tools disagreed. Most disagreements were dull. These two were not: in both, a removed method seemed harmless, because a method with the same name was still there. Both turned out to break code that had been compiled against the previous release.

Both libraries are carefully maintained by experienced people, which is the point. These are edges of the Java platform, not mistakes anyone should be embarrassed about.

Case 1: an override that took a String

In Spring Framework, AnnotationAttributes extends LinkedHashMap<String, Object>. Release 5.1.9 overrides putIfAbsent, and release 5.1.10 removes the override. The class still has putIfAbsent, inherited from HashMap, so the change looks safe.

// 5.1.9
public class AnnotationAttributes extends LinkedHashMap<String, Object> {
    @Override
    public Object putIfAbsent(String key, Object value) { ... }
}

// 5.1.10: the override is gone, HashMap's putIfAbsent remains

The catch is in the parameter type. Because of the generic superclass, the override is declared with String, and generics are erased on the JVM. The compiler therefore produces two methods in 5.1.9, as javap shows:

public java.lang.Object putIfAbsent(java.lang.String, java.lang.Object);
public java.lang.Object putIfAbsent(java.lang.Object, java.lang.Object);   // bridge, synthetic

A client compiled against 5.1.9 calls the most specific one, the (String, Object) method. That exact method no longer exists in 5.1.10. We compiled a three-line client against 5.1.9 and ran it with the 5.1.10 jar:

java.lang.NoSuchMethodError: 'java.lang.Object org.springframework.core.annotation.AnnotationAttributes.putIfAbsent(java.lang.String, java.lang.Object)'

Recompiled against 5.1.10, the same client works: the call now goes to HashMap.putIfAbsent(Object, Object). So the break only hits code that is not recompiled, such as another library that uses Spring. That is exactly the case semantic versioning is meant to protect in a patch release.

Case 2: a static method that moved up

In jackson-core, NonBlockingJsonParserBase had a protected static helper. In 2.23.0 it is gone from that class, and its superclass JsonParserBase has a method with the same name and parameters:

// 2.22.4, in NonBlockingJsonParserBase
protected static final int _padLastQuad(int, int)

// 2.23.0, in JsonParserBase
protected final int _padLastQuad(int, int) throws JsonParseException

Same name, same parameters, same return type, still visible to subclasses. But it is no longer static. A subclass compiled against 2.22.4 calls it with the instruction for static methods, and the JVM refuses:

java.lang.IncompatibleClassChangeError: Expected static method 'int com.fasterxml.jackson.core.json.JsonParserBase._padLastQuad(int, int)'

Recompiling does not fix this one on its own. A call from a static context stops compiling (non-static method _padLastQuad(int,int) cannot be referenced from a static context), and the new method declares a checked exception. The method is protected, so only subclasses outside the library are affected, and probably very few exist. It is still a break in a minor release.

Why they are easy to miss

Both cases pass the question a person, or a tool, naturally asks: can I still call a method with this name on this class? Yes, in both.

The JVM asks a stricter question. A compiled call names the exact descriptor (all parameter types and the return type, after erasure) and the kind of call (static or instance). If that precise method cannot be resolved, the call fails, whatever else the class offers.

  • Generic overrides create a second, more specific method. Removing the override removes that method, even when the bridge's target is still inherited.
  • Moving a method to a superclass is compatible only if everything else stays the same, including static.

In our replay, japicmp, which we used as the second tool, did not report either change. That is no criticism: japicmp is excellent, it found breaks of its own, and every checker draws lines somewhere. The full list of disagreements, in both directions, is in the report.

How to avoid them in your library

  • Before removing an override in a generic subclass, run javap -p on the class. If the override has narrower parameter types than the inherited method, keep it, mark it @Deprecated and let it call super.
  • When moving a method to a superclass, keep it static if it was static, or keep a static method in the old place that delegates to the new one.
  • Check against the published jar, not against your sources or tests. Both changes compile, and a library's own tests are compiled with the new code, so they cannot see them.

Where Bumpwise stands

Bumpwise, our IntelliJ IDEA plugin, flagged both: the Spring change as a removed method and the jackson-core change as a method that is no longer static. In the same replay it also raised a few false alarms, such as an override of toString() that was still inherited from Object. We fixed those for the next release. We would rather tell you both halves.

Disclosure: Gilware makes Bumpwise, a paid plugin with a free edition for Java libraries published on Maven Central.

How we checked

Spring: a client that calls putIfAbsent("k", "v") on an AnnotationAttributes, compiled against spring-core 5.1.9.RELEASE and run with 5.1.10.RELEASE, then recompiled against 5.1.10.RELEASE. Jackson: an abstract subclass of NonBlockingJsonParserBase whose main calls _padLastQuad(1, 2), compiled against jackson-core 2.22.4 and run with 2.23.0. OpenJDK 25.0.5. The messages above are copied from those runs.

Found a mistake? Write to hello@gilware.com.

Find out before your users do

Bumpwise warns you while you type when a change breaks your last release. Free for Java libraries on Maven Central.