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

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?
.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.
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.
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.
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());
}
}
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.
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.
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));
}
}
127 == 127? true
128 == 128? false
127 equals 127? true
128 equals 128? true
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.
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.
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.
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?
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.
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.
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;
}
}
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.
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;
}
}
isInteger, unroll loops, and leverage CPU branch prediction. Exceptions are reserved for truly exceptional conditions, such as an unexpected null or a broken invariant.
Optional
or result objects with explicit error fields.
4. Question 4: What does the JIT compiler do to improve performance?
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.
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);
}
}
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”.
5. Question 5: What is escape analysis and how does stack allocation work?
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);
}
}
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.
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;
}
}
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.
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”.
Read more at : Series of Interview Questions for Backend Developers Part 4





