How it works

Champaca follows OpenLDK, Anthony Green's Java runtime for SBCL: translate JVM bytecode into Lisp source and let the Lisp compiler turn it into machine code. Here the translator is jolt Clojure, the Lisp is Chez Scheme, and jolt.scheme/eval-string compiles the generated text natively inside the running jolt process, with no interpreter and no second runtime. A Java object is a Chez record in jolt's own heap, and a Java method is a Chez procedure.

From class file to procedure

  1. Find the bytes. classpath.clj looks for pkg/Name.class on the user classpath (directories and jars), then in the JDK module that owns the package (jars-and-modules). A class loader can also hand over the bytes itself. ClassLoader.defineClass, Lookup.defineClass and defineHiddenClass, Proxy and Unsafe.defineClass0 all end in one native, which passes them to loader.clj, to take the same path. A named class that one of the program's own loaders defines is cached by its bytes, as a jar's classes are (performance). A hidden class, a proxy and anything the built-in loaders define aren't cached. Such a class keeps its defining loader (getClassLoader, findLoadedClass). A hidden one is named Name/0x<n> as HotSpot names it, and its own constant pool is renamed to match, so two hidden classes from the same bytes don't share compiled methods.
  2. Parse. classfile.clj reads the constant pool, fields (with ConstantValue), methods and their Code, BootstrapMethods, and the attributes reflection needs: signatures, annotations and type annotations, inner-class entries, record components, parameter names and declared exceptions, and the nest and PermittedSubclasses attributes.
  3. Register. loader.clj loads the superclass and interfaces first. Then link.clj makes HotSpot's checks of the definition, and any of these fails with HotSpot's error before anything registers: a final superclass, a class among the interfaces, an override of a final method, a class among its own supertypes, or a subclass a sealed type doesn't permit. After that link.clj lays out the instance fields into slots and builds the vtable, and loader.clj registers the class with the runtime. Every method gets a stub as its top-level definition, named Class.name(descriptor). When no class in the chain declares a method, an interface's default fills in, picked the way the JVM picks: the most specific of the superinterfaces that declare it. So B's default wins over A's when B extends A, whatever order a class lists them in. If two unrelated interfaces both supply a default and the class overrides neither, as only a class file compiled against older interfaces can, the entry is a stub that throws IncompatibleClassChangeError at every call, with the JVM's message naming the interfaces.
  4. Compile on first call. The stub's first call translates that one method with codegen.clj, hands the text to Chez, and replaces its own definition with the compiled procedure, which later calls go straight to. A method that's never called is never translated.

Classes load lazily too: a class is found and parsed the first time something names it, and its static initializer runs on first active use, superclass first, as the JVM specifies.

The shape of a translated method

Each local becomes a Scheme variable l0, l1, ... and each operand-stack slot becomes s0, s1, .... The translator tracks the stack's types at compile time. The method's code splits into basic blocks, each a letrec-bound procedure that takes the method's live locals and whatever the stack holds on entry. A jump is a tail call passing the current values, which Chez compiles to a plain jump.

Inside a block nothing is assigned. A store or a push binds a fresh variable in a let, shadowing the old one, because Chez keeps an assigned variable in a heap box even when no closure captures it, while a let-bound one can live in a register. A let closes as soon as its value is dead, so a method reads as a sequence of statements instead of one deep nest. Hello.main is a single block:

(set-top-level-value! '|Hello.main([Ljava/lang/String;)V|
  (let ((|Hello.main([Ljava/lang/String;)V|
         (let ((ic0 (cons #f #f)) (ic1 (cons #f #f)))
    (lambda (l0)
      (let ((npe-tag '|Hello.main([Ljava/lang/String;)V|))
        (letrec ((|Hello.main([Ljava/lang/String;)V#B0|
                  (lambda (l0)
                    (begin (jinit-ic ic0 '|java/lang/System| (begin '(jpc . 0) npe-tag) 0)
                           (let ((s0 |java/lang/System:out|))
                             (let ((s1 (jstring-lit "Hello, World!")))
                               ((begin '(jpc . 5)
                                       (jvirt-ic ic1 s0 '|println(Ljava/lang/String;)V| (begin '(jpc . 5) npe-tag) 5))
                                s0 s1)))
                           (void)))))
          (|Hello.main([Ljava/lang/String;)V#B0| l0))))))

The two arguments to println are scoped to that one call, and the return follows it. Closing the lets early keeps a long straight-line method quick to compile. Chez's expander walks the enclosing bindings for every variable it resolves, and the module system's generated descriptor builder, 21 KB of bytecode, nests 12,000 lets deep when they stay open, which took the expander 4.6 s. Closed as it goes, it's 53 deep. For the same reason, a method over 4,000 bytes of bytecode keeps its inline-cache cells in one vector instead of a variable each.

getstatic became an initialization check plus a top-level variable. The check goes through its own cell, ic0, which holds the class once it's initialized, so after the first time it costs one car. invokevirtual became a call through jvirt-ic, an inline cache. ic1 remembers the receiver class this call site saw last and the procedure it found for it, so a repeat call costs one eq? test, and a new class looks the method up in that class's vtable, keyed by interned symbols.

A vtable keyed by name and descriptor picks the nearest method of that name. That's right for a public or protected method, which every method of the same name and descriptor overrides. A package-private method is overridden only from its own run-time package (the package and its loader), or through a method that overrides it (JVMS 5.4.5). So a call whose method resolves to a package-private one goes through jvirt-pp-ic. Its cache fills by walking the classes from just below the method's down to the receiver's, keeping each one that overrides as an anchor for those below it. Reflection and method handles select the same way, and call a private method as itself. A static call is a call to the target's top-level name.

new, instanceof and checkcast get cells too. new keeps the initialized class. The two type tests keep the last class they saw with its answer, since the full test compares class names up the hierarchy. When a site meets another class, the answer comes from a table that class keeps, one entry per site it failed to match, so the walk happens once per class and site.

A store into a reference array (aastore) gets a cell as well, and checks the value against the array's element type as the JVM does, throwing ArrayStoreException. The cell remembers the element type it last stored into and up to two kinds of value that passed (an object's class, or an array value's element type), so a repeat costs a few eq? tests. That works across arrays because each element descriptor is interned: every String[] shares one descriptor string, and a method interns the ones it allocates with once, when it's defined. An Object[] takes any value, so its cell skips the value test.

Some calls skip dispatch. An invokevirtual that can only reach one method, because the method is private or final or the class is final like String, becomes a direct call after a null check. So does an invokeinterface of a private interface method, which no class can override and which is how javac calls one from a default method. A small leaf method called directly is compiled into its caller as a let-bound procedure, which Chez calls without a lookup or inlines. Here a leaf has no calls of its own but Object's empty constructor, no loops, no exception handlers, and at most 40 bytes of bytecode. Only a callee in the caller's own class or in the JDK qualifies, because compiled code is cached per class and a copy of another class's code would go stale when that class changes. Which JDK methods get inlined also depends on which classes the run had already parsed. Every choice gives the same results, just at different speeds.

A Scheme handler around a block also covers every block that block tail-calls into, which is wrong for Java's exception ranges, so a handler can't just wrap a block. The blocks of a method with an exception table fall into regions instead, one for each list of handlers that covers them, and jumps inside a region are tail calls as above. Entering a region from outside goes through an entry procedure that pushes one handler for the whole region onto Chez's handler stack (jtry in runtime.ss). A jump that leaves a region returns a thunk instead of calling its target, and a small trampoline loop calls whatever comes back, so the region's handler is gone before the target runs. A loop inside a try therefore runs as fast as one outside it. Only entering the try costs anything.

In a method without handlers, an innermost loop over arrays held in locals it never assigns gets a second copy of its blocks (#F<pc>). It's entered through #V<header> once each array checks out, and it reads each array's vector and length taken before the loop (performance).

A handler gets the exception and each local's value from when its region was entered. That value is stale only for a local the region assigns and the handler then reads, and a liveness pass over the blocks finds those. They stay method-wide variables assigned with set!, and everything else stays in lets. A Java throw is Scheme's raise of the exception object itself. The handler checks the raised object's class against the exception table's entries in order, and when none matches it passes the exception on to the next handler from where it was raised, so nothing is unwound until some handler takes it.

Class files from before Java 6 can call subroutines: jsr jumps into one and pushes a return address, and ret jumps back to the address a local holds. javac 1.4 and earlier compiled every finally that way, so old jars such as commons-lang 2.4 still carry them. The return address is the next instruction's pc, a fixnum. ret becomes a case on its local over the pcs that can follow the jsrs reaching it, each arm an ordinary jump to that block. The decoder finds those pcs by walking each subroutine from its start and counting a jsr inside it as coming back to its next instruction, so a nested subroutine's ret isn't taken for the outer one's (test/java/JsrCheck.java, from test/gen/JsrGen.java).

A few instructions are compiled specially. invokedynamic with LambdaMetafactory or ObjectMethods (lambdas and records), string concatenation's makeConcatWithConstants, and the typeSwitch and enumSwitch behind pattern switches are compiled directly, with no bootstrap method run. VarHandle access modes become calls into the runtime's own VarHandle table, and invokeExact and invoke on a method handle into its handle table (Method handles).

Values

The comment at the top of resources/champaca/runtime.ss has the full mapping. The int, short, byte, char and boolean types are fixnums, with booleans as 0 and 1 as in bytecode. A long is an exact integer wrapped to 64 bits, although inside a method a long that long operations pass between them is two fixnums, its 32-bit halves (performance). float and double are flonums, and a float rounds through IEEE single precision. null is #f. An object is a record holding its class, a slot vector and its monitor once something locks it. An array is a record holding its element descriptor and a Scheme vector. A java.lang.String is an ordinary object with the JDK's own fields, a byte[] and a coder.

Natives

The JDK leaves its native methods to the JVM, and champaca writes them in Scheme: natives.ss for the general ones (System.arraycopy, time zones and so on), memory.ss for Unsafe and memory access, threads.ss for threads and monitors, management.ss for the management beans, loaders.ss for modules and class loaders, varhandles.ss for VarHandles, reflect.ss for reflection, files.ss for files, net.ss for sockets, spawn.ss for child processes and foreign.ss for java.lang.foreign's calls into C.

Where the JDK's own code would need machinery champaca doesn't have, a Java method is overridden outright. Serialization's constructor is one. The JDK builds the constructor that allocates a serializable class and runs its first non-serializable superclass's no-arg constructor from a method handle on a MemberName, which needs MethodHandleNatives. reflect.ss hands the JDK its own native constructor accessor instead, with a side table naming the class to allocate.

One native needs the generated code's help: a NullPointerException the JVM raises itself gets a message such as Cannot invoke "String.length()" because "<local1>" is null, which HotSpot works out from the failing instruction. Each null check in generated code passes its method and bytecode index to the runtime, as constants in the failing branch only, and getExtendedNPEMessage hands them to npe.clj. That follows JEP 358. A data-flow pass over the method records which instruction pushed each value on the operand stack, and the message walks back from the failing instruction to what pushed the null. Since it re-reads the class with its local variable names, the work happens only when a program asks for the message.

The file natives call libc through Chez's foreign interface (foreign-procedure), on real file descriptors, the way the JDK's C code does. The JDK hands its NIO natives paths as addresses of C strings it has copied into native memory with Unsafe, so Unsafe's memory calls work on real malloc'd memory, and a path's address goes to libc unchanged. The console is the exception: System.in, System.out and System.err keep going through Chez's own ports, which jolt shares, so Java's and jolt's output stay in order. A channel writing to file descriptor 1 or 2 calls libc directly, after flushing those ports. java.util.zip calls zlib the same way.

Sockets go the same way (net.ss). sun.nio.ch.Net, the socket dispatcher and Inet4AddressImpl call socket, connect, accept, poll, getaddrinfo and the rest through the foreign interface, on real descriptors. So java.net.Socket runs on the JDK's own NioSocketImpl, with a socket it switches to non-blocking mode and polls when a timeout is set. The calls that can wait give the GIL up, as the file natives do.

Closing a socket from another thread wakes a thread blocked on it as the JDK does: the socket's descriptor is pointed at a dead one and the blocked native thread is signalled (pthread_kill, a handler installed without SA_RESTART), so its call returns EINTR and the Java code throws Socket closed. A peer that went away is an exception (Broken pipe) and doesn't end the process. Chez's runtime ignores SIGPIPE, and for a host that doesn't, every socket also gets SO_NOSIGPIPE on macOS and writes use send with MSG_NOSIGNAL on Linux.

IPv6 is there when an AF_INET6 socket can be made (on Linux, when /proc/net/if_inet6 lists an address) and java.net.preferIPv4Stack isn't set, as the JDK's own ipv6_available decides. The JDK then makes every socket AF_INET6 with IPV6_V6ONLY off and resolves through Inet6AddressImpl. The natives write an IPv4 address as ::ffff:a.b.c.d (0.0.0.0 as ::, the dual-stack wildcard) and read a mapped one back as an Inet4Address, as NET_SockaddrToInetAddress does.

Selectors are the JDK's own, KQueueSelectorImpl on macOS and EPollSelectorImpl on Linux. They keep their event arrays in Unsafe memory, so the natives only make the kqueue/kevent and epoll_* calls (a wait that can block gives the GIL up) and create the pipe (macOS) or eventfd (Linux) that wakeup writes to. A Pipe's ends write through the socket dispatcher. On Linux a socket write is a send, so a pipe that send refuses falls back to write.

DatagramSocket runs on DatagramChannelImpl, whose natives are recvfrom, sendto and, once connected, recv, send, readv and writev. Each moves one datagram, never one call per buffer, which would split it. The sender's address goes into the JDK's own NativeSocketAddress, a sockaddr in Unsafe memory, so those natives only give its layout. A datagram socket gets the JDK's setup: on macOS a send buffer of at least 65507 bytes, since the kernel caps a datagram at the buffer's size (9216 by default). NetworkInterface is built from getifaddrs (netif.ss), with flags and the MTU from ioctl as the JDK reads them.

Child processes skip the JDK's launcher (jspawnhelper or vfork, a small C program that runs in the child before exec). spawn.ss does the same setup with posix_spawn's file actions: the pipes or files onto descriptors 0, 1 and 2, the working directory, and every other descriptor closed. macOS closes them with POSIX_SPAWN_CLOEXEC_DEFAULT and glibc 2.34 and later with addclosefrom_np. The pipes are close-on-exec as well, which is all glibc 2.33 gets. PATH is searched the way the JDK's JDK_execvpe does, with each candidate checked by access first, and a file the kernel can't run goes to /bin/sh. Chez ignores SIGPIPE, and an ignored signal stays ignored across exec, so the child's SIGPIPE is reset to the default a JVM's child gets. The JDK's reaper thread then waits in waitpid with the GIL given up.

A downcall through java.lang.foreign replaces only what HotSpot generates and keeps the JDK's Java. ForeignLinkerSupport says the linker is supported, so Linker.nativeLinker() is the platform's own (MacOsAArch64Linker, LinuxAArch64Linker or SysVx64Linker). Its downcallHandle runs as written: it checks the layouts and options, adds the capture-segment check and the allocator, and binds the symbol.

HotSpot's arrangeDowncall turns a function descriptor into register moves for a stub it compiles. Here foreign.ss runs the JDK's own CallArranger.getBindings for its checks alone, then returns a method handle. That handle acquires each segment's session for the call, so a closed arena or another thread's fails as on the JVM. It unboxes the arguments and calls the address through a foreign-procedure built once per signature. A struct passed or returned by value becomes a generated define-ftype of the same shape, which Chez passes by the platform's rules. A call that isn't critical gives the global lock up while it runs in C, when another Java thread exists to want it.

An upcall goes the other way and keeps the JDK's Java too. Linker.upcallStub checks the target, and arrangeUpcall with UpcallLinker builds the binding handle that turns register values into the target's arguments and its result back. Only the stub is HotSpot's. foreign.ss makes it a Chez foreign-callable of the binding handle's primitive types, with its code object locked so the collector leaves it in place, and frees it when its arena closes.

Stack arguments need care, because given only primitive types Chez would place such an argument in a register that's still free. So when an argument reaches the stack, the callable takes every register C passes arguments in, in the ABI's order, and then the stack as 8-byte words, and reads each value where the JDK's calling sequence says it is. Two registers are left out: x8, the in-memory result's on arm64, and rax, a variadic call's vector count on x86_64. A struct result comes back through a buffer of return registers, which the stub copies into a struct of one 8-byte field per register. Returned in memory, it comes through the pointer Chez hands the callable.

The stub takes the global lock for the call. A thread champaca hasn't seen, one C started, is attached as a daemon java.lang.Thread, kept for that thread's life by pthread_self, and ended by a pthread_key_create destructor when the thread exits. Libraries load through dlopen, behind the JDK's own NativeLibraries and RawNativeLibraries, and symbols resolve through dlsym, so the default lookup is the JDK's libsyslookup, as on HotSpot.

Resources aren't native. The JDK finds its own through the JVM's image reader, which champaca doesn't run, so getResourceAsStream, getResource and getResources are overridden to ask the loader, which looks in the JDK's runtime image first and then the classpath, the reverse of the order it uses for classes. A classpath resource gets a file: or jar: URL, which the JDK's own URL handlers then open.

Booting the JDK

init! runs the JDK's own startup code in the order HotSpot does, because the JDK depends on that order:

  1. Load the runtime, natives and reflection support.
  2. Make sure Object, String and Class are registered.
  3. Run String.<clinit> first. It sets COMPACT_STRINGS, and string hashes computed before that take the UTF-16 path, so the system property map would be keyed by the wrong hashes.
  4. Run System.<clinit>, then build the main thread and its thread group.
  5. Run Reference.<clinit> and AccessibleObject.<clinit>, which install the shared-secrets hooks initPhase1 reaches for.
  6. Run System.initPhase1(), which sets up properties, System.out and System.err.
  7. Run System.initPhase2(), which builds the boot layer. ModuleBootstrap takes the module graph jlink resolved into the image (SystemModules$default, so no resolution runs), defines each module to the VM through Module.defineModule0 and registers its service providers. A warm start gets that module graph from the startup cache instead, as HotSpot's class data sharing gives it: CDS.initializeFromArchive hands ArchivedModuleGraph the finder and configuration a cold boot archived (performance), and SystemModules$default doesn't run. champaca's defineModule0 records the module's packages, and from then on a Class mirror's getModule() is its package's module, as HotSpot patches the classes it loaded before java.base existed. A class on your classpath is in the application loader's unnamed module and reports that loader, as on the JVM, and a JDK class reports the loader its module is defined to. Both are set on the mirror when it's made. define-module! patches them into mirrors of a module's packages made before the module was. No class of yours is mirrored during the boot, so none needs the application loader patched in.
  8. Run System.initPhase3(), which makes the application class loader the system class loader and the main thread's context class loader. The built-in loaders (boot, platform, application) all answer from champaca's one class table: their loadClassOrNull, module-qualified findClass and findResourceAsStream look there. So no class is ever defined from bytes through them, where the JDK's own path would read the image through jrt: and ClassLoader.defineClass.

After that, System.out.println goes through the JDK's own PrintStream, StreamEncoder and UTF-8 encoder down to a native FileOutputStream.writeBytes. The startup cache (performance) saves the result of all this, so a warm start loads it rather than repeating it.

Threads

Thread.start0 forks a Chez thread, which runs the thread's run() and then does what HotSpot does on the way out: an uncaught exception goes to dispatchUncaughtException (an exception the handler throws is reported in HotSpot's one line and dispatched no further), then Thread.exit(), then the thread is marked terminated and its monitor notified, which is what join waits on.

One global lock (the GIL, in threads.ss) lets one thread at a time run Java code, so the class table, the JIT and the runtime's own hashtables need no locks of their own. A thread gives it up whenever it blocks: entering a monitor another thread holds, wait, sleep, LockSupport.park, a class another thread is still initializing, or I/O that waits on something outside the process (files.ss: a read from standard input, a pipe or a terminal, a write to a full pipe, a FIFO's open, a blocking file lock).

Those I/O calls go through __collect_safe libc entries, so a thread waiting in C doesn't hold up the collector for every other thread. Each has a buffer of its own, apart from the shared scratch memory the lock protects. Standard input is the exception. It's read through one Chez port for the whole process, whose reads don't stall the collector either, with a mutex of its own so two readers take turns. Reads and writes on regular files keep the lock, since they don't wait, and a stat tells a FIFO's open from any other. Generated code also checks at every backward branch whether some thread is waiting for the lock, and after a thousand such checks the running thread lets it go first.

A monitor has an owner, a count and a wait set. An object keeps it in a field of its own record, which costs no memory since Chez rounds a record up to the same 32 bytes either way, and an array's sits in a weak table keyed by the array. The same field holds the object's identity hash until the object first needs a monitor, which then carries the hash, as HotSpot keeps the hash in the object's header. An array's hash sits in a weak table too. Only the thread holding the GIL runs Java code, so a monitor's owner is compared against the GIL's owner.

As the lock changes hands, the runtime saves and restores what it keeps in globals for the running thread (the current java.lang.Thread, the CHAMPACA_BT shadow stack). A thread also hands its stack over when it lets go. It waits inside a call/1cc whose continuation it leaves in its state, and takes it back only once it holds the lock again, so it can't return into those frames while another thread reads them. Thread.getStackTrace() on that thread, and getAllStackTraces, walk the continuation as a throw walks its own (above).

When main returns, champaca.main ends the main thread as HotSpot ends any thread: it runs Thread.exit(), marks the thread terminated and notifies its monitor, which wakes the threads joining it. Then, as the java launcher does, the same native thread carries on as a new non-daemon thread named DestroyJavaVM in the main group, which waits for the other non-daemon threads and calls Shutdown.shutdown() to run the shutdown hooks. After System.exit, main never ends, as on HotSpot. A jolt program that calls into Java keeps its main thread, since jolt goes on running on it.

Reflection

JDK 25 implements Method.invoke and Constructor.newInstance either with method handles or with two VM natives, invoke0 and newInstance0. It uses the natives when jdk.reflect.useNativeAccessorOnly=true, which champaca always sets. So the runtime supplies those natives, plus Class.getDeclaredMethods0, getDeclaredFields0, getDeclaredConstructors0 and the rest, and builds real java.lang.reflect objects through their own constructors. Field access has no native path in JDK 25, so Field.get/set and the typed variants are overridden.

Stack traces

A Java activation is a Chez frame whose code object is named after the method (Class.name(desc), or a block or trampoline of it carrying the same name). So the Java stack is a walk of the continuation, and ordinary calls record nothing. Two kinds of method keep a frame of their own through jframe, an identity call the optimizer can't see through: a synchronized method, whose body runs under dynamic-wind, and a method under CHAMPACA_BT, whose body runs under fluid-let.

Throwable.fillInStackTrace takes the walk with Chez's own continuation primitives, splitting off one frame at a time. It keeps a pair per frame, the code object and its return offset, in the throwable's backtrace field, where Java sees an opaque Object as it does HotSpot's. getOurStackTrace turns the pairs into StackTraceElements when a trace is asked for, up to 1024 frames. As HotSpot does, it leaves out fillInStackTrace and the constructors of the exception's own class and superclasses, the methods the JDK marks @Hidden (Thread.runWith), and every method of a hidden class (an upcall stub's). StackWalker's natives feed the same walk to the JDK's batching protocol a batch at a time.

The line number is read off the frame. For each return point, Chez's inspector information records the expression of the call it's waiting on. The generated code wraps every call's operator as (begin '(jpc . pc) op), a constant the optimizer drops before any code is generated, and the helpers that throw on their own (a division, an array access, a cast, a class's initialization) carry the same constant as an argument. So the recorded call carries its bytecode pc. jit.clj maps that to a line through the class file's LineNumberTable, read on demand like the local variable names behind NPE messages, with the file from its SourceFile attribute and the module from the image's package index.

That record is kept for classes from classpath directories, and for every class defined while a source file runs (javac's source launcher defines the program's own). JDK code goes without it. By default so does a jar's class or another loader-defined class, since the record costs about as much memory as the code (CHAMPACA_LINES, performance). An inlined leaf's own frame depends on it too. Without the information Chez folds the leaf's named lambda into the caller, so a throw from one of the checks inside it is attributed to the caller's frame (limitations).

A try body runs in the unnamed procedure call/1cc calls, and a synchronized body in an unnamed thunk under dynamic-wind, so either one's pending call counts for the next named frame out. An inlined leaf is compiled once per call site, and its markers name the site and the callee. When Chez folds the copy into the caller, the walk still shows the callee as a frame above the caller's line, as HotSpot does for an inlined method.

The runtime turns inspector information on for the code it evaluates in the process, your classes, whatever the jolt build's default. The startup cache compiles JDK code without it, since keeping it cost 0.02 to 0.04 s on every warm start and half again the cache's size, so a JDK frame carries its file name and no line. A classpath class keeps it in the cache, and its frames keep their lines (performance).

Method handles

The JDK builds a method handle from a MemberName the VM resolves and a LambdaForm it compiles to a hidden class, and neither exists here. So handles.ss replaces the layer above them. A method handle is a bare MethodHandle object, allocated without running a constructor, whose type field holds a real MethodType. A weak table maps it to a Scheme procedure and the descriptor that procedure takes. The Lookup.find* and unreflect* methods, asType, bindTo, invokeWithArguments and the MethodHandles combinators short of the loop builders are overridden to build and combine those. Everything else in java.lang.invoke, MethodType included, is the JDK's own code.

A memory segment's VarHandle is the exception that keeps the JDK's code. VarHandles.memorySegmentViewHandle is overridden to build the JDK's own SegmentVarHandle, with the form its alignment selects, and wrap it in a champaca handle whose access modes call the matching static of that form's class (VarHandleSegmentAsInts.get(VarHandle, Object, long) and the rest), the methods HotSpot links through the VarForm. Those do the segment's checks and reach memory through ScopedMemoryAccess, whose unaligned accessors are already Scheme loops over an array's elements or foreign-refs on native memory. A float[] or double[] holds Scheme flonums, so reading one as bytes goes through each element's bits.

A call to invokeExact or invoke carries the call site's descriptor. For invokeExact it must be the handle's type exactly, or the call throws WrongMethodTypeException with the JDK's text, and the arguments go to the handle's procedure as they are. For invoke the runtime converts each argument and the result between that descriptor and the handle's: boxing, unboxing and primitive widening. When the two match, which is the usual case, nothing is converted.

A pattern switch compiles to a call that walks its case labels in order (jswitch-index), since a label is only a class, a string, an integer or an enum constant.

An invokedynamic with any other bootstrap runs that bootstrap on its first call, with a Lookup on the calling class, the call's name and MethodType, then the static arguments, which codegen writes out as data. The runtime keeps the CallSite it returns for that call site. A ConstantCallSite's target is kept directly, and any other kind is asked for its current target on each call, so MutableCallSite.setTarget takes effect. A failed bootstrap is a BootstrapMethodError with the texts CallSite.makeSite gives. One that throws, or returns something that isn't a CallSite, is "bootstrap method initialization exception". One returning null, or a call site whose target has another type, is "CallSite bootstrap method initialization exception". The call site then fails the same way on every later call without running the bootstrap again.

An ldc of a method handle, a method type or a dynamic constant resolves once per constant-pool entry, as JVMS 5.4.3 asks. Codegen writes each such entry as (entry |Class#index| datum), so every instruction and bootstrap argument naming it gets the same object, null included. A resolution that fails with a LinkageError is kept too. Each later use throws a new error of the same class and message, with a new cause of the same class and message, as HotSpot does, and an error with no message takes the constant's name. Any other Error isn't kept. A method handle constant naming a member that isn't there is NoSuchMethodError or NoSuchFieldError with HotSpot's text (test/java/CondyCheck.java).

Calls from jolt

champaca.api sits on top. Each call takes the lock for as long as it runs Java and releases it on return, so Java threads keep running while jolt code does something else. The descriptor-based functions call a method's procedure directly. invoke and friends (dynamic.ss) first collect candidate methods from the class's registration data and score them against the arguments, then convert the arguments for the winner and call it, with the call-site cache in front of all of that.

A jolt fn passed as a single-method interface becomes an instance of a generated class whose one method calls back into the fn. proxy hands java.lang.reflect.Proxy.newProxyInstance an InvocationHandler of that kind, so the JDK's own ProxyGenerator writes the proxy class. define-class writes a version 52 class file itself, with its methods native, binds each native to its jolt fn and defines the class through the application loader, as ClassLoader.defineClass does. A jolt error that leaves a fn Java called crosses into Java as a RuntimeException, and comes back out as the original error when it reaches jolt again.

Files

FileJob
src/champaca/classfile.cljparses .class files: constant pool, fields with ConstantValue, methods, Code, BootstrapMethods
src/champaca/codegen.cljturns one method's bytecode into a Scheme define, with basic blocks as letrec thunks and a typed compile-time stack
src/champaca/bytecode.cljdecodes instructions, splits basic blocks, finds control flow and the loops array versioning works on
src/champaca/cgtext.cljthe text codegen writes: forms, literals, descriptors, block labels and pc markers
src/champaca/intrinsics.cljthe methods a call site does in line (String.charAt, range checks, boxing) and the runtime form for each
src/champaca/port.cljstatic methods written out as jolt functions from their structured form (porting-a-class); resources/champaca/port.ss hands it that form as data
src/champaca/linkage.cljwhat an instruction names but can't be resolved, as code that throws where the JVM would
src/champaca/lambdas.cljlambda sites and bootstrap constants
src/champaca/main.cljthe java-like launcher: reads the command line and runs the main class
src/champaca/state.cljthe state the others share (one atom), tracing and profiling, and packing cache entries
src/champaca/launcher.cljthe java-like command line: options, -D properties, module options, the class path
src/champaca/classpath.cljwhere class and resource bytes come from: the JDK image, class path entries and their manifests, boot layer lookups, resource URLs
src/champaca/cachedir.cljwhere the caches live and what keys them: the cache root and key directory, the translator fingerprint, read-only caches and a bundle's user copy, pruning, compiled units
src/champaca/link.cljthe class table's readers and the linker: resolution of fields and methods, the class map with its field layout and vtable (interface defaults included), the registration text the runtime evaluates, HotSpot's checks of a definition
src/champaca/libcache.cljthe caches that outlive a process: the library cache (classes from jars, directories and a program's own loaders, restored while what they depend on is unchanged) and the startup cache (boot.so, state.img and the generations later saves add)
src/champaca/loader.cljloads and defines classes (from the class path, the JDK's runtime image or a loader's defineClass), and the synthetic classes behind lambdas
src/champaca/jit.cljcompiles methods on first call, and answers the runtime's hooks for NPE messages, stack frames' lines and constant pool entries
src/champaca/boot.cljboots the JDK: loads the runtime, installs its hooks and runs the JVM's boot before main, from the startup cache when it can
src/champaca/api.cljthe jolt-facing API
src/champaca/jimage.cljreads the JDK's runtime image, lib/modules
src/champaca/npe.cljthe JVM's helpful NullPointerException messages, from the failing bytecode (written from JEP 358)
resources/champaca/runtime.ssnumbers, objects, arrays, exceptions, Class mirrors, lambdas, records
resources/champaca/arrays.ssmaking arrays: the allocators, the limits on an array's size, and jolt's out-of-memory error as OutOfMemoryError (limitations)
resources/champaca/inline.sscall-site forms for the JDK methods HotSpot intrinsifies: range checks, requireNonNull, boxing (performance)
resources/champaca/strings.ssjava.lang.String to and from Scheme strings, concatenation
resources/champaca/modules.sswhich module and loader a class belongs to, and the texts that name them
resources/champaca/units.ssevaluating and compiling generated code, globals as their own labels, the cache's unit files
resources/champaca/structure.ssstructured method code (CHAMPACA_STRUCTURED=1): a method's generated form rewritten for reading before it's compiled (debugging)
resources/champaca/launch.sslaunching main, System.exit's halt, uncaught-exception reports
resources/champaca/natives.ssJDK native methods not covered by the files below, resources, a few overrides
resources/champaca/management.ssjava.management: the runtime, memory, OS, class-loading and thread beans, the heap pool and collector, GC notifications
resources/champaca/memory.ssUnsafe (named natives plus pattern rules), scoped memory access, the Unaligned accessors
resources/champaca/threads.ssthe GIL, threads, parking, monitors and Thread's natives
resources/champaca/loaders.ssmodules, the built-in class loaders, classes defined at run time
resources/champaca/varhandles.ssVarHandles and their coordinate combinators
resources/champaca/reflect.ssreflection natives, invoke0/newInstance0, Field overrides, getCallerClass and the frame walk behind stack traces
resources/champaca/handles.ssmethod handles, call sites for bootstraps run for real, pattern-switch matching
resources/champaca/files.ssfile and channel natives through libc, UnsafeConstants
resources/champaca/zip.ssInflater and Deflater over zlib
resources/champaca/prefs.ssjava.util.prefs: lock files on Linux, CFPreferences on macOS
resources/champaca/spawn.sschild processes: forkAndExec through posix_spawn, the reaper's waitpid, destroy0
resources/champaca/net.sssocket, datagram and selector natives through libc: sun.nio.ch.Net, the socket and datagram dispatchers, DatagramChannelImpl, Inet4AddressImpl, KQueue, EPoll, the selector's wakeup pipe
resources/champaca/netif.ssNetworkInterface through getifaddrs and ioctl, and InetAddress.isReachable
resources/champaca/foreign.ssjava.lang.foreign downcalls through foreign-procedure, upcall stubs through foreign-callable, dlopen and dlsym for library loading and symbol lookup
resources/champaca/interop.ssthe conversions and entry points behind api.clj
resources/champaca/archive.ssthe archived module graph: written from a cold boot's ArchivedModuleGraph, rebuilt for a warm one
resources/champaca/dynamic.ssdescriptor-free calls: candidate lookup, overload fit, the call-site cache, auto-wrapping jolt fns