Cloud-Native Java vs Golang

Igor Domrev
The Startup
Published in
10 min readJul 5, 2020

--

Photo by Raquel Martínez on Unsplash

Java once-famous motto: “Write once and run everywhere” is pretty much obsolete these days, the only place we want to run code is inside a container. Where a “Just in time” compiler does not make any sense.

For this reason, probably, the Java ecosystem is in the midst of its transformation in order to become better suited for the cloud. Oracle’s GraalVm allows compiling byte code to Linux executables (ELF) and Rad Heat’s Quarkus, and other frameworks, aspire to make it as easy as bootstrapping a react app. Quarkus also leverages Netty and Vertx.x at its core to build very efficient reactive web services.

quarkus official performance stats

Java compiled to executable binaries that launch in milliseconds, with a small memory footprint. That can leverage the Java ecosystem, and can even be written in other JVM languages like Scala and Kotlin!

sounds too good to be true…

If You don’t believe it, you can play with Quarkus using the online project generator or by generating a project locally using the maven plugin.

Golang on the other hand was born in the cloud and has no legacy to burden it when it comes to running in containers. It’s considered to be the programming language of the cloud. Small binaries, fast bootstrap, small memory footprint, all there from day one. And it’s wildly adopted. A serious challenge to the java world.

Does Java stand a chance? only time will tell. However, out of curiosity, I’d like to compare a java cloud-native service with a golang equivalent in terms of performance and development experience.

In this post, I will stress two services. Compare their CPU, RAM, latency, and uptime speed. The services will be launched in a container with the same resource allocation and apache benchmark will make them sweat.

Its a “good enough” benchmark for my case study, since I dont assume to find the best/worst benchmark results but a comparison of two benchmarks executed on the same environment.

The scenario

Both services will connect to a MySQL database that runs in another container, with one table and three rows.

the database

Every service will fetch all three rows, transform them into a domain object, and then write a JSON array response.

Apache benchmark will run 10K requests with a concurrency level of 100, two times for the quarkus JVM version ( to also test a “cold” / “warm” JVM )

the apache benchmark command

The Golang Service

Using a popular reactive web framework called gin that has great benchmarks.

When looking for a golang nonblocking MySQL driver I found nothing, the internet recommends go-sql-driver in a unison, so that’s what I’ll be using.

The golang style is very explicit. An In your face attitude. The main function launches the server, configures the request handler, and opens a DB connection.

Building the native go executable

Easy and fast build process. The only tool I had to use was the go compiler. No hustle at all.

The Kotlin Cloud Native Service — Quarkus

kotlin quarkus service

This is a Kotlin example that roughly follows the quarkus reactive MySql extension guide.

datasource configuration

Compared to the go version there are implicit stuff going on, CDI Dependency Injection, Declarative routes using javax annotations, automatic configuration parsing, and datasource/connection creation/server bootstrap. But this is a cost of using a framework, it does the heavy lifting for you and dictates its way of doing things. However, it’s much shorter than the go version and as long as the black magic works I don’t mind!

Under the hood, there’s a Netty reactive web server, wrapped by Vert.x multi-event loop and a Vert.x reactive MySQL driver that can handle multiple DB connections with a single thread.

Also, I can use Kotlin’s amazing collection library to fold a list wherein the go version that has no generics yet ( but coming soon ), and no rich standard collections library I'd had to write or generate it manually.

Building the Java native executable

It took 4 minutes, partly because Gradle executes the native image compilation inside a Linux GraalVM container.

Basically what I’ve been able to figure out about whats happening in the container that builds our native executable is that SubstrateVM. An embeddable virtual machine designed to be compiled ahead-of-time is linked to our code and compiled as one unit. This is amazing, but not without a cost, SubstrateVM has fewer optimizations then HotSpot Vm and a simpler garbage collector, according to oracle.

The compiler that does this is called “Graal” it’s language agnostic and before it can be used the java bytecode needs to be translated into an intermediate representation, a Truffle language. This is very interesting, A great Graal and Truffle explanation can be found in this post.

Building a java native image looks much more complex, it’s slower, it produces a binary almost twice the size. But it works! And a 35M executable binary file is really nothing compared to a java Uber (fat) Jar that can be easily ten time times larger. 35MB is something you can even put in an aws lambda.

Stressing the services

I’m running all tests on my local machine with the following setup:

Ill be using a:

MacBook Pro (15-inch, 2017)

2.9 GHz Intel Core i7 ( 8 cores )

16 GB 2133 MHz LPDDR3

Ill be using a tool called cAdvisor to monitor the stats of my containers.

The scenarios

  • quarkus jvm hotspot container
  • quarkus java native container
  • golang container

each with the following resources allocated

  • 100MB / 0.5 CPU | 200MB / 1 CPU | 300MB / 2 CPU

I’m interested in

  • cpu/ram utilization ( how well multi cores are utilized )
  • cpu/ram spikes
  • cpu/ram idle
  • bootstrap time
  • response latency avg/max
  • throughput ( requests per second )

Now I’m going to run a lot of benchmark tests and collect many data points for each. Feel free to jump to the summary at the end if that’s too much information

github repo with all the code of this experiment can be found here

quarkus jvm hotspot — 100MB / 0.5 CPU

launching the JVM version with 0.5 cpu and 100 ram
  • idle cpu usage 0.25%
  • idle ram usage 66MB
  • bootstrap time 6s
CPU usage during bootstrap. ( a spike , probably jit + launching JVM )

Stress Test Round 1 ( Cold JVM )

Surprisingly there were no failed requests.

CPU usage during stress.
RAM launched from 60 to almost 100 MB (limit) and stayed there.

Stress Test Round 2 ( warm JVM )

CPU and RAM usage was the same as the “cold” test, however, the avg/max latency and throughput saw a two times improvement factor!

quarkus jvm hotspot — 200MB / 1 CPU

  • idle cpu usage 0.13%
  • idle ram usage 66MB
  • bootstrap time 3s
CPU usage during bootstrap. ( a spike again )

Stress Test Round 1 ( Cold JVM )

CPU / RAM usage under stress
Surprisingly the JVM did not eat all the allocated 200MB and 140MB was sufficient

Stress Test Round 2 ( warm JVM )

Not bad at all

quarkus jvm hotspot — 300MB / 2 CPU

  • idle cpu/ram same as previous scenario
  • bootstrap time 1.1s ( NICE )
CPU usage during bootstrap, a spike again.

Stress Test Round 1 ( Cold JVM )

Good CPU utilzation
142 mb ram was sufficient

Stress Test Round 2 ( warm JVM )

Not bad at all.. the warm JVM with two cores produced the best results so far !

Now lets see how the native image will perform.

quarkus java Native — 100MB / 0.5 CPU

  • bootstrap time: 0.125s. ( !!! )
  • no cpu spike on startup
cpu / ram during bootstrap

Stress Test Results

worst results so far, repeated test sevral times without significant improvements. ( no warmup effect )
CPU reached 0.5 limit as expected
Good ram usage, 19MB active memory. WOW

quarkus java Native — 200MB / 1 CPU

  • instant bootstrap (0.0125s)
  • 4 idle ram usage
  • 19 ram usage under stress
  • 100% cpu utilization
  • no cpu spike on startup

Test results

significant improvement compared to the 0.5 CPU setup. ( almost as good as JVM with 2 cores )

quarkus java Native — 300MB / 2 CPU

no improvement.

golang — 100MB / 0.5 CPU

  • idle cpu 0
  • idle ram 2.3MB (nice)
  • bootstrap time: fraction of a second
  • no cpu spike on startup
gin server with go-sql-driver results

The results are a bit skewed. For some reason a tiny fraction of requests take ~7s to complete.

When attempting to run the test again to see if the skewd results reproduce ive the test actualy crushed !

go server logs

runtime error: invalid memory address or nil pointer dereference. mmm… May be im doing something wrong? It seems theres a bug in the go-sql library. The code that reads from a table is 100% as the documentation says, and works 99% of the time. This should not happen.

golang — 200MB / 1 CPU

best result so far

I keep getting the runntime error. suspiciously always at the end of the test. However, the correctnes of the go-mysql driver is not a main concern, so im terminating manually the test after it completes 90% of the requests.

CPU / RAM usage under stress

cpu utilization during stress
RAM usage during stress. 12.27MB, very nice.

golang — 300MB/ 2 CPU

no significant improvement, all stats are pretty much the same. CPU utilization is under 1.0. I wonder why go wasn’t able to utilize well more cores, Interesting… may be because the process is IO bound, Or may be gin needs to be manually configured to better utilize multiple cores.

Summary

aggregated stats ( warm jvm/native image | golang )

It seems Quarkus is production-ready, it allows an easy JVM / native release/dev modes and allows running native tests locally. And as long as you don’t use reflection or JNI , you are safe with regards to GraalVM configuration. Else you will have to configure the graal compiler yourself, and there are existing solutions for that as well.

Latency and throughput

Both the golang and cloud-native java produced similar results, though slightly in favor of the golang service on average. However, the java native results were more stable. The golang service sometimes responded within 1.25µs and very rarely within 7s.

The JVM after “warming up” produced good results, but worse than the native or go version.

CPU utilization

Both go and native-java seem to under-perform under load when given less than a single-core and displayed no significant improvement when launched with 2 cores. May be because the workload is IO bound. Or because the default configuration of gin/Netty does not account for multiple cores.

JVM on the other hand utilized all cores given to it, and improved its performance in all dimensions.

RAM usage

under stress, 40MB for the java native, and 24MB for the golang service. Not bad in both cases, although the golang version used almost twice less ram.

JVM used 140MB under stress. Exactly as the official quarkus stats. Not bad at all for a JVM but almost 6 times more than the golang version.

Bootstrap time

Both golang and cloud-native java start instantly, the JVM version on the other hand takes a few seconds ( depending on the allocated CPU ) and produces a CPU spike on startup. Which can cause k8s HPA to freak out and spin up twice the amount of pods you want if not properly configured, as described in my other post here.

Development expirience

This is more a religious than a practical question. So ill answer it cautiously. Quarkus creates abstractions well familiar in the java world ( like annotation-based DI ). It starts the service for you and creates the connection pool. It’s possible to use the rich collections standard library and generics. However, it can feel a bit like black magic, and once something stops working you can find yourself feeling helpless. In addition, compiling java code to a native binary is not that simple, there are limitations and caveats that you must be aware of, and not every java library will be compatible with the native compilation, though red hat does a great progress with their extensions. ( java libraries pre-configured for native compilation ). Using a library that is not compatible with the native compilation ( like Guice for example ) will require you to configure Graal VM manually. Which is possible, but is not straight forward like just using the jar. Quarkus and Graal VM are also “relatively” new. So there are many adventures waiting ahead. But because of the dual-mode ( JVM or native ). There is always a fallback in case something with the native version stops working, which is a great workaround to any emerging problem.

Golang on the other hand only now ( after 10 years of existence ) admitted it needs generics. And it certainly does not like implicit stuff going on. This is both good and bad in many ways. In addition, there are fewer tools and libraries available ( for example only one popular blocking MySQL-driver ), although the go community does a really good job catching up. On the other hand, its compilation and build process is so much faster / simpler. And every golang package will work for you without the limitations the java-native platform introduces.

Conclusion

Its great that java is becoming cloud native and Golang does not vastly over performs it like it does to the JVM . I’m sure its going to be wildly used in the future. But golang can definitely give a fight.

So choose carefully !

And don’t forget to water the cactus

Resources

--

--