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
-
The Java Preference API’s backing store format is vastly different between Mac (MacOSXPreferences.java) and Linux (FileSystemPreferences.java). ↩
-
POSIX Pathname Resolution, more than two leading slash characters shall be treated as a single slash character ↩