Before Java 8, adding a method to an interface broke every class that implemented it. That is not
a theoretical problem — it is why Collection went years without obvious conveniences.
Default methods solved it, and understanding why they exist tells you when to use them.
The problem they were invented for
Java 8 wanted to add stream() to Collection and forEach to
Iterable. Both are interfaces implemented by thousands of classes inside the JDK and
millions outside it. Adding an abstract method to either would have failed to compile every one of
them.
The escape was to let an interface method carry a body. Existing implementers inherit it and
carry on compiling; anyone who wants different behaviour overrides it. That is the entire feature,
and it is why forEach works on a List you wrote in 2006.
interface Greeter {
String name(); // implementers must supply this
default String greet() { // they get this free
return "Hello, " + name();
}
}
class English implements Greeter {
public String name() { return "Folau"; } // greet() inherited as-is
}
class Tongan implements Greeter {
public String name() { return "Folau"; }
@Override
public String greet() { return "Malo e lelei, " + name(); } // overridden
}
Real examples in the JDK
Once you know the shape, you recognise it across the standard library. Every one of these is a default method added to an existing interface without breaking anything:
class Demo {
void run() {
List<String> names = new ArrayList<>(List.of("Ana", "", "Bo"));
names.forEach(System.out::println); // Iterable.forEach
names.removeIf(String::isBlank); // Collection.removeIf
names.replaceAll(String::toUpperCase); // List.replaceAll
names.sort(Comparator.naturalOrder()); // List.sort
System.out.println(names); // [ANA, BO]
Map<String, Integer> ages = new HashMap<>();
ages.putIfAbsent("Ana", 30); // Map.putIfAbsent
ages.merge("Ana", 1, Integer::sum); // Map.merge
System.out.println(ages.getOrDefault("Bo", 0)); // Map.getOrDefault
}
}
Static methods on interfaces
Java 8 also allowed static methods on an interface. These are not inherited and
cannot be overridden — they are called on the interface itself, and they exist so that helpers can
live next to the contract instead of in a separate XxxUtils class:
interface Validator {
boolean test(String input);
static Validator notBlank() { // a factory
return s -> s != null && !s.isBlank();
}
static Validator maxLength(int n) {
return s -> s.length() <= n;
}
default Validator and(Validator other) {
return s -> this.test(s) && other.test(s);
}
}
class Demo {
void run() {
Validator v = Validator.notBlank().and(Validator.maxLength(10));
System.out.println(v.test("hello")); // true
System.out.println(v.test("")); // false
}
}
The JDK does this too — Comparator.comparing, List.of,
Map.entry and Predicate.not are all static interface methods. Before Java 8
they would have been on a Collections-style utility class, one step removed from the
type they serve.
The diamond problem
Two interfaces can now supply two bodies for the same method, and a class implementing both has an ambiguity. Java refuses to guess — it will not compile until you say which you meant:
interface Swimmer {
default String move() { return "swimming"; }
}
interface Runner {
default String move() { return "running"; }
}
class Triathlete implements Swimmer, Runner {
@Override
public String move() {
// Must resolve it. `Interface.super.method()` picks one explicitly.
return Swimmer.super.move() + " then " + Runner.super.move();
}
}
Swimmer.super.move() is the syntax, and it only exists for this situation. The
resolution rules in full are: a class's own method beats any inherited default; a more specific
interface beats a less specific one; and anything still ambiguous is a compile error. In practice you
only ever meet the third case.
Private interface methods
Since Java 9, an interface can have private methods. They exist purely so two default methods can share code without exposing a helper on the public contract:
interface Report {
List<String> rows();
default String asCsv() { return String.join(",", cleaned()); }
default String asList() { return String.join("\n", cleaned()); }
private List<String> cleaned() { // not part of the contract
return rows().stream().map(String::strip).filter(s -> !s.isEmpty()).toList();
}
}
When to use a default method — and when not to
Use one to evolve an interface that already has implementers, or to provide a
genuine convenience derived from the other methods — and on Validator above
is a good example, because it is expressible entirely in terms of test.
Do not use one to smuggle in shared implementation. If several classes need the same code and the same state, that is what an abstract class is for. The tell is a default method that wishes it could read a field: an interface has no state, so any default method that "needs" one is a design that has outgrown the interface.
| abstract method | default method | static method | |
|---|---|---|---|
| Has a body | no | yes | yes |
| Inherited | must implement | yes, overridable | no |
| Called on | an instance | an instance | the interface |
| Counts toward "functional" | yes | no | no |
That last row matters if you also want lambdas: an interface stays functional no matter how many default and static methods you add, because only abstract methods count.
One caveat about compatibility
Default methods make an interface change source compatible and binary compatible — existing code compiles and existing jars keep running. They do not make it behaviourally compatible.
If a class already had its own sort method with a different meaning, adding a
default sort to the interface it implements will silently bind calls to the class's
version, or fail to compile if the signatures clash awkwardly. The JDK hit exactly this when adding
Iterable.forEach — some existing libraries had their own forEach, and the
override semantics had to be checked case by case.
The practical advice: when you add a default method to a widely implemented interface, pick a name nobody is likely to have used, and expect to find out you were wrong.
Next
The Date and Time API is next — the other large Java 8 addition, and the one that finally made dates usable.