Lesson 15 • Advanced
Advanced Methods & Overloading
Go beyond the basics: by the end you'll know exactly when Java chooses one method over another, why your method sometimes can't change a value, and how to design clean, flexible method signatures.
What You'll Learn in This Lesson
- ✓Tell overloading apart from overriding — and predict which runs
- ✓Write varargs methods that accept any number of arguments
- ✓Create generic methods that work for any type without casting
- ✓Decide between static and instance methods with confidence
- ✓Use default and private interface methods, plus method references
- ✓Explain Java's pass-by-value rule and build immutable classes
📚 Before You Start
This lesson assumes you're comfortable with:
- Methods — parameters, return types, and calling methods.
- Generics — type parameters like
<T>. - Interfaces — contracts a class promises to fulfil.
💡 A Real-World Analogy
Picture a coffee machine with one big "Brew" button. Press it with no cup setting and it makes a default coffee; press it after choosing "espresso" or "latte" and it does something different. The button's name never changes — what changes is the inputs. That's overloading: one method name, many parameter lists.
Now imagine you buy a fancier machine from the same brand. The "Brew" button is in the same place and you press it the same way, but the newer machine replaces the old behaviour with a better one. That's overriding: same button, same signature, but a subclass swaps in new behaviour. Keep this picture in mind — most of this lesson is just these two ideas plus the rules around them.
1️⃣ Overloading & Varargs — One Name, Many Shapes
Overloading means you write several methods with the same name but different parameter lists (a different number of parameters, or different types). The compiler reads your arguments and quietly picks the best match — it decides at compile time, before the program ever runs.
The matching happens in three steps: (1) look for an exact type match; (2) if none, try widening (e.g. int → long); (3) if still none, try autoboxing (int → Integer) and varargs last. Return type alone is not enough to tell two overloads apart.
Varargs (Type... name) let a method accept zero or more arguments. Inside the method, name is simply an array. The rule: varargs must be the last parameter, and a method can have only one.
Read every comment in the worked example below — the result of each line is stated inline.
public class Main {
// OVERLOADING: same name, different parameter lists (compile-time choice)
static int area(int side) { // 1 arg -> square
return side * side;
}
static int area(int w, int h) { // 2 args -> rectangle
return w * h;
}
static double area(double radius) { // double -> circle
return Math.PI * radius * radius;
}
// VARARGS: zero or more ints. Must be the LAST parameter.
static int sum(int... numbers) { // "numbers" is really an int[]
int total = 0;
for (int n : numbers) total += n; // loop over however many were passed
return total;
}
public static void main(String[] args) {
System.out.println("square(5) = " + area(5)); // 25 -> int side
System.out.println("rectangle(4,3) = " + area(4, 3)); // 12 -> int w,h
System.out.printf("circle(2.0) = %.2f%n", area(2.0)); // 12.57 -> double
System.out.println("sum() = " + sum()); // 0 args -> 0
System.out.println("sum(1,2,3) = " + sum(1, 2, 3)); // 3 args -> 6
System.out.println("sum(10,20,30,40) = " + sum(10, 20, 30, 40)); // 100
}
}square(5) = 25
rectangle(4,3) = 12
circle(2.0) = 12.57
sum() = 0
sum(1,2,3) = 6
sum(10,20,30,40) = 1002️⃣ Overriding — Same Signature, Chosen at Runtime
Overriding is different. A subclass provides its own version of a method it inherited, using the exact same signature. Java decides which version to run by looking at the actual object at runtime, not the type of the variable holding it. This is what makes polymorphism work.
Always add the @Override annotation. It's not required, but it makes the compiler check that you really are overriding something — if you typo the name or parameters, you get an error instead of an accidental new method.
In the example, the loop variable is typed Animal, yet each object reports its own sound. That's the heart of the overloading-vs-overriding distinction: overloading is resolved by arguments at compile time; overriding is resolved by the object at runtime.
public class Main {
// A base class with a method subclasses can replace.
static class Animal {
String speak() { return "..."; } // default behaviour
}
static class Dog extends Animal {
@Override // OVERRIDING: same signature
String speak() { return "Woof"; } // replaces Animal.speak()
}
static class Cat extends Animal {
@Override
String speak() { return "Meow"; }
}
public static void main(String[] args) {
// The variable type is Animal, but the OBJECT decides which speak() runs.
Animal[] zoo = { new Dog(), new Cat(), new Animal() };
for (Animal a : zoo) {
// Runtime dispatch: Java looks at the real object, not the variable type.
System.out.println(a.getClass().getSimpleName() + " says " + a.speak());
}
}
}Dog says Woof
Cat says Meow
Animal says ...🎯 Your Turn #1 — Overload with Varargs
Fill in the two blanks so join works both with exactly two strings and with any number of strings. The expected output is shown in the comments — run it to check yourself.
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Overload "join" so this version handles TWO strings.
static String join(String a, String b) {
return a + " " + b;
}
// 2) Add a VARARGS overload that joins ANY number of strings with a space.
// 👉 the parameter type is "String..." and it must be the last parameter
static String join(String... parts) {
return String.join(" ", ___); // 👉 pass the varargs array here
}
public static void main(String[] args) {
System.out.println(join("Hello", "World")); // two-arg version
System.out.println(join("a", "b", "c", "d")); // varargs version
System.out.println(join()); // varargs, zero args
// ✅ Expected output:
// Hello World
// a b c d
// (an empty line)
}
}Hello World
a b c d
3️⃣ Generic Methods & Static vs Instance
A generic method declares its own type parameter in angle brackets before the return type: static <T> T firstOf(T[] items). The T is a placeholder that the compiler fills in from the argument you pass, so the same method works for String[], Integer[], or anything else — with no casting and full type safety.
A static method belongs to the class, so you call it on the class name and it can't touch any object's fields. An instance method belongs to a particular object created with new, so it can read and change that object's state. A handy rule: if the method's result depends only on its arguments, make it static; if it needs the object's data, make it an instance method.
The Counter in the example shows the difference between a static field (shared by every object) and an instance field (one copy per object).
public class Main {
// GENERIC METHOD: <T> before the return type declares a type parameter.
// Works for any type, no casting, no Object.
static <T> T firstOf(T[] items) {
return items[0]; // returns the same type T it received
}
// STATIC method: belongs to the class. No object needed to call it.
static int square(int n) { return n * n; }
// Tiny counter to contrast INSTANCE state with STATIC (shared) state.
static class Counter {
static int created = 0; // STATIC: shared by ALL counters
int id; // INSTANCE: one per object
Counter() { id = ++created; } // each new object bumps the shared count
}
public static void main(String[] args) {
String[] names = { "Ana", "Ben", "Cy" };
Integer[] nums = { 7, 8, 9 };
System.out.println("firstOf(names) = " + firstOf(names)); // Ana
System.out.println("firstOf(nums) = " + firstOf(nums)); // 7
// Static method: call on the class, no object.
System.out.println("square(6) = " + Main.square(6)); // 36
Counter a = new Counter();
Counter b = new Counter();
Counter c = new Counter();
System.out.println("ids: " + a.id + ", " + b.id + ", " + c.id); // 1, 2, 3
System.out.println("Counter.created = " + Counter.created); // 3 (shared)
}
}firstOf(names) = Ana
firstOf(nums) = 7
square(6) = 36
ids: 1, 2, 3
Counter.created = 34️⃣ Default/Private Interface Methods & Method References
Interfaces used to hold only abstract methods. Since Java 8, an interface can include a default method — a method with a body that every implementing class inherits for free. This lets you add new behaviour to an interface without breaking the classes that already implement it. Java 9 added private interface methods: helpers that a default method can call but that stay hidden from implementers and callers.
A method reference (the :: operator) is a compact way to write a lambda that just calls an existing method. String::toUpperCase means s -> s.toUpperCase(), and System.out::println means x -> System.out.println(x). Use a method reference whenever a lambda would only forward its argument to one method — it reads more cleanly.
import java.util.List;
import java.util.function.Function;
public class Main {
interface Greeter {
String name(); // abstract: each class supplies it
// DEFAULT method: a body lives in the interface. Implementers inherit it
// for free but may override it.
default String greet() {
return banner() + "Hello, " + name() + "!";
}
// PRIVATE interface method: a helper for the default method only.
// Not visible to implementers or callers.
private String banner() {
return ">> ";
}
}
// This class only writes name() — greet() comes from the interface.
static class English implements Greeter {
public String name() { return "Sam"; }
}
public static void main(String[] args) {
System.out.println(new English().greet()); // >> Hello, Sam!
// METHOD REFERENCE: String::toUpperCase is shorthand for s -> s.toUpperCase()
Function<String, String> shout = String::toUpperCase;
System.out.println(shout.apply("hi")); // HI
// Static method reference passed straight to forEach.
List.of("Ana", "Ben").forEach(System.out::println); // prints each name
}
}>> Hello, Sam!
HI
Ana
Ben5️⃣ Pass-by-Value — Why Your Method Can't Change That Number
This trips up almost everyone. Java is always pass-by-value. When you call a method, Java copies each argument into the method's parameter.
For a primitive (int, double, boolean…), the value is copied. The method works on its own copy, so changes never reach the caller's variable. For an object, the value that's copied is the reference — the address of the object. Both the caller and the method now point at the same object, so if the method mutates that object (adds to a list, changes a field), the caller sees it. But if the method reassigns its parameter to a brand-new object, only the local copy changes — the caller is unaffected.
One sentence to remember: Java copies the reference, not the object — so you can change what the object contains, but not which object the caller's variable points to.
import java.util.ArrayList;
import java.util.List;
public class Main {
// Java is ALWAYS pass-by-value. For a primitive, the VALUE is copied.
static void tryToChange(int x) {
x = 999; // changes only this local copy
}
// For an object, the REFERENCE (an address) is copied — both point at the
// same object, so mutating it is visible to the caller.
static void addItem(List<String> list) {
list.add("added inside"); // mutates the SHARED object
}
// Reassigning the copied reference does NOT affect the caller's variable.
static void reassign(List<String> list) {
list = new ArrayList<>(); // local copy now points elsewhere
list.add("ignored"); // caller never sees this
}
public static void main(String[] args) {
int n = 5;
tryToChange(n);
System.out.println("primitive n = " + n); // 5 — unchanged
List<String> items = new ArrayList<>();
addItem(items);
System.out.println("after addItem = " + items); // [added inside]
reassign(items);
System.out.println("after reassign = " + items); // [added inside] (same)
}
}primitive n = 5
after addItem = [added inside]
after reassign = [added inside]6️⃣ Immutability & the Builder Pattern
An immutable object can never change after it's created. You make a class immutable by marking every field final, setting them once in the constructor, and providing no setters. Immutable objects are easy to reason about and safe to share between threads — nobody can secretly change them under you.
But a constructor with many parameters is hard to read (new Pizza("large", true, false, true)— which boolean is which?). The builder pattern fixes this: a small helper class with one method per option, each returning this so calls chain fluently, ending in build(). The result reads like a sentence and produces a fully-formed immutable object.
public class Main {
// IMMUTABLE class: all fields final, set once in the constructor, no setters.
// Once built, a Pizza can never change — safe to share freely.
static final class Pizza {
private final String size;
private final boolean cheese;
private final boolean pepperoni;
private Pizza(Builder b) { // only the Builder can construct one
this.size = b.size;
this.cheese = b.cheese;
this.pepperoni = b.pepperoni;
}
@Override public String toString() {
return size + " pizza" +
(cheese ? " +cheese" : "") +
(pepperoni ? " +pepperoni" : "");
}
// BUILDER: each method returns "this" so calls chain fluently.
static class Builder {
private String size = "medium";
private boolean cheese = false;
private boolean pepperoni = false;
Builder size(String s) { this.size = s; return this; }
Builder cheese() { this.cheese = true; return this; }
Builder pepperoni() { this.pepperoni = true; return this; }
Pizza build() { return new Pizza(this); }
}
}
public static void main(String[] args) {
Pizza order = new Pizza.Builder()
.size("large")
.cheese()
.pepperoni()
.build();
System.out.println(order); // large pizza +cheese +pepperoni
Pizza plain = new Pizza.Builder().build();
System.out.println(plain); // medium pizza
}
}large pizza +cheese +pepperoni
medium pizza🎯 Your Turn #2 — Recursive Countdown
Fill in the two blanks so the method counts down and then prints Liftoff!. Every recursive method needs a base case (when to stop) and a step that moves toward it.
public class Main {
// 🎯 YOUR TURN — finish the recursive method
// Count down from n to 1, then print "Liftoff!".
static void countdown(int n) {
if (n ___ 0) { // 👉 base case: stop when n reaches 0 (use <=)
System.out.println("Liftoff!");
return;
}
System.out.println(n);
countdown(n ___ 1); // 👉 recurse toward the base case (subtract 1)
}
public static void main(String[] args) {
countdown(3);
// ✅ Expected output:
// 3
// 2
// 1
// Liftoff!
}
}3
2
1
Liftoff!🧩 Mini-Challenge — Average of Any Scores
No blanks this time — just a comment outline. Write a varargs method average from scratch, handle the empty case so you never divide by zero, and print the two results. The expected output is in the comments.
public class Main {
// 🎯 MINI-CHALLENGE: average of any number of scores
// 1. Write a static method double average(int... scores)
// - if no scores were passed, return 0.0 (avoid divide-by-zero!)
// - otherwise add them all and divide by scores.length
// 2. In main, print average(80, 90, 100) and average()
//
// ✅ Expected output:
// 90.0
// 0.0
// your code here
}Common Errors (and How to Fix Them)
- ❌ Ambiguous overload.
error: reference to add is ambiguous— happens when two overloads match equally well, e.g. callingadd(1, 2)when bothadd(int, long)andadd(long, int)exist. Fix: remove one overload, or cast an argument to force a single match, e.g.add(1L, 2). - ❌ Only the return type differs.
error: method add(int,int) is already defined— you can't overload by return type alone. Fix: make the parameter lists actually different, or give the methods different names. - ❌ Varargs + generics warning.
warning: [unchecked] Possible heap pollution from parameterized vararg type— a method likestatic <T> List<T> of(T... items)can't create the array safely. Fix: if the method only reads the array, annotate it@SafeVarargs(on astatic,final, orprivatemethod); otherwise accept aList<T>instead. - ❌ Thinking Java is pass-by-reference. Reassigning a parameter inside a method (
list = new ArrayList<>();) and expecting the caller to see it. Fix: remember Java copies the reference — mutate the existing object, or return the new object and let the caller reassign. - ❌ Recursion with no reachable base case.
Exception in thread "main" java.lang.StackOverflowError— each call adds a stack frame and you never stop. Fix: add a base case that's actually reached, ensure each call moves toward it, and for very deep cases switch to a loop.
📋 Quick Reference
| Concept | Looks Like | Key Idea |
|---|---|---|
| Overloading | add(int), add(double) | Same name, different params; chosen at compile time |
| Overriding | @Override speak() | Subclass, same signature; chosen at runtime |
| Varargs | sum(int... n) | Zero or more args; must be last parameter |
| Generic method | <T> T firstOf(T[]) | Type param before return type; no casting |
| Static method | Math.max(a, b) | Belongs to the class; no object needed |
| Default method | default String greet() | Interface method with a body; inherited free |
| Method reference | String::toUpperCase | Shorthand for a lambda that calls one method |
| Pass-by-value | method(x) | Java copies the value (or the reference) |
| Builder | new Builder().size("L").build() | Fluent construction of an immutable object |
Frequently Asked Questions
What is the difference between overloading and overriding in Java?
Overloading means several methods share one name but have different parameter lists; the compiler picks the right one at compile time based on the arguments. Overriding means a subclass replaces an inherited method using the exact same signature; Java picks the right one at runtime based on the actual object. A quick test: different parameters in the same class is overloading; same signature in a subclass is overriding.
Is Java pass-by-reference or pass-by-value?
Java is always pass-by-value. For primitives, the value itself is copied, so the method cannot change the caller's variable. For objects, the value that gets copied is the reference (an address), so the method can mutate the shared object, but reassigning the parameter to a new object does not affect the caller. Java never passes the variable itself, which is what true pass-by-reference would do.
Why does varargs cause an 'unchecked generic array creation' warning?
Java implements varargs by creating an array, but it cannot create an array of a generic type safely, so a method like void of(T... items) produces an unchecked warning at the call site. If you have verified the method only reads the array and never stores anything unsafe into it, you can suppress it with @SafeVarargs on a static, final, or private method. Otherwise, accept a List<T> instead of varargs.
When should I use a static method instead of an instance method?
Use a static method when the logic does not depend on any object's state, such as a pure helper like Math.max or a utility that only works with its arguments. Use an instance method when the behaviour reads or changes the fields of a particular object. A static method belongs to the class and is called on the class name; an instance method needs an object created with new.
How do I avoid a StackOverflowError with recursion?
Make sure every recursive method has a base case that is actually reachable and that each call moves closer to it, so the recursion terminates. Java does not optimise tail calls, so very deep recursion still grows the call stack and can overflow even with a correct base case. For large inputs, rewrite the recursion as a loop or use an explicit Deque as your own stack.
🎉 Lesson Complete!
Great work! You can now tell overloading from overriding, write varargs and generic methods, choose between static and instance methods, lean on default and private interface methods plus method references, explain Java's pass-by-value rule, and build clean immutable objects with a builder.
Next up: OOP Design Patterns — apply these method skills to Singleton, Factory, Builder, and Decorator patterns.
Sign up for free to track which lessons you've completed and get learning reminders.