✍️
Human Authored & ReviewedFact-checked by Kodivio Engineering
Enterprise Interview MasteryJava & JVM PlatformPublished: Apr 10, 2026 · Updated: Jun 3, 2026

Java InterviewQuestions— & real answers.

18+ questions covering Java 21, Spring Boot 3, JVM internals, and distributed systems. Written by engineers who've been on both sides of the whiteboard.

18+
Questions
3
Difficulty levels
7
Topic modules
12+
Code examples
🎯

How to use this guide

Each question is collapsible — click to reveal the full answer. Filter by difficulty to focus your prep. Senior engineers: skip to JVM Internals and Microservices.

💡

What interviewers actually want

They don't just want the definition. They want to know you understand the *why*, can give a concrete example, and know when *not* to use a given approach.

⏱️

Realistic prep timeline

Junior: focus on the Screening Room + OOP modules (3–4 hours). Mid-level: add Spring Boot and Core Java (full day). Senior: all modules, especially JVM and Microservices.

Java interviews in 2026 look different than they did five years ago. The JVM has changed. The ecosystem has changed. And the bar for what counts as "senior" has quietly risen.

A decade ago, memorizing the difference between HashMap and ConcurrentHashMap was enough to clear a mid-level screen. Today, interviewers at product companies expect you to reason about virtual threads, GraalVM compilation tradeoffs, and distributed transaction patterns — not just recite them, but apply them to novel scenarios on the spot.

This guide is built around that reality. Every answer goes deeper than the definition, includes a concrete example or failure mode, and ends with the practical implication — what you'd actually do differently in a codebase. That's the level interviewers are testing at, and that's what this guide prepares you for.

18 questions

This is the most misanswered OOP question. Abstraction is about intent: what an object *does*, not how it does it. You expose a clean surface area (an interface, an abstract class) and hide the messy internals behind it. Think of a car's steering wheel — you turn it, and the car turns. You don't need to understand the hydraulics.

Polymorphism is about behavior at runtime. It's the ability for a single variable reference (say, Animal animal) to hold a Dog, a Cat, or a Parrot, and for each to respond to animal.makeSound() in its own way. The 'what' versus the 'how'.

Why does it matter? Confuse the two and your design falls apart. Abstraction gives you a stable API. Polymorphism gives you extensibility without modification — the Open/Closed Principle in action.

Before Records, writing a proper immutable value object in Java was painful: private final fields, a verbose constructor, getters, hashCode(), equals(), and toString() — usually 50+ lines for a 3-field class. Most developers took shortcuts, leaving fields package-private or skipping the boilerplate, and encapsulation quietly broke.

Records eliminate that friction. With record Point(int x, int y) {}, the compiler enforces the contract: fields are private, final, and backed by canonical accessors. You can't accidentally mutate state. The class is implicitly final too.

More importantly, Records are a signal about *intent*. They communicate to every reader: 'This object is pure data. It has no behavior. Treat it as a value.' That clarity is itself a form of encapsulation — protecting the concept, not just the fields.

The textbook says: 'Subtypes must be substitutable for their base types.' That's true but opaque. Here's what it means in practice.

Imagine a Rectangle class with setWidth() and setHeight(). You then create Square extends Rectangle, because geometrically a square *is* a rectangle. But now setWidth(5) on a Square should also set the height to 5 (to remain a valid square), which breaks the Rectangle contract that width and height are independent.

Any code written to manipulate a Rectangle now behaves incorrectly when handed a Square. The subtype isn't truly substitutable. This is an LSP violation.

In real codebases this manifests as instanceof checks scattered everywhere ('if it's actually a Square, do this differently'). That's a smell. The fix is often to reconsider the hierarchy: favor composition over inheritance, or use interfaces that accurately represent the contract your implementations can honour.

DIP says: high-level modules (your business logic) shouldn't depend on low-level modules (database drivers, HTTP clients). Both should depend on abstractions (interfaces).

Here's a concrete Spring example. You have an OrderService that needs to save orders. If OrderService has a field private MySQLOrderRepository repo, it's tightly coupled to MySQL. Swap to PostgreSQL and you're refactoring business logic.

Instead: define interface OrderRepository. Your OrderService depends on that interface. The Spring IoC container reads your @Configuration, finds a bean that implements OrderRepository (maybe JpaOrderRepository), and injects it. OrderService never knows or cares what's underneath.

Spring makes DIP nearly automatic, but understanding *why* it's structured that way helps enormously when debugging injection failures, circular dependencies, or testing with mocks — which are all just DIP in action.

Double-checked locking (the infamous if (instance == null) { synchronized ... }) was popular for years and is subtly broken in classic Java. The issue is instruction reordering: the JVM can reorder the steps of object construction, so another thread might see a non-null but incompletely initialized instance.

Marking the field volatile fixes this (volatile prevents reordering), but it's still error-prone boilerplate.

The modern alternatives are cleaner. The Initialization-on-Demand Holder Idiom uses a nested static class whose instance field is only initialized when first accessed — and class loading in the JVM is inherently thread-safe. No synchronization needed, no volatile, no locking at all.

Even cleaner: an enum. enum MySingleton { INSTANCE; } is thread-safe, serialization-safe, and reflection-proof by specification. It's the approach Joshua Bloch has recommended since Effective Java, and it's still correct in 2026.

Constructors seem simpler, but they have real limitations. They always return the exact type they're called on. They can't cache instances. And they force you to expose the concrete class to every caller.

Consider a Shape factory: you call ShapeFactory.create('circle') and get back an instance of the Shape interface. Tomorrow you add an oval, a hexagon. No client code changes.

Or consider connection pooling. You want new DatabaseConnection() to return an existing idle connection if one's available, not always allocate a new one. A constructor can't do that. A factory method can check a pool first.

A more subtle case: named constructors. BigDecimal.valueOf(0.1) behaves differently from new BigDecimal(0.1) — the static factory avoids floating-point precision issues. Names like of(), from(), getInstance(), create() communicate intent that overloaded constructors can't.

G1GC (Garbage-First Garbage Collector) divides the heap into small, equal-sized regions (typically 1–32MB each) rather than fixed Young/Old generations. This gives the GC flexibility to collect whatever combination of regions offers the best reclaim-to-pause tradeoff — hence 'Garbage-First'.

A typical cycle: Eden regions fill up, triggering a Minor GC (young-only collection). G1 traces live objects from GC roots, copies survivors into Survivor regions, and eventually promotes long-lived objects to Old regions. This is stop-the-world but brief.

Periodically, G1 runs a concurrent marking phase alongside your application threads to identify which Old regions have the most garbage. Then during a Mixed GC, it collects both young regions and the most profitable old regions.

For latency-sensitive apps, you can set -XX:MaxGCPauseMillis=200 as a soft target. G1 will attempt to keep pauses under 200ms by limiting how many regions it collects per cycle.

Where G1 falls short: very large heaps (hundreds of GBs), or apps that need sub-millisecond pauses. That's where ZGC and Shenandoah come in.

ZGC's key insight is to perform almost all GC work concurrently — while your application threads are running, not paused. The only stop-the-world phases are short root scans (typically 1–2ms regardless of heap size).

The magic behind this is load barriers and colored pointers. ZGC embeds metadata (GC phase information) into the upper bits of object pointers. When your application code loads an object reference, a JVM-injected load barrier checks these bits and takes corrective action if needed (remapping a reference to a relocated object, for example). This happens transparently, on every reference load.

Relocation (moving objects to compact the heap) happens concurrently too. ZGC maintains a forwarding table so that when an object moves, any thread loading a stale reference gets redirected via the load barrier to the new address.

The cost: load barriers add ~5–15% throughput overhead compared to G1. That's the tradeoff. For real-time trading systems, low-latency APIs, or any app where a 200ms GC pause is catastrophic, ZGC is worth every percentage point.

Traditional Java threads (platform threads) map one-to-one to OS threads. OS threads are expensive — each costs about 1MB of stack space, and context switching between them burns CPU cycles. This is why thread pools exist. It's why reactive frameworks like WebFlux and Project Reactor were invented: to do more I/O work with fewer threads using non-blocking callbacks and event loops.

But reactive code is notoriously hard to write, debug, and trace. Stack traces become meaningless. Debugging is painful. Learning curves are steep.

Virtual Threads (Project Loom, GA in Java 21) break the 1:1 assumption. A virtual thread is a JVM construct — a cheap data structure that gets *mounted* onto a platform thread when it needs CPU, and *unmounted* when it blocks (on a socket, a database call, a lock). A single platform thread can multiplex thousands of virtual threads.

With virtual threads, you write plain blocking code — Thread.sleep(), socket.read(), JDBC calls — and it scales. You can create millions of virtual threads per JVM instance.

Spring Boot 3.2+ automatically uses virtual threads for request handling when enabled. Many teams are now replacing WebFlux + reactive drivers with virtual threads + synchronous JDBC, recovering the mental clarity they lost to reactive.

Note: CPU-bound work doesn't benefit. Virtual threads shine specifically for I/O-bound concurrency.

Before Java 21, processing a sealed hierarchy looked like this:

if (shape instanceof Circle c) { return Math.PI * c.radius() * c.radius(); } else if (shape instanceof Rectangle r) { return r.width() * r.height(); } else if (shape instanceof Triangle t) { return 0.5 * t.base() * t.height(); } else { throw new IllegalArgumentException("Unknown shape"); }

With pattern matching switch:

double area = switch (shape) { case Circle c -> Math.PI * c.radius() * c.radius(); case Rectangle r -> r.width() * r.height(); case Triangle t -> 0.5 * t.base() * t.height(); };

Three things improved. First, it's exhaustive — if Shape is sealed and you forget a subtype, the compiler rejects it (not a runtime exception at 3am). Second, binding and casting happen in one step, eliminating the error-prone manual cast. Third, it's an expression — it returns a value, so you can inline it or assign it, making code far more composable.

Pair this with sealed classes and you have a system where the compiler *enforces* that you've handled every case. That's a fundamentally safer program structure.

Abstract classes and interfaces are open by design. Anyone can extend them. That openness is powerful for frameworks and APIs, but it's a liability when you want to model a closed domain.

Consider a payment system. Your PaymentResult should only ever be Success, Failure, or Pending. Nothing else is valid. With a regular abstract class, someone on another team can add PartialRefundResult without going through a review. Your switch statements silently fall through.

Sealed classes (sealed interface PaymentResult permits Success, Failure, Pending) make the set of subtypes finite and explicit. The compiler knows the full universe of possibilities. So when you switch on a PaymentResult in Java 21, the compiler can verify exhaustiveness — every permitted subtype is handled, and you get a compile error if you add a new one without updating the switch.

This turns domain modeling from a 'trust everyone not to break things' exercise into a 'the compiler enforces the contract' exercise. It's especially powerful for library authors who want to evolve their APIs without breaking callers.

Native images compile your entire Spring application — plus the JVM, plus all libraries — ahead of time into a single OS-native binary. The result starts in 30–100ms instead of 10–30 seconds, and uses a fraction of the heap.

But the AOT (Ahead-of-Time) compilation is strict. Java's reflection, dynamic class loading, JNI, and runtime proxies all assume a complete classpath at runtime. The GraalVM compiler needs to know *at build time* which classes will be accessed via reflection — which is exactly what Spring uses internally for its proxy magic.

Spring Boot 3 has invested heavily in AOT processing: it generates reflection hints, resource hints, and proxy hints at build time so GraalVM knows what to include. Most Spring features work. But third-party libraries that use reflection without providing hints may silently break.

When is it worth it? Serverless functions (AWS Lambda, Google Cloud Functions) where cold starts are billed and frequent. Short-lived CLI tools. Environments with strict memory limits. For long-running services with plenty of memory and a warm JVM, native images may actually reduce peak throughput compared to JIT-optimized code.

Field injection (@Autowired private OrderService orderService) looks clean but has real problems.

First, it makes testing harder. To test a class with field-injected dependencies, you need the Spring container or reflection hacks. With constructor injection, you just call new OrderController(mockOrderService) in your unit test. No container needed.

Second, it hides the class's true dependencies. A constructor makes them explicit: if your constructor takes 8 parameters, that's a loud signal that the class is doing too much. Field injection lets you silently accumulate dependencies — the class becomes bloated before anyone notices.

Third, field-injected beans must be mutable (Spring sets them after construction). Constructor injection can use final fields, making the dependency truly immutable and the object's state consistent from the moment it's created.

Since Spring 4.3, if a class has a single constructor, @Autowired is optional — Spring injects automatically. The idiomatic modern pattern is just a regular final field and a single all-args constructor, often generated by Lombok's @RequiredArgsConstructor.

Two-phase commit (2PC) is theoretically correct for distributed transactions but rarely practical at scale: it blocks resources across all participants while the coordinator makes decisions, creating contention and a single point of failure.

The Saga pattern instead breaks a multi-step transaction into a sequence of local transactions, each with a corresponding compensating transaction that can undo it.

Choreography: Each service publishes events and listens for events. OrderService publishes OrderCreated → InventoryService reserves stock and publishes StockReserved → PaymentService charges the card. If PaymentService fails, it publishes PaymentFailed → InventoryService releases stock. No central coordinator.

The danger: debugging a choreography-based Saga is hard. Events flow across multiple services asynchronously. Tracing a failure requires correlating events across Kafka topics and service logs. It's powerful but operationally complex.

Orchestration: A dedicated Saga orchestrator (a class, often in the initiating service) explicitly calls each participant in sequence and handles failures with compensating calls. Easier to reason about, easier to trace, but the orchestrator can become a God object if not carefully scoped.

In 2026, most teams use Orchestration for complex transactions and lean on outbox patterns + event sourcing to make it reliable.

The three pillars are Traces, Metrics, and Logs. You need all three because they answer different questions.

Logs answer 'what happened': a timestamped record of events. When a NullPointerException is thrown, the log tells you. But logs are unstructured by default, don't connect events across services, and at microservices scale you're correlating millions of lines across dozens of pods.

Metrics answer 'how is the system behaving right now': request rates, error rates, latency histograms, JVM heap usage. Metrics are efficient (just numbers), aggregatable, and great for dashboards and alerts. But they tell you something is wrong, not *why*.

Traces answer 'what was the path of this specific request': a distributed trace follows a single user request from the API gateway through Service A, Service B, a database call, and back. You can see exactly where latency accumulated or where a failure first appeared.

OpenTelemetry provides a vendor-neutral SDK and wire format. You instrument your Spring Boot 3 application once, and you can export traces to Jaeger, Zipkin, Honeycomb, or Grafana Tempo — or switch between them — without touching your application code. Spring Boot 3 ships with Micrometer Tracing as a first-class feature, which can automatically export to any OpenTelemetry-compatible backend.

== compares identity: are both references pointing to the same object in memory? .equals() compares equality: do the objects represent the same logical value?

For primitives (int, long, double), == compares values directly since primitives aren't objects. For reference types, == compares memory addresses.

The gotcha that trips senior engineers: Integer caching. Java caches Integer objects for values -128 to 127. So:

Integer a = 127; Integer b = 127; a == b → true (same cached object)
Integer a = 128; Integer b = 128; a == b → false (different heap objects)

This is a real production bug category. Always use .equals() for Integer (and String, Long, etc.) comparisons. Never == unless you specifically want reference equality.

For String: 'hello' == 'hello' is usually true because of the String pool (interned literals share the same object). But new String('hello') == new String('hello') is always false. That inconsistency is why .equals() is the rule, full stop.

String is immutable. Every 'modification' creates a new object. 'Hello' + ', ' + 'world' creates three String objects, not one.

In a loop this gets serious. Concatenating in a loop of 10,000 iterations creates up to 10,000 intermediate String objects, all of which need GC. The complexity is O(n²) in the length of the result.

StringBuilder is mutable. It maintains an internal char array and appends in place. Amortized O(1) per append, O(n) total. Use it inside loops.

StringBuffer is StringBuffer + synchronized. Every method is thread-safe. It was the original (Java 1.0) solution, and it's slower than StringBuilder because of the locking overhead. Use it only if multiple threads genuinely write to the same builder simultaneously — which is rare. Most developers should forget StringBuffer exists.

Modern Java (8+) optimizes simple concatenations via invokedynamic and StringConcatFactory. Short chains of + outside loops compile to efficient code. The loop case still warrants explicit StringBuilder.

The common answer: 'ArrayList is O(1) random access, LinkedList is O(1) insertion at head/tail.' That's true but misses the practical story.

In modern hardware, memory locality dominates. ArrayList stores elements in a contiguous array — iterating it is cache-friendly. LinkedList stores elements as nodes scattered across the heap, each with pointers to prev/next. Cache misses on every element during iteration. In practice, LinkedList is slower than ArrayList for iteration even for large collections.

LinkedList also has higher memory overhead: each element wraps a node object with two pointer fields. For Integer elements, you're paying ~40 bytes per element instead of ~4 in an ArrayList.

When is LinkedList actually right? When you need a Deque (double-ended queue) — frequent insertions and removals at both ends. ArrayDeque is often better even then, but LinkedList's Deque implementation is solid. For most use cases — iteration, random access, append — ArrayList wins.

Deep DiveProject Loom

Virtual Threads in practice

The snippet below starts 10,000 concurrent tasks — each sleeping for a second — with virtual threads. On traditional platform threads, this would exhaust thread pool limits or consume gigabytes of stack memory. With Loom, it's lightweight enough to run on a laptop.

Modern concurrency with Virtual Threads (Java 21)

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}
// Total wall time: ~1 second, not 10,000 seconds.
Spring Boot config

In application.properties: spring.threads.virtual.enabled=true. That's it. Spring Boot 3.2+ handles the rest.

What still blocks?

CPU-heavy code (encryption, compression, computation) doesn't benefit. Virtual threads help when you're *waiting*, not *computing*.

JDBC note

Traditional JDBC drivers hold OS threads while waiting on queries. Virtual thread gains require async-compatible drivers or connection pool tuning.

Day-before interview checklist

Quick review items that make the difference in the first 20 minutes.

OOP

Can you explain LSP with a concrete Java example (not just the definition)?

SOLID

Do you know why field injection is discouraged — not just that it is?

JVM

Can you explain what a load barrier is in ZGC's context?

Concurrency

Do you know the difference between platform threads and virtual threads at the OS level?

Java 21

Have you used sealed classes and pattern matching switch in at least one pet project?

Spring

Can you name 2 things that break with GraalVM Native Images?

Distributed

Can you draw the Saga choreography flow on a whiteboard with compensating steps?

Observability

Do you know the difference between a trace, a metric, and a log — and which tool to reach for?

Frequently Asked Questions

Kodivio Team
Kodivio Team

AI & Web Development Specialists

The Kodivio team covers AI tools, automation, and modern web development based on real-world testing and hands-on experience.

Learn more about us →

Good luck — the best interviews feel like conversations, not interrogations.

Last updated April 2026 · Covers Java 21 LTS + Spring Boot 3.3

Join Our Engineering Newsletter

Get high-signal insights on DevOps, AI architecture, and secure web development delivered straight to your inbox. No spam, just technical deep dives.

ML

M. Leachouri

Founder & Chief Architect

"We built Kodivio because professional tools shouldn't come at the cost of your privacy. Our mission is to provide enterprise-grade utilities that process data exclusively in your browser."

M. Leachouri is an Expert Web Developer, Data Scientist Engineer, and Systems Architect with a deep specialization in DevOps and Cybersecurity. With over a decade of experience building scalable distributed systems and Zero-Trust architectures, he engineered Kodivio to bridge the gap between high-performance computing and absolute user sovereignty.

Verified Expert
Certified Architect
Full Profile & Mission →