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:
- Fast startup time
- Reasonable file size
- Good deployment story and possibility to target the main platforms: MacOS, Linux, Windows
- A good command line library
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.5 ms |
go |
1.7M | 1.3 ms |
haskell |
9.9M | 1.2 ms |
java (jar) |
809 | 92.0 ms |
java (native image) |
12M | 1.8 ms |
lua |
21 | 1.2 ms |
python |
45 | 29.3 ms |