Methods & Functions

How Java methods work, and how to design them well

← Back to Index

What is a Method?

A method is a named, reusable block of code that performs a specific task. You define it once and call it as many times as needed β€” from anywhere that has access to it. Without methods, every program would be a single unbroken sequence of instructions: impossible to read, impossible to test, impossible to maintain.

In Java, methods always live inside a class β€” there are no standalone functions like in Python or JavaScript. Every method has a clear contract: what it receives (parameters), what it does (body), and what it gives back (return type). The compiler enforces that contract at every call site.

// Without methods: repeated logic, no structure
            double total1 = 99.99 * 1.21;  // apply 21% VAT
            double total2 = 49.50 * 1.21;  // same logic repeated
            double total3 = 15.00 * 1.21;  // change the rate? update 3 places. miss one? bug.
            
            // With a method: defined once, called everywhere, change in one place
            public double applyVat(double price) {
                return price * 1.21;
            }
            
            double total1 = applyVat(99.99);
            double total2 = applyVat(49.50);
            double total3 = applyVat(15.00);

Methods are also the unit of testing. You can write a test that calls applyVat(100.0) and verifies the result is 121.0 β€” independently of the rest of the program. That's only possible because the logic is isolated in a method with a clear input and output.

Methods vs functions

In Java, the terms are often used interchangeably, but technically: a function is a standalone block of code. A method is a function attached to a class or object. Since Java 8, lambdas let you treat methods as values and pass them around β€” but they still live inside a class. Every method in Java is either an instance method (belongs to an object) or a static method (belongs to the class itself).

Method Anatomy

In Java, methods live inside classes β€” no standalone functions. Every method has a signature (name + parameter types), a return type, and a body. The signature is what uniquely identifies a method for overloading and overriding.

/*
 *  access   modifiers    return    name          parameters            throws
 *    β”‚           β”‚         β”‚         β”‚                β”‚                   β”‚
 *    β–Ό           β–Ό         β–Ό         β–Ό                β–Ό                   β–Ό
 */
public static String formatCurrency(double amount, String symbol)
        throws IllegalArgumentException {
    if (amount < 0) throw new IllegalArgumentException("Negative amount");
    return String.format("%s%.2f", symbol, amount);
}

Access modifiers

Modifier Same class Same package Subclass Everywhere
public βœ… βœ… βœ… βœ…
protected βœ… βœ… βœ… ❌
(package-private) βœ… βœ… ❌ ❌
private βœ… ❌ ❌ ❌
Default access rule in practice

Start private. Widen only when something external actually needs it. Most helper/internal methods should be private. This is not just style β€” it reduces the public surface of your API and makes refactoring safer.

How Method Calls Work: The Call Stack

Every method call pushes a new stack frame onto the thread's call stack. The frame holds local variables, parameters, and the return address. When the method returns, the frame is popped and memory is reclaimed immediately.

/*
 *  CALL STACK β€” calculateTotal(100.0, 5) example
 *  ================================================
 *
 *  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 *  β”‚  calculateTotal(double price, int quantity)        β”‚  ← active frame
 *  β”‚    price    = 100.0  (argument copied)             β”‚
 *  β”‚    quantity = 5      (argument copied)             β”‚
 *  β”‚    subtotal = 500.0  (local variable)              β”‚
 *  β”‚    β†’ return 500.0                                  β”‚
 *  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
 *  β”‚  main()                                            β”‚  ← waiting
 *  β”‚    double result = calculateTotal(100.0, 5);       β”‚
 *  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 *
 *  After return: calculateTotal frame is gone.
 *  result = 500.0 in main's frame.
 *
 *  StackOverflowError = stack ran out of space
 *  (infinite recursion is the usual cause)
 */

Pass by value β€” always

Java is always pass-by-value. What gets copied depends on the type:

// PRIMITIVE: copy of the value β€” caller is unaffected
public void tryToChange(int x) {
    x = 999;  // local copy only
}
int n = 10;
tryToChange(n);
System.out.println(n);  // Still 10

// OBJECT: copy of the REFERENCE β€” mutations to the object ARE visible
public void addItem(List<String> list) {
    list.add("new");         // ← modifies the shared object βœ…
    list = new ArrayList<>(); // ← only changes local reference ❌ caller unaffected
}
List<String> items = new ArrayList<>();
addItem(items);
System.out.println(items);  // [new] β€” mutation visible, reassignment not

Parameters: varargs and final

Varargs β€” variable number of arguments

// int... is treated as int[] internally
public int sum(int... numbers) {
    int total = 0;
    for (int n : numbers) total += n;
    return total;
}

sum();                  // 0  β€” zero args allowed
sum(1, 2, 3);           // 6
sum(new int[]{10, 20}); // 30 β€” can pass array directly

// Varargs MUST be last β€” mixing with other params
public String format(String template, Object... args) {
    return String.format(template, args);
}
Varargs rules
  • One varargs parameter per method maximum
  • Must be the last parameter in the list
  • Avoid overloading methods where one uses varargs β€” resolution can be ambiguous

Effectively final parameters

// Parameters used in lambdas or anonymous classes must be effectively final
public void schedule(String message, int delayMs) {
    // message and delayMs cannot be reassigned after this point
    Executors.newSingleThreadScheduledExecutor()
        .schedule(() -> System.out.println(message), delayMs, TimeUnit.MILLISECONDS);
}

// ❌ This breaks it:
public void broken(String message) {
    message = message.trim();  // reassignment β€” now NOT effectively final
    Runnable r = () -> System.out.println(message);  // ERROR
}

// βœ… Fix: use a new local variable
public void fixed(String message) {
    final var trimmed = message.trim();
    Runnable r = () -> System.out.println(trimmed);
}

Method Overloading

Same name, different parameter list. The compiler resolves which overload to call at compile time based on argument types β€” this is static dispatch, not polymorphism.

// Compiler picks the MOST SPECIFIC match
public void process(Object obj)  { System.out.println("Object"); }
public void process(String str)  { System.out.println("String"); }
public void process(int n)        { System.out.println("int");    }
public void process(long n)       { System.out.println("long");   }

process("hello");              // "String"  β€” String more specific than Object
process(10);                   // "int"     β€” exact match
process(10L);                  // "long"    β€” exact match
short s = 5;
process(s);                    // "int"     β€” short widens to int
process(Integer.valueOf(10));   // "Object"  β€” Integer autoboxed, then β†’ Object
What can't differentiate overloads

Return type alone, access modifier, parameter names, and throws clause do NOT count. int foo() and String foo() cannot coexist β€” the compiler can't distinguish them at the call site when the return value is ignored.

Static vs Instance Methods

public class Counter {
    private int count = 0;               // instance state
    private static int totalCreated = 0;  // class-level state

    public Counter() { totalCreated++; }

    // INSTANCE β€” reads/writes this object's state
    public void increment()  { count++; }
    public int  getCount()   { return count; }

    // STATIC β€” no access to instance state, no 'this'
    public static int  getTotalCreated() { return totalCreated; }
    public static int  add(int a, int b) { return a + b; }
}

// Usage
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.increment(); c1.increment();

c1.getCount();             // 2  β€” this counter only
c2.getCount();             // 0  β€” different object
Counter.getTotalCreated(); // 2  β€” shared across all instances
Use static when… Use instance when…
No object state needed Method reads/writes object fields
Utility / helper (Math, Collections) Behaviour varies per object
Factory method Method needs polymorphism / overriding
Entry point (main) Implements an interface method

Method Design Best Practices

// ❌ BAD: one method doing too many things
public void processUserData(String data) {
    // validates, parses, saves, emails, logs, updates stats...
}

// βœ… GOOD: Single Responsibility β€” compose focused methods
public void registerUser(String data) {
    if (!validateUserData(data)) return;
    User user = parseUserData(data);
    saveUser(user);
    notifyUser(user);
}
private boolean validateUserData(String data) { ... }
private User    parseUserData(String data)    { ... }
private void    saveUser(User user)            { ... }
private void    notifyUser(User user)          { ... }
// ❌ BAD: too many parameters (hard to read, hard to test)
public Order createOrder(String product, int qty, double price,
        String customer, String address, boolean express) { ... }

// βœ… GOOD: group into an object (record in Java 16+)
record OrderRequest(String product, int qty, double price,
                    String customer, String address, boolean express) {}

public Order createOrder(OrderRequest req) { ... }

// βœ… GOOD: or Builder pattern for optional parameters
Order order = Order.builder()
    .product("Widget").quantity(5).customer(customer).express(true)
    .build();
Rules of thumb
  • Under 20 lines β€” if it's longer, it's probably doing too much
  • Max 3-4 parameters β€” above that, use a parameter object
  • Return early β€” validate preconditions at the top, return/throw fast
  • Avoid null returns β€” return empty collections, Optional, or throw
  • Name as verb + noun β€” findUserById, not get
  • Booleans: ask a question β€” isValid(), hasPermission()

Senior Topics: Method Design Patterns

Return Optional instead of null (Java 8+)

// ❌ Forces callers to remember null checks β€” NPE waiting to happen
public User findById(long id) {
    return db.find(id);  // null if not found
}

// βœ… Contract is explicit: caller knows this may be absent
public Optional<User> findById(long id) {
    return Optional.ofNullable(db.find(id));
}

// Caller is forced to handle the absence
findById(42L)
    .map(User::getEmail)
    .ifPresent(emailService::send);

Method references β€” methods as values (Java 8+)

// Lambdas calling a single method can be replaced with method references
List<String> names = List.of("Alice", "Bob", "Charlie");

// Lambda
names.forEach(name -> System.out.println(name));
// Method reference β€” cleaner when intent is obvious
names.forEach(System.out::println);

// Static method reference
names.stream().map(String::toUpperCase).toList();

// Instance method reference on a specific object
names.stream().filter(emailValidator::isValid).toList();

// Constructor reference
names.stream().map(User::new).toList();

Default methods in interfaces (Java 8+)

// Default methods add behaviour to interfaces without breaking existing implementations
public interface Notifiable {
    void sendNotification(String message);  // abstract β€” must implement

    default void sendUrgent(String message) {  // default β€” can override
        sendNotification("[URGENT] " + message);
    }
}

// Common real-world use: Comparator.comparing()
List<User> sorted = users.stream()
    .sorted(Comparator.comparing(User::lastName)
                       .thenComparing(User::firstName))
    .toList();

Covariant return types

// Overriding method can return a MORE SPECIFIC type (Java 5+)
public class Animal {
    public Animal create() { return new Animal(); }
}

public class Dog extends Animal {
    @Override
    public Dog create() { return new Dog(); }  // βœ… Dog is a subtype of Animal
}
// This is used extensively in the Builder pattern to return 'this' as the concrete type

Interview Questions

πŸŽ“ Junior level

Q: What is the difference between method overloading and overriding?
Overloading: same name, different parameter list, resolved at compile time (static dispatch). Overriding: subclass redefines a superclass method with the same signature, resolved at runtime (dynamic dispatch via vtable). Overloading has nothing to do with inheritance.

Q: Can you overload a method by changing only the return type?
No. Return type alone doesn't differentiate overloads β€” the compiler can't tell which version to call when the return value is ignored: foo();.

Q: Can a method return multiple values?
Not directly. Options: return an array or collection; return a custom class; use a record (Java 16+): record Result(int code, String message) {}. Records are the cleanest solution for multiple return values.

Q: What does void mean?
The method produces no return value. You cannot assign a void method call to a variable. void is a type keyword, not an object β€” unlike Void (capital V), which is a wrapper class used in generics like Callable<Void>.

πŸ”₯ Senior level

Q: How does method dispatch work for overridden vs overloaded methods?
Overloaded methods use static dispatch β€” the compiler picks the overload based on the declared (static) type of the argument at compile time. Overridden methods use dynamic dispatch β€” the JVM resolves the actual method at runtime based on the object's runtime type. This is the core of polymorphism. Consequence: if you pass a Dog declared as Animal to an overloaded method, the Animal overload is picked, not Dog.

Q: When would you use a static factory method instead of a constructor?
Static factories have named semantics (Optional.of() vs Optional.empty()), can return subtypes or cached instances, and can return null or Optional. Constructors always create new instances and their names are fixed. Effective Java (Bloch) recommends static factories as the default for public APIs β€” they give you more flexibility to change the implementation without breaking the interface.

Q: What is the difference between default methods and abstract methods in interfaces?
Abstract methods define a contract β€” implementing classes must provide an implementation. Default methods provide an implementation in the interface itself β€” implementing classes inherit it but can override it. Default methods were added in Java 8 specifically to evolve existing interfaces (like Iterable, Collection) without breaking every class that already implemented them.

Q: What is an effectively final variable and why does it matter for lambdas?
A variable is effectively final if it's never reassigned after initialisation. Lambdas and anonymous classes can only capture effectively final local variables because the lambda may outlive the stack frame where the variable was declared β€” the JVM needs to copy the value into the lambda's closure. If the variable could change, the copy would be stale. This is why reassigning a variable and then using it in a lambda is a compile error.