Building a Virtual Machine, JVM-inspired — Synchronized (Part 4)
Why synchronization matters
In modern software development, multi-threaded applications are common but can be tricky to get right. When multiple threads access shared resources simultaneously, they can create race conditions — unpredictable behavior that leads to bugs that are hard to reproduce and fix. This article demonstrates how to implement basic thread synchronization in our TinyVM project, similar to Java’s synchronized
keyword.
What we will build
We’ll implement two key synchronization primitives:
- Mutexes (mutual exclusion locks) that threads can acquire and release
- Lock/unlock instructions that let threads safely coordinate access to shared resources
This implementation will help us understand:
- How Java’s
synchronized
blocks work under the hood - Why proper synchronization is crucial for thread safety
- How to prevent race conditions in multi-threaded code
Implementation goals
Our implementation builds on the previous articles where we showed how unsynchronized access to shared variables can lead to race conditions.
Here’s how we’ll add synchronization and allow developers, who are running code in our TinyVM, to write thread-safe code:
const char* program[] = {
// Thread-0: creates shared counter variable
"setshared counter 0", // Line 0
"thread 6", // Line 1 - Start Thread-1
"thread 13", // Line 2 - Start Thread-2
"sleep 500", // Line 3 - Thread-0 thread waits
"print counter", // Line 4 - Thread-0 prints final value
"exit", // Line 5
// Thread-1: setting counter to 1
"sleep 2", // Line 6
"lock counter_mutex", // Line 7: start of synchronized block
"setshared counter 1", // Line 8
"print counter", // Line 9
"setshared counter 1", // Line 10
"unlock counter_mutex", // Line 11: end of synchronized block
"exit", // Line 12
// Thread-2: setting counter to 2
"sleep 1", // Line 13
"lock counter_mutex", // Line 14: start of synchronized block
"setshared counter 2", // Line 15
"print counter", // Line 16
"setshared counter 2", // Line 17
"print counter", // Line 18
"unlock counter_mutex", // Line 19: end of synchronized block
"exit", // Line 20
NULL
};
In Java, the synchronized
keyword creates a critical section where only one thread can execute at a time. The new lock
and unlock
instructions in our VM will provide similar functionality, allowing threads to safely modify shared variables like counter
without interference.
Mutexes to lock/unlock execution of code
In our TinyVM implementation, we need to add synchronization capabilities to prevent race conditions when multiple threads access shared resources. Let’s explore the core changes required in our header file.
Key additions to the header file
The synchronization lock structure:
// tiny_vm.h
// ...
// Mutex object for synchronization
typedef struct {
char* name;
pthread_mutex_t mutex;
int locked; // For debugging
} SynchronizationLock;
This structure represents our mutex implementation. Each lock has:
- A name for identification — threads will be able to acquire any lock using a name, that way they can acquire multiple locks to protect various shared resources if necessary
- The actual pthread mutex for synchronization
- A debugging flag to track lock status
Management of mutexes in the VM
struct to make lock acquiring thread-safe:
// tiny_vm.h
// ...
// VM state
struct VM {
// ... other code
// Mutex management
SynchronizationLock* mutexes;
int mutex_count;
int mutex_capacity;
pthread_mutex_t mutex_mgmt_lock; // Protect mutex management
};
New VM instruction to lock and unlock using a mutex:
// tiny_vm.h
// ...
// Instruction types
typedef enum {
// ... other instructions
LOCK, // lock <mutex_name>
UNLOCK // unlock <mutex_name>
} InstructionType;
Implementing thread synchronization
The first step in implementing synchronization is properly initializing and cleaning up our mutex management system:
// tiny_vm.c
// ...
VM* create_vm() {
// ...
// Initialize mutex management
vm->mutex_capacity = 10;
vm->mutex_count = 0;
vm->mutexes = malloc(sizeof(SynchronizationLock) * vm->mutex_capacity);
pthread_mutex_init(&vm->mutex_mgmt_lock, NULL);
return vm;
}
void destroy_vm(VM* vm) {
// ...
// Cleanup mutexes
for (int i = 0; i < vm->mutex_count; i++) {
pthread_mutex_destroy(&vm->mutexes[i].mutex);
free(vm->mutexes[i].name);
}
pthread_mutex_destroy(&vm->mutex_mgmt_lock);
free(vm->mutexes);
// ...
}
Lock management
The get_sync_lock
function handles mutex creation and retrieval:
// Get or create mutex
SynchronizationLock* get_sync_lock(VM* vm, const char* name) {
// Look for existing mutex
for (int i = 0; i < vm->mutex_count; i++) {
if (strcmp(vm->mutexes[i].name, name) == 0) {
return &vm->mutexes[i];
}
}
// Create new mutex if not found
if (vm->mutex_count < vm->mutex_capacity) {
pthread_mutex_lock(&vm->mutex_mgmt_lock);
SynchronizationLock* mutex = &vm->mutexes[vm->mutex_count++];
mutex->name = strdup(name);
pthread_mutex_init(&mutex->mutex, NULL);
mutex->locked = 0;
pthread_mutex_unlock(&vm->mutex_mgmt_lock);
return mutex;
}
return NULL;
}
The
get_sync_lock
is only going to acquire/release themutex_mngt_lock
when creating a new lock, because reading memory is inherently thread-safe — multiple threads can read simultaneously without corruption.
Instruction handling
Finally, we implement the lock
/unlock
instructions:
Instruction parse_instruction(const char* line) {
Instruction instr;
memset(&instr, 0, sizeof(Instruction));
char cmd[32];
sscanf(line, "%s", cmd);
// ... other if/else branches
else if (strcmp(cmd, "lock") == 0) {
instr.type = LOCK;
sscanf(line, "%s %s", cmd, instr.args[0]);
}
else if (strcmp(cmd, "unlock") == 0) {
instr.type = UNLOCK;
sscanf(line, "%s %s", cmd, instr.args[0]);
}
return instr;
}
void execute_instruction(ThreadContext* thread, Instruction* instr) {
switch (instr->type) {
// ... other cases...
case LOCK: {
SynchronizationLock* mutex = get_sync_lock(thread->vm, instr->args[0]);
if (mutex) {
print("[Thread %d] Waiting for lock '%s' at address %p",
thread->thread_id,
mutex->name,
(void*)&mutex->mutex
);
pthread_mutex_lock(&mutex->mutex);
mutex->locked = 1;
print("[Thread %d] Acquired lock '%s'", thread->thread_id, mutex->name);
}
break;
}
case UNLOCK: {
SynchronizationLock* mutex = get_sync_lock(thread->vm, instr->args[0]);
if (mutex && mutex->locked) {
pthread_mutex_unlock(&mutex->mutex);
mutex->locked = 0;
print("[Thread %d] Released lock '%s' at address %p",
thread->thread_id,
mutex->name,
(void*)&mutex->mutex
);
}
break;
}
}
}
Testing thread synchronization
Scenario 1: Successful thread synchronization
Let’s examine a program where multiple threads safely coordinate access to a shared counter.
// main.c
#include <stdio.h>
#include "tiny_vm.h"
int main() {
// Our "Java" program
const char* program[] = {
"setshared counter 0", // Line 0
"thread 6", // Line 1 - Start Thread-1
"thread 13", // Line 2 - Start Thread-2
"sleep 500", // Line 3 - Thread-0 thread waits
"print counter", // Line 4 - Thread-0 prints final value
"exit", // Line 5
// Thread-1: setting counter to 1
"sleep 2", // Line 6
"lock counter_mutex", // Line 7: start of synchronized block
"setshared counter 1", // Line 8
"print counter", // Line 9
"setshared counter 1", // Line 10
"unlock counter_mutex", // Line 11: end of synchronized block
"exit", // Line 12
// Thread-2: setting counter to 2
"sleep 1", // Line 13
"lock counter_mutex", // Line 14: start of synchronized block
"setshared counter 2", // Line 15
"print counter", // Line 16
"setshared counter 2", // Line 17
"print counter", // Line 18
"unlock counter_mutex", // Line 19: end of synchronized block
"exit", // Line 20
NULL
};
printf("Starting TinyVM...\n");
VM* vm = create_vm();
start_vm(vm, program);
destroy_vm(vm);
printf("TinyVM finished.\n");
return 0;
}
The execution log shows our synchronization in action:
- Main thread creates a shared counter
Thread-2
acquires the lock first and sets counter to2
Thread-1
waits for the lock, then sets counter to1
- Main thread prints the final value
1
✗ ./build/tiny_vm
Starting TinyVM...
[2025-01-07 20:18:07.342998] [Thread 0] Thread instructions started
[2025-01-07 20:18:07.344637] [Thread 0] Created shared variable counter
[2025-01-07 20:18:07.344640] [Thread 0] Set-shared counter = 0
[2025-01-07 20:18:07.344671] [Thread 1] Thread instructions started
[2025-01-07 20:18:07.344677] [Thread 2] Thread instructions started
[2025-01-07 20:18:07.345943] [Thread 2] Waiting for lock 'counter_mutex' at address 0x14de04ad8
[2025-01-07 20:18:07.345951] [Thread 2] Acquired lock 'counter_mutex'
[2025-01-07 20:18:07.345953] [Thread 2] Found shared variable counter
[2025-01-07 20:18:07.345955] [Thread 2] Set-shared counter = 2
[2025-01-07 20:18:07.345957] [Thread 2] Found shared variable counter
[2025-01-07 20:18:07.345959] [Thread 2] Variable counter = 2
[2025-01-07 20:18:07.345961] [Thread 2] Found shared variable counter
[2025-01-07 20:18:07.345962] [Thread 2] Set-shared counter = 2
[2025-01-07 20:18:07.345964] [Thread 2] Found shared variable counter
[2025-01-07 20:18:07.345966] [Thread 2] Variable counter = 2
[2025-01-07 20:18:07.345968] [Thread 2] Released lock 'counter_mutex' at address 0x14de04ad8
[2025-01-07 20:18:07.345970] [Thread 2] Thread instructions finished
[2025-01-07 20:18:07.346936] [Thread 1] Waiting for lock 'counter_mutex' at address 0x14de04ad8
[2025-01-07 20:18:07.346940] [Thread 1] Acquired lock 'counter_mutex'
[2025-01-07 20:18:07.346942] [Thread 1] Found shared variable counter
[2025-01-07 20:18:07.346943] [Thread 1] Set-shared counter = 1
[2025-01-07 20:18:07.346945] [Thread 1] Found shared variable counter
[2025-01-07 20:18:07.346946] [Thread 1] Variable counter = 1
[2025-01-07 20:18:07.346948] [Thread 1] Found shared variable counter
[2025-01-07 20:18:07.346949] [Thread 1] Set-shared counter = 1
[2025-01-07 20:18:07.346951] [Thread 1] Released lock 'counter_mutex' at address 0x14de04ad8
[2025-01-07 20:18:07.346953] [Thread 1] Thread instructions finished
[2025-01-07 20:18:07.848649] [Thread 0] Found shared variable counter
[2025-01-07 20:18:07.848738] [Thread 0] Variable counter = 1
[2025-01-07 20:18:07.848745] [Thread 0] Thread instructions finished
TinyVM finished.
This demonstrates proper mutex usage — threads take turns accessing the shared resource.
Scenario 2: Creating a deadlock
To verify our synchronization works correctly, we can also create a deadlock situation — a common threading problem where threads wait indefinitely for each other’s resources:
// main.c
#include <stdio.h>
#include "tiny_vm.h"
int main() {
// Our "Java" program with a dealock
const char* program[] = {
// Thread-0: Starts two threads
"thread 3", // Line 0 - Start Thread-1
"thread 9", // Line 1 - Start Thread-2
"exit", // Line 2
// Thread-1: Tries to acquire mutexA then mutexB
"lock mutexA", // Line 3
"sleep 500", // Line 4 - Wait for Thread-2 to acquire mutexB
"lock mutexB", // Line 5 - Will deadlock here
"unlock mutexB", // Line 6
"unlock mutexA", // Line 7
"exit", // Line 8
// Thread-2: Tries to acquire mutexB then mutexA
"sleep 250", // Line 9 - Wait for Thread-1 to acquire mutexA
"lock mutexB", // Line 10
"lock mutexA", // Line 11 - Will deadlock here
"unlock mutexA", // Line 12
"unlock mutexB", // Line 13
"exit", // Line 14
NULL
};
printf("Starting TinyVM...\n");
VM* vm = create_vm();
start_vm(vm, program);
destroy_vm(vm);
printf("TinyVM finished.\n");
return 0;
}
The dealock process:
Thread-1
acquiresmutexA
, then sleepsThread-2
wakes up and acquiresmutexB
Thread-2
tries to acquiremutexA
(held byThread-1
)Thread-1
wakes up and tries to acquiremutexB
(held byThread-2
)- Both threads wait indefinitely
When we execute it, we will see how the program execution stops as the both threads are waiting for each other to acquire a lock:
Here is a sequence diagram to help with understanding what is going on:
This deadlock example illustrates why proper lock ordering is crucial in multi-threaded applications.
Understanding JVM’s synchronized implementation
When we use the synchronized
keyword in Java, the JVM employs a sophisticated system of lock types and optimizations. Let's understand why and how it works.
Why multiple lock types?
The JVM doesn’t just use simple mutexes like our TinyVM. Instead, it uses three different types of locks, each serving a specific purpose.
1. Biased Locking
In real applications, most synchronized blocks are only accessed by a single thread during their lifetime. For example:
public class UserService {
private Map<String, User> cache = new HashMap<>();
public synchronized User getUser(String id) {
return cache.get(id);
}
}
If this service is only accessed by one thread, using heavy synchronization would be wasteful. Biased locking optimizes for this common case by:
- Marking the object as “owned” by the first thread that locks it
- Avoiding all synchronization overhead for that thread
- Only revoking the bias if another thread tries to acquire the lock
2. Lightweight Locking
When occasional contention occurs, the JVM first tries lightweight locking using atomic operations:
// What happens under the hood for:
synchronized(object) {
// code
}
// JVM attempts atomic Compare-And-Swap (CAS) on object header
if (atomic_compare_and_swap(object_header, unlocked, locked_by_current_thread)) {
// Success - no OS mutex needed
} else {
// Contention - inflate to heavy lock
}
This is faster than using OS mutexes because:
- Atomic CPU operations are much cheaper than system calls
- No context switches are required
- Works well for brief synchronization with low contention
3. Heavy Locking (ObjectMonitor)
Only when there’s significant contention does the JVM use full mutex-based synchronization. Here is a piece of ObjectMonitor
from the HotSpot (OpenJDK) codebase:
class ObjectMonitor {
void* _object; // Points to Java object
void* _owner; // Points to owning thread
volatile intptr_t _count; // Recursion count
volatile intptr_t _waiters; // # of waiting threads
volatile intptr_t _recursions; // Lock recursion count
ObjectWaiter* _WaitSet; // Threads waiting to be notified
ObjectWaiter* _EntryList; // Threads waiting to acquire lock
// ... more fields
};
// simplified implementation to show the logic
void enter(Handle obj, BasicLock* lock) {
if (UseBiasedLocking) {
if (BiasedLocking::revoke_and_rebias(obj, false, THREAD)) {
return;
}
}
markOop mark = obj->mark();
if (mark->is_neutral()) {
// Try lightweight locking
if (lock->try_set_lock(mark, obj)) {
return;
}
}
// Heavy-weight monitor path
ObjectMonitor* monitor = inflate(THREAD, obj(), lock);
monitor->enter(THREAD);
}
If you want to find out more about the details in the HotSpot, focus on these files:
- /hotspot/share/runtime/synchronizer.cpp
- /hotspot/share/runtime/synchronizer.hpp
- /hotspot/share/runtime/objectMonitor.cpp
- /hotspot/share/runtime/objectMonitor.hpp
Lock example
Consider this Java code running in a web service:
public synchronized void processRequest(Request req) {
// Process single request
}
Biased locking:
- First thread marks method object as biased to itself
- Zero synchronization overhead for all requests
Lightweight locking:
- When other threads start accessing the method
- JVM uses atomic CAS operations to manage access
- Still avoids expensive OS mutexes
Heavy locking:
- Under heavy concurrent load
- Full ObjectMonitor provides fair queueing
- Ensures no thread starvation
This adaptive approach is why Java synchronization can be both efficient for simple cases and robust under heavy load.
The complete source code for this article is available in the tiny-vm_04_synchronized directory of the TinyVM repository.
Next steps
Next time, we’ll transform our TinyVM code from a simple string-based instruction set to a proper function-based system, enabling more natural program organization and paving the way for true multi-threaded function calls — a crucial step toward building a more realistic virtual machine.
- Introduction
- Part 1 — Foundations
- Part 2 — Multithreading
- Part 3 — Heap
- Part 4 — Synchronized (you are here)
- Part 5 — Refactoring
- Part 6 — Functions
- Part 7 — Compilation
- Part 8 — Byte-code execution
- Part 9 — Function call stack (not started)
- Part 10 — Garbage collector (not started)