In some rare cases you might want to run Java code as a script. Let’s discover how to create portable, executable, robust, no-compilation-needed scripts with Java 21!

While most of the functionality described in this article has been around since Java 11 not everyone might be aware of it and it’s a useful trick to have in your toolbox. In fact, I only discovered it recently myself! I’m hoping it will come in handy for someone else too.

In this example, I need to be able to programmatically set Java User Preferences from a shell environment. The robust and portable1 way to do this is to use the Java Preferences API, but then I have to run Java code of course. What I want is a simple command line tool that takes three parameters: the name of the node in the user preference tree, the key and the value. I need it to run on Unix-like systems (Linux and macOS).

What are my options?

JShell has existed since Java 9 and I first tried to go that way but JShell is not a good fit for this use case. One reason is that JShell is too relaxed with errors. Even if your JShell script contains invalid syntax or if your expressions throw Throwables the JShell interpreter will still continue executing the shell until the end. And no matter what the jshell process will exit with status code 0. Another reason is that you have to jump through hoops if you want to robustly pass arguments to a JShell script.

An alternative is JBang which is excellent but requires additional software to be installed. This prevents using JBang in some use cases.

Instead, let’s use the JEP 330 Single-File Source-Code Programs mechanism introduced in Java 11. It allows us to write executables as plain Java programs without needing to manually compile them. And with JEP 445 in Java 21, we have instance main methods and unnamed classes so we can reduce boilerplate code.

How do I do it?

Here’s my file javauserprefadd. In my case I put the file in /usr/bin so it’s always available on the PATH.

///usr/bin/env java --source 21 --enable-preview "$0" "$@"; exit $?

import java.util.prefs.Preferences;

void main(String[] args) {
  String node = args[0];
  String key = args[1];
  String value = args[2];
  Preferences.userRoot().node(node).put(key, value);
  System.out.println("Java Preference " + node + " " + key + " set to " + value);
}

Make the file executable:

chmod +x javauserprefadd

Run the script:

./javauserprefadd "/an/example/node" "a.key" "a.value"

(You can omit ./ if the file is on your PATH).

Output:

Note: /usr/bin/javauserprefadd uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
Java Preference /an/example/node a.key set to a.value

Depending on your operating system, the preference is persisted at ~/.java/.userPrefs/ (Linux) or ~/Library/Preferences/ (macOS) by default.

Nice!

What’s going on here?

How can a shell simply run a Java file? There’s a happy coincidence that makes it possible.

To a Unix-like program loader, the first line of the file is an interpreter directive that tells the loader to run /usr/bin/env:

///usr/bin/env

The fact that it starts with triple slashes doesn’t make any difference, that’s just normalized into a single slash2. As far as the program loader is concerned, this could have been a single slash but in our case triple slash is important as we’ll see later on. By the way, JEP 330 suggests using a shebang pointing to the java executable but by using env our script doesn’t have to know where Java is installed.

The next word on the first line will cause env to run java:

///usr/bin/env java

The first arguments for java are --source 21 and --enable-preview:

///usr/bin/env java --source 21 --enable-preview

These are needed for two reasons. First, the --source argument allows the script to break the standard naming conventions for Java source files. With this argument we can name the file something else than the name of the class defined inside the file and we don’t have to use a .java file ending. Second, the --enable-preview argument activates JEP 445 features.

The filename of the script itself ($0) is passed as the <sourcefile> argument to java and any additional arguments that were passed to the script ($@) are passed as command-line arguments to the Java program:

///usr/bin/env java --source 21 --enable-preview "$0" "$@"

The Java launcher will both compile and run the program. And when the Java compiler looks at the file, the first line is just a simple comment since it starts with double slashes! This is the coincidence that makes all of this possible. As far as Java is concerned, the file is a Java source file that defines an unnamed class with an instance main method, while at the same time the Unix-like program loader considers the file an executable with an interpreter directive. The script is simultaneously speaking two different languages with two different meanings!

The program runs, sets the Java user preference to the value I want, prints information to stdout and exits with a status code, typically 0 if all went well.

Finally, when the Java process has terminated, the exit code from the Java process ($?) is propagated as the exit code of the script so that users of the script can handle errors in a predictable way. It also prevents the program loader from attempting to execute the rest of the file:

///usr/bin/env java --source 21 --enable-preview "$0" "$@"; exit $?

What if I have an older Java version?

As long as you have Java 11 or higher, you can still make Java shell scripts, you just need to declare a class and make the main method static:

///usr/bin/env java --source 11 "$0" "$@"; exit $?

import java.util.prefs.Preferences;

public class JavaUserPrefAdd {
  public static void main(String[] args) {
    String node = args[0];
    String key = args[1];
    String value = args[2];
    Preferences.userRoot().node(node).put(key, value);
    System.out.println("Java Preference " + node + " " + key + " set to " + value);
  }
}

What do I think about it?

This is so much more robust than using JShell. If there’s a Java syntax error in the file, java will complain and exit with status code 1. Likewise if the Java program throws a Throwable, exit code 1.

As you can see, the output does contain warnings that we’re using features that are still in preview. Once JEP 445 makes it out of preview we’ll get rid of those. If these warnings annoy you, see the Java 11 example for a script that works without any preview features.

In conclusion, JEP 330, JEP 445 and the triple slash trick with env makes it easy to leverage Java’s powerful standard libraries in a shell environment without having to create a project for compilation, packaging and distribution.

When can I not use it?

One limitation is that if you want everything self-contained like this you can’t have any dependencies on external jars, because then you have to distribute those too and the whole neatness falls apart. If dependencies are an absolute must, then JBang is the way to go.

And, we can’t be sure this will work in non Unix-like environments since it relies on the Unix-like program loader.

Footnotes

Jonas Lind

Consultant at Redpill Linpro

Jonas is a senior developer happy to work across different technologies, layers and disciplines to make every day better. He takes pride in his work as a craftsman with long experience of server-side development at telecom scale, cloud architecture, security and privacy, CI/CD and Linux.

Thoughts on the CrowdStrike Outage

Unless you’ve been living under a rock, you probably know that last Friday a global crash of computer systems caused by ‘CrowdStrike’ led to widespread chaos and mayhem: flights were cancelled, shops closed their doors, even some hospitals and pharmacies were affected. When things like this happen, I first have a smug feeling “this would never happen at our place”, then I start thinking. Could it?

Broken Software Updates

Our department do take responsibility for keeping quite a lot ... [continue reading]

Alarms made right

Published on June 27, 2024

Just-Make-toolbox

Published on March 22, 2024