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.
In this topic