Java Developer Interview Questions · 2026

Java Developer Interview Questions (2026): Core Java to Spring Boot

Oracle shipped Java 21 as a Long-Term Support release in September 2023, finalizing virtual threads and generational garbage collection. Almost three years later, a lot of java developer interview questions asked in India and the US are still written for Java 8. That gap is worth knowing before you walk in, since sounding fluent in outdated syntax doesn't help much when someone asks how a virtual thread differs from a plain OS thread.

Here's an opinion plenty of senior engineers would push back on: the average Java interview overweights collections trivia (name the six methods on the List interface) and underweights the expensive stuff, like why a Spring Boot service's connection pool exhausted at 2am in production. Companies still ask both, and this page covers what actually comes up across fresher and experienced loops. TCS and Infosys ask it for onboarding batches numbering in the thousands, and Goldman Sachs asks a version of the same HashMap question at the senior level with a lot less patience for a shaky answer.

The questions below are split by topic instead of by company: core language and OOP, collections, exceptions, concurrency, JVM internals and garbage collection, the Java 8+ functional style, Spring and Spring Boot, and two coding problems you should be able to write without a compiler open.

8Topics
2-5Rounds
Easy-MediumCoding level
Fresher-SeniorExperience range

Core Java and OOP fundamentals

Every Java loop starts here, whether it's a campus round at Wipro or a mid-level loop at a product company. Interviewers use these three to filter out candidates who've memorized syntax without understanding why the language behaves the way it does.

Easy questions

15

An abstract class can hold constructor logic, instance fields, and non-public methods, but a class can only extend one. An interface, since Java 8, can also carry default and static methods, but it still can't hold instance state, and a class can implement as many interfaces as it wants. Use an abstract class when subclasses share real implementation and state, a Vehicle base class with an odometer field, for example. Use an interface when you're defining a contract that unrelated classes need to honor, like Comparable or Serializable.

The multiple-inheritance workaround, implementing several interfaces at once, is the main reason interfaces exist at all in a single-inheritance language. Interviewers usually follow up by asking what happens if two default methods from different interfaces conflict. The answer: the compiler forces you to override the method explicitly and pick one, or write your own.

ArrayList, almost every time, and I'd argue LinkedList is one of the most overused "textbook correct" answers in Java interviews. ArrayList is backed by a resizable array: O(1) random access, and appends are amortized O(1) since the array only needs to double occasionally.

LinkedList gives O(1) insertion and removal, but only at a node you already hold a reference to. Getting to that node in the middle of the list is still O(n), which erases the advantage for most real use cases, and LinkedList has worse cache locality since its nodes scatter across the heap instead of sitting contiguously. The one place LinkedList earns its keep is as a Deque implementation, and even there, ArrayDeque usually wins because it skips the per-node object overhead entirely.

Checked exceptions (anything extending Exception except RuntimeException, like IOException or SQLException) must be declared or caught at compile time. Unchecked exceptions, extending RuntimeException, aren't enforced by the compiler at all.

The pushback on checked exceptions is real and comes from good engineers, not just laziness. They don't compose with the Streams API or lambda expressions, since none of the standard functional interfaces (Function, Consumer, Supplier) declare a checked exception, so you end up wrapping everything in try-catch or a custom unchecked wrapper just to satisfy the compiler. Effective Java author Joshua Bloch has argued for using checked exceptions sparingly for exactly this reason. My take: use them for recoverable conditions a caller genuinely needs to handle differently, and use unchecked for programming errors nobody should be catching anyway.

Each thread gets its own stack: a LIFO structure of frames, one per method call, holding local variables and partial results. Recurse too deep and you get a StackOverflowError, not an OutOfMemoryError.

The heap is shared across every thread and holds every object created with new. It's divided into generations, Young (an Eden space plus two Survivor spaces) and Old, and running out of room there throws OutOfMemoryError instead. Since Java 8 replaced PermGen with Metaspace, class metadata itself lives off-heap in native memory that grows automatically, which is why "PermGen space" errors mostly vanished from modern stack traces.

A functional interface declares exactly one abstract method (default and static methods don't count against that limit). That single-abstract-method shape is what lets the compiler treat a lambda expression or method reference as an instance of that interface, since there's only one method signature it could possibly implement.

java.util.function ships the ones you'll actually use: Function<T,R>, Predicate<T>, Supplier<T>, Consumer<T>, and BiFunction<T,U,R>. The @FunctionalInterface annotation is optional but worth adding to your own interfaces, since it makes the compiler reject an accidental second abstract method instead of failing quietly much later.

Constructor injection, setter injection, and field injection (the @Autowired-on-a-field pattern most tutorials teach first). Constructor injection is the one worth defaulting to: dependencies become final and immutable, the class is trivially testable without a Spring context at all (just call the constructor), and a missing dependency fails at application startup instead of surfacing as a null pointer three requests into production.

Field injection hides the dependency list, makes the class hard to instantiate without reflection or a container, and is the pattern the Spring team itself now recommends against in its own documentation.

Keep three pointers: previous, current, and next. Walk the list once, redirecting each node's next pointer backward before moving all three pointers forward. No recursion, no extra data structure, O(n) time and O(1) space.

java
class ListNode {
    int val;
    ListNode next;
    ListNode(int val) { this.val = val; }
}

public ListNode reverseList(ListNode head) {
    ListNode prev = null;
    ListNode curr = head;

    while (curr != null) {
        ListNode next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}

Yes, and the argument against it is weaker than it sounds online. Java didn't top Stack Overflow's 2025 Developer Survey technology rankings the way JavaScript, HTML/CSS, and Python did, but it remains one of the most widely used languages in that survey, and it's still the default choice for enterprise backends across banking, insurance, and India's IT-services sector. The demand isn't hype-driven, it's install-base-driven: an enormous amount of existing Java infrastructure isn't going anywhere, and someone has to maintain it.

For most product-based and mid-size company roles, yes, practically speaking. Spring Boot is the default framework for new Java backend work, and the Spring section above covers the questions that actually get asked about it. For large IT-services companies hiring fresh graduates into legacy maintenance projects, Spring Boot comes up less at the fresher stage and more once you're staffed onto a specific project that uses it, though knowing it going in is still a clear advantage in the interview itself.

== compares references for objects, it asks whether two variables point at the same spot in memory, not whether the objects hold the same data. .equals() is a method call, and what it does depends entirely on whether the class overrode it, Object's default .equals() just falls back to ==, so if a class never overrides it, the two behave identically.

A gotcha that catches people off guard: arrays never override equals(), so two arrays with identical contents will fail both == and .equals(), you need Arrays.equals() (or Arrays.deepEquals() for nested arrays) to compare contents. It's a common bug when someone assumes array equality "just works" like a List's does.

final is a modifier. On a variable it means the reference can't be reassigned after initialization, though the object it points to can still change internally, a final List is still fully mutable, you just can't point that variable at a different List later. On a method it blocks overriding, on a class it blocks subclassing.

finally is a block attached to try/catch that always runs, whether the try succeeded, threw, or even returned, and it's the standard place to close resources. finalize() was a method the garbage collector called on an object before reclaiming it, meant as a last chance for cleanup, but it was unreliable, there's no guarantee it ever runs, it can resurrect an object by re-adding a reference to it, and it slows down collection. It was deprecated in Java 9 and is on its way out entirely, replaced by try-with-resources and Cleaner.

throw is a statement that actually raises an exception at a specific line, you use it with one exception instance: throw new IllegalArgumentException("bad input"). throws is part of a method's signature, it declares which checked exceptions the method might let propagate to its caller, it's a compile-time contract, not an action.

A method's throws clause can list several exception types separated by commas, but a single throw statement only ever throws one object, though that object can carry another exception as its cause through constructor chaining, which is how you preserve the original stack trace when you wrap and rethrow.

String is immutable, every concatenation creates a brand new String object under the hood, so building one up with += inside a loop copies everything that came before it on every iteration, which is O(n squared) for n appends. StringBuilder is mutable, backed by a resizable char array, and appending to it is close to O(1) amortized, which is why the compiler itself rewrites simple string concatenation chains into StringBuilder calls behind the scenes.

StringBuffer does the exact same job as StringBuilder except every method is synchronized, so it's safe if multiple threads mutate the same buffer, but that locking is wasted overhead in the far more common single-threaded case. StringBuilder is the default choice today, StringBuffer mostly shows up in code written before Java 5.

The JVM is the actual runtime that executes bytecode, it handles class loading, bytecode verification, JIT compilation, and garbage collection, and it's the piece that makes Java portable, since every platform ships its own JVM but the compiled bytecode stays the same everywhere. The JRE is the JVM plus the core class libraries (java.lang, java.util, and so on) needed to run a compiled program, but it has no compiler in it.

The JDK is the JRE plus the development tools, javac to compile source into bytecode, jar, javadoc, jshell, and a debugger. If you're only running a compiled .jar you technically only need a JRE, but if you're compiling code you need the full JDK, and most people just install the JDK now since standalone JRE-only installers have become rare.

Overloading is having multiple methods with the same name but different parameter lists in the same class, and which one gets called is decided at compile time based on the argument types you pass, that's static binding. Overriding is a subclass providing its own implementation of a method with the exact same signature as its superclass, and which version actually runs is decided at runtime based on the object's real type, that's dynamic binding, and it's the mechanism that makes polymorphism work.

A detail that trips people up: changing only the return type doesn't create an overload, the compiler needs different parameters to distinguish two methods with the same name, and when overriding, the return type has to be the same or a covariant subtype of the parent's, otherwise it's just a compile error, not a new overload.

Medium questions

25

Because HashMap, HashSet, and every hash-based collection use hashCode() to pick a bucket before it ever calls equals() to confirm a match. If two objects are equal by your equals() logic but return different hash codes, the map will happily store both in different buckets, and a lookup for the second one silently returns null even though a "duplicate" already exists somewhere else in the table.

The contract from the Object class is specific: equal objects must produce equal hash codes, though the reverse isn't required. Most interviewers ask a follow-up: what breaks if you only override equals()? The honest answer is that everything compiles fine, and the bug only shows up at runtime, inside a Set that quietly holds duplicates nobody notices until a report comes back wrong.

A String object can't change after creation. Every "modification", concatenation, substring, replace, actually returns a new object. That immutability is what makes the string pool possible: since a literal can never change out from under another reference, the JVM can safely reuse one copy in a shared pool (part of heap memory since Java 7) instead of allocating a fresh object for every identical literal.

This is also why == and .equals() diverge for strings. new String("java") == "java" returns false, because new explicitly bypasses the pool and allocates a separate object, while "java" == "java" (both literals) returns true because they point at the same pooled instance. Interviewers use this to check whether you understand reference equality versus value equality, not just whether you memorized the trick.

ArrayList and HashMap track a modCount field that increments on every structural change. Their iterators capture that count when created and check it on every next() call. If something else, another thread, or your own loop body, modifies the collection outside the iterator, next() throws ConcurrentModificationException. It's a best-effort check, not a guarantee, so don't rely on it for real thread safety.

Fail-safe collections like CopyOnWriteArrayList or ConcurrentHashMap sidestep the exception entirely. CopyOnWriteArrayList's iterator works off a snapshot array taken at creation time, so it never throws, but it also won't see updates made after that snapshot. The fix for the common case, removing items while iterating a plain ArrayList, is simply to call the iterator's own remove() method instead of the list's.

The finally block always runs, even after a return or a thrown exception in the try block, and if finally itself contains a return statement, it wins outright. It silently discards whatever the try block was about to return and swallows any exception that was in flight.

This gotcha shows up almost verbatim across interview loops because it tests whether you actually understand control flow instead of assuming finally is "just cleanup code." The safe pattern is to never put a return, break, or continue inside finally. Try-with-resources, since Java 7, handles the common cleanup case more safely by auto-closing any AutoCloseable resource in reverse declaration order, without needing an explicit finally block at all.

volatile guarantees visibility only: a write to a volatile field is immediately visible to every thread that reads it afterward, because the JVM won't let that value sit cached in a CPU register or get reordered across the write. It guarantees nothing about atomicity, count++ on a volatile int is still a read-modify-write with a race condition, volatile just makes the race visible sooner.

synchronized guarantees both visibility and mutual exclusion. Only one thread can hold the monitor at a time, so compound operations become atomic, at the cost of contention and potential context-switch overhead. The short version interviewers want: reach for volatile on simple status flags and one-way signals, reach for synchronized or an AtomicInteger when the operation is actually a compound read-modify-write.

The naive approach, marking the whole getInstance() method synchronized, works but forces every caller through a lock forever, even though the lock is only needed once. The initialization-on-demand holder idiom avoids that entirely by relying on the JVM's own guarantee that a class isn't initialized until it's first referenced, and that class initialization is already thread-safe.

java
public class ConfigLoader {
    private ConfigLoader() {}

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

    public static ConfigLoader getInstance() {
        return Holder.INSTANCE;
    }
}

Double-checked locking with a volatile field is the other accepted answer, and older codebases use it a lot, but the holder idiom is shorter and doesn't depend on getting the volatile declaration exactly right.

The weak generational hypothesis: empirically, most objects die young, and the ones that survive tend to live a long time. Collectors optimize for that shape instead of scanning the whole heap every cycle.

Minor GCs run frequently against just the small Young generation using a copying collector: live objects get copied from Eden into a Survivor space, and objects that survive enough cycles (tracked by an age counter, the tenuring threshold) get promoted into Old. Old-generation collections happen far less often, since most of what lives there is genuinely long-lived, but they're more expensive when they do run, typically a mark-sweep-compact pass across a much bigger region. Oracle's HotSpot GC Tuning Guide walks through exactly how this plays out for both G1 and ZGC, worth reading once instead of memorizing secondhand summaries.

map() applies a function to each element and returns one result per element, so if that function itself returns a Stream, you end up with a Stream of Streams, nested one level deeper than you wanted. flatMap() applies the function and then flattens the result into a single stream, which is what you need when each input maps to zero, one, or many outputs.

java
List<String> sentences = List.of("java is verbose", "streams help a bit");

List<String> words = sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))
    .collect(Collectors.toList());
// [java, is, verbose, streams, help, a, bit]

Optional exists to signal, through the return type, that a method might not have a value to give back, so callers are forced to handle that case instead of silently risking a NullPointerException three calls later.

The misuse that comes up constantly: using Optional as a method parameter or a field type. It doesn't implement Serializable, it adds an allocation for no real benefit over a plain null check in those positions, and it just moves the same problem one layer over. Calling .get() without checking isPresent() first is the other tell, since that's functionally identical to the null check you were trying to avoid, just wrapped in extra ceremony. My honest opinion: Optional as a parameter type is close to always a mistake, and I don't think there's a defensible case for it in a public API.

They're all meta-annotated with @Component, so Spring's component scan treats them identically for bean registration. The difference is what else rides along. @Repository adds automatic exception translation: Spring wraps persistence-specific exceptions (a raw SQLException, for instance) into its own unchecked DataAccessException hierarchy, so the service layer doesn't need to know which database driver threw what.

@Service and @Controller carry no extra framework behavior by default. They're semantic markers mainly for readability and for AOP pointcuts, transaction advice, for example, is commonly configured to target @Service-annotated classes by convention, not because the framework requires it.

Push opening brackets onto a stack. On a closing bracket, pop and confirm it matches the expected opener; if the stack is empty or the popped value doesn't match, the string isn't balanced. At the end, the string is only balanced if the stack is also empty, a common miss is forgetting that last check and letting an input like "(()" pass as valid.

java
public boolean isBalanced(String s) {
    Deque<Character> stack = new ArrayDeque<>();
    Map<Character, Character> pairs = Map.of(')', '(', ']', '[', '}', '{');

    for (char c : s.toCharArray()) {
        if (pairs.containsValue(c)) {
            stack.push(c);
        } else if (pairs.containsKey(c)) {
            if (stack.isEmpty() || stack.pop() != pairs.get(c)) return false;
        }
    }
    return stack.isEmpty();
}

Know Java 8's stream and lambda syntax cold, since that's still the baseline almost every interviewer assumes. Then layer on records, pattern matching for switch, and virtual threads as the newer material, since Spring Boot 3 requires Java 17 as a minimum and a growing number of interviewers, especially at product companies, are starting to ask about virtual threads specifically. You don't need Java 21 mastery for a fresher role. You do need to not look blank when a senior interviewer brings it up.

Autoboxing an int literal into an Integer doesn't call new Integer(), it calls Integer.valueOf(), and valueOf() returns cached, shared instances for values from -128 to 127 (the IntegerCache class, whose upper bound can be raised with -XX:AutoBoxCacheMax). Two Integer variables boxed from the same value inside that range end up pointing at the same cached object, so == returns true, but the same code with a value outside that range allocates two distinct objects, and == returns false.

This is exactly why production code should never use == on boxed types, use .equals() or unbox to primitives before comparing. The sharper trap is unboxing a null Integer, which throws a silent NullPointerException the moment it's used in ==, an if condition, or arithmetic, with no .intValue() call visible anywhere in the code to hint at what happened.

java
Integer a = 100;
Integer b = 100;
System.out.println(a == b); // true, both reference the cached Integer

Integer x = 200;
Integer y = 200;
System.out.println(x == y); // false, both are new objects outside the cache range

Hashtable is a leftover from Java 1.0, every method is synchronized on the entire table, it rejects null keys and null values outright, and today you'd only find it in an old codebase, nobody reaches for it in new code. HashMap allows one null key and any number of null values, isn't synchronized at all, which makes it fast single-threaded but genuinely unsafe under concurrent writes, in Java 7 concurrent puts during a resize could even corrupt the bucket structure into an infinite loop.

ConcurrentHashMap is the one you actually use in concurrent code. It doesn't lock the whole map, in Java 8 and later it uses CAS operations for the common case of inserting into an empty bin and only synchronizes on the specific bin's head node when there's a collision to resolve, so lock granularity is per-bin, not per-table or per-segment like the older Java 7 design. It also refuses null keys and values, deliberately, because a null return value from get() would be ambiguous with a concurrent map, you couldn't tell if a key is absent or another thread just mapped it to null.

Comparable is implemented by the class itself through compareTo(), it defines one natural ordering, and a class can only have one. Comparator is a separate object that defines an ordering externally through compare(a, b), and you can write as many of them as you need for the same class without ever touching its definition.

Use Comparable when there's one obvious sort order for the domain object, sorting Money by amount for example. Use Comparator when the ordering is a runtime decision, sorting employees by last name on one screen and by hire date on another. Java 8's Comparator.comparing(Employee::getLastName).thenComparing(Employee::getFirstName) is the standard way to build these chains now, it replaced the old anonymous inner class boilerplate almost entirely.

Any resource that implements AutoCloseable can be declared in the try's parentheses, and the compiler generates code that closes it automatically when the block exits, whether that's normal completion or an exception, closing resources in the reverse order they were declared. It replaces the old pattern of a resource declared outside the try and closed by hand in a finally block, which was verbose and easy to get wrong when there were multiple resources.

If the try block throws and then close() also throws while cleaning up, the close() exception isn't lost and it doesn't silently replace the real one either, it gets attached to the primary exception as a suppressed exception, retrievable through getSuppressed(). That's a real improvement over hand-written finally blocks before Java 7, where a throwing finally would just clobber whatever exception the try block had raised.

Generics exist purely at compile time for type checking, the compiler erases type parameters down to their bound (or Object if unbounded) in the actual bytecode. That's why List<String> and List<Integer> are literally the same class at runtime, List.class, and it's why you can't write new T[], can't do instanceof List<String>, and can't overload two methods that only differ by generic type parameter.

Erasure exists because generics were added in Java 5 and had to stay binary-compatible with pre-generics bytecode and libraries. The practical consequence is you can't reliably reflect your way to a generic type's element type at runtime unless it's captured somewhere concrete, which is exactly the trick libraries like Gson use, a subclass anonymous class captures the type parameter in its own superclass signature, where it survives erasure because it's now part of the class hierarchy rather than a local type parameter.

Yes, each enum constant is actually an instance of the enum class, not just a labeled integer, so the enum can have fields, an implicitly private constructor, regular methods, and even an abstract method that each constant implements differently. An Operation enum with ADD, SUBTRACT, and MULTIPLY constants, each supplying its own apply(int, int) body, is the textbook example, it replaces a switch statement with actual polymorphism.

Because the JVM guarantees exactly one instance per constant per classloader, and enums are safe against reflection and serialization attacks that can break a hand-rolled singleton, Joshua Bloch's Effective Java specifically recommends a single-element enum as the cleanest way to implement the Singleton pattern in Java.

reduce() folds a stream down to a single value using an associative accumulator function, it's built for immutable folding, summing numbers or finding a max, and every step produces a new result instead of mutating shared state, which is exactly what makes it safe to run in parallel streams. collect() is for mutable reduction, it takes a Collector, made up of a supplier, accumulator, combiner, and sometimes a finisher, and it builds up a mutable container like a List or a Map, which is far cheaper for something like assembling a list than creating a brand new immutable result on every element.

Rule of thumb: if you're building a collection, use collect() with the built-in Collectors. Using reduce() over a mutable accumulator is a mistake that shows up in early Streams code, it breaks the no-side-effects contract the API expects and can produce genuinely wrong results when the stream is run in parallel.

Propagation controls what happens when a transactional method calls another transactional method. REQUIRED, the default, joins an existing transaction if one's already open, or starts a new one if not. REQUIRES_NEW always suspends any outer transaction and starts a completely separate one, useful for something like audit logging that has to commit even if the surrounding business transaction later rolls back. NESTED uses savepoints so the inner method can roll back on its own without killing the whole outer transaction, but it only works with resource managers that actually support savepoints, like JDBC.

The gotcha that trips up a lot of teams: @Transactional works through a proxy, so calling a @Transactional method from another method inside the same class bypasses the proxy entirely, the annotation is silently ignored and no transaction gets started. The fix is injecting the bean into itself, using AopContext.currentProxy(), or just pulling the method out into a separate bean.

singleton, the default, means one instance shared across the whole container, fine for stateless beans but dangerous if it accidentally holds per-request state. prototype creates a fresh instance every time it's requested, but Spring only manages its creation, not its destruction, so a prototype bean holding resources that need cleanup has to handle that cleanup itself. request and session scopes are web-aware and tie a bean's life to an HTTP request or session, useful for holding per-request data without threading it manually through every method call.

On the lifecycle side, every bean goes through instantiation, dependency injection, then @PostConstruct (or afterPropertiesSet() from InitializingBean) before it's ready to use. On shutdown, @PreDestroy (or destroy() from DisposableBean) runs, but only for singleton beans, since the container has no way of knowing when a prototype-scoped bean actually falls out of use in your own code.

With constructor injection, a true circular dependency, Bean A needs B in its constructor, B needs A in its constructor, fails immediately at startup with a BeanCurrentlyInCreationException, since Spring can't finish building either bean without the other one already existing. With field or setter injection Spring can actually resolve it, it instantiates A with its no-arg constructor, drops an early reference into its internal cache before A's fields are populated, lets B look that reference up while B itself is being constructed, then comes back and finishes wiring A. This works because of a three-level cache Spring keeps specifically for this case inside the bean factory.

That said, most senior engineers treat a resolvable circular dependency as a warning sign, not a feature to rely on. It usually means two beans are too tightly coupled, and the better fix is extracting a third interface or service both can depend on, or adding @Lazy on one of the injection points to break the cycle on purpose rather than leaning on the container's internal plumbing.

Records, finalized in Java 16, are a compact syntax for immutable data carriers. record Point(int x, int y) {} alone generates private final fields, a canonical constructor, accessor methods named after the fields (x(), not getX()), and equals(), hashCode(), and toString() based on every component, none of which you write by hand.

You can still add a compact constructor to validate or transform arguments before the implicit field assignment, add extra methods, and implement interfaces, but you can't add extra instance fields beyond the declared components, can't extend another class since records implicitly extend java.lang.Record, and can't make the fields anything but final. They're a strong fit for DTOs and API response objects where equality by value is the whole point, but a poor fit for JPA entities, which need a no-arg constructor and mutable fields records don't allow.

java
record Point(int x, int y) {
    Point {
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException("coordinates must be non-negative");
        }
    }
}

Sealed classes and interfaces, standard since Java 17, let you restrict exactly which classes are allowed to extend or implement a type, using sealed combined with a permits clause naming the allowed subtypes. Every permitted subtype has to be declared final, sealed itself to keep extending the restriction further down, or non-sealed to deliberately reopen it to unrestricted extension.

The real payoff shows up with pattern matching in switch expressions. Because the compiler knows the complete, closed list of subtypes, a switch over a sealed type with no default branch is checked for exhaustiveness at compile time, so if someone adds a new permitted subtype later and forgets to update a switch handling it, the build fails instead of silently falling through at runtime, which plain inheritance and interfaces could never guarantee.

java
sealed interface Shape permits Circle, Square, Triangle {}

final class Circle implements Shape {}
final class Square implements Shape {}
non-sealed class Triangle implements Shape {}

Declare the class final so nobody can subclass it into a mutable variant, make every field private final, skip setters entirely, and set everything through the constructor. That part is easy and most developers get it right.

What people actually miss is a mutable field hiding inside an otherwise immutable class, a List, a Date, or an array. If you just store the reference you were handed, the caller who passed it in can mutate it after construction and silently break your object's invariants, even though every field is marked final, since final only pins the reference itself, not whatever the reference points to. You have to defensively copy on the way in through the constructor and on the way out through the getter, or for collections specifically, wrap the copy in Collections.unmodifiableList(), or just return List.copyOf(), which handles both problems in one call.

Hard questions

12

The key's hashCode() runs first, then HashMap applies its own spreading function, XORing the hash with a right-shifted copy of itself, to reduce collisions from keys with similar low-order bits. That hash picks a bucket, an index into the internal array, via hash & (capacity - 1). Each bucket holds a linked list of nodes by default.

If a bucket's list grows past 8 entries and the table has at least 64 buckets, Java 8+ converts that bucket into a red-black tree, so worst-case lookup drops from O(n) to O(log n). The default load factor is 0.75: the map resizes, doubles capacity and rehashes everything, once it's 75% full. Most candidates know "HashMap uses hashing." Fewer can explain why treeification exists or what a resize costs mid-request.

java
// simplified version of HashMap's internal hash spreading, java.util.HashMap
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

A platform thread maps one-to-one to an OS thread, which is expensive enough (a couple of MB of stack space by default, plus OS scheduling overhead) that a service handling tens of thousands of concurrent blocking calls the old way runs out of threads before it runs out of anything else.

A virtual thread, made permanent in Java 21 via JEP 444, is a lightweight thread the JVM manages instead of the OS. The JVM multiplexes millions of virtual threads onto a small pool of OS threads, unmounting one from its carrier whenever it blocks on I/O instead of leaving an OS thread idle. You write the same simple blocking-style code you always have, no reactive rewrite, and still scale to a large number of concurrent requests. The catch senior interviewers push on: virtual threads don't help CPU-bound work, and heavy synchronized blocks or native pinning can stop one from unmounting, quietly reintroducing the scaling ceiling you were trying to escape.

It depends entirely on the RejectedExecutionHandler configured on the pool, and plenty of candidates don't know that's even configurable. The default, AbortPolicy, throws a RejectedExecutionException immediately. CallerRunsPolicy pushes the task back to run on the calling thread, a built-in backpressure mechanism that slows down whatever's submitting work. DiscardPolicy silently drops the task. DiscardOldestPolicy evicts the oldest queued task to make room for the new one.

Production services almost never want the default silently throwing under load. CallerRunsPolicy is a common, deliberate choice specifically because it applies backpressure instead of failing outright, and it's the answer that shows an interviewer you've actually tuned a pool under real load rather than just instantiated one.

I'd default to G1 unless there's an actual measured pause-time problem, and I think a lot of teams reach for ZGC prematurely because the name sounds more impressive. G1 (the default collector since Java 9) divides the heap into regions and prioritizes collecting the regions with the most garbage first, targeting a pause-time goal, typically in the tens of milliseconds.

ZGC targets sub-millisecond max pauses even on multi-terabyte heaps using colored pointers and load barriers, but that comes at a real CPU cost. Java 21 added Generational ZGC (JEP 439) as an opt-in mode because the original single-generation ZGC didn't exploit the same young-object-death pattern G1 already did, and it paid for that in extra CPU overhead on allocation-heavy workloads. Java 23 made generational mode the default, and Java 25 removed the non-generational mode entirely. If your service is an ordinary CRUD API and G1's 50ms pauses have never once shown up in a complaint, switching to ZGC is solving a problem you don't have yet.

@EnableAutoConfiguration, pulled in automatically by @SpringBootApplication, triggers Boot to read a list of autoconfiguration classes and each one is gated behind conditional annotations, most commonly @ConditionalOnClass and @ConditionalOnMissingBean. That's the whole mechanism: Boot only wires a bean if the relevant library is actually on the classpath, and only if you haven't already defined your own bean of that type.

It's why adding spring-boot-starter-data-jpa auto-configures a DataSource and an EntityManagerFactory, but only until you declare your own DataSource bean, at which point Boot backs off and uses yours instead. Spring's own documentation on auto-configuration covers this conditional chain in more depth than interviewers expect you to recite, but the underlying idea is what gets tested.

In Java 7, ConcurrentHashMap split the table into a fixed number of Segments, 16 by default, each essentially its own small lock, so writes to different segments could run in parallel but two writes landing in the same segment still blocked each other, while reads were mostly lock-free through volatile access. In Java 8 that design was scrapped entirely, the map is now backed by a single Node array, the same shape as a plain HashMap, and thread safety instead comes from CAS operations for the common case of inserting into an empty bin, combined with a synchronized block scoped to just that bin's head node when there's a collision to resolve, so lock granularity became per-bin rather than per-segment, which scales considerably better under real contention.

Java 8 also added treeification, once a single bin's linked list grows past 8 entries, and the table has at least 64 buckets, that bin converts into a red-black tree so worst-case lookup inside the bin drops from O(n) to O(log n), a defense specifically against hash-flooding attacks where an attacker crafts keys engineered to collide into the same bucket.

size() and mappingCount() don't lock anything at all, they sum a set of per-thread counter cells similar to LongAdder, and the result can be momentarily inconsistent under concurrent modification, which is an accepted tradeoff, an exact point-in-time size was never part of the contract for a genuinely concurrent collection.

GC only reclaims objects that are unreachable, so a Java memory leak is really an object graph that's technically still reachable but nobody actually needs anymore. The classic case is a static collection, a Map or List that things keep getting added to but nothing ever removes from, since static fields are GC roots and live for the entire lifetime of the classloader, this shows up constantly in naive in-memory caches with no eviction policy.

ThreadLocal is another repeat offender, especially in thread-pooled environments like servlet containers. If you set a value with ThreadLocal and never call remove(), that value stays attached to the pooled thread, which never actually dies, long after the request that created it finished, and in application-server contexts this can pin an entire webapp classloader in memory across a hot redeploy, because the ThreadLocalMap entry still references a class loaded by the old classloader. Listener and callback registration causes the same shape of problem, registering a listener on a long-lived object without ever unregistering it keeps that listener, and everything it closes over, alive indefinitely.

None of these get fixed with a GC tuning flag, they're fixed with discipline, bounded caches or a WeakHashMap where appropriate, always pairing ThreadLocal.set() with a try/finally that calls remove(), and unregistering listeners on teardown. In a real incident, a heap dump analyzed in something like Eclipse MAT, looking at dominator trees and retained size per object, is how you actually figure out which of these it is.

The bootstrap classloader, native code that loads the JDK's own core classes, sits at the top, the platform classloader below it, and the application classloader that loads your own classpath below that. Any custom classloader (application servers like Tomcat give each deployed webapp its own) delegates upward first by default, a class load request travels all the way up to bootstrap and each loader gives its parent the first chance before trying to load the class itself. That's the parent delegation model, and its whole purpose is guaranteeing that a core class like java.lang.String can never be shadowed by some rogue jar sitting on the classpath.

The practical consequence is that class identity in Java isn't just the fully qualified name, it's the pair of classloader plus class name. If the same class gets loaded twice by two different classloaders, common in app servers running multiple webapps, in OSGi, or in plugin systems, you can get a ClassCastException that literally says something like com.foo.Bar cannot be cast to com.foo.Bar, which looks like a joke until you realize they're two distinct Class objects loaded by two different loaders.

This is also the exact mechanism behind classloader leaks after a hot redeploy. The old webapp's classloader should become garbage once the webapp is undeployed, but if anything outside it still points to an object that classloader created, a ThreadLocal on a pooled thread, a JDBC driver still registered in DriverManager, a static field in a shared library, the entire old classloader, and every class and static field it ever loaded, stays pinned in memory, which is exactly why classloader leaks show up as slow, steady memory growth across repeated redeploys in app servers.

Start by capturing a thread dump, jstack on the pid, or kill -3 which prints straight to stdout, or jcmd's Thread.print, and take it two or three times a few seconds apart so you can tell a genuine deadlock, identical stacks and states every time, from threads that are just slow but still making progress. The JVM actually helps here, if it detects a classic monitor deadlock, thread A holding lock 1 and waiting on lock 2 while thread B holds lock 2 and waits on lock 1, it prints a "Found one Java-level deadlock" section right in the dump, naming the exact threads and monitors involved.

Where it gets harder is a deadlock that isn't that clean two-thread cycle, thread pool exhaustion is the common one, every thread in a fixed pool ends up blocked waiting on a Future that can only be completed by another task that's still sitting in that same pool's queue, waiting for a free thread that will never come. The JVM won't flag that as a deadlock at all, every thread just looks BLOCKED or WAITING on a queue with nothing moving, and you find it by reading what each individual thread's stack is actually waiting on and tracing it back to the same exhausted executor.

Once you've found the cycle, the real fix is almost always a consistent lock ordering across the whole codebase, or narrowing the critical section so one of the locks isn't needed at all. In production you usually mitigate immediately with a restart, then follow up with a lock-ordering audit or switch bare synchronized blocks to tryLock() with a timeout so the app degrades instead of hanging the next time it happens.

If any stage in a CompletableFuture chain throws, that exception doesn't flow into the next thenApply or thenCompose the way a normal thrown exception would, instead the future completes exceptionally and every dependent stage after it is skipped entirely until the chain hits a stage actually built to handle failure, exceptionally(), handle(), or whenComplete(). handle() and whenComplete() both run no matter what happened upstream, success or failure, but handle() lets you inspect the outcome and return a normal recovered value, while whenComplete() is a pure side-effecting callback, whatever it returns doesn't replace the exception, the original result or exception keeps propagating right past it.

A common bug is reaching for thenApply when thenCompose is what's needed, if the function you pass itself returns a CompletableFuture, thenApply hands you back a CompletableFuture wrapping another CompletableFuture, which is almost never useful, thenCompose flattens that, it's the exact same map versus flatMap distinction that shows up in Streams and Optional.

And when you finally call get() or join() on the completed future, the original exception gets wrapped, get() throws a checked ExecutionException around the real cause, join() throws an unchecked CompletionException instead, and in both cases you need getCause() to see the actual root exception. Logging code that just calls e.getMessage() on the wrapper prints a useless generic message and buries the exception you actually care about.

Escape analysis is a JIT optimization performed by C2, the server compiler, once a method gets hot enough to be compiled, where the compiler works out whether an object created inside a method can ever be seen outside that method's scope, escaping by being returned, stored into a field, handed to another method that might stash it somewhere, or touched by another thread. If the compiler can prove an object never escapes, it doesn't have to allocate it on the heap at all, it can perform scalar replacement, breaking the object apart into its individual primitive fields and keeping those as plain local variables or registers instead.

That means no allocation and no GC pressure from that object whatsoever, which is why small helper or wrapper objects created and thrown away inside a tight, hot loop often show near-zero allocation in a profiler once the JVM has warmed up, even though the code looks allocation-heavy on paper.

The catch is that this only kicks in after the method has actually been JIT compiled by C2. Code measured cold, a short-lived CLI tool that never leaves the interpreter or C1, or a JMH benchmark without proper warmup iterations, shows the full allocation cost, which is exactly why microbenchmarking "is creating this object here expensive" with a stopwatch around a plain for loop gives you a wrong answer, and why real JVM benchmarking needs JMH with real warmup phases before the numbers mean anything.

This moved. Before Java 7, the string pool lived in PermGen, a fixed-size, GC-unfriendly region also used for class metadata, and interning large numbers of unique strings there was a genuine cause of OutOfMemoryError: PermGen space in production. From Java 7 onward the pool was relocated into the main heap, so interned strings became ordinary heap objects, collected normally once nothing references them, and PermGen itself was removed entirely in Java 8, replaced by Metaspace, which lives in native memory outside the heap and grows dynamically, bounded only if you set -XX:MaxMetaspaceSize, closing off a whole class of PermGen exhaustion bugs tied to dynamically generated classes from heavy reflection or proxy-based frameworks.

Calling String.intern() looks the string up in the pool, if an equal string is already there it returns that canonical pooled reference, if not it adds this one and returns it. The real use case is parsing huge volumes of text with a lot of duplicate content, log parsing or CSV ingestion, where collapsing duplicate strings down to shared references genuinely saves memory. It's not free though, every call does a pool lookup, and interning strings that don't actually have meaningful duplication in your dataset just adds overhead for no benefit, so it should be a deliberate decision on a measured hot path, not something applied out of habit.

What we've seen in Java mock interview sessions

Across Java-focused mock interviews run through LastRoundAI, the pattern that trips up candidates most isn't a gap in language knowledge. It's candidates who define volatile and synchronized correctly in isolation, then freeze the moment a follow-up connects the concept to a symptom: "your service's response times spiked under load, walk through what you'd check first." Naming a term is not the same skill as reasoning backward from a symptom to a cause, and interviewers notice the difference immediately.

The second pattern shows up in the JVM and GC section: candidates who've memorized that "G1 is the default collector" but can't explain why that matters for the service they're being hired to run. At the senior level, that question isn't a vocabulary check. It's asking whether you've reasoned through a real pause-time or memory problem, not just read about one.

Fresher versus experienced: same list, different weighting

Prepping for a campus or 0-2 year role, the kind TCS, Infosys, or Wipro run at scale? Put most of your remaining time into OOP, Collections, Exceptions, and Java 8+ basics, plus the two coding problems. Prepping for a 3+ year role instead? Weight your time toward Concurrency, JVM/GC, and Spring Boot, since that's where experienced loops actually separate candidates.

Leave a Reply

Your email address will not be published. Required fields are marked *