« Continued from The Java Language

The Java Virtual Machine and Platform

The Java language drew upon many years of experience with earlier programming environments, notably C and C++. This was quite deliberate, as James Gosling wanted a familiar environment for programmers to work within. It isn’t too much of an exaggeration to describe the Java language as "C++ simplified for ordinary developers."

However, Java code cannot execute without a Java Virtual Machine (JVM). This scheme provides a suitable runtime environment in which Java programs can execute. Put another way, Java programs are unable to run unless there is a JVM available on the appropriate hardware and OS we want to execute on.

This may seem like a chicken-and-egg problem at first sight, but the JVM has been ported to run on a wide variety of environments. Anything from a TV set-top box to a huge mainframe probably has a JVM available for it.

In environments like Unix and Windows, Java programs are typically started by from the command line, e.g.:

java <arguments> <program name>

This command starts up the JVM as an operating system process. In turn, this process provides the Java runtime environment, and then finally executes our Java program inside the freshly started (and empty) virtual machine.

The Design of the JVM

The design of the JVM also drew on the experiences of its designers with languages such as C and C++ (but also more dynamic languages such as Lisp and Smalltalk). In addition, it took some bold steps to advance the state of the computing industry. These steps included the use of stack-based virtual machine technology to assist porting and to enable a strong security "pinch point."

When the JVM executes a program, it is not supplied as language source code. Instead, the source must have been converted (or compiled) into a form known as Java bytecode. The JVM expects all programs to be supplied in a format called class files (which always have a .class extension). It is these class files, rather than the original source that are executed when a Java program runs.

The JVM Interpreter and Bytecode

The JVM specification describes how an interpreter for the bytecode must operate. Put simply, it steps through a program one bytecode instruction at a time. However, as Java and other JVM languages natively support threading, both the JVM and the user program are capable of spawning additional threads of execution. As a result, a user program may have many different functions running at once.

The Java language and JVM bytecode have developed somewhat separately, and there is no requirement for the two to exactly replicate each other. One obvious example of this is what happens to Java’s loop keywords (for, while, etc.). They are compiled away by javac, and are replaced with bytecode branch instructions. In fact, in JVM bytecode, the flow control instructions consist of if statements, jumps, and method invocation.

From the bytecode perspective this is also a safety feature, as it partitions transfer of control into local operations (essentially just if and jmp), which can be range-checked, and non-local operations, which are forced to go through the method dispatch mechanism. Nowhere in JVM bytecode is C’s unsafe "transfer control to arbitrary memory address" construct supported.

Bytecode also allows a number of perfectly legal constructions that no Java source compiler would ever emit. However, if we write bytecode directly we can access these capabilities and create classes with some unusual properties.

For example, the Java language spec requires that every class has at least one constructor, and javac will insert a simple constructor if it has been omitted. However, in raw bytecode it is completely possible to write classes that have no constructor. Such classes will be completely usable from Java, provided only static access to fields and methods is used.

The separation was not required by either language or the JVM, but the co-evolution of both aspects means that there are areas where the nature of the JVM "leaks through" into the Java language, and vice versa.

Influence of Language and VM on Each Other

For example, consider the insistence of the Java language that void is not a type, but merely represents the absence of a return type. This outlook can seem strange to the Java beginner, but it really stems from the design of the JVM.

The Java Virtual Machine is a stack machine, in the sense that each method has an evaluation stack in which intermediate results are worked out before a final result is handed back to caller. To see this in action, consider this bit of Java code:

class GetSet {
    private int one;

    public int getOne() {
        return one;
    }

    public void setOne(int one) {
        this.one = one;
    }
}

This Java code, when compiled with javac, produces this bytecode for the getOne() method:

  public int getOne();
    Code:
       0: aload_0
       1: getfield      #2                  // Field one:I
       4: ireturn

When executed, the aload_0 bytecode places this on the top of the execution stack. Next, the getfield opcode consumes the value of the top of the stack and replaces it with the object field that corresponds to position 2 in the table of constants (the class’s Constant Pool) of this class.

300
Figure 3-1. The JVM stack for getOne()

Finally, the method explicitly returns to caller, indicating (by the initial letter of ireturn) that there is an int at the top of the stack that should be collected as the return value. This explicitness of return type allows for more static checking of JVM bytecode during classloading, and helps to improve the Java security model.

Now consider the corresponding setter method. This compiles to bytecode as shown:

  public void setOne(int);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #2                  // Field one:I
       5: return

Here, of course, there is nothing to return, as the putfield opcode consumes not only this but also the value that had been pushed onto the stack above it (the value that the object field was to be set to). Accordingly, the return opcode has no prefix—as the evaluation stack of setOne() is entirely empty.

300
Figure 3-2. The JVM stack for setOne()

So, the Java keyword void indicates that the "method returns no value," and it’s not a type, because it corresponds to the JVM condition of "method returns with the evaluation stack in an empty state."

The low-level JVM design decision here mirrors the condition of the method execution stack in the signature of the high-level language. Java makes this decision in preference to alternatives, such as creating a specific type for the purpose of indicating this condition (as Scala does with its Unit type). This design decision has some far-reaching consequences, especially when more advanced Java language constructs (such as reflection and generics) are introduced; that’s why language design is a complex undertaking. To define a language feature in any given release is to open the door to unintended consequences in the future, when additional language features are seen as desirable.

Due to Java’s stringent backwards compatibility requirements, these unknown interactions between language features (present and future) are a force that has driven the language to be very conservative. If the language has to maintain perfect consistency when adopting new features, then today’s must-have new feature may be the exact same thing that constrains the language on the next release.

For example, Java 7 introduced the invokedynamic bytecode. This was a big step in advancing the JVM and making it friendlier to non-Java JVM languages. It was introduced into the platform very cautiously. The version of javac that ships with Java 7 will not, under any circumstances, emit an invokedynamic instruction. For Java 8, the feature is used to implement features related to lambda expressions (such as default methods), but there is still no direct language support for manipulating dynamic call sites.

Self-Management

There is another major aspect of the JVM’s design that’s not always recognized by beginners: the use of runtime information to enable the JVM to self-manage, sometimes called profile guided optimization (PGO).

Software research had revealed that the runtime behavior of programs has a large number of potentially useful patterns that can’t be predicted ahead of time. The JVM was the first mainstream platform to try to utilize this research.

Time and again when developers chose Java, they reaped the benefits of the implementation continuing to improve with the hardware and OS, making their existing programs better without their having to lift a finger.

Georges Saab

The JVM collects runtime information to make better decisions about how to execute code. Through this monitoring, the JVM can optimize a program and achieve better performance. In fact, modern JVMs can frequently provide performance beyond the capability of platforms that don’t have PGO.

Just-In-Time Compilation

One example of PGO is based on the observation that some parts of a Java program will be called far more often than others (and some methods will be very rarely, if ever called). The Java platform takes advantage of this fact with a technology called just-in-time (JIT) compilation.

In the HotSpot JVM, a profiling subsystem identifies which methods of the program are called most frequently. These methods are eligible for compilation into machine code, which allows the important parts of the code to achieve far higher performance than was possible from interpreted code. In Java’s 20 year history, the optimizations used by the JVM have advanced to the point where they often surpasses the performance of compiled C and C++ code.

In order to assist the JIT compiler, the javac source code compiler performs only very limited optimizations, and instead produces "dumb bytecode." This provides an easy-to-understand representation of the program.

For example, javac does not recognize and eliminate tail recursion. So this code:

    public static void main(String[] args) {
        int i = inc(0, 1_000_000_000);
        System.out.println(i);
    }

    private static int inc(int i, int iter) {
        if (iter > 0)
            return inc(i+1, iter-1);
        else
            return i;
    }

will cause a stack overflow if run. The equivalent Scala code, however, would run fine, because scalac does a great deal of optimization at compile time, and will optimize away the tail recursion.

The general philosophy is that the JIT compiler is the part of the system best able to cope with optimizing code. So javac allows the JIT compiler free reign to apply complex optimizations (although this does not include tail recursion elimination).

Garbage Collection

The JVM allows for automatic management of memory, via garbage collection (GC). This typically runs as a separate, out-of-band task within the JVM, which user code neither knows nor cares about.

Java’s approach to GC, at least in the Hotspot JVM, is unique. Hotspot regards collectors as pluggable systems, and out of the box the JVM has several different algorithms available. Each of these is highly configurable, and each has the ability to adapt operation to the allocation behavior and other runtime conditions of the running Java process.

The self-management features of the JVM have contributed to the emergence of highly performant execution as a defining feature of the overall Java environment. Gone are the days when Java was the punchline of jokes about poor performance. However, the JVM proved to have a reach and a utility outside of just Java code.

Beyond Java

The JVM turns out to be quite a good general purpose virtual machine. The mixture of performant primitive operations and object orientation is a good fit for a wide range of languages.

There are versions of languages such as Ruby, Python, Lisp, and Javascript that run on top of the JVM. It’s relatively easy to implement a two-level interpreter, with the language interpreter written in Java. Not only that, but far more sophisticated options are possible. For example, JRuby starts off using an interpreted mode for Ruby, but then will use JIT compilation to convert important methods to JVM bytecode. Eventually, the JVM’s JIT compiler will kick in, and the Ruby method will be compiled to native code.

One of the main advantages of using the JVM as a language runtime is that it’s easy to interoperate with Java bytecode. This means that each individual language need not reimplement full library support, but can start off with a language-specific wrapper over an existing library. This allows new JVM languages to piggy-back from the established Java ecosystem.

Languages that fundamentally aim to be "a better Java," such as Scala and Kotlin, require good interoperability with Java if they are to gain traction and credibility. However, what is perhaps more surprising is how good the interoperability story is for languages that are not very close to Java in linguistic terms.

For example, Java 8 shipped the Nashorn implementation of Javascript. This was the first implementation of Javascript to hit 100% conformance on the ECMA standard testing kit. Despite the historical accident that led to the similarity in names, Java and Javascript are radically different languages. The fact that Javascript can be implemented on top of the JVM is a huge win. This is further helped by the tight integration that is available between Javascript and Java.

In Nashorn, every Java type is exposed via an extremely simple and natural mechanism. This means that there is seamless access to all of Java from the scripting environment. Not only that, but every Javascript function value can be used as a Java lambda expression, and vice versa.

A very similar picture also emerges in the Clojure language. Clojure is a JVM language in the Lisp family. Language pedants may argue about whether Clojure is an actual Lisp, a Lisp dialect, or merely a language in the same overall diaspora. However, the Lisp nature of Clojure is apparent at first sight. It’s not necessarily a language that would seem easy to integrate with Java at first sight, since the type systems and the feel of the languages are totally different.

In both the Nashorn and the Clojure case, the language implementors have taken time and expended effort to ensure that Java libraries are easily accessible, and that they feel idiomatic in the language that they are being transplanted into.

Since the JVM is a first-class target even for languages as different from Java as Lisp and Javascript are, it stands to reason that the JVM would be a good general home for programming languages. There is also a historical trend—with the release of Java 7, the specifications explicitly broke the references to the Java language in the JVM spec. Instead, Java is now "first among equals" in terms of languages running on the JVM—a privileged position, but not the only game in town.

We can see this in the way that invokedynamic was handled. It was introduced in Java 7 in order to help non-Java languages (notably JRuby, although it would have been almost impossible to build Nashorn without something like invokedynamic). At the time of writing, there are no plans to give the Java language a way to directly access invokedynamic call sites. Instead, it’s seen more as a feature for library builders and non-Java languages.

Conclusion

The JVM has been an enormous success. Some of the design lessons learned during its evolution have been widely adopted by other languages. Features that were once novel are now just part of the furniture and the standard toolkit for building virtual machines. It certainly isn’t perfect, but it represents centuries of engineering effort, and the end result is a general purpose virtual machine that is arguably the best available target for all sorts of programming languages.

Article image: (source: O'Reilly).