Skip to main content

Command Palette

Search for a command to run...

Series of Interview Questions for Backend Developers Part 1

Modern Java backends live and die by how well they use the JVM. Interviewers know this, so they love questions that go below the surface: not just “What is the JVM?” but “What actually happens to your code from .java file to running thread?” or “...

Published
17 min read
Series of Interview Questions for Backend Developers Part 1
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. Question 1: How does the JVM work? From class loading to initialization

When you hit “Run” on a Java program, a lot of machinery spins into action before main is even touched. The JVM pipeline for a class essentially goes through three big phases: loading, linking and initialization. Understanding this flow helps you reason about static blocks, NoClassDefFoundError, class loaders in application servers and subtle startup bugs.

Imagine this small program:

public class JvmLifecycleDemo {

static {
System.out.println("JvmLifecycleDemo: static block");
}

private static Helper helper = new Helper();

public static void main(String[] args) throws Exception {
System.out.println("main: starting");
Class<!--?--> clazz = Class.forName("Helper");
System.out.println("main: loaded class = " + clazz.getName());
}
}

class Helper {

static {
System.out.println("Helper: static block");
}

public Helper() {
System.out.println("Helper: constructor");
}
}

If you run this, the console output will typically look like this:

Helper: static block
Helper: constructor
JvmLifecycleDemo: static block
main: starting
main: loaded class = Helper

The exact order may vary slightly across JVM versions, but the important thing is the story behind these messages.

The journey starts with class loading. When the JVM needs a class, it asks a class loader to find the corresponding .class bytes, normally from the classpath or a JAR. In our example, the main class JvmLifecycleDemo is requested first. The default application class loader reads JvmLifecycleDemo.class, verifies the bytecode and creates an internal representation of this class. That is the “loading” step.

Right after loading comes linking. Linking itself has three substeps: verification, preparation and resolution. Verification runs a series of bytecode checks to ensure that the class obeys the JVM rules and will not corrupt memory. Preparation allocates memory for static fields and sets their default values (for example 0 for numbers, null for references). Resolution then replaces symbolic references inside the class (like “java/lang/String”) with direct references to other loaded classes and methods, but this can be delayed until first use (lazy resolution) to save time and memory.

Initialization is where Java semantics become visible to you. During initialization, the JVM runs the class’s static initializers in a well-defined order. It processes static variable initializers and static initializer blocks from top to bottom, in the order they appear in the source. In JvmLifecycleDemo, before main runs, the JVM must first initialize the helper field, which requires creating a Helper instance. That in turn triggers loading, linking and initialization of Helper. Its static block executes, then its constructor prints a message. Only after finishing that does the JVM continue with JvmLifecycleDemo’s static block, and finally arrives at main.

Two subtle but important consequences for backend work come from this pipeline. First, heavy static initialization can slow startup significantly. If you pre-load large caches, open database connections or parse huge configuration files in static blocks, this all runs before main and can make your service feel “stuck” during deployment. Many teams gradually move that work into explicit bootstrap code that can be timed, logged, and retried. Second, custom class loaders in servers like Tomcat or application frameworks can change which classes are visible in which module. When you see mysterious ClassNotFoundException only in production, it is often about which class loader is responsible for which package rather than about the code itself.

If you like visuals, JVM architecture diagrams showing class loaders feeding verified classes into runtime data areas and the execution engine are very useful to keep this mental model clear while reading GC logs or tuning performance. (Gist)

2. Question 2: How does the G1 Garbage Collector work in detail?

G1 (“Garbage First”) is the default garbage collector in most modern Java versions. It was designed for large heaps and applications that care about predictable pause times more than absolute throughput. To understand G1 you need to shift your thinking away from the old “young vs old generation with fixed contiguous spaces” and toward a world of many regions that can take on different roles. (oracle.com)

Consider this small Java program that intentionally creates a lot of garbage:

import java.util.ArrayList;
import java.util.List;

public class G1Demo {

private static final int ONE_MB = 1024 * 1024;

public static void main(String[] args) {
List<byte[]> holder = new ArrayList<>();
long start = System.currentTimeMillis();

for (int i = 0; i < 10_000; i++) {
byte[] data = new byte[ONE_MB]; // allocate 1MB
if (i % 3 == 0) {
holder.add(data); // keep some alive
}
if (i % 100 == 0) {
System.out.println("Allocated " + i + " MB");
}
}

long end = System.currentTimeMillis();
System.out.println("Finished allocations in " + (end - start) + " ms");

try {
Thread.sleep(60_000); // keep JVM alive so we can inspect GC logs
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

Running this with something like:

java -XX:+UseG1GC -Xms512m -Xmx512m -Xlog:gc* G1Demo

will produce a stream of GC log entries that show G1 performing young collections and occasional mixed collections. The code itself is not magic; it just constantly allocates memory, with some objects surviving longer than others. The magic is how G1 manages the heap.

Instead of one contiguous young generation and one contiguous old generation, G1 splits the Java heap into many equal-sized regions, typically from 1MB to 32MB each depending on overall heap size. Each region can play a role: some are Eden, some are Survivor, and many are Old. The collector tracks which regions contain lots of garbage and which are mostly live objects. (Stack Overflow)

During a young collection, G1 takes a set of Eden regions that are suspected to be mostly garbage. It stops the world, traces live references starting from the roots (stacks, registers, static fields), copies live objects out of those regions into Survivor or Old regions, and then marks the evacuated Eden regions as free. This copying simultaneously collects garbage and compacts memory, removing fragmentation within those regions. Because G1 can choose how many regions to collect in a given cycle, it can approximately honor a pause-time goal like “try to keep pauses below 200ms” by adjusting the amount of work per pause.

Over time, as objects survive multiple young collections, they are promoted into Old regions. The problem then becomes that old memory can also accumulate garbage. Here G1 runs concurrent marking cycles. In these cycles, the collector walks the object graph while your application threads continue running, marking which objects in Old regions are still alive. When this marking information is complete, G1 computes which Old regions contain the most garbage. Later, during mixed collections, G1 will collect some of these garbage-rich Old regions together with Eden and Survivor regions. This is where the name “Garbage First” comes from: it targets the regions with the highest expected garbage first, which tends to maximize reclaimed memory per pause.

An important data structure used by G1 is the remembered set. Since regions are independent, the collector needs to know when objects in one region hold references into another region; otherwise it would have to scan the entire heap every time. Write barriers on field stores update remembered sets so the collector can efficiently find cross-region references.

In real backend systems, G1 gives you a few practical advantages. You can set a soft pause-time goal with -XX:MaxGCPauseMillis= and G1 will try to shape its work to respect that, which is a big deal for latency-sensitive APIs. You also get automatic compaction within regions, reducing old-school fragmentation issues seen in CMS. On the flip side, G1 has more metadata, more bookkeeping and sometimes higher CPU overhead, so profiling both CPU and GC logs is essential when tuning. There are great diagrams online showing the heap divided into many colored regions with arrows indicating evacuation paths, which can make reading your GC logs far less intimidating. (Red Hat)

3. Question 3: Why is String immutable? Benefits for memory and security

String being immutable is not just an academic statement; it deeply shapes how Java applications use memory, handle security and behave under concurrency. In backend code, you pass strings for everything: URLs, SQL commands, JWT tokens, cache keys, user IDs. If String were mutable, the entire ecosystem would be much more fragile.

Take this example:

import java.util.HashMap;
import java.util.Map;

public class ImmutableStringDemo {

public static void main(String[] args) {
Map<string, string=""> secrets = new HashMap<>();
String key = new String("API_KEY_123");

secrets.put(key, "super-secret-token");

System.out.println("Value before: " + secrets.get(key));

String other = key.replace("123", "456");

System.out.println("Key: " + key);
System.out.println("Other: " + other);
System.out.println("Value after: " + secrets.get(key));
}
}

This code prints the value both times and shows that key stays the same string even though we “changed” it. The call to replace does not mutate key; it returns a new String object, which becomes other. The original key remains unchanged and continues to correctly fetch the value from the map.

Now imagine a world where String was mutable. Someone could hold a reference to your key object in the map and then change its internal characters. The hash code would be wrong, the bucket in the hash table would not match the new content and the entry would be virtually lost. This is why immutable strings are safe as keys for maps, as entries in sets, and as constants across your application.

Immutability also helps with memory and performance via the string pool. The JVM maintains a pool of unique string literals; when you write "hello" in many places, the runtime can store a single "hello" instance and share it. If strings were mutable, you could not safely share a single instance, because one caller could change it and surprise everyone else sharing the same object. That would force the VM to duplicate many strings, increasing memory usage and hurting cache locality. With immutability, a string literal used by hundreds of classes still lives as a single object in memory, which is extremely efficient in large backend services where log messages, SQL fragments and constant IDs are repeated everywhere. (GeeksforGeeks)

Security is another big reason. Many core APIs assume that once a string is passed in, it cannot secretly change behind their backs. For example, java.io.File and many network or security APIs rely on string paths and hostnames. If an attacker could mutate the string used in permission checks after the check but before the operation, they might trick the system into writing to or reading from unintended locations. Because String is immutable, these “time-of-check to time-of-use” attacks at the API level are much harder.

In concurrent code, immutability means thread safety by default. Multiple threads can freely share the same String reference without synchronization. You never worry about one thread half-writing to a string while another thread reads a corrupted value. This is crucial for backend services handling thousands of concurrent requests that pass through layers of filters, controllers, services and repositories.

Of course, immutability means that operations like concatenation create new objects. For tight loops that build large strings, this can create a lot of garbage. That is why Java offers StringBuilder and StringBuffer: mutable string builders that are efficient for incremental construction. A common pattern in backend code is to build a large response with StringBuilder and then convert to an immutable String at the boundary where you send it over the network. This gives you the best of both worlds: fast construction inside the method, safe immutable value passed outside.

You can find nice diagrams online showing how strings are stored in the heap and pooled, which help visualize how the same literal value points to the same object and why modification would break this model. (DEV Community)

4. Question 4: What is the difference between volatile and synchronized?

At first glance, both volatile and synchronized seem to be about “threads” and “shared variables”, so it is tempting to treat them as interchangeable tools. In reality they solve different problems. volatile is primarily about visibility and ordering of reads and writes to a single variable. synchronized is about mutual exclusion and establishing a wider happens-before relationship around a block of code.

Consider this Java example:

public class VolatileVsSynchronized {

private static volatile boolean running = true;
private static int counter = 0;

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) {
increment();
}
});

worker.start();

Thread.sleep(1000);

running = false;
worker.join();

System.out.println("Final counter = " + counter);
}

private static void increment() {
counter++;
}
}

Here, running is marked volatile while counter is not synchronized in any way. The volatile flag guarantees that when the main thread sets running to false, the worker thread will eventually see that change and stop the loop. Without volatile, the worker might cache the value of running in a register or CPU cache and never see the update, looping forever on some architectures.

However, counter increments are not atomic. The expression counter++ reads the current value, adds one and writes it back. In a multithreaded context, these steps can interleave in a way that loses updates. In this particular example, there is only one worker thread incrementing counter, so you will not see lost updates. But if you start multiple workers, visibility alone is not enough; you need atomicity. Marking counter as volatile would not fix this, because volatile does not combine the three sub-operations of increment into one indivisible operation.

Now consider a variant using synchronized:

public class VolatileVsSynchronizedFixed {

private static volatile boolean running = true;
private static int counter = 0;

public static void main(String[] args) throws InterruptedException {
Thread worker1 = new Thread(VolatileVsSynchronizedFixed::runLoop);
Thread worker2 = new Thread(VolatileVsSynchronizedFixed::runLoop);

worker1.start();
worker2.start();

Thread.sleep(1000);

running = false;
worker1.join();
worker2.join();

System.out.println("Final counter = " + counter);
}

private static void runLoop() {
while (running) {
increment();
}
}

private static synchronized void increment() {
counter++;
}
}

In this second version, running remains volatile for cross-thread visibility, but increment is synchronized. The synchronized keyword on the method ensures that only one thread at a time can execute increment. It implicitly uses the class object as a monitor. When a thread enters the synchronized method, it acquires the monitor; on exit it releases it. This provides mutual exclusion and also establishes happens-before relationships. All writes done by one thread inside the synchronized block become visible to another thread that later acquires the same monitor.

The key difference is that volatile does not provide mutual exclusion. Two threads can still execute code that reads and writes a volatile variable at the same time. Volatile guarantees that each read sees the most recent write and that there is a memory barrier around those operations, but it does not combine multiple lines of code into an atomic transaction. synchronized on the other hand can guard a whole critical section, ensuring that the entire block appears atomic with respect to other threads.

In practice, volatile is ideal for simple flags, configuration values that change rarely, or double-checked initialization patterns when used correctly with the Java Memory Model. synchronized remains useful for protecting complex invariants involving multiple fields, though in high-concurrency backends many teams now prefer java.util.concurrent abstractions like ReentrantLock, AtomicInteger, ConcurrentHashMap and others for more fine-grained control and better performance under contention.

When you debug race conditions in a backend service, asking yourself “Do I need just visibility or do I need full mutual exclusion and invariants?” often leads directly to whether volatile, synchronized or a higher-level concurrent structure is appropriate.

5. Question 5: When should you use ReentrantLock instead of synchronized?

If synchronized is a simple, built-in lock, ReentrantLock is its more feature-rich cousin from java.util.concurrent.locks. Both provide mutual exclusion, but ReentrantLock gives you extra capabilities that matter in complex backend systems: timed acquisition, interruptible acquisition, separate lock and condition objects, fair ordering, and more transparent control.

Here is a straightforward example:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {

private static final ReentrantLock lock = new ReentrantLock(true); // fair lock
private static int sharedResource = 0;

public static void main(String[] args) {
Thread t1 = new Thread(ReentrantLockDemo::doWork, "Worker-1");
Thread t2 = new Thread(ReentrantLockDemo::doWork, "Worker-2");

t1.start();
t2.start();
}

private static void doWork() {
System.out.println(Thread.currentThread().getName() + " trying to acquire lock");

boolean acquired = false;
try {
acquired = lock.tryLock(500, TimeUnit.MILLISECONDS);
if (!acquired) {
System.out.println(Thread.currentThread().getName() + " could not get lock in time, backing off");
return;
}

System.out.println(Thread.currentThread().getName() + " acquired lock");
for (int i = 0; i < 5; i++) {
sharedResource++;
System.out.println(Thread.currentThread().getName() + " updated sharedResource to " + sharedResource);
Thread.sleep(200);
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " was interrupted while waiting for lock");
Thread.currentThread().interrupt();
} finally {
if (acquired) {
lock.unlock();
System.out.println(Thread.currentThread().getName() + " released lock");
}
}
}
}

In this example, both worker threads try to acquire the same lock. The constructor new ReentrantLock(true) requests a fair lock, which tries to grant access in roughly first-come-first-served order. Each thread calls tryLock with a timeout, so if the lock is not available within 500 milliseconds, it can give up and do something else instead of waiting forever. This is something you simply cannot do with synchronized, where entering a synchronized block will always block until the lock becomes available.

There are several concrete situations where ReentrantLock is a better choice than synchronized in backend code.

The first is when you need timed or interruptible lock acquisition. For example, suppose you have a cache rebuild operation that is expensive. You might want only one thread to rebuild at a time, but other threads should not block indefinitely; they should either use stale data briefly or return an error after a timeout. With a ReentrantLock, you can call tryLock(timeout, unit) and if acquisition fails, you can log a warning and degrade gracefully. Interruptible acquisition via lock.lockInterruptibly() is also important in systems where threads should react to shutdown signals without being stuck forever in a lock that is held by a slow or dead thread.

The second is when you need multiple condition variables associated with the same lock. synchronized blocks come with a single implicit monitor condition, accessed via wait, notify and notifyAll. ReentrantLock allows you to create multiple Condition objects from a single lock, which is powerful when you have multiple waiting groups with different wake-up rules, such as producer-consumer queues with priority levels or slot-type segmentation.

The third is when you care about explicit fairness. While fairness is not a strict guarantee even with ReentrantLock(true), it significantly reduces the chance that one hot thread will repeatedly re-acquire the lock and starve others. In APIs where each request should get roughly equal treatment, fairness can help prevent pathological starvation scenarios that show up as mysterious long-tail latencies.

One more practical difference is tooling and debugging. Because ReentrantLock is a regular object with methods, you can instrument it, log where tryLock fails, wrap it, or even pass it around as a dependency. With synchronized, the monitor is bound to the object or class, and you rely more on thread dumps and external tools to understand contention.

That said, synchronized is still perfectly fine and often preferable for simple use cases: short critical sections, initialization blocks, or places where you do not need advanced features. It has the advantage of being managed by the JVM with no risk of forgetting to call unlock (which is easy to do in complex flows if you are not disciplined with try/finally). Many performance regressions in real projects come not from choosing the “wrong” construct but from overusing locks where higher-level concurrent collections or lock-free algorithms would be better.

If you have reached this point, you already see that these “simple” interview questions open doors into a lot of practical decisions: how you design class initialization, how you tune GC, why strings behave the way they do, how to avoid subtle race conditions, and when to reach for more advanced concurrency primitives. In real backend systems, these are exactly the kinds of details that separate a stable, predictable service from one that is full of elusive bugs.

If there is any part you want to go deeper into or any other Java backend interview question you are struggling with, feel free to drop your questions in the comments below and we can explore them together.

Read more at : Series of Interview Questions for Backend Developers Part 1

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!