jvmtp: benchmarks

Two things a buyer actually feels: how much the protection costs at runtime, and whether it survives real, obfuscated input. Startup numbers come from a serial min-of-7 timing harness; the dispatch numbers come from a per-call JNI microbenchmark of the resolved native path against generic boxed dispatch. These are current-release figures.

Startup cost

Native protection adds a one-time loader cost, not a per-call tax. Startup stays flat across protection levels: turning on the VM or the obfuscator does not slow the jar down. Only aggressive is measurably heavier.

Cold start to exit milliseconds · serial min of 7
0 40 80 120 160 ms baseline: 70 ms 70 clean: 132 ms 132 obf: 130 ms 130 vm: 131 ms 131 vm-obf: 134 ms 134 dual-vm: 129 ms 129 hotspot-only: 133 ms 133 full: 136 ms 136 aggressive: 160 ms 160 base clean obf vm vm-obf dual hspot full aggr
unprotected baseline protected aggressive
Baseline 70 ms rises to ~130 ms and stays there whether the VM, the obfuscator, both, or neither are on. The delta is a fixed native-loader + JNI startup cost, paid once, independent of protection depth and of image size.

Faster invokedynamic, natively

Chain jvmtp behind a bytecode obfuscator and it runs the obfuscator's own invokedynamic dispatchers as native code. Two views below: end-to-end on a real jar, and the per-call dispatch cost underneath it.

On a jar run through ZKM with reference obfuscation on, the input carries 749 invokedynamic dispatchers. All four VM backends decrypt and execute them; the stack backend is the shipped default, fastest and smallest.

Runtime seconds · min
0 .25 .5 .75 1.0s no VM no VM: 0.30 s 0.30 stack ✓ stack (default): 0.89 s 0.89 reg ½ register ratio 0.5: 0.92 s 0.92 dual dual: 0.94 s 0.94 register register ratio 1.0: 1.06 s 1.06
Output jar size kilobytes
0 200 400 600 800 no VM no VM: 150 KB 150 stack ✓ stack (default): 351 KB 351 reg ½ register ratio 0.5: 452 KB 452 dual dual: 776 KB 776 register register ratio 1.0: 567 KB 567
no VM (reference) stack — shipped default register / dual runtime register / dual jar size

All four backends run the ZKM jar correctly. Stack is fastest and smallest; register and dual cost little runtime but grow the jar, so they are a deliberate VM-diversity upgrade, not a speed choice.

Underneath every dispatch is a MethodHandle chain the JVM re-links and calls through generic, fully-boxed machinery. jvmtp resolves that chain once and replaces the per-call boxing with a direct native call: 5x to 9x faster per invocation on the shapes obfuscators install, and never slower on one it cannot crack.

Per-call dispatch cost — boxed vs cracked nanoseconds · steady state
0 200 400 600 800 ns dropInvoker relink steady staticDirect virtualDirect collectorBind collectorFirst nonCrackable dropInvoker boxed: 772 ns772 relink boxed: 818 ns818 staticDirect boxed: 632 ns632 virtualDirect boxed: 528 ns528 collectorBind boxed: 630 ns630 collectorFirst boxed: 615 ns615 nonCrackable boxed: 625 ns625 dropInvoker cracked: 89 ns89 relink cracked: 125 ns125 staticDirect cracked: 84 ns84 virtualDirect cracked: 97 ns97 collectorBind cracked: 510 ns510 collectorFirst cracked: 512 ns512 nonCrackable: 625 ns (unchanged)625 8.7× 6.5× 7.5× 5.4× 1.25× 1.25× 1.0×
boxed dispatch (before) native cracked (after) partial / one-shot shape
Boxed is generic dispatch: cast to Object, box every argument, call through invokeWithArguments. Cracked is a direct native call to the resolved target. Steady-state per-call cost only; the one-time crack resolution is not counted. 4,000,000 iterations after 600,000 warmup, monotonic clock. The heaviest obfuscation hides in the relinking call site, which cracks ~6.5x; a shape that cannot be cracked stays on the boxed path at no penalty.

End-to-end runtime and jar size come from a VM-backend sweep on the ZKM jar. Per-call figures come from a standalone JNI microbenchmark linked against the JDK, timing the boxed path against the crack primitive per shape. Startup timing is a serial min-of-7 over the corpus jars.