Saturday, March 21, 2026

JDK-26: incremental improvement

Time for celebration once again: the JDK-26 was released just a few days ago! Although from all perspectives this release looks like incremental improvement (not a feature fest), it is worth paying close attention to.

  • JEP 500: Prepare to Make Final Mean Final: issues warnings about uses of deep reflection to mutate final fields. These warnings aim to prepare developers for a future release that ensures integrity by default by restricting final field mutation, which will make Java programs safer and potentially faster. Application developers can avoid both current warnings and future restrictions by selectively enabling the ability to mutate final fields where essential using --enable-final-field-mutation=module1,module2,... and --illegal-final-field-mutation=allow|warn}debug|deny command line arguments.

  • JEP 516: Ahead-of-Time Object Caching with Any GC: enhances the ahead-of-time cache, which enables the HotSpot Java Virtual Machine to improve startup and warmup time, so that it can be used with any garbage collector, including the low-latency Z Garbage Collector (ZGC). Achieve this by making it possible to load cached Java objects sequentially into memory from a neutral, GC-agnostic format, rather than map them directly into memory in a GC-specific format.

    GC-specific cached objects are mapped directly into memory, while GC-agnostic cached objects are streamed into memory. You can explicitly create a cache whose objects are in the streamable, GC-agnostic format by specifying -XX:+AOTStreamableObjects.

  • JEP 517: HTTP/3 for the HTTP Client API: updates the HTTP Client API to support the HTTP/3 protocol, so that libraries and applications can interact with HTTP/3 servers with minimal code change.

      var client = HttpClient
          .newBuilder()
          .version(HttpClient.Version.HTTP_3)
          .build();
      

    Interestingly, JDK does not provide HTTP/3 server implementation (yet), however Netty library, the de facto standard in Java ecosystem for implementing high performance protocol servers (and clients), is supporting HTTP/3 in 4.2 release line.

  • JEP 522: G1 GC: Improve Throughput by Reducing Synchronization: increases application throughput when using the G1 garbage collector by reducing the amount of synchronization required between application threads and GC threads.

  • JEP 504: Remove the Applet API: removes the Applet API, which was deprecated for removal in JDK 17. It is obsolete because neither recent JDK releases nor current web browsers support applets.

There are a few JEPs that made into JDK-26 as preview features, all of them are carried over from the previous JDK releases.

  • JEP 524: PEM Encodings of Cryptographic Objects (2nd Preview): introduces an API for encoding objects that represent cryptographic keys, certificates, and certificate revocation lists into the widely-used Privacy-Enhanced Mail (PEM) transport format, and for decoding from that format back into objects. This is a preview API feature.

  • JEP 525: Structured Concurrency (6th Preview): simplifies concurrent programming by introducing an API for structured concurrency. Structured concurrency treats groups of related tasks running in different threads as single units of work, thereby streamlining error handling and cancellation, improving reliability, and enhancing observability. This is a preview API feature.

  • JEP 526: Lazy Constants (2nd Preview): introduces an API for lazy constants, which are objects that hold unmodifiable data. Lazy constants are treated as true constants by the JVM, enabling the same performance optimizations that are enabled by declaring a field final. Compared to final fields, however, lazy constants offer greater flexibility as to the timing of their initialization. This is a preview API feature.

    This feature used to be known as stable values (JDK-25) and was renamed to lazy constants to better capture its intended use cases.

  • JEP 530: Primitive Types in Patterns, instanceof, and switch (4th Preview): enhances pattern matching by allowing primitive types in all pattern contexts, and extend instanceof and switch to work with all primitive types. This is a preview API feature.

  • JEP 529: Vector API (11th Incubator): introduces an API to express vector computations that reliably compile at runtime to optimal vector instructions on supported CPUs, thus achieving performance superior to equivalent scalar computations.

Indeed, the list of JEPs is not very impressive, but it does not make JDK-26 less important. There are quite a lot of interesting fixes and improvements in this release.

For more elaborate overview of GC changes, please refer to JDK 26 G1/Parallel/Serial GC changes blog post. Besides just JEP 517, the java.net.http.HttpClient got quite a lot of attention in this JDK release, certainly worth highlighting separately.

Another notable changes in JDK include:

This is pretty much it but we haven't talked about security related changes, it is just about time.

If you look for a bit more in-depth overview of the security related changes, please check out JDK 26 Security Enhancements blog post.

To summarize, the gems of JDK-26 release, in my opinion, are JEP 522: G1 GC: Improve Throughput by Reducing Synchronization, JEP 517: HTTP/3 for the HTTP Client API, and JDK-8369238: Allow virtual thread preemption on some common class initialization paths. Those are truly game changing enhancements for quite a wide audience of applications and services. Java continues to impress!

I πŸ‡ΊπŸ‡¦ stand πŸ‡ΊπŸ‡¦ with πŸ‡ΊπŸ‡¦ Ukraine.

Friday, December 19, 2025

DuckDB: very useful were least expected

If you haven't heard about DuckDB yet, you definitely have to check it out. It is fascinating piece of technology for quick data exploration and analysis. But today we are going to talk about somewhat surprising but exceptionally useful area where DuckDB could be tremendously helpful - dealing with chatty HTTP/REST services.

One of the best things about DuckDB is that it is just a single binary (per OS/arch), with no additional dependencies, so the installation process is a breath.

Let me set the stage here. I have been working with OpenSearch (and Elasticsearch) for years, those are great search engines with very reach HTTP/REST APIs. The production grade clusters constitute hundreds of nodes, and at this scale, mostly every single cluster wide HTTP/REST endpoint invocation returns unmanageable JSON blobs. Wouldn't it be cool to somehow transform such JSON blobs into structured, queryable form somehow? Like relational table for example and run SQL queries over it, without writing a single line of code? It is absolutely doable with DuckDB and its JSON Processing Functions.

As an exercise, we are going to play with Nodes API which returns a detailed per node response, following deep nested JSON structure:

{
  "cluster_name" : "...",
  "_nodes" : {
     ...
  },
  "nodes" : {
    <node1> : {
        ...
    },
    <node2> : {
        ...
    },
    ...
    <nodeN> : {
        ...
    }
  }

Ideally, what we want is to flatten this structure into a table where each row represents individual node and each JSON key becomes an individual column by itself. To put things in the context, each node structure has nested arrays and objects, we will not recursively traverse them (although it is possible but needs more complex transformations). With that, let us start our exploration journey!

The first step is the simplest: extract nodes collection of objects from the Nodes API response and just feed it directly into DuckDB.

$ curl "https://localhost:9200/_nodes?pretty" -u admin:<password> -k --raw -s | duckdb -c "
  WITH nodes AS (
    SELECT key as id, value FROM  read_json_auto('/dev/stdin') AS r, json_each(r, '$.nodes')
  )
  SELECT * FROM nodes"

We would get back something like this (the OpenSearch cluster I use for tests has only two nodes, hence we see only two rows):

┌──────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│          id          │                                                                 value                                                                  │
│       varchar        │                                                                  json                                                                  │
├──────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ YQzVQ_kHT-WnYTReh0…  │ {"name":"opensearch-node2","transport_address":"10.89.0.3:9300","host":"10.89.0.3","ip":"10.89.0.3","version":"3.0.0","build_type":"…  │
│ J9a5OM8STainCdkaLm…  │ {"name":"opensearch-node1","transport_address":"10.89.0.2:9300","host":"10.89.0.2","ip":"10.89.0.2","version":"3.0.0","build_type":"…  │
└──────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Although we could stop just here (since DuckDB lets you query over JSON values easily), we would like to do something useful with value blob: we want it to become a table with real columns. There are many ways that could be accomplished in DuckDB, some do require precise JSON structure (schema) to be provided, some require predefined list of keys upfront. We would stick to dynamic exploration instead and extract all keys from the actual JSON data.

$ curl "https://localhost:9200/_nodes?pretty" -u admin:<password> | duckdb -c "
  WITH nodes AS (
    SELECT key as id, value FROM  read_json_auto('/dev/stdin') AS r, json_each(r, '$.nodes')
  ),
  all_keys AS (
    SELECT distinct(unnest(json_keys(value))) AS key FROM nodes
  )
  SELECT * FROM all_keys"

In the version of the OpenSearch I am running, there are 22 unique keys (JSON field names) returned, an example of the output is below:

┌────────────────────────────────┐
│              key               │
│            varchar             │
├────────────────────────────────┤
│ plugins                        │
│ jvm                            │
│ host                           │
│ version                        │
│ build_hash                     │
│ ...                            │
│ modules                        │
│ build_type                     │
│ os                             │
│ transport                      │
│ search_pipelines               │
│ attributes                     │
├────────────────────────────────┤
│            22 rows             │
└────────────────────────────────┘

Good progress so far, but we need to go over the last mile and build a relational table out of these pieces. This is where DuckDB's powerful PIVOT statement comes in very handy.

$ curl "https://localhost:9200/_nodes?pretty" -u admin:<password> | duckdb -c "
  WITH nodes AS (
    SELECT key as id, value FROM  read_json_auto('/dev/stdin') AS r, json_each(r, '$.nodes')
  ),
  all_keys AS (
    SELECT distinct(unnest(json_keys(value))) AS key FROM nodes
  ),
  keys AS (
    SELECT * FROM all_keys WHERE key not in ['plugins', 'modules']
  )
  SELECT id, node.* FROM nodes, (PIVOT keys ON(key) USING first(json_extract(value, '$.' || key))) as node"

And here we are:

┌──────────────────────┬──────────────────────┬──────────────────────┬──────────────────────┬───┬──────────────────────┬──────────────────────┬──────────────────────┬───────────────────┬─────────┐
│          id          │     aggregations     │      attributes      │      build_hash      │ … │     thread_pool      │ total_indexing_buf…  │      transport       │ transport_address │ version │
│       varchar        │         json         │         json         │         json         │   │         json         │         json         │         json         │       json        │  json   │
├──────────────────────┼──────────────────────┼──────────────────────┼──────────────────────┼───┼──────────────────────┼──────────────────────┼──────────────────────┼───────────────────┼─────────┤
│ YQzVQ_kHT-WnYTReh0…  │ {"adjacency_matrix…  │ {"shard_indexing_p…  │ "dc4efa821904cc2d7…  │ … │ {"remote_refresh_r…  │ 53687091             │ {"bound_address":[…  │ "10.89.0.3:9300"  │ "3.0.0" │
│ J9a5OM8STainCdkaLm…  │ {"adjacency_matrix…  │ {"shard_indexing_p…  │ "dc4efa821904cc2d7…  │ … │ {"remote_refresh_r…  │ 53687091             │ {"bound_address":[…  │ "10.89.0.2:9300"  │ "3.0.0" │
├──────────────────────┴──────────────────────┴──────────────────────┴──────────────────────┴───┴──────────────────────┴──────────────────────┴──────────────────────┴───────────────────┴─────────┤
│ 2 rows                                                                                                                                                                      21 columns (9 shown) │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

As we set for the goal, we have transformed each node from JSON to structured table. There is one subtle quirk to mention, the presence of the additional step to filter out plugins and modules fields from the transformations, DuckDB seems to have difficulties pivoting those:

Binder Error:
PIVOT is not supported in correlated subqueries yet

I hope you find it useful, typical enterprise grade HTTP/REST services often throw a pile of JSON at you, try to make sense of it!

I πŸ‡ΊπŸ‡¦ stand πŸ‡ΊπŸ‡¦ with πŸ‡ΊπŸ‡¦ Ukraine.

Sunday, September 21, 2025

JDK-25: The Next Big Thing

Not exactly sure why but JDK-25 is a long awaited release. Probably because it is the next LTS (or whatever it means these days), or probably because it establishes a new baseline where there is no place for SecurityManager anymore. In any case, let us talk about everything that JDK-25 bundles in, starting with the stable features first.

  • JEP-506: Scoped Values: introduces scoped values, which enable a method to share immutable data both with its callees within a thread, and with child threads. Scoped values are easier to reason about than thread-local variables. They also have lower space and time costs, especially when used together with virtual threads and structured concurrency.

    Since the API is final now, let us take a look at it closely.

       private static final ScopedValue<Object> CONTEXT = ScopedValue.newInstance();
    
       executor.submit(() -> ScopedValue.where(CONTEXT, new Object()).run(() -> {
           final Object context = CONTEXT.get();
           // Use 'context'
        }));
      

    In simple terms, you can think of scoped values as an immutable ThreadLocals, however their true power kicks in with structured concurrency, which sadly is still in preview in JDK-25.

  • JEP-511: Module Import Declarations: enhances the Java programming language with the ability to succinctly import all of the packages exported by a module. This simplifies the reuse of modular libraries, but does not require the importing code to be in a module itself.

    This is pretty useful and simple enhancement that helps with imports explosion, for example:

      import module jdk.jfr;
     
  • JEP-512: Compact Source Files and Instance Main Methods: evolves the Java programming language so that beginners can write their first programs without needing to understand language features designed for large programs. Far from using a separate dialect of the language, beginners can write streamlined declarations for single-class programs and then seamlessly expand their programs to use more advanced features as their skills grow. Experienced developers can likewise enjoy writing small programs succinctly, without the need for constructs intended for programming in the large.

    It is now possible to omit some boilerplate when using the language:

      void main() {
          IO.println("Hello, World!");
      }
      
  • JEP-510: Key Derivation Function API: introduces an API for Key Derivation Functions (KDFs), which are cryptographic algorithms for deriving additional keys from a secret key and other data.

  • JEP-503: Remove the 32-bit x86 Port: removes the source code and build support for the 32-bit x86 port. This port was deprecated for removal in JDK 24.

  • JEP-514: Ahead-of-Time Command-Line Ergonomics: makes it easier to create ahead-of-time caches, which accelerate the startup of Java applications, by simplifying the commands required for common use cases.

    This is really nice improvement over two-step workflow in JDK 24 (please notice a new -XX:AOTCacheOutput command line flag), only one step is now required:

    $ java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...

    As a convenience, when operating in this way the JVM creates a temporary file for the AOT configuration and deletes the file when finished. The command line to run the application stays the same:

    $ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...

    A new environment variable, JDK_AOT_VM_OPTIONS, can be used to pass command-line options that apply specifically to cache creation (AOTMode=create), without affecting the training run (AOTMode=record). The syntax is the same as for the existing JAVA_TOOL_OPTIONS environment variable. This enables the one-step workflow to apply even in use cases where it might seem that two steps are necessary due to differences in the command-line options.

  • JEP-513: Flexible Constructor Bodies: in the body of a constructor, allows statements to appear before an explicit constructor invocation, i.e., super(...) or this(...). Such statements cannot reference the object under construction, but they can initialize its fields and perform other safe computations. This change allows many constructors to be expressed more naturally. It also allows fields to be initialized before they become visible to other code in the class, such as methods called from a superclass constructor, thereby improving safety.

    In my opinion, this feature would greatly improve the readability of the class initialization, let us take a look at the example:

         class ByteArrayInputStreamInputStream extends ByteArrayInputStream {
            public ByteArrayInputStreamInputStream(int size) {
                if (size <= 0) {
                    throw new IllegalArgumentException("The size has to be greater than 0");
                }
                super(new byte[size]);
            }
        }
        

    In pre-JDK-25, super(...) had to be the first statement in the constructor body and we would have no choice but to implement a function to validate the size and return new byte array (or throw an IllegalArgumentException exception).

  • JEP-519: Compact Object Headers: changes compact object headers from an experimental feature (introduced in JDK 24) to a product feature. To enable this feature pass command line option:

    $ java -XX:+UseCompactObjectHeaders
  • JEP-521: Generational Shenandoah: changes the generational mode of the Shenandoah garbage collector from an experimental feature (introduced in JDK 24) to a product feature. The generational mode could be enabled through command line flags:

    $ java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational
  • JEP-515: Ahead-of-Time Method Profiling: improves warmup time by making method-execution profiles from a previous run of an application instantly available, when the HotSpot Java Virtual Machine starts. This will enable the JIT compiler to generate native code immediately upon application startup, rather than having to wait for profiles to be collected.

  • JEP-518: JFR Cooperative Sampling: improves the stability of the JDK Flight Recorder (JFR) when it asynchronously samples Java thread stacks. Achieves this by walking call stacks only at safepoints, while minimizing safepoint bias.

    There is a new event introduced, jdk.SafepointLatency, which records the time it takes for a thread to reach a safepoint, for example:

        $ java -XX:StartFlightRecording:jdk.SafepointLatency#enabled=true,filename=recording.jfr
        $ jfr print --events jdk.SafepointLatency recording.jfr
        
  • JEP-520: JFR Method Timing & Tracing: extends the JDK Flight Recorder (JFR) with facilities for method timing and tracing via bytecode instrumentation.

    There are two new JFR events introduced, jdk.MethodTiming and jdk.MethodTrace, they both accept a filter to select the methods to time and trace, couple of the examples below:

        $ java '-XX:StartFlightRecording:jdk.MethodTrace#filter=java.util.HashMap::resize,filename=recording.jfr' ...
        $ jfr print --events jdk.MethodTrace --stack-depth 20 recording.jfr
        
        $ java '-XX:StartFlightRecording:filename=fd.jfr,method-trace=java.io.FileDescriptor::<init>java.io.FileDescriptor::close' ..
        $ jfr view --cell-height 5 MethodTrace fd.jfr
        
        $ java '-XX:StartFlightRecording:method-timing=::<clinit>,filename=clinit.jfr' ...
        $ jfr view method-timing clinit.jfr
       

    A filter can also name an annotation. This causes all methods bearing the annotation, and all methods in all classes bearing the annotation, to be timed or traced.

        $ jcmd <pid> JFR.start method-timing=@jakarta.ws.rs.GET
        

    It is also possible to use JMX and the JFRs RemoteRecordingStream class to configure timing and tracing over the network.

From all perspectives, the list of the finalized features that made it into JDK-25 is rock solid. But the release also bundles a number of a new experimental and preview APIs, in addition to carried over ones.

Besides JEPs, JDK-25 has plenty of enhancements across the board, including bugfixes and changes in the behavior that may affect the existing applications.

Moving on to the standard library, JDK-25 delivers rather moderate changes, but quite handy nonetheless. Let us take a look at those.

With respect to security, there are a few changes worth mentioning:

Last but not least, few regressions slipped into JDK-25 at the last moment, please be aware of those:

My personal retrospective on the JDK-25 release, among many other things, highlights a significant progress of the Project Leyden and substantial investments into JDK Flight Recorder (JFR) tooling and instrumentation. Let us see what comes next, the lineup for JDK-26 already looks exciting.

I πŸ‡ΊπŸ‡¦ stand πŸ‡ΊπŸ‡¦ with πŸ‡ΊπŸ‡¦ Ukraine.

Sunday, June 29, 2025

(Mostly) Zero Trust: Sandboxing Java applications with GraalVM and Espresso

With the death of SecurityManager, isolating untrusted Java code execution (for example, in case of plugins or scripting) becomes even more difficult. But, luckily, the maturity of GraalVM, both from features and stability perspectives, offers a compelling solution to sandboxing Java code.

In today's post, we are going to explore GraalVM polyglot capabilities, more specifically GraalVM Espresso - an implementation of the Java Virtual Machine Specification built upon GraalVM as a Truffle interpreter. To make things more interesting, there are two runtime models that we are going to talk about:

  • out of process: isolated host/guest JVM with prebuilt Espresso runtime
  • out of process: isolated, potentially even different, host/guest JVM runtimes

At first, the amount of the details that GraalVM polyglot programming throws at you feels overwhelming, but the documentation really helps to navigate over them. The latest version of the GraalVM available as of this writing is 24.2.1 and this is what we are going to use. To keep things simple but meaningful, we are going to sandbox an application that:

  • exposes one interface ApplicationService
          public interface ApplicationService {
              ApplicationInfo getApplicationInfo();
              ApplicationInfo setApplicationInfo(ApplicationInfo info);
          }
          
  • exposes one custom data type (Java record)
          public record ApplicationInfo(String env, String version) {
          }
          
  • provides the default ApplicationService implementation
        public class AppRunner {
            public static void main(String[] args) {
                System.out.println(getApplication().getApplicationInfo());
            }
    
            public static ApplicationService getApplication() {
                return new ApplicationService() {
                    private ApplicationInfo info = new ApplicationInfo("unconstrained", "1.0.0");
    
                    @Override
                    public ApplicationInfo getApplicationInfo() {
                        return info;
                    }
    
                    @Override
                    public ApplicationInfo setApplicationInfo(ApplicationInfo info) {
                        this.info = info;
                        return info;
                    }
                };
            }
       }
          

What the host will be doing with this sandboxed application? Quite simple: it will get access to default ApplicationService implementation, query the ApplicationInfo and change it to its own provided value. Such an exercise would demonstrate the essential interoperability dance between host and guest in the GraalVM world. What is interesting, the guest (application we are going to sandbox) has no idea it is running inside the GraalVM Espresso managed runtime.

Let us start building with provided GraalVM Espresso runtime (which is based on JDK-21). Here are the dependencies that we need to get started.

        <dependency>
            <groupId>org.graalvm.polyglot</groupId>
            <artifactId>polyglot</artifactId> 
            <version>24.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.graalvm.espresso</groupId>
            <artifactId>espresso-language</artifactId> 
            <version>24.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.graalvm.espresso</groupId>
            <artifactId>polyglot</artifactId>
            <version>24.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.graalvm.espresso</groupId>
            <artifactId>espresso-runtime-resources-jdk21</artifactId>
            <version>24.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.graalvm.polyglot</groupId>
            <artifactId>java-community</artifactId> 
            <version>24.2.1</version>
            <type>pom</type>
        </dependency>

To have access to sandboxed application types, we would also need to include the dependency on its API (just for sake of simplicity, we include the application module itself):

        <dependency>
            <groupId>com.example.graalvmt</groupId>
            <artifactId>app</artifactId> 
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

The bootstrapping is pretty straightforward. The first thing the host application has to do is to create a new polyglot engine:

        final Engine engine = Engine
            .newBuilder()
            .build();

The next step establishes the polyglot context and is a bit more engaging. Please don't panic if some things are not clear from the start, we are going to look at all of them shortly.

        final Context context = Context
            .newBuilder("java")
            .option("java.Properties.java.class.path", application)
            .option("java.Polyglot", "true")
            .option("java.EnableGenericTypeHints", "true")
            .allowExperimentalOptions(true)
            .allowNativeAccess(true)
            .allowCreateThread(false)
            .allowHostAccess(HostAccess
                .newBuilder(HostAccess.ALL)
                .targetTypeMapping(
                        Value.class,
                        ApplicationInfo.class,
                        v -> true,
                        v -> new ApplicationInfo(
                            v.invokeMember("env").asString(), 
                            v.invokeMember("version").asString())
                    )
                .build()
            )
            .allowIO(IOAccess.NONE)
            .allowPolyglotAccess(PolyglotAccess
                .newBuilder()
                .allowBindingsAccess("java")
                .build())
            .engine(engine)
            .build();

Let us go over this snippet line by line. Since this is a polyglot context, we limit its languages to java only. We also provide the classpath to our sandboxed application JAR file using java.Properties.java.class.path property. We explicitly prohibiting thread creation with allowCreateThread(false) and I/O access with allowIO(IOAccess.NONE) but have to allow native access (allowNativeAccess(true)), this is a limitation of the GraalVM Espresso engine at the moment (please check [Espresso] Support running without native access. for more details).

The host access configuration needs some wider explanation. We do allow access to host from guest (sandboxed application) with HostAccess.ALL policy and also provide the type mapping for ApplicationInfo record class so we could reconstruct it from generic polyglot Value class. Last but not least, we allow polyglot access java bindings with allowBindingsAccess("java"). And by and large, this is it!

With the context bootstrapped, we are ready to interact with the sandboxed application (or in terms of GraalVM, guest) through polyglot bindings. To illustrate the host/guest isolation, let us make an inquiry about their JVM runtime versions.

        final Value runtime = context.getBindings("java").getMember("java.lang.Runtime");
        System.out.println("Host JVM version: " + Runtime.version());
        System.out.println("Guest JVM version: " + runtime.invokeMember("version"));
  

Depending on your system and settings, you may see something along these lines:

Host JVM version: 24.0.1+9-30
Guest JVM version: 21.0.2+13-LTS-jvmci-23.1-b33

Clearly, the host and guest JVMs are far apart. According to our plan, let us retrieve the instance of the ApplicationService and ask for ApplicationInfo:

        final ApplicationService service = context.getBindings("java")
            .getMember("com.example.AppRunner")
            .invokeMember("getApplication")
            .as(ApplicationService.class);
        System.out.println("ApplicationInfo? " + service.getApplicationInfo());

We should see in the console:

ApplicationInfo? ApplicationInfo[env=unconstrained, version=1.0.0]

Now let us change the ApplicationInfo by construction a new instance on the host and sending it back to the guest, using ApplicationService instance at hand:

        final ApplicationInfo newInfo = context.getBindings("java")
            .getMember("com.example.ApplicationInfo")
            .newInstance("sandboxed", "1.0.0")
            .as(ApplicationInfo.class);

        final ApplicationInfo info = service.setApplicationInfo(newInfo);
        System.out.println("ApplicationInfo? " + info);

This time, we should see in the console a different picture:

ApplicationInfo? ApplicationInfo[env=sandboxed, version=1.0.0]

With all details (hopefully) explained, we could see how things work in action. The easiest way to run the host application is by using Apache Maven and its Exec Maven Plugin (please change JAVA_HOME accordingly):

$ export JAVA_HOME=/usr/lib/jvm/java-24-openjdk-amd64/
$ mvn package exec:java -Dexec.mainClass="com.example.graalvm.sandbox.SandboxRunner" -Dexec.args="app/target/app-0.0.1-SNAPSHOT.jar" -f sandbox-bundled-jvm/

Here is how the complete output in the console looks like:

Host JVM version: 24.0.1+9-30
Guest JVM version: 21.0.2+13-LTS-jvmci-23.1-b33
ApplicationInfo? ApplicationInfo[env=unconstrained, version=1.0.0]
ApplicationInfo? ApplicationInfo[env=sandboxed, version=1.0.0]

Let us recap what we have done: we basically isolated the application we wanted to sandbox into separate JVM instance but preserved full access to its API, limiting some very key JVM capabilities (no threads, no I/O). This is very powerful but has one limitation: we are bounded by the GraalVM Espresso runtime. What if we could have used any other JVM? Good news, it is totally an option and this is what we are going to do next.

As of this writing, GraalVM Espresso allows running either Java 8, Java 11, Java 17, or Java 21 guest JVMs. However, the host could be running any other JVM, and why not latest JDK-24? There aren't actually too many changes that we need to do to the Context:

    final Context context = Context
            .newBuilder("java")
            .option("java.JavaHome", jdkHome)
            .option("java.Classpath", application)
            .option("java.Properties.java.security.manager", "allow")
            .option("java.PolyglotInterfaceMappings", getInterfaceMappings())
            .option("java.Polyglot", "true")
            .option("java.EnableGenericTypeHints", "true")
            .allowExperimentalOptions(true)
            .allowNativeAccess(true)
            .allowCreateThread(false)
            .allowHostAccess(HostAccess
                .newBuilder(HostAccess.ALL)
                .targetTypeMapping(
                        Value.class,
                        ApplicationInfo.class,
                        v -> true,
                        v -> new ApplicationInfo(
                            v.invokeMember("env").asString(), 
                            v.invokeMember("version").asString())
                    )
                .build()
            )
            .allowIO(IOAccess.NONE)
            .allowPolyglotAccess(PolyglotAccess
                .newBuilder()
                .allowBindingsAccess("java")
                .build())
            .engine(engine)
            .build();

Notice that we do provide own JDK installation (using java.JavaHome) and classpath (using java.Classpath). More to that, we have an opportunity to pass any additional JVM properties (for example, "java.Properties.java.security.manager", "allow" is equivalent to passing -Djava.security.manager=allow, however we don't really use it). The new option here is java.PolyglotInterfaceMappings:

    private static String getInterfaceMappings(){
        return "com.example.ApplicationService;";
    }

It allows to automatically constructing guest proxies for host objects that implement declared interfaces in the list (essentially, we could provide our own ApplicationService and push it to the guest). And one important note, we don't need espresso-runtime-resources-jdk21 dependency anymore. Everything else stays unchanged.

If we run the host application this time:

$ export JAVA_HOME=/usr/lib/jvm/java-24-openjdk-amd64/
$ mvn package exec:java -Dexec.mainClass="com.example.graalvm.sandbox.SandboxRunner" -Dexec.args="/usr/lib/jvm/java-17-openjdk-amd64/ app/target/app-0.0.1-SNAPSHOT.jar" -f sandbox-custom-jvm/

The output will be slightly different:

Host JVM version: 24.0.1+9-30
Guest JVM version: 17.0.15+6-Ubuntu-0ubuntu124.04
ApplicationInfo? ApplicationInfo[env=unconstrained, version=1.0.0]
ApplicationInfo? ApplicationInfo[env=sandboxed, version=1.0.0]

Sky is the limit! GraalVM with its powerful polyglot features and GraalVM Espresso engine offers an unique capability to isolate (sandbox) untrusted Java code execution, using a JDK of your choice (you can even run JDK-21 with full-fledged SecurityManager activated if dimed necessary). We have just looked into some foundational concepts (there are still some limitations) however there are much more to uncover here (and please, note that GraalVM evolves very fast, the limitations of today become features of tomorrow). Hope you find it useful.

The complete source code of the project is available on Github.

I πŸ‡ΊπŸ‡¦ stand πŸ‡ΊπŸ‡¦ with πŸ‡ΊπŸ‡¦ Ukraine.