Skip to main content

Command Palette

Search for a command to run...

Series of Interview Questions for Backend Developers Part 4

When a backend developer says they “know Java,” what interviewers really want to see is whether you understand what happens under the hood: how classes are loaded, why tiny details like Integer caching matter, and how the JIT and escape analysis ...

Published
17 min read
Series of Interview Questions for Backend Developers Part 4
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: What types of Java class loaders exist and how do they work?

At a high level, a class loader is just a piece of Java code whose job is to take a .class file (or bytes from a JAR, a network location, or even a generated array in memory) and turn it into a Class object that the JVM can execute. Java does not have a single global loader; instead it uses a small hierarchy of cooperating class loaders that follow a parent-delegation model. In a typical HotSpot JVM, you will meet three main built-in loaders, often shown as a simple three-level diagram in many references: Bootstrap at the top, then Extension/Platform, then the Application (System) class loader.

The bootstrap class loader is implemented in native code and is responsible for loading the core Java runtime classes (for example java.lang.String, collections, concurrency utilities). It usually loads them from the runtime modules or their equivalent of the old rt.jar. Because it is part of the JVM itself, you never hold a direct reference to it; calling getParent() on the top “real” loader returns null, which conceptually represents the bootstrap loader. Below that lies the platform (historically called extension) class loader, which loads classes from standard extension modules, such as additional JDK libraries. At the bottom of the default chain is the application (system) class loader. It loads classes from your application’s classpath or module path: the JARs and directories you pass with -cp or --class-path or declare as modules. Application servers and frameworks often insert additional class loaders below this to isolate applications or plugins, which is why diagrams of enterprise servers show deeper hierarchies branching from the system loader.

The most important behavioural rule is parent delegation. When a class loader is asked to load com.example.Foo, it first asks its parent. Only if the parent cannot provide the class does the child try to find and define it from its own set of URLs or byte sources. This rule is what prevents accidentally redefining java.lang.String in your own JAR and keeps core APIs consistent across an entire JVM process. It also means that subtle bugs can appear if two different class loaders load the same class name but from different locations: the JVM sees them as different types, which is why you can get a ClassCastException saying that a class cannot be cast to itself when two copies of the same bytecode were loaded by different loaders.

A very common interview follow-up is: can you write a custom class loader? The answer is yes, and the simplest way is to extend ClassLoader and override findClass, letting the parent delegation mechanism call your method only when the parent fails. Here is a small example that loads classes from a custom directory to simulate a plugin system:

public class DirectoryClassLoader extends ClassLoader {
private final Path classesDir;

public DirectoryClassLoader(Path classesDir, ClassLoader parent) {
super(parent); // keep parent delegation
this.classesDir = classesDir;
}

@Override
protected Class<!--?--> findClass(String name) throws ClassNotFoundException {
try {
String relativePath = name.replace('.', '/') + ".class";
Path classFile = classesDir.resolve(relativePath);
byte[] bytes = Files.readAllBytes(classFile);

// defineClass links the bytes into the JVM as a real Class
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("Cannot load " + name, e);
}
}

public static void main(String[] args) throws Exception {
Path pluginDir = Path.of("plugins");
DirectoryClassLoader loader =
new DirectoryClassLoader(pluginDir, ClassLoader.getSystemClassLoader());

Class<!--?--> pluginClass = loader.loadClass("com.example.Plugin");
Object plugin = pluginClass.getDeclaredConstructor().newInstance();
System.out.println("Loaded plugin with class loader: " +
pluginClass.getClassLoader());
}
}

When you call loader.loadClass("com.example.Plugin"), the JVM first asks the system loader. If the class is not in the application classpath, the system loader throws ClassNotFoundException, then the call falls back to findClass in your loader, which reads the .class file from disk and defines it. This pattern is what allows application servers and frameworks to implement hot-swappable plugins, tenant isolation, or “reload without restarting the JVM” features. It also explains subtle issues like memory leaks caused by class loaders that are kept alive by static singletons or thread-locals after a web application is undeployed.

For visual reinforcement, it is often helpful to look at a simple hierarchy diagram where Bootstrap is at the top, Platform/Extension in the middle and System/Application plus custom loaders at the bottom branching out.

2. Question 2: Why does Java cache Integer values from −128 to 127?

Integer is a wrapper around the primitive int, but using it naïvely can create a lot of short-lived objects. To reduce both memory pressure and allocation overhead, the JDK keeps a small pool of Integer instances for a specific range of values and reuses them whenever those values are requested. The official documentation of Integer.valueOf(int) explains that values in the range −128 to 127 are always cached, and the JVM may decide to cache a wider range depending on configuration.

You can see the effect of the cache very clearly with a small example:

public class IntegerCacheDemo {
public static void main(String[] args) {
Integer a = 127;
Integer b = 127;
Integer c = 128;
Integer d = 128;

System.out.println("127 == 127? " + (a == b));
System.out.println("128 == 128? " + (c == d));
System.out.println("127 equals 127? " + a.equals(b));
System.out.println("128 equals 128? " + c.equals(d));
}
}

If you run this with a typical HotSpot configuration, you will get:

127 == 127? true
128 == 128? false
127 equals 127? true
128 equals 128? true

The first line prints true because both a and b point to the same cached Integer instance representing 127. Internally, the call Integer a = 127; is compiled as Integer.valueOf(127), which consults the cache instead of allocating a new object when the value is in the cached range. For 128 and other values outside that default range, the method usually allocates new objects, so c and d refer to different instances and c == d is false, even though their numeric values are equal. That is why best practice is to compare wrapper objects with .equals() rather than == when you care about numeric equality rather than object identity.

From a runtime perspective, this caching brings two concrete benefits. First, values like −1, 0, 1 and small loop counters show up everywhere in Java code, so reusing objects for those values removes a huge number of allocations and therefore reduces garbage collection work. A memory diagram showing multiple references pointing to the same cached Integer object in the heap makes this idea very intuitive. Second, because the cache is populated during JVM startup, many performance-critical parts of the standard library can rely on fast identity comparisons without paying per-call allocation costs.

For completeness, many other wrapper classes also use caches for small values. For example, Boolean.valueOf, Byte.valueOf, Short.valueOf and Long.valueOf cache certain ranges, although the details vary by class and JVM. The cached upper bound for Integer can even be configured in some JVMs via properties such as java.lang.Integer.IntegerCache.high or a VM flag, allowing you to extend the range beyond 127 when you know your application heavily uses larger but still relatively small integers.

The key interview takeaway is that Integer caching both explains weird behaviour when using == on wrappers and highlights how tightly the language and runtime are tuned for performance in everyday usage.

3. Question 3: Why should we avoid using exceptions for normal control flow?

Exceptions in Java are designed to represent exceptional situations: I/O failures, protocol violations, programming mistakes and so on. They are not meant to be a regular branch mechanism like if or switch. The main reasons have to do with performance characteristics, code readability, and how the JIT optimizes code based on the assumption that exceptions are rare.

From a performance viewpoint, throwing an exception is significantly more expensive than a simple conditional branch. When you throw, the JVM needs to capture the current call stack, create an exception object (if one does not already exist), fill in the stack trace and then unwind the stack until it finds a matching catch block. All of this involves walking frames, touching metadata and often allocating memory. In contrast, a simple if (condition) { ... } else { ... } usually compiles down to a handful of machine instructions with predictable control flow that the CPU and JIT can optimize aggressively. Papers and blog posts that measure exception cost repeatedly show that frequent throwing can be orders of magnitude slower than a clear conditional path written with normal control structures.

To see how using exceptions as a “normal” branch quickly becomes ugly, consider the following naive attempt to parse a list of integers:

public class ExceptionControlFlowBad {
public static int sumValidNumbers(List<string> inputs) {
int sum = 0;
for (String input : inputs) {
try {
// Using exception as branch: "if it fails, we'll catch"
sum += Integer.parseInt(input);
} catch (NumberFormatException e) {
// Ignore invalid numbers
}
}
return sum;
}
}

At first glance this might look neat: no explicit validation, just try and catch. But if many of the inputs are invalid, you are constantly throwing NumberFormatException, filling stack traces and unwinding, which puts pressure on the allocator and GC. Worse, your logs might be flooded if you later add logging inside the catch block. The JIT also treats exception paths as cold branches that are not optimized as much, so leaning on them as your main flow can prevent inlining or other optimizations along the hot path.

A more robust and efficient version uses explicit checks or library methods to validate before parsing:

public class ExceptionControlFlowGood {
public static int sumValidNumbers(List<string> inputs) {
int sum = 0;
for (String input : inputs) {
if (isInteger(input)) {
sum += Integer.parseInt(input);
}
}
return sum;
}

private static boolean isInteger(String s) {
if (s == null || s.isEmpty()) return false;
int i = 0;
if (s.charAt(0) == '-' && s.length() > 1) {
i = 1;
}
for (; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) {
return false;
}
}
return true;
}
}

Now the hot path is a simple loop with predictable branches; the JIT can inline isInteger, unroll loops, and leverage CPU branch prediction. Exceptions are reserved for truly exceptional conditions, such as an unexpected null or a broken invariant.

There is also a design dimension. When exceptions represent rare, serious issues, reading the code becomes easier: a try/catch block says “this operation might fail in unusual ways, and here is how we handle that”. If every minor branch is implemented with throw/catch, it becomes harder to see real problems among the noise, and unit tests need to care about stack traces instead of clear return types like Optional or result objects with explicit error fields.

In performance-sensitive backend services, you will almost always want the happy path to be free of exceptions, both to keep latency predictable and to avoid surprising CPU spikes under heavy load. This philosophy is reflected in many JVM optimizations: execution traces that rarely throw are compiled more aggressively, while exception edges are treated as cold paths and sometimes remain interpreted or less optimized.

4. Question 4: What does the JIT compiler do to improve performance?

The Just-In-Time (JIT) compiler is the secret engine that allows Java to be both portable and fast. At compile time, javac turns your source code into platform-independent bytecode. At run time, the JVM steps in: it interprets the bytecode at first, observes which parts of the code are “hot” (executed frequently), and then compiles those hot regions into optimized machine code for the actual CPU you are running on. Diagrams that show the transition from Java source to bytecode to JIT-compiled native code, often with the JIT sitting next to the interpreter inside the JVM box, capture this pipeline nicely.

In a modern HotSpot JVM, there is even a notion of tiered compilation. Initially, methods are either interpreted or compiled with a fast, simple compiler (client or C1 compiler) that adds lightweight instrumentation. As profiling data accumulates, truly hot methods are recompiled by a more aggressive, optimizing compiler (server or C2 compiler) using that profile data. The profile contains information such as which branches are usually taken, which virtual calls actually target which concrete classes at runtime, and how often certain loops run.

Based on this dynamic information, the JIT can perform optimizations that static ahead-of-time compilers cannot do as effectively for Java. Some of the key techniques include inlining, devirtualization, loop optimizations and allocation elimination.

Inlining replaces a method call with the body of the method itself when the JIT believes it is profitable. This reduces call overhead and exposes further optimization opportunities across method boundaries. Devirtualization is closely related: when the JIT sees that an interface call or virtual method is almost always dispatched to a single concrete implementation at a call site, it can inline that implementation and sometimes even remove the virtual dispatch entirely. In the context of backend development, this is crucial because frameworks and dependency injection containers rely heavily on interfaces and abstractions.

Consider a simple example:

interface PriceCalculator {
int price(int base, int taxPercent);
}

class DefaultPriceCalculator implements PriceCalculator {
@Override
public int price(int base, int taxPercent) {
return base + (base * taxPercent / 100);
}
}

public class JitInliningDemo {
private static final PriceCalculator CALCULATOR =
new DefaultPriceCalculator();

public static void main(String[] args) {
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += CALCULATOR.price(100, 10);
}
System.out.println(sum);
}
}

At the Java source level you are calling an interface method inside a tight loop. Naively, each iteration would need a virtual dispatch to figure out which price implementation to call. At runtime, the JIT sees that CALCULATOR is always a DefaultPriceCalculator. After profiling a bit, it can inline the price method directly into the loop, turning the hot path into simple arithmetic instructions: multiply, divide, add. If later you change CALCULATOR dynamically to point to another implementation, the JVM can deoptimize, throw away the compiled code and recompile with the new information, which is why people say Java’s performance is “adaptive”.

The JIT also does classic loop optimizations such as loop unrolling, strength reduction and hoisting of invariant computations out of loops. It can remove bounds checks on arrays when it proves that the index is always within range; otherwise each array access would need a check for every iteration. On top of that, it performs escape analysis, which we will discuss in the next question, to decide when to allocate objects on the stack or even not allocate them at all. All of this is guided by runtime feedback rather than static guesses.

From the perspective of a backend service, what you experience is that performance improves after the JVM has warmed up. The first few calls run interpreted or with less optimized code; as traffic builds up and hotspots are identified, the JIT compiles them more aggressively, reducing latency and CPU usage. That is why load tests usually include a warm-up phase and why you sometimes see animated diagrams in JVM blogs showing bytecode being gradually replaced by optimized native code inside the jitter.

5. Question 5: What is escape analysis and how does stack allocation work?

Escape analysis is a static analysis performed by the JIT at runtime to determine whether an object can be accessed outside the scope in which it was created. In simpler terms, the JVM asks: “Does this object escape the method or thread where it was allocated, or is it confined?” If the analysis proves that the object does not escape, the JIT is free to perform optimizations such as allocating it on the stack instead of the heap, or even breaking it into separate scalar variables (scalar replacement). Classic slides on escape analysis show graphs where “captured” objects are candidates for stack allocation while “escaped” ones must remain on the heap.

To understand why this matters, recall that heap allocations increase GC pressure: every object you allocate must eventually be traced and collected. Stack allocations, on the other hand, are extremely cheap: increasing or decreasing the stack pointer when entering or leaving a method. Articles that compare stack versus heap memory allocation usually depict the stack as a simple vertical structure of frames and the heap as a large arena of objects, emphasizing how short-lived local data fits naturally on the stack.

Here is a simple example where escape analysis can help the JVM:

public class EscapeAnalysisDemo {

static class Point {
final int x;
final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}

public static int compute() {
Point p = new Point(10, 20);
return p.x * p.y;
}

public static void main(String[] args) {
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += compute();
}
System.out.println(sum);
}
}

At the Java level, compute() clearly allocates a new Point each time it’s called. Naively, that means 10 million heap allocations. But the JIT sees that the Point object does not escape the compute method: it is not returned, not stored in a field, not passed to another method that might stash it somewhere. All uses of p are local. Escape analysis can therefore rewrite the code so that no actual heap object is created. Instead, the fields x and y become local integers on the stack or even CPU registers, and the compiled version of compute() ends up being equivalent to return 10 * 20; after constant propagation, without any allocation at all.

Contrast that with this slightly different method:

public class EscapeAnalysisNegative {

private static Point lastPoint;

static class Point {
final int x;
final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}

public static int computeAndStore() {
Point p = new Point(10, 20);
lastPoint = p; // escapes to a static field
return p.x * p.y;
}
}

Here the object clearly escapes: you assign it to the static field lastPoint, which can be read by other methods or threads at any time. The JIT cannot safely transform this allocation into a stack object, because the object must outlive the method; it must be heap-allocated. This is the essence of escape analysis: proving the absence of such escapes so that more aggressive optimizations are sound.

The practical implication for backend developers is that object allocation is not always as expensive as it appears in source code. If you keep objects short-lived and confined to a method or thread, the JIT may turn them into stack allocations or even remove them entirely through scalar replacement. That said, you should not rely on compiler magic to fix fundamentally inefficient designs. Escape analysis is a best-effort optimization; if your code stores objects into global caches, long-lived collections or shares them among threads, they will escape and the JVM will have no choice but to allocate them on the heap and let the garbage collector deal with them.

If you look up academic presentations on escape analysis, you will find diagrams where nodes in a points-to graph are coloured to show whether objects are captured or escaped and slides showing how “stack allocation of objects” eliminates GC overhead and improves locality. These visuals are helpful to build a mental model: captured nodes stay within the method’s stack frame; escaped nodes leak out through parameters, return values or fields.

Understanding these five topics—class loaders, Integer caching, proper use of exceptions, JIT compilation and escape analysis—gives you a much deeper mental model of how Java really runs your backend code. It also helps you debug weird behaviour (like Integer identity surprises), design better APIs (clear control flow instead of exception gymnastics) and reason about performance beyond “let’s just add more CPUs”.

If there is any part you would like to dive into further, or if you have another tricky interview question you want to unpack, just leave your question in the comments below and we can explore it together.

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

More from this blog

T

tuanh.net

540 posts

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