Comparison between Java, Go, and Rust

Dex
9 min readApr 26, 2020

--

This is a comparison between Java, Go, and Rust. Not in the sense of a benchmark, but more of a comparison between the output executable file size, memory usage, CPU usage, run-time requirements, and of course a small benchmark to get some requests per second figures, and to try to make some sense of the numbers.

In an effort to try to compare apples to apples (maybe?), I have written a web service in each of the languages in this comparison. The web service is very simple, it serves three REST endpoints.

The endpoints served by the web service, in Java, Go, and Rust.

The repository for the three web services is hosted on github.

Artifact Size

Some information about how the binaries were built. In the case of Java, I have built everything into one big fat jar using the maven-shade-plugin and used the mvn packagetarget. In the case of Go, I used go build. And finally, for Rust I used cargo build --release.

Compiled size of each program in megabytes.

The artifact compiled size also depends on the chosen libraries/dependencies, so if they are bloated your compiled program will end up the same. In my specific case, for the libraries I have chosen, the above is the programs compiled size.

In a separate section, I will build and package all the three programs as docker images and will list their sizes as well to show the runtime overhead needed for each of these languages. More details below.

Memory Usage

Idle, doing nothing

Memory usage of each application while running idle in memory.

What? Where are the bars for Go and Rust versions that show the memory footprint while running idle? Well, they are there, only that Java consumes an upward of 160 MB when the JVM starts the program and sitting idle, doing nothing. In the case of Go, the program uses 0.86 MB, and 0.36 MB in the case of Rust. This is a big difference! Here Java uses two orders of magnitudes more memory than the Go and Rust counterparts, just sitting in memory doing nothing. That is a huge waste of resources.

Serving REST Requests

Lets hit the API with requests using wrk and observe the memory and CPU usages, and the number of requests per second achieved on my machine for each endpoint of the three versions of the program.

wrk -t2 -c400 -d30s http://127.0.0.1:8080/hello 
wrk -t2 -c400 -d30s http://127.0.0.1:8080/greeting/Jane
wrk -t2 -c400 -d30s http://127.0.0.1:8080/fibonacci/35

The above wrk commands say the following, use two threads (for wrk) and keep 400 open connections in the pool, and call the GET endpoint repeatedly for a duration of 30 seconds. Here I am using only two threads because both wrk and the program under test are running on the same machine, so I do not want them to compete (much) with each other on the available resources, especially the CPU.

Each web service was tested separately, and between each run, the web service was restarted.

The below are the best of three runs for each version of the program.

/hello

This endpoint returns a Hello, World! message. It allocates the string “Hello, World!” and serializes it and returns it in JSON format.

CPU usage while hitting the /hello endpoint
Memory usage while hitting the /hello endpoint
Requests per second while hitting the /hello endpoint

/greeting/{name}

This endpoint accepts a segment path parameter {name} and then formats the string “Hello, {name}!”, serializes and returns it as JSON formatted greeting message.

CPU usage while hitting the /greeting endpoint
Memory usage while hitting the /greeting endpoint
Requests per second while hitting the /greeting endpoint

/fibonacci/{number}

This endpoint accepts a segment path parameter {number} and returns the Fibonacci number and the input number serialized to JSON format.

For this specific endpoint I chose to implement it in a recursive form. I know with no doubt that an iterative implementation yields for far better performance results, and for production purposes one should choose an iterative form instead, but there are cases in production code where one has to use recursion (not specifically for calculating the nth Fibonacci number). So for this, I wanted the implementation to be heavily involved in CPU stack allocations.

CPU usage while hitting the /fibonacci endpoint
Memory usage while hitting the /fibonacci endpoint
Requests per second while hitting the /fibonacci endpoint

During the Fibonacci endpoint test, the Java implementation was the only one that timed out on 150 requests, as shown below in wrk’s output.

Timeouts
Latency for the /fibonacci endpoint

Runtime size

To mimic a real world cloud native application, and to eliminate the “it works on my machine!”, I have created a docker image for each of the three applications.

The source for the Docker files is included in the repository under the respective program’s folder.

As a base runtime image for the java application I have used openjdk:8-jre-alpine which is known to be one of the smallest images in size, however, this comes with a couple of caveats that may or may not apply to your application, mainly the alpine image is not posix compliant in terms of handling environment variable names, so you cannot use the . (dot) character in the ENV in the docker file (not a big deal), another one would be that the alpine Linux image is compiled with musl libc and not glibc, this means if your application depends on something that needs glibc (or friends) to be present, it simply wont work. In my case alpine works just fine.

As for both the Go and the Rust version of the application, I have statically compiled them, this means they do not expect a libc (glibc, musl…etc) to be present in the runtime image, also this means that they do not need a base image with an OS to run in. So I used the scratch docker image, which is a no-op image that hosts the compiled executable with zero overhead.

The naming convention for the docker images I used is {lang}/webservice. The image size for the Java, Go, and Rust version of the application, is 113, 8.68, and 4.24 MB respectively.

Final Docker images size

Conclusion

How the three languages compare

Before drawing any conclusions, I would like to point out the relationship (or the lack of) between these three languages. Both Java and Go are garbage collected languages, however, Java is compiled ahead-of-time (AOT) to bytecode that runs on the JVM. When a Java application is started, the Just-In-Time (JIT) compiler is invoked to optimizes the bytecode by compiling it to native code whenever and wherever possible to enhance the application’s performance.

Both Go and Rust are compiled to native code ahead-of-time, and no further optimization occurs at runtime.

Both Java and Go are garbage collected languages, with a stop-the-world side effect. Meaning whenever the garbage collector runs, it will stop the application, do the garbage collection, and when finished it resumes the application from where it left off. Most garbage collectors need to stop the world, however there are some implementations that seem to not require this.

When the Java language was created back in the 90's, one of its biggest selling points was Write once, Run anywhere. This was great at the time, as there weren’t many virtualisation solutions on the market. Nowadays, most CPUs support virtualisation, which nullifies the lure of developing with a language solely on the premise that the code will run anywhere (on any of supported platforms anyway.) Docker, and other solutions, provide virtualisation for cheap.

Throughout the tests, the Java version of the application consumed more memory than the Go or Rust counterparts in orders of magnitude, for the first two tests it was around 8000% more memory used by Java. This means for a real world application, the operating costs for a Java application is higher.

For the first two tests, the Go application used around 20% less CPU than Java while serving 38% more requests. The Rust version on the other hand, used 57% less CPU than Go while serving 13% more requests.

The third test is CPU intensive by design, and I wanted to squeeze every bit out of the CPU by it. Both Go and Rust utilized 1% more CPU than Java. And I think if wrk was not running on the same machine, all three versions would have capped the CPU at 100%. In terms of memory, Java used more than 2000% more memory than Go and Rust. Java was able to serve around 20% more requests than Go, while Rust served around 15% more requests than Java.

At the time of writing this article, the Java programming language had been around for almost three decades now, which makes it relatively easier to find Java developers for on the market. On the other hand, both Go and Rust are relatively new languages, so naturally the number or developers out there is less compared to Java. Both Go and Rust are gaining a lot of traction though, and many developers are adopting them for new projects, and there are many project running in production that use Go and Rust, because simply put, they are more efficient than Java in terms of resources needs. (And maybe because they are the new cool languages on the block!)

I learned both Go and Rust while I was writing the programs for this post. In my case, the learning curve for Go was short, as it is a relatively easy language to pick up, and the syntax is small compared to other languages. It took me only a couple of days to write the program in Go. One thing to note about Go is the compilation speed, and I have to admit that it is extremely fast compared to other languages like Java/C/C++/Rust. The Rust version of the program took me around a week to complete, and I have to say that most of that time was spent figuring out what the borrow checker wants from me. Rust has strict ownership rules, but once one grasps the concepts of ownership and borrowing in Rust, the compiler error messages will suddenly make a lot more sense. The reason the Rust compiler yells at you when the borrow checking rules are violated, is because the compiler wants to prove at compile time the lifetime and ownership of an allocated memory. By doing so, it guarantees the safety of the program (ex: no dangling pointers, unless unsafe code escapes were used), and the deallocation is determined at compile time, thus eliminating the need, and the runtime cost, of a garbage collector. This of course comes at the cost of learning Rust’s ownership system.

In terms of competition, in my opinion, Go is a direct competitor to Java (JVM languages in general), but not to Rust. On the other hand, Rust is a serious competitor to Java, Go, C, and C++.

Because of their efficiency, I see myself; and will be, writing more programs in both Go and Rust, but most likely more in Rust. Both of them are great for web-services, cli’s, system programs (..etc) development. Rust, however, has a fundamental advantage over Go. It is not a garbage collected language, and it is designed to be safe to write code in when contrasted to C and C++. Go is not particularly suitable, for example, for writing OS kernel in, and here again is where Rust shines, and competes with C/C++ as they are the long standing and defacto languages to write OS’s in. Another plane where Rust competes with C/C++ is in the embedded world, but I will keep this discussion for later.

Thank you for reading!

--

--