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 “...

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
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.
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");
}
}
Helper: static block
Helper: constructor
JvmLifecycleDemo: static block
main: starting
main: loaded class = Helper
.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.
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.
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.
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.
2. Question 2: How does the G1 Garbage Collector work in detail?
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();
}
}
}
java -XX:+UseG1GC -Xms512m -Xmx512m -Xlog:gc* G1Demo
-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.
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));
}
}
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.
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.
"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)
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.
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.
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.
4. Question 4: What is the difference between volatile and synchronized?
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.
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++;
}
}
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.
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.
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++;
}
}
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.
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.
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.
volatile, synchronized or a higher-level concurrent structure is appropriate.
5. Question 5: When should you use ReentrantLock instead of synchronized?
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.
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");
}
}
}
}
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.
ReentrantLock is a better choice than synchronized in backend code.
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.
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.
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.
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.
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.
Read more at : Series of Interview Questions for Backend Developers Part 1





