Advanced Methods & Overloading

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

📚 Before You Start

This lesson assumes you're comfortable with:

💡 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
    }
}

2️⃣ 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());
        }
    }
}

🎯 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)
    }
}

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)
    }
}

4️⃣ 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
    }
}

5️⃣ 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)
    }
}

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
    }
}

🎯 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!
    }
}

🧩 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)

📋 Quick Reference

ConceptLooks LikeKey Idea
Overloadingadd(int), add(double)Same name, different params; chosen at compile time
Overriding@Override speak()Subclass, same signature; chosen at runtime
Varargssum(int... n)Zero or more args; must be last parameter
Generic method<T> T firstOf(T[])Type param before return type; no casting
Static methodMath.max(a, b)Belongs to the class; no object needed
Default methoddefault String greet()Interface method with a body; inherited free
Method referenceString::toUpperCaseShorthand for a lambda that calls one method
Pass-by-valuemethod(x)Java copies the value (or the reference)
Buildernew Builder().size("L").build()Fluent construction of an immutable object

🎉 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.

Practice quiz

Overloading (same name, different parameter lists) is resolved:

  • At runtime, based on the actual object
  • Never — it is illegal in Java
  • At compile time, based on the arguments
  • Only for static methods

Answer: At compile time, based on the arguments. The compiler picks the matching overload from the argument types at compile time. Overriding is the runtime one.

Overriding (a subclass replacing an inherited method with the same signature) is resolved:

  • At runtime by the actual object's type
  • At compile time by the variable type
  • At link time
  • By the order of the methods in the file

Answer: At runtime by the actual object's type. Java uses dynamic dispatch: the real object decides which overridden method runs, not the variable's declared type.

A varargs parameter (int... numbers) must be:

  • The first parameter
  • The only parameter
  • Declared final
  • The last parameter, and there can be only one

Answer: The last parameter, and there can be only one. Varargs must be the last parameter and a method may have at most one. Inside the method it is just an array.

Given overloads sum() and sum(int... n), what does sum() return?

  • null
  • 0
  • Throws an exception
  • 1

Answer: 0. Calling sum() passes an empty int[] to the varargs version; the loop adds nothing, so the total is 0.

Java's parameter passing is best described as:

  • Always pass-by-value (the value, or the reference, is copied)
  • Pass-by-reference for objects
  • Pass-by-reference for primitives
  • Pass-by-name

Answer: Always pass-by-value (the value, or the reference, is copied). Java is always pass-by-value. For objects the COPIED value is the reference, so you can mutate but not reassign.

A method does list.add("x") on a passed List, then list = new ArrayList<>(). What does the caller see afterward?

  • An empty list
  • A NullPointerException
  • The list with "x" added; the reassignment is not visible
  • The original list unchanged (no "x")

Answer: The list with "x" added; the reassignment is not visible. Mutating the shared object is visible; reassigning the local copy of the reference is not.

You CANNOT overload two methods that differ only in:

  • The number of parameters
  • Their return type alone
  • The types of parameters
  • Both number and types

Answer: Their return type alone. Return type alone is not part of the signature for overloading — the parameter lists must differ.

Where does a generic method declare its type parameter?

  • After the method name
  • Inside the parameter list only
  • Generic methods are not allowed
  • In angle brackets before the return type, e.g. static <T> T firstOf(T[] items)

Answer: In angle brackets before the return type, e.g. static <T> T firstOf(T[] items). A generic method puts <T> before the return type; the compiler infers T from the argument, no casting needed.

When is a static method the right choice over an instance method?

  • When the logic depends on a particular object's fields
  • When the result depends only on its arguments (a pure helper)
  • Always — static is faster
  • Only inside interfaces

Answer: When the result depends only on its arguments (a pure helper). Use static when behaviour does not read or change an object's state; use instance methods when it does.

A recursive method with no reachable base case will:

  • Return null
  • Loop forever silently
  • Throw StackOverflowError
  • Be optimized away by the JVM

Answer: Throw StackOverflowError. Java does not optimize tail calls, so endless recursion grows the call stack until it overflows.

Continue this course

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.

Related lessons