Skip to content
Java polymorphism 6 min read

Compile-Time Polymorphism

Compile-time polymorphism means the Java compiler decides which method to call while it is compiling your code — before a single line runs. Because everything is resolved early, it is also called static polymorphism or early binding.

What Is Polymorphism?

Polymorphism literally means “many forms.” In Java, one name can refer to multiple method implementations. There are two flavors:

TypeResolved byMechanism
Compile-time (static)CompilerMethod Overloading
Runtime (dynamic)JVMMethod Overriding

This page focuses entirely on the compile-time variety.

Method Overloading — The Engine of Compile-Time Polymorphism

Method overloading means defining multiple methods with the same name inside the same class, each with a different parameter list. The compiler looks at the number, types, and order of arguments at the call site and picks the right version.

class Calculator {

    // Version 1 — two ints
    int add(int a, int b) {
        return a + b;
    }

    // Version 2 — three ints
    int add(int a, int b, int c) {
        return a + b + c;
    }

    // Version 3 — two doubles
    double add(double a, double b) {
        return a + b;
    }
}

public class Main {
    public static void main(String[] args) {
        Calculator calc = new Calculator();

        System.out.println(calc.add(2, 3));        // calls version 1
        System.out.println(calc.add(2, 3, 4));     // calls version 2
        System.out.println(calc.add(2.5, 3.5));    // calls version 3
    }
}

Output:

5
9
6.0

The compiler inspects each call, matches it to the best-fitting signature, and bakes the direct method reference into the bytecode. No decision-making happens at runtime.

Rules for Overloading

You must change the parameter list in at least one of these ways:

  1. Number of parametersadd(int, int) vs add(int, int, int)
  2. Types of parametersadd(int, int) vs add(double, double)
  3. Order of parameter typesprint(String, int) vs print(int, String)

Warning: Changing only the return type is NOT valid overloading and will not compile. The compiler resolves the call before it knows which return type you expect.

class Demo {
    int getValue()    { return 10; }
    // double getValue() { return 10.0; } // COMPILE ERROR — same signature
}

Overloading with Type Promotion

When no exact match exists, Java automatically promotes smaller numeric types to larger ones to find a match. The promotion chain is:

byte → short → int → long → float → double

class Promoter {
    void show(long x) {
        System.out.println("long: " + x);
    }
}

public class PromoDemo {
    public static void main(String[] args) {
        Promoter p = new Promoter();
        p.show(42);   // int 42 is promoted to long — no exact int match
    }
}

Output:

long: 42

Note: If multiple promotions are equally valid (e.g., both show(long) and show(float) exist), the compiler picks the most specific one — long in this case because it is a narrower promotion than float.

Overloading with Autoboxing

Since Java 5, primitive types can be autoboxed to their wrapper counterparts during overload resolution. However, the compiler prefers an exact primitive match or type promotion before it resorts to autoboxing.

class BoxDemo {
    void process(Integer x) { System.out.println("Integer wrapper: " + x); }
    void process(long x)    { System.out.println("long primitive: " + x); }
}

public class AutoboxTest {
    public static void main(String[] args) {
        BoxDemo bd = new BoxDemo();
        bd.process(5);   // widening (int→long) preferred over autoboxing (int→Integer)
    }
}

Output:

long primitive: 5

Tip: The compiler’s priority order is: exact match → widening primitive promotion → autoboxing → varargs. Knowing this prevents subtle bugs.

Overloading with Varargs

Varargs methods (Type... args) can be overloaded too, but the compiler always tries a non-varargs match first.

class Printer {
    void print(String s)          { System.out.println("single: " + s); }
    void print(String... strings) { System.out.println("varargs: " + strings.length + " items"); }
}

public class VarTest {
    public static void main(String[] args) {
        Printer p = new Printer();
        p.print("hello");              // matches single-String version first
        p.print("a", "b", "c");       // must use varargs version
    }
}

Output:

single: hello
varargs: 3 items

Overloading in Constructors

Constructors can be overloaded just like methods — a common pattern for providing flexible object creation.

class Point {
    double x, y, z;

    Point() {
        this(0, 0, 0);
    }

    Point(double x, double y) {
        this(x, y, 0);
    }

    Point(double x, double y, double z) {
        this.x = x; this.y = y; this.z = z;
    }

    @Override
    public String toString() {
        return "(" + x + ", " + y + ", " + z + ")";
    }
}

public class PointDemo {
    public static void main(String[] args) {
        System.out.println(new Point());
        System.out.println(new Point(1, 2));
        System.out.println(new Point(1, 2, 3));
    }
}

Output:

(0.0, 0.0, 0.0)
(1.0, 2.0, 0.0)
(1.0, 2.0, 3.0)

Notice the use of this(...) to chain constructors — this keeps logic in one place. Read more on the this Keyword and Constructors pages.

Under the Hood

When the Java compiler encounters an overloaded call like calc.add(2, 3), it performs overload resolution — a compile-time algorithm defined in the Java Language Specification (JLS §15.12):

  1. Phase 1: Find all applicable methods without autoboxing or varargs (widening only).
  2. Phase 2: Find all applicable methods with autoboxing/unboxing, still no varargs.
  3. Phase 3: Find all applicable methods allowing varargs.

The compiler picks the most specific applicable method from the winner set of each phase. If two methods are equally specific, you get an ambiguous-call compile error.

The selected method is recorded directly in the bytecode as an invokevirtual (or invokestatic for static methods) instruction with the fully resolved method descriptor — (II)I for add(int, int), (DI)I for add(double, int), etc. The JVM executes this instruction without any extra lookup at the call site; the heavy lifting is already done.

// javap -c output for calc.add(2, 3)
invokevirtual #4  // Method Calculator.add:(II)I

This is fundamentally different from runtime polymorphism, where the JVM must consult the vtable at the call site to dispatch to the right overriding implementation. Compile-time dispatch is cheaper — there is zero vtable lookup cost.

Tip: Use javap -c MyClass.class to inspect the bytecode yourself and see which descriptor the compiler chose. The javap tool page walks you through it step by step.

Compile-Time vs Runtime Polymorphism — Quick Comparison

AspectCompile-TimeRuntime
Resolved atCompile timeRuntime (JVM)
MechanismMethod overloadingMethod overriding
Binding typeStatic / early bindingDynamic / late binding
Inheritance required?NoYes
PerformanceSlightly faster (no vtable lookup)Tiny overhead from vtable
Bytecode instructioninvokevirtual / invokestatic with fixed descriptorinvokevirtual with vtable dispatch

Common Mistakes to Avoid

  • Overloading by return type only — does not compile; the signature must differ in parameters.
  • Ambiguous overloaded calls — passing null to overloaded methods that each accept a reference type causes an ambiguity error. Fix it with a cast: method((String) null).
  • Confusing overloading with overriding — overloading is same class, different parameters; overriding is subclass, same signature. See Overloading vs Overriding for the full comparison.
  • Forgetting wrapper vs primitive preference — widening beats autoboxing, so process(5) binds to process(long) before process(Integer).
Last updated June 13, 2026
Was this helpful?