Java Interview Questions for 5 Years of Experience

30 Java interview questions for experienced developers, with direct answers and code examples covering collections, concurrency, JVM memory, streams, design, Spring transactions and small system design tasks.

Layered architecture diagram with controller, service and repository layers, green allowed arrows from RecipeController to RecipeService to RecipeRepository, and a red dashed arrow from ShoppingListController to ShoppingListRepository marked as a violation

Java interview questions for five years of experience usually move beyond definitions to implementation choices, failure diagnosis and trade-offs. These questions cover core Java, collections, concurrency, JVM behavior, Spring and small design problems, with a short answer before the supporting explanation.

Use the sections to revise for a mid-level or senior Java role. Prepare one genuine project example for topics you have used, and explain the assumptions behind answers that depend on Java version or configuration. The examples use the language features shown in each snippet.

The following ten questions provide a quick starting point for revision. Use the short answers to check your understanding, then read the full explanations rather than memorizing the wording.

Quick question Short answer
How does HashMap work internally? It hashes the key to a bucket index, stores entries as a linked list or a red-black tree, and doubles the table above 75% load.
What is the equals() and hashCode() contract? Equal objects must return the same hash code, otherwise HashMap and HashSet lose them.
How is ConcurrentHashMap thread-safe? It writes to an empty bucket with CAS and locks only the first node of a non-empty bucket. Reads take no lock.
What does volatile guarantee? Visibility and ordering, not atomicity, so count++ on a volatile field still loses updates.
What are virtual threads? Cheap threads managed by the JVM, good for blocking I/O tasks and useless for CPU-bound work.
What problem do sealed classes solve? They fix the list of subtypes, so a switch over them is checked for completeness by the compiler.
How do we make a class immutable? A final class, private final fields, no setters, and defensive copies of mutable inputs.
How does G1 differ from ZGC? G1 balances throughput and pauses (target 200 ms). ZGC aims for very short pauses, with a throughput trade-off that depends on the workload.
Why does a self-call skip @Transactional? The transaction starts in the Spring proxy, and this.method() never goes through the proxy.
How do we find a memory leak? Take a heap dump on OutOfMemoryError and look at which objects keep the biggest retained size.

Each answer below starts with that short version and explains the reason after it, with a small example or diagram beside the explanation. Related examples are in the example project on GitHub. For the basics, the site also has separate core Java interview questions and Java OOP interview questions.

1. Core Java Interview Questions for 5 Years Experience

Core Java questions at the 5-year level are rarely about syntax. Interviewers use them, like the classic String interview questions, to check whether we can predict what the JVM does.

1.1. How Do We Make a Class Immutable?

We declare the class final, make every field private final, skip the setters, and copy every mutable input and output. An immutable object never changes after construction, so we can share it between threads without locks and use it as a map key safely.

The copy is an important part of the contract. In the following snippet, Playlist stores List.copyOf(songs) in its constructor, so a change to the caller’s list does not reach the playlist. A “change” method such as withSong() returns a new object.

List<String> songs = new ArrayList<>(List.of("one", "two"));
Playlist playlist = new Playlist("road trip", songs);

songs.add("three");                                  // the caller changes its own list
int size = playlist.songs().size();                  // 2, the playlist kept its copy
Playlist longer = playlist.withSong("three");        // new object with 3 songs
boolean added = playlist.songs().add("four");        // UnsupportedOperationException

1.2. Checked or Unchecked Exceptions, Which One Do We Throw?

A checked exception must be caught or declared; an unchecked exception does not impose that compiler requirement. Choose according to the API contract and how callers should handle the failure. Recoverability is a design consideration, not a rule that mechanically decides the exception category.

The 5-year follow-up is what try-with-resources does when both the body and close() fail. The exception from the body wins, and the exception from close() is attached to it as a suppressed exception, so the log shows both.

String main;
String suppressed;

AutoCloseable resource = () -> {
  throw new IllegalStateException("close failed");
};
try (resource) {
  throw new RuntimeException("body failed");
} catch (Exception e) {
  main = e.getMessage();                             // body failed
  suppressed = e.getSuppressed()[0].getMessage();    // close failed
}

2. Modern Java Features

Interviewers may ask how records, sealed classes, pattern matching and virtual threads change everyday Java code. Explain the behavior you have actually used, and check the selected JDK before relying on newer APIs.

2.1. What Is a Record, and When Should We Not Use One?

A record is a final class whose state is a fixed set of components, and the compiler generates the constructor, accessors, equals(), hashCode() and toString() for us. We use records for DTOs, API responses, map keys and values returned from methods. We validate the input in a compact constructor.

public record Fruit(String name, int count) {
  public Fruit {
    Objects.requireNonNull(name, "name");
    if (count < 0) {
      throw new IllegalArgumentException("count must be >= 0");
    }
  }
}

Fruit apple = new Fruit("apple", 5);
String text = apple.toString();                      // Fruit[name=apple, count=5]
boolean same = apple.equals(new Fruit("apple", 5));  // true
int count = apple.count();                           // 5
Fruit bad = new Fruit("banana", -1);                 // IllegalArgumentException: count must be >= 0

A record is not a portable JPA entity: entity requirements include a non-final class and an accessible no-argument constructor. Records fit projections and DTOs better. Their immutability is shallow, so copy mutable component values when the object must remain stable.

2.2. What Problem Do Sealed Classes Solve?

A sealed class or interface lists the only classes that may extend it in its permits clause. Before sealed types, any class could implement our interface, so the compiler could not tell whether a switch covered all cases. With a sealed hierarchy, it can, and a switch over it needs no default branch.

The following snippet models three payment types. If we add a fourth record to permits later, every switch that does not handle it stops compiling, which is the main reason to use sealed types.

public sealed interface Payment permits Card, Cash, Voucher {}
record Card(int amount) implements Payment {}
record Cash(int amount) implements Payment {}
record Voucher(int amount, boolean expired) implements Payment {}

Payment payment = new Card(500);
int fee = switch (payment) {
  case Card(int amount) -> amount * 2 / 100;
  case Cash _ -> 0;
  case Voucher(int amount, boolean expired) when expired ->
      throw new IllegalStateException("voucher expired");
  case Voucher _ -> 0;
};                                                   // 10

2.3. How Does Pattern Matching for switch Work?

A pattern in a switch case checks the value’s type and can bind the matching value to a variable. This lets a payment handler deal with each payment type without a cast after every test. Put a guarded case before the broader case for the same type.

sealed interface Payment permits Card, Cash {}
record Card(int amount) implements Payment {}
record Cash(int amount) implements Payment {}

static String describe(Payment payment) {
    return switch (payment) {
        case Card card when card.amount() > 1000 -> "large card payment";
        case Card card -> "card payment: " + card.amount();
        case Cash cash -> "cash payment: " + cash.amount();
        case null -> "no payment";
    };
}

System.out.println(describe(new Card(1200))); // large card payment

The first case matches only a Card above the threshold. The second handles every other Card. Reversing those cases makes the guarded one unreachable, so the compiler rejects it. The null case makes the method’s behavior explicit for a missing payment.

2.4. What Are Virtual Threads, and When Should We Not Use Them?

A virtual thread is scheduled by the JVM onto carrier platform threads. It helps applications support many concurrent tasks that spend time waiting on supported blocking operations. It does not make CPU-bound work faster, and not every blocking operation releases its carrier.

The example compares 10,000 one-second waiting tasks with a 200-thread pool. The fixed pool needs roughly 50 batches; virtual threads can overlap far more waits. The shown timings are recorded results, not guarantees for another machine or workload.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
  List<Future<Integer>> futures = IntStream.range(0, 10_000)
      .mapToObj(i -> executor.submit(() -> {
        LockSupport.parkNanos(Duration.ofSeconds(1).toNanos());   // blocks like an I/O call
        return i;
      }))
      .toList();
}                                                    // all 10,000 finish in under 2 seconds

We do not use virtual threads in these cases.

  • CPU-bound work, such as image resizing, gets no speedup, because the number of carrier threads equals the number of CPU cores.
  • We never pool virtual threads. We create one per task, and we limit access to a scarce resource, such as a database with 20 connections, with a Semaphore or the connection pool.
  • Large objects in ThreadLocal multiply by the number of threads, so a million virtual threads with a 1 MB cache each run out of memory.

Virtual-thread pinning behavior depends on the JDK in use. The synchronization improvement removed a common source of pinning, but blocking inside a class initializer or a native callback can still matter. In a Spring Boot application, one property turns them on for Tomcat, as shown in Spring Boot virtual threads.

2.5. When Would We Use Scoped Values or Structured Concurrency?

A ScopedValue passes immutable request context down a call without adding that context to every method parameter. The binding ends when the call ends, including when the call fails. That limited lifetime is useful for request IDs and tracing data.

static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

String label = ScopedValue.where(REQUEST_ID, "req-42")
    .call(() -> "handling " + REQUEST_ID.get());
System.out.println(label);                  // handling req-42
System.out.println(REQUEST_ID.isBound());   // false

Structured concurrency groups related subtasks under one parent task. The parent can wait for them together and cancel unfinished siblings when the group fails. It is useful when a request needs, for example, a customer record and shipping quote before it can reply. Check the selected JDK’s documentation and preview flags before using the API in a project, because its availability and signature can change.

3. Collections Internals

Collections questions decide many interviews at this level, because they show whether we understand hashing and thread safety. Out of the long list of Java collections interview questions, these four come up most often for experienced developers.

3.1. How Does HashMap Work Internally?

HashMap stores entries in an array of buckets. On put(), it takes the key’s hashCode(), mixes the high bits into the low bits with h ^ (h >>> 16), and picks the bucket with (n – 1) & hash, where n is the table size. Inside the bucket, it compares the stored hash first and then calls equals(), and either replaces the value or appends a new node.

Diagram of HashMap put showing the hash step, the bucket index step and the find-or-add step, a 16-bucket table with a linked list in one bucket and a red-black tree in another, and the resize rule at 75 percent load
HashMap picks a bucket from the mixed hash; colliding keys form a list, and a long list in a big table becomes a red-black tree.

Interviewers expect the numbers too. The default capacity and load factor are in the HashMap Javadoc, and the tree thresholds are implementation details in the JDK source.

Setting Value Effect
Default capacity 16 buckets The table is created on the first put()
Load factor 0.75 The table doubles when size exceeds 12 of 16, 24 of 32, and so on
Treeify threshold 8 A bucket with more than 8 entries becomes a red-black tree, so lookup is O(log n) instead of O(n)
Minimum capacity for trees 64 buckets Below 64, the map resizes instead of building a tree
Untreeify threshold 6 entries A tree turns back into a list
null key Allowed, one Stored in bucket 0

More follow-ups are in HashMap interview questions.

3.2. What Is the equals() and hashCode() Contract?

Equal objects must have equal hash codes; unequal objects may share a hash code. Override both methods when equality is value-based. Breaking the contract can cause a hash-based collection to miss a logically equal key, even though occasional lookups may appear to work.

The same failure happens when a key changes after we put it in the map. In the following snippet, Person computes its hash code from name, and we rename the person after the put().

Map<Person, Integer> ages = new HashMap<>();
Person lokesh = new Person("Lokesh");
ages.put(lokesh, 37);
lokesh.setName("Alex");                              // hashCode() changes
Integer age = ages.get(lokesh);                      // null
boolean present = ages.containsValue(37);            // true, the entry is still there

The entry is still in the map, but in the bucket for the old hash code. So we use immutable keys, such as String, Integer, records or enums.

3.3. How Is ConcurrentHashMap Thread-Safe?

Modern ConcurrentHashMap uses fine-grained coordination, including compare-and-swap and synchronization on contended bins. It is not the old fixed-segment design. Retrievals generally avoid blocking on updates, but unrelated operations can still share coordination work during resizing or collisions.

The usual follow-up is how to count with it. Separate get() and put() calls are not atomic together, so we use merge() or compute(), which run atomically per key.

Map<String, Integer> counts = new ConcurrentHashMap<>();
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
  for (int i = 0; i < 1_000; i++) {
    pool.submit(() -> counts.merge("apple", 1, Integer::sum));
  }
}
int apples = counts.get("apple");                    // 1000
Integer old = counts.put("banana", null);            // NullPointerException

Notice that ConcurrentHashMap rejects null keys and values, because get() returning null must mean “no mapping” without a second, racy containsKey() call. The older Collections.synchronizedMap() wrapper allows null but locks the whole map on every call.

3.4. What Is the Difference Between Fail-Fast and Fail-Safe Iterators?

Fail-fast iterators detect some structural modifications and may throw ConcurrentModificationException; detection is best effort. “Fail-safe” is informal terminology. CopyOnWriteArrayList uses snapshot iteration, while ConcurrentHashMap offers weakly consistent iteration that may reflect concurrent updates.

List<String> fruits = new ArrayList<>(List.of("apple", "banana", "cherry"));
for (String fruit : fruits) {
  if (fruit.startsWith("a")) {
    fruits.remove(fruit);                            // ConcurrentModificationException
  }
}

List<String> more = new ArrayList<>(List.of("apple", "banana", "cherry"));
boolean removed = more.removeIf(fruit -> fruit.startsWith("a"));   // true, more = [banana, cherry]

The safe version is removeIf() or an explicit Iterator with iterator.remove(). Removing the second-to-last element in the loop above does not throw, because hasNext() returns false before the check runs, which is a favorite trick question.

4. Concurrency and Multithreading

Concurrency questions separate 5-year candidates most clearly, because the bugs show up only under load. These six come up in nearly every loop, and the full list is in Java concurrency interview questions.

4.1. What Does volatile Guarantee, and What Does It Not?

A write to a volatile field happens-before a subsequent read of that field. This publishes earlier writes to the reading thread, but does not make a compound action such as hits++ atomic. Use an atomic counter or a lock for read-modify-write operations.

The following snippet runs one million increments in each of two threads, once on a volatile int and once on an AtomicInteger.

private volatile boolean running = true;   // visibility: readers see the latest write
private volatile int hits = 0;             // hits++ is still read-modify-write, not atomic
private final AtomicInteger safeHits = new AtomicInteger();

// 2 threads x 1,000,000 increments each
// hits      -> 1,913,354 (lost updates, a different number on every run)
// safeHits  -> 2,000,000

So volatile fits a stop flag or a reference that one thread publishes and others read. For counters we use AtomicInteger or LongAdder, and for several fields that change together we use a lock.

4.2. What Is the happens-before Relationship?

Happens-before is a Java Memory Model ordering rule. If action A happens-before action B, the writes visible to A are visible to B. A volatile write followed by a read of the same field is one way to establish that relationship.

class ResultBox {
    int result;
    volatile boolean ready;
}

ResultBox box = new ResultBox();
Thread worker = new Thread(() -> {
    box.result = 42;
    box.ready = true;
});
worker.start();

while (!box.ready) {
    Thread.onSpinWait();
}
System.out.println(box.result); // 42

The worker writes result before it writes ready. When the other thread reads ready as true, that volatile read makes the earlier result write visible. This busy-wait loop is only a small demonstration; a Future or other coordination tool is a better fit for waiting on real work. Happens-before does not turn result++ into an atomic operation.

4.3. When Do We Use ReentrantLock Instead of synchronized?

We use synchronized by default, because it is shorter and the JVM releases the lock even when an exception is thrown. We switch to ReentrantLock when we need a feature synchronized does not have, such as a timeout, an interruptible wait, a fair queue or several wait conditions.

int balance = 100;
ReentrantLock lock = new ReentrantLock();
boolean acquired = lock.tryLock(500, TimeUnit.MILLISECONDS);   // true, nobody holds the lock
if (acquired) {
  try {
    balance += 10;                                             // balance = 110
  } finally {
    lock.unlock();
  }
}

The unlock() call always goes in finally, because a ReentrantLock is never released on its own.

4.4. How Do We Shut Down an ExecutorService Correctly?

When ExecutorService implements AutoCloseable in the selected JDK, try-with-resources calls close() and waits for submitted tasks to finish. Otherwise, call shutdown() and wait with awaitTermination(). If the tasks do not finish in time, we call shutdownNow().

try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
  Future<Integer> future = pool.submit(() -> 6 * 7);
  int answer = future.get(1, TimeUnit.SECONDS);      // 42
}                                                    // close(): shutdown() + wait for tasks

ExecutorService workers = Executors.newFixedThreadPool(2);
workers.submit(() -> 6 * 7);
workers.shutdown();                                  // no new tasks
if (!workers.awaitTermination(10, TimeUnit.SECONDS)) {
  workers.shutdownNow();                             // interrupt the stragglers
}

An executor that is never shut down keeps its threads alive, so the JVM does not exit.

4.5. How Do We Combine Results With CompletableFuture?

We start each call with supplyAsync() on our own executor, join two results with thenCombine(), add a deadline with orTimeout(), and turn failures into a fallback with exceptionally(). Without an executor argument, supplyAsync() uses the common ForkJoinPool, which is sized for CPU work and is shared by the whole JVM.

CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 100, pool);
CompletableFuture<Integer> tax = CompletableFuture.supplyAsync(() -> 18, pool);
int total = price.thenCombine(tax, Integer::sum)
    .orTimeout(2, TimeUnit.SECONDS)
    .join();                                         // 118

int fallback = CompletableFuture.supplyAsync(() -> Integer.parseInt("abc"), pool)
    .exceptionally(ex -> 0)
    .join();                                         // 0

The ex passed to exceptionally() is a CompletionException that wraps the NumberFormatException, so we call getCause() before logging it. In Spring, @Async methods return a CompletableFuture too, as shown in Spring Boot @Async with CompletableFuture.

4.6. What Causes a Deadlock, and How Do We Prevent It?

A deadlock occurs when two threads each hold a lock needed by the other. Consider transfers between accounts A and B: one thread locks A then waits for B, while another locks B then waits for A.

Thread 1: holds A  → waits for B
                    ↑           ↓
Thread 2: waits for A ← holds B

Give each account a stable ID and always acquire the lower-ID account’s lock first, regardless of transfer direction. Both transfers then request the locks in the same order, so the circular wait cannot form. A timeout with tryLock() is another option when a fixed lock order is impractical; release any lock already held when the second attempt fails. A thread dump can show which locks the threads hold and await.

5. JVM, Memory and Garbage Collection

JVM questions test diagnosis as well as terminology. Be ready to distinguish heap exhaustion, native-memory pressure and retained objects, then describe a real investigation you performed without inventing a production incident.

5.1. What Are the Memory Areas of the JVM?

The JVM heap is the logical allocation area for objects, and each thread has stack frames for its calls. HotSpot stores class metadata in native Metaspace and can also use substantial native memory for stacks and direct buffers. Garbage collection can reclaim heap objects and, when enabled and applicable, unload classes; it is not a cure for every memory shortage.

Diagram of JVM memory areas with the heap split into young and old generation, Metaspace and code cache as shared areas, and the JVM stack, PC register and native stack per thread
The heap, Metaspace and code cache are shared; each platform thread gets its own stack, and virtual threads keep their stack frames in the heap.

Each area fails with its own error, so the error message tells us which area to look at.

Area Holds Size flag Error when full
Heap Objects, arrays, string pool, static fields -Xms, -Xmx OutOfMemoryError: Java heap space
Metaspace Class metadata -XX:MaxMetaspaceSize OutOfMemoryError: Metaspace
Thread stack Frames, local variables, references -Xss StackOverflowError
Code cache JIT-compiled code -XX:ReservedCodeCacheSize JIT stops compiling, with a warning

5.2. How Do G1 and ZGC Differ, and Which One Should We Pick?

G1 aims to balance throughput with pause-time goals; ZGC is designed for very short pauses using more concurrent work. A pause target is not a maximum, and neither collector guarantees a fixed latency for every workload. Choose using GC logs, allocation behavior and the service latency budget.

Latency budget missed? → inspect GC pauses → compare G1 and ZGC on the same workload
Latency budget met?    → keep the current collector and monitor throughput

The same input and load matter: changing the workload while changing collectors does not isolate the collector’s effect.

G1 ZGC Parallel
Flag default (G1 docs) -XX:+UseZGC (ZGC docs) -XX:+UseParallelGC
Pause time Tunable pause goal (-XX:MaxGCPauseMillis) Designed for very short pauses Often longer pauses in exchange for throughput
Heap sizes Small to large A few hundred MB to 16 TB Small to large
Best for Most services Latency-sensitive APIs, big heaps Batch jobs, maximum throughput

Collector defaults and modes can change across JDKs. Check the runtime with java -XX:+PrintFlagsFinal -version and compare GC logs under a representative workload before choosing a collector.

We start with G1 and switch to ZGC only when GC logs show pauses that break our latency goal. The garbage collection algorithms post compares the older collectors.

5.3. How Can a Java Program Leak Memory if It Has a Garbage Collector?

The garbage collector frees only objects that nothing references. A leak in Java is an object that our code still references but will never use again, so the collector must keep it. The most common causes we see are these.

  • A static map used as a cache that never removes entries.
  • Listeners or callbacks that are registered and never unregistered.
  • ThreadLocal values in a thread pool that are never removed, because pool threads live forever.
  • Unclosed resources, such as streams and JDBC connections, that keep buffers alive.
private static final Map<String, byte[]> CACHE = new HashMap<>();   // never evicts

CACHE.put("report-" + i, new byte[1024 * 1024]);     // 1 MB per entry, until OutOfMemoryError

The fix is a bounded cache, such as the LRU cache from section 9.2, or a cache library with a size limit and expiry. A WeakHashMap helps only when the key object itself goes out of use, and for ThreadLocal in pools we call remove() in finally, as explained in ThreadLocal.

5.4. How Do We Diagnose an OutOfMemoryError?

We start the JVM with -XX:+HeapDumpOnOutOfMemoryError, open the dump in a heap analyzer such as Eclipse Memory Analyzer, and look at the dominator tree, the objects with the biggest retained size. The class that holds them, often a static map, is the leak. The message after OutOfMemoryError tells us which area ran out, as the table in section 5.1 shows.

Running the leaking cache from the previous question with a 64 MB heap prints this.

java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=target \
     -cp target/classes com.howtodoinjava.interview.jvm.LeakDemo
java.lang.OutOfMemoryError: Java heap space
Dumping heap to target/java_pid31161.hprof ...
Heap dump file created [36475834 bytes in 0.043 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.base/java.util.HashMap.newNode(HashMap.java:1910)
    at com.howtodoinjava.interview.jvm.LeakDemo.main(LeakDemo.java:16)

The stack trace shows only the allocation that failed, not the leak, so we always read the dump. On a running JVM, jcmd PID GC.class_histogram lists the classes with the most bytes before we take a full dump with jcmd PID GC.heap_dump. A [B row (byte arrays) at the top of the histogram matches a leak like the 1 MB arrays in the static map.

6. Streams and Functional Programming

Stream questions at this level check whether we know when a stream runs and why a collector fails, more than whether we know the method names.

6.1. Are Java Streams Lazy?

Yes. Intermediate operations such as filter() and map() only build a pipeline, and nothing runs until a terminal operation such as toList() or findFirst() asks for elements. The stream pulls one element at a time through the whole pipeline, and a short-circuiting operation stops as soon as it has its answer.

List<String> log = new ArrayList<>();
Optional<String> first = Stream.of("apple", "banana", "cherry")
    .peek(fruit -> log.add("saw " + fruit))
    .filter(fruit -> fruit.startsWith("b"))
    .findFirst();                                    // Optional[banana]
// log = [saw apple, saw banana], cherry is never read

We use peek() only for debugging like this, because a stream may skip it when the result does not need it, for example in count().

6.2. Why Does Collectors.toMap() Throw IllegalStateException?

Collectors.toMap() throws IllegalStateException when two elements produce the same key, because it does not know which value to keep. We pass a merge function as the third argument, or we use groupingBy() when we want all values per key.

List<String> fruits = List.of("apple", "avocado", "banana");

Map<Character, String> byLetter = fruits.stream()
    .collect(Collectors.toMap(fruit -> fruit.charAt(0), fruit -> fruit));
    // IllegalStateException: Duplicate key a (attempted merging values apple and avocado)

Map<Character, String> merged = fruits.stream()
    .collect(Collectors.toMap(fruit -> fruit.charAt(0), fruit -> fruit,
        (first, second) -> first + "," + second));  // {a=apple,avocado, b=banana}
Map<Character, Long> counts = fruits.stream()
    .collect(Collectors.groupingBy(fruit -> fruit.charAt(0), Collectors.counting()));   // {a=2, b=1}

7. Design Principles and Patterns

Design questions at five years are about judgement. The interviewer wants a short definition, one example from our own code, and the cost of the pattern.

7.1. Can You Explain SOLID With Examples?

SOLID is five rules that keep classes small and safe to change. We explain each one in one line and give an example from a project.

Principle Rule Example
Single responsibility A class has one reason to change InvoicePdfWriter writes PDFs, InvoiceService calculates totals
Open/closed Add behavior with new classes, not by editing old ones A new Payment record instead of another if branch
Liskov substitution A subtype works wherever its parent type works A read-only list must not extend a type whose contract allows add()
Interface segregation Small interfaces instead of one big one Readable and Writable instead of one Storage with 20 methods
Dependency inversion Depend on interfaces, inject the implementation PriceService takes a TaxClient interface in its constructor
interface TaxClient { int taxFor(int subtotal); }

record PriceService(TaxClient taxClient) {
    int total(int subtotal) {
        return subtotal + taxClient.taxFor(subtotal);
    }
}

PriceService depends on the small TaxClient contract. A test can supply a simple implementation; production can supply the real tax service. This illustrates dependency inversion without requiring the pricing class to know the client implementation.

7.2. How Do We Write a Thread-Safe Singleton?

We use an enum with one constant, or the holder idiom when we need lazy creation. Both rely on class initialization, which the JVM runs only once and under a lock (JLS 12.4.2), so we write no synchronized code.

public final class Config {
  private Config() {
  }

  private static final class Holder {
    private static final Config INSTANCE = new Config();
  }

  public static Config getInstance() {
    return Holder.INSTANCE;            // Holder is initialized on the first call only
  }
}

boolean same = Config.getInstance() == Config.getInstance();   // true

Double-checked locking also works when the field is volatile, but it is longer and more error-prone. In a Spring Boot application we rarely write singletons at all, because every bean is a singleton by default.

8. Spring Boot Questions

Spring Boot questions often accompany Java application interviews. These examples focus on dependency injection and transaction boundaries, two topics where a correct definition should be followed by an explanation of the runtime behavior.

Constructor injection makes required dependencies explicit and allows tests to supply them directly. Fields can be final, and constructor validation can reject null. Injection alone does not guarantee either property; the class must enforce the invariants it needs.

The following example is a PriceService that gets a TaxClient through its constructor. The unit test passes a Mockito mock to new PriceService(…), with no Spring context, on JUnit 6.1.3 and Mockito 5.24.0.

public PriceService(TaxClient taxClient) {
  this.taxClient = taxClient;
}

TaxClient taxClient = mock(TaxClient.class);
when(taxClient.taxPercent("IN")).thenReturn(18);
PriceService service = new PriceService(taxClient);
int price = service.finalPrice(100, "IN");           // 118
verify(taxClient).taxPercent("IN");                  // passes, called once with "IN"

A mock returns the values we program and records the calls. We mock what is slow or external, such as an HTTP client, and test against the real database in an integration test, for example with Testcontainers.

8.2. Why Does a Self-Call Skip @Transactional?

Spring applies @Transactional through a proxy object that it puts in front of our bean. The proxy starts the transaction before our method runs, and it commits or rolls back after the method returns or throws. When one method of the bean calls another method of the same bean, the call goes through this, not through the proxy, so no transaction starts.

Diagram with a caller, a Spring CGLIB proxy and the InvoiceService target; a call to save through the proxy starts a transaction, while saveAll calls this.save inside the target without a transaction
Only calls that enter through the proxy get a transaction; this.save() inside the bean bypasses it.

The following example uses an InvoiceService bean with a @Transactional save() method and a plain saveAll() method that calls save() in a loop. A transaction manager that only logs shows when transactions begin and end, on Spring Framework 7.0.9.

--- 1. call through the proxy
BEGIN com.howtodoinjava.interview.spring.InvoiceService.save
save(apple) transaction active = true
COMMIT
--- 2. self-invocation
save(banana) transaction active = false
--- 3. checked exception, default rules
BEGIN com.howtodoinjava.interview.spring.InvoiceService.importFile
COMMIT
caught file missing
--- 4. checked exception, rollbackFor = Exception.class
BEGIN com.howtodoinjava.interview.spring.InvoiceService.importFileWithRollback
ROLLBACK
caught file missing

Case 3 is the second pitfall. By default, Spring rolls back only on RuntimeException and Error, so a method that throws IOException commits its changes. The other pitfalls interviewers ask about follow the same proxy rules.

Pitfall What happens Fix
Self-invocation No transaction Move the method to another bean, or put @Transactional on the outer method
Checked exception Commit rollbackFor = Exception.class, or @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) since 6.2
private method Annotation ignored Use a public method; protected and package-private work with class-based proxies since Spring 6.0
Exception caught inside the method Commit Rethrow, or call setRollbackOnly()
New thread or @Async inside The new thread has no transaction Start the transaction in the code that runs on that thread

9. System Design Warm-Up Questions

A design exercise may ask for one component or a wider system. For the small components below, explain the algorithm, thread safety and limits before discussing distribution. Ask about throughput, consistency and failure requirements rather than assuming the interviewer expects one standard design.

9.1. How Would You Build a Rate Limiter?

We use a token bucket. The bucket holds up to capacity tokens and refills at a fixed rate; each request takes one token, and a request that finds the bucket empty is rejected with HTTP 429. Bursts up to the capacity pass, and the average rate stays at the refill rate.

public synchronized boolean tryAcquire() {
  long now = clock.getAsLong();
  tokens = Math.min(capacity, tokens + (now - lastRefill) * tokensPerNano);
  lastRefill = now;
  if (tokens >= 1) {
    tokens -= 1;
    return true;
  }
  return false;
}

TokenBucket limiter = new TokenBucket(2, 1, System::nanoTime);   // burst of 2, then 1 per second
boolean first = limiter.tryAcquire();                // true
boolean second = limiter.tryAcquire();               // true
boolean third = limiter.tryAcquire();                // false, bucket empty: answer HTTP 429

The clock is a LongSupplier, so a test can move time forward without waiting. The follow-up is “what about three instances of the service?” A per-instance limiter allows three times the rate, so a shared limit needs a central store such as Redis, or a gateway that limits before the service.

9.2. How Do We Build an LRU Cache?

We extend LinkedHashMap with access order turned on and override removeEldestEntry(). Access order moves an entry to the end on every get(), so the first entry is always the least recently used, and removeEldestEntry() drops it when the size goes over the capacity.

public class LruCache<K, V> extends LinkedHashMap<K, V> {
  private final int capacity;

  public LruCache(int capacity) {
    super(16, 0.75f, true);            // true = access order
    this.capacity = capacity;
  }

  @Override
  protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return size() > capacity;
  }
}

LruCache<String, Integer> cache = new LruCache<>(2);
cache.put("apple", 5);
cache.put("banana", 3);
Integer apple = cache.get("apple");                  // 5, apple is now the most recently used
cache.put("cherry", 7);                              // evicts banana
Set<String> keys = cache.keySet();                   // [apple, cherry]

The LruCache class is not thread-safe, because even get() changes the order. For a shared cache, we wrap it with Collections.synchronizedMap() or use a cache library.

9.3. How Do We Make a Retried Request Safe?

A client may retry a payment request after a timeout even though the first request succeeded. Give the operation an idempotency key so the server can recognize the retry and return the original result.

POST payment, key K → reserve K → charge once → store result for K
POST payment, key K → find stored result for K → return it

The key reservation, payment record, and stored response must be coordinated atomically. Otherwise two concurrent requests with the same key can both pass the initial lookup and charge twice. Reject a reused key if its request parameters differ from the original. A bounded retention period needs to be part of the API contract, because a retry after the record expires can create a new operation.

10. Conclusion

For an experienced Java interview, explain what each mechanism guarantees, show a concrete case, and name the limit that affects a real design choice. A collection choice, thread coordination rule, or transaction boundary becomes easier to defend when its behavior is visible in code.

Happy Learning!

11. References

Happy Learning !!

Related Java examples on GitHub

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.