60+ Java Core Interview Questions 2025: OOP, Collections, Streams & Concurrency

·31 min read
javaoopcollectionsconcurrencybackendinterview-preparation

Every Java interview eventually comes back to fundamentals. You might discuss Spring Boot for thirty minutes, then get asked "How does HashMap handle collisions?" or "Explain the difference between synchronized and ReentrantLock." These aren't trick questions—they reveal whether you truly understand the language you've been using.

This guide covers Java core concepts at interview depth. Not syntax basics you can look up, but the underlying mechanisms interviewers probe to gauge your expertise.

Table of Contents

  1. OOP Fundamentals Questions
  2. Abstract Classes and Interfaces Questions
  3. SOLID Principles Questions
  4. Collections Framework Questions
  5. Map Implementations Questions
  6. Generics Questions
  7. Streams and Functional Programming Questions
  8. Concurrency Fundamentals Questions
  9. Advanced Concurrency Questions
  10. Memory Management Questions
  11. Modern Java Features Questions
  12. Common Java Interview Questions

OOP Fundamentals Questions

Object-oriented programming isn't just about classes and objects. Interviewers want to know if you understand why OOP principles exist and when to apply them.

What are the four pillars of OOP and why do they matter?

The four pillars—encapsulation, inheritance, polymorphism, and abstraction—form the foundation of object-oriented design. Understanding them goes beyond definitions to knowing when and why to apply each principle.

Encapsulation protects internal state and exposes behavior through controlled interfaces. It's not just about private fields—it's about guaranteeing invariants. A BankAccount class with encapsulation ensures balance can never go negative through controlled mutations.

// Bad: Exposed internal state
public class BankAccount {
    public double balance;  // Anyone can set this to anything
}
 
// Good: Encapsulated state with controlled access
public class BankAccount {
    private double balance;
 
    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
        this.balance += amount;
    }
 
    public void withdraw(double amount) {
        if (amount > balance) throw new InsufficientFundsException();
        this.balance -= amount;
    }
 
    public double getBalance() {
        return balance;  // Read-only access
    }
}

What is inheritance and when should you use it versus composition?

Inheritance allows classes to share behavior through hierarchies. A subclass inherits all non-private members of its parent and can override methods to provide specialized behavior.

However, inheritance creates tight coupling—changes to the parent affect all children. Prefer composition (having an instance of another class) when you want to reuse behavior without the "is-a" relationship. Inheritance makes sense for true hierarchies (Dog is an Animal) and when you need polymorphism.

public abstract class Animal {
    protected String name;
 
    public Animal(String name) {
        this.name = name;
    }
 
    public abstract void makeSound();  // Subclasses must implement
 
    public void sleep() {  // Shared implementation
        System.out.println(name + " is sleeping");
    }
}
 
public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }
 
    @Override
    public void makeSound() {
        System.out.println(name + " barks");
    }
}

How does polymorphism work in Java?

Polymorphism lets you treat different types through a common interface. The actual method executed is determined at runtime based on the object's concrete type, not the reference type. This enables flexible, extensible code.

public void feedAnimals(List<Animal> animals) {
    for (Animal animal : animals) {
        animal.makeSound();  // Each animal's specific implementation runs
    }
}
 
// Runtime polymorphism - actual method determined at runtime
List<Animal> animals = List.of(new Dog("Rex"), new Cat("Whiskers"));
feedAnimals(animals);  // Rex barks, Whiskers meows

What is abstraction and how do interfaces support it?

Abstraction hides complexity behind simple interfaces. Users of an abstraction don't need to know implementation details—they only interact with the defined contract. This enables swapping implementations without changing client code.

// The user of this interface doesn't need to know HOW payments work
public interface PaymentProcessor {
    PaymentResult process(Payment payment);
}
 
// Could be Stripe, PayPal, or a mock for testing
// The abstraction hides the implementation details

Abstract Classes and Interfaces Questions

The distinction between abstract classes and interfaces comes up constantly in Java interviews.

What is the difference between abstract class and interface in Java?

Abstract classes and interfaces serve different purposes, though they can overlap in functionality. The choice depends on what you're modeling and how implementations will be related.

Abstract classes can hold state (instance fields), have constructors for initialization, and provide both abstract and concrete methods. Use them when implementations share common state or initialization logic.

Interfaces define contracts that unrelated classes can fulfill. Since Java 8, they can include default and static methods, but still cannot hold instance state.

FeatureAbstract ClassInterface
Instance fieldsYesNo (only constants)
ConstructorsYesNo
Method implementationAbstract and concreteAbstract, default, static
Multiple inheritanceNo (single extends)Yes (multiple implements)
Access modifiersAnyPublic (implicitly)
// Abstract class: Shared state and implementation
public abstract class DatabaseConnection {
    protected String connectionString;  // State
    protected int timeout;
 
    public DatabaseConnection(String connectionString) {
        this.connectionString = connectionString;
        this.timeout = 30;
    }
 
    public abstract void connect();  // Subclasses implement
    public abstract void disconnect();
 
    public void setTimeout(int seconds) {  // Shared implementation
        this.timeout = seconds;
    }
}
 
// Interface: Capability contract
public interface Cacheable {
    String getCacheKey();
    Duration getTtl();
 
    default boolean isExpired(Instant cachedAt) {  // Default implementation
        return Instant.now().isAfter(cachedAt.plus(getTtl()));
    }
}
 
// A class can extend one abstract class AND implement multiple interfaces
public class PostgresConnection extends DatabaseConnection implements Cacheable, AutoCloseable {
    // ...
}

When would you choose an abstract class over an interface?

Choose an abstract class when you need shared state (instance fields) across implementations, constructor logic for initialization, non-public methods, or a clear hierarchical relationship between types.

Choose an interface when you need multiple inheritance of type, a contract that unrelated classes can fulfill, or maximum flexibility for implementers. Modern Java interfaces with default methods blur this line, but the state restriction remains the key differentiator.


SOLID Principles Questions

SOLID principles guide good object-oriented design. Interviewers often ask for examples of each principle.

What is the Single Responsibility Principle and why does it matter?

The Single Responsibility Principle states that a class should have only one reason to change. This doesn't mean one method—it means one cohesive responsibility. When a class handles multiple concerns, changes to one concern risk breaking others.

// Bad: Multiple responsibilities
public class UserService {
    public void createUser(User user) { /* ... */ }
    public void sendWelcomeEmail(User user) { /* ... */ }  // Email is separate concern
    public String generateReport(List<User> users) { /* ... */ }  // Reporting is separate
}
 
// Good: Single responsibility each
public class UserService {
    public void createUser(User user) { /* ... */ }
}
 
public class EmailService {
    public void sendWelcomeEmail(User user) { /* ... */ }
}
 
public class UserReportGenerator {
    public String generate(List<User> users) { /* ... */ }
}

What does Open/Closed Principle mean?

The Open/Closed Principle means classes should be open for extension but closed for modification. You should be able to add new behavior without changing existing code. This is typically achieved through abstractions and polymorphism.

// Bad: Must modify class to add new payment types
public class PaymentProcessor {
    public void process(Payment payment) {
        if (payment.getType().equals("CREDIT_CARD")) {
            // process credit card
        } else if (payment.getType().equals("PAYPAL")) {
            // process paypal
        }
        // Adding new type requires modifying this class
    }
}
 
// Good: Extend without modifying
public interface PaymentHandler {
    boolean supports(PaymentType type);
    void process(Payment payment);
}
 
public class PaymentProcessor {
    private List<PaymentHandler> handlers;
 
    public void process(Payment payment) {
        handlers.stream()
            .filter(h -> h.supports(payment.getType()))
            .findFirst()
            .orElseThrow()
            .process(payment);
    }
}
// New payment types = new handler classes, no modification to PaymentProcessor

What is Liskov Substitution Principle?

Liskov Substitution Principle states that subtypes must be substitutable for their base types without breaking program correctness. If code works with a base type, it should work correctly with any subtype. Violations create subtle bugs.

// Violation: Square can't properly substitute Rectangle
public class Rectangle {
    protected int width, height;
 
    public void setWidth(int w) { width = w; }
    public void setHeight(int h) { height = h; }
    public int getArea() { return width * height; }
}
 
public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        width = w;
        height = w;  // Breaks expectations!
    }
}
 
// Code expecting Rectangle behavior breaks with Square
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
assert r.getArea() == 50;  // Fails! Area is 100

What is Interface Segregation Principle?

Interface Segregation Principle states that clients shouldn't depend on methods they don't use. Large "fat" interfaces force implementers to provide stub methods for irrelevant operations. Break interfaces into smaller, focused contracts.

// Bad: Fat interface
public interface Worker {
    void work();
    void eat();
    void sleep();
}
 
public class Robot implements Worker {
    public void work() { /* ... */ }
    public void eat() { /* Robots don't eat! */ }
    public void sleep() { /* Robots don't sleep! */ }
}
 
// Good: Segregated interfaces
public interface Workable { void work(); }
public interface Eatable { void eat(); }
public interface Sleepable { void sleep(); }
 
public class Robot implements Workable {
    public void work() { /* ... */ }
}

What is Dependency Inversion Principle?

Dependency Inversion Principle states that high-level modules should depend on abstractions, not concrete implementations. This decouples components and enables flexibility, testing, and swapping implementations.

// Bad: High-level module depends on low-level module
public class OrderService {
    private MySqlOrderRepository repository = new MySqlOrderRepository();
}
 
// Good: Both depend on abstraction
public class OrderService {
    private final OrderRepository repository;  // Interface
 
    public OrderService(OrderRepository repository) {
        this.repository = repository;  // Injected
    }
}

Collections Framework Questions

The Collections Framework is a goldmine for interview questions. Understanding the internals separates senior developers from juniors.

How is the Java Collection hierarchy organized?

The Collections Framework provides a unified architecture for storing and manipulating groups of objects. Understanding the hierarchy helps you choose the right implementation for your use case.

flowchart TB
    subgraph collections["Collection Hierarchy"]
        IT["Iterable"]
        CO["Collection"]
        LIST["List"]
        SET["Set"]
        QUEUE["Queue"]
        AL["ArrayList"]
        LL["LinkedList"]
        HS["HashSet"]
        PQ["PriorityQueue"]
        TS["TreeSet"]
 
        IT --> CO
        CO --> LIST
        CO --> SET
        CO --> QUEUE
        LIST --> AL
        LIST --> LL
        SET --> HS
        SET --> TS
        QUEUE --> PQ
    end
 
    subgraph maps["Map (separate hierarchy)"]
        MAP["Map"]
        HM["HashMap"]
        TM["TreeMap"]
        LHM["LinkedHashMap"]
 
        MAP --> HM
        MAP --> TM
        HM --> LHM
    end

What is the difference between ArrayList and LinkedList?

ArrayList and LinkedList both implement List but have fundamentally different internal structures that affect performance characteristics.

ArrayList uses a dynamic array internally. It provides O(1) random access since elements are in contiguous memory. However, insertion or deletion in the middle requires shifting all subsequent elements, making it O(n). When the array fills, it grows by 50%.

LinkedList uses doubly-linked nodes where each element points to its predecessor and successor. Random access is O(n) because you must traverse from the head. However, insertion and deletion are O(1) if you already have a reference to the node.

ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("a");     // O(1) amortized - may trigger resize
arrayList.get(0);       // O(1) - direct array access
arrayList.remove(0);    // O(n) - shifts all subsequent elements
arrayList.contains("a"); // O(n) - linear search
 
LinkedList<String> linkedList = new LinkedList<>();
linkedList.addFirst("a");  // O(1)
linkedList.addLast("b");   // O(1)
linkedList.get(50);        // O(n) - must traverse nodes

When would you use LinkedList over ArrayList?

Almost never in practice. ArrayList's cache locality (contiguous memory) outweighs LinkedList's theoretical O(1) insertions. Modern CPUs are optimized for sequential memory access, making ArrayList faster even for operations where LinkedList has better Big-O complexity.

LinkedList wins only when you frequently insert or remove at known positions during iteration, need a Deque (though ArrayDeque is often better), or memory is extremely constrained and elements are large.

What is the difference between HashSet, LinkedHashSet, and TreeSet?

All three implement Set (unique elements) but differ in ordering and performance characteristics.

HashSet uses a HashMap internally with elements as keys. It provides O(1) average-case performance but doesn't guarantee iteration order.

LinkedHashSet maintains insertion order using a doubly-linked list through the entries. Performance is slightly slower than HashSet but order is predictable.

TreeSet uses a Red-Black tree (TreeMap internally) to maintain sorted order. All operations are O(log n), but you get sorted iteration and range queries.

HashSet<String> hashSet = new HashSet<>();
hashSet.add("c"); hashSet.add("a"); hashSet.add("b");
// Iteration order might be: b, c, a (based on hash codes)
 
LinkedHashSet<String> linkedHashSet = new LinkedHashSet<>();
linkedHashSet.add("c"); linkedHashSet.add("a"); linkedHashSet.add("b");
// Iteration order: c, a, b (insertion order)
 
TreeSet<String> treeSet = new TreeSet<>();
treeSet.add("c"); treeSet.add("a"); treeSet.add("b");
// Iteration order: a, b, c (natural ordering)
treeSet.first();        // "a" - O(log n)
treeSet.headSet("b");   // elements less than "b"

Map Implementations Questions

Map implementations are essential for Java developers. Understanding HashMap internals is particularly important.

How does HashMap work internally?

HashMap is the workhorse map implementation. Understanding its internals helps you use it correctly and debug performance issues.

Internally, HashMap uses an array of buckets (default size 16). When you put a key-value pair, it calculates the key's hashCode(), computes the bucket index using hash & (n-1), and stores an Entry node. If multiple keys map to the same bucket (collision), they form a linked list within that bucket. Since Java 8, when a bucket exceeds 8 entries, it converts to a Red-Black tree for O(log n) lookup.

The load factor (default 0.75) determines when to resize. When 75% of buckets are used, HashMap doubles its capacity and rehashes all entries.

HashMap<String, Integer> map = new HashMap<>();
 
// How put() works:
// 1. Calculate hashCode() of key
// 2. Compute bucket index: hash & (n - 1) where n = array length
// 3. If bucket empty, insert new Node
// 4. If bucket occupied, traverse list/tree
//    - If key exists (equals() returns true), update value
//    - Otherwise, append new Node
// 5. If size exceeds threshold, resize (double capacity, rehash all entries)

Why must you override both hashCode() and equals() for HashMap keys?

HashMap uses hashCode() to find the bucket and equals() to find the exact key within the bucket. The contract states that if two objects are equals(), they must have the same hashCode(). Violating this contract causes keys to be lost—equal objects could end up in different buckets.

class Person {
    String name;
    int age;
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Person)) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(name, age);  // Must use same fields as equals()
    }
}

What happens when HashMap has many hash collisions?

With a poor hashCode() implementation (like returning a constant), all keys map to the same bucket. This degrades HashMap from O(1) to O(n) performance—effectively a linked list traversal for every operation.

// Hash collision example
class BadKey {
    @Override
    public int hashCode() { return 1; }  // All keys go to same bucket!
}
// With proper hashCode(): O(1) average
// With constant hashCode(): O(n) - degrades to linked list traversal

Since Java 8, buckets with more than 8 entries convert to trees, limiting worst-case to O(log n), but good hash distribution remains essential.

What is the difference between HashMap and TreeMap?

HashMap provides O(1) average-case operations but no ordering guarantees. TreeMap uses a Red-Black tree to maintain keys in sorted order, with O(log n) operations.

Choose TreeMap when you need sorted iteration, range queries (subMap, headMap, tailMap), or navigation methods (firstKey, lastKey, floorKey, ceilingKey).

TreeMap<String, Integer> map = new TreeMap<>();
map.put("c", 3);
map.put("a", 1);
map.put("b", 2);
 
map.keySet();        // [a, b, c] - sorted
map.firstKey();      // "a"
map.lastKey();       // "c"
map.floorKey("bb");  // "b" - greatest key <= "bb"
map.ceilingKey("bb"); // "c" - smallest key >= "bb"

How does ConcurrentHashMap differ from HashMap?

ConcurrentHashMap is thread-safe without locking the entire map. It uses fine-grained locking (or lock-free algorithms in modern versions) to allow concurrent reads and writes to different segments.

Unlike synchronizing a regular HashMap, ConcurrentHashMap provides better concurrency and atomic compound operations like computeIfAbsent and merge.

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
 
// Thread-safe operations
map.put("key", 1);
map.computeIfAbsent("key", k -> expensiveComputation(k));  // Atomic
map.merge("key", 1, Integer::sum);  // Atomic increment
 
// Iteration is weakly consistent - may not reflect concurrent updates

Generics Questions

Generics enable type-safe collections and methods. Interview questions probe beyond basic usage into bounded types and wildcards.

What are generics and why are they important?

Generics provide compile-time type safety for collections and methods. Before generics, collections stored Objects, requiring casts and risking ClassCastException at runtime. With generics, the compiler catches type errors.

// Generic class
public class Box<T> {
    private T content;
 
    public void set(T content) { this.content = content; }
    public T get() { return content; }
}
 
Box<String> stringBox = new Box<>();
stringBox.set("hello");
String s = stringBox.get();  // No cast needed
 
// Generic method
public <T> T firstOrNull(List<T> list) {
    return list.isEmpty() ? null : list.get(0);
}

What are bounded type parameters?

Bounded type parameters restrict which types can be used as type arguments. Upper bounds (extends) require the type to be a subtype of a specified class or interface.

// Upper bound: T must be Number or subclass
public <T extends Number> double sum(List<T> numbers) {
    return numbers.stream()
        .mapToDouble(Number::doubleValue)
        .sum();
}
 
sum(List.of(1, 2, 3));        // Works: Integer extends Number
sum(List.of(1.5, 2.5));       // Works: Double extends Number
sum(List.of("a", "b"));       // Compile error: String doesn't extend Number
 
// Multiple bounds: T must be both Comparable AND Serializable
public <T extends Comparable<T> & Serializable> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}

What are wildcards and what does PECS mean?

Wildcards represent unknown types in generic code. The mnemonic "PECS" (Producer Extends, Consumer Super) helps remember when to use each.

Upper bounded wildcard (? extends T) is for producers—you read from them. You can read elements as type T but can't add (except null) because you don't know the exact type.

Lower bounded wildcard (? super T) is for consumers—you write to them. You can add type T (and subtypes) but can only read as Object.

// Upper bounded wildcard: ? extends T (producer - read from)
public double sumAll(List<? extends Number> numbers) {
    double sum = 0;
    for (Number n : numbers) {
        sum += n.doubleValue();
    }
    // numbers.add(1);  // Compile error - can't add
    return sum;
}
 
// Lower bounded wildcard: ? super T (consumer - write to)
public void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
    Object obj = list.get(0);  // Can only read as Object
}
 
// Unbounded wildcard: ? (read-only, type doesn't matter)
public void printAll(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

What is type erasure in Java generics?

Java generics are compile-time only. At runtime, generic type information is erased—List<String> and List<Integer> both become raw List. This was done for backward compatibility with pre-generics code.

List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
 
// At runtime, both are just ArrayList
strings.getClass() == integers.getClass();  // true
 
// This is why you can't do:
// if (obj instanceof List<String>)  // Compile error
// new T[]  // Compile error

Streams and Functional Programming Questions

Streams provide a declarative way to process collections. They're not always faster, but they're often more readable.

What are Java Streams and how do they work?

Streams are a functional-style API for processing sequences of elements. A stream pipeline consists of a source, zero or more intermediate operations (lazy), and a terminal operation (triggers execution).

Intermediate operations transform the stream but don't execute until a terminal operation is invoked. This laziness enables optimizations like short-circuiting and loop fusion.

List<Person> people = getPeople();
 
// Stream pipeline: source -> intermediate ops -> terminal op
List<String> adultNames = people.stream()         // Source
    .filter(p -> p.getAge() >= 18)                // Intermediate (lazy)
    .map(Person::getName)                          // Intermediate (lazy)
    .sorted()                                      // Intermediate (lazy)
    .distinct()                                    // Intermediate (lazy)
    .collect(Collectors.toList());                // Terminal (triggers execution)

What are the common stream intermediate operations?

Intermediate operations return a new stream and are lazy—nothing happens until a terminal operation is called.

stream.filter(predicate)      // Keep elements matching predicate
stream.map(function)          // Transform elements
stream.flatMap(function)      // Transform to stream, then flatten
stream.sorted()               // Natural order
stream.sorted(comparator)     // Custom order
stream.distinct()             // Remove duplicates (uses equals())
stream.limit(n)               // Take first n elements
stream.skip(n)                // Skip first n elements
stream.peek(consumer)         // Debug - perform action without modifying

What are the common stream terminal operations?

Terminal operations produce a result or side effect and trigger stream processing.

stream.collect(collector)     // Accumulate into collection
stream.forEach(consumer)      // Perform action on each
stream.reduce(identity, op)   // Combine all elements
stream.count()                // Count elements
stream.anyMatch(predicate)    // True if any match
stream.allMatch(predicate)    // True if all match
stream.noneMatch(predicate)   // True if none match
stream.findFirst()            // First element (Optional)
stream.findAny()              // Any element (Optional) - better for parallel

What are the most useful Collectors?

Collectors accumulate stream elements into various data structures. The Collectors utility class provides common implementations.

// To List
List<String> list = stream.collect(Collectors.toList());
 
// To Map
Map<Long, Person> byId = people.stream()
    .collect(Collectors.toMap(Person::getId, p -> p));
 
// Grouping
Map<Department, List<Person>> byDept = people.stream()
    .collect(Collectors.groupingBy(Person::getDepartment));
 
// Grouping with downstream collector
Map<Department, Long> countByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.counting()
    ));
 
// Partitioning (special case of grouping with boolean)
Map<Boolean, List<Person>> adultsAndMinors = people.stream()
    .collect(Collectors.partitioningBy(p -> p.getAge() >= 18));
 
// Joining strings
String names = people.stream()
    .map(Person::getName)
    .collect(Collectors.joining(", "));  // "Alice, Bob, Charlie"

How should you use Optional correctly?

Optional represents a value that may or may not be present. It forces explicit handling of absence rather than returning null. Use the functional methods rather than isPresent()/get().

Optional<Person> maybePerson = repository.findById(id);
 
// Bad: Defeats the purpose
if (maybePerson.isPresent()) {
    Person p = maybePerson.get();
}
 
// Good: Functional style
String name = maybePerson
    .map(Person::getName)
    .orElse("Unknown");
 
// Chain operations safely
String city = maybePerson
    .map(Person::getAddress)
    .map(Address::getCity)
    .orElseThrow(() -> new NotFoundException("Person not found"));
 
// Conditional action
maybePerson.ifPresent(p -> sendEmail(p));
 
// With lazy alternative
Person person = maybePerson
    .orElseGet(() -> createDefaultPerson());  // Lazy evaluation

When should you NOT use Optional?

Optional adds overhead and isn't appropriate everywhere. Avoid using Optional as class fields (use null and document it), for collection return types (return empty collection instead), as method parameters (overloading is clearer), or in performance-critical code where object creation overhead matters.


Concurrency Fundamentals Questions

Java concurrency is complex. Interviewers test whether you understand the pitfalls, not just the APIs.

What is a race condition and how do you prevent it?

A race condition occurs when multiple threads access shared mutable state and the outcome depends on timing. The classic example is the "lost update" where two threads read, modify, and write without synchronization.

public class Counter {
    private int count = 0;
 
    public void increment() {
        count++;  // NOT atomic! Read-modify-write
    }
}
 
// With multiple threads:
Counter counter = new Counter();
// Thread 1: reads count (0), increments, writes (1)
// Thread 2: reads count (0), increments, writes (1)
// Expected: 2, Actual: 1 - lost update!

Prevent race conditions with synchronization mechanisms: the synchronized keyword, ReentrantLock, atomic classes, concurrent collections, or by making objects immutable.

How does the synchronized keyword work?

The synchronized keyword provides mutual exclusion—only one thread can hold a lock at a time. It can be applied to methods (locks on this or the Class object for static methods) or blocks (locks on a specified object).

// synchronized method
public class Counter {
    private int count = 0;
 
    public synchronized void increment() {
        count++;  // Only one thread at a time
    }
 
    public synchronized int getCount() {
        return count;
    }
}
 
// synchronized block (more granular)
public class BankAccount {
    private final Object lock = new Object();
    private double balance;
 
    public void transfer(BankAccount target, double amount) {
        synchronized (lock) {
            if (balance >= amount) {
                balance -= amount;
                target.deposit(amount);
            }
        }
    }
}

What is a deadlock and how do you prevent it?

Deadlock occurs when two or more threads wait for each other's locks, creating a circular dependency. Neither thread can proceed, causing the application to hang.

// Deadlock scenario
// Thread 1: locks A, waits for B
// Thread 2: locks B, waits for A
 
public void transfer(Account from, Account to, double amount) {
    synchronized (from) {
        synchronized (to) {
            // If another thread calls transfer(to, from, x) simultaneously...
            // DEADLOCK!
        }
    }
}
 
// Prevention: Always acquire locks in consistent order
public void transfer(Account from, Account to, double amount) {
    Account first = from.getId() < to.getId() ? from : to;
    Account second = from.getId() < to.getId() ? to : from;
 
    synchronized (first) {
        synchronized (second) {
            // Safe - consistent ordering
        }
    }
}

What are atomic classes and when do you use them?

Atomic classes provide lock-free thread-safe operations on single variables. They use CPU-level compare-and-swap (CAS) instructions, avoiding synchronization overhead for simple operations.

private AtomicInteger count = new AtomicInteger(0);
 
count.incrementAndGet();    // Atomic increment, returns new value
count.getAndIncrement();    // Atomic increment, returns old value
count.compareAndSet(5, 10); // Set to 10 only if currently 5
 
// AtomicReference for objects
AtomicReference<User> currentUser = new AtomicReference<>();
currentUser.compareAndSet(oldUser, newUser);

Use atomic classes for counters, flags, and single-variable state. For more complex invariants involving multiple variables, you still need synchronization.


Advanced Concurrency Questions

Modern Java provides higher-level concurrency abstractions that are safer and more powerful than raw threads.

Why should you use ExecutorService instead of raw threads?

Creating threads is expensive, and unbounded thread creation can exhaust system resources. ExecutorService provides thread pooling, lifecycle management, and a clean API for task submission.

// Fixed thread pool
ExecutorService executor = Executors.newFixedThreadPool(4);
 
// Submit tasks
Future<String> future = executor.submit(() -> {
    return computeResult();
});
 
// Get result (blocks until complete)
String result = future.get();  // Can throw ExecutionException
 
// Shutdown properly
executor.shutdown();  // Stops accepting new tasks
executor.awaitTermination(60, TimeUnit.SECONDS);  // Wait for completion

How does CompletableFuture enable composable async code?

CompletableFuture provides a fluent API for composing asynchronous operations. Unlike plain Future, you can chain transformations, combine results, and handle errors without blocking.

CompletableFuture<User> userFuture = CompletableFuture
    .supplyAsync(() -> userService.findById(id))
    .thenApply(user -> enrichWithProfile(user))
    .thenApply(user -> enrichWithPreferences(user));
 
// Combine multiple futures
CompletableFuture<String> nameFuture = getNameAsync();
CompletableFuture<Integer> ageFuture = getAgeAsync();
 
CompletableFuture<String> combined = nameFuture
    .thenCombine(ageFuture, (name, age) -> name + " is " + age);
 
// Handle errors
CompletableFuture<User> withFallback = userFuture
    .exceptionally(ex -> {
        log.error("Failed to fetch user", ex);
        return defaultUser;
    });
 
// Wait for all
CompletableFuture.allOf(future1, future2, future3).join();

What concurrent collections should you know?

Java provides thread-safe collection implementations optimized for different access patterns.

ConcurrentHashMap - Thread-safe map with fine-grained locking. Supports atomic compound operations.

CopyOnWriteArrayList - For read-heavy, write-light scenarios. Writes create a new copy (expensive), but reads never block.

BlockingQueue - For producer-consumer patterns. put() blocks when full, take() blocks when empty.

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.computeIfAbsent("key", k -> expensiveComputation(k));  // Atomic
 
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// Great for iteration during concurrent modification
 
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
queue.put(task);     // Blocks if full
queue.take();        // Blocks if empty

Memory Management Questions

Understanding JVM memory helps you write efficient code and debug memory issues.

How is JVM memory organized?

The JVM divides memory into several regions, each serving different purposes. Understanding this structure helps diagnose memory issues and tune performance.

flowchart TB
    subgraph jvm["JVM Memory Structure"]
        MA["Method Area<br/>Class metadata, static variables"]
        HEAP["Heap<br/>Objects (Young + Old generation)"]
        STACK["Stack<br/>Per-thread: local variables, method calls"]
        NM["Native Memory<br/>Direct buffers, JNI, metaspace"]
    end

The heap stores all objects and is divided into generations based on object age.

flowchart TB
    subgraph heap["Heap"]
        subgraph young["Young Generation"]
            EDEN["Eden<br/>(new allocations)"]
            S0["Survivor 0"]
            S1["Survivor 1"]
        end
        OLD["Old Generation<br/>(long-lived objects)"]
    end

How does garbage collection work in Java?

Garbage collection automatically reclaims memory from objects that are no longer reachable. Objects are "reachable" if they can be accessed through a chain of references starting from GC roots (stack variables, static fields, JNI references).

public void process() {
    User user = new User();  // Reachable via stack
    user = null;             // Now unreachable - eligible for GC
}  // user goes out of scope - also makes object unreachable

Young GC (Minor) runs frequently and fast, copying live objects from Eden to Survivor spaces. Most objects die here.

Old GC (Major) runs less frequently on the old generation using mark-sweep-compact.

Full GC is stop-the-world on both generations—try to avoid triggering it.

What garbage collectors are available in modern Java?

Modern JVMs offer several collectors optimized for different use cases.

G1 (default since Java 9) divides the heap into regions and collects garbage-first (highest garbage) regions. Good balance of throughput and latency.

ZGC (Java 15+) provides sub-millisecond pauses regardless of heap size, scaling to terabytes.

Shenandoah offers concurrent compaction for low latency applications.

What causes memory leaks in Java and how do you prevent them?

Java can still have memory leaks—objects that are reachable but no longer needed. Common causes include static collections that grow forever, registered listeners never unregistered, and inner classes holding references to outer classes.

// Classic leak: static collection that grows forever
public class Cache {
    private static Map<String, Object> cache = new HashMap<>();
 
    public static void put(String key, Object value) {
        cache.put(key, value);  // Never removed!
    }
}
 
// Inner class leak: holds reference to outer class
public class Outer {
    private byte[] largeData = new byte[10_000_000];
 
    public Runnable getTask() {
        return new Runnable() {  // Anonymous inner class holds Outer.this
            public void run() {
                // Even if this doesn't use largeData,
                // it keeps Outer (and largeData) alive
            }
        };
    }
}
 
// Fix: use static nested class or lambda (if it doesn't capture 'this')
public Runnable getTask() {
    return () -> System.out.println("No reference to Outer");
}

What JVM tuning flags should you know?

Basic tuning starts with heap size and garbage collector selection. More advanced tuning requires profiling your specific application.

# Heap size
-Xms512m        # Initial heap
-Xmx2g          # Maximum heap
 
# GC selection
-XX:+UseG1GC           # G1 (default in modern Java)
-XX:+UseZGC            # ZGC (low latency)
 
# GC logging
-Xlog:gc*:file=gc.log  # GC logs for analysis
 
# Memory analysis
-XX:+HeapDumpOnOutOfMemoryError  # Dump heap on OOM
-XX:HeapDumpPath=/tmp/heapdump.hprof

Modern Java Features Questions

Modern Java has evolved significantly. Know the recent features interviewers expect.

What are records and when should you use them?

Records (Java 16+) provide a concise syntax for immutable data carriers. The compiler automatically generates constructor, accessors, equals, hashCode, and toString.

// Old way - lots of boilerplate
public class Point {
    private final int x;
    private final int y;
 
    public Point(int x, int y) { this.x = x; this.y = y; }
    public int x() { return x; }
    public int y() { return y; }
    public boolean equals(Object o) { /* ... */ }
    public int hashCode() { /* ... */ }
    public String toString() { /* ... */ }
}
 
// Record - one line
public record Point(int x, int y) { }
// Records are final and immutable

Use records for DTOs, value objects, and any class that's primarily a data carrier.

What are sealed classes and why were they added?

Sealed classes (Java 17+) restrict which classes can extend them. This enables exhaustive pattern matching in switch expressions and provides stronger modeling of domain hierarchies.

public sealed class Shape permits Circle, Rectangle, Triangle {
    // Only Circle, Rectangle, Triangle can extend Shape
}
 
public final class Circle extends Shape { }  // Must be final, sealed, or non-sealed
public final class Rectangle extends Shape { }
public non-sealed class Triangle extends Shape { }  // Opens hierarchy

How does pattern matching work in Java?

Pattern matching (evolving since Java 16) simplifies type checking and extraction. It reduces boilerplate and makes code more readable.

instanceof pattern matching (Java 16+):

// Old
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}
 
// New - automatic cast
if (obj instanceof String s) {
    System.out.println(s.length());  // s already cast and in scope
}

Switch pattern matching (Java 21+):

String describe(Object obj) {
    return switch (obj) {
        case Integer i -> "Integer: " + i;
        case String s -> "String of length " + s.length();
        case null -> "null";
        default -> "Unknown";
    };
}
 
// With sealed classes - exhaustive, no default needed
double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
    };
}

What are virtual threads and when should you use them?

Virtual threads (Java 21+) are lightweight threads managed by the JVM rather than the OS. You can create millions of them, enabling simple blocking code for high-concurrency applications.

// Platform thread (OS thread) - expensive, limited to thousands
Thread platformThread = new Thread(() -> blockingOperation());
 
// Virtual thread - cheap, millions possible
Thread virtualThread = Thread.ofVirtual().start(() -> blockingOperation());
 
// With ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i -> {
        executor.submit(() -> {
            // Each task gets its own virtual thread
            // Blocking operations don't waste OS threads
            return fetchFromDatabase(i);
        });
    });
}

Use virtual threads for I/O-bound work with many concurrent tasks. They shine in web servers, database clients, and any code that spends time waiting.


Common Java Interview Questions

These fundamental questions appear in almost every Java interview.

What is the difference between == and equals()?

The == operator compares references for objects—are they the exact same object in memory? The equals() method compares values—are they logically equivalent?

For primitives, == compares values. For objects, always use equals() unless you specifically want reference comparison.

What is immutability and why is it useful?

Immutable objects cannot be modified after creation. Once constructed, their state never changes.

Benefits:

  • Thread-safety without synchronization
  • Safe as HashMap keys (hashCode won't change)
  • Easier reasoning about program state
  • Can be freely shared and cached

Creating immutable classes:

  • Make the class final
  • Make all fields final and private
  • No setters
  • Defensive copies of mutable fields in constructor and getters

What is the difference between final, finally, and finalize()?

These three keywords sound similar but serve completely different purposes.

final is a modifier: final variables can't be reassigned, final methods can't be overridden, final classes can't be extended.

finally is a block that always executes after try/catch, used for cleanup code like closing resources.

finalize() is a deprecated method called before garbage collection. Don't use it—use try-with-resources and Cleaner instead.

What is the difference between checked and unchecked exceptions?

Checked exceptions (subclasses of Exception but not RuntimeException) must be caught or declared in the method signature. Use them for recoverable conditions the caller should handle—FileNotFoundException, SQLException.

Unchecked exceptions (subclasses of RuntimeException) don't require explicit handling. Use them for programming errors—NullPointerException, IllegalArgumentException, IndexOutOfBoundsException.


Quick Reference

TopicKey Points
OOPEncapsulation, inheritance, polymorphism, abstraction
Abstract vs InterfaceAbstract has state/constructors; interface for contracts
SOLIDSRP, OCP, LSP, ISP, DIP
ArrayList vs LinkedListArrayList for most cases (cache locality)
HashMapO(1) avg, hash + equals contract, collisions
GenericsType safety, PECS, type erasure
StreamsLazy, intermediate/terminal ops, collectors
Concurrencysynchronized, atomic, ExecutorService, CompletableFuture
MemoryHeap generations, GC roots, GC algorithms
Modern JavaRecords, sealed classes, pattern matching, virtual threads

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides