vtable and itable — Tasks¶
8 hands-on exercises that turn the concepts in
junior/middle/seniorinto things you can see and measure. Use a JDK 21 (or later) and a Linux/macOS shell. Each task lists the tools needed, the steps to run, and the expected observations. Skip none — together they cover the toolkit professional Java engineers use to reason about dispatch.
Task 1 — Inspect ArrayList's vtable with HSDB¶
Goal. See an actual vtable in HotSpot.
Tools. jhsdb (ships with the JDK), a running JVM you can attach to.
Steps.
- Start a small program that keeps the JVM alive with
ArrayListloaded:
public class HsdbTarget {
public static void main(String[] args) throws Exception {
java.util.ArrayList<Integer> list = new java.util.ArrayList<>();
list.add(1); list.add(2); list.add(3);
System.out.println("pid=" + ProcessHandle.current().pid());
Thread.sleep(Long.MAX_VALUE);
}
}
-
Run it:
java HsdbTarget. Note the PID it prints. -
In another terminal:
jhsdb hsdb --pid <pid>. (On macOS you may need to disable SIP for the duration; alternatively use a Linux VM.) -
In the HSDB GUI:
Tools->Class Browser-> filter forjava.util.ArrayList. Double-click the class. -
Click into the
Klassand locate the vtable. Note: - The first ~10 slots are inherited from
Object. - Slots for
AbstractList's methods follow. - Slots for
ArrayList's overrides (add,get,size, etc.).
Observe. ArrayList's vtable is roughly 40-50 slots: Object's 10 + AbstractCollection + AbstractList + ArrayList's own. Compare with the vtable of java.lang.Object (Tools -> Class Browser -> java.lang.Object) which has just the base entries.
Why this matters. Numbers on paper become tangible. You can now answer "how many slots does class X have?" with a measurement instead of an estimate.
Task 2 — Predict the vtable slot for an override¶
Goal. Confirm your mental model of slot allocation by predicting before checking.
Steps.
- Write this code:
class A {
public void m1() {}
public void m2() {}
public void m3() {}
}
class B extends A {
@Override public void m2() {} // override
public void m4() {} // new
}
class C extends B {
@Override public void m1() {} // override A's
@Override public void m4() {} // override B's
public void m5() {} // new
}
-
Without running anything, write down (on paper) the expected slot layout for
C.vtable, after Object's inherited slots. -
Open HSDB on a running program that loads
C. Compare your prediction with what HSDB shows.
Expected layout (after Object's slots):
Try again carefully:
Observe. Each subclass inherits or replaces the parent's slot order. New methods append. Your first prediction is probably wrong somewhere; checking against HSDB calibrates your mental model.
Task 3 — Compare vtable sizes: Object, String, custom deep hierarchy¶
Goal. Quantify how vtable size scales with class complexity.
Steps.
- Create three classes:
Empty(extendsObject, declares nothing).- A wrapper around
String(useString.classdirectly). -
A 6-level deep hierarchy where each level adds 5 methods. The leaf
Deepis your custom class. -
In HSDB, navigate to each class. Note the total vtable length.
-
Tabulate:
Object -> ~10 slots
Empty -> ~10 slots (no own methods, so same as Object)
String -> ~30+ slots (final class with many own methods)
Deep -> ~10 + 30 = ~40 slots
- Now look at itables.
Stringimplements 4 interfaces (Serializable,Comparable,CharSequence,Constable). Each contributes an itable. HSDB shows them as separate entries under theKlass.
Observe. Vtable size is roughly Object slots + sum(declared overridable methods up the chain). Itable count equals the size of the implements-closure. For an enterprise class implementing 8 marker/role interfaces, itable cost can exceed vtable cost.
Task 4 — Verify devirtualization with -XX:+PrintInlining¶
Goal. See the JIT decide whether to devirtualize a call.
Tools. JDK, -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining.
Steps.
- Write a tight loop:
interface Op { int apply(int x); }
static final class AddOne implements Op { public int apply(int x){ return x+1; } }
public static int sum(Op op) {
int s = 0;
for (int i = 0; i < 100_000_000; i++) s = op.apply(s);
return s;
}
public static void main(String[] args) {
Op op = new AddOne();
for (int warm = 0; warm < 5; warm++) sum(op);
System.out.println(sum(op));
}
-
Run with:
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining DevirtTest 2>&1 | grep -A 3 'sum'. -
Look for lines like:
Versus the megamorphic case (when op varies):
Observe. With one implementation, the call is inlined. Add MulTwo, Negate, Square implementations and rotate them — the inlining annotation changes to (virtual call) or (megamorphic).
Task 5 — Refactor a megamorphic interface call site¶
Goal. Practice the refactor pattern from find-bug.md Bug 2.
Steps.
- Start with the megamorphic loop:
interface Handler { void handle(Event e); }
class HandlerA implements Handler { ... }
class HandlerB implements Handler { ... }
class HandlerC implements Handler { ... }
class HandlerD implements Handler { ... }
class HandlerE implements Handler { ... }
public void process(List<Event> events, Map<EventType, Handler> handlers) {
for (Event e : events) {
handlers.get(e.type()).handle(e);
}
}
-
Write a JMH benchmark of
processwith 100,000 events distributed across all 5 types. Record the score. -
Refactor to group by type:
public void process(List<Event> events, Map<EventType, Handler> handlers) {
Map<Handler, List<Event>> grouped =
events.stream().collect(Collectors.groupingBy(e -> handlers.get(e.type())));
grouped.forEach((handler, batch) -> {
for (Event e : batch) handler.handle(e);
});
}
-
Re-run the benchmark. The second version should be 2-3x faster on a CPU-bound
handlebecause each inner loop is monomorphic. -
Bonus: seal
Handler(sealed interface Handler permits HandlerA, HandlerB, ...) and verify with-XX:+PrintInliningthat the JIT inlines via CHA even in the original loop.
Observe. Source-level structure determines call-site polymorphism. Same logic, different shape, different cost.
Task 6 — Design a sealed hierarchy to keep itables small¶
Goal. Apply sealed types to a real domain.
Steps.
-
Take an existing open hierarchy in your codebase (or invent one — payment methods, notification channels, audit events).
-
List the current implementations. If there are 3-8, you're in the sweet spot for sealed.
-
Convert:
public sealed interface PaymentMethod permits Card, Bank, Wallet, ApplePay {
void charge(BigDecimal amount);
}
public final class Card implements PaymentMethod { ... }
public final class Bank implements PaymentMethod { ... }
public final class Wallet implements PaymentMethod { ... }
public final class ApplePay implements PaymentMethod { ... }
- Replace any
if/else if instanceofchains withswitchover the sealed type:
switch (method) {
case Card c -> processCard(c);
case Bank b -> processBank(b);
case Wallet w -> processWallet(w);
case ApplePay a -> processApplePay(a);
}
-
Verify exhaustiveness: remove one
caseand confirmjavacrejects the code. -
Compare HSDB output before/after: the implementations are now
finalrecords or final classes, so subclass-related vtable slack is gone, and CHA sees a closed set of itable targets.
Observe. Sealed + final implementations + exhaustive switch is the JVM-friendly equivalent of an algebraic data type. The vtable/itable structures don't change shape, but the JIT's confidence in them does.
Task 7 — Profile a polymorphic loop with JMH + async-profiler¶
Goal. Combine throughput numbers with flame-graph evidence.
Tools. JMH (Gradle/Maven plugin), async-profiler (download from GitHub), JDK 21.
Steps.
-
Build the
DispatchBenchfromoptimize.mdSection 4 (mono/bi/megamorphic Shape loop). -
Run JMH with async-profiler attached:
- Open the resulting
flame-megamorphic.html. You should see: - The
area()call dispatched throughitable_stuborvtable_stubframe. -
A wide bar in
Klass::is_subtype_ofor the itable lookup path. -
Compare with
flame-monomorphic.html: thearea()call is inlined; you see only the arithmetic. -
As an extra, run with
-XX:+PrintInliningenabled in the JMH fork:
and grep for (megamorphic) and (virtual call) annotations.
Observe. The flame graph and JMH numbers tell complementary stories. JMH gives you scalar latency; the flame graph tells you which code is responsible.
Task 8 — Explain bridge methods' vtable impact¶
Goal. See the bridge method that javac produces and where it lives in the vtable.
Steps.
- Write the code:
class Container<T> {
public Object peek() { return null; }
}
class StringContainer extends Container<String> {
@Override public String peek() { return "hi"; }
}
- Compile and run
javap -v -p StringContainer.class. Look for twopeekentries:
public java.lang.String peek();
flags: ACC_PUBLIC
public java.lang.Object peek();
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
0: aload_0
1: invokevirtual #N // Method peek:()Ljava/lang/String;
4: areturn
-
Load
StringContainerin HSDB. Look at the vtable. You should see both methods occupying separate slots. -
Write a small driver:
Container<?> c = new StringContainer();
System.out.println(c.peek()); // dispatches through bridge -> real method
StringContainer s = new StringContainer();
System.out.println(s.peek()); // dispatches directly
- Run with
-XX:+PrintInlining. The first call shows two inlined methods (bridge + real); the second shows one.
Observe. The bridge is real. It occupies a vtable slot. Through a generic-erased reference, you pay an extra hop. The JIT inlines both in practice, so the cost vanishes — but in reflective code, both methods are visible and must be filtered (Bug 3 in find-bug.md).
Wrap-up checklist¶
After completing all eight tasks, you should be able to:
- Open HSDB and read a class's vtable and itables.
- Predict slot allocation for a given hierarchy and verify your prediction.
- Compare vtable sizes across classes of different complexity.
- Use
-XX:+PrintInliningto identify devirtualized vs. virtual call sites. - Refactor a megamorphic call site to recover monomorphism.
- Design a sealed hierarchy and confirm
javacchecks exhaustiveness. - Run JMH + async-profiler to combine numbers with flame graphs.
- Identify a bridge method in javap output and explain its vtable cost.
Quick rules¶
- HSDB is for structural questions ("what's in the vtable?").
-
-XX:+PrintInliningis for behavioural questions ("did the JIT devirtualize?"). - JMH is for quantitative questions ("how much faster after the refactor?").
- async-profiler is for production-shaped questions ("where is the time going under load?").
- Combine all four — no single tool gives the full picture.
Memorize this: the eight tasks are a vocabulary. Once you've done them, conversations about dispatch performance stop being abstract and become "let me check with X, run Y, look at Z". That's the skill these exercises build.