Upgrade to Pro — share decks privately, control downloads, hide ads and more …

The Quarkus advantage: Turning build-time metap...

The Quarkus advantage: Turning build-time metaprogramming into runtime performances

Java frameworks have long relied on reflection, classpath scanning and runtime dynamism to deliver flexibility, but at a significant cost in terms of startup time, memory footprint, and raw throughput. The main goal of this talk is demonstrating with a recently introduced practical optimization how the radically different architecture of Quarkus unlocks performance gains that traditional frameworks simply cannot achieve.

Using the case studies of JSON serialization, REST Client and Quarkus LangChain4j we will walk through how a Quarkus extension can leverage Quarkus's build-time indexing to identify and inspect the application's domain model at build time, and Gizmo to generate optimized bytecode that completely replaces Jackson's reflection-heavy serialization machinery. The result is not just cleaner execution, but measurable performance improvements—demonstrating double-digit throughput gains while reducing latency and runtime overhead.

Beyond the specific optimization, this talk reveals a deeper insight: many “dynamic” features in Java are only necessary because frameworks lack build-time knowledge. By embracing ahead-of-time metaprogramming, Quarkus redefines what is possible in the JVM ecosystem.

Attendees will leave with a clear mental model of Quarkus’ architecture, a practical understanding of build-time metaprogramming, and a fresh perspective on how rethinking the boundary between build and runtime can unlock entirely new performance and footprint frontiers.

Avatar for Mario Fusco

Mario Fusco

October 05, 2026

More Decks by Mario Fusco

Other Decks in Programming

Transcript

  1. Build time vs. Runtime Runtime Build Time </> @ @

    Load config file from file system Parse it
  2. Build time vs. Runtime Runtime Build Time </> @ @

    Classpath scanning to find annotated classes Attempt to load class to enable/disable features
  3. Build time vs. Runtime Runtime Build Time </> @ @

    Start the management (thread, pool…)
  4. Build time vs. Runtime </> @ @ Build Time ➔

    Do the work once, not at each start ➔ All the bootstrap classes are no longer loaded ➔ Less time to start, less memory used ➔ Less or no reflection nor dynamic proxy Runtime
  5. Reflecting…on Reflection ➢ It allows to query a class for

    its fields, methods, etc etc ➢ It needs to be secure i.e. respecting visibility, types, module boundaries etc ➢ It enables getting/setting fields or calling methods/constructors on a given instance or a Class
  6. What reflection needs to work ➢ metadata on the class

    ➢ metadata on the caller (for security reasons) ➢ where these metadata lives? It depends :)
  7. Old school reflection (pre OpenJDK 18) ➢ ➢ ➢ Callee

    metadata: ◦ parsed from the classfile ◦ cached on Class.reflectionData as a SoftReference<ReflectionData> ▪ GC can reclaim it! (need rebuilt from VM metadata in metaspace) Method/Constructor invoke: ◦ Calls 1–15 (inflationThreshold): NativeMethodAccessorImpl ▪ VM native call. No class generation, per-call native overhead. ◦ Call 16+: MethodAccessorGenerator spins real bytecode ▪ direct invoke* bytecode + 1 2 ▪ JIT-compilable/inlinable va a J on Field accesses: s i 4 s u ◦ Unsafe-based rk Qua
  8. Old school secure reflection: caller checks ➢ ➢ Lives in

    the reflective object itself, before the accessor runs: ◦ setAccessible(true): skip check entirely, forever ◦ public member and class: no caller needed ◦ else: stack walk: invokes checkAccess() ▪ checkAccess has a 1-slot cache per instance ▪ same caller as last time: cache hit, skip re-check ▪ different caller: full ensureMemberAccess() check, cache overwritten Cost: ◦ one-time for the same caller ◦ every-call by alternating callers on same object
  9. Old school secure reflection: callee checks ➢ ➢ Runs inside

    the accessor itself (native / generated bytecode / Unsafe) after the visibility gate, on every call: ◦ Receiver check: obj instanceof declaring class ◦ Arity check: args count matches params ◦ Per-arg type check + widening/unboxing ◦ final field write: read-only unless setAccessible(true) Cost: ◦ no cache: paid in full on every call.
  10. TL;DR old vs new school reflection ➢ ➢ Invocation (Method/Constructor):

    ◦ Uses MethodHandle ◦ No more inflationThreshold ◦ Native VM call only at JVM bootstrap, not steady-state Field access: ◦ ➢ More secure: ◦ ➢ “trusted final fields" stay read-only even after setAccessible(true) Callee metadata: ◦ ➢ Unsafe replaced by VarHandle still cached on Class.reflectionData as a soft-reference Visibility and Argument/receiver checks: unchanged
  11. Reflection-free REST Endpoint Invocation @Path("/persons ") @RequestScoped public class PersonResource

    { @GET @Produces (MediaType.APPLICATION_JSON) @Path("/get") public Person get() { return new Person( "John", "Doe", 30); } }
  12. Reflection-free REST Endpoint Invocation @Path("/persons ") @RequestScoped public class PersonResource

    { @GET @Produces (MediaType.APPLICATION_JSON) @Path("/get") public Person get() { return new Person( "John", "Doe", 30); } } public class PersonResource$quarkusrestinvoker$get_f2ea5 implements EndpointInvoker { public Object invoke(Object var1, Object[] var2) { return ((PersonResource)var1).get(); } }
  13. Reflection-free REST Endpoint Invocation @Path("/persons ") @RequestScoped public class PersonResource

    { @GET @Produces (MediaType.APPLICATION_JSON) @Path("/get") public Person get() { return new Person( "John", "Doe", 30); } } public class PersonResource$quarkusrestinvoker$get_f2ea5 implements EndpointInvoker { public Object invoke(Object var1, Object[] var2) { return ((PersonResource)var1).get(); } }
  14. What is metaprogramming? Metaprogramming is a computer programming technique in

    which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyse, or transform other programs, and even modify itself, while running. In some cases, this allows programmers to minimize the number of lines of code to express a solution, in turn reducing development time. It also allows programs more flexibility to efficiently handle new situations with no recompiling.
  15. Metaprogramming the Quarkus way ➢ Build Step Processors ◦ ➢

    Build Steps ◦ ➢ Execute the Quarkus augmentation phase at build time A non-static method annotated with @BuildStep. Each build step may consume items that are produced by earlier stages, and produce items that can be consumed by later stages. Build Items ◦ Concrete, final subclasses of the abstract io.quarkus.builder.item.BuildItem class. Each build item represents some unit of information that must be passed from one stage to another
  16. BuildItems as project code index ➢ Existing build items cover

    almost all needs ➢ They act like ◦ a library for extension writers ◦ carriers of information across BuildSteps
  17. Metaprogramming the Quarkus way Quarkus automatically determines correct execution order

    and injects parameters build items are communication mechanism between build steps
  18. Scanning, reading and and analyzing code with Jandex Jandex is

    a fast, memory-efficient Java class-file indexer and offline reflection library. Its main purpose is to let frameworks inspect classes, annotations, types, and inheritance without relying on Java reflection and without loading the classes. Jandex parses Java bytecode at build time and turns class/annotation/type metadata into a compact searchable Index, allowing frameworks to perform reflection-like discovery without runtime classpath scanning or class loading.
  19. Discovering REST endpoints with Jandex public class ResteasyReactiveCommonProcessor { public

    static final DotName PATH = DotName.createSimple(Path.class.getName()); @BuildStep void scanResources( JaxRsResourceIndexBuildItem jaxRsResourceIndexBuildItem, BuildProducer<ResourceScanningResultBuildItem> resourceProducer) { IndexView index = jaxRsResourceIndexBuildItem.getIndexView(); Collection<AnnotationInstance> paths = index.getAnnotations(PATH); [...] ResourceScanningResult res = new ResourceScanningResult(index, ...); resourceProducer.produce(new ResourceScanningResultBuildItem(res)); } }
  20. Discovering REST endpoints with Jandex public class ResteasyReactiveCommonProcessor { public

    static final DotName PATH = DotName.createSimple(Path.class.getName()); @BuildStep void scanResources( JaxRsResourceIndexBuildItem jaxRsResourceIndexBuildItem, BuildProducer<ResourceScanningResultBuildItem> resourceProducer) { IndexView index = jaxRsResourceIndexBuildItem.getIndexView(); Collection<AnnotationInstance> paths = index.getAnnotations(PATH); [...] ResourceScanningResult res = new ResourceScanningResult(index, ...); resourceProducer.produce(new ResourceScanningResultBuildItem(res)); } } Jandex representation of @Path BuildItems Jandex Index Metadata of @Path annotations in code Use Jandex to retrieve info on Method exposing REST endpoints and collect them Produce the results of the endpoints scanning in a BuildItem so it could be consumed by another BuildStep
  21. Jandex indexes instead of Runtime classpath scanning ➢ Like many

    other information, Jandex indexes are stored in BuildItems and made available throughout the whole build phase ➢ This eliminates need for runtime classpath scanning
  22. Byte code generation with Gizmo Gizmo is a Java bytecode-generation

    library developed by and for the Quarkus project. It provides a higher-level, Java-friendly API for generating JVM classes and methods without having to manipulate raw bytecode directly.
  23. Generating reflection-free endpoint invoker with Gizmo String create(ResourceMethod method, ClassInfo

    currentClassInfo, MethodInfo info) { String baseName = currentClassInfo.name() + "$quarkusrestinvoker$" + method.getName() + "_" + HashUtil.sha1(endpointIdentifier); Gizmo.create().class_(baseName, cc -> { cc.defaultConstructor(); cc.implements_(EndpointInvoker.class); cc.method("invoke", mc -> { ParamVar resourceParam = mc.parameter("resource", Object.class); mc.returning(Object.class); mc.body(bc -> { List<Expr> args = ... Expr res = Modifier.isInterface(currentClassInfo.flags())) ? bc.invokeInterface(methodDescOf(info), resourceParam, args) : bc.invokeVirtual(methodDescOf(info), resourceParam, args); if (info.returnType().kind() == Type.Kind.VOID) { bc.returnNull(); } else { bc.return_(res); } }); }); }); return baseName; }
  24. Generating reflection-free endpoint invoker with Gizmo String create(ResourceMethod method, ClassInfo

    currentClassInfo, MethodInfo info) { String baseName = currentClassInfo.name() + "$quarkusrestinvoker$" + method.getName() + "_" + HashUtil.sha1(endpointIdentifier); Gizmo.create().class_( baseName , cc -> { cc.defaultConstructor(); cc.implements_(EndpointInvoker.class); cc.method("invoke", mc -> { ParamVar resourceParam = mc.parameter("resource", Object.class); mc.returning(Object.class); mc.body(bc -> { List<Expr> args = ... Expr res = Modifier.isInterface(currentClassInfo.flags())) ? bc.invokeInterface(methodDescOf(info), resourceParam, args) : bc.invokeVirtual(methodDescOf(info), resourceParam, args); if (info.returnType().kind() == Type.Kind.VOID) { bc.returnNull(); } else { bc.return_(res); public class } PersonResource$quarkusrestinvoker$get_f2ea5 }); implements EndpointInvoker { }); }); public Object invoke(Object var1, Object[] var2) return baseName; { } return ((PersonResource)var1).get(); } }
  25. Generating reflection-free endpoint invoker with Gizmo String create(ResourceMethod method, ClassInfo

    currentClassInfo, MethodInfo info) { String baseName = currentClassInfo.name() + "$quarkusrestinvoker$" + method.getName() + "_" + HashUtil.sha1(endpointIdentifier); Gizmo.create().class_( baseName , cc -> { cc.defaultConstructor(); cc.implements_( EndpointInvoker .class); cc.method("invoke", mc -> { ParamVar resourceParam = mc.parameter("resource", Object.class); mc.returning(Object.class); mc.body(bc -> { List<Expr> args = ... Expr res = Modifier.isInterface(currentClassInfo.flags())) ? bc.invokeInterface(methodDescOf(info), resourceParam, args) : bc.invokeVirtual(methodDescOf(info), resourceParam, args); if (info.returnType().kind() == Type.Kind.VOID) { bc.returnNull(); } else { bc.return_(res); public class } PersonResource$quarkusrestinvoker$get_f2ea5 }); implements EndpointInvoker { }); }); public Object invoke(Object var1, Object[] var2) return baseName; { } return ((PersonResource)var1).get(); } }
  26. Generating reflection-free endpoint invoker with Gizmo String create(ResourceMethod method, ClassInfo

    currentClassInfo, MethodInfo info) { String baseName = currentClassInfo.name() + "$quarkusrestinvoker$" + method.getName() + "_" + HashUtil.sha1(endpointIdentifier); Gizmo.create().class_( baseName , cc -> { cc.defaultConstructor(); cc.implements_( EndpointInvoker .class); cc.method( "invoke" , mc -> { ParamVar resourceParam = mc.parameter("resource", Object.class); mc.returning(Object.class); mc.body(bc -> { List<Expr> args = ... Expr res = Modifier.isInterface(currentClassInfo.flags())) ? bc.invokeInterface(methodDescOf(info), resourceParam, args) : bc.invokeVirtual(methodDescOf(info), resourceParam, args); if (info.returnType().kind() == Type.Kind.VOID) { bc.returnNull(); } else { bc.return_(res); public class } PersonResource$quarkusrestinvoker$get_f2ea5 }); implements EndpointInvoker { }); }); public Object invoke(Object var1, Object[] var2) return baseName; { } return ((PersonResource)var1).get(); } }
  27. Generating reflection-free endpoint invoker with Gizmo String create(ResourceMethod method, ClassInfo

    currentClassInfo, MethodInfo info) { String baseName = currentClassInfo.name() + "$quarkusrestinvoker$" + method.getName() + "_" + HashUtil.sha1(endpointIdentifier); Gizmo.create().class_( baseName , cc -> { cc.defaultConstructor(); cc.implements_( EndpointInvoker .class); cc.method( "invoke" , mc -> { ParamVar resourceParam = mc.parameter("resource", Object.class); mc.returning( Object.class); mc.body(bc -> { List<Expr> args = ... Expr res = Modifier.isInterface(currentClassInfo.flags())) ? bc.invokeInterface(methodDescOf(info), resourceParam, args) : bc.invokeVirtual(methodDescOf(info), resourceParam, args); if (info.returnType().kind() == Type.Kind.VOID) { bc.returnNull(); } else { bc.return_(res); public class } PersonResource$quarkusrestinvoker$get_f2ea5 }); implements EndpointInvoker { }); }); public Object invoke(Object var1, Object[] var2) return baseName; { } return ((PersonResource)var1).get(); } }
  28. A request is a chain of small handlers 01 One

    handler, one step public interface ServerRestHandler extends RestHandler<ResteasyReactiveRequestContext> { Each step—such as routing, filtering, or response writing—is a class implementing the ServerRestHandler interface. void handle(ResteasyReactiveRequestContext ctx) throws Exception; } 02 The chain is an array // loop in AbstractResteasyReactiveContext.run() A loop within the request context walks this array by position, ensuring minimal overhead and direct CPU execution paths. while (position < handlers.length) { int pos = position++; invokeHandler(pos); if (suspended) { return; // resumed later at position 03 Suspend and swap Handlers can pause the loop to suspend the request and resume later, or swap the chain entirely for class routing or exception handling. } }
  29. The chain of a plain GET is built once, at

    build time Every request executes approximately eleven handlers on the happy path, with the same handler classes recurring in every chain. An initial chain handles matrix parameters and class routing, while a method chain—generated once during deployment—encompasses filters, parameters, invocation, and response writing. INITIAL CHAIN (SHARED BY ALL) ARCHITECTURE DIAGRAM NOTE • Handlers are generated once at deployment for maximum performance. • Extensions easily insert more handlers for Security, Observability, and JFR. METHOD CHAIN (GENERATED ONCE PER RESOURCE METHOD) restarts to Matrix Rest Initial Class Routing Security Worker Switch Request filters Create method params Invoke REST Resource method Write response
  30. The problem One call site, eighteen receiver types Megamorphic dispatch

    creates a significant performance bottleneck at the primary execution call site. Because there are eighteen possible receiver classes for the handler interface, C2 compilation abandons inlining and defaults to slow interface dispatch. Unlike monomorphic or bimorphic profiles, which allow for inlined bodies and optimizations, this megamorphic lookup prevents boundary optimizations across the eleven calls made during a plain GET request. CALL SITE handler.handle(ctx) itable interface dispatch stub Impl_01 Impl_02 Impl_03 +15 more Receiver Receiver Receiver Classes Monomorphic (1 class) • Guard + Inlined [Free] Bimorphic (2 classes) • Both Inlined [Fast] Megamorphic (3+ classes) • Zero Inlining [Slow] 18 receiver types forced to itable stub lookup. ! 18 TYPES Megamorphic dispatch penalty C2 compilation completely abandons inlining. Virtual call site defaults to slow interface dispatch, destroying hot-path execution performance.
  31. The problem The previous fix: a hand-written instanceof cascade if

    (h instanceof MatrixParamHandler mh) { mh.handle(ctx); } else if (h instanceof QueryParamHandler qh) { qh.handle(ctx); } else if (h instanceof HeaderParamHandler hh) { hh.handle(ctx); } else { // Fallback to megamorphic dispatch h.handle(ctx); } Rediscovers type every time Required rediscovering the type on every request, resulting in roughly eighty type checks per plain GET. Tuning ordering issues The order of checks became a fragile tuning problem that was difficult to manage and maintain. Locked-out extensions Handlers from external extensions were entirely locked out of the optimized path and suffered slow fallbacks.
  32. The improvement Decide the type once, at build time 1.

    Build Time 2. Deployment 3. Every Request Gizmo Generates Dispatcher Assign Kinds byte array per chain Direct tableswitch casting Handler classes registered at build time are now compiled into a generated dispatcher using Gizmo. During the build, handlers from core modules and extensions are assigned a numeric kind. At deployment, an array of these kinds is resolved and stored alongside the handler chain once per resource method. On every request, a tableswitch jump directly casts the handler for a monomorphic, inlined call. Moving the type resolution to build-time replaces hundreds of runtime checks with a single O(1) tableswitch jump.
  33. The Improvement What the generated class looks like OPTIMIZATION DYNAMICS

    class ServerRestHandlerDispatcher$Generated { // Linear instanceof scan at deployment int kindOf(ServerRestHandler h) { if (h instanceof AbortChainHandler) return 0; if (h instanceof BlockingHandler) return 1; … return -1; } } void dispatch(int kind, ServerRestHandler h, Context ctx) { switch (kind) { // tableswitch compilation case 0: ((AbortChainHandler) h).handle(ctx); break; case 1: ((BlockingHandler) h).handle(ctx); break; … default: h.handle(ctx); // megamorphic fallback } } The generated dispatcher utilizes contiguous kinds, allowing it to compile to a highly efficient tableswitch where ordering no longer impacts performance. Each switch case acts as its own invoke instruction, casting the receiver to a single concrete class. This allows class hierarchy analysis to bind and inline the code without consulting the profile. Unregistered handlers intentionally fall into a megamorphic default case. tableswitch runs in constant time precise casting allows compilation inlining megamorphic default fallback
  34. … and why this matters The whole point is to

    replace Method.invoke() reflection with a direct method call. This: ➢ Eliminates reflection overhead entirely ➢ Produces bytecode the JIT compiler can inline and optimize ➢ Is very useful for GraalVM native image (which has limited/expensive reflection support) ➢ Follows the same pattern Quarkus uses throughout: move work from runtime to build time
  35. Implementation details ➢ GraalVM native-image JSON configuration generation ◦ ◦

    ◦ ➢ Serialization Proxy Classes Make code unreachable Make code compatible with build time initialization Code generation ◦ ◦ ◦ ➢ ◦ ◦ Substitutions (com.oracle.svm.core.annotate.Substitute ) ◦ ◦ ➢ Resources JNI Reflection Framework setup code GraalVM Features (org.graalvm.nativeimage.hosted.Feature ) GraalVM API calls Default configuration overrides
  36. JSON configuration files generation Automatically generates: ➢ ➢ ➢ ➢

    ➢ jni-config.json proxy-config.json reflect-config.json resource-config.json serialization-config.json Handled by io.quarkus.deployment.steps.NativeImage*ConfigStep Based on present build items io.quarkus.deployment.builditem.nativeimage.*BuildItem • Typically provided by the core framework and its extensions
  37. Example substitutions @Substitute @TargetClass(className = "io.netty.handler.codec.compression.ZstdConstants", onlyWith = IsZstdAbsent.class) final

    class Target_io_netty_handler_codec_compression_ZstdConstants { // The constants make <clinit> calls to com.github.luben.zstd.Zstd // so we cut links with that substitution. static final int DEFAULT_COMPRESSION_LEVEL = 0; static final int MIN_COMPRESSION_LEVEL = 0; static final int MAX_COMPRESSION_LEVEL = 0; static final int DEFAULT_MAX_ENCODE_SIZE = 0; static final int DEFAULT_BLOCK_SIZE = 0; }
  38. Example substitutions @TargetClass(className = "io.netty.handler.ssl.OpenSsl") final class Target_io_netty_handler_ssl_OpenSsl { @Alias

    @RecomputeFieldValue(kind = Kind.FromAlias) private static Throwable UNAVAILABILITY_CAUSE = new RuntimeException("OpenSsl unsupported on Quarkus"); ... @Substitute public static boolean isAvailable() { return false; } }
  39. Generated substitution g.class_(LOGMANAGER_LOGGER_CLASS_NAME, cc -> { cc.final_(); This this_ =

    cc.this_(); cc.addAnnotation(TargetClass.class, ac -> ac.add(TargetClass::value, org.jboss.logmanager.Logger.class)); FieldDesc name = cc.field("name", fc -> { fc.setType(String.class); fc.addAnnotation(Alias.class); }); FieldDesc loggerNode = cc.field("loggerNode", fc -> { fc.setType(ClassDesc.of(LOGGER_NODE_CLASS_NAME)); fc.addAnnotation(Alias.class); }); cc.method("isLoggable", mc -> { mc.returning(boolean.class); mc.addAnnotation(Substitute.class); ParamVar level = mc.parameter("level", Level.class); mc.body(b0 -> { var levelInt = b0.localVar("levelInt", b0.invokeVirtual(MethodDesc.of(Level.class, "intValue", int.class), level)); b0.ifNot(isMinLevelEnabledFunction.apply(b0, this_.field(name), levelInt), BlockCreator::returnFalse); b0.return_(b0.invokeVirtual( ClassMethodDesc.of(ClassDesc.of(LOGGER_NODE_CLASS_NAME), "isLoggableLevel", boolean.class, int.class), this_.field(loggerNode), levelInt)); }); }); });
  40. Feature Generation ➢ Handled by io.quarkus.deployment.steps.NativeImageFeatureStep ➢ Uses Gizmo2 for

    bytecode generation ➢ Necessary to register/configure anything that’s not possible through JSON config
  41. Generating GraalVM Feature with Gizmo static final String GRAAL_FEATURE =

    "io.quarkus.runner.Feature"; static final MethodDesc BUILD_TIME_INITIALIZATION = MethodDesc.of(RuntimeClassInitialization.class, "initializeAtBuildTime", void.class, String[].class); g.class_(GRAAL_FEATURE, cc -> { cc.implements_(Feature.class); cc.defaultConstructor(); cc.method("getDescription", mc -> { mc.returning(String.class); mc.body(b0 -> b0.return_(Const.of("Auto-generated class by Quarkus from the existing extensions"))); }); cc.method("duringSetup", mc -> { mc.parameter("access", Feature.DuringSetupAccess.class); mc.body(b0 -> { // empty string means initialize everything b0.invokeStatic(BUILD_TIME_INITIALIZATION, b0.newArray(String.class, Const.of(""))); b0.return_(); }); });
  42. Some relevant Netty details ➢ Supports Java 8 ◦ ➢

    ➢ Remember that Quarkus 4 requires Java 21 Uses a lot of reflection to adapt ◦ Slows down the max throughput ◦ But also affects startup time We can transform all this at build time ◦ Using the same principles we’ve already seen
  43. DefaultChannelId example public final class DefaultChannelId implements ChannelId { ...

    static int processHandlePid(ClassLoader loader) { // pid is positive on unix, non{-1,0} on windows int nilValue = -1; if (PlatformDependent.javaVersion() >= 9) { Long pid; try { Class<?> processHandleImplType = Class.forName("java.lang.ProcessHandle", true, loader); Method processHandleCurrent = processHandleImplType.getMethod("current"); Object processHandleInstance = processHandleCurrent.invoke(null); Method processHandlePid = processHandleImplType.getMethod("pid"); pid = (Long) processHandlePid.invoke(processHandleInstance); } catch (Exception e) { logger.debug("Could not invoke ProcessHandle.current().pid();", e); return nilValue; } if (pid > Integer.MAX_VALUE || pid < Integer.MIN_VALUE) { throw new IllegalStateException("Current process ID exceeds int range: " + pid); } return pid.intValue(); } return nilValue; } Reflective call }
  44. DefaultChannelId example public final class DefaultChannelId implements ChannelId { ...

    static int processHandlePid(ClassLoader cl) { int result; try { long pid = ProcessHandle.current().pid(); if (Long.compare(pid, 2147483647L) <= 0) { result = (int)pid; } else { result = -1; } } catch (Exception e) { logger.debug("Could not invoke ProcessHandle.current().pid();", e); result = -1; } return result; } } Normal Java code
  45. DefaultChannelId example BuildItem that represents transformations @BuildStep void transformDefaultChannelId(BuildProducer<BytecodeTransformerBuildItem> bytecodeTransformers)

    { String className = DefaultChannelId.class.getName(); bytecodeTransformers.produce( new BytecodeTransformerBuildItem.Builder() .setClassToTransform(className) .setCacheable(true) .setVisitorFunction((s, classVisitor) -> { ClassVisitor updateBytecodeVersion = new ClassVisitor(Gizmo.ASM_API_VERSION, classVisitor) { @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { // bump bytecode version to at least 52 as we need it to be able to call // static methods on interfaces with Gizmo super.visit(Math.max(version, 52), access, name, signature, superName, interfaces); } }; ClassTransformer transformer = new ClassTransformer(className); MethodDescriptor methodDescriptor = MethodDescriptor.ofMethod( className, "processHandlePid", int.class, ClassLoader.class); ... } Lot’s of boilerplate We only transform one method
  46. Quarkus REST Client @Path("/persons ") @RequestScoped public class PersonResource {

    @GET @Produces (MediaType.APPLICATION_JSON) @Path("/get") public Person get() { return new Person( "John", "Doe", 30); } } public class PersonResource$quarkusrestinvoker$get_f2ea5 implements EndpointInvoker { public Object invoke(Object var1, Object[] var2) { return ((PersonResource)var1).get(); } }
  47. Quarkus LangChain4j @RegisterAiService(tools = EmailService.class) public interface MyAiService { @SystemMessage("You

    are a professional poet") @UserMessage(""" Write a single poem about {topic}. The poem should be {lines} lines long and your response should only include them poem itself, nothing else. Then send this poem by email. Your response should include the poem. """) String writeAPoem(String topic, int lines); }
  48. Quarkus LangChain4j @RegisterAiService(tools = EmailService.class) public interface MyAiService { Who

    implements it? @SystemMessage("You are a professional poet") @UserMessage(""" Write a single poem about {topic}. The poem should be {lines} lines long and your response should only include them poem itself, nothing else. Then send this poem by email. Your response should include the poem. """) String writeAPoem(String topic, int lines); }
  49. Quarkus LangChain4j @RegisterAiService(tools = EmailService.class) public interface MyAiService { Who

    implements it? @SystemMessage("You are a professional poet") @UserMessage(""" Write a single poem about {topic}. The poem should be {lines} lines long and your response should only include them poem itself, nothing else. Then send this poem by email. Your response should include the poem. """) String writeAPoem(String topic, int lines); } @RequestScoped @DefaultBean public class MyAiService$$QuarkusImpl implements MyAiService, ChatMemoryRemovable, ChatMemoryAccess, AutoCloseable { private final QuarkusAiServiceContext context; @Inject public MyAiService$$QuarkusImpl(@QuarkusAiServiceContextQualifier ("org.quarkus.metaprogramming.MyAiService") QuarkusAiServiceContext var1) { this.context = var1; } public String writeAPoem(String var1, int var2) { AiServiceMethodCreateInfo var7 = AiServicesRecorder.getAiServiceMethodCreateInfo( "org.quarkus.metaprogramming.MyAiService", "writeAPoem([java.lang.String, int])" ); Object var8 = Arc.container().instance(AiServiceMethodImplementationSupport.class, new Annotation[0]).get(); Input var9 = new Input(this.context, var7, new Object[]{var1, var2}); return (String)((AiServiceMethodImplementationSupport)var8).implement(var9); } }
  50. ➢ These are some of the most complex examples ➢

    They use the same principles we’ve outlined - just at an level
  51. Jackson default serialization is reflection-based @Path("/persons ") @RequestScoped public class

    PersonResource { @GET @Produces (MediaType.APPLICATION_JSON) @Path("/get") public Person get() { return new Person( "John", "Doe", public class Person { private String firstName; 30); } private String lastName; } private int age; serialized by Jackson through reflection public Person(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } { "firstName":"John", "lastName":"Doe", "age":30 [ ... omitted getters and setters ... ] } }
  52. Overriding Jackson serialization public class PersonSerializer extends StdSerializer { public

    PersonSerializer() { super(Person. class); } public void serialize(Object obj, JsonGenerator jsonGen, SerializerProvider serProv) throws IOException { Person person = (Person)obj; jsonGen.writeStartObject(); jsonGen.writeStringField( "firstName" , person.getFirstName()); jsonGen.writeStringField( "lastName" , person.getLastName()); jsonGen.writeNumberField( "age", person.getAge()); jsonGen.writeEndObject(); } } SimpleModule module = new SimpleModule(); module.addSerializer(Person. class, new PersonSerializer()); ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(module);
  53. Overriding Jackson serialization public class PersonSerializer extends StdSerializer { public

    PersonSerializer() { super(Person. class); } public void serialize(Object obj, JsonGenerator jsonGen, SerializerProvider serProv) throws IOException { Person person = (Person)obj; jsonGen.writeStartObject(); jsonGen.writeStringField( "firstName" , person.getFirstName()); jsonGen.writeStringField( "lastName" , person.getLastName()); jsonGen.writeNumberField( "age", person.getAge()); jsonGen.writeEndObject(); } } No reflection SimpleModule module = new SimpleModule(); module.addSerializer(Person. class, new PersonSerializer()); ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(module);
  54. Actual generated Jackson serializer public class PersonSerializer extends StdSerializer {

    public PersonSerializer() { super(Person. class); } public void serialize(Object obj, JsonGenerator jsonGen, SerializerProvider serProv) throws IOException { Person person = (Person)obj; SerializationInclude serInc = SerializationInclude.decode(obj, serProv); PropertyNamingStrategy naming = serProv.getConfig().getPropertyNamingStrategy(); String firstName = person.getFirstName(); if (serInc.shouldSerialize(firstName)) { JacksonMapperUtil.writeFieldName(jsonGen, naming, "firstName"); jsonGen.writeString(firstName); } int age = person.getAge(); if (serInc.shouldSerialize(age)) { JacksonMapperUtil.writeFieldName(jsonGen, naming, "age"); jsonGen.writeNumber(age); } } }
  55. Easy, isn’t it? Off by default, can be enabled with:

    quarkus.rest.jackson.optimization.enable-reflection-free-serializers=true
  56. Measuring the actual cost of reflection public class ExtendedPerson {

    private String firstName; private String lastName; private int age; private Address address; private Car car; [ ... omitted getters and setters ... ] } public class Address { private String city; private String street; } public class Car { private String brand; private String model; }
  57. Measuring the actual cost of reflection public class Address {

    private String city; private String street; } public class ExtendedPerson { private String firstName; private String lastName; private int age; private Address address; private Car car; public class Car { private String brand; private String model; } [ ... omitted getters and setters ... ] } @Path("/persons ") @RequestScoped public class PersonResource { To stress JSON serialization returns a list of 20 identical ExtendedPerson instances private static final ExtendedPerson EXTENDED_DEFAULT_PERSON = new ExtendedPerson("John", "Doe", 30, new Address("Gotham", "123 Main St"), new Car("Toyota", "Camry")); private static final List<ExtendedPerson> EXTENDED_DEFAULT_PERSONS = Collections.nCopies(20, EXTENDED_DEFAULT_PERSON); @GET @Path("/get-all-extended" ) @Produces (MediaType.APPLICATION_JSON) public List<ExtendedPerson> getAllExtended() { return EXTENDED_DEFAULT_PERSONS; }
  58. Measuring the actual cost of reflection Benchmark environment: ➢ OpenJDK

    25 ➢ Quarkus 4 (main branch) ➢ Jackson 3 ➢ Hyperfoil 0.29.3 (load generator)
  59. Measuring the actual cost of reflection Benchmark environment: ➢ OpenJDK

    25 ➢ Quarkus 4 (main branch) ➢ Jackson 3 ➢ Hyperfoil 0.29.3 (load generator) req/sec Jacksons reflection-based serialization 144,019 Generated reflection-free serializers 140,073
  60. Measuring the actual cost of reflection Benchmark environment: ➢ OpenJDK

    25 ➢ Quarkus 4 (main branch) ➢ Jackson 3 ➢ Hyperfoil 0.29.3 (load generator) Is reflection faster? What’s happening here? req/sec Jacksons reflection-based serialization 144,019 Generated reflection-free serializers 140,073
  61. ➢ Cost of reflection is totally different between JDK 17

    and 25 ➢ Jackson also changed how it uses reflection from 2.x to 3.x
  62. Jackson’s hot loop path: what can go wrong?! private final

    void _writeStringSegment(String text, int offset, int len) { len += offset; int outputPtr = _outputTail; final byte[] outputBuffer = _outputBuffer; final int[] escCodes = _outputEscapes; while (offset < len) { int ch = text.charAt(offset); if (ch > 0x7F || escCodes[ch] != 0) { break; } outputBuffer[outputPtr++] = (byte) ch; ++offset; } [ ... omitted cold path code ... ] } Just a loop, per char: ➢ String::charAt ➢ ASCII fast path via lookup table ➢ a store
  63. Inlining differences What it is Inlining? ➢ “Copy” a callee

    method body into the caller It shouldn’t be a good thing?! ➢ reflection serializers have a single hot loop copy, inlined in a small method ➢ the reflection-free serializer inlined copies land into big methods ◦ the bigger caller’s context affects negatively the hot loop copies :”(
  64. Inlined hot loop’s register spilling What it is register spilling?

    ➢ CPU registers holds the state of your method, but if too big, the JIT spills them into wider registers, or memory :”( The reflection-free serializers hot loops share the bigger caller’s state, causing severe register spilling, affecting performance!
  65. None can fix it: It’s a joke, right?! No panic,

    can be mitigated: ➢ the hot loop has been compiled into a single-byte-per-iteration loop, spilling N times per each char ➢ the JIT can do better using loop unrolling What it is Loop Unrolling? “copy” the original loop body N times into a bigger one ➢ The spill happen per loop iteration e.g. unrolling 4x reduce the spill by a 4x factor! ➢ How we can “help” the JIT to do it?
  66. Problem 1: String::charAt Profile Pollution ➢ Jackson 3.x iterates a

    single non-ASCII String for RFC1123 Date formatting ➢ every String::charAt is poisoned by the presence of a call to StringUTF16::charAt preventing Loop Unrolling
  67. Problem 2: JIT Unrolling budget if (text.coder != LATIN1) deoptimize();

    byte[] value = text.value; int escLen = escCodes.length; int limit = len - 1; while (offset < limit) { // FIRST CHAR int c0 = value[offset] & 0xFF; if (c0 > 0x7F) break; if (c0 >= escLen) throw ..; if (escCodes[c0] != 0) break; outputBuffer[outputPtr] = (byte) c0; The table lookups (and bound-checks) consume the “unrolling budget” of the JIT :”( // SECOND CHAR int c1 = value[offset + 1] & 0xFF; if (c1 > 0x7F) { offset += 1; outputPtr += 1; break; } if (c1 >= escLen) throw ..; if (escCodes[c1] != 0) { offset += 1; outputPtr += 1; break; } outputBuffer[outputPtr + 1] = (byte) c1; offset += 2; outputPtr += 2; }
  68. If unrolling won’t work, unroll MORE if (text.coder != LATIN1)

    deoptimize(); byte[] value = text.value; int limit = len - 3; while (offset < limit) { Replacing the table lookup with a “simpler” check allows the JIT to unroll the loop 4 times! // FIRST CHAR int c0 = value[offset] & 0xFF; if (c0 < 0x20 || c0 > 0x7F) break; if (c0 == '"') break; if (c0 == '\\') break; outputBuffer[outputPtr] = (byte) c0; // SECOND CHAR int c1 = value[offset + 1] & 0xFF; if (c1 < 0x20 || c1 > 0x7F) { offset += 1; outputPtr += 1; break; } if (c1 == '"') { offset += 1; outputPtr += 1; break; } if (c1 == '\\') { offset += 1; outputPtr += 1; break; } outputBuffer[outputPtr + 1] = (byte) c1; // THIRD CHAR int c2 = value[offset + 2] & 0xFF; if (c2 < 0x20 || c2 > 0x7F) { offset += 2; outputPtr += 2; break; } if (c2 == '"') { offset += 2; outputPtr += 2; break; } if (c2 == '\\') { offset += 2; outputPtr += 2; break; } outputBuffer[outputPtr + 2] = (byte) c2; // FORTH CHAR int c3 = value[offset + 3] & 0xFF; if (c3 < 0x20 || c3 > 0x7F) { offset += 3; outputPtr += 3; break; } if (c3 == '"') { offset += 3; outputPtr += 3; break; } if (c3 == '\\') { offset += 3; outputPtr += 3; break; } outputBuffer[outputPtr + 3] = (byte) c3; offset += 4; outputPtr += 4; } // remainder
  69. Does it help? Before fixes After fixes req/sec req/sec diff

    Jacksons reflection-based serialization 144,019 147,115 +2.2% Generated reflection-free serializers 140,073 151,154 +7.9%
  70. Lessons learned ➢ Performance is a moving target: always reevaluate

    your decisions over time ➢ Generating more “human-readable” (byte)code doesn’t mean that the compiler will like it ➢ Not all JIT decisions (inlining) produce better optimized code :”( ➢ NO PANIC: we (Quarkus team) will handle it for you including fixing the ecosystem, if necessary!