Saturday, May 14, 2022

Can you write a CLI using Java?

A recent conversation got me curious about how people feel about writing command line applications in Java.

I knew that Java for many isn’t the first choice when thinking of building a CLI, but I was curious if people consider it an option at all. I started a poll on Twitter. Only 19 people participated, but the outcome was quite clear:

Java is a good choice to write command line applications

Option Votes
Agree 5.3%
Disagree 68.4%
No way, are you crazy 26.3%

If you asked me several years ago I’d have had a similar reaction, but in 2022 I’m not so sure anymore.

When choosing a programming language for a given problem I tend to look at various aspects. Most importantly if the programming language can meet the applications requirements. I think the biggest weight should be on whether there are good libraries for a given problem domain, but there are some general requirements specific for CLI applications. A few of those come to mind:

Startup time

Java is notorious for having a slow startup time, but how bad is it?

Humans perceive anything below 100ms as instant. That makes a good upper boundary. What would be a lower boundary? Let’s find out by writing some c and measuring the time using hyperfine.

Startup of a c application

#include <stdio.h>

int main()
{
    printf("Hello world\n");
    return 0;
}
> gcc main.c
> hyperfine ./a.out
Benchmark 1: ./a.out
  Time (mean ± σ):       0.5 ms ±   0.1 ms    [User: 0.4 ms, System: 0.0 ms]
  Range (min … max):     0.4 ms …   0.9 ms    4013 runs

That’s pretty quick and kinda where I’d expect it to be considering the work it has to do. How does Java fare in comparison?

Startup of a java application

I set up a small gradle project with gradle init --type java-application and adjusted the generated App:

package demo;

public class App {

    public static void main(String[] args) {
        System.out.println("Hello World");
    }
}

I updated the build.gradle to include a Main-Class entry in the manifest of the jar and built a jar file using ./gradlew jar before running hyperfine:

hyperfine "java -jar app/build/libs/app.jar"

The results:

Benchmark 1: java -jar app/build/libs/app.jar
  Time (mean ± σ):      92.0 ms ±  13.4 ms    [User: 85.7 ms, System: 19.1 ms]
  Range (min … max):    63.6 ms … 108.2 ms    27 runs

Thats pretty bad compared to the c example and close to the upper boundary.

Startup of a java application using GraalVM

Luckily we can do better than that. GraalVM supports ahead of time compilation, significantly reducing the startup time of an application written in Java.

I adjusted the build.gradle file to include the GraalVM native-build-tools:

plugins {
    id 'application'
    id 'org.graalvm.buildtools.native' version '0.9.11'
}

After including this plug-in it’s possible to generate a native executable using ./gradlew nativeCompile.

hyperfine -N ./app/build/native/nativeCompile/app
Benchmark 1: ./app/build/native/nativeCompile/app
  Time (mean ± σ):       1.8 ms ±   0.3 ms    [User: 0.9 ms, System: 0.7 ms]
  Range (min … max):     1.2 ms …   2.6 ms    1349 runs

Still slower than the c version, but much better. There should be plenty of legroom for application logic before reaching the 100ms threshold.

Startup of a go application

Let’s look at one other contender and create a go hello world application:

package main

import "fmt"

func main() {
  fmt.Println("Hello world");
}
> go build
> hyperfine -N ./hello
Benchmark 1: ./hello
  Time (mean ± σ):       1.3 ms ±   0.2 ms    [User: 1.0 ms, System: 0.4 ms]
  Range (min … max):     0.8 ms …   1.8 ms    1648 runs

A little bit faster, but not by much. This is comparable to the Java GraalVM native image version.

File size

Disk space has become pretty cheap. I wouldn’t put too much weight on this, but depending on the use case it may matter.

Language Size
c 16K
go 1.7M
java (jar) 809
java (native image) 12M

Using a tool like upx it would be possible to get the file size down further. upx app/build/native/nativeCompile/app decreases the file size to 3.5M but increases the startup time to ~80ms, due to the added decompression overhead.

Deployment

Cross-platform support was one of the original Java “killer” features. Build the jar once and run it on any system that has a JVM. But startup time for the jar was slow and typing java -jar .. makes for a poor user interface. Most applications written in Java include shell scripts as entry point to have a more convenient interface. Unless you force people to have bash on Windows you need at least two different scripts. One for Windows and one for both Linux and MacOS. Far from ideal.

The alternative we already looked at is building a native image. As we’ve seen this is possible with GraalVM, but without built-in cross-compilation support. This means you’ll have to setup Github Actions or a similar system to build native images for all systems.

The point here would go to Go, which since 1.5 has fantastic cross-compilation support. As far as I’m aware of, among the best of any language.

Command line library

Last but not least, if you built a command line application you likely want to have good library support to parse command line arguments. Ideally it also offers some support go generate bash and zsh completions, to avoid having to do that manually.

You probably also want to follow some command line interface guidelines and have features like displaying a help page if the user provides no options, -h or --help flag. A good command line library makes that easy.

Most languages have such a library and Java is no exception. picocli checks pretty much all requirements.

Conclusion

I’d argue that Java is competitive despite not topping out in the given criteria. If there are good Java libraries for your problem domain, and if your team consists of Java engineers, there is no compelling reason to use another language.

Ramping up on tooling and language quirks takes time. If using one language over another would bring significant productivity advantages I’d expect to see this better reflected on the market. We don’t even know for sure if programming languages have measurable impact on defect rates

Here a final overview with a few more languages. Note for Java jar, Python and Lua you’d probably have to count the JVM, Python and Lua interpreter for a fair comparison in file size.

Language File size Startup time (mean)
c 16K 0.5ms
java (jar) 809 92.0ms
java (native image) 12M 1.8ms
go 1.7M 1.3ms
python 45 29.3 ms
haskell 9.9M 1.2ms
lua 21 1.2ms