☕ Java Interview Questions
50 Java interview questions covering language fundamentals, collections, OOP, concurrency, JVM internals, Spring Boot, JPA, and 10 real-world "when to use X vs Y" decision questions.
Java is a high-level, object-oriented programming language designed to compile source code into bytecode that runs on the Java Virtual Machine (JVM). The JVM acts as an execution layer between your compiled code and the operating system.
Write Once, Run Anywhere means the same compiled Java bytecode can run on Windows, Linux, or macOS as long as a compatible JVM exists on that platform. You compile once with javac, then the JVM handles platform-specific execution details.
This portability made Java dominant in enterprise backends, Android development for many years, and large internal systems where consistency across environments mattered.
public class BillingApp {
public static void main(String[] args) {
double subtotal = 1250.00;
double taxRate = 0.18;
double total = subtotal + (subtotal * taxRate);
System.out.println("Invoice total: " + total);
}
}
// Compile once:
// javac BillingApp.java
// Run on any machine with a compatible JVM:
// java BillingApp
A logistics company ran the same Java warehouse service across 600+ Windows desktops in branch offices and Linux servers in the central data center. The codebase stayed identical across environments, which cut release coordination time from multiple OS-specific builds to one shared release pipeline.
Many candidates say Java code runs "directly on any operating system." That is incomplete. Java runs on the JVM, and the JVM is what gets ported to each operating system.
Java is portable because the OS understands Java code.Java is portable because javac produces bytecode and a platform-specific JVM executes that bytecode.What is the difference between the JDK, JRE, and JVM?
Java has two broad categories of types: primitive types and reference types.
Primitive types store actual values directly. Java has 8 primitives: byte, short, int, long, float, double, char, and boolean. They are lightweight and efficient.
Reference types store a reference to an object in memory. Classes, arrays, interfaces, enums, and records all fall into this category. A variable like String name does not hold the characters directly; it holds a reference to a String object.
Primitives cannot be null, while reference types can. Java also provides wrapper classes like Integer and Double when you need object behavior for primitive values.
public class OrderSummary {
public static void main(String[] args) {
int quantity = 3; // primitive
double price = 499.99; // primitive
boolean priority = true; // primitive
String customerName = "Amit"; // reference type
int[] orderIds = {101, 102, 103}; // reference type
Integer boxedQuantity = quantity; // autoboxing primitive -> wrapper
System.out.println(customerName + " ordered " + quantity + " items.");
System.out.println("First order id: " + orderIds[0]);
System.out.println("Boxed quantity class: " + boxedQuantity.getClass().getSimpleName());
}
}
A pricing engine processed 8 million line items per hour. Keeping hot numeric calculations in primitives instead of unnecessary wrapper objects reduced memory pressure enough to lower GC pauses from 220 ms to 90 ms during peak billing windows.
A common bad answer is "String is a primitive because it behaves like one." It is not. String is a class, so it is a reference type.
int and String are both basic built-in primitive types.int is primitive. String is a reference type backed by an object.Why do wrapper classes exist, and what is autoboxing in Java?
In Java, == compares whether two variables refer to the same object in memory. For primitives, == compares the actual values because primitives are not objects.
.equals() is a method used to compare object content. Classes like String, Integer, and many domain objects override equals() so two separate objects with the same logical value can still be considered equal.
If a class does not override equals(), it inherits the default implementation from Object, which behaves like reference equality.
public class EqualityDemo {
public static void main(String[] args) {
String regionA = new String("APAC");
String regionB = new String("APAC");
System.out.println(regionA == regionB); // false: different objects
System.out.println(regionA.equals(regionB)); // true: same content
int taxRateA = 18;
int taxRateB = 18;
System.out.println(taxRateA == taxRateB); // true: primitive value compare
}
}
A user-entitlement service compared role names with == instead of .equals(). Under test data it passed because of String interning, but in production 3.2% of authorization checks failed when roles came from the database as separate String objects.
== for primitive value comparison and object identity checks; use .equals() for logical equality of object content.The usual mistake is saying "== and .equals() are the same for Strings." They are not reliably the same.
if (userRole == "ADMIN") {
grantAccess();
}if ("ADMIN".equals(userRole)) {
grantAccess();
}Why is it often safer to call equals() on the constant string, like "ADMIN".equals(role)?
An array in Java has a fixed size once created. It can store primitives or objects, provides fast indexed access, and is ideal when the size is known upfront.
ArrayList is part of the Collections Framework. It stores objects only, grows dynamically, and provides methods like add(), remove(), and contains(). For primitives, ArrayList uses wrapper types such as Integer.
Arrays are lower-level and slightly more memory-efficient. ArrayList is usually more convenient for application code where collection size changes over time.
import java.util.ArrayList;
import java.util.List;
public class CartItems {
public static void main(String[] args) {
String[] fixedTopSellers = new String[3];
fixedTopSellers[0] = "Laptop";
fixedTopSellers[1] = "Mouse";
fixedTopSellers[2] = "Monitor";
List<String> cart = new ArrayList<>();
cart.add("Laptop");
cart.add("Keyboard");
cart.add("USB Hub");
cart.remove("Keyboard");
System.out.println("Top sellers count: " + fixedTopSellers.length);
System.out.println("Cart size: " + cart.size());
System.out.println("Cart contents: " + cart);
}
}
A catalog ingestion service knew every supplier feed had exactly 12 monthly buckets, so it used arrays for that hot fixed-size structure. The customer-facing cart flow used ArrayList because item counts were unpredictable. That split kept the hot path lean while preserving flexibility in business logic.
Candidates often say ArrayList is just a "better array." It solves a different problem: dynamic resizing and collection APIs.
ArrayList works exactly like arrays and also stores primitives directly.ArrayList stores objects, resizes dynamically, and offers collection methods.
Arrays can store primitives and have fixed length.What is the difference between ArrayList and LinkedList in Java?
These four concepts form the core of object-oriented programming in Java:
Encapsulation means bundling data and behavior together and controlling access through methods. You hide implementation details behind a clean public API.
Inheritance allows one class to reuse and extend behavior from another class.
Polymorphism means the same interface or parent reference can point to different concrete implementations, so the right behavior is selected at runtime.
Abstraction means exposing only the essential behavior while hiding unnecessary internal complexity, often through interfaces or abstract classes.
interface PaymentMethod {
void pay(double amount);
}
class CardPayment implements PaymentMethod {
private String maskedCardNumber; // encapsulation
CardPayment(String maskedCardNumber) {
this.maskedCardNumber = maskedCardNumber;
}
@Override
public void pay(double amount) {
System.out.println("Charging card " + maskedCardNumber + " for " + amount);
}
}
class UpiPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing UPI payment for " + amount);
}
}
public class CheckoutService {
public static void main(String[] args) {
PaymentMethod payment = new CardPayment("XXXX-XXXX-XXXX-9012");
payment.pay(2499.0); // polymorphism via interface reference
}
}
A fintech checkout platform used a shared PaymentMethod abstraction for cards, UPI, and net banking. Adding a new wallet payment option took 2 days instead of a week because the team extended the abstraction instead of rewriting the entire checkout flow.
The common mistake is reciting textbook definitions without connecting them to code structure.
Encapsulation means data hiding.
Inheritance means child class.
Polymorphism means many forms.
Abstraction means hiding details.Encapsulation protects state behind methods.
Inheritance reuses behavior.
Polymorphism lets one interface support multiple implementations.
Abstraction keeps callers focused on what an object does, not how it does it.When would you choose composition over inheritance in Java?
Both abstract classes and interfaces are tools for abstraction, but they serve different design purposes.
An abstract class can contain both abstract methods and concrete methods, can hold instance state, and supports constructors. It is useful when related classes should share base behavior or fields.
An interface defines a contract that classes agree to implement. Modern Java interfaces can also include default and static methods, but they are still primarily about capabilities rather than shared state.
A class can extend only one abstract class, but it can implement multiple interfaces. That makes interfaces the more flexible option for composing behavior.
interface Notifier {
void send(String message);
}
abstract class BaseReportService {
protected String reportName;
BaseReportService(String reportName) {
this.reportName = reportName;
}
void logRun() {
System.out.println("Running report: " + reportName);
}
}
class EmailReportService extends BaseReportService implements Notifier {
EmailReportService() {
super("Sales Summary");
}
@Override
public void send(String message) {
System.out.println("Email sent: " + message);
}
}
A reporting platform used an abstract base class for shared audit logging and scheduling hooks across 14 report types, while interfaces modeled optional capabilities like exportable, cacheable, and notifiable. That avoided rigid inheritance trees while still keeping shared behavior centralized.
A weak answer is "interfaces are for abstraction and abstract classes are for partial abstraction." That is dated and not useful in practice.
Interface = only abstract methods.
Abstract class = some implemented methods.Interfaces model capabilities and support multiple implementations.
Abstract classes share common state and reusable base logic across a family of types.When would you prefer composition plus interfaces over inheritance from an abstract class?
Java handles runtime problems through exceptions. Code that might fail goes inside a try block. Specific failure types are handled in catch blocks. The finally block runs whether an exception occurs or not, which makes it useful for cleanup.
The throws keyword declares that a method may pass an exception to its caller instead of handling it directly.
Java separates exceptions into checked exceptions, which must be handled or declared, and unchecked exceptions, which usually indicate programming bugs like NullPointerException or invalid state.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class InvoiceReader {
static String loadInvoice(Path file) throws IOException {
return Files.readString(file);
}
public static void main(String[] args) {
try {
String invoice = loadInvoice(Path.of("invoice-1001.txt"));
System.out.println(invoice);
} catch (IOException ex) {
System.out.println("Could not read invoice: " + ex.getMessage());
} finally {
System.out.println("Invoice read attempt finished.");
}
}
}
A payments reconciliation job initially swallowed all exceptions with a generic catch block and logged only "Something failed." Debugging incidents took hours. Splitting handling by exception type cut mean time to resolution from 90 minutes to under 20 because file errors, parsing errors, and database errors were immediately distinguishable.
The common mistake is catching Exception everywhere and hiding useful context.
try {
processPayment();
} catch (Exception e) {
System.out.println("Error");
}try {
processPayment();
} catch (PaymentGatewayTimeoutException e) {
retryLater(e);
} catch (ValidationException e) {
returnErrorToUser(e);
}What is the difference between checked and unchecked exceptions in Java?
Access modifiers control where classes, methods, and fields can be used.
public means accessible from anywhere. private means accessible only inside the same class. protected means accessible in the same package and in subclasses. default access, also called package-private, means accessible only inside the same package when no modifier is specified.
These modifiers are part of encapsulation. Good Java design keeps implementation details private and exposes only the methods or classes that other parts of the system truly need.
package billing;
public class InvoiceService {
private String encryptionKey = "internal-key";
public void generateInvoice() {
logAuditTrail();
System.out.println("Invoice generated");
}
protected void validateCustomer() {
System.out.println("Customer validated");
}
void logAuditTrail() {
System.out.println("Audit trail recorded");
}
}
A large claims-processing codebase reduced accidental misuse of internal helpers by moving 120 utility methods from public to package-private or private. That cut cross-module coupling and made refactoring safer because fewer external callers depended on internal implementation details.
Candidates often remember the keywords but not their design purpose. Making everything public is not simpler; it increases coupling.
public class Customer {
public String ssn;
public double creditLimit;
}public class Customer {
private String ssn;
private double creditLimit;
public double getCreditLimit() {
return creditLimit;
}
}Why is package-private access often useful in Java application design?
The String pool is a special JVM memory area that reuses identical string literals. If two literals contain the same text, Java can point both references to the same pooled String object instead of creating duplicates.
Strings are immutable, meaning once created their value cannot change. Immutability makes String objects safe to share, safe to cache in the pool, safe as map keys, and safer in multithreaded code.
When you appear to modify a String, Java actually creates a new String object. That is why repeated concatenation in loops can be inefficient and why StringBuilder exists.
public class StringPoolDemo {
public static void main(String[] args) {
String a = "invoice";
String b = "invoice";
String c = new String("invoice");
System.out.println(a == b); // true: same pooled literal
System.out.println(a == c); // false: new object
System.out.println(a.equals(c)); // true: same content
String report = "Q1";
report = report + " Sales"; // creates a new String object
System.out.println(report);
}
}
A document-processing service used millions of repeated status labels like READY, FAILED, and PENDING. Because literals are pooled and immutable, those labels could be reused safely across threads without synchronization, reducing duplicate allocations in the hot path.
The common mistake is saying the pool means all Strings are automatically shared. Only literals and interned Strings participate in the pool by default.
String a = new String("invoice");
String b = new String("invoice");
System.out.println(a == b); // trueString a = "invoice";
String b = "invoice";
System.out.println(a == b); // true because literals are pooledWhy is StringBuilder preferred over String concatenation inside loops?
The static keyword means a member belongs to the class itself rather than to any individual object instance.
Static variables are shared across all instances. Static methods can be called without creating an object, but they cannot directly access instance fields. Static blocks run once when the class is first loaded and are often used for one-time initialization.
Static is useful for constants, utility methods, counters, and bootstrap logic, but overusing it can make code harder to test and less object-oriented.
public class SessionTracker {
private static int activeSessions;
private String userId;
static {
activeSessions = 0;
System.out.println("SessionTracker initialized");
}
public SessionTracker(String userId) {
this.userId = userId;
activeSessions++;
}
public static int getActiveSessions() {
return activeSessions;
}
public String getUserId() {
return userId;
}
}
A support dashboard kept a shared in-memory counter of active agent sessions using a static field in a lightweight JVM-side component. That gave operations teams instant visibility without querying the database on every refresh, cutting monitoring query volume by 70% during peak traffic.
A common mistake is treating static as a shortcut for "easy global state." That often creates hidden coupling.
public class CartService {
public static List items = new ArrayList<>();
} public class MathUtil {
public static final double TAX_RATE = 0.18;
public static double calculateTax(double amount) {
return amount * TAX_RATE;
}
}Why can a static method not directly access a non-static instance field?
The Java Collections Framework is a standard set of interfaces and implementations for storing and manipulating groups of objects.
List keeps insertion order and allows duplicates. Set enforces uniqueness. Map stores key-value pairs. Queue models ordered processing, often FIFO.
The key interview point is not memorizing names but understanding trade-offs: ordered vs unordered, duplicates vs uniqueness, lookup speed vs sorted traversal, and interface-first design.
import java.util.*;
public class CollectionOverview {
public static void main(String[] args) {
List<String> tasks = new ArrayList<>(List.of("validate", "price", "invoice"));
Set<String> uniqueRegions = new HashSet<>(List.of("IN", "US", "IN"));
Map<String, Integer> stockBySku = new HashMap<>();
Queue<String> emailQueue = new ArrayDeque<>();
stockBySku.put("LAPTOP-14", 18);
stockBySku.put("MOUSE-WL", 240);
emailQueue.offer("welcome@freebytes.in");
emailQueue.offer("billing@freebytes.in");
System.out.println(tasks.get(0));
System.out.println(uniqueRegions.size());
System.out.println(stockBySku.get("LAPTOP-14"));
System.out.println(emailQueue.poll());
}
}
An order pipeline used a List for ordered validation steps, a Set to remove duplicate coupon codes, a Map for O(1) SKU lookup, and a Queue for outbound email dispatch. Picking the right abstraction up front simplified the code and removed several custom data structures that had been hard to maintain.
Candidates often say "Collection" when they really mean any data structure. In Java, Map is part of the framework but not a subtype of Collection.
Everything in Java collections is basically a List.List, Set, Queue, and Map solve different storage problems and have different guarantees.What is the difference between ArrayList, LinkedList, HashSet, TreeSet, and HashMap?
Generics let you parameterize classes, interfaces, and methods with types. They provide compile-time type safety and reduce explicit casting.
Without generics, collections would store Object, forcing callers to cast values back and risking runtime ClassCastException. With generics, the compiler enforces that a List<String> contains strings, not random objects.
Interviews often expect you to mention that Java generics are implemented with type erasure, meaning generic type information is mostly removed at runtime.
import java.util.ArrayList;
import java.util.List;
public class GenericExample {
public static void main(String[] args) {
List<String> customerIds = new ArrayList<>();
customerIds.add("CUS-1001");
customerIds.add("CUS-1002");
String firstCustomer = customerIds.get(0);
System.out.println(firstCustomer);
}
}
A legacy support platform used raw Lists across service boundaries. Refactoring those APIs to generics removed dozens of unsafe casts and surfaced 47 type mismatches at compile time that had previously been latent production bugs.
The common weak answer is "generics are just placeholders." The better answer is that they encode type constraints in APIs.
List items = new ArrayList();
items.add("invoice");
items.add(101);List items = new ArrayList<>();
items.add("invoice");
// items.add(101); // compile-time error What is type erasure, and why can you not create new T() inside a generic class?
Lambda expressions let you pass behavior as data using concise function-like syntax. Streams provide a declarative way to process collections through operations like filter, map, sorted, and collect.
A stream pipeline is usually built from a source, zero or more intermediate operations, and a terminal operation. Streams do not store data themselves; they describe how data should be processed.
The key interview point is that streams improve readability for collection transformations, but they are not automatically faster than loops in every case.
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<Integer> invoiceTotals = List.of(1200, 450, 2200, 900, 3100);
List<Integer> highValueInvoices = invoiceTotals.stream()
.filter(total -> total >= 1000)
.map(total -> total + 100)
.toList();
System.out.println(highValueInvoices);
}
}
A finance dashboard replaced nested loops and temporary lists with stream pipelines for filtering and transforming invoice records. The code shrank by 35% and became easier to review, especially in reporting features where the logic was mostly data transformation.
Candidates sometimes claim streams are always faster or always more modern. That is not the point.
Streams are always better than loops.Streams are often clearer for collection transformations, but loops can be simpler for complex branching or performance-critical hot paths.What is the difference between map() and flatMap() in Java streams?
Optional is a container type that represents either a present value or no value. It is meant to make absence explicit in APIs instead of returning raw null and hoping callers remember to check.
Methods like map, flatMap, orElse, orElseGet, and ifPresent let you handle optional values more safely and declaratively.
Interviewers usually want you to know that Optional is useful for return values, but it is generally not recommended for fields in entities or for every parameter in your codebase.
import java.util.Map;
import java.util.Optional;
public class OptionalExample {
public static void main(String[] args) {
Map<String, String> cityByCustomer = Map.of(
"CUS-1001", "Pune",
"CUS-1002", "Bengaluru"
);
String city = Optional.ofNullable(cityByCustomer.get("CUS-1003"))
.map(String::toUpperCase)
.orElse("UNKNOWN");
System.out.println(city);
}
}
A customer-profile API replaced several nullable return values with Optional in its service layer. That forced callers to handle missing address and loyalty-tier data explicitly, reducing a recurring class of NullPointerException incidents in the checkout flow.
A common mistake is treating Optional as a universal null replacement in fields, setters, DTOs, and persistence entities.
class Customer {
private Optional firstName;
} Optional findCustomerCity(String customerId) {
return Optional.ofNullable(cityByCustomer.get(customerId));
} What is the difference between orElse() and orElseGet() in Optional?
Multithreading lets multiple threads of execution run within the same Java process. A Thread represents the execution unit, and Runnable is a task that can be executed by a thread.
When multiple threads access shared mutable state, you need coordination. The synchronized keyword provides mutual exclusion so only one thread at a time can execute a protected block or method for the same monitor.
Interview basics should cover why race conditions happen, why shared state is dangerous, and why modern code often prefers executors over manually creating many threads.
public class InventoryCounter {
private int availableUnits = 10;
public synchronized void reserveOne() {
if (availableUnits > 0) {
availableUnits--;
System.out.println(Thread.currentThread().getName() + " reserved one. Left: " + availableUnits);
}
}
public static void main(String[] args) {
InventoryCounter counter = new InventoryCounter();
Runnable task = counter::reserveOne;
new Thread(task, "worker-1").start();
new Thread(task, "worker-2").start();
new Thread(task, "worker-3").start();
}
}
An inventory reservation service once decremented stock from multiple request threads without synchronization, occasionally selling the same last item twice. Adding thread-safe coordination removed oversell incidents that had been appearing several times a week during flash-sale traffic.
Candidates often explain multithreading only as "doing many things at the same time" without mentioning shared-state hazards.
Threads make code faster because everything runs in parallel.Threads can improve throughput, but once data is shared you must prevent race conditions with synchronization, locks, or thread-safe designs.What is the difference between synchronized, volatile, and atomic classes in Java?
All three are Map implementations, but they differ in ordering and performance.
HashMap offers fast average O(1) put/get operations and does not guarantee iteration order. LinkedHashMap keeps insertion order by maintaining a linked list alongside the hash table. TreeMap stores keys in sorted order using a tree structure, so operations are typically O(log n).
The key interview point is not memorizing names but understanding trade-offs: raw lookup speed, predictable iteration order, or sorted key traversal.
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public class MapVariants {
public static void main(String[] args) {
Map<String, Integer> salesHash = new HashMap<>();
salesHash.put("Mar", 300);
salesHash.put("Jan", 100);
salesHash.put("Feb", 200);
Map<String, Integer> salesLinked = new LinkedHashMap<>(salesHash);
Map<String, Integer> salesSorted = new TreeMap<>(salesHash);
System.out.println(salesHash);
System.out.println(salesLinked);
System.out.println(salesSorted);
}
}
A reporting module used LinkedHashMap so invoice sections rendered in the same order they were assembled, while a pricing-rules engine used TreeMap so tax brackets stayed sorted by threshold. Its hot lookup cache used HashMap for speed because ordering did not matter there.
A common weak answer is treating TreeMap as just a slower HashMap. The point is not speed alone; it is sorted navigation.
Use HashMap unless you accidentally need order.Use TreeMap when sorted keys or range operations matter.
Use LinkedHashMap when stable iteration order matters.
Use HashMap for general-purpose fast lookup.What is the difference between HashMap and ConcurrentHashMap?
Serialization converts an object into a byte stream so it can be stored or transmitted. In classic Java serialization, a class marks itself serializable by implementing the marker interface Serializable.
Fields marked transient are skipped during serialization. The serialVersionUID acts like a version identifier so the JVM can detect incompatible class changes during deserialization.
In interviews, it is also worth noting that default Java serialization exists but is often avoided in modern distributed systems because it can be brittle, verbose, and risky from a security standpoint.
import java.io.Serializable;
public class CustomerSnapshot implements Serializable {
private static final long serialVersionUID = 1L;
private String customerId;
private String tier;
private transient String authToken;
public CustomerSnapshot(String customerId, String tier, String authToken) {
this.customerId = customerId;
this.tier = tier;
this.authToken = authToken;
}
}
A back-office batch job serialized customer snapshots for overnight handoff to another internal system. Marking API tokens as transient prevented secrets from leaking into disk snapshots, and an explicit serialVersionUID avoided accidental breakage during class evolution between releases.
Candidates often describe Serializable as if it automatically makes all objects safe to persist forever. It does not.
Implement Serializable and the object is future-proof.Serializable enables default serialization, but schema evolution, sensitive fields, and cross-version compatibility still require design choices.Why do many modern systems prefer JSON, Avro, or Protobuf over Java native serialization?
A functional interface is an interface with exactly one abstract method. That makes it compatible with lambda expressions and method references.
Java provides common functional interfaces in java.util.function. Predicate<T> takes a value and returns boolean, usually for filtering. Function<T,R> transforms one value into another. Consumer<T> accepts a value and returns nothing, usually for side effects.
This pattern is foundational for streams and modern Java APIs.
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
public class FunctionalInterfacesDemo {
public static void main(String[] args) {
List<String> emails = List.of("sales@freebytes.in", "help@freebytes.in", "ops@freebytes.in");
Predicate<String> supportMailbox = email -> email.startsWith("help");
Function<String, String> domainOnly = email -> email.split("@")[1];
Consumer<String> printer = value -> System.out.println("Value: " + value);
emails.stream()
.filter(supportMailbox)
.map(domainOnly)
.forEach(printer);
}
}
A notification-routing service used Predicate rules for channel eligibility, Function transformers for payload reshaping, and Consumer handlers for final dispatch. Standardizing those patterns made the pipeline easier to test because each step had a narrow contract.
A common issue is remembering the names but not the input-output contract.
Predicate, Function, and Consumer are all lambda interfaces.Predicate returns boolean.
Function maps T to R.
Consumer accepts T and returns nothing. What is the difference between Consumer and Supplier in Java functional interfaces?
NIO.2 is Java's modern file I/O API introduced in Java 7. Instead of the older File API, it uses Path to represent file-system paths and Files for common operations like reading, writing, copying, and checking existence.
Channels provide lower-level, more scalable I/O primitives and are often used for larger transfers or non-blocking patterns.
Interviewers typically want to hear that NIO.2 offers better path handling, richer file operations, and improved support for scalable I/O patterns.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
public class NioExample {
public static void main(String[] args) throws IOException {
Path report = Path.of("daily-summary.txt");
Files.writeString(report, "Revenue: 185000\n", StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
Files.writeString(report, "Orders: 420\n", StandardOpenOption.APPEND);
String contents = Files.readString(report);
System.out.println(contents);
}
}
A nightly reconciliation job switched from older stream-heavy file handling to NIO.2 utility methods for copying, appending, and path normalization. The code became shorter and easier to audit, which mattered because these jobs handled regulated financial exports.
Candidates often mention NIO only as "faster I/O". The better answer includes API design improvements as well.
NIO is just the fast way to read files.NIO.2 gives better path abstractions, richer file operations, and scalable I/O primitives like channels.When would you choose Files.readString() versus buffered streaming or channels?
Try-with-resources is Java's syntax for automatically closing resources like streams, readers, database connections, and channels. Any object that implements AutoCloseable can be managed this way.
When execution leaves the try block, Java closes the resources automatically, even if an exception occurs. This reduces boilerplate and prevents resource leaks.
This is one of the most common practical interview topics because it connects language syntax to real production stability.
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class TryWithResourcesDemo {
public static void main(String[] args) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(Path.of("customers.csv"))) {
System.out.println(reader.readLine());
}
}
}
A CSV import service once leaked file handles during partial failures because readers were closed manually in only some code paths. After switching to try-with-resources, file-handle exhaustion incidents disappeared during bulk onboarding runs.
Candidates sometimes think finally is always equivalent. In practice, try-with-resources is easier to get right and preserves suppressed exceptions properly.
BufferedReader reader = Files.newBufferedReader(path);
try {
return reader.readLine();
} finally {
reader.close();
}try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.readLine();
}What are suppressed exceptions in try-with-resources?
The JVM executes Java bytecode. Source code is compiled by javac into bytecode, then the JVM loads classes, verifies them, and executes them.
The ClassLoader subsystem loads class files into memory. The JVM runtime organizes memory into areas such as the heap, thread stacks, metaspace, and program counters. Initially code may be interpreted, but frequently executed hot paths are optimized by the JIT compiler into native machine code.
Interviewers usually want a practical mental model: Java is compiled first, then dynamically optimized at runtime by the JVM.
public class RevenueCalculator {
public static int calculate(int subtotal, int tax) {
return subtotal + tax;
}
public static void main(String[] args) {
for (int index = 0; index < 1_000_000; index++) {
calculate(1000, 180);
}
}
}
// The JVM initially interprets this code.
// After enough repeated calls, the JIT may compile calculate() into optimized native code.
A pricing service with a handful of hot methods spent most of its CPU time in just a few calculation paths. Profiling showed those methods were JIT-compiled and heavily optimized after warm-up, which explained why cold-start latency and steady-state latency behaved very differently in production.
A weak answer is "Java is interpreted." That is only part of the story.
Java runs slowly because the JVM interprets everything.Java starts from bytecode, but the JVM profiles execution and uses JIT compilation to optimize hot methods at runtime.What is the difference between the heap, stack, and metaspace in the JVM?
Garbage collection reclaims memory from objects that are no longer reachable. Different collectors trade throughput, pause time, and complexity.
Serial GC is simple and single-threaded. Parallel GC focuses on throughput for batch-style workloads. G1 divides the heap into regions and aims for more predictable pauses. ZGC is a low-latency collector designed to keep pause times extremely small even on large heaps.
In interviews, the right answer is usually about trade-offs rather than memorizing every implementation detail.
public class GcPressureDemo {
public static void main(String[] args) {
for (int batch = 0; batch < 1000; batch++) {
byte[] payload = new byte[2 * 1024 * 1024];
payload[0] = 1;
}
}
}
// Example JVM flags:
// -XX:+UseParallelGC
// -XX:+UseG1GC
// -XX:+UseZGC
A checkout API on a 16 GB heap moved from Parallel GC to G1 because high-throughput throughput was acceptable but pause spikes were hurting tail latency. After tuning, pause times dropped enough to stabilize P99 response times during sale events.
Candidates often say one collector is simply "best." There is no universal winner.
ZGC is always the best garbage collector.Collector choice depends on heap size, latency targets, CPU budget, and workload profile.How do you investigate whether GC pauses are actually the cause of slow response times?
Concurrent collections are data structures designed for safe use by multiple threads without requiring callers to wrap every operation in manual synchronization.
ConcurrentHashMap supports high-concurrency reads and updates more efficiently than synchronizing a normal HashMap. CopyOnWriteArrayList creates a new internal copy on every write, which makes reads very cheap and safe but makes writes expensive.
The main interview point is workload fit: frequent reads versus frequent writes.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class ConcurrentCollectionsDemo {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> counters = new ConcurrentHashMap<>();
counters.merge("orders", 1, Integer::sum);
counters.merge("orders", 1, Integer::sum);
CopyOnWriteArrayList<String> listeners = new CopyOnWriteArrayList<>();
listeners.add("email-listener");
listeners.add("audit-listener");
System.out.println(counters.get("orders"));
System.out.println(listeners);
}
}
An eventing service kept a frequently read listener registry in CopyOnWriteArrayList because listener changes were rare, while it tracked live counters in ConcurrentHashMap because many threads updated them continuously. Matching the structure to the read-write pattern simplified concurrency and improved stability.
A common weak answer is assuming CopyOnWriteArrayList is just a thread-safe ArrayList for general use.
Use CopyOnWriteArrayList whenever threads are involved.Use CopyOnWriteArrayList when reads dominate and writes are rare.
Use ConcurrentHashMap for highly concurrent key-value access.When would you still need explicit synchronization even with concurrent collections?
The Java Memory Model defines how threads interact through memory and what visibility guarantees exist between them. Without coordination, one thread may not immediately see another thread's updates.
The happens-before relationship describes when one action is guaranteed to be visible to another. Synchronization constructs such as synchronized, volatile, thread start, and thread join create these guarantees.
This topic matters because thread safety is not only about atomicity; it is also about visibility and ordering.
public class VisibilityExample {
private static volatile boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!ready) {
// waiting for visibility of update
}
System.out.println("Worker noticed update");
});
worker.start();
Thread.sleep(100);
ready = true;
worker.join();
}
}
A polling component once cached a stop flag in one thread and never observed shutdown requests from another thread during peak load. Marking the flag volatile fixed the visibility issue and allowed rolling deployments to terminate workers cleanly instead of leaving stray threads alive.
Candidates often reduce volatile to "thread-safe" or think synchronization is only about blocking.
volatile makes everything thread-safe.volatile provides visibility and ordering guarantees for a variable, but compound operations may still need synchronization or atomic classes.What is the difference between volatile and synchronized in Java?
Design patterns are common, named solutions to recurring design problems. They are not rules; they are reusable ways to talk about structure and intent.
Singleton controls creation of a single instance. Factory centralizes object creation. Builder constructs complex objects step by step. Observer models one-to-many notification. Strategy encapsulates interchangeable algorithms behind a common interface.
Interviewers expect you to explain when a pattern helps and when it becomes unnecessary abstraction.
interface DiscountStrategy {
double apply(double amount);
}
class FestivalDiscount implements DiscountStrategy {
public double apply(double amount) {
return amount * 0.90;
}
}
class MemberDiscount implements DiscountStrategy {
public double apply(double amount) {
return amount * 0.85;
}
}
public class PricingService {
public static double finalPrice(double amount, DiscountStrategy strategy) {
return strategy.apply(amount);
}
}
A promotions engine used Strategy so pricing rules could change by campaign without editing the checkout core. That let product teams launch seasonal discount logic quickly while keeping the calculation pipeline stable and testable.
A common mistake is treating patterns as definitions to memorize rather than trade-offs to apply.
We should always use Singleton for shared services.Choose a pattern when it matches the problem: Strategy for interchangeable logic, Builder for complex construction, Factory for creation control, and so on.When does a design pattern improve maintainability, and when does it become over-engineering?
Reflection is the JVM capability to inspect classes, methods, fields, constructors, and annotations at runtime. It allows frameworks to discover metadata and invoke behavior dynamically.
Annotations attach structured metadata to code. Frameworks such as Spring, JUnit, and JPA use annotations plus reflection to wire dependencies, map entities, or detect test methods.
Reflection is powerful but should be used carefully because it can reduce type safety, increase complexity, and add runtime overhead.
import java.lang.reflect.Method;
class BillingService {
@Deprecated
public void generateInvoice() {
System.out.println("Invoice generated");
}
}
public class ReflectionDemo {
public static void main(String[] args) throws Exception {
Method method = BillingService.class.getMethod("generateInvoice");
System.out.println(method.isAnnotationPresent(Deprecated.class));
method.invoke(new BillingService());
}
}
A plugin-based admin console scanned classes for custom annotations to discover new report handlers without hardcoding them in a registry. Reflection reduced manual configuration but was limited to startup time so runtime overhead stayed controlled.
A common weak answer is calling reflection "magic." It is just runtime metadata inspection and invocation.
Spring works because annotations automatically do everything.Frameworks read annotations via reflection and then apply runtime behavior such as dependency injection, proxying, or mapping.Why is reflection usually avoided in tight performance-critical loops?
CompletableFuture represents a future result that can be completed asynchronously and chained with further processing steps. It improves on older Future APIs by supporting composition, callbacks, error handling, and non-blocking pipelines.
You can use methods like supplyAsync, thenApply, thenCompose, thenCombine, and exceptionally to build async workflows.
The interview goal is to show that async programming is not just about parallelism, but also about composition and controlled blocking.
import java.util.concurrent.CompletableFuture;
public class CompletableFutureDemo {
public static void main(String[] args) {
CompletableFuture<String> customerFuture = CompletableFuture
.supplyAsync(() -> "customer-tier-gold")
.thenApply(String::toUpperCase)
.exceptionally(error -> "UNKNOWN");
System.out.println(customerFuture.join());
}
}
A dashboard service fetched customer profile, order summary, and loyalty status in parallel using CompletableFuture, reducing endpoint latency from 850 ms to 320 ms because the three remote calls no longer waited on each other sequentially.
A weak answer is using CompletableFuture only to call get() immediately, which removes most of the benefit.
CompletableFuture result = CompletableFuture.supplyAsync(this::loadData);
return result.get(); return CompletableFuture.supplyAsync(this::loadData)
.thenApply(this::transform)
.exceptionally(error -> fallback());What is the difference between thenApply() and thenCompose()?
Records are compact data carriers that automatically generate constructor, accessors, equals, hashCode, and toString. They are ideal for immutable DTO-style objects.
Sealed classes restrict which classes can extend or implement them. This gives the compiler and the reader a closed set of allowed subtypes, which helps model finite domains more safely.
Together they make modern Java domain modeling more concise and explicit.
sealed interface PaymentResult permits Success, Failure {}
record Success(String referenceId) implements PaymentResult {}
record Failure(String reason) implements PaymentResult {}
public class PaymentPrinter {
public static void print(PaymentResult result) {
System.out.println(result);
}
}
A payment gateway integration replaced several mutable DTO classes with records and used a sealed hierarchy for result states. The code became smaller, clearer, and harder to misuse because callers could only work with the explicitly permitted outcome types.
A common weak answer is treating records as just "shorter POJOs" without mentioning immutability intent and generated semantics.
Records are classes with less typing.Records are concise immutable data carriers, and sealed types explicitly control which subtypes are allowed.When would you choose a record over a normal class in Java?
Spring Boot builds on Spring's dependency injection container. Beans are registered in the application context, and dependencies are injected into other beans, typically through constructor injection.
Auto-configuration uses classpath detection, conditions, and sensible defaults to configure common infrastructure automatically. For example, if Spring MVC is on the classpath, Boot can auto-configure a web application; if a datasource driver is present, it can auto-configure database support.
The key interview point is that Boot reduces manual wiring, but the container still decides bean creation and lifecycle behind the scenes.
import org.springframework.stereotype.Service;
@Service
class TaxService {
public double taxAmount(double amount) {
return amount * 0.18;
}
}
@Service
class InvoiceService {
private final TaxService taxService;
InvoiceService(TaxService taxService) {
this.taxService = taxService;
}
public double total(double subtotal) {
return subtotal + taxService.taxAmount(subtotal);
}
}
A team migrating from handwritten servlet configuration to Spring Boot cut application bootstrap code dramatically because web server setup, datasource wiring, health endpoints, and configuration binding came from Boot auto-configuration rather than dozens of XML and Java config classes.
Candidates often say Boot "does dependency injection automatically" without explaining the application context.
Spring Boot magically creates everything.Spring Boot uses the Spring container to create and inject beans, and auto-configuration adds conventional defaults based on the classpath and configuration.Why is constructor injection generally preferred over field injection in Spring?
JPA is the Java specification for object-relational mapping, and Hibernate is a popular implementation of that specification. They let you map Java entities to relational tables and work with object graphs instead of writing raw SQL for every query.
The N+1 query problem happens when you load one parent query and then trigger an additional query for each related child entity, usually through lazy loading inside a loop. That leads to one query plus N extra queries.
Interviewers expect you to explain both the ORM convenience and the performance trap.
@Entity
class Order {
@Id
private Long id;
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}
// Problem pattern:
// List<Order> orders = orderRepository.findAll();
// for (Order order : orders) {
// System.out.println(order.getItems().size());
// }
// This can trigger 1 query for orders and N more for items.
A customer-orders page loaded 100 orders, then lazily fetched each order's items one by one. Response time ballooned under production data because the page generated 101 SQL queries instead of 2. Fixing the fetch strategy dropped database load sharply and stabilized latency.
A weak answer is blaming Hibernate itself without understanding fetch behavior.
Hibernate is slow because ORMs always generate too many queries.N+1 usually comes from how relationships are fetched and accessed. You fix it with the right fetch strategy, joins, projections, or query design.How do fetch joins, entity graphs, and projections help solve N+1 issues?
Designing microservices in Java means splitting a system into smaller independently deployable services that own specific business capabilities. Good design focuses on bounded contexts, API contracts, data ownership, observability, and failure handling.
Common communication patterns include synchronous HTTP/gRPC calls and asynchronous messaging through queues or event streams. Resilience techniques include retries, timeouts, circuit breakers, idempotency, and bulkheads.
Interviewers generally want to hear trade-offs, not just buzzwords: microservices add autonomy and scaling flexibility, but also introduce operational complexity.
record OrderCreatedEvent(String orderId, String customerId) {}
interface InventoryClient {
boolean reserve(String orderId);
}
class OrderService {
private final InventoryClient inventoryClient;
OrderService(InventoryClient inventoryClient) {
this.inventoryClient = inventoryClient;
}
boolean placeOrder(String orderId) {
return inventoryClient.reserve(orderId);
}
}
A retail platform split catalog, ordering, payments, and notifications into separate Java services. This improved team autonomy and deployment speed, but only became stable after the team introduced centralized tracing, timeouts, retries, and clear ownership of cross-service contracts.
A weak answer is presenting microservices as automatically better architecture.
Microservices scale better, so every large app should use them.Microservices help when team boundaries, deployment independence, and scaling needs justify the operational cost and distributed-system complexity.How do you manage transactions and consistency across multiple microservices?
Reactive programming models asynchronous data flows as streams of events. In the Java ecosystem, Project Reactor provides Mono for zero-or-one results and Flux for zero-to-many results. Spring WebFlux builds on Reactor for reactive web applications.
The goal is not raw speed by default. It is efficient use of threads and non-blocking I/O under high concurrency, especially when much of the work is I/O-bound.
The key interview point is understanding where reactive fits and where traditional blocking MVC remains simpler.
Mono<String> customer = Mono.just("gold-tier-customer")
.map(String::toUpperCase)
.defaultIfEmpty("UNKNOWN");
customer.subscribe(System.out::println);
A notification ingestion API had thousands of concurrent long-lived client connections. Moving that slice to WebFlux reduced thread pressure and improved resource usage, but the rest of the business application stayed on regular Spring MVC because it was simpler and easier for most teams to reason about.
A common weak answer is saying WebFlux is always faster than Spring MVC.
Reactive is the modern replacement for blocking Java apps.Reactive shines for specific high-concurrency, non-blocking workloads. Blocking MVC can still be the right default for many teams and services.What happens if you call a blocking database or HTTP client inside a reactive pipeline?
Effective unit tests isolate the behavior of one unit and assert meaningful outcomes. JUnit 5 provides the modern Java testing framework, while Mockito helps replace collaborators with mocks when isolation is needed.
The goal is not maximum mocking. Good tests keep setup small, focus on behavior, and mock only the true external dependencies. Constructor injection makes this much easier.
Interviewers expect you to talk about test readability, isolation, and when not to mock.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
class TaxService {
double rate() { return 0.18; }
}
class InvoiceService {
private final TaxService taxService;
InvoiceService(TaxService taxService) {
this.taxService = taxService;
}
double total(double subtotal) {
return subtotal + subtotal * taxService.rate();
}
}
// In a JUnit test, TaxService could be mocked to verify behavior deterministically.
A billing team reduced flaky tests by replacing broad integration-style service tests with focused JUnit and Mockito unit tests for calculation rules, while keeping a smaller number of higher-value integration tests for database and HTTP boundaries.
A common issue is over-mocking simple value objects or writing tests that verify implementation details instead of behavior.
Mock every class the service touches, even simple DTOs and collections.Mock true collaborators like repositories or external clients, and assert business outcomes rather than private implementation steps.What is the difference between unit tests, integration tests, and end-to-end tests in a Java service?
The Java Platform Module System (JPMS) lets you define explicit module boundaries through module-info.java. A module can declare which packages it exports and which other modules it requires.
This improves encapsulation at the package level and can make large codebases more explicit, but it also introduces migration and tooling considerations, especially for older libraries that were designed before JPMS.
In interviews, the value is explaining how modules formalize dependencies and visibility across large systems.
module billing.core {
exports com.freebytes.billing.api;
requires java.sql;
}
A large internal platform used JPMS selectively in core libraries to make package exposure explicit and prevent accidental use of internal implementation packages. The rollout was gradual because some third-party dependencies were not module-friendly yet.
A common weak answer is thinking modules are just renamed Maven artifacts.
Java modules are the same thing as Maven modules.Maven organizes build units. JPMS defines runtime and compile-time module boundaries, exports, and readability rules inside Java itself.What problems can appear when migrating an older monolith to JPMS?
Distributed transactions are hard because a single business flow may span multiple services or databases that cannot be locked together cheaply or safely. In modern systems, teams often prefer eventual consistency instead of trying to make everything strongly consistent across boundaries.
Common approaches include sagas, outbox patterns, idempotent consumers, retries, compensation actions, and clear state transitions.
Interviewers want to hear that consistency is a business design choice, not just a technical mechanism.
record PaymentApproved(String orderId) {}
record InventoryReserved(String orderId) {}
record InventoryCompensationRequired(String orderId) {}
// If payment succeeds but inventory reservation later fails,
// the workflow can publish a compensation event instead of attempting a global 2PC transaction.
An order workflow charged the customer first and reserved inventory second. When inventory failed, the system published a compensation event to reverse the payment instead of trying to coordinate a fragile cross-service distributed lock. That made failure handling explicit and recoverable.
A common weak answer is assuming distributed transactions can be solved just by adding a framework.
Use a global transaction manager and the problem is solved.Distributed consistency needs business-aware workflow design, explicit failure handling, and compensating actions.What is the outbox pattern and how does it help with reliable event publication?
Production JVM tuning starts with measurement, not guesses. The main knobs are heap sizing, garbage collector choice, pause-time goals, and memory diagnostics.
Common settings include -Xms and -Xmx for heap size, collector flags such as G1 or ZGC, and GC logging for visibility. Tuning should reflect workload characteristics: latency-sensitive APIs, batch jobs, container limits, object-allocation rate, and CPU budget.
The interview point is to show a methodical approach: observe, hypothesize, change one thing, and measure again.
JAVA_OPTS="-Xms2g -Xmx2g -XX:+UseG1GC -Xlog:gc*:file=gc.log:time,level,tags"
// Workflow:
// 1. Capture baseline latency and GC metrics
// 2. Check pause times, allocation rate, and heap occupancy
// 3. Adjust heap or collector based on evidence
// 4. Re-test under production-like load
A payment API running in containers had erratic latency because heap settings ignored container memory limits. After right-sizing the heap, enabling GC logs, and tuning for predictable pauses, the team reduced memory-related restarts and stabilized P99 response time during peak traffic.
A common mistake is copying JVM flags from another service without understanding the workload.
Use whatever GC flags worked in another project.Use production metrics, GC logs, and load tests to decide whether heap size, collector choice, or allocation behavior is the real bottleneck.Which JVM metrics and GC log signals do you check first when response times spike?
The Just-In-Time compiler watches which methods and branches run frequently, then compiles hot code paths into optimized native instructions. It can inline small methods, remove redundant checks, optimize loops, and make speculative assumptions based on runtime profiles.
This is why Java applications often run slower during warm-up and faster after the JVM has observed the workload.
The main interview point is that Java performance is dynamic: code quality, runtime behavior, and warm-up all matter.
public class HotPathDemo {
static int addTax(int amount) {
return amount + 180;
}
public static void main(String[] args) {
int total = 0;
for (int index = 0; index < 10_000_000; index++) {
total += addTax(1000);
}
System.out.println(total);
}
}
// Frequently executed methods like addTax() may be inlined by the JIT after warm-up.
A billing calculation benchmark looked disappointing during cold startup but performed well after warm-up because the JIT had inlined small arithmetic methods and optimized loop execution. The team adjusted its benchmarking process to separate cold-start from steady-state behavior.
Candidates often assume compiled Java code is fixed once javac finishes.
javac does all Java optimization at build time.javac produces bytecode, but the JVM can later JIT-compile hot methods into optimized native code based on real execution patterns.Why do Java microbenchmarks need warm-up, and what tools like JMH help with that?
Connection pooling keeps a reusable pool of database connections instead of creating a new connection for every request. Opening database connections is expensive because it involves network setup, authentication, and server resources.
HikariCP is a widely used high-performance JDBC connection pool. It manages pool size, connection timeouts, idle cleanup, and leak detection.
The interview point is simple: pooling reduces latency and protects the database from churn.
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.idle-timeout=600000
// Reusing pooled connections is far cheaper than creating a new DB connection per request.
A reporting API accidentally created too many short-lived database connections under load, overwhelming the DB during month-end traffic. Moving to a properly sized HikariCP pool reduced connection churn and improved both API latency and database stability.
A common weak answer is setting huge pool sizes without regard for database capacity.
More connections always means more throughput.Pool size should match workload characteristics and database capacity. Too many connections can increase contention and hurt performance.How do you choose an appropriate database connection pool size?
Profiling means measuring where CPU time, memory, allocations, locks, or threads are actually going. Java tools like VisualVM and JProfiler provide JVM-level inspection, while async-profiler is popular for low-overhead production-friendly flame graphs.
The key is to profile the real bottleneck: CPU hot methods, heap usage, allocation churn, blocked threads, or slow database calls. Guessing is unreliable.
Interviewers want to hear a disciplined process: capture evidence, identify the hotspot, fix it, and measure again.
1. Reproduce the slowdown under load.
2. Capture CPU and allocation profiles.
3. Inspect flame graphs or hot methods.
4. Check GC behavior, thread dumps, and lock contention.
5. Change one suspected bottleneck.
6. Re-profile to confirm the gain.
A pricing service suspected garbage collection was causing latency spikes, but profiling revealed most CPU time was actually spent in JSON serialization. Fixing the serializer path improved throughput immediately and avoided a week of unnecessary JVM flag tweaking.
A common mistake is optimizing based on intuition or one suspicious method name.
This method looks slow, so let's rewrite it first.Capture CPU, memory, thread, and allocation evidence first, then optimize the real bottleneck.What is the difference between CPU profiling, allocation profiling, heap dumps, and thread dumps?
Virtual threads are lightweight threads managed by the JVM rather than mapped one-to-one to operating system threads. They let Java applications handle a much larger number of concurrent blocking tasks without the same thread overhead as platform threads.
This is especially useful for request-per-thread styles of programming that want simpler code without fully switching to reactive programming.
The interview point is that virtual threads improve concurrency scalability for many I/O-bound workloads, but they do not magically speed up CPU-bound work.
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("load customer profile"));
executor.submit(() -> System.out.println("load invoice history"));
}
A service that previously managed large thread pools for blocking downstream calls moved to virtual threads and simplified concurrency code significantly. It handled many more concurrent requests with lower thread-management overhead while keeping the programming model straightforward for the team.
A common mistake is claiming virtual threads make all Java code faster.
Project Loom makes CPU-heavy code faster because it adds more threads.Virtual threads improve scalability for high-concurrency blocking operations. CPU-bound work is still limited by cores and algorithm efficiency.When would you choose virtual threads over reactive programming, and when might reactive still be a better fit?
Both implement List, but their internal structures are completely different, and that difference determines which wins in each situation.
ArrayList is backed by a contiguous array. Random access by index is O(1) because the JVM calculates the memory offset directly. Appending to the end is amortised O(1). Inserting or removing in the middle is O(n) because elements must shift. Critically, the contiguous layout is cache-friendly — the CPU can prefetch the next element while processing the current one.
LinkedList is a doubly-linked list of node objects. Adding or removing at a known node position is O(1). But finding element at index i is O(n), and every node holds two extra pointers plus object header overhead. Those scattered heap allocations destroy CPU cache locality.
Practical rule: use ArrayList by default. Switch to LinkedList only when you are doing frequent insertions/deletions at both ends and you already hold a reference to the node (e.g. implementing a Deque). In benchmarks on modern JVMs, ArrayList outperforms LinkedList even for middle insertions up to tens of thousands of elements because of cache effects.
import java.util.*;
// ── ArrayList: O(1) indexed read, O(n) middle insert ──
List<String> products = new ArrayList<>();
products.add("Laptop");
products.add("Mouse");
products.add("Monitor");
String second = products.get(1); // O(1) — direct index
products.add(1, "Keyboard"); // O(n) — shifts elements right
// ── LinkedList: O(n) indexed read, O(1) add at ends ──
Deque<String> taskQueue = new LinkedList<>();
taskQueue.addFirst("high-priority-task"); // O(1)
taskQueue.addLast("low-priority-task"); // O(1)
String next = taskQueue.pollFirst(); // O(1)
// ── Why LinkedList loses on random access ──
List<Integer> ll = new LinkedList<>();
for (int i = 0; i < 100_000; i++) ll.add(i);
// ll.get(50_000) traverses ~50,000 nodes — O(n)
// al.get(50_000) on ArrayList: single memory offset — O(1)
// ── Memory cost per element ──
// ArrayList element: ~4 bytes (object reference in array)
// LinkedList node: ~32 bytes (prev ptr + next ptr + data ref + header)
A trading platform stored the day's order book as a LinkedList believing "insertions are faster." Profiling showed 70% of CPU time was in get() calls for rendering the order ladder. Switching to ArrayList cut p99 render latency from 18 ms to 2 ms. The benchmark showed ArrayList was faster even for insertions up to 50,000 elements due to System.arraycopy leveraging CPU SIMD instructions.
Candidates say "LinkedList is faster for insertions" without qualifying that the O(1) insert only applies when you already hold the node reference. Finding the position first is O(n).
// "LinkedList insert at index 5 is O(1)"
list.add(5, "item"); // LinkedList must traverse 5 nodes first — O(n)!ListIterator it = linkedList.listIterator();
// traverse once to position 5
for (int i = 0; i < 5; i++) it.next();
it.add("item"); // O(1) — we already hold the node reference If you need a thread-safe list with frequent reads and occasional writes, which collection would you choose instead of synchronizing ArrayList?
All three implement Map but serve different ordering and performance needs.
HashMap: O(1) average get/put using a hash table. No ordering guarantee on iteration. Best general-purpose map when you just need key lookup.
TreeMap: O(log n) get/put, backed by a Red-Black tree. Keys are kept in natural sorted order (or a custom Comparator). Use it when you need firstKey(), lastKey(), subMap(), or range queries.
LinkedHashMap: O(1) get/put like HashMap, but maintains insertion order (or access order if constructed with accessOrder=true). Perfect for LRU caches and anywhere iteration order must mirror insertion order.
Rule of thumb: HashMap by default → LinkedHashMap when order matters → TreeMap when sorted order or range queries are needed.
import java.util.*;
// ── HashMap: fastest, no order ──
Map<String, Integer> inventory = new HashMap<>();
inventory.put("Laptop", 42);
inventory.put("Mouse", 150);
inventory.put("Monitor", 30);
// iteration order unpredictable
// ── TreeMap: sorted by key ──
Map<String, Integer> sorted = new TreeMap<>(inventory);
System.out.println(sorted.firstKey()); // "Laptop" (alphabetical)
System.out.println(sorted.subMap("L", "N")); // keys L..M range
// ── LinkedHashMap: insertion order ──
Map<String, Integer> ordered = new LinkedHashMap<>(inventory);
// iterates Laptop → Mouse → Monitor (insertion order)
// ── LRU Cache with LinkedHashMap (access order) ──
Map<String, String> lru = new LinkedHashMap<>(16, 0.75f, true) {
protected boolean removeEldestEntry(Map.Entry e) {
return size() > 100; // evict when over 100 entries
}
};
A financial report builder stored monthly ledger buckets in a HashMap. The UI always displayed months Jan→Dec but the map iterated randomly, requiring a post-sort on every render. Switching to a TreeMap
Candidates reach for TreeMap to "keep things organised" without considering the O(log n) cost on every put/get, and forget LinkedHashMap exists for the insertion-order case.
// Just want to iterate in insertion order:
Map config = new TreeMap<>(); // O(log n) per op, sorts alphabetically — not what you want Map config = new LinkedHashMap<>(); // O(1), preserves insertion order How would you implement an LRU cache in Java using only standard library classes?
These three tools solve different scopes of thread-safety problems.
volatile: guarantees visibility — when one thread writes a volatile variable, all other threads immediately see the updated value. It does NOT guarantee atomicity for compound actions (check-then-act, increment). Use it for single flags or state variables read by many threads and written by one.
synchronized: provides mutual exclusion and visibility together. It is re-entrant (the same thread can acquire it multiple times). Syntax is simple. Drawback: no way to time out, no fairness policy, cannot interrupt a waiting thread.
ReentrantLock: explicit lock with more power — tryLock(timeout), lockInterruptibly(), and a fairness flag. Also supports Condition objects for fine-grained wait/notify. Use it when synchronized falls short: you need timeouts, fairness, or multiple wait conditions.
import java.util.concurrent.locks.*;
// ── volatile: single-writer visibility flag ──
class Worker implements Runnable {
private volatile boolean running = true;
public void stop() { running = false; }
public void run() {
while (running) { /* process */ } // sees updated value immediately
}
}
// ── synchronized: simple mutual exclusion ──
class BankAccount {
private int balance = 0;
public synchronized void deposit(int amount) { balance += amount; }
public synchronized int getBalance() { return balance; }
}
// ── ReentrantLock: timeout + fairness ──
class ConnectionPool {
private final ReentrantLock lock = new ReentrantLock(true); // fair
public boolean acquire(long timeoutMs) throws InterruptedException {
if (lock.tryLock(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)) {
try {
// use connection
return true;
} finally {
lock.unlock();
}
}
return false; // timed out — caller can retry or fail fast
}
}
A payment gateway used synchronized on its connection pool, causing threads to wait indefinitely during downstream timeouts. Switching to ReentrantLock with tryLock(200ms) let the service return HTTP 503 immediately instead of queueing indefinitely, reducing p99 latency from 12s to 220ms during outage conditions.
Candidates use volatile for a counter thinking it makes increment atomic.
private volatile int counter = 0;
counter++; // read-modify-write — NOT atomic! two threads can both read 5, both write 6private final AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic CAS operationWhat is the difference between ReentrantLock and ReentrantReadWriteLock, and when does the read-write separation pay off?
The choice comes down to two questions: does the shared code need state? and does the relationship mean "is-a" or "can-do"?
Use an abstract class when: you need to share fields (state), you need a constructor, you want to provide default method implementations that depend on fields, or all subclasses truly share a common type hierarchy (Animal → Dog, Cat).
Use an interface when: you are defining a capability or contract that unrelated classes can implement (Serializable, Comparable, Runnable), you need a class to fulfil multiple contracts (Java has no multiple inheritance of classes), or you want to keep the abstraction purely behavioural with no coupling to state.
Since Java 8, interfaces can have default methods. Since Java 9, private helper methods. This blurs the line — but interfaces still cannot hold instance fields or define constructors.
// ── Abstract class: shared state + partial implementation ──
abstract class Report {
protected final String title; // shared state
protected final LocalDate generated; // shared state
protected Report(String title) {
this.title = title;
this.generated = LocalDate.now();
}
public final String header() { // shared behaviour using state
return title + " — " + generated;
}
public abstract void render(); // subclasses must implement
}
class PdfReport extends Report {
public PdfReport(String title) { super(title); }
public void render() { /* PDF-specific rendering */ }
}
// ── Interface: capability contract, no state ──
interface Exportable {
byte[] exportAsCsv();
default String exportAsJson() { return "{}"; } // optional default
}
interface Printable {
void print();
}
// A class can implement multiple interfaces
class SalesReport extends Report implements Exportable, Printable {
public SalesReport() { super("Sales Report"); }
public void render() { /* ... */ }
public byte[] exportAsCsv() { return new byte[0]; }
public void print() { /* ... */ }
}
A document processing platform had a BaseProcessor abstract class with shared fields (auditLog, tenantId, retryPolicy) and common pre/post hooks. Export formats (CSV, PDF, XLSX) were interfaces. This let third-party plugins implement Exportable without extending BaseProcessor, keeping the plugin API clean while the core platform kept its shared infrastructure in the abstract class.
Candidates pick abstract class just because it "feels more like a class" and miss that their design needs multiple inheritance of behaviour.
abstract class Loggable { ... }
abstract class Auditable { ... }
// class OrderService extends Loggable, Auditable — compile error!interface Loggable { default void log(String msg) { ... } }
interface Auditable { default void audit(String event) { ... } }
class OrderService implements Loggable, Auditable { ... } // worksJava 8 added default methods to interfaces. Does that make abstract classes obsolete? What can abstract classes still do that interfaces cannot?
Java has two exception hierarchies. Checked exceptions (extend Exception but not RuntimeException) must be either caught or declared with throws. The compiler enforces this. Unchecked exceptions (extend RuntimeException or Error) require no declaration and can propagate freely.
Use checked exceptions when the caller can reasonably be expected to recover from the failure and the failure is a normal part of the operation — e.g. file not found, network timeout, insufficient funds. You are forcing the caller to decide: handle it or propagate it.
Use unchecked exceptions for programming errors (invalid arguments, null where not expected, broken invariants) where recovery is usually not meaningful — e.g. NullPointerException, IllegalArgumentException, IllegalStateException.
Modern style (Google Guava, Spring, most frameworks) leans toward unchecked exceptions for everything, wrapping checked I/O exceptions in unchecked wrappers. The rationale: checked exceptions pollute method signatures and encourage empty catch blocks.
// ── Checked: caller must handle or declare ──
public class PaymentService {
public Receipt charge(String accountId, BigDecimal amount)
throws InsufficientFundsException, AccountNotFoundException {
// Caller MUST handle these — they are expected business conditions
Account account = findAccount(accountId); // throws AccountNotFoundException
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(accountId, amount);
}
return processCharge(account, amount);
}
}
// ── Unchecked: programming error, caller cannot recover ──
public void setRetryCount(int count) {
if (count < 0) {
throw new IllegalArgumentException("Retry count cannot be negative: " + count);
}
this.retryCount = count;
}
// ── Wrapping checked in unchecked (modern style) ──
public byte[] loadTemplate(String name) {
try {
return Files.readAllBytes(Path.of("templates/" + name));
} catch (IOException e) {
throw new UncheckedIOException(e); // caller handles at boundary, not everywhere
}
}
A payments API originally threw checked IOExceptions from its HTTP client through 8 layers of service code. Every method signature was polluted with "throws IOException" and developers added empty catch blocks just to silence the compiler. Refactoring to wrap at the HTTP client boundary with an unchecked PaymentGatewayException cleaned up 40+ method signatures and made the one real recovery point — the API controller — the only place that handled it.
The most common mistake is catching a checked exception and swallowing it silently — the worst of both worlds.
try {
account.charge(amount);
} catch (InsufficientFundsException e) {
// TODO handle later
// (never handled — charge silently succeeds in the caller's view)
}try {
account.charge(amount);
} catch (InsufficientFundsException e) {
log.warn("Charge declined for account {}: {}", accountId, e.getMessage());
throw new PaymentDeclinedException("Insufficient funds", e);
}How do checked exceptions interact with Java Streams and lambdas, and what patterns exist to handle them cleanly?
These three abstractions differ in which ends you insert and remove from.
Queue (FIFO — First In, First Out): insert at tail, remove from head. Use for task queues, breadth-first search, rate-limiting buffers. Key implementations: LinkedList, ArrayDeque, PriorityQueue.
Stack (LIFO — Last In, First Out): insert and remove from the same end. Use for undo history, expression parsing, depth-first search. Do not use java.util.Stack — it extends Vector and is synchronised even when you don't need it. Use Deque as a stack instead (ArrayDeque).
Deque (double-ended queue): insert and remove from both ends in O(1). It replaces both Stack and Queue. ArrayDeque is the recommended implementation for most cases — faster than LinkedList due to cache locality.
import java.util.*;
// ── Queue (FIFO) — task processing ──
Queue<String> emailQueue = new ArrayDeque<>();
emailQueue.offer("welcome@user1.com");
emailQueue.offer("receipt@user2.com");
String next = emailQueue.poll(); // "welcome@user1.com" — first in, first out
// ── Deque as Stack (LIFO) — undo history ──
Deque<String> undoStack = new ArrayDeque<>();
undoStack.push("typed 'H'");
undoStack.push("typed 'e'");
undoStack.push("typed 'l'");
String lastAction = undoStack.pop(); // "typed 'l'" — last in, first out
// ── Deque as both ends — sliding window ──
Deque<Integer> window = new ArrayDeque<>();
int[] prices = {3, 1, 4, 1, 5, 9, 2, 6};
for (int price : prices) {
while (!window.isEmpty() && window.peekLast() > price) {
window.pollLast(); // remove from back
}
window.offerLast(price); // add to back
}
// ── Why not java.util.Stack? ──
// Stack<String> legacy = new Stack<>(); // synchronized on every op — avoid!
// Deque<String> modern = new ArrayDeque<>(); // use this instead
A code editor's undo/redo system was originally built with java.util.Stack. Load testing showed synchronization contention on every keystroke because Stack extends Vector. Replacing with ArrayDeque eliminated the contention and cut per-keystroke overhead by 60% — the editor became noticeably more responsive on long documents.
Candidates use java.util.Stack because it is named "Stack" without knowing it is synchronised and inherits unwanted List methods.
Stack history = new Stack<>();
history.push("action1"); // synchronized — unnecessary overhead
// Also exposes .get(0), .remove(0) etc — not stack behaviour Deque history = new ArrayDeque<>();
history.push("action1"); // O(1), no sync
history.pop(); // O(1), LIFO When would you use PriorityQueue over ArrayDeque, and what is its time complexity for insertion and retrieval?
All three handle text in Java but differ in mutability and thread safety.
String is immutable. Every concatenation with + creates a new String object. For a small number of fixed concatenations this is fine — the compiler optimises "a" + "b" + "c" at compile time. But concatenating inside a loop creates O(n²) objects and is a well-known performance trap.
StringBuilder is mutable and not thread-safe. Appending to it modifies the internal buffer in place. Use this for building strings in a single thread — inside loops, constructing SQL/JSON fragments, building formatted output.
StringBuffer is identical to StringBuilder but all methods are synchronised. It predates StringBuilder (added in Java 5). Use it only if multiple threads genuinely share and mutate the same buffer simultaneously — which is rare. In almost every real case, use StringBuilder.
// ── Wrong: String concatenation in a loop ──
String report = "";
for (String line : lines) {
report += line + "\n"; // creates a new String object every iteration — O(n²)
}
// ── Correct: StringBuilder for loop concatenation ──
StringBuilder sb = new StringBuilder();
for (String line : lines) {
sb.append(line).append("\n"); // mutates buffer — O(n) total
}
String report = sb.toString();
// ── Building structured output ──
StringBuilder json = new StringBuilder();
json.append("{")
.append("\"orderId\":").append(order.getId()).append(",")
.append("\"total\":").append(order.getTotal())
.append("}");
// ── Compiler optimises simple literal concat ──
String greeting = "Hello, " + name + "!";
// Compiler converts this to: new StringBuilder().append("Hello, ").append(name).append("!").toString()
// So this is fine — don't manually use StringBuilder for one-liners
// ── StringBuffer: only for shared mutable buffer across threads ──
StringBuffer sharedLog = new StringBuffer(); // synchronized on every method
// In practice: prefer StringBuilder + explicit locking, or use a concurrent logger
A report generator built 10,000-row CSV exports using String concatenation inside a loop. Each row append allocated a new full-copy String, causing heap allocation to balloon to 4 GB for a 50 MB output file. Replacing the loop body with a single StringBuilder reduced peak heap to 120 MB and cut generation time from 28s to 1.1s.
Candidates manually wrap single-expression concats in StringBuilder thinking it is always faster — the compiler already does this.
// Single-expression concat — compiler generates StringBuilder anyway:
String msg = new StringBuilder().append("Order ").append(id).append(" confirmed").toString();// Simple concat — let the compiler optimise:
String msg = "Order " + id + " confirmed";
// Loop — use StringBuilder explicitly:
StringBuilder sb = new StringBuilder();
for (Order o : orders) sb.append(o.getId()).append(",");
String csv = sb.toString();In Java 15+, text blocks were introduced. When would you choose a text block over StringBuilder for multi-line string construction?
These three approaches model a fixed set of known values, but with different levels of type safety and expressiveness.
Constant class (public static final fields): the oldest approach. Simple, but gives no type safety — a method accepting int STATUS happily accepts any int, not just your defined constants.
Enum: a first-class type whose values are an exhaustive, compiler-enforced set. Enums can have fields, methods, and implement interfaces. The compiler warns when a switch is not exhaustive (Java 14+ pattern matching). Use enums for a fixed, named set of values that are known at compile time and do not need to carry rich subtype-specific behaviour.
Sealed class/interface (Java 17+): defines a fixed set of permitted subclasses. Each subclass can carry different fields and behaviour. Use sealed classes when your "variants" have different shapes — e.g. Shape with Circle(radius), Rectangle(width, height), Triangle(base, height). Combined with switch pattern matching and record, this is algebraic data types in Java.
// ── Constant class: no type safety ──
class OrderStatus {
public static final int PENDING = 0;
public static final int SHIPPED = 1;
public static final int DELIVERED = 2;
}
void process(int status) { ... } // accepts any int — no safety
// ── Enum: type-safe named constants with behaviour ──
enum OrderStatus {
PENDING("Awaiting dispatch"),
SHIPPED("In transit"),
DELIVERED("Completed");
private final String label;
OrderStatus(String label) { this.label = label; }
public String getLabel() { return label; }
}
void process(OrderStatus status) { ... } // only valid values accepted
// ── Sealed class: variants with different data ──
sealed interface PaymentResult permits Success, Failure, Pending {}
record Success(String transactionId, BigDecimal amount) implements PaymentResult {}
record Failure(String reason, int errorCode) implements PaymentResult {}
record Pending(String referenceId, Duration timeout) implements PaymentResult {}
// Exhaustive switch — compiler error if a variant is missing:
String message = switch (result) {
case Success s -> "Paid: " + s.transactionId();
case Failure f -> "Failed: " + f.reason();
case Pending p -> "Awaiting: " + p.referenceId();
};
A payment processor originally modelled outcomes as integer constants (0 = success, 1 = failure, 2 = pending). A bug caused a method to return 3 (unhandled), which was silently cast to a success code. Migrating to a sealed PaymentResult hierarchy made unhandled cases a compile error — the team caught 4 missing outcome paths during migration that had been silently ignored in production for 6 months.
Candidates add an UNKNOWN or OTHER enum value "just in case" — this defeats the exhaustiveness guarantee that makes enums valuable.
enum PaymentStatus { SUCCESS, FAILURE, PENDING, UNKNOWN }
// Now every switch must handle UNKNOWN — the compiler can't help you know if it's real// If unknown is a real state, model it explicitly with data:
record UnknownStatus(String rawCode) implements PaymentResult {}
// Now the compiler enforces handling it, and it carries the raw code for debuggingHow do sealed classes interact with Java's pattern matching switch, and what advantage does that give over a traditional if-else chain or instanceof checks?
Both solve the problem of handling many concurrent I/O-bound tasks efficiently, but with very different programming models.
CompletableFuture (Java 8+): non-blocking, callback-based. You chain .thenApply(), .thenCompose(), .exceptionally() to build async pipelines without blocking threads. The code executes on a thread pool, and threads are never blocked waiting. Downside: callback chains are hard to read, debug, and reason about. Stack traces are useless. Exception handling is scattered.
Virtual threads (Java 21, stable): let you write blocking code in a sequential style but run it on lightweight JVM-managed threads. A virtual thread that blocks on a socket read is unmounted from the carrier OS thread, which is freed to run other virtual threads. You can have millions of virtual threads. Code looks like simple synchronous Java but scales like async.
Decision rule: new services targeting Java 21+ should prefer virtual threads for I/O-bound concurrency — the code is simpler and maintenance is easier. Keep CompletableFuture for complex async fan-out/fan-in pipelines where you need explicit composition semantics (allOf, anyOf) or when targeting Java 8–17.
import java.util.concurrent.*;
// ── CompletableFuture: non-blocking pipeline ──
CompletableFuture<String> future =
CompletableFuture.supplyAsync(() -> fetchCustomer(id))
.thenApply(customer -> enrichWithOrders(customer))
.thenApply(enriched -> formatResponse(enriched))
.exceptionally(ex -> fallbackResponse(ex));
// Hard to debug: stack trace shows thread pool internals, not your code
// ── Virtual threads: blocking style, scales like async ──
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> customerFuture = executor.submit(() -> fetchCustomer(id));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(id));
String customer = customerFuture.get(); // blocks virtual thread, not OS thread
List<Order> orders = ordersFuture.get();
return buildResponse(customer, orders);
}
// Stack trace shows your actual code — easy to debug
// ── Fan-out with both: CompletableFuture still wins here ──
CompletableFuture<Void> all = CompletableFuture.allOf(
CompletableFuture.runAsync(this::syncInventory),
CompletableFuture.runAsync(this::syncPricing),
CompletableFuture.runAsync(this::syncCatalog)
); // explicit "wait for all three" semantics
A microservice aggregator rewrote its CompletableFuture chain (9 chained stages, 3 error-handling paths) using virtual threads on Java 21. The new code was 60% fewer lines. The team resolved a production bug in 20 minutes that had taken 3 days with the CompletableFuture version because stack traces were now readable. Throughput was identical; maintainability improved dramatically.
Candidates think virtual threads are faster than CompletableFuture. They are not faster — they use the same OS threads underneath. The advantage is simplicity and debuggability, not raw throughput.
"Virtual threads are faster than CompletableFuture because they use less memory per thread."
// Memory is lower, yes — but throughput for the same I/O workload is comparable.// Virtual threads: simpler code, readable stack traces, blocking style
// CompletableFuture: explicit async composition (allOf, anyOf, thenCompose)
// Choose based on which matters more for your team and Java versionWhat is structured concurrency in Java (JEP 453), and how does it improve on manually managing CompletableFuture.allOf() calls?
Choosing the wrong collection type is one of the most common Java mistakes. The right choice depends on three questions: Do you need duplicates? Do you need lookup by key? Does order matter?
List: ordered, allows duplicates, access by index. Use when sequence and position matter — a shopping cart, paginated results, an ordered event log. ArrayList for random access, LinkedList/ArrayDeque for queue/deque behaviour.
Set: no duplicates, no positional index. Use when uniqueness is the constraint — tracking visited URLs, deduplicating tags, checking membership. HashSet for O(1) membership checks, TreeSet for sorted unique values, LinkedHashSet for insertion-ordered unique values.
Map: key-value pairs, keys are unique. Use when you need to look up, group, or count by a key — user sessions by ID, word frequency counts, config key-value pairs. HashMap for O(1) lookup, TreeMap for sorted keys, LinkedHashMap for ordered iteration.
import java.util.*;
import java.util.stream.*;
// ── List: ordered, allows duplicates ──
List<String> cartItems = new ArrayList<>();
cartItems.add("Laptop");
cartItems.add("Mouse");
cartItems.add("Mouse"); // duplicate allowed — cart has 2 mice
System.out.println(cartItems.get(0)); // "Laptop" — positional access
// ── Set: uniqueness, fast membership check ──
Set<String> visitedUrls = new HashSet<>();
visitedUrls.add("/home");
visitedUrls.add("/products");
visitedUrls.add("/home"); // ignored — already present
System.out.println(visitedUrls.contains("/home")); // true — O(1)
System.out.println(visitedUrls.size()); // 2, not 3
// ── Map: lookup by key ──
Map<String, Integer> wordCount = new HashMap<>();
String[] words = {"java", "is", "fast", "java", "is", "great"};
for (String word : words) {
wordCount.merge(word, 1, Integer::sum);
}
System.out.println(wordCount); // {java=2, is=2, fast=1, great=1}
// ── Decision questions ──
// "I need to track all product IDs a user viewed, no duplicates, insertion order"
// → LinkedHashSet<String>
// "I need to count how many times each error code occurred"
// → Map<String, Integer> (HashMap)
// "I need the top-5 most-expensive items in order"
// → List<Item> sorted, or PriorityQueue<Item>
A search service tracked "recently viewed" products per user with a List, which grew unbounded and contained duplicates. Deduplication on every page load was O(n²). Replacing with a LinkedHashSet (insertion-ordered, no duplicates) capped at 50 entries made membership checks O(1) and eliminated the dedup loop entirely. API response time for the "recently viewed" widget dropped from 340 ms to 12 ms.
Candidates store items in a List and then call contains() inside a loop — O(n) per check, O(n²) total — when a Set would make it O(1).
List processed = new ArrayList<>();
for (String item : incoming) {
if (!processed.contains(item)) { // O(n) every iteration!
process(item);
processed.add(item);
}
} Set processed = new HashSet<>();
for (String item : incoming) {
if (processed.add(item)) { // add() returns false if already present — O(1)
process(item);
}
} When would you use a LinkedHashSet over a HashSet, and when would you use a TreeSet over both?
Frequently Asked Questions
This page contains 50 Java interview questions across all 5 levels: Basic, Intermediate, Advanced, Experienced, and Performance. The latest additions focus on "when to use X vs Y" decision questions — ArrayList vs LinkedList, HashMap vs TreeMap, synchronized vs ReentrantLock, and more.
The Java section covers core language basics, collections, generics, streams, concurrency, JVM internals, garbage collection, reflection, Spring Boot, JPA/Hibernate, microservices, testing, distributed systems, production performance tuning, and a dedicated set of real-world decision questions on when to choose one data structure or tool over another.
The examples use realistic Java code with business-style names, real collection usage, and interview-ready explanations. They are meant to reflect production Java rather than textbook placeholders.
The page covers all 5 levels: Basic (10), Intermediate (10), Advanced (8), Experienced (7), and Performance (5). You can filter them from the level pills above the question list.
Interviewers want you to connect concepts to behavior: how the JVM runs bytecode, why String equality differs from object identity, when ArrayList beats arrays, and how OOP principles improve maintainability in real systems.
Yes. The examples assume modern Java usage, typically Java 11 to Java 21, while still covering classic concepts that appear in enterprise interviews regardless of version.
Yes. The completed Java set includes multithreading fundamentals, JVM internals, garbage collection, Spring Boot auto-configuration, JPA/Hibernate, microservices, and performance profiling topics.
Because senior-level design and performance answers still depend on getting fundamentals right. If a candidate confuses == with .equals() or arrays with collections, interviewers doubt deeper architectural judgement.