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

Both 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 (three chars, then a short). Linux passes stack arguments in 8-byte slots, so these placements are right there, and ForeignCheck prints 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 short after a 3-byte struct arrived as 0). ForeignCheck prints 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, blocking SocketChannels, HttpURLConnection and InetAddress lookups. Selectors work, and with them non-blocking channels and java.net.http.HttpClient. UDP works over both families (DatagramSocket, DatagramChannel), multicast groups included. - The tests need no network, so jolt test checks HTTP/1.1 against a server in the same process. HTTPS over HTTP/1.1 and over HTTP/2 (the response reported HTTP_2, so the server negotiated it) and HttpsURLConnection were 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. - NetworkInterface reads getifaddrs for both families. The JDK reads Linux's IPv6 addresses from /proc/net/if_inet6 instead, so their order within an interface can differ there. A Linux alias such as eth0:1 is listed as an interface of its own, where the JVM makes it a virtual sub-interface of eth0. - InetAddress.isReachable always 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 of jdk.net report 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_spawn whatever jdk.lang.Process.launchMechanism says, so the JDK's jspawnhelper never runs, and what a program can see matches the JVM (how-it-works). ProcessHandle reads 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), and Info's command, arguments, user, start time and CPU time. It reads them from sysctl on macOS, where another process's CPU time stays unknown as on the JVM, and from /proc on Linux. Under Docker's x86_64 emulation on an arm64 Mac, a script without #! exits 127 instead of running through /bin/sh, because the emulated posix_spawn doesn't report that exec failed. Natively it runs.
  • A program can't install its own signal handler: new sun.misc.Signal("USR2") throws IllegalArgumentException: Unknown signal: USR2, where HotSpot registers it, because the JDK's Signal natives 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.invoke here, DirectMethodHandleAccessor.invoke on 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's SHOW_HIDDEN_FRAMES option 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=all gives 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.expected has the shape. CHAMPACA_LINES=all restores the frame along with the lines, and so does CHAMPACA_BT=1, whose wrappers keep the inlined code apart.
  • java.management: the Runtime, Memory, OperatingSystem and ClassLoading beans answer, and ThreadMXBean counts and lists threads. - Thread CPU times are measured for any live thread the way HotSpot measures them (thread_info on macOS, the thread's CPU clock and /proc on Linux). The thread lists hold the threads Java started and main. A jolt thread that called in isn't listed, and there are no JVM system threads such as Reference 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, so ThreadInfo.toString lists no - locked lines. 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. getThreadInfo without them, findDeadlockedThreads and dumpAllThreads(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 ones Runtime.totalMemory() and freeMemory() 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 daemon Notification Thread champaca 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 cause Allocation Failure, even when Chez collected every generation in it. getLastGcInfo gives the last collection, with no GC-specific attributes. - The CompilationMXBean's name is champaca (Chez Scheme) (the sun.management.compiler property), 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 is jdk.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 overrides loadClass (a child-first one) or a name some loader renamed, champaca takes what the loader's loadClass answers (test/aaa/siblingloaders.java, test/aaa/childfirst.java). The classes in an invokedynamic site'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 :classpath or :preview or 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 call save-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 Object parameter becomes an array, not a List.
  • A Java object prints as #object[:object] in jolt. Use ldk/to-string.
  • A jolt error that leaves one of your fns through Java is a plain java.lang.RuntimeException to Java, where on the JVM it would be Clojure's own ExceptionInfo. Java code that catches every exception and only logs it, or retries, swallows your error, as on the JVM.
  • proxy puts its proxies in the application class loader. A proxy method left out of the map runs its interface's default body, and Java code of your own can call InvocationHandler.invokeDefault as 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 switch keep 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-class made 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-init sets 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 the native modifier, as Java would.
  • Its methods are native methods, so reflection reports them with the native modifier, and they carry no generic signatures or throws lists. Annotations go on the class, its fields, methods and constructors, but not on parameters or types (TYPE_USE annotations on a field or method are refused). A @Repeatable annotation 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-class refuse 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 no module-info.class), but not .jmod files. A multi-release jar's classes are its base ones, on the module path as on the class path, and init! takes no module path. Where the output differs from HotSpot's: - A module path class's stack frame leaves out HotSpot's module/ prefix (app.Main.main(Main.java:7) for app/app.Main.main(...)). A missing class's NoClassDefFoundError trace 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 written jar:file:/... where HotSpot writes jar:file:///.... A non-ASCII path isn't percent-encoded either, and a directory's URL has no trailing slash. - Class.forName of a module path class through the boot loader proper names it slashed in its ClassNotFoundException. - MethodHandles.Lookup doesn't check module access, so findStatic on a class in a package that isn't exported to the caller succeeds where HotSpot throws IllegalAccessException. 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. And jdeps can't see a module a program reaches only by name: jdk.charsets and jdk.localedata always 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.invoke runs 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 by Class.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.file needs macOS, or Linux with glibc 2.33 or newer, on arm64 or x86_64. The file natives read struct stat, struct dirent, struct flock and struct statvfs through a table of offsets measured on each of those systems. They also call stat, 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.init throws UnsupportedOperationException instead of guessing. Only Ubuntu 26.04 (glibc 2.43) has been tested. java.io streams and RandomAccessFile don't depend on those layouts and work anywhere libc does. On Linux the birth time comes from statx, 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 WatchService polls in plain Java, but not on Linux, where it needs inotify natives champaca doesn't have yet.
  • A float is held as a Scheme double, so a signaling NaN's bits don't survive: Float.floatToRawIntBits(Float.intBitsToFloat(0x7fa00001)) is 0x7fe00001 here, as is an int written into a float[] through a memory segment and read back, where the JVM keeps 0x7fa00001. Doubles keep every NaN payload.
  • A thread waiting for a FileChannel lock 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 ldc of a class (a Foo.class literal) 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 throw NoClassDefFoundError when 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 through loadClass for 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=1 turns that inlining off. A loader's error other than ClassNotFoundException is 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 NoClassDefFoundError naming the cause from Class.forName and loadClass, as a class the JVM can't define throws a LinkageError. A library that probes for an optional class and catches only ClassNotFoundException stops with the cause instead of taking its fallback, while one catching Throwable or LinkageError carries on. A class file that doesn't parse at all is the JVM's ClassFormatError instead, 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 through getResource and getResources, 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. -jar with 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.vendor and java.vm.version say champaca, java.vm.info is compiled mode, sun.management.compiler names champaca's JIT, and jdk.reflect.useNativeAccessorOnly is set. java.library.path is empty unless a -D option 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 (LANG isn't read there, as on the JVM). On Linux it's LC_MESSAGES and LC_CTYPE, and a locale that isn't installed counts as en_US. Of a Linux locale's @modifier only a script (@latin, @cyrillic) is read, and the JDK's legacy aliases aren't applied. java.io.tmpdir is macOS's per-user temporary directory, and sun.java.command is set when champaca.main starts the program.
  • champaca.main reads -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, -Xlog and HotSpot's execution-mode and diagnostic -X options, 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, and Runtime.maxMemory reports it. When a full collection leaves more than that live, the allocation throws OutOfMemoryError: 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 a byte[] 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 above Integer.MAX_VALUE - 2 is Requested array size exceeds VM limit, and an array or a multi-dimensional array with no room for it is Java 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 WeakHashMap drops keys nobody holds, a Cleaner's action runs, and a direct buffer's native memory is freed once the buffer is unreachable (test/java/ReferenceCheck.java, and upstream's aaa/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 huge WeakHashMap) 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_LIMIT decides when set, and the jolt API leaves jolt's own bound alone. Past the bound a StackOverflowError is 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 a try in every frame that does work in its finally, 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:, -javaagent among them) stops with Unrecognized option, and the CLASSPATH environment 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 a MemorySegment work, through the JDK's memory segment code (test/java/VectorCheck.java). The vector math library (SLEEF, SVML) isn't loaded, so SIN, EXP and 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.
  • jsr and ret run 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 throws VerifyError.
  • 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 NoClassDefFoundError before 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 static ones 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:off it fails as champaca does.
  • Class loaders answer as on the JVM for your classes, the JDK's and the built-in loaders. A module-info on 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 for java.base, the platform loader for java.sql). - A class a loader defined at run time reports that loader. - A lambda's class reports its caller's loader and module, so lambda.getClass().getModule() == Main.class.getModule() holds, as on the JVM, and -ea reaches 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 in Class.forName) its own modules only.
  • A NullPointerException raised in champaca's own Scheme code, which stands in for some of the JDK's Java (the method handle layer, for one), has a null message 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 as Cannot invoke "String.length()" because "<local1>" is null (test/java/NpeCheck.java).