Methods, parameters and varargs

A declaration part by part, overloading resolution, varargs, pass-by-value for references, and the defensive copy a public method owes its caller.

13 min read Java Fundamentals

A method is a contract: give me these, I will do this, and here is what you get back. Most of the subtlety in Java methods is about the edges of that contract — what happens when several methods share a name, what a caller can do to your parameters after the call, and what you owe a caller about the object you hand back. This lesson is those edges, then the machinery under them: the three phases of overload resolution, the five invoke instructions and which one your call becomes, and why the JIT cares how long your method is.

A declaration, part by part

One method, every part presentjava
public static int clamp(int value, int min, int max) {
    if (value < min) return min;
    if (value > max) return max;
    return value;
}
PartHereWhat it decides
modifierspublic staticwho may call it, and whether it needs an object (static means it does not)
return typeintwhat the caller gets back; void means nothing
nameclampby convention a verb, in camelCase
parameter list(int value, int min, int max)what a caller must pass: how many values, in what order, of what types
throws clausenone herethe checked exceptions it may throw, covered with exceptions
body{ ... }the statements that run, which must return an int on every path

Parameters and arguments are the two ends of one thing. The parameters are the variables in the declaration: value, min, max. The arguments are the values in a call: clamp(150, 0, 100). Java matches them by position, never by name, and the count and types must fit. Calling a two-parameter add as add(1) fails with method add in class ... cannot be applied to given types.

The return type is a promise the compiler checks. A method that declares int must return an int on every path out, or it fails with missing return statement. A void method returns nothing: a bare return; may end it early, and return 1; fails with incompatible types: unexpected return value. A statement after a return in the same block is unreachable statement, a compile error rather than a warning.

A method's signature is its name plus its parameter types. The return type is not part of it, so two methods that differ only in return type are the same method declared twice: method f(int) is already defined. Overloading is built on that rule.

Static or instance. A static method belongs to the class and is called through it, Math.max(a, b); it has no this and cannot reach instance fields. An instance method is called on an object, name.length(), and receives that object as a hidden first parameter. When to choose which is covered in Object-Oriented Java. How each call is carried out is further down this lesson.

Overloading and how a call is resolved

Two methods with the same name and different parameter lists are overloads. The compiler picks one at compile time, from the static types of the arguments, in three phases (JLS §15.12.2): first the methods applicable by exact match, widening primitive and widening reference conversion, with no boxing and no varargs; if none, allow boxing and unboxing; if still none, allow varargs. Within the winning phase, the most specific method wins, and if two are equally specific the call is ambiguous and does not compile.

java
void log(Object o)  { System.out.println("object"); }
void log(String s)  { System.out.println("string"); }
void log(Integer i) { System.out.println("integer"); }
void log(long l)    { System.out.println("long"); }
 
log("hi");           // string   — phase 1, most specific
log(5);              // long     — phase 1: int widens to long; boxing to Integer is phase 2 and never reached
Object o = "hi";
log(o);              // object   — static type is Object; the runtime type does not matter
log(null);           // does not compile: String and Integer are both applicable, neither more specific

The second call is the one that surprises people who expect integer: widening beats boxing because it is an earlier phase, not a closer type. The third is the one people get wrong in code: overload resolution uses the declared type of the expression. Dynamic dispatch — choosing an implementation by the object's runtime class — only happens for overriding, and only on the receiver, never on the arguments. If you want behaviour to vary by an argument's runtime type, overloading is the wrong tool: use a virtual method on the argument, double dispatch, or an explicit instanceof chain that says what it is doing.

phase 1phase 2phase 3most specific
applicablelog(long)boxingnot allowedresultlog(long)

phase 1. Applicable by exact match and WIDENING only -- no boxing, no varargs. int widens to long, so log(long) applies, and that ends it. A winner here is never compared against a later phase.

1 / 4

Under the hood: five ways to call a method

Every call in your source becomes one of five bytecodes, chosen by javac from what it knows about the target:

InstructionUsed forDispatch
invokestaticstatic methodsdirect: the target is fixed at compile time
invokespecialconstructors, private methods, super.m()direct
invokevirtualevery other instance method on a class typevirtual: through the receiver's vtable, an array of method pointers per class, indexed by a slot fixed at class load
invokeinterfacea method called through an interface typethrough an itable search, because interface slots are not fixed across unrelated classes; slower in the interpreter, and the JIT turns hot ones into guarded direct calls
invokedynamiclambdas, string concatenation, pattern switches, records' equalsa call site linked on first execution by a bootstrap method, then as fast as the others

A final method or class lets javac and the JIT know the target cannot be overridden; the JIT also proves that on its own for a method with no loaded overrides, and deoptimises if one appears (the Bytecode and JIT lesson). Each call pushes a frame, copies the arguments into the callee's local slots, and pops on return; the frame is the whole cost of a call that the JIT does not inline, and inlining is decided by size: under 35 bytes of bytecode, nearly always; under 325 bytes if hot; larger, never. That is the concrete meaning of "small methods are faster": a 400-byte method is a call boundary that stops every optimisation across it.

Varargs

java
static int sum(int... values) {
    int total = 0;
    for (int v : values) total += v;
    return total;
}
sum();          // values is an empty array
sum(1, 2, 3);   // values is {1, 2, 3}
sum(existing);  // an int[] is passed as-is

A varargs parameter is an array with call-site sugar; javap shows newarray and three iastores before the invokestatic. It must be last. It allocates an array on every call — fine for String.format, not fine in a hot path. That cost is why the JDK pays for fixed-arity overloads wherever a call is common: java.util.Arrays declares asList alongside a dozen fixed-shape helpers, and String.format is the case where the array is worth it because formatting dwarfs it. Combining varargs with generics produces the "possible heap pollution" warning, because T... is really Object[] and the array can be made to hold the wrong type; @SafeVarargs is the promise that the method does not store the array or hand it out.

Parameters are locals

A parameter is a local variable initialised with a copy of the argument: slot 0 is this, then each parameter in order, a long or double taking two slots. Reassigning it does nothing outside the method. Mutating the object it refers to does. Both halves of that sentence were covered under the stack and the heap; here is the practical rule:

Do not reassign parameters. Declare them final if your style guide allows, or just do not do it. A method that reassigns input = input.trim() and later uses input has two meanings for one name, and the trace on the day it goes wrong is confusing.

Defensive copies

Accepting a mutable object and keeping it means the caller can change your state later, without calling any of your methods:

java
class Schedule {
    private final List<LocalDate> dates;
    Schedule(List<LocalDate> dates) { this.dates = dates; }         // shares the caller's list
}
 
List<LocalDate> mine = new ArrayList<>(Arrays.asList(monday));
Schedule s = new Schedule(mine);
mine.clear();                                                      // Schedule is now empty too

If the field is meant to be owned by the object, copy on the way in and either copy or wrap on the way out:

java
Schedule(List<LocalDate> dates) {
    this.dates = Collections.unmodifiableList(new ArrayList<>(dates));   // copy, then wrap
}
List<LocalDate> dates() { return dates; }                                  // safe: already unmodifiable

The copy and the wrap are two different protections and you usually need both. new ArrayList<>(dates) copies, so the caller's later clear() cannot reach you — but the copy is still mutable, so anyone who gets it from the getter can change your state. Collections.unmodifiableList wraps without copying, so it stops the getter's caller — but it is a view: whoever still holds the original list can mutate it and your "unmodifiable" list changes underneath you. Copy on the way in, wrap on the way out, and the pair holds. Arrays have no unmodifiable view; clone() or Arrays.copyOf them. The same applies to Date (mutable — one of the reasons java.time exists), StringBuilder, and any object with a setter.

Walkthrough: the discount that applied twice

PromoService.javajava
class Cart { final List<Line> lines; Cart(List<Line> lines) { this.lines = lines; } }
 
Cart cart = new Cart(request.lines());          // shares the request's list
promo.apply(cart);                              // adds a discount Line to cart.lines
audit.record(request);                          // serialises request.lines(): the discount is in it
retry(() -> promo.apply(new Cart(request.lines())));   // applies again: two discounts
  1. Cart stored the caller's list. There is now one ArrayList reachable from request and from cart.
  2. promo.apply added a line through cart.lines. The same ArrayList now has the discount, so request has it too.
  3. The audit wrote the request with a discount the customer never sent, and a retry built a new Cart from a request that already contained one, then applied another.
  4. One defensive copy in the constructor breaks the sharing: the cart owns its lines, the request stays what the customer sent, the retry is idempotent.

The bug is invisible in a unit test that builds Cart from Collections.unmodifiableList(...), because nobody can mutate it through that reference, so nothing leaks. It appears with the mutable ArrayList a JSON deserialiser produces.

Returning

  • Return an empty collection, never null, for "no results". Collections.emptyList() costs nothing: it is a shared singleton.
  • Return Optional for "maybe one result"; never null for an object the caller will dereference. Optional is for return types, not fields or parameters.
  • Do not return a mutable internal collection unless mutating it is the intended API.
  • If a method can fail in a way the caller should handle, throw — do not return a magic value. -1 and "" as error codes are how bugs hide.

Method size and naming

A method should do the thing its name says and nothing its name does not. validateAndSave is two methods. Names are verbs for actions (calculateTotal, findByEmail), predicates for booleans (isExpired, hasItems), and nouns are for records and getters. Length is not the measure; whether a reader can hold it in their head is. If a method needs a comment to explain its sections, the sections are methods, and the inliner agrees with the reader.

Try it yourself

Which overload?

java
class Log {
    void f(long x)    { System.out.println("long"); }
    void f(Integer x) { System.out.println("Integer"); }
    void f(Object x)  { System.out.println("Object"); }
    void f(int... x)  { System.out.println("varargs"); }
}
Log log = new Log();
 
log.f(3);
log.f((short) 3);
log.f(3, 4);
log.f(Integer.valueOf(3));
Answer

long, long, varargs, Integer. 3 is an int: phase 1 finds long by widening; Integer would need boxing (phase 2) and Object boxing plus widening, neither reached. (short) 3 widens to long the same way. (3, 4) matches only the varargs method, phase 3. Integer.valueOf(3) is an Integer: phase 1 matches Integer exactly and Object by widening; Integer is more specific.

Find the leak

java
class Report {
    private final Map<String, List<String>> sections;
    Report(Map<String, List<String>> s) { this.sections = Collections.unmodifiableMap(new HashMap<>(s)); }
    List<String> section(String name) { return sections.get(name); }
}

The map was copied and wrapped. Can a caller still change a Report?

Answer

Yes. Copying the map copies the map, not the lists inside it; the values are still the caller's mutable ArrayLists, and section() hands one back. report.section("intro").add("...") changes the report. A deep copy is needed: build a new map, and put a new ArrayList<>(v) for each value, then wrap the whole thing with Collections.unmodifiableMap. Defensive copying is per level of mutability — every copy helper in the JDK is shallow by design, and nesting is where people stop one level too early.

Why did the JIT refuse?

-XX:+PrintInlining shows com.acme.Pricing::applyRules (612 bytes) callee is too large. The method is called a million times per second. What are your options?

Answer

Split it. Extract the hot path (the branch most calls take) into a method under 325 bytes and leave the rare cases in a separate one; the JIT inlines the small hot method and keeps the rare one as a call. This is the same refactoring the reader wanted, for the same reason. Raising -XX:FreqInlineSize globally is the wrong tool: it inflates every compiled method and pressures the code cache.

Misconceptions

  • "Two methods can differ only by return type." The signature is the name and the parameter types, so the second declaration is a duplicate and does not compile.
  • "The most specific type wins overload resolution." The earliest phase wins, then the most specific within it. long beats Integer for an int argument because widening is phase 1 and boxing is phase 2.
  • "Overloading and overriding are the same kind of polymorphism." Textbooks and interviews call overloading compile-time polymorphism and overriding runtime polymorphism, and those names are fine to use. The mechanisms are unrelated: an overload is a compile-time choice by the static types of the arguments, and an override is dispatched at run time on the receiver only.
  • "final parameters make the object immutable." final stops the slot being reassigned. The object it refers to is as mutable as it ever was.
  • "Collections.unmodifiableList protects the field." It protects whoever gets the view. The original list is still held by whoever passed it in.
  • "Method length is a style matter." It is also the JIT's inlining budget, and a boundary the optimiser cannot see across.

Going deeper

  • JLS §15.12.2, the three phases of overload resolution, with the examples that make the remove(int) case follow from the rules.
  • JVM Specification §6.5, the invoke* instructions; javap -c on a class with a lambda, an interface call and a super call.
  • JLS §8.4.1, varargs and @SafeVarargs; Effective Java item 32 on heap pollution.
  • Effective Java items 49–52: parameter validation, defensive copies, overloading judiciously.
  • -XX:+PrintInlining with MaxInlineSize and FreqInlineSize, for the budget in your own code.
Progress is saved on this device and to your account when signed in.