Code execution is simply translating a human-readable code into a form the computer can understand and execute. Native code is code compiled into machine code the operating system can directly work on. A native program stays on the machine’s memory as machine code. To execute native code, the operating system fetches the instructions from the memory to the processor and relays the results back to the memory, this happens repetitively.
Programming languages have different execution techniques
Depending on the programming language, here’s how programs can be executed.
1.Compiled languages
In compiled languages such as C and C++, the execution of their code is first compiled into platform-specific machine code, once compiled, the resulting machine code can be executed directly by the computer’s processor.
2. Interpreted languages
In interpreted languages such as Python, PHP, Ruby, and Javascript, the source code is read line by line by an interpreter, translating each line into machine code and executing it immediately.
3. A combination of both compilation and interpretation techniques.
In special cases, some languages such as Java use a combination of both techniques. Its source code is compiled into byte code, this byte code is not understandable by the computer, so the JVM comes in to interpret the byte code for the underlying platform [5].
It is worth noting that even though Java code can be compiled into bytecode and interpreted by the JVM, the Java ecosystem also supports the execution of Java bytecode directly into standalone native executable bypassing the need for an intermediary Java Virtual Machine (JVM) environment.
Native execution of Java code
Native execution of Java code entails compiling bytecode directly into platform-specific native machine code which can be executed by the machine’s underlying hardware, without relying on an intermediary runtime environment such as Java Virtual Machine (JVM) [1].
The execution uses the Ahead-Of-Time (AOT) compilation technique introduced in Java 9. This technique compiles Java bytecode into native machine code ahead of execution or at build time.
GraalVM is a high-performance JDK distribution designed to accelerate the execution of applications written in Java. It has GraalVM’s Native Image tool bundled in it, which works as an Ahead-Of-Time (AOT) compiler to build native executables for Java applications [4].
Execution process to native code
GraalVM’s compiler starts by analyzing the bytecode from the main method of the main class, including the application dependencies, method invocations, and class hierarchies. In addition to analysis, the GraalVM also performs an aggressive performance optimization, this optimization includes the elimination of unused code, inlining frequently used methods, resolving virtual method calls, and optimizing memory access patterns [2].
Memory Configuration
The next step involves memory configuration, the GraalVM determines the application’s layout including the organization of classes and objects, and allocates memory efficiently to minimize runtime overhead.
Reflection configuration
This feature allows Java programs to inspect and modify their structure at runtime. GraalVM analyses application usage of reflection and generates configuration files to guide the native image generation process ensuring reflection-related operations are handled correctly in the native environment [3].
Resource bundling
GraalVM packages the application’s resources such as class files, property files, and other assets into the native image, eliminating the need to access resources from the file system at runtime.
When bundling is done, the compiler generates the native code which is highly optimized for performance. Once native code generation is complete, the compiler finally builds a native executable binary that is specific to the target platform and architecture.
The final executable includes the application classes, third-party dependencies classes, java runtime libraries classes, and the statically linked native code from Java Development Kit (JDK). This whole process is referred to as build time.
The native executable doesn’t run on JVM but includes the necessary components to run including memory management, thread scheduling, and deoptimizer.
It can be executed directly by the underlying platform with fast startup time, reduced memory consumption, and better performance compared to JVM-based execution.
Building a native executable
To build a native executable, you can use a class file, a JAR file, or a module. Ensure you have a native image installed on your machine.
We will use a class file, the native-image command below invokes the GraalVM native-image tool, and the 1st option modifies the behavior of the native image e.g. –no-fallback. Provide the Java class to be compiled into a native executable, the [imagename] and [options] are optional parameters.
- In your working directory, save this code into a file named Greeting.java
- Compile it with javac, and build a native executable from the Java class
This will create a native executable, greeting, in your working directory.
- Run the application
You can see the output below:
Java code execution with JVM mode
A Java Virtual Machine (JVM) is a virtual representation of a physical computer that enables a host computer to run Java programs in an isolated space acting as an intermediary between compiled Java bytecode and the underlying platform and hardware.
Execution of Java code with Java Virtual Machine mode involves compiling Java source code into a platform-independent bytecode by Javac compiler and stored in the memory, this bytecode is not understandable to the computer, so the JVM comes in to fetch instructions and interprets them on behalf of the computer.
Execution process with JVM mode
The stored bytecode has .class files, the JVM now takes over and continues with:
- Loading
The JVM architecture has a ClassLoader that loads the bytecode of a Java class file and other dependencies needed by the program from the filesystem or a network location into memory and generates the original class from the bytecode.
- Linking
After loading a class into memory, a linking process is performed, linking a class and combining different dependencies and elements of the program. During the linking process, other checks are done as follows:
- Verification
- This process checks the correctness of the .class files against rules, structures, and restrictions imposed by the Java language. If verification fails we get a VerifyException error. This step ensures bytecode integrity and helps prevent potential security vulnerabilities.
- Preparation
- In this phase, JVM allocates memory for static fields of a class and initializes them with their default values. For example, you have the variable below in your class:
During preparation, JVM will allocate memory for the variable isEvening and set its value to the default value for a boolean in Java, which is false.
- Initialization
This phase involves initializing the class, not excluding the static variables, calling the class constructor, and assigning the values to all the static variables. For example, when we declared isEvening static variable earlier:
The variable was set to its default value of false during the preparation phase, in the initialization phase, this variable is assigned to its actual value of true.
- Execution
After the bytecode is loaded into memory and initialization is done, the execution engine(JVM) comes in to handle the execution of the code. Before proceeding, the bytecode must be transformed into machine code, the JVM can use an interpreter or a Just-In-Time (JIT) compiler for the transformation.
- Interpreter
- The interpreter reads and executes the bytecode instructions line by line, it decodes the instructions and performs corresponding actions as per each instruction. These actions may include arithmetic operations such as addition, object creation, method calls, control flow statements, memory allocation, and handling exceptions.
An interpreter however has some drawbacks, It is slow given the nature of its line-by-line execution and lacks a caching mechanism for a method called multiple times, each method call requires a new interpretation.
- JIT Compiler
- The JIT compiler addresses the drawback of the interpreter, the JVM first uses the interpreter to execute bytecode line-by-line, when it finds repeated code, it uses the JIT compiler to change the entire bytecode into native machine code. The native machine code is used directly in place of repeated code such as method calls, optimizing performance.
During transformation, garbage collection is also taken care of, with the garbage collector, all unreferenced objects are collected and removed from memory, making up free space for new objects. This process is handled automatically by the JVM at intervals of time.
Impact on microservices architecture
When an application is developed as a collection of services (microservices), the execution of Java code natively or with JVM mode brings out distinct differences in various factors in a microservice architecture. As discussed below:
- Performance: Native execution offers better performance as compared to running Java code on JVM. Natively executed code is compiled directly into machine code, which can be tailored for a particular hardware architecture, resulting in faster execution and startup time of micro-services intending to communicate with each other fast e.g. serverless functions. However, Java code on JVM may require additional time for interpretation and JIT compilation.
- Deployment: In a microservices architecture, deployment agility is crucial. Native executables might simplify deployment as they can be packaged as standalone binaries with all dependencies included. This simplifies deployment and reduces the chance of runtime errors due to missing dependencies.
- Portability: Native execution produces standalone native executables specific to a particular platform, this makes it difficult to be used across different operating systems, and may require additional adjustments and recompilation to fit different platforms. On the other hand, Java code on JVM can run on any platform with a compatible JVM installed.
- Resource Utilization: In a microservices architecture, with containerized deployments, where there are memory constraints, native execution helps reduce memory usage, improving resource utilization and, therefore saving costs, as compared to running a JVM for each microservice instance which takes up a ton of memory footprint.
- Security: JVM-based applications benefit from the security features provided by the JVM, such as bytecode verification and runtime checks. Native executables might have a smaller attack surface as they don’t rely on the JVM, but they need to be carefully managed to ensure security updates are applied promptly.
To summarize
Native execution of Java code compared to JVM mode solely relies on what the application intends to do, cloud costs, and what developer or deployment experience a team wants. This drills down into how each micro-service should communicate for optimal application performance. For applications that require critical start-up time, have shortlived processes, need standalone executables, and where memory constraint is a factor, native code execution is the path to take.
On the other hand, for applications that are long-running with dynamic code paths where performance is critical in the long run and not at startup time, JVM is the right choice.
References