Java Interview Questions for 10 Years Experience


Welcome to the comprehensive guide on "Java Interview Questions for 10 Years Experience." Java has been one of the most popular programming languages for decades, and its widespread adoption means that the demand for skilled Java developers has never been higher. If you are someone who has been working with Java for the last decade, you have accumulated a wealth of experience and knowledge that you can showcase during interviews.

These simple and short articles help you to add AI to your resume and enhance the chance of selection:

This blog post will provide you with a list of commonly asked interview questions for 10 years experienced Java developers. It will also include detailed answers to each question, with explanations and examples to help you understand the concepts and their practical implementations better. Whether you are preparing for a job interview or just want to brush up your knowledge of Java, this guide will be an excellent resource for you. So, let's dive in and explore the world of Java together!
Updated for modern Java. Examples are written against Java 17+ and behaviour that changed in later releases (up to Java 25 LTS) is called out explicitly.

Explain the Java Memory Model in detail, what is happens-before, Volatile Variables, Memory Visibility? Also give example.

The Java Memory Model (JMM) defines how threads in a multi-threaded Java program interact with memory and each other. Understanding JMM is crucial for ensuring thread safety in high-concurrency scenarios.

Concepts in the Java Memory Model: 1. Happens-Before Relationship:

The "happens-before" relationship is the core of the JMM. It is a partial ordering on memory operations: if action A happens-before action B, then everything A wrote is guaranteed to be visible to B. Without a happens-before edge, the JVM and CPU are free to reorder and cache, and B may see stale data.

The important part is knowing what actually creates a happens-before edge. These are the rules you should be able to list in an interview:

  • Program order: within a single thread, each statement happens-before the statements that follow it.
  • Monitor lock: releasing a lock happens-before any subsequent acquisition of that same lock.
  • Volatile: a write to a volatile field happens-before every subsequent read of that same field.
  • Thread start: Thread.start() happens-before any action in the started thread.
  • Thread join: every action in a thread happens-before another thread returning from join() on it.
  • Final fields: correctly initialised final fields are visible to any thread that sees the object after the constructor completes, without extra synchronisation.
  • Transitivity: if A happens-before B and B happens-before C, then A happens-before C.

Note what is not on this list: simply writing a value in one thread and reading it in another creates no ordering at all. That is exactly the bug most concurrency interviews are probing for.

2. Volatile Variables:

Declaring a field volatile gives you two guarantees: the value is never cached in a way that hides it from other threads, and the compiler and CPU may not reorder operations across the volatile access. In happens-before terms, a volatile write happens-before every later volatile read of that field.

What volatile does not give you is atomicity. Any read-modify-write — count++, flag = !flag, total += x — is three separate operations, and volatile does nothing to make them a single indivisible step. Two threads can interleave and lose an update. Volatile is for publishing a value, not for updating one based on its old value.

Here is the pattern volatile is actually correct for — a flag that is written by one thread and only read by another:


package codeKatha;

public class VolatileExample {

    // Written by the control thread, read by the worker thread.
    private volatile boolean running = true;

    public void stop() {
        running = false;   // a plain write, not a read-modify-write
    }

    public void runWorker() {
        while (running) {
            // do work
        }
        // Without volatile, this loop may never exit: the JIT is
        // allowed to hoist the read of `running` out of the loop.
    }
}

If you need to toggle a flag safely, volatile is the wrong tool — use AtomicBoolean:


package codeKatha;

import java.util.concurrent.atomic.AtomicBoolean;

public class ToggleExample {

    private final AtomicBoolean flag = new AtomicBoolean(false);

    public boolean toggle() {
        boolean current, next;
        do {
            current = flag.get();
            next = !current;
        } while (!flag.compareAndSet(current, next));
        return next;
    }
}
3. Memory Visibility:

Memory visibility is the property that ensures changes made by one thread to shared variables are visible to other threads. Without a happens-before edge, changes made by one thread may never be visible to others, leading to data inconsistencies and bugs that reproduce only under load. Locks, volatile fields, final fields and the java.util.concurrent classes are the tools that create those edges.

Ensuring Thread Safety:

To ensure thread safety in Java applications, especially in high-concurrency scenarios, you can follow these principles:

1. Use Synchronization:

Use synchronized blocks or methods to protect critical sections of code. This ensures that only one thread can execute the synchronized region at a time, and it also creates the happens-before edge that makes the changes visible to the next thread that acquires the lock.


package codeKatha;

public class SynchronizationExample {
    private int sharedVariable = 0;

    public synchronized void increment() {
        sharedVariable++;
    }

    public synchronized int get() {
        return sharedVariable;   // the read must be synchronized too
    }
}

A point candidates often miss: guarding only the write is not enough. If get() were unsynchronized, a reader could still see a stale value.

2. Use Volatile Variables:

Use volatile for fields that are shared across threads and are written independently of their previous value — status flags, a cached configuration reference, a "shutdown requested" signal. See the example above.

3. Use Thread-Safe Data Structures:

Prefer thread-safe data structures from the java.util.concurrent package, such as ConcurrentHashMap or ConcurrentLinkedQueue, when dealing with shared collections or queues. Note that individual operations on a ConcurrentHashMap are atomic, but a get-then-put sequence is not — use computeIfAbsent or merge for that.

4. Leverage Atomic Operations:

Use atomic classes like AtomicInteger or AtomicLong to perform compound actions atomically without the need for locks or synchronized blocks. Internally these use a compare-and-swap (CAS) instruction rather than a lock.


package codeKatha;

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicExample {
    private final AtomicInteger counter = new AtomicInteger(0);

    public void increment() {
        counter.incrementAndGet();
    }

    public int get() {
        return counter.get();
    }
}

Under heavy contention from many threads, LongAdder usually outperforms AtomicLong because it spreads updates across multiple cells instead of having every thread CAS the same variable.

Discuss the differences between the Serializable and Externalizable interfaces in Java. When would you use one over the other for object serialization, and what are the potential performance implications?

In Java, the Serializable and Externalizable interfaces are used for object serialization, but they differ in terms of customization, control and performance. Let's explore the differences and when to use one over the other.

Serializable Interface:

The Serializable interface is a marker interface — it declares no methods. It simply indicates that a class can be serialized (converted into a byte stream) and deserialized (reconstructed from a byte stream). The serialization and deserialization process is handled by the Java runtime, and you have limited control over it.

Two things every experienced candidate is expected to mention here:

  • serialVersionUID: a version identifier for the class. If you do not declare it, the compiler generates one from the class structure — so adding a single field changes the generated ID and every previously serialized object fails to deserialize with an InvalidClassException. Always declare it explicitly.
  • transient: marks a field that should be skipped during serialization — passwords, caches, open connections, or anything not meaningfully restorable. On deserialization such fields come back as the default value (null, 0, false).

package codeKatha;

import java.io.*;

public class SerializableExample implements Serializable {

    private static final long serialVersionUID = 1L;

    private int data;
    private transient String cachedToken;   // never written to the stream

    public SerializableExample(int data) {
        this.data = data;
    }

    public static void main(String[] args) {
        // Serialization
        try (ObjectOutputStream oos =
                     new ObjectOutputStream(new FileOutputStream("object.ser"))) {
            oos.writeObject(new SerializableExample(42));
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Deserialization
        try (ObjectInputStream ois =
                     new ObjectInputStream(new FileInputStream("object.ser"))) {
            SerializableExample obj = (SerializableExample) ois.readObject();
            System.out.println(obj.data);       // 42
            System.out.println(obj.cachedToken); // null - it was transient
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

Note also that during Serializable deserialization the constructor of the class is never called. The JVM allocates the object and populates the fields directly. This is why invariants you enforce in a constructor can be bypassed by a crafted byte stream.

Externalizable Interface:

Externalizable extends Serializable but hands the entire process to you. You must implement writeExternal and readExternal, which define exactly what is written and read.

The difference interviewers actually probe: Externalizable requires a public no-argument constructor. During deserialization the runtime calls that constructor to create an empty instance, and then calls readExternal on it to populate the fields. If the class has no accessible no-arg constructor, deserialization fails with an InvalidClassException.


package codeKatha;

import java.io.*;

public class ExternalizableExample implements Externalizable {

    private int data;
    private String name;

    // REQUIRED: public no-arg constructor, called during deserialization
    public ExternalizableExample() {
    }

    public ExternalizableExample(int data, String name) {
        this.data = data;
        this.name = name;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        // You control the format and the order completely
        out.writeInt(data);
        out.writeUTF(name);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        // Must read back in exactly the same order it was written
        data = in.readInt();
        name = in.readUTF();
    }

    public static void main(String[] args) {
        try (ObjectOutputStream oos =
                     new ObjectOutputStream(new FileOutputStream("object.ser"))) {
            oos.writeObject(new ExternalizableExample(42, "Advait"));
        } catch (IOException e) {
            e.printStackTrace();
        }

        try (ObjectInputStream ois =
                     new ObjectInputStream(new FileInputStream("object.ser"))) {
            ExternalizableExample obj = (ExternalizableExample) ois.readObject();
            System.out.println(obj.data + " / " + obj.name);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
Quick comparison:

                          Serializable          Externalizable
-------------------------------------------------------------------
Methods to implement      none (marker)         writeExternal, readExternal
Constructor on read       not called            public no-arg IS called
Control over format       minimal               complete
Inherited fields          handled automatically you must handle them
Boilerplate               almost none           significant
Risk                      serialises too much   easy to get read/write order wrong
When to Use Serializable vs. Externalizable:
  • Use Serializable when you want a simple and straightforward way to serialize and deserialize objects with minimal effort. It is suitable for most cases. 
  • Use Externalizable when you need full control — a compact custom wire format, omitting fields, versioning the payload yourself, or encrypting during serialization. Keep in mind it requires more code and the read and write sides must be kept in lockstep. 

Performance Implications:
  • Serializable relies on reflection and writes class metadata alongside the data, so the output is larger and slower to produce than a hand-written format. 
  • Externalizable lets you write only what you need and skip the reflective field walk, producing smaller and faster payloads. The gain is real for hot paths and negligible for occasional use.
  • In practice: for anything crossing a network or a service boundary, most teams now skip Java serialization entirely and use JSON, Protobuf or Avro. Java's built-in serialization has a long history of deserialization vulnerabilities, and you should never deserialize a byte stream from an untrusted source.

Could you provide the differences between Heap and Stack Memory in the context of Java, and also provide insights into how these memory areas are used?

In Java, memory management plays a crucial role in determining how objects are stored and accessed during program execution. Heap and Stack Memory are two distinct regions where different types of data are managed. Understanding their differences is essential for efficient memory utilisation and managing object lifecycles.

Stack Memory

Stack Memory is a region used for storing method calls, local variables, and references to objects. It operates in a Last-In-First-Out (LIFO) manner, resembling a stack of items. Each method call creates a new frame in the stack, containing variables specific to that method. Every thread gets its own stack, which is why local variables are never shared between threads.

Stack Memory is relatively fast for allocation and deallocation because it follows a strict order — a frame is simply popped when the method returns. However, it has limited space; unbounded recursion overflows it and throws StackOverflowError.

A precise point that trips people up: the stack holds local variables — both primitive locals and references to heap objects. It does not hold primitives in general. A primitive that is an instance field lives inside the object on the heap, not on the stack.

Example: Using Stack Memory

package codeKatha;

public class StackMemoryExample {
    public static void main(String[] args) {
        int a = 5;
        int b = 10;
        int sum = addNumbers(a, b);
        System.out.println("Sum: " + sum);
    }

    public static int addNumbers(int x, int y) {
        int result = x + y;
        return result;
    }
}

In this example, the variables 'a', 'b', 'x', 'y', 'result', and 'sum' are all local variables and live in Stack Memory. As methods are called and return, their corresponding frames are pushed and popped.


   Stack Memory  (while addNumbers is executing)
--------------------------------------------------
[addNumbers Frame]
 result = 15
 y = 10
 x = 5

[main Frame]
 sum = (not assigned yet - assigned once addNumbers returns)
 b = 10
 a = 5
Heap Memory

Heap Memory is a region used for dynamic memory allocation, primarily for objects that have varying lifetimes. Unlike Stack Memory, Heap Memory doesn't have a strict order, and objects are reclaimed by the garbage collector rather than on method return.

Objects stored in the Heap are accessed through references stored in the Stack. Objects in Heap Memory can exist beyond the scope of a single method and can be shared among multiple methods or even different threads — which is exactly why heap data needs synchronisation and stack data does not.

Example: Using Heap Memory

package codeKatha;

public class HeapMemoryExample {

    public static void main(String[] args) {

        Person person1 = new Person("Shanav", 25);
        Person person2 = new Person("Advait", 30);

        person1.sayHello();
        person2.sayHello();
    }
}

class Person {

    private String name;
    private int age;

    public Person(String name, int age) {

        this.name = name;
        this.age = age;
    }

    public void sayHello() {
        System.out.println("Hello, my name is " + name + " and I'm " + age + " years old.");
    }
}

In this example, the 'Person' objects are created in the Heap Memory using the 'new' keyword. The references to these objects ('person1' and 'person2') are stored in the Stack Memory. Note that the age field is a primitive, but because it is an instance field it lives inside the Person object on the heap.


   Stack (main frame)          Heap
-------------------------  ------------------------------
 person1  --------------->  Person { name="Shanav", age=25 }
 person2  --------------->  Person { name="Advait", age=30 }
Two follow-ups worth knowing
  • Metaspace: class metadata used to live in PermGen, which was part of the heap. Since Java 8 it lives in Metaspace, which is native memory outside the heap and grows dynamically. OutOfMemoryError: PermGen space no longer exists; you may see OutOfMemoryError: Metaspace instead.
  • String pool: the interned string pool moved from PermGen to the heap in Java 7, which means interned strings are now garbage collected normally.
Utilisation and Implications

Understanding the distinctions between Heap and Stack Memory is vital for efficient memory usage. Stack Memory is suited for small and short-lived data, while Heap Memory is used for dynamically allocated objects with varying lifetimes. Effective memory management ensures that objects become unreachable when no longer needed, preventing memory leaks and improving program performance.

Explain the differences between abstract classes and interfaces in Java. When should you use one over the other?

Abstract classes and interfaces are two mechanisms in Java for defining contracts and providing abstraction. They have some similarities but also differ in key ways:
  • Abstract Classes: Can have both abstract and concrete methods, can have instance fields with any access modifier, can have constructors, can hold state, and support single inheritance. A class can extend only one abstract class.
  • Interfaces: Can declare abstract methods, and since Java 8 can also provide default and static methods with a body. Since Java 9 they can additionally have private methods, used to share code between default methods. Interfaces cannot hold instance state — any field declared in an interface is implicitly public static final. They have no constructors, and a class can implement multiple interfaces.

So the sharpest one-line distinction is not "implementation vs no implementation" — default methods blurred that in Java 8. It is state and construction: an abstract class can hold instance fields and run a constructor, an interface cannot.

When to use abstract classes:
  • If you want to provide a partial implementation or share common functionality across closely related classes.
  • If your abstraction needs instance fields and constructor logic.
  • If you want to enforce a specific inheritance hierarchy.
When to use interfaces:
  • If you want to provide a contract that multiple unrelated classes can implement.
  • If you need a class to inherit from multiple abstractions.
  • If you want to provide a common API for different implementations.

Since Java 17, sealed interfaces and classes give you a third option: a contract that only a fixed, declared set of types may implement — useful for modelling closed hierarchies that the compiler can exhaustively check in a switch.

Explain the concept of method overloading and method overriding in Java. What are the rules for each?

Method Overloading: Method overloading is the concept of having multiple methods with the same name but different parameter lists in the same class. The compiler differentiates these methods based on the number, type, and order of the parameters, and it resolves the call at compile time. Method overloading is a form of compile-time polymorphism.
Rules for method overloading:
  • Methods must have the same name but different parameter lists.
  • Methods can have different return types, access modifiers, and exception lists.
  • Return type alone is not enough — two methods that differ only in return type will not compile.
  • Constructor overloading is also possible in Java.
Method Overriding: Method overriding is the concept of providing a new implementation in a subclass for a method inherited from the superclass. Method overriding is a form of runtime polymorphism — the JVM picks the implementation based on the actual object type, not the reference type.
Rules for method overriding:
  • Same method name and same parameter list as the superclass method.
  • The return type may be the same or a subtype of the superclass method's return type. This is called a covariant return type and has been legal since Java 5. For example, if the parent declares Object clone(), the child may declare Employee clone().
  • The access level of the overriding method cannot be more restrictive than the superclass method. It may be wider — a protected method can be overridden as public.
  • If the superclass method throws checked exceptions, the overriding method can throw the same exceptions, subclasses of those exceptions, or no exception, but it cannot throw a new or broader checked exception. Unchecked exceptions are unrestricted.
  • private, static and final methods cannot be overridden. Declaring a static method with the same signature in a subclass is method hiding, not overriding — it is resolved by reference type at compile time, which is a classic trick question.
  • The @Override annotation is not required, but it makes the compiler verify that you really are overriding something. Use it always; it catches typos and signature drift.

Explain the concept of Java generics. What are some benefits of using generics, and what are some common use cases?

Java generics is a language feature introduced in Java 5 that allows the creation of generic classes, interfaces, and methods that can operate on different types of objects while providing type safety and code reusability. With generics, you can define a single class or method that works with different data types, while maintaining type safety at compile time.
Benefits of using generics:
  • Type safety: Generics help ensure type safety by checking the types of objects at compile time. This catches type-related errors early and reduces the chances of a runtime ClassCastException.
  • Code reusability: Generics enable you to write generic classes, interfaces, and methods that can be reused with different types, reducing code duplication and increasing maintainability.
  • Improved readability: Generics make the code more expressive and easier to read, as the types of objects being used are explicitly defined.

Type erasure is the follow-up question that almost always comes next. Generics exist only at compile time — the compiler inserts the checks and the casts, then erases the type parameters. At runtime a List<String> and a List<Integer> are both just List. That is why you cannot write new T[10], cannot use instanceof List<String>, and cannot overload two methods that differ only by their generic parameter.

Common use cases for generics:
  • Collections: One of the most common use cases for generics is in the Java Collections Framework. Generics allow you to create type-safe collections like List<String>, Set<Integer>, and Map<String, Integer>, ensuring that only the specified type of objects can be added or retrieved from the collection.
  • Custom generic classes and interfaces: You can create your own generic classes and interfaces to handle multiple data types while maintaining type safety. For example, you could create a generic Pair class that can store two objects of different types:
  •  public class Pair<K, V> {
        private final K key;
        private final V value;
    
        public Pair(K key, V value) {
            this.key = key;
            this.value = value;
        }
    
        public K getKey()   { return key; }
        public V getValue() { return value; }
    } 
  • Generic methods: You can create generic methods that can be used with different types of arguments. For example, a method to find the maximum value in a list of comparable elements:
  •  public static <T extends Comparable<T>> T findMax(List<T> list) {
        if (list.isEmpty()) {
            throw new IllegalArgumentException("list must not be empty");
        }
        T max = list.get(0);
        for (T item : list) {
            if (item.compareTo(max) > 0) {
                max = item;
            }
        }
        return max;
    } 

Explain the difference between final, finally, and finalize in Java.

final: The final keyword in Java can be applied to variables, methods, and classes. It serves different purposes depending on the context:
  • final variables: A final variable can be assigned a value only once, either at declaration or in a constructor. Note that final means the reference cannot be reassigned — a final List can still have elements added to it.
  • final methods: A final method cannot be overridden by a subclass, ensuring that the method's behavior remains consistent across the class hierarchy.
  • final classes: A final class cannot be extended. This is useful for creating immutable classes or classes whose behaviour must not be altered — String is the best-known example.
finally: The finally keyword is used with a try-catch block. A finally block executes whether or not an exception was thrown, and is typically used to release resources.
 try {
    // Code that might throw an exception
} catch (IOException e) {
    // Code to handle the exception
} finally {
    // Always executed, whether or not an exception was thrown
}

In modern code, prefer try-with-resources over a finally block for anything that implements AutoCloseable. It closes resources in reverse order, and it correctly handles the case where both the body and the close throw — the close exception is attached as a suppressed exception rather than swallowing the original:

 try (BufferedReader reader = Files.newBufferedReader(path)) {
    return reader.readLine();
}   // reader.close() is called automatically
finalize: finalize() was a protected method on java.lang.Object, called by the garbage collector before reclaiming an object. It was deprecated in Java 9 and removed entirely in Java 18. If you are on any recent JDK, it no longer exists and there is nothing to override.

It was removed because it was unfixable: there was no guarantee it would ever run, it ran on an unpredictable thread, it could resurrect the object being collected, and it delayed reclamation by at least one GC cycle. The replacements are:

  • try-with-resources for anything with a clear scope — this covers the overwhelming majority of cases.
  • java.lang.ref.Cleaner (Java 9+) as a safety net for native resources, where you register an action that runs after the object becomes unreachable. The cleanup action must not hold a reference to the object it is cleaning, or it will never become unreachable.

package codeKatha;

import java.lang.ref.Cleaner;

public class NativeResource implements AutoCloseable {

    private static final Cleaner CLEANER = Cleaner.create();

    // Must NOT be an inner class and must not reference NativeResource
    private static class ReleaseTask implements Runnable {
        private final long handle;

        ReleaseTask(long handle) {
            this.handle = handle;
        }

        @Override
        public void run() {
            // release the native handle
        }
    }

    private final Cleaner.Cleanable cleanable;

    public NativeResource(long handle) {
        this.cleanable = CLEANER.register(this, new ReleaseTask(handle));
    }

    @Override
    public void close() {
        cleanable.clean();   // deterministic cleanup - the normal path
    }
}

Explain the difference between the Comparable and Comparator interfaces in Java.

Both Comparable and Comparator are used for ordering objects, but they answer different questions. Comparable answers "what is this type's natural order?" and lives inside the class. Comparator answers "how do I want to sort this time?" and lives outside it.
Comparable: The class itself implements Comparable<T> and defines compareTo(), which returns a negative number, zero, or a positive number depending on whether this object sorts before, equal to, or after the argument.
 public class Employee implements Comparable<Employee> {

    private int id;
    private String name;
    // Constructor, getters, and setters

    @Override
    public int compareTo(Employee other) {
        return Integer.compare(this.id, other.id);
    }
}

Two things to get right: use Integer.compare(a, b) rather than a - b, because subtraction overflows for large or negative values and silently produces the wrong order. And your natural ordering should normally be consistent with equals — if compareTo returns 0 for two objects that are not equals, a TreeSet will treat them as duplicates while a HashSet will not.

Comparator: A separate object that defines an ordering without modifying the class. Since Java 8 it is a functional interface, so you almost never write a separate named class any more — use the factory methods:
 // Sort by name
employeeList.sort(Comparator.comparing(Employee::getName));

// Sort by department, then by salary descending, then by name
employeeList.sort(
    Comparator.comparing(Employee::getDepartment)
              .thenComparing(Employee::getSalary, Comparator.reverseOrder())
              .thenComparing(Employee::getName));

// Nulls last, using the natural order otherwise
employeeList.sort(Comparator.nullsLast(Comparator.naturalOrder()));

// Reverse the natural order defined by compareTo()
employeeList.sort(Comparator.reverseOrder());

Use comparingInt, comparingLong or comparingDouble when the key is a primitive — it avoids boxing on every comparison.

The older style still compiles and you will meet it in legacy code:

 public class EmployeeNameComparator implements Comparator<Employee> {
    @Override
    public int compare(Employee e1, Employee e2) {
        return e1.getName().compareTo(e2.getName());
    }
}

Collections.sort(employeeList, new EmployeeNameComparator());
In summary: use Comparable to define one natural ordering inside the class, and Comparator to define any number of alternative orderings outside it — including for classes you do not own.

What are the key differences between ArrayList and LinkedList in Java?

ArrayList and LinkedList are both implementations of the List interface in Java, but they have different underlying data structures and performance characteristics:
ArrayList:
  • Underlying data structure: a dynamic array.
  • Access time: fast random access, O(1), because the index maps directly to an array offset.
  • Insertion and deletion: inserting or deleting in the middle is O(n) because the remaining elements must be shifted. Appending at the end is amortised O(1).
  • Memory overhead: lower than LinkedList — no per-element node object, though the backing array may have unused capacity.
  • Use cases: the correct default. Random access is common, and iteration is fast because the elements sit contiguously in memory.
 List<String> arrayList = new ArrayList<>();
LinkedList:
  • Underlying data structure: a doubly-linked list.
  • Access time: slow random access, O(n), since it must walk the chain from whichever end is nearer.
  • Insertion and deletion: O(1) only if you already hold the node — for example while iterating with ListIterator.remove(). list.add(index, element) is still O(n), because finding the position requires the same traversal. This is the most common misconception about LinkedList.
  • Memory overhead: higher — each element needs a node object with two pointers, which is typically 24–40 bytes of overhead per element.
  • Use cases: queue or deque behaviour where you add and remove at the ends, and iterator-driven removal from the middle.
 List<String> linkedList = new LinkedList<>();

// For queue/deque usage, declare it as a Deque instead
Deque<String> deque = new ArrayDeque<>();

The practical answer: in real benchmarks ArrayList usually wins even for workloads that look like LinkedList's strength, because contiguous memory is far friendlier to the CPU cache than chasing pointers across the heap. If you need a queue or deque, ArrayDeque is faster than LinkedList for almost every operation. Reach for LinkedList rarely and with a measurement in hand.

What is the purpose of the hashCode() and equals() methods in Java?

The hashCode() and equals() methods are used to compare objects for equality in Java. They play a crucial role when objects are used as keys in hash-based collections, such as HashMap or HashSet.
The contract between them:
  • If two objects are equal according to equals(), they must return the same hashCode().
  • The reverse is not required — two unequal objects may share a hash code. That is a collision, and it is handled by the collection.
  • Therefore: whenever you override equals(), you must override hashCode() as well. Overriding one without the other is the single most common source of "my object went into the HashMap and I can never get it back out".
equals() must also satisfy:
  • Reflexive: x.equals(x) should return true for any non-null reference value x.
  • Symmetric: x.equals(y) should return the same value as y.equals(x).
  • Transitive: if x.equals(y) and y.equals(z) are both true, then x.equals(z) must be true.
  • Consistent: repeated invocations return the same value, provided neither object is modified.
  • x.equals(null) must return false for any non-null x.
A correct implementation:

package codeKatha;

import java.util.Objects;

public class Employee {

    private final int id;
    private final String email;

    public Employee(int id, String email) {
        this.id = id;
        this.email = email;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee other = (Employee) o;
        return id == other.id
                && Objects.equals(email, other.email);
    }

    @Override
    public int hashCode() {
        // Must use the same fields as equals(), in any order
        return Objects.hash(id, email);
    }
}

The mutable key trap. If you put an object into a HashMap and then change a field that hashCode() depends on, the object is now sitting in the wrong bucket. A lookup computes the new hash, looks in a different bucket, and finds nothing — the entry is effectively leaked. This is why the fields used in equals and hashCode should be final wherever possible, and why mutable objects make poor map keys.

Records do this for you. Since Java 16, a record automatically generates equals(), hashCode() and toString() from its components, and its fields are final. For a pure data carrier, prefer a record over hand-written boilerplate:

 public record Employee(int id, String email) { }

What are some common uses of Java Reflection API?

Java Reflection API allows a program to inspect and interact with its own code at runtime. It provides the ability to analyze and manipulate classes, fields, methods, and other elements of the Java code. Some common uses of Java Reflection API include:
  • Dynamic object creation: Reflection allows you to instantiate objects without knowing their class at compile time. This can be useful for creating objects based on user input or configuration files.
  • Method invocation: With Reflection, you can invoke methods on objects without knowing the methods at compile time. This is helpful for implementing features like plugins or scripting languages that interact with Java code.
  • Accessing private members: Reflection can be used to access private fields and methods of an object, which can be useful for testing or debugging purposes. However, using Reflection to break encapsulation in regular code is discouraged.
  • Inspecting class metadata: Reflection allows you to retrieve information about classes, such as their fields, methods, constructors, and annotations. This is how frameworks like Spring, Hibernate and Jackson do their work.
Example:

package codeKatha;

import java.lang.reflect.Field;

class Student {
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void display() {
        System.out.println("Name: " + name + ", Age: " + age);
    }
}

public class ReflectionExample {
    public static void main(String[] args) {
        // Create an instance of the Student class
        Student student = new Student("Advait", 20);

        // Use Reflection to access and modify private fields
        Class<?> studentClass = student.getClass();

        try {
            // Access the 'name' field
            Field nameField = studentClass.getDeclaredField("name");
            nameField.setAccessible(true); // Allow access to private field
            String nameValue = (String) nameField.get(student);
            System.out.println("Name Field: " + nameValue);

            // Access the 'age' field
            Field ageField = studentClass.getDeclaredField("age");
            ageField.setAccessible(true);
            int ageValue = (int) ageField.get(student);
            System.out.println("Age Field: " + ageValue);

            // Modify the 'name' field
            nameField.set(student, "Shanav");
            System.out.print("Updated: ");
            student.display();   // display() returns void - call it, don't concatenate it
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

In this example we define a Student class with private fields, obtain its Class object via getClass(), then use getDeclaredField() with setAccessible(true) to read and modify those private fields.

Two caveats worth raising in an interview:

  • Performance. Reflective access is significantly slower than a direct call and blocks many JIT optimisations. Fine at startup or in a framework's wiring phase; not something to put in a hot loop.
  • Strong encapsulation. Since Java 16, setAccessible(true) on JDK-internal classes fails by default — it throws InaccessibleObjectException unless the module is explicitly opened with --add-opens. Reflection on your own classes, as above, still works normally.

Define the role of a ClassLoader in Java, its hierarchy, and its primary functions.

ClassLoader Role and Significance:

A ClassLoader is the component responsible for dynamically loading classes at runtime. It locates class files — from the file system, a JAR, or a network location — and turns their bytes into an instance of java.lang.Class that the JVM can use. Classes are loaded lazily, on first use, not all at startup.

Primary functions:

  • Loading: reads the binary data of a class and converts it into an instance of java.lang.Class.
  • Linking: verifies the bytecode is well-formed and safe, prepares the class by allocating static fields with default values, and resolves symbolic references.
  • Initialization: runs static initializer blocks and assigns the declared values to static fields.

ClassLoader Hierarchy:

Java uses a hierarchical system. The names changed in Java 9 with the module system, and interviewers do notice if you give the old ones:

  • Bootstrap ClassLoader: the top-level loader, written in native code, responsible for the core Java classes such as java.lang.*. It has no parent and getClassLoader() returns null for classes it loaded.
  • Platform ClassLoader: loads the remaining JDK platform modules. Before Java 9 this was called the Extension ClassLoader and it loaded JARs from the jre/lib/ext directory. That extension mechanism was removed in Java 9 — mentioning "Extension ClassLoader" as current is a common giveaway that the answer is a decade old.
  • Application (System) ClassLoader: loads classes from the application's classpath. This is the one that loads your own code.
  • Custom ClassLoaders: you can subclass ClassLoader to load from non-standard sources — this is how application servers, plugin systems and hot-reloading frameworks isolate code.

The Parent Delegation Model:

This is the part most answers get backwards. A class loading request does not start at the top and work down. It arrives at the Application ClassLoader, which delegates upward to its parent before trying anything itself. Each parent does the same, so the request climbs all the way to Bootstrap. Bootstrap attempts the load first; if it cannot find the class, control returns downward and each loader gets its turn on the way back:


Request arrives at:  Application ClassLoader
                          |  delegates up
                          v
                     Platform ClassLoader
                          |  delegates up
                          v
                     Bootstrap ClassLoader   <-- tries FIRST
                          |  not found, returns
                          v
                     Platform ClassLoader    <-- tries SECOND
                          |  not found, returns
                          v
                     Application ClassLoader <-- tries LAST
                          |
                     ClassNotFoundException if still not found

The reason for this design is security and consistency. If you put your own java.lang.String on the classpath, delegation guarantees the real one from Bootstrap wins, so core classes cannot be spoofed. It also guarantees a class is loaded once per loader rather than repeatedly.

A related point: class identity includes the ClassLoader. The same class file loaded by two different loaders produces two distinct types, and casting between them throws ClassCastException. This is what makes application-server isolation possible and what causes the confusing "ClassCastException: Foo cannot be cast to Foo" errors.

ClassLoader Example:


package codeKatha;

public class ClassLoaderExample {

    public static void main(String[] args) throws Exception {

        // Core JDK class -> loaded by Bootstrap -> prints null
        System.out.println("ClassLoader for String: " + String.class.getClassLoader());

        // Your own class -> loaded by the Application ClassLoader
        System.out.println("ClassLoader for this class: "
                + ClassLoaderExample.class.getClassLoader());

        // Loading a class dynamically by name
        Class<?> myClass = Class.forName("java.util.ArrayList");

        // Class.newInstance() was deprecated in Java 9 - use this instead
        Object myInstance = myClass.getDeclaredConstructor().newInstance();
        System.out.println("Created: " + myInstance.getClass().getName());
    }
}

Note that String.class.getClassLoader() prints null, not "Bootstrap". The Bootstrap loader is native and has no Java object representing it — another small detail interviewers like.

What is the difference between Checked and Unchecked Exceptions in Java?

Exceptions in Java are categorized into two types: Checked Exceptions and Unchecked Exceptions.
Checked Exceptions: Checked Exceptions are verified by the compiler at compile time. They must be either handled with a try-catch block or declared in the method signature with throws. Examples are IOException, SQLException, and ClassNotFoundException. They extend Exception but not RuntimeException.
 public void readFile(String fileName) throws IOException {
    // Read file and handle IOException
}
Unchecked Exceptions: Unchecked Exceptions are not verified by the compiler. They usually represent programming errors — reading past the end of an array, dereferencing null, dividing by zero. They are subclasses of RuntimeException and need not be declared or caught. Examples are NullPointerException, ArrayIndexOutOfBoundsException, and ArithmeticException.
 public int divide(int a, int b) {
    // Throws ArithmeticException if b is 0 - no 'throws' clause needed
    return a / b;
}
Handle checked exceptions where you can genuinely recover, and reserve unchecked exceptions for bugs that should be fixed rather than caught. Two things never to do: catch an exception and leave the block empty, and catch Exception broadly when you only meant to handle one specific failure.

What is the Singleton design pattern in Java, and how can it be implemented? Include a code example.

The Singleton design pattern ensures that a class has only one instance and provides a global point of access to it. It is useful for a shared configuration holder, a connection pool, or a single cache instance.

The naive implementation - and why it is broken:

This is the version most tutorials show, and the version interviewers hand you specifically to see whether you spot the problem:


package codeKatha;

public class BrokenSingleton {

    private static BrokenSingleton instance;

    private BrokenSingleton() {
    }

    public static BrokenSingleton getInstance() {
        if (instance == null) {              // Thread A and Thread B can
            instance = new BrokenSingleton(); // BOTH pass this check
        }
        return instance;
    }
}

This is not thread-safe. Two threads can evaluate instance == null as true before either assigns, and you end up with two instances — which defeats the entire point of the pattern. It works fine in single-threaded tests and fails in production under load. Here are the three implementations that actually work.

1. Static Holder Idiom (recommended for most cases):

The cleanest lazy singleton. The JVM guarantees that class initialization is thread-safe, and the holder class is not initialized until getInstance() is first called. No locking, no volatile, no cost on subsequent calls.


package codeKatha;

public class Singleton {

    private Singleton() {
    }

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

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}
2. Enum Singleton (the most robust):

Recommended in Effective Java. The JVM guarantees a single instance, and unlike every other approach it is automatically safe against reflection and serialization attacks — you cannot call the constructor reflectively, and deserializing does not create a second instance.


package codeKatha;

public enum ConfigManager {

    INSTANCE;

    private final Properties config = loadConfig();

    public String get(String key) {
        return config.getProperty(key);
    }
}

// Usage
String value = ConfigManager.INSTANCE.get("timeout");
3. Double-Checked Locking (when you need lazy init with arguments):

The volatile keyword here is not optional. Without it, another thread can see a non-null reference to a partially constructed object, because the JVM is allowed to reorder the assignment of the reference ahead of the constructor finishing.


package codeKatha;

public class LazySingleton {

    // volatile is REQUIRED - without it this is still broken
    private static volatile LazySingleton instance;

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        if (instance == null) {                    // first check, no lock
            synchronized (LazySingleton.class) {
                if (instance == null) {            // second check, with lock
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}
Usage:

package codeKatha;

public class SingletonExample {
    public static void main(String[] args) {
        Singleton singleton1 = Singleton.getInstance();
        Singleton singleton2 = Singleton.getInstance();

        System.out.println(singleton1 == singleton2); // Output: true
    }
}

A closing caveat. Singletons carry global mutable state, which makes code hard to test and hides dependencies. In a Spring application you rarely hand-write one at all — a @Component is already a singleton within the application context, and it is injected rather than reached for globally. Being able to say this is often worth more in an interview than the implementation itself.

What is the purpose of the "synchronized" keyword in Java, and when should it be used? Include a code example.

The "synchronized" keyword ensures that only one thread at a time can execute a given block of code, preventing race conditions. It also creates the happens-before edge that makes one thread's changes visible to the next thread that acquires the same lock — synchronized gives you both mutual exclusion and visibility, which is why it is not simply interchangeable with volatile.
Consider the following code example:

package codeKatha;

public class BankAccount {

    private double balance;

    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        balance -= amount;
    }

    public synchronized double getBalance() {
        return balance;
    }
}

Note that getBalance() is synchronized too. Without it a reader could see a stale balance even though the writes are guarded — and on a 32-bit JVM a non-volatile double can even be read half-updated, since 64-bit reads are not guaranteed atomic.

The synchronized keyword should be used when:
  • You have shared mutable state, like the balance above, accessed by multiple threads.
  • You need a compound action — read, decide, write — to be indivisible. Volatile cannot do this.
Points to know:
  • A synchronized instance method locks on this; a synchronized static method locks on the class object. They are different locks and do not exclude each other.
  • Locks are reentrant — a thread already holding a lock can acquire it again without deadlocking itself.
  • Prefer a private lock object or a synchronized block over synchronizing the whole method, so external code cannot acquire your lock and interfere.
  • For anything needing a timeout, a try-lock, or fairness, use ReentrantLock from java.util.concurrent.locks instead.

What is the difference between a shallow copy and a deep copy in Java, and when should you use each one? Include a code example.

Shallow Copy:

A shallow copy creates a new object that is a copy of the original object, but it does not create new copies of the objects referenced by the original. Instead, it copies references to the same objects. As a result, changes to the objects inside the copy are reflected in the original and vice versa.


package codeKatha;

class Student {
    String name;
    Course course;

    public Student(String name, Course course) {
        this.name = name;
        this.course = course;
    }
}

class Course {
    String name;

    public Course(String name) {
        this.name = name;
    }
}

public class ShallowCopyExample {
    public static void main(String[] args) {
        Course course = new Course("Computer Science");
        Student originalStudent = new Student("Advait", course);

        Student shallowCopyStudent = new Student(originalStudent.name, originalStudent.course);

        // Changes in course name affect both original and shallow copy
        shallowCopyStudent.course.name = "Mathematics";

        System.out.println(originalStudent.course.name); // Output: Mathematics
    }
}
Deep Copy:

A deep copy creates a new object and also recursively creates new copies of the objects referenced by the original. This ensures that changes in the copied objects do not affect the original or vice versa.


package codeKatha;

// Uses the same Student and Course classes defined above

public class DeepCopyExample {
    public static void main(String[] args) {

        Course course = new Course("Computer Science");
        Student originalStudent = new Student("Advait", course);

        Student deepCopyStudent =
                new Student(originalStudent.name, new Course(originalStudent.course.name));

        // Changes in course name of deep copy don't affect the original
        deepCopyStudent.course.name = "Mathematics";

        System.out.println(originalStudent.course.name); // Output: Computer Science
    }
}
Key Differences:
  • Shallow Copy: Copies references, changes affect both copies.
  • Deep Copy: Creates new objects, changes are isolated.
Use a shallow copy when:
  • The referenced objects are immutable (String, Integer, LocalDate, a record of immutable components) — then there is nothing to protect against, and a deep copy would be wasted work.
Use a deep copy when:
  • The referenced objects are mutable and the copy must be genuinely independent of the original.

This is also what "defensive copying" means. If a getter returns a reference to a mutable internal field, the caller can modify your object's state from outside. Return a copy instead — or, better, make the field immutable in the first place.

Tell me about clone() function, give some example.

Writing copy logic by hand for every class is repetitive, so Java offers clone(). Getting the details right matters, because this is a favourite source of trick questions.

A correction to a very widespread myth: clone() is not provided by the Cloneable interface. Cloneable is a marker interface — it declares no methods at all. clone() is a protected method on java.lang.Object. All Cloneable does is flip a switch: if a class does not implement it, Object.clone() throws CloneNotSupportedException. And because clone() is protected, you must also override it as public for anyone outside the class to call it.

Object.clone() performs a shallow copy by default — it copies each field bit for bit, so reference fields end up pointing at the same objects.

Shallow Copy using clone():

package codeKatha;

class Person implements Cloneable {

    String name;
    Address address;

    public Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    @Override
    public Person clone() throws CloneNotSupportedException {
        // Covariant return type: Object.clone() returns Object,
        // we narrow it to Person so callers need no cast.
        return (Person) super.clone();
    }
}

class Address implements Cloneable {

    String city;

    public Address(String city) {
        this.city = city;
    }

    @Override
    public Address clone() throws CloneNotSupportedException {
        return (Address) super.clone();
    }
}

public class CloneShallowExample {

    public static void main(String[] args) throws CloneNotSupportedException {

        Address address = new Address("Delhi");
        Person originalPerson = new Person("Advait", address);

        Person clonedPerson = originalPerson.clone();

        // Both objects share the SAME Address instance
        clonedPerson.address.city = "Muzaffarpur";

        System.out.println(originalPerson.address.city); // Output: Muzaffarpur
    }
}

Note that Address now implements Cloneable and overrides clone() as public. Without both of those, the deep copy below would not even compile — Object.clone() is protected, so address.clone() is not callable from another class.

Deep Copy using clone():

To get a deep copy, override clone() and clone the referenced objects inside it, so callers cannot forget:


package codeKatha;

class PersonDeep implements Cloneable {

    String name;
    Address address;

    public PersonDeep(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    @Override
    public PersonDeep clone() throws CloneNotSupportedException {
        PersonDeep copy = (PersonDeep) super.clone();  // shallow copy first
        copy.address = this.address.clone();           // then deepen it
        return copy;
    }
}

public class CloneDeepExample {

    public static void main(String[] args) throws CloneNotSupportedException {

        Address address = new Address("Delhi");
        PersonDeep originalPerson = new PersonDeep("Advait", address);

        PersonDeep clonedPerson = originalPerson.clone();

        // Each object now has its OWN Address instance
        clonedPerson.address.city = "Muzaffarpur";

        System.out.println(originalPerson.address.city); // Output: Delhi
    }
}
Why modern Java code avoids clone():

Effective Java advises against the Cloneable/clone() mechanism altogether. It bypasses constructors, it cannot assign final fields, it throws a checked exception for a case that is usually impossible, and every class in the hierarchy has to cooperate. Prefer a copy constructor or a static factory method — they are plain Java, they work with final fields, and they can return a different type:

 // Copy constructor - simpler and safer than clone()
public Person(Person other) {
    this.name = other.name;
    this.address = new Address(other.address.city);  // deep
}

// Static factory alternative
public static Person copyOf(Person other) {
    return new Person(other);
}

How has Java evolved over the years, and what are some key features introduced in recent Java versions (Java 8 onwards)?

Java has changed enormously since 1995, and the pace picked up after Java 9 introduced the six-month release cadence. Long-Term Support releases are the ones most companies actually run: Java 8, 11, 17, 21 and 25. As of now, Java 25 is the current LTS and Java 21 is the previous one.
  • Java 8 (LTS): Lambdas, Functional Interfaces, Streams API, Optional, default and static methods in interfaces, and the new Date and Time API. Still the biggest single leap in the language.
  • Java 9: Java Platform Module System (JPMS), JShell (REPL), factory methods for collections, Stream API enhancements, private methods in interfaces, and Cleaner as the replacement for finalize(). 
  • Java 10: Local variable type inference (var), Application Class-Data Sharing, and garbage collector improvements. 
  • Java 11 (LTS): The standard HTTP Client API, new String methods, running a single-file source program directly, and local variable syntax for lambda parameters. 
  • Java 12: Switch expressions (preview), Shenandoah garbage collector, and the JVM Constants API.
  • Java 13: Text blocks (preview) and switch expression refinements.
  • Java 14: Switch expressions become standard, Records (preview), Pattern Matching for instanceof (preview), and much more helpful NullPointerException messages.
  • Java 15: Text blocks become standard, Sealed Classes (preview), and hidden classes. 
  • Java 16: Records and Pattern Matching for instanceof become standard, and strong encapsulation of JDK internals becomes the default.
  • Java 17 (LTS): Sealed Classes become standard, Pattern Matching for switch (preview), and the removal of the experimental AOT and JIT compilers. Long the default choice for enterprise projects.
  • Java 18: UTF-8 becomes the default charset, and a simple built-in web server for prototyping.
  • Java 19: Virtual Threads (preview), Structured Concurrency (incubator), and Record Patterns (preview).
  • Java 20: Scoped Values (incubator) and further refinement of the above previews.
  • Java 21 (LTS): A landmark release. Virtual Threads become standard, letting you write blocking code that scales to millions of concurrent tasks. Also standard: Pattern Matching for switch, Record Patterns, Sequenced Collections, and Generational ZGC.
  • Java 22: The Foreign Function & Memory API becomes standard, replacing JNI for native interop. Also unnamed variables and patterns.
  • Java 23: Markdown documentation comments, and continued pattern matching work including primitive types in patterns (preview).
  • Java 24: Stream Gatherers become standard, allowing custom intermediate stream operations, plus the Class-File API.
  • Java 25 (LTS): The current Long-Term Support release. It finalises a batch of features that had been in preview through 22–24, including Scoped Values, Module Import Declarations, Flexible Constructor Bodies, and Compact Source Files with instance main methods — which finally makes a beginner's "hello world" a three-line program.

If you are asked what to focus on: Java 8's lambdas and streams are assumed knowledge at this level, so they will not differentiate you. Virtual threads (Java 21) are the feature most worth being able to discuss in 2026 — what they solve, how they differ from platform threads, and why pinning on synchronized blocks used to matter. Records, sealed classes and pattern matching together are the second cluster worth knowing well.

Comments

Popular Posts on Code Katha

Sql Interview Questions for 10 Years Experience

Spring Boot Interview Questions for 10 Years Experience

Java interview questions - Must to know concepts

Visual Studio Code setup for Java and Spring with GitHub Copilot

Spring AI with Ollama

Data Structures & Algorithms Tutorial with Coding Interview Questions

Spring Data JPA

Bit Manipulation and Bit Masking Concepts

Topological Sort in Graph