Interfaces and default methods
An interface is a contract. Default and static methods, functional interfaces, and the diamond rule that resolves conflicts.
An interface is a contract with no implementation attached: here are the operations, here is what they mean, and any class that implements them can stand in for any other. It is the most important abstraction tool in Java because it is the one that does not couple you to a class hierarchy. Since Java 8 it can also carry code, which changed what an interface is for and introduced one rule you need to know. This lesson is the contract, the rule, how a lambda becomes an object, and the compatibility problem default methods were invented to solve.
The contract
public interface PaymentGateway {
/** Charges the card. Returns a reference on success; throws PaymentDeclined otherwise. */
ChargeRef charge(Money amount, CardToken card) throws PaymentDeclined;
void refund(ChargeRef ref, Money amount);
}Every method is implicitly public abstract; every field is implicitly public static final. A class that implements the interface must implement every abstract method. The point is the caller: code written against PaymentGateway works with StripeGateway, AdyenGateway and FakeGateway in tests, and it cannot tell the difference. That substitutability is what "program to an interface" means.
An interface earns its existence when there are, or plausibly will be, two implementations — or when one of them is a test double. An interface with one implementation, named FooService with FooServiceImpl beside it, is ceremony. Delete it; extract it on the day a second implementation appears.
Default methods
Java 8 needed to add forEach and stream() to Collection without breaking every existing implementation. The answer was default methods: methods with a body in the interface, inherited by implementers that do not override them.
public interface Repository<T, ID> {
Optional<T> findById(ID id);
default T getById(ID id) {
return findById(id).orElseThrow(() -> new NotFoundException(id));
}
}getById is defined once, in terms of the abstract findById, and every implementation gets it. Use default methods for exactly this: convenience built on the abstract methods. Do not use them to smuggle state (an interface has none) or as a replacement for an abstract class.
Under the hood: what the class file and the JVM do with an interface
An interface is a class file with the ACC_INTERFACE flag, no instance fields, and, since Java 8, methods that may have Code attributes: a default method is an ordinary non-abstract method in that file, and Loggable.super.tag() compiles to invokespecial against the interface, exactly like a super call. A call through an interface type compiles to invokeinterface, which cannot use a vtable slot (unrelated classes implement the same interface at different table positions), so the JVM searches the receiver class's itable, a small list of (interface, method table) pairs. In the interpreter that is slower than a virtual call; the JIT erases the difference at monomorphic and bimorphic call sites by emitting a class check and a direct call.
Binary compatibility is the whole reason default methods exist. Adding an abstract method to an interface does not fail at compile time for an implementer that was compiled before the change; it fails at run time, with AbstractMethodError, the first time anyone calls the new method on the old implementation. A default method fills the slot for every old class file, so the old jar keeps working. That is why Collection.stream() could be added to an interface with ten thousand implementations in the wild.
A lambda is not an anonymous class. RetryPolicy p = (a, f) -> a < 3; compiles to an invokedynamic instruction whose bootstrap, LambdaMetafactory, spins a small hidden class implementing RetryPolicy at first execution and links the call site to its constructor (or to a cached singleton, when the lambda captures nothing). No class file on disk, no $1.class, and the body itself is a private static method on the enclosing class that the hidden class calls. That is why lambdas start slower the first time and run as fast as a direct call after, why this inside a lambda is the enclosing instance, and why a non-capturing lambda allocates nothing.
Static and private methods
Interfaces can have static methods — factories, usually: Comparator.comparing, List.of, Path.of. Since Java 9 they can have private methods, for sharing code between default methods without exposing it.
The diamond rule
A class can implement many interfaces. If two of them define a default method with the same signature, the class must resolve it:
interface Loggable { default String tag() { return "log"; } }
interface Auditable { default String tag() { return "audit"; } }
class Event implements Loggable, Auditable {
@Override public String tag() { return Loggable.super.tag() + "+" + Auditable.super.tag(); }
}Without the override, it does not compile. The rules (JLS §9.4.1), in order: a method from a class (including an inherited one from a superclass) wins over any interface default; a more specific interface (a sub-interface) wins over its parent; otherwise the class must choose explicitly with Interface.super.method(). Because the compiler forces the choice, the diamond is not the problem here that it is in C++. The first rule has a sharp corner: a superclass method with the same signature silently overrides an interface's default, even if the superclass never heard of the interface.
Walkthrough: the method that broke every plugin
A platform team owns interface Plugin { void start(); void stop(); }, implemented by forty plugins in other repositories. They need a health check.
- First attempt. Add
boolean healthy();to the interface, release 2.0. Every plugin still compiles against 1.x and still loads. The platform callsplugin.healthy()on startup:AbstractMethodError: Receiver class com.acme.CsvPlugin does not define or inherit an implementation of the resolved method 'abstract boolean healthy()'. Forty plugins, one crash each. Nothing was caught before production because the plugins are not in the platform's build. - Second attempt.
default boolean healthy() { return true; }. Every old plugin inherits the default; new plugins override it. No plugin author has to do anything today, and the ones that want a real check do it on their own schedule. - The corner case, a month later. One plugin extends a shared
BasePluginclass that already had apublic boolean healthy()from an unrelated monitoring hook that returns whether the plugin's thread is alive. The class-wins rule means that method now serves as the interface'shealthy(), with a different meaning, and no compiler mentioned it. The fix is a rename, and the lesson is that adding a default method into an ecosystem you do not control can collide with names you cannot see.
Functional interfaces
An interface with exactly one abstract method is a functional interface, and a lambda can implement it:
@FunctionalInterface
interface RetryPolicy {
boolean shouldRetry(int attempt, Throwable failure);
}
RetryPolicy threeTimes = (attempt, failure) -> attempt < 3;
RetryPolicy never = (attempt, failure) -> false;@FunctionalInterface is optional and worth writing: it makes the compiler reject a second abstract method. Default methods do not count, which is why Comparator has one abstract method and a dozen defaults (reversed, thenComparing) and is still a lambda target. The standard library ships Function, Supplier, Consumer, Predicate, BiFunction and the rest in java.util.function — prefer those unless a domain name (RetryPolicy) communicates something.
Marker and constant interfaces
Serializable and Cloneable have no methods; they mark a type for the runtime. That was the pre-annotation way; an annotation is the modern one. An interface that exists only to hold constants (interface Limits { int MAX = 100; }) is an anti-pattern — use a final class or an enum. Implementing an interface to inherit its constants leaks them into your public API.
Interfaces versus abstract classes, decided
| Need | Use |
|---|---|
| A type several unrelated classes can be | Interface |
| A lambda target | Interface (functional) |
| Shared fields or constructor logic | Abstract class |
| A template method with protected hooks and state | Abstract class |
| Both | Interface for the type; abstract class as one skeletal implementation (AbstractList pattern) |
Try it yourself
Which tag() runs?
interface Named { default String tag() { return "named"; } }
interface Labelled extends Named { default String tag() { return "labelled"; } }
class Base { public String tag() { return "base"; } }
class A implements Named, Labelled {}
class B extends Base implements Labelled {}What do new A().tag() and new B().tag() return, and does either fail to compile?
Answer
Both compile. A returns "labelled": Labelled is more specific than Named, so its default wins without an explicit choice. B returns "base": a class method beats any interface default, even though Base has no relation to Labelled. If Base were removed, B would return "labelled". The class-wins rule is the one that surprises people during refactors.
Why did it work in staging?
A library adds void flush() to its Sink interface, without a default, in a minor release. Your service compiles cleanly against the new version and passes its tests. In production, a partner's jar that implements Sink and was built against the old version throws AbstractMethodError on the first flush(). Why did nothing catch it?
Answer
Your code compiled against the new interface and only your own implementations, which you updated. The partner's class file has no flush method and was never recompiled; the JVM resolves invokeinterface Sink.flush at run time, finds nothing in that class's itable, and throws. A default void flush() {} in the library would have made the old class file valid. Interface changes are a binary-compatibility concern for every implementer you cannot rebuild.
How many objects?
Runnable r1 = () -> System.out.println("hi");
int n = 3;
Runnable r2 = () -> System.out.println(n);
Runnable r3 = new Runnable() { public void run() { System.out.println("hi"); } };Roughly what does each line allocate, and how many class files exist on disk for them?
Answer
r1 captures nothing: LambdaMetafactory returns a cached singleton after the first call, so repeated evaluation allocates zero. r2 captures n: one small object per evaluation, holding the captured value. r3 is an anonymous inner class: one object per evaluation, and it captures the enclosing this if inside an instance method whether it uses it or not. On disk: one .class file for r3 (Outer$1.class), none for the lambdas, which are hidden classes spun at run time.
Misconceptions
- "Default methods are multiple inheritance." Of behaviour, without state, with a resolution rule the compiler enforces. The problems of C++ diamonds do not arise; a silent override by a superclass method does.
- "Adding a method to an interface breaks compilation for implementers." It breaks linking, at run time, in whoever did not recompile. Compilation catches only the code in your own build.
- "A lambda is syntactic sugar for an anonymous class." It is an
invokedynamiccall site linked to a hidden class, with no class file, a cached instance when non-capturing, and no capturedthisunless used. - "
invokeinterfaceis slow, so prefer abstract classes." Only in the interpreter. The JIT emits the same guarded direct call at a monomorphic site for either. - "An interface with one implementation is good practice." It is an indirection with no second target. Extract it when the test double or the second implementation arrives.
Going deeper
- JLS §9.4.1 (inheritance and overriding in interfaces), for the exact resolution order.
- JLS §13.5.3 and §13.5.7: which interface changes are binary compatible, and which produce
AbstractMethodError. - JEP 126 (default methods) and Brian Goetz, "Interface evolution via virtual extension methods", the design rationale.
java.lang.invoke.LambdaMetafactoryJavadoc, and Brian Goetz, "Translation of Lambda Expressions".javap -con a class with a lambda and an anonymous class side by side:invokedynamicversusnew.