Skip to main content

Managing Development Tool Versions with mise

· 6 min read
Haril Song
Owner, Software Engineer at 42dot

Overview

  • Do you use a variety of programming languages rather than just one?
  • Have you ever felt fatigued from memorizing commands for multiple package managers like sdkman, rvm, nvm, etc.?
  • Would you like to manage your development environment more quickly and conveniently?

With mise, you can use the exact version of any language or tool you need, switch between different versions, and specify versions for each project. By specifying versions in a file, you can reduce communication costs among team members about which version to use.

Until now, the most famous tool in this field was asdf[^fn-nth-1]. However, after starting to use mise recently, I found that mise offers a slightly better user experience. In this post, I will introduce some simple use cases.

mise vs asdf

Not sure if it's intentional, but even the web pages look similar.

mise-en-place, mise

mise (pronounced 'meez') is a tool for setting up development environments. The name comes from a French culinary term that roughly translates to "setting" or "putting in place." It means having all your tools and ingredients ready before you start cooking.

Here are some of its simple features:

Journey to a Multi-Connection Server

· 14 min read
Haril Song
Owner, Software Engineer at 42dot

banner

Overview

Implementing a server application that can handle multiple client requests simultaneously is now very easy. Just using Spring MVC alone can get you there in no time. However, as an engineer, I am curious about the underlying principles. In this article, we will embark on a journey to reflect on the considerations that were made to implement a multi-connection server by questioning the things that may seem obvious.

info

You can check the example code on GitHub.

Socket

The first destination is 'Socket'. From a network programming perspective, a socket is a communication endpoint used like a file to exchange data over a network. The description 'used like a file' is important because it is accessed through a file descriptor (fd) and supports I/O operations similar to files.

Why are sockets identified by fd instead of port?

While sockets can be identified using one's IP, port, and the other party's IP and port, using fd is preferred because sockets have no information until a connection is accepted, and more data is needed than just a simple integer like fd.

To implement a server application using sockets, you need to go through the following steps:

How SELECT FOR UPDATE Works

· 6 min read
Haril Song
Owner, Software Engineer at 42dot

banner

In PostgreSQL, the FOR UPDATE lock is used to explicitly lock rows in a table while performing a SELECT query within a transaction. This lock mode is typically used to ensure that the selected rows do not change until the transaction is completed, preventing other transactions from modifying or locking these rows in a conflicting manner.

For example, it can be used to prevent other customers from changing data while a specific customer is going through the ticket booking process.

The cases we will examine in this article are somewhat special:

  • How does select for update behave if there is a mix of locked reads and unlocked reads?
  • If a lock is used initially, is it possible for other transactions to read?
  • Can consistent reading of data be guaranteed even if reading methods are mixed?

In PostgreSQL, the select for update clause operates differently depending on the transaction isolation level. Therefore, it is necessary to examine how it behaves at each isolation level.

Let’s assume a scenario where data is being modified when the following data exists.

idname
1null

Understanding 3 Way Handshake with Termshark through Packets

· 5 min read
Haril Song
Owner, Software Engineer at 42dot

banner

What are Network Packets?

How do we transmit data over a network? Establishing a connection with the recipient and sending the data all at once might seem like the most straightforward approach. However, this method becomes inefficient when handling multiple requests because a single connection can only maintain one data transfer at a time. If a connection is prolonged due to a large data transfer, other data will have to wait.

To efficiently handle the data transmission process, networks divide data into multiple pieces and require the receiving end to reassemble them. These fragmented data structures are called packets. Packets include additional information to allow the receiving end to reassemble the data in the correct order.

While transmitting data in multiple packets enables efficient processing of many requests through packet switching, it can also lead to various errors such as data loss or incorrect delivery order. How should we debug such issues? 🤔

Managing Environment Variables with AWS S3 and Automation

· 5 min read
Haril Song
Owner, Software Engineer at 42dot

Situation

  • As the codebase grows, the number of configuration values required for running a Spring application is increasing.
  • While most situations are validated with test code, there are times when testing with bootRun locally is necessary.

Complication

  • Want to separate configuration values into environment variables for better management.
  • .env files are typically ignored in Git, making version tracking difficult and prone to fragmentation.
    • Need a way to synchronize files across multiple machines.

Question

  • Is there a convenient method that minimizes friction among developers and is easy to apply?
    • Preferably a familiar method for easier maintenance.
  • Can the version of .env files be managed?
  • Is the learning curve low?
    • Want to avoid a situation where the solution is more complex than the problem.
  • Can it be applied directly to the production environment?

Answer

AWS S3

  • Updating .env files is convenient with AWS CLI.
  • Version control of .env files can be done through snapshots.
  • AWS S3 is a service familiar to most developers and has a low learning curve.
  • In the AWS ECS production environment, system variables can be directly applied using S3 ARNs.

.

..

...

....

Is that all?

If that's it, the article might seem a bit dull, right? Of course, there are still a few issues remaining.

Which Bucket is it in?

When using S3, it's common to end up with many buckets due to file structure optimization or business-specific categorization.

aws s3 cp s3://something.service.com/enviroment/.env .env

If the .env file is missing, you'll need to download it using AWS CLI as shown above. Without someone sharing the bucket with you in advance, you might have to search through all buckets to find the environment variable file, which could be inconvenient. The intention was to avoid sharing, but having to receive something to share again might feel a bit cumbersome.

Too many buckets. Where's the env...?

Automating the process of exploring buckets in S3 to find and download the necessary .env file would make things much easier. This can be achieved by writing a script using tools like fzf or gum.

Spring Boot Requires System Environment Variables, Not .env...

Some of you may have already noticed that Spring Boot reads system environment variables to fill in placeholders in YAML files. However, using just the .env file won't apply the system environment variables, thus not being picked up during Spring Boot's initialization process.

Let's briefly look at how it works.

# .env
HELLO=WORLD
# application.yml
something:
hello: ${HELLO} # Retrieves the value from the HELLO environment variable on the OS.
@Slf4j
@Component
public class HelloWorld {

@Value("${something.hello}")
private String hello;

@PostConstruct
public void init() {
log.info("Hello: {}", hello);
}
}

SystemEnvironmentPropertySource.java

You can see that the placeholder in @Value is not resolved, causing the bean registration to fail and resulting in an error.

Just having a .env file doesn't register it as a system environment variable.

To apply the .env file, you can either run the export command or register the .env file in IntelliJ's run configurations. However, using the export command to register too many variables globally on your local machine can lead to unintended behavior like overwriting, so it's recommended to manage them individually through IntelliJ's GUI.

IntelliJ supports configuring .env files via GUI.

The placeholder is resolved and applied correctly.

Final Answer - The Real Final One

Phew, the long process of problem identification and scoping has come to an end. Let's summarize the workflow once more and introduce a script.

  1. Use an automation script to find and download the appropriate .env from S3.
  2. Set the .env as system environment variables.

The shell script is written to be simple yet stylized using gum.

Full Code

#!/bin/bash

S3_BUCKET=$(aws s3 ls | awk '{print $3}' | gum filter --reverse --placeholder "Select...") # 1.

# Choose deployment environment
TARGET=$(gum choose --header "Select a environment" "Elastic Container Service" "EC2")
if [ "$TARGET" = "Elastic Container Service" ]; then
TARGET="ecs"
else
TARGET="ec2"
fi

S3_BUCKET_PATH=s3://$S3_BUCKET/$TARGET/

# Search for the env file
ENV_FILE=$(aws s3 ls "$S3_BUCKET_PATH" | grep env | awk '{print $4}' | gum filter --reverse --placeholder "Select...") # 2.

# Confirm
if (gum confirm "Are you sure you want to use $ENV_FILE?"); then
echo "You selected $ENV_FILE"
else
die "Aborted."
fi

ENV_FILE_NAME=$(gum input --prompt.foreground "#04B575" --prompt "Enter the name of the env file: " --value ".env" --placeholder ".env")
gum spin -s meter --title "Copying env file..." -- aws s3 cp "$S3_BUCKET_PATH$ENV_FILE" "$ENV_FILE_NAME" # 3.

echo "Done."
  1. Use gum filter to select the desired S3 bucket.
  2. Search for items containing the word env and assign it to a variable named ENV_FILE.
  3. Finalize the object key of the .env file and proceed with the download.

I've created a demo video of the execution process.

Demo

After this, you just need to apply the .env file copied to the current directory to IntelliJ as mentioned earlier.

tip

Using direnv and IntelliJ's direnv plugin can make the application even more convenient.

Conclusion

  • The script is easy to maintain due to its simplicity.
  • Team response has been very positive.
  • Developers appreciate aesthetics.
  • For sensitive credentials, consider using AWS Secret Manager.

Optimizing Spatial Data Queries Using Spatial Index

· 4 min read
Haril Song
Owner, Software Engineer at 42dot

banner

This article discusses the inefficient existing implementation and documents the methods attempted to improve it.

Existing Issues

While it wasn't impossible to join tables scattered across multiple databases in a single query, it was challenging...

  1. Is a specific coordinate within area "a"?
  2. Writing join queries was difficult due to tables existing on physically different servers
    1. Why the need for a single query? Due to the large size of the data to be queried, I wanted to minimize the amount loaded into application memory as much as possible.
  3. Since DB joins were not possible, application joins were necessary, resulting in around 24 billion loops (60000 * 40000)
    1. Although processing time was minimized through partitioning, CPU load remained high due to the loops.
  4. Through the migration process of merging physically different databases into one, the opportunity for query optimization was achieved as joins became possible.

Approach

Given that the primary reason for not being able to use database joins had been resolved, I actively considered utilizing index scans for geometry processing.

  • Using PostGIS's GIST index allows for creating a spatial index similar to R-tree, enabling direct querying through index scans.
  • To use spatial indexing, a column of type geometry is required.
  • While latitude and longitude coordinates were available, there was no geometry type, so it was necessary to first create geometry POINT values using the coordinates.

To simulate this process, I prepared the exact same data as in the live DB and conducted experiments.

First, I created the index:

CREATE INDEX idx_port_geom ON port USING GIST (geom);

Then, I ran the PostGIS contains function:

SELECT *
FROM ais AS a
JOIN port AS p ON st_contains(p.geom, a.geom);

Awesome...

Results

Before Applying Spatial Index

1 minute 47 seconds to 2 minutes 30 seconds

After Applying Spatial Index

0.23 milliseconds to 0.243 milliseconds

I didn't prepare a capture, but before applying the index, queries took over 1 minute and 30 seconds.

Let's start with the conclusion and then delve into why these results were achieved.

GiST (Generalized Search Tree)

A highly useful index for querying complex geometric data, the internal structure is illustrated below.

The idea of an R-tree is to divide the plane into rectangles to encompass all indexed points. Index rows store rectangles and can be defined as follows:

"The point we are looking for is inside the given rectangle."

The root of the R-tree contains several of the largest rectangles (which may intersect). Child nodes contain smaller rectangles included in the parent node, collectively encompassing all base points.

In theory, leaf nodes should contain indexed points, but since all index rows must have the same data type, rectangles reduced to points are repeatedly stored.

To visualize this structure, let's look at images for three levels of an R-tree. The points represent airport coordinates.

Level one: two large intersecting rectangles are visible.

Two intersecting rectangles are displayed.

Level two: large rectangles are split into smaller areas.

Large rectangles are divided into smaller areas.

Level three: each rectangle contains as many points as to fit one index page.

Each rectangle contains points that fit one index page.

These areas are structured into a tree, which is scanned during queries. For more detailed information, it is recommended to refer to the following article.

Conclusion

In this article, I briefly introduced the specific conditions, the problems encountered, the efforts made to solve them, and the basic concepts needed to address these issues. To summarize:

  • Efficient joins using indexes could not be performed on physically separated databases.
  • By enabling physical joins through migration, significant performance improvements were achieved.
  • With the ability to utilize index scans, overall performance was greatly enhanced.
  • There was no longer a need to unnecessarily load data into application memory.
  • CPU load due to loops was alleviated.

Reference

Make Testing Easy and Convenient with Fixture Monkey

· 6 min read
Haril Song
Owner, Software Engineer at 42dot

"Write once, Test anywhere"

Fixture Monkey is a testing object creation library being developed as open source by Naver. The name seems to be inspired by Netflix's open source tool, Chaos Monkey. By generating test fixtures randomly, it allows you to experience chaos engineering in practice.

Since I first encountered it about 2 years ago, it has become one of my favorite open source libraries. I even ended up writing two articles about it.

I haven't written any additional articles as there were too many changes with each version update, but now that version 1.x has been released, I am revisiting it with a fresh perspective.

While my previous articles were based on Java, I am now writing in Kotlin to align with current trends. The content of this article is based on the official documentation with some added insights from my actual usage.

Why Fixture Monkey is Needed

Let's examine the following code to see what issues exist with the traditional approach.

info

I used JUnit5, which is familiar to Java developers, for the examples. However, personally, I recommend using Kotest in a Kotlin environment.

data class Product (
val id: Long,

val productName: String,

val price: Long,

val options: List<String>,

val createdAt: Instant,

val productType: ProductType,

val merchantInfo: Map<Int, String>
)

enum class ProductType {
ELECTRONICS,
CLOTHING,
FOOD
}
@Test
fun basic() {
val actual: Product = Product(
id = 1L,
price = 1000L,
productName = "productName",
productType = ProductType.FOOD,
options = listOf(
"option1",
"option2"
),
createdAt = Instant.now(),
merchantInfo = mapOf(
1 to "merchant1",
2 to "merchant2"
)
)

// The preparation process is lengthy compared to the test purpose
actual shouldNotBe null
}

Challenges of Test Object Creation

Looking at the test code, it feels like there is too much code to write just to create objects for assertion. Due to the nature of the implementation, if properties are not set, a compilation error occurs, so even meaningless properties must be written.

When the preparation required for assertion in test code becomes lengthy, the meaning of the test purpose in the code can become unclear. The person reading this code for the first time would have to examine even seemingly meaningless properties to see if there is any hidden significance. This process increases developers' fatigue.

Difficulty in Recognizing Edge Cases

When directly setting properties to create objects, many edge cases that could occur in various scenarios are often overlooked because the properties are fixed.

val actual: Product = Product(
id = 1L, // What if the id becomes negative?
// ...omitted
)

To find edge cases, developers have to set properties one by one and verify them, but in reality, it is often only after runtime errors occur that developers become aware of edge cases. To easily discover edge cases before errors occur, object properties need to be set with a certain degree of randomness.

Issues with the Object Mother Pattern

To reuse test objects, a pattern called the Object Mother pattern involves creating a factory class to generate objects and then executing test code using objects created from that class.

However, this method is not favored because it requires continuous management not only of the test code but also of the factory. Furthermore, it does not help in identifying edge cases.

Using Fixture Monkey

Fixture Monkey elegantly addresses the issues of reusability and randomness as mentioned above. Let's see how it solves these problems.

First, add the dependency.

testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter-kotlin:1.0.13")

Apply KotlinPlugin() to ensure that Fixture Monkey works smoothly in a Kotlin environment.

@Test
fun test() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()
}

Let's write a test again using the Product class we used before.

data class Product (
val id: Long,

val productName: String,

val price: Long,

val options: List<String>,

val createdAt: Instant,

val productType: ProductType,

val merchantInfo: Map<Int, String>
)

enum class ProductType {
ELECTRONICS,
CLOTHING,
FOOD
}
@Test
fun test() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()

val actual: Product = fixtureMonkey.giveMeOne()

actual shouldNotBe null
}

You can create an instance of Product without the need for unnecessary property settings. All property values are filled randomly by default.

image Fills in multiple properties nicely

Post Condition

However, in most cases, specific property values are required. For example, in the example, the id was generated as a negative number, but in reality, id is often used as a positive number. There might be a validation logic like this:

init {
require(id > 0) { "id should be positive" }
}

After running the test a few times, if the id is generated as a negative number, the test fails. The fact that all values are randomly generated makes it particularly useful for finding unexpected edge cases.

image

Let's maintain the randomness but restrict the range slightly to ensure the validation logic passes.

@RepeatedTest(10)
fun postCondition() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()

val actual = fixtureMonkey.giveMeBuilder<Product>()
.setPostCondition { it.id > 0 } // Specify property conditions for the generated object
.sample()

actual.id shouldBeGreaterThan 0
}

I used @RepeatedTest to run the test 10 times.

image

You can see that all tests pass.

Setting Various Properties

When using postCondition, be cautious as setting conditions too narrowly can make object creation costly. This is because the creation is repeated internally until an object that meets the condition is generated. In such cases, it is much better to use setExp to fix specific values.

val actual = fixtureMonkey.giveMeBuilder<Product>()
.setExp(Product::id, 1L) // Only the specified value is fixed, the rest are random
.sample()

actual.id shouldBe 1L

If a property is a collection, you can use sizeExp to specify the size of the collection.

val actual = fixtureMonkey.giveMeBuilder<Product>()
.sizeExp(Product::options, 3)
.sample()

actual.options.size shouldBe 3

Using maxSize and minSize, you can easily set the maximum and minimum size constraints for a collection.

val actual = fixtureMonkey.giveMeBuilder<Product>()
.maxSizeExp(Product::options, 10)
.sample()

actual.options.size shouldBeLessThan 11

There are various other property setting methods available, so I recommend exploring them when needed.

Conclusion

Fixture Monkey really resolves the inconveniences encountered while writing unit tests. Although not mentioned in this article, you can create conditions in the builder and reuse them, add randomness to properties, and help developers discover edge cases they may have missed. As a result, test code becomes very concise, and the need for additional code like Object Mother disappears, making maintenance easier.

Even before the release of Fixture Monkey 1.x, I found it very helpful in writing test code. Now that it has become a stable version, I hope you can introduce it without hesitation and enjoy writing test code.

Reference

Deep Dive into Java: The Path to Hello World - Part 3

· 11 min read
Haril Song
Owner, Software Engineer at 42dot

banner

In the previous chapter, we compiled Java and examined the bytecode structure. In this chapter, we will explore how the JVM executes the 'Hello World' code block.

Chapter 3: Running Java on the JVM

  • Class Loader
  • Java Virtual Machine
  • Java Native Interface
  • JVM Memory Loading Process
  • Interaction of Hello World with Memory Areas

Class Loader

To understand when, where, and how Java classes are loaded into memory and initialized, we need to first look at the * Class Loader* of the JVM.

The class loader dynamically loads compiled Java class files (.class) and places them in the Runtime Data Area, which is the memory area of the JVM.

The process of loading class files by the class loader consists of three stages:

  1. Loading: Bringing the class file into JVM memory.
  2. Linking: The process of verifying the class file for use.
  3. Initialization: Initializing the class file with appropriate values.

It is important to note that class files are not loaded into memory all at once but are dynamically loaded into memory * when needed by the application*.

A common misconception is the timing of when classes or static members within classes are loaded into memory. Many mistakenly believe that all classes and static members are loaded into memory as soon as the source is executed. However, static members are only loaded into memory when the class is dynamically loaded into memory upon calling a member within the class.

By using the verbose option, you can observe the process of loading into memory.

java -verbose:class VerboseLanguage

image

You can see that the VerboseLanguage class is loaded before 'Hello World' is printed.

info

Java 1.8 and Java 21 have different log output formats starting from the compilation results. As versions progress, optimizations are made and compiler behavior changes slightly, so it is important to check the version. This article uses Java 21 as the default version, and other versions will be specified separately.

Runtime Data Area

The Runtime Data Area is the space where data is stored during program execution. It is divided into Shared Data Areas and Per-thread Data Areas.

Shared Data Areas

Within the JVM, there are several areas where data can be shared among multiple threads running within the JVM. This allows various threads to access one of these areas simultaneously.

Heap

Where instances of the VerboseLanguage class exist

The Heap area is where all Java objects or arrays are allocated when created. It is created when the JVM starts and is destroyed when the JVM exits.

According to the Java specification, this space should be automatically managed. This role is performed by a tool known as the Garbage Collector (GC).

There are no constraints on the size of the Heap specified in the JVM specification. Memory management is also left to the JVM implementation. However, if the Garbage Collector fails to secure enough space to create new objects, the JVM will throw an OutOfMemory error.

Method Area

The Method Area is a shared data area that stores class and interface definitions. Similar to the Heap, it is created when the JVM starts and is destroyed when the JVM exits.

Global variables and static variables of a class are stored in this area, making them accessible from anywhere in the program from start to finish. (= Run-Time Constant Pool)

Specifically, the class loader loads the bytecode (.class) of a class and passes it to the JVM, which then generates the internal representation of the class used for creating objects and invoking methods. This internal representation collects information about fields, methods, and constructors of the class and interfaces.

In fact, according to the JVM specification, the Method Area is an area with no clear definition of 'how it should be'. It is a logical area and depending on the implementation, it can exist as part of the Heap. In a simple implementation, it can be part of the Heap without undergoing GC or compression.

Run-Time Constant Pool

The Run-Time Constant Pool is part of the Method Area and contains symbolic references to class and interface names, field names, and method names. The JVM uses the Run-Time Constant Pool to find the actual memory addresses for references.

As seen when analyzing bytecode, the constant pool was found inside the class file. During runtime, the constant pool, which was part of the class file structure, is read and loaded into memory by the class loader.

String Constant Pool

Where the "Hello World" string is stored

As mentioned earlier, the Run-Time Constant Pool is part of the Method Area. However, there is also a Constant Pool in the Heap, known as the String Constant Pool.

When creating a string using new String("Hello World"), the string is treated as an object and is managed in the Heap. Let's look at an example:

String s1 = "Hello World";
String s2 = new String("Hello World");

The string literal used inside the constructor is retrieved from the String Pool, but the new keyword guarantees the creation of a new and unique string.

0: ldc           #7                  // String Hello World
2: astore_1
3: new #9 // class java/lang/String
6: dup
7: ldc #7 // String Hello World
9: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
12: astore_2
13: return

If we examine the bytecode, we can see that the string is 'created' using the invokespecial instruction.

The invokespecial instruction means that the object initialization method is directly called.

Why does the String Constant Pool exist in the Heap, unlike the Run-Time Constant Pool in the Method Area? 🤔

  • Strings belong to very large objects. Also, it is difficult to predict how many strings will be created, so a process is needed to efficiently use memory space by cleaning up unused strings. This means that it is necessary for the String Constant Pool to exist in the Heap.
    • Storing in the stack would make it difficult to find space, and declaring a string could fail.
    • The stack size is typically around 320kb1MB for 32-bit and 1MB2MB for 64-bit systems.
  • Strings are managed as immutable. They cannot be modified and are always created anew. By reusing already created strings, memory space is saved (interning). However, unused (unreachable) strings may accumulate over the application's lifecycle. To efficiently utilize memory, there is a need to clean up unreferenced strings, which again leads to the need for GC.

In conclusion, the String Constant Pool needs to exist in the Heap to be under the influence of GC.

String comparison operations require N operations for perfect matching if the length is N. In contrast, using the pool, the equals comparison only requires checking the reference, incurring a cost of O(1)O(1).

It is possible to move a string that is outside the String Constant Pool into the String Constant Pool by creating a string using new.

String greeting = new String("Hello World");
greeting.intern(); // using the constant pool

// Now, comparison with the string literal in the SCP is possible.
assertThat(greeting).isEqualTo("Hello World"); // true

While this was provided as a trick in the past to save memory, it is no longer necessary, so it is best to use strings as literals.

To summarize:

  1. Numbers have a maximum value, whereas strings, due to their nature, have an unclear maximum size.
  2. Strings can become very large and are likely to be used frequently after creation compared to other types.
  3. Naturally, high memory efficiency is required. To achieve this while increasing usability, they should be globally referable.
  4. If placed in the Per-Thread Data Area within the Stack, they cannot be reused by other threads, and if the size is large, finding allocation space becomes difficult.
  5. It is rational to have them in the Shared Data Area + in the Heap, but since they need to be treated as immutable at the JVM level, a dedicated Constant Pool is created within the Heap to manage them separately.
tip

While string literals inside constructors are retrieved from the String Constant Pool, the new keyword guarantees independent string creation. Consequently, there are two strings, one in the String Constant Pool and one in the Heap.

Per-thread Data Areas

In addition to the Shared Data Area, the JVM manages data for individual threads separately. The JVM actually supports the concurrent execution of quite a few threads.

PC Register

Each JVM thread has a PC (program counter) register.

The PC register stores the current position of the execution of instructions to enable the CPU to continue executing instructions. It also holds the memory address of the next instruction to be executed, aiding in optimizing instruction execution.

The behavior of the PC depends on the nature of the method:

  • For non-native methods, the PC register stores the address of the currently executing instruction.
  • For native methods, the PC register holds an undefined value.

The lifecycle of the PC register is essentially the same as the thread's lifecycle.

JVM Stack

Each JVM thread has its own independent stack. The JVM stack is a data structure that stores method invocation information. A new frame is created on the stack for each method invocation, containing the method's local variables and the address of the return value. If it is a primitive type, it is stored directly on the stack, while if it is a wrapper type, it holds a reference to an instance created in the Heap. This results in int and double types having a slight performance advantage over Integer and Double.

Thanks to the JVM stack, the JVM can trace program execution and record stack traces as needed.

  • This is known as a stack trace. printStackTrace is an example of this.
  • In scenarios like webflux's event loop where a single operation traverses multiple threads, the significance of a stack trace may be difficult to understand.

The memory size and allocation method of the stack can be determined by the JVM implementation. Typically, around 1MB of space is allocated when a thread starts.

JVM memory allocation errors can result in a stack overflow error. However, if a JVM implementation allows dynamic expansion of the JVM stack size and a memory error occurs during expansion, the JVM may throw an OutOfMemory error.

Native Method Stack

Native methods are methods written in languages other than Java. These methods cannot be compiled into bytecode (as they are not Java, javac cannot be used), so they require a separate memory area.

  • The Native Method Stack is very similar to the JVM Stack but is exclusively for native methods.
  • The purpose of the Native Method Stack is to track the execution of native methods.

JVM implementations can determine how to manipulate the size and memory blocks of the Native Method Stack.

In the case of memory allocation errors originating from the Native Method Stack, a stack overflow error occurs. However, if an attempt to increase the size of the Native Method Stack fails, an OutOfMemory error occurs.

In conclusion, a JVM implementation can decide not to support Native Method calls, emphasizing that such an implementation does not require a Native Method Stack.

The usage of the Java Native Interface will be covered in a separate article.

Execution Engine

Once the loading and storage stages are complete, the JVM executes the Class File. It consists of three elements:

  • Interpreter
  • JIT Compiler
  • Garbage Collector

Interpreter

When a program starts, the Interpreter reads the bytecode line by line, converting it into machine code that the machine can understand.

Interpreters are generally slower. Why is that?

Compiled languages can define resources and types needed for a program to run during the compilation process before execution. However, in interpreted languages, necessary resources and variable types cannot be known until execution, making optimization difficult.

JIT Compiler

The Just In Time Compiler was introduced in Java 1.1 to overcome the shortcomings of the Interpreter.

The JIT compiler compiles bytecode into machine code at runtime, improving the execution speed of Java applications. It detects frequently executed parts (hot code) and compiles them.

You can use the following keywords to check JIT-related behaviors if needed:

  • -XX:+PrintCompilation: Outputs JIT-related logs
  • -Djava.compiler=NONE: Deactivates JIT. You can observe a performance drop.

Garbage Collector

The Garbage Collector is a critical component that deserves a separate document, and there is already a document on it, so it will be skipped this time.

  • Optimizing the GC is not common.
    • However, there are cases where a delay of over 500ms due to GC operations occurs, and in scenarios handling high traffic or tight TTLs in caches, a 500ms delay can be a significant issue.

Conclusion

Java is undoubtedly a complex language.

In interviews, you often get asked questions like this:

How well do you think you know Java?

Now, you should be able to answer more confidently.

Um... 🤔 Just about Hello World.

Reference

Deep Dive into Java: The Path to Hello World - Part 2

· 9 min read
Haril Song
Owner, Software Engineer at 42dot

banner

Continuing from the previous post, let's explore how the code evolves to print "Hello World."

Chapter 2. Compilation and Disassembly

Programming languages have levels.

The closer a programming language is to human language, the higher-level language it is, and the closer it is to the language a computer can understand (machine language), the lower-level language it is. Writing programs in a high-level language makes it easier for humans to understand and increases productivity, but it also creates a gap with machine language, requiring a process to bridge this gap.

The process of a high-level language descending to a lower level is called compilation.

Since Java is not a low-level language, there is a compilation process. Let's take a look at how this compilation process works in Java.

Compilation

As mentioned earlier, Java code cannot be directly executed by the computer. To execute Java code, it needs to be transformed into a form that the computer can read and interpret. This transformation involves the following major steps:

The resulting .class file from compilation is in bytecode. However, it is still not machine code that the computer can execute. The Java Virtual Machine (JVM) reads this bytecode and further processes it into machine code. We will cover how the JVM handles this in the final chapter.

First, let's compile the .java file to create a .class file. You can compile it using the javac command.

// VerboseLanguage.java
public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java

You can see that the class file has been created. You can run the class file using the java command, and this is the basic flow of running a Java program.

java VerboseLanguage
// Hello World

Are you curious about the contents of the class file? Wondering how the computer reads and executes the language? What secrets lie within this file? It feels like opening Pandora's box.

Expecting something, you open it up, and...

No way!

Only a brief binary content is displayed.

Wait, wasn't the result of compilation supposed to be bytecode...?

Yes, it is bytecode. At the same time, it is also binary code. At this point, let's briefly touch on the differences between bytecode and binary code before moving on.

Binary Code : Code composed of 0s and 1s. While machine language is made up of binary code, not all binary code is machine language.

Bytecode : Code composed of 0s and 1s. However, bytecode is not intended for the machine but for the VM. It is converted into machine code by the VM through processes like the JIT compiler.

Still, as this article claims to be a deep dive, we reluctantly tried to read through the conversion.

Fortunately, our Pandora's box contains only 0s and 1s, with no other hardships or challenges.

While we succeeded in reading it, it is quite difficult to understand the content with just 0s and 1s 🤔

Now, let's decipher this code.

Disassembly

During the compilation process, the code is transformed into bytecode composed of 0s and 1s. As seen earlier, interpreting bytecode directly is quite challenging. Fortunately, the JDK includes tools that help developers read compiled bytecode, making it useful for debugging purposes.

The process of converting bytecode into a more readable form for developers is called disassembly. Sometimes this process can be confused with decompilation, but decompilation results in a higher-level programming language, not assembly language. Also, since the javap documentation clearly uses the term disassemble, we will follow suit.

info

Decompilation refers to representing binary code in a relatively higher-level language, just like before compiling binary. On the other hand, disassembly represents binary code in a minimal human-readable form (assembler language).

Virtual Machine Assembly Language

Let's use javap to disassemble the bytecode. The output is much more readable than just 0s and 1s.

javap -c VerboseLanguage.class
Compiled from "VerboseLanguage.java"
public class VerboseLanguage {
public VerboseLanguage();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}

What can we learn from this?

Firstly, this language is called virtual machine assembly language.

The Java Virtual Machine code is written in the informal “virtual machine assembly language” output by Oracle's javap utility, distributed with the JDK release. - JVM Spec

The format is as follows:

<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

index : Index of the JVM code byte array. It can be thought of as the method's starting offset.

opcode : Mnemonic symbol representing the set of instructions opcode. We remember the order of the rainbow colors as 'ROYGBIV' to distinguish the instruction set. If the rainbow colors represent the instruction set, each syllable of 'ROYGBIV' can be considered as a mnemonic symbol defined to differentiate them.

operandN : Operand of the instruction. The operand of a computer instruction is the address field. It points to where the data to be processed is stored in the constant pool.

Let's take a closer look at the main method part of the disassembled result.

Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
  • invokevirtual: Call an instance method
  • getstatic: Get a static field from a class
  • ldc: Load data into the run-time constant pool.

The 3: ldc #13 on the third line means to put an item at index 13, and the item being put is kindly indicated in the comment.

Hello World

Note that bytecode instructions like getstatic and invokevirtual are represented by a single-byte opcode number. For example, getstatic=0xb2, invokevirtual=0xb6, and so on. It can be understood that Java bytecode instructions also have a maximum of 256 different opcodes.

JVM Instruction Set showing the bytecode for invokevirtual

If we look at the bytecode of the main method in hex, it would be as follows:

b2 00 07 12 0d b6

It might still be a bit hard to notice the pattern. As a hint, remember that earlier we mentioned the number before the opcode is the index in the JVM array. Let's slightly change the representation.

arr = [b2, 00, 07, 12, 0d, b6]
  • arr[0] = b2 = getstatic
  • arr[3] = 12 = ldc
  • arr[5] = b6 = invokevirtual

It becomes somewhat clearer what the index meant. The reason for skipping some indices is quite simple: getstatic requires a 2-byte operand, and ldc requires a 1-byte operand. Therefore, the ldc instruction, which is the next instruction after getstatic, is recorded at index 3, skipping 1 and 2. Similarly, skipping 4, the invokevirtual instruction is recorded at index 5.

Lastly, notice the comment (Ljava/lang/String;)V on the 4th line. Through this comment, we can see that in Java bytecode, classes are represented as L;, and void is represented as V. Other types also have their unique representations, summarized as follows:

Java BytecodeTypeDescription
Bbytesigned byte
CcharUnicode character
Ddoubledouble-precision floating-point value
Ffloatsingle-precision floating-point value
Iintinteger
Jlonglong integer
L<classname>;referencean instance of class <classname>
Sshortsigned short
Zbooleantrue or false
[referenceone array dimension

Using the -verbose option, you can see a more detailed disassembly result, including the constant pool. It would be interesting to examine the operands and constant pool together.

  Compiled from "VerboseLanguage.java"
public class VerboseLanguage
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // VerboseLanguage
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World
#14 = Utf8 Hello World
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // VerboseLanguage
#22 = Utf8 VerboseLanguage
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 VerboseLanguage.java
{
public VerboseLanguage();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "VerboseLanguage.java"

Conclusion

In the previous chapter, we explored why a verbose process is required to print Hello World. In this chapter, we looked at the compilation and disassembly processes before printing Hello World. Next, we will finally examine the execution flow of the Hello World printing method with the JVM.

Reference