There are many different dimensions to this answer, and I can't guarantee that I will hit them all, or even that I will hit the most significant ones. Here are just the ones that come to mind.
If you haven't read the following Stack Overflow question, then go ahead and so so now:
How does Go compile so quickly?
First off, as always, it is important to accurately define what, precisely, you are measuring. You are not measuring AOT compilation of Go vs. AOT compilation of Java. You are measuring AOT compilation of Go using a specific version of a specific implementation of Go vs. AOT compilation using a specific version of a specific implementation of Java.
For example, there is no reason to expect that AOT compilation of Go using the gc compiler and AOT compilation of Go using the gccgo compiler will take the same time. In fact, it is well known that gccgo is slower than gc. So, if compiling the same program written in the same programming language using two different compilers takes significantly longer on one compiler than the other, it should be immediately obvious that the compile time cannot possibly be solely a property of the programming language.
The architecture of gc is based on the architecture of the Plan 9 C Compilers, which are widely known to be extremely fast. The Plan 9 compilers and as a result also the gc compiler are specifically designed to be fast.
The truth of the matter is that most modern compilers are actually not designed to be fast. They make a lot of design decisions that actively work against them being fast. For example, most C compilers primarily targeting Unixoid systems (e.g. GCC) work based on a very traditional split of responsibilities between the compiler, the assembler, the archiver, and the linker: the compiler generates assembly code which it stores on disk as a text file. The assembler (which could be from a totally different project with totally different developers) reads the text file and produces an object file, which it again stores on disk. The archiver reads multiple object files and stores them in an archive file on disk. The linker reads multiple archive files and links them together into an executable which it stores on disk.
The gc compiler, OTOH skips many of those steps. The compiler produces a specially designed binary assembly format, which it hands to the assembler in memory, without ever storing it on disk. The binary assembly format is much more compact than textual assembly, it is much faster to generate and much faster to process, since it is designed for machines, whereas textual assembly languages are designed for humans. Also, it is assumed that only the assemblers and linkers which are part of gc are being used, so there is no need to store any intermediate results on disk in a standardized format.
But, there are Java compilers which are designed for speed as well. The Jikes Java compiler was explicitly designed for speed, it is much faster than javac
from Oracle. Jikes also supports incremental compilation at the method-level, where it only needs to compile methods which have changed since the last time they were compiled.
So, that's one dimension: you just happened to compare one of the fastest Go compilers with one of the slowest Java compilers. Gccgo would probably be slower than gc. Jikes, unfortunately, is no longer maintained, so you would probably not be able to compile a modern Java program with it, but if you could, it would be significantly faster than Oracle's javac
.
The second dimension is that you are comparing apples to oranges: gc compiles Go to native machine code. But in your Java example, you are compiling Java to JVM bytecode, and then in a second, separate, step, you are compiling JVM bytecode to native machine code. So, you are compiling twice.
Also, you are compiling the Java standard library (which is distributed in the form of JVM bytecode) every single time, whereas with gc, you only compile the Go standard library once, when you install Go (or even never, if you install a pre-built release). So, you are compiling a lot more code than you do with gc.
A third dimension is the size of the runtime system and the standard library. The Substrate VM's Garbage Collector, for example, is a lot more sophisticated than gc's. That's not to say that gc's GC (pardon the pun) is not good, in fact, it is extremely efficient. But Substrate VM's GC is much bigger. And again, all the runtime code in Substrate VM, such as the garbage collector, are written in Java and delivered as JVM bytecode, so they are compiled every time, whereas gc merely needs to link the already-compiled code into the final executable.
Another dimension is dependency analysis. Go is explicitly designed so that dependency analysis is easy. All dependencies are explicitly listed at the top of each file. As soon as you have read the first top-level declaration, you know, there are no more import
s. And you never need to recursively read all imports, you only need the direct dependencies. Dependency analysis is linear in the number of dependencies, whereas e.g. for C++, it is exponential.
Dependency analysis for JVM bytecode is hard. In fact, given that JVM bytecode supports dynamic code loading, it is actually equivalent to solving the Halting Problem in the general case. This doesn't matter if you interpret the code or JIT compile it, because you only compile the code that is actually running, so you never compile code that is not needed. But for AOT, if you can't do precise dependency analysis, then you have to preemptively compile a lot more code than you actually need.
Since JVM bytecode was not intended to be implemented with an AOT compiler, dependency analysis was never a priority during language design. This has changed recently with the introduction of Modules in Java 9, where dependencies between Modules need to be explicitly declared. But, Modules are fairly large, and their dependencies are fairly coarse-grained.
As a result, you are compiling a lot more code in your Java example. That is also why the size of the resulting executable is so different.
time native-image HelloWorld
) and produced an 11Mb executable , vs .235 seconds for the comparable hello.go program which produced a 2 Mb executable. There is still a massive compile time difference, native-image is taking 164 times longer. Just trying to understand what the underlying reasons for this are...