Limitations
Champaca is a spike. It runs the JDK's own code, but only along the paths its tests and the twenty-three checked libraries (commons-lang3, commons-lang 2.4, commons-io, commons-compress, Commons Math, gson, jsoup, Jackson and its XML module, Caffeine, Guava, Guice, Joda-Time, Kryo, Netty, SLF4J, Log4j 2, SnakeYAML, picocli, H2, Lucene, Bouncy Castle, Byte Buddy) have exercised. Off those paths, code can hit a missing native or one of the shortcuts below, listed roughly in the order a program meets them.
Threads take turns
Threads work, but they don't run in parallel. Each Java thread is a real Chez thread, and one global lock lets only one of them run Java code at a time (how-it-works). A program that uses threads for correctness (a background worker, a producer and a consumer, an executor) behaves as on a JVM. One that uses them for speed gets none.
A thread that loops without calling anything gives the lock up every thousand backward branches once another thread is waiting, so a spin-wait ends. A long recursive computation with no loop in it can keep the others waiting until it returns. So can a MemorySegment.mismatch over a long segment, or a Vector API load or store through one. Those hold the lock until they finish, so no thread is midway through a shared arena's memory when another closes it (the JVM uses a handshake for that).
A thread waiting on I/O gives the lock up: reading standard input, a pipe, a FIFO or a terminal, writing to a full pipe, opening a FIFO, Files.copy from a pipe (test/java/BlockingIoCheck.java) and taking a blocking file lock. Writes to standard output and error still hold it, so a program whose output pipe fills up stops until the reader catches up.
Closing a socket, a channel or a FIFO from another thread wakes a thread blocked on it with the JVM's exception (SocketException: Socket closed, AsynchronousCloseException), and interrupting a thread blocked in a channel read gives ClosedByInterruptException (test/java/SocketCheck.java). A thread waiting for a blocking file lock is the exception: see other gaps.
Since only one thread runs Java at a time, VarHandle and Unsafe memory-ordering modes can all behave alike without harm.
Virtual threads run without continuations. The JDK falls back to its BoundVirtualThread, one platform thread per virtual thread, so Thread.ofVirtual(), startVirtualThread and newVirtualThreadPerTaskExecutor give the JVM's results and isVirtual is true. Each costs what a platform thread costs, though, and takes turns like the rest (test/java/VirtualThreadCheck.java). getState says what a thread waits for as HotSpot does: TIMED_WAITING while it sleeps, WAITING or TIMED_WAITING in Object.wait and LockSupport.park, BLOCKED while it waits to enter a monitor, and RUNNABLE while it waits on I/O (test/java/OtherStackCheck.java).
invokedynamic and method handles
invokedynamic and method handlesBoth work, with a few gaps. The bootstraps javac emits are compiled directly: lambdas, records, string concatenation and pattern switches. Any other bootstrap method runs once per call site, and a dynamic constant once per constant-pool entry (how-it-works).
Method handles come from the Lookup.find* and unreflect* methods. invokeExact, invoke, invokeWithArguments, asType, bindTo, asSpreader, asCollector and the MethodHandles combinators work on them, with the JVM's conversions and exception texts: zero, empty, constant, identity, insertArguments, dropArguments, filterArguments, filterReturnValue, foldArguments, collectArguments, guardWithTest, permuteArguments, explicitCastArguments, catchException, throwException, tryFinally, the array element handles and the three invokers (test/java/MethodHandleCombinatorCheck.java). So do Lookup.bind, findVirtual on MethodHandle.invoke and invokeExact or on a VarHandle access mode, and the two VarHandle invokers (test/java/InvokerCheck.java). The loop builders (loop, whileLoop, doWhileLoop, both countedLoops, iteratedLoop) run the JDK's own Java, so their checks and texts are the JVM's, and the loop they build runs in Scheme the way the JDK's interpreted form runs it (test/java/LoopHandleCheck.java).
Types are checked as on the JVM. invokeExact compares the call's type with the handle's exactly, so a subtype, a boxed value or a widened result is WrongMethodTypeException with the JDK's text (test/java/ExactCheck.java). asType refuses an impossible conversion when the handle is built, with the JVM's text. explicitCastArguments narrows, turns a boolean into 1 or 0 and any number into a boolean by its low bit, reads a null as zero, and doesn't check a cast to an interface, all as the JVM's does (test/java/ExplicitCastCheck.java).
Lookups check access as the JVM's do, with its messages (test/java/LookupAccessCheck.java). A field one finds is resolved the JVM's way, superinterfaces included, with the JDK's texts when it's missing, static where it shouldn't be or the reverse, or final for a setter (test/java/FieldLookupCheck.java). A pattern switch label that javac builds from a dynamic constant other than a primitive class, a boolean constant or an enum constant fails to translate.
VarHandles work too: findVarHandle, findStaticVarHandle, unreflectVarHandle, arrayElementVarHandle, byteArrayViewVarHandle and byteBufferViewVarHandle (test/java/ByteBufferViewCheck.java), which is what java.util.concurrent.atomic and ConcurrentHashMap need. Each supports the JVM's access modes and throws UnsupportedOperationException for the rest, so a final field is read-only, a float has no bitwise updates, and a byte[] view takes only plain get and set, as on JDK 25.
A call site's types are converted as asType would: widening, boxing and unboxing, never narrowing, so an int passed for a short throws WrongMethodTypeException. A handle from withInvokeExactBehavior throws it for any call whose types differ. One case differs: a reference passed where the handle wants a primitive is cast when the access runs, so a String for an int throws ClassCastException, where the JVM refuses the call with WrongMethodTypeException before it runs. The six coordinate combinators (filterValue, filterCoordinates, insertCoordinates, permuteCoordinates, collectCoordinates, dropCoordinates) work with the JVM's construction-time checks and messages, and a derived handle supports the modes its target does. Their filters are screened for checked exceptions: a direct handle whose method declares one is refused, and any other handle's checked exception becomes IllegalStateException when it's thrown, except under filterCoordinates, where the JDK lets it through (test/java/VarHandleFilterCheck.java).
Memory segments work, over Java arrays and over native memory from any kind of Arena. A layout's VarHandle (ValueLayout.varHandle, a layout path's, the one behind MemorySegment.get and set) holds the JDK's own SegmentVarHandle, and each access mode calls the JDK's Java. So bounds, alignment, read-only, closed-arena and wrong-thread checks run as written, with the JDK's exceptions and texts, and an unaligned layout's handle takes only plain get and set (test/java/SegmentCheck.java). Access is slow for the same reason: summing 100,000 ints through getAtIndex took 46 ms on a heap segment and 33 ms on a native one, against 0.3 ms over the array; on HotSpot all three are about equal.
Downcalls go through Linker.nativeLinker() with the JDK's checks and texts: structs and unions by value, variadic calls, errno capture, and critical calls that see a heap array, which is copied into native memory for the call and back after it (test/java/ForeignCheck.java, compared against HotSpot). A critical call costs 0.25 us. Any other gives the global lock up while it runs in C and costs 0.6 to 1.3 us, against HotSpot's 5 to 25 ns. A downcall handle's invokeExact doesn't insist on an exact type match: it converts its arguments as other handles do.
Upcalls work. Linker.upcallStub gives C a function pointer that calls a method handle, with the JDK's checks (a target declaring a checked exception is refused). A target that throws prints its trace and ends the process with status 1, as the JDK handles it (test/java/UpcallCheck.java, test/mainsel/UpcallThrow.java). Structs pass and return by value, in registers or through memory. Arguments past the registers come from the stack where the JDK's calling sequence puts them, a struct the ABI passes there included, on macOS and Linux, arm64 and x86_64. When an upcall comes from a thread C started, that thread becomes a daemon Thread-N in group main for its life, as HotSpot attaches it. Each upcall costs 0.8 to 1.5 us against HotSpot's 60 ns, most of it in taking and giving back the global lock and in the JDK's binding handles. Neither champaca nor HotSpot supports an upcall from a function called through a critical downcall.
Chez Scheme 10.4.1 gets some arguments wrong once the registers are used up and they go on the stack:
- On macOS arm64, Chez places some of them where Apple's ABI doesn't. A struct of 16 bytes or less that isn't all floats or all doubles starts on an 8-byte boundary and takes a multiple of 8 bytes there, but Chez packs it like a scalar. An argument after one that ends at an odd offset can then start a few bytes early (a struct of three floats, a
char, then another such struct), and that moves plain scalars too (threechars, then ashort). Linux passes stack arguments in 8-byte slots, so these placements are right there, andForeignCheckprints HotSpot's output on Linux arm64. - On Linux x86_64 the placements are right, but Chez copies a struct passed on the stack in pieces and writes each piece as a whole 8-byte word. When the struct's size is 3, 5, 6 or 7 bytes past a multiple of 8, the copy runs over the start of the next argument (a
shortafter a 3-byte struct arrived as 0).ForeignCheckprints HotSpot's output on GitHub's x86_64 runner. - On both systems, a struct over 16 bytes that comes after the registers are used up goes by pointer on the stack, and Chez fails to compile the call at all.
Unless jolt's Chez is patched, champaca refuses any such signature with UnsupportedOperationException. Chez has the fixes in cisco/ChezScheme#1091, and jolt carries them as patches (jolt#1502; the x86_64 copy is patch 0007). Only jolt's release binaries have them: make build uses a system Chez such as Homebrew's as it is, and jolt's own provisioning doesn't patch it either. So champaca asks. When jolt.ffi/stack-args-correct? (jolt 0.8.27 and later) answers true, as on the 0.8.27 release binary, champaca makes these calls, and when it answers false, can't tell, or the jolt is older, champaca refuses them. On 3066 generated signatures of eight ints (and eight doubles, in some) followed by two or three mixed arguments, 650 would have been wrong. Champaca refused 676, so 26 of those it could have made, and it ran the rest right.
Reflection
Methods, fields, constructors, Method.invoke, newInstance and field get and set work. They check the receiver and each argument or value as the JVM does: a box for a primitive is unboxed and widened but never narrowed, a reference must be an instance of its type, and anything else is the JDK's IllegalArgumentException, message included. getInt, setLong and the other typed accessors take only a primitive field the value widens to or from. Setting a final field throws IllegalAccessException: a static final field always, a record's or a hidden class's always, any other only until setAccessible(true). When a caller-sensitive method such as Class.forName, Method.invoke or setAccessible is called through Method.invoke, it sees the caller the JVM would give it.
Annotations with their defaults, generic signatures, member, local and anonymous classes, record components, parameter names (real ones when the class was compiled with javac -parameters, else arg0, arg1), checked-exception lists and type annotations all match the JVM's, so Jackson binds records. getDeclaredClasses and getClasses list a class's member classes as the JVM does (test/java/DeclaredClassesCheck.java).
getDeclaredMethods and getMethods list a class's methods in class-file order. HotSpot's order comes from the addresses of its method name symbols and varies with the JDK build and its archive, so nothing should rely on it, but a library that numbers methods by it gets other numbers here: ReflectASM's MethodAccess.getIndex("reverse") on StringBuilder is 34 where HotSpot gives 31, each consistent within a run.
Reflective field access checks access as the JVM does when the field wasn't made accessible, with the JDK's IllegalAccessException text. Called from jolt, with no Java caller, it isn't checked.
Class.getPackage gives one Package per name. A classpath package carries the version attributes of the manifest of the jar its first loaded class came from, as the JVM's class loaders define it, so a package split over two jars reads the same. Sealing isn't enforced, so a sealed package split over two jars loads instead of throwing SecurityException. A jar whose manifest the JDK would refuse gets a warning on stderr, and its classes load without version information where the JVM fails to load them. A JDK package belongs to its named module, so it reports isSealed and no version. Package.getPackage(String) and Package.getPackages() find a JDK package of the boot loader once a class of it has loaded, and it's the same Package that Class.getPackage gives.
A package of your own classes is its class loader's. The application loader's getDefinedPackage and getDefinedPackages and Package.getPackage find it, and a loader that defines a package before defining a class in it (URLClassLoader does, from its jar's manifest) gives that Package back. A lambda's class sits in its caller's package. The timing differs: the loader defines a package the first time something asks one of its classes for its package, where the JVM's does it as the first class loads, so a lookup by name before then finds nothing. Likewise a package of a platform-loader module such as java.sql is found only once something has called getPackage on one of its classes.
Nests come from the NestHost and NestMembers attributes, checked as JVMS 5.4.4 asks: the host loads, is in the same package and lists the class. A hidden class defined as a nestmate joins its lookup class's nest. So getNestHost, getNestMembers and isNestmateOf answer as on the JVM, and a class file older than Java 11 is its own nest host there too.
Sealed classes and interfaces answer isSealed and getPermittedSubclasses. A class one of them doesn't permit (outside its module, a non-public one outside its package, or not listed) fails to load with HotSpot's IncompatibleClassChangeError. One case gets through: a lambda for a sealed interface, which javac never emits but hand-made bytecode can, is made where the JVM throws.
Native code
A JDK library load (zip, net, management and so on) is a no-op, because those natives are Scheme here. Any other library loads as on the JVM, from java.library.path or by System.load's absolute path, so SymbolLookup.loaderLookup() finds its symbols. Its JNI code can't run: a library with a JNI_OnLoad is refused with UnsatisfiedLinkError, and a Java native method the library implements stays missing. A native champaca hasn't written yet throws UnsatisfiedLinkError too (debugging), so a library's try/catch around an optional feature works, and a program that needs the native stops there. The ones known to be missing, roughly in the order a library meets them:
- Sockets work over IPv4 and IPv6, through libc:
Socket,ServerSocket, blockingSocketChannels,HttpURLConnectionandInetAddresslookups. Selectors work, and with them non-blocking channels andjava.net.http.HttpClient. UDP works over both families (DatagramSocket,DatagramChannel), multicast groups included. - The tests need no network, sojolt testchecks HTTP/1.1 against a server in the same process. HTTPS over HTTP/1.1 and over HTTP/2 (the response reportedHTTP_2, so the server negotiated it) andHttpsURLConnectionwere checked by hand against a public site (https://example.com, 2026-10-25). In two warm runs that day the first HTTPS request took 3.0 and 3.1 s, against 0.2 to 0.4 s on the JVM, and a second client's request 0.4 to 0.5 s. -NetworkInterfacereadsgetifaddrsfor both families. The JDK reads Linux's IPv6 addresses from/proc/net/if_inet6instead, so their order within an interface can differ there. A Linux alias such aseth0:1is listed as an interface of its own, where the JVM makes it a virtual sub-interface ofeth0. -InetAddress.isReachablealways tries TCP port 7, as the JDK does when it can't open a raw socket. Run as root, the JVM sends an ICMP echo instead, so a host that silently drops port 7 but answers ping is reachable there and not here. - Pending urgent data isn't discarded, and the extended options ofjdk.netreport themselves unsupported. - On a Mac whose hostname the resolver doesn't know (a home network, say),InetAddress.getLocalHost()answers with the interfaces' IPv4 addresses, leaving the loopback out unless it's all there is, as the JDK's own macOS code does. On Linux neither the JDK nor champaca has that fallback. - Child processes start through
posix_spawnwhateverjdk.lang.Process.launchMechanismsays, so the JDK'sjspawnhelpernever runs, and what a program can see matches the JVM (how-it-works).ProcessHandlereads processes as the JDK's natives do (test/java/ProcessHandleCheck.java): parents, children and descendants, start times (so a handle never signals a later process that reused its pid), andInfo's command, arguments, user, start time and CPU time. It reads them fromsysctlon macOS, where another process's CPU time stays unknown as on the JVM, and from/procon Linux. Under Docker's x86_64 emulation on an arm64 Mac, a script without#!exits 127 instead of running through/bin/sh, because the emulatedposix_spawndoesn't report thatexecfailed. Natively it runs. - A program can't install its own signal handler:
new sun.misc.Signal("USR2")throwsIllegalArgumentException: Unknown signal: USR2, where HotSpot registers it, because the JDK'sSignalnatives are stubs. SIGTERM, SIGINT and SIGHUP still run the shutdown hooks and exit as on HotSpot. - A stack trace lists translated methods only, so a native champaca wrote in Scheme has no frame where the JVM prints one as
Native Method. Reflection's own frames differ in name (DirectMethodHandleAccessor$NativeAccessor.invokehere,DirectMethodHandleAccessor.invokeon the JVM). Another thread's stack (Thread.getStackTrace(),Thread.getAllStackTraces()) starts at the Java method that blocked, while the JVM also shows the native under it (Thread.sleep0,Object.wait0).StackWalker'sSHOW_HIDDEN_FRAMESoption doesn't bring the hidden frames back. Line numbers depend on where the class came from: - JDK frames carry the file but no line, since the startup cache keeps no inspector information for JDK code (performance). - By default the frames of classes from jars have no lines either, and neither do those of classes a loader of the program's own defines from a jar. One generated at run time, with no code source location, keeps its lines.CHAMPACA_LINES=allgives them lines, at about the memory their code takes (performance). - Without lines, a method of such a class that calls nothing, inlined into its caller, and throwing from one of champaca's own checks (a division by zero, an array index, a cast, an array's size) shows the caller's frame where the JVM shows its own, because Chez folds the inlined code into the caller's.test/libcache/StackCheck-lines-off.expectedhas the shape.CHAMPACA_LINES=allrestores the frame along with the lines, and so doesCHAMPACA_BT=1, whose wrappers keep the inlined code apart. java.management: the Runtime, Memory, OperatingSystem and ClassLoading beans answer, andThreadMXBeancounts and lists threads. - Thread CPU times are measured for any live thread the way HotSpot measures them (thread_infoon macOS, the thread's CPU clock and/procon Linux). The thread lists hold the threads Java started andmain. A jolt thread that called in isn't listed, and there are no JVM system threads such asReference Handler. -ThreadInfo, thread dumps and both deadlock finders work as on HotSpot, with two differences. A locked monitor's stack depth is -1, as HotSpot gives for one a native locked, because champaca doesn't note which frame entered a monitor, soThreadInfo.toStringlists no- lockedlines. And a dump that asks for locked monitors or synchronizers (dumpAllThreads(true, true),getThreadInfo(ids, true, true)) runs a full collection to find them, about 120 ms per 4 million objects with every thread stopped, and briefly holds a list of every object. That keeps taking a lock as cheap as before. A monitor that polls such dumps every few seconds (an actuator's thread dump endpoint, a JMX console) pauses a program with a large heap on each poll.getThreadInfowithout them,findDeadlockedThreadsanddumpAllThreads(false, false)cost no collection. - Chez has one heap and one collector, so there is one memory pool,Chez heap, and one garbage collector,Chez, where HotSpot's G1 has three pools and two collectors. The pool's usage and peak are Chez's heap counters, the same onesRuntime.totalMemory()andfreeMemory()report, and the collector counts Chez's collections and their real time. Neither supports usage or collection thresholds. - The collector is jdk.management's notification emitter, as on HotSpot. Once a listener is added, it sends the JDK's GC notification after each collection, on a daemonNotification Threadchampaca starts then (HotSpot's is there from the start).System.gc()is a major collection with HotSpot's cause,System.gc(). Any collection Chez asks for is a minor one with causeAllocation Failure, even when Chez collected every generation in it.getLastGcInfogives the last collection, with no GC-specific attributes. - TheCompilationMXBean's name ischampaca (Chez Scheme)(thesun.management.compilerproperty), and its total compilation time is the time spent translating and compiling methods, so a warm run whose methods all come from the startup cache reports 0. - The operating system bean isjdk.management's, as on the JVM. Its host CPU counts and process CPU time are measured. Its memory and swap sizes, CPU loads and file descriptor counts are -1, which its contract allows for what isn't measured.- Classes of the same name from different loaders live in one class table under names of their own. When a loader of the program's own defines a class under a name another loader, the class path or the JDK already has, the class is named
<name>/0L<k>inside, and Java sees<name>. Its references to other classes resolve as its loader resolves them. The loader's own class of a name comes first. Then, for a loader that overridesloadClass(a child-first one) or a name some loader renamed, champaca takes what the loader'sloadClassanswers (test/aaa/siblingloaders.java,test/aaa/childfirst.java). The classes in aninvokedynamicsite's descriptor, a method type constant's and a dynamic constant's resolve the same way, which Groovy's calls to a script's own methods need. Three things aren't covered. A member reference's descriptor names classes by their plain names, since it's the key the member is found by, as the declaring class wrote it. A resolution error is kept per name, not per loader. And the InnerClasses, NestHost and PermittedSubclasses attributes of a renamed class are read under plain names. A renamed class is never cached, because its inside name depends on the order classes were defined, so a program that renames many recompiles them every run.
The calling API
init!takes effect once per process. A second call, with a different:classpathor:previewor not, does nothing.- Until
save-cache!runs, each JDK method the process compiles keeps its generated source in memory (a few KB per method) so a save can write it. A long-lived program should callsave-cache!once its working set is warm, which also empties that list. - Every conversion copies. A Java method that changes a collection you passed it changes the copy, and a big collection is copied again on each call (data-conversion).
- A vector passed to a plain
Objectparameter becomes an array, not aList. - A Java object prints as
#object[:object]in jolt. Useldk/to-string. - A jolt error that leaves one of your fns through Java is a plain
java.lang.RuntimeExceptionto Java, where on the JVM it would be Clojure's ownExceptionInfo. Java code that catches every exception and only logs it, or retries, swallows your error, as on the JVM. proxyputs its proxies in the application class loader. Aproxymethod left out of the map runs its interface's default body, and Java code of your own can callInvocationHandler.invokeDefaultas on the JVM (test/java/MethodHandleCombinatorCheck.java).- A Java string can hold a lone surrogate (half of a UTF-16 pair) and a jolt string can't, so one comes back to jolt with U+FFFD in its place. Inside Java nothing is lost: string constants, concatenation and
switchkeep the code units exactly.
jolt has no byte type to ask for, and Java never narrows an argument, so an integer goes to a byte or short parameter, and a vector of them to a byte[] or short[], only when no overload fits without narrowing. ByteBuffer.wrap([1 2 3]) and MessageDigest.update take a vector. Where an int overload exists it wins, though, and an integer out of range for the type fits nothing. A byte is signed, so 0xCA or 200 is out of range: write -54, or build the array with HexFormat.parseHex (data-conversion). char isn't narrowed this way, so pass a character.
Classes defined from jolt
- A class
define-classmade is defined once per process, since the application loader defines it and a loader defines a name only once. Evaluating the same definition again swaps in its new fns, but a new field, a new method key, another superclass or interface, or other annotations are refused. At the REPL, give the class a new name while you experiment, or restart the process. - The class is final. Its instance fields start as Java's defaults (
null,0,false), and:post-initsets them. Before the superclass constructor runs, a constructor of its own runs only:init, which computes that constructor's arguments and can't see the object. Static fields can be set by:static-init, which runs once per process: a new fn given on a redefinition runs only if the class hasn't been initialized yet, and a class whose initializer failed stays failed. A static method needs its full JVM name. - The static initializer is a native
<clinit>. champaca runs it, but HotSpot ignores the native flag on a<clinit>and refuses such a class file (ClassFormatError: Absent Code attribute), so the bytes are of no use outside champaca. A constructor of its own is likewise a native<init>. Reflection shows it without thenativemodifier, as Java would. - Its methods are native methods, so reflection reports them with the
nativemodifier, and they carry no generic signatures orthrowslists. Annotations go on the class, its fields, methods and constructors, but not on parameters or types (TYPE_USEannotations on a field or method are refused). A@Repeatableannotation is given once, or through its container. - A method name never covers a package-private method of the superclass. A full JVM name with the same signature makes a public method, which overrides it only when the class is in the superclass's package, as on the JVM. A name in one of the JDK's packages is refused.
- When a method name has overloads that are final and others that aren't, the name covers the ones that aren't, and the final ones keep their bodies.
- Java code can't be compiled against the class, since it exists only at run time. Java reaches it through an interface or superclass it knows, through reflection, or through
Class.forName. - A class from the classpath with the same name that hasn't been loaded yet is never loaded afterwards, because the name already belongs to the jolt class. One that has been loaded makes
define-classrefuse the name. - The class isn't kept in the startup cache. Each process defines it again, and its constructors are compiled again on their first call.
Speed
The startup cache makes boot quick, and plain computation runs within a few times of the JVM: test/java/Bench.java spends about 0.17 s computing what the JVM does in about 0.04 s. Calls, allocation, try blocks and synchronization cost several to many times more than on the JVM (performance has the numbers). The startup cache slows a little as generations pile up: a cache filled by the whole test suite costs a warm Hello a few tens of milliseconds, even though state.img entries parse only when a run touches their class. Memory use is high. A warm Spring Boot start peaks at about six times the RSS HotSpot's does, and a cold one at about thirteen times (bench/README).
Other gaps
- The module path (
-p,-m) takes modular jars, exploded modules and automatic modules (a jar with nomodule-info.class), but not.jmodfiles. A multi-release jar's classes are its base ones, on the module path as on the class path, andinit!takes no module path. Where the output differs from HotSpot's: - A module path class's stack frame leaves out HotSpot'smodule/prefix (app.Main.main(Main.java:7)forapp/app.Main.main(...)). A missing class'sNoClassDefFoundErrortrace has no line in the frame naming it and no loader frames under its cause, as for any class that isn't there. - A module's resource URL is writtenjar:file:/...where HotSpot writesjar:file:///.... A non-ASCII path isn't percent-encoded either, and a directory's URL has no trailing slash. -Class.forNameof a module path class through the boot loader proper names it slashed in itsClassNotFoundException. -MethodHandles.Lookupdoesn't check module access, sofindStaticon a class in a package that isn't exported to the caller succeeds where HotSpot throwsIllegalAccessException. The same holds for JDK classes. - An app bundle runs only on the OS and architecture it was built on, since its compiled code is Chez machine code. Installed read-only with no writable home (or with
CHAMPACA_USER_CACHE_DIR=off), it has nowhere to save, so it compiles whatever its training run didn't reach on every run. With a writable home it keeps that in a cache of its own there. Andjdepscan't see a module a program reaches only by name:jdk.charsetsandjdk.localedataalways go in, and anything else needs--add-modules. - A lambda's class lists the interface method it implements, public, as the JVM's does, and
Method.invokeruns it (test/java/LambdaReflectCheck.java). Its declared fields differ: it has one private field holding the closure, where the JVM's has one per captured value. - A lambda's class is one per call site, as the JVM spins them. It's a hidden, synthetic, final class named for the class holding the site (
Caller$$Lambda/0x..., with a number that differs from HotSpot's), in its caller's package, module and loader, its caller's nestmate, and not found byClass.forName(test/java/LambdaSiteCheck.java). Lambdas jolt makes for Java (ldk/implement, a jolt fn passed as an interface) share one class per interface,champaca.$$Lambda$.... java.nio.fileneeds macOS, or Linux with glibc 2.33 or newer, on arm64 or x86_64. The file natives readstruct stat,struct dirent,struct flockandstruct statvfsthrough a table of offsets measured on each of those systems. They also callstat, which glibc exports itself only from 2.33 on (RHEL 8 and Debian 11 are older). On anything else, a BSD, musl or an older glibc,UnixNativeDispatcher.initthrowsUnsupportedOperationExceptioninstead of guessing. Only Ubuntu 26.04 (glibc 2.43) has been tested.java.iostreams andRandomAccessFiledon't depend on those layouts and work anywhere libc does. On Linux the birth time comes fromstatx, as the JVM's does.- Linux's container support is off, as with
-XX:-UseContainerSupport: inside a container champaca reports the host's memory and processor count, where the JVM reports the container's limits. - File watching works on macOS, where the JDK's
WatchServicepolls in plain Java, but not on Linux, where it needs inotify natives champaca doesn't have yet. - A
floatis held as a Scheme double, so a signaling NaN's bits don't survive:Float.floatToRawIntBits(Float.intBitsToFloat(0x7fa00001))is0x7fe00001here, as is an int written into afloat[]through a memory segment and read back, where the JVM keeps0x7fa00001. Doubles keep every NaN payload. - A thread waiting for a
FileChannellock isn't woken when another thread closes the channel: the wait restarts after the wake signal, where the JVM gives up, and ends only when the lock is granted and finds the channel closed. Blocked reads, writes and accepts on sockets, channels and FIFOs do wake. - Champaca looks for a class that code names when the method naming it is first translated, the whole method at once, while the JVM resolves each reference when its instruction first runs. An
ldcof a class (aFoo.classliteral) is the exception: it resolves when it runs, through the defining loader of the class whose code it is, as on the JVM. A class that isn't there makes the instruction naming it throwNoClassDefFoundErrorwhen it runs. One that only becomes loadable later (a loader that registers it after the method's first call, a class file written meanwhile) is never seen by that method, though. A loader of the program's own is asked throughloadClassfor every class such a method names, branches that never run included. A small method of the caller's own class (40 bytecode bytes or fewer, with no calls, loops or exception handlers, called directly: static, private or final) is compiled into its caller, so the classes it names are looked for when the caller is first translated, before the small method is ever called.CHAMPACA_BT=1turns that inlining off. A loader's error other thanClassNotFoundExceptionis kept for the name and thrown by each instruction that names it, as the JVM repeats a failed resolution's error. - A class that exists but champaca can't load (an opcode the translator lacks, a missing superclass) throws
NoClassDefFoundErrornaming the cause fromClass.forNameandloadClass, as a class the JVM can't define throws aLinkageError. A library that probes for an optional class and catches onlyClassNotFoundExceptionstops with the cause instead of taking its fallback, while one catchingThrowableorLinkageErrorcarries on. A class file that doesn't parse at all is the JVM'sClassFormatErrorinstead, from those and from any instruction that needs the class, every time it runs. A file cut short and a wrong magic number carry HotSpot's messages, and any other parse failure champaca's own (test/java/ClassFormatCheck.java). - A JDK module's class file has the JVM's
jrt:URL throughgetResourceandgetResources, from every loader, so a library that reads class files by URL (Byte Buddy's class injection, ASM-based tools) works (test/java/JrtResourceCheck.java). Any other resource of a JDK package is read as a stream only, with no URL, as on the JVM, where those packages aren't open.-jarwith no jar gives the JVM's error and status but not its full usage text. - System properties are the JVM's except where they name the VM.
java.vm.name,java.vm.vendorandjava.vm.versionsay champaca,java.vm.infoiscompiled mode,sun.management.compilernames champaca's JIT, andjdk.reflect.useNativeAccessorOnlyis set.java.library.pathis empty unless a-Doption sets it, since no JNI code runs. The default locale comes from where the JDK gets it. On macOS that's your first preferred language in System Settings (LANGisn't read there, as on the JVM). On Linux it'sLC_MESSAGESandLC_CTYPE, and a locale that isn't installed counts asen_US. Of a Linux locale's@modifieronly a script (@latin,@cyrillic) is read, and the JDK's legacy aliases aren't applied.java.io.tmpdiris macOS's per-user temporary directory, andsun.java.commandis set whenchampaca.mainstarts the program. champaca.mainreads-D,-cp(and its spellings), the assertion switches,-server/-client,-Xss,--enable-preview, the module options (--add-modules,--enable-native-access,--illegal-native-access,--add-opens,--add-exports,--add-reads,--limit-modules) and the version switches. It ignores heap sizes (-Xmx,-Xms,-Xmn),-Xshare:,-XX:flags,-verbose,-Xlogand HotSpot's execution-mode and diagnostic-Xoptions, with a warning on stderr. The heap is Chez Scheme's, bounded by jolt's ceiling instead of a switch here (the next point), and there's no HotSpot logging to turn on.- The heap's limit is jolt's: a quarter of physical memory, or of a container's memory limit, as HotSpot's default is.
JOLT_MAX_HEAP=<n>[k|m|g]sets another, andRuntime.maxMemoryreports it. When a full collection leaves more than that live, the allocation throwsOutOfMemoryError: Java heap space, and a program can catch it and go on once it lets go of what filled the heap (test/heap/HeapFullCheck.java). An array takes eight bytes an element whatever its element type, so abyte[]of a thousand million bytes asks for 8 GB here and 1 GB on the JVM, and an array HotSpot makes can be refused. A length aboveInteger.MAX_VALUE - 2isRequested array size exceeds VM limit, and an array or a multi-dimensional array with no room for it isJava heap space, checked in HotSpot's order (test/java/ArrayLimitCheck.java). HotSpot sets memory aside for these errors and champaca doesn't, so with the heap full to the last byte, making the error can itself fail and end the program. - Chez's collector clears weak and phantom references, and the JDK's Reference Handler thread queues them within about 100 ms of the collection that cleared the referent. So
WeakHashMapdrops keys nobody holds, aCleaner's action runs, and a direct buffer's native memory is freed once the buffer is unreachable (test/java/ReferenceCheck.java, and upstream'saaa/referenceprocessing). To find the cleared ones, champaca looks at every live weak or phantom reference with a queue after each collection, so a program holding hundreds of thousands of them (a hugeWeakHashMap) pays for that scan. A soft reference keeps its referent for good, which the JVM is allowed to do until memory runs short.finalize()never runs, as on a JVM started with finalization disabled.System.gc()runs a full collection, and other threads wait for it. - A thread's stack is bounded at 1 MB, the JVM's default, or at
-Xss's size.JOLT_STACK_LIMITdecides when set, and the jolt API leaves jolt's own bound alone. Past the bound aStackOverflowErroris thrown, which a program can catch as on the JVM. jolt checks for the overflow at collections, so the stack can pass the bound first, and a recursion of small frames can run millions deep before it's found. Champaca's frames are smaller than HotSpot's, so the same bound holds a deeper recursion: about 52,000 frames of a small method against HotSpot's 42,000. A plain recursion overflows in a quarter of a second. With atryin every frame that does work in itsfinally, unwinding takes time quadratic in the depth, because each escape from a handler's region measures the winder lists, and such a recursion took minutes to overflow. A method champaca compiles isn't held to the bound, since the compiler's recursion is champaca's work (BigLiteralCheck). Any other launcher option (--patch-module,--upgrade-module-path,-Xbootclasspath/a:,-javaagentamong them) stops withUnrecognized option, and theCLASSPATHenvironment variable isn't read (running Java programs). - The Vector API (
jdk.incubator.vector, with--add-modules) gives HotSpot's results but isn't accelerated. Champaca answers the few questions it asks the VM (the vector width: 128 bits on arm64, as HotSpot has it there, and 256 on x86_64 whatever the CPU), and every operation runs on the Java fallback the JDK keeps for each intrinsic, lane by lane, with a vector object per step. Summing 100,000 ints that way took 30 ms against 0.18 ms for a plain loop; on HotSpot the two are about equal. Operations that view lanes as another type (reinterpretAs*,castShape,viewAs*,test(IS_NAN)) and loads and stores through aMemorySegmentwork, through the JDK's memory segment code (test/java/VectorCheck.java). The vector math library (SLEEF, SVML) isn't loaded, soSIN,EXPand the rest use the JDK's Java versions, as HotSpot does when the library is missing. Where HotSpot's compiled code calls the library instead, results can differ in the last place, although they matched in VectorCheck's rows on an arm64 Mac. jsrandretrun in a class file of any version. HotSpot's verifier refuses them from version 51 (Java 7) on, and champaca doesn't verify bytecode, so such a class runs here where the JVM throwsVerifyError.- A class naming a module outside the boot layer fails one instruction at a time, as each runs. HotSpot's verifier loads some named classes while it links the class naming them, a catch clause's type among them, so there the whole class fails with
NoClassDefFoundErrorbefore any of it runs. champaca doesn't verify bytecode, so such a class runs, and a catch clause naming an out-of-layer class never matches. JDK code compiled by an earlier run keeps what it resolved, but a JDK module names only modules it requires, and the boot layer holds those,requires staticones apart. - A lone
--add-opens=, with nothing after the=, stops the program with the JDK's "Unable to parse" error. HotSpot runs the program, because it takes the boot layer from its class data sharing archive, which never reads the option. With-Xshare:offit fails as champaca does. - Class loaders answer as on the JVM for your classes, the JDK's and the built-in loaders. A
module-infoon your class path is never read, as on the JVM: what the class path holds is unnamed, with no checks between your own packages, and named modules of your own come from a module path (-p,-m) (running-java-programs). The JDK's own modules are named and their exports and opens are checked (jars-and-modules). What each class reports: - Your classes report the application loader and sit in its unnamed module. - A JDK class reports the loader its module is defined to (null forjava.base, the platform loader forjava.sql). - A class a loader defined at run time reports that loader. - A lambda's class reports its caller's loader and module, solambda.getClass().getModule() == Main.class.getModule()holds, as on the JVM, and-eareaches a lambda's class as it reaches its caller. - The built-in loaders see what the JVM's do: the application loader everything, the platform loader the JDK, the boot loader (a null loader inClass.forName) its own modules only. - A
NullPointerExceptionraised in champaca's own Scheme code, which stands in for some of the JDK's Java (the method handle layer, for one), has anullmessage where the JVM's might describe the failing bytecode. One the program's or the JDK's bytecode raises carries the JVM's message, such asCannot invoke "String.length()" because "<local1>" is null(test/java/NpeCheck.java).