Java interview questions
Table of Contents
Lambdas vs Anonymous Classes
In Java, both anonymous classes and lambda expressions allow developers to define small blocks of behavior inline — typically to pass as callbacks or functional arguments. However, the way they are compiled and executed under the hood is quite different, with important implications for performance and memory efficiency.
An anonymous inner class is a full-fledged, unnamed class created at compile time. Each one generates a separate .class file (for example, OuterClass$1.class) that must be loaded and verified at runtime. This introduces additional overhead related to class loading, memory allocation, and object instantiation. Each use of an anonymous class typically results in a new object created on the heap.
On the other hand, lambda expressions are implemented more efficiently using theinvokedynamic instruction introduced in Java 7. Instead of creating a new class file, the JVM links the lambda to its corresponding functional interface at runtime, often reusing the same instance if no variables are captured from the enclosing scope. This means that lambdas typically have far less overhead in both memory and class-loading time.
Modern JVMs further optimize lambdas through techniques like method inlining — especially when a lambda is small or frequently invoked in a tight loop. In some cases, such as with method references, the compiler can even bypass object creation altogether, directly referencing the existing method.
.class file for each instance.invokedynamic for dynamic runtime binding, avoiding extra class files.In short, while anonymous classes provide flexibility and full object-oriented semantics, lambdas are lighter, more efficient, and better aligned with functional programming styles. They are preferred in modern Java for concise, performant, and cleaner code.
What is Inlining
Inlining is one of the most fundamental and powerful optimizations performed by the JVM's Just-In-Time (JIT) compiler. Inlining means replacing a method call with the method's actual body of code. Instead of performing a separate function call — which involves stack setup, jumps, and returns — the JIT copies the method's body directly into the caller at runtime.
1int square(int x) {
2 return x * x;
3}
4
5int compute(int a, int b) {
6 return square(a) + square(b);
7}Without inlining, the compiled bytecode performs:
square(a) and square(b))When the JIT compiler detects that square() is small and frequently called, it inlines the method. The resulting optimized code looks like this:
1int compute(int a, int b) {
2 return (a * a) + (b * b);
3}By inlining, the JVM avoids unnecessary method calls and enables further optimizations. The benefits include:
constant folding, loop unrolling, and dead code elimination.However, inlining is not always beneficial. Excessive inlining can lead to code bloat, which increases the size of compiled machine code. This can:
In summary, inlining trades off a small increase in code size for a large potential gain in runtime performance. It's one of the JVM's most important optimizations for achieving the speed of native code while maintaining Java's flexibility and portability.
What is invokedynamic?
Prior to Java 7, the JVM provided four primary bytecode instructions for invoking methods:invokestatic,invokevirtual,invokespecial, and invokeinterface. These instructions work well for statically typed languages because the compiler knows which method signature should be invoked before the program ever runs.
Each instruction serves a different purpose. invokestatic calls static methods, invokevirtual performs normal virtual dispatch for instance methods, invokeinterface invokes methods declared on interfaces, and invokespecial is used for constructors, private methods, and superclass method calls.
1Math.max(1, 2); // invokestatic
2
3Object obj = "Hello";
4obj.toString(); // invokevirtualWhen compiling these calls, the Java compiler already knows the class, method name, and method descriptor. The generated bytecode therefore contains symbolic references to the target method, allowing the JVM to resolve and cache the linkage without needing additional runtime decision-making.
This model works extremely well for Java, but it is too restrictive for dynamic languages such as Groovy and JRuby. Consider the following pseudo-code:
1obj = someMethod() # Type only known at runtime
2
3obj.doSomething()At compile time, the compiler has no idea what type obj will actually be. Consequently, it cannot determine which implementation of doSomething() should be called. Before Java 7, JVM language implementations often relied on reflection or generated complicated bytecode to emulate dynamic method dispatch, resulting in additional complexity and runtime overhead.
invokedynamic, introduced in Java 7 (JSR 292), solves this problem by allowing method linkage to be deferred until runtime. Instead of embedding the final target method directly into the bytecode, the JVM determines how the call should be linked the first time the instruction is executed.
How does invokedynamic work?
When the JVM encounters an invokedynamic instruction for the first time, it invokes a bootstrap method. This method is responsible for determining how the call site should be linked.
The bootstrap method returns a CallSite, which holds a MethodHandle. A MethodHandle is a lightweight, strongly typed reference to executable code, providing a much faster alternative to reflection.
After the call site has been linked, the JVM caches the resulting CallSite. Every future execution of the same invokedynamic instruction jumps directly through the cached MethodHandle, eliminating the cost of repeated method resolution.
Lambdas and invokedynamic
One of the most common uses of invokedynamic is Java lambdas.
1Runnable task = () -> System.out.println("Hello");Rather than generating a separate anonymous inner class during compilation, the Java compiler emits an invokedynamic instruction. When the lambda is first executed, the JVM invokes LambdaMetafactory.metafactory(...), which creates and links the lambda implementation. The resulting CallSite is then cached, allowing future lambda invocations to execute with performance comparable to ordinary method calls.
Why is it important?
It provides first-class JVM support for dynamic method dispatch, making dynamic languages significantly faster and simpler to implement.
It enables modern Java features such as lambdas and method references without requiring the compiler to generate additional anonymous classes.
By resolving a call site only once and caching the resulting MethodHandle, it combines runtime flexibility with performance close to statically linked method invocations.
You can think of invokedynamic as telling the JVM: "Don't decide what this call means during compilation. Figure it out the first time it's executed, remember the answer, and make every subsequent call fast."
Standard memory model
In the JVM memory model, local variables declared inside a method (e.g.,MyObject obj) store references, which are essentially pointers to objects. These references are stored in the current thread's stack frame, making them thread-local and fast to access.
The actual objects themselves are typically allocated on the heap when using new. The heap is shared across threads and managed by the garbage collector, which handles memory allocation and reclamation automatically.
However, modern JVMs do not strictly follow this simple stack-versus-heap model due to aggressive runtime optimizations.
One important optimization is escape analysis. If the JVM determines that an object does not escape the scope of a method (for example, it is not returned or shared with other threads), it may allocate the object on the stack instead of the heap. This avoids heap allocation overhead and reduces pressure on the garbage collector.
Another optimization is scalar replacement, where the JVM eliminates the object allocation entirely. Instead of creating an object, its individual fields are broken down and stored directly in registers or on the stack, allowing for more efficient execution.
In summary, while the conceptual model is “references on the stack and objects on the heap,” modern JVMs dynamically optimize this behavior at runtime to reduce allocation costs and improve performance.
Serialization and Deserialization
Serialization is the process of converting an object's state into a byte stream, while deserialization is the reverse process of reconstructing an object from that byte stream. This mechanism is commonly used for persistence (saving objects to files or databases) and network communication (sending objects between different JVMs).
Serializable Interface: A marker interface (it contains no methods) that a class must implement to be eligible for serialization.
ObjectOutputStream: Used to serialize objects via the writeObject() method.
ObjectInputStream: Used to deserialize objects via the readObject() method.
transient Keyword: Marks fields that should not be serialized, such as sensitive data (e.g., passwords) or temporary values.
static Fields: These are not serialized because they belong to the class rather than individual object instances.
serialVersionUID: A unique identifier used for class versioning. If the sender and receiver have mismatched IDs, deserialization will fail with an InvalidClassException.
1import java.io.*;
2
3class User implements Serializable {
4 private static final long serialVersionUID = 1L;
5
6 private String name;
7 private transient String password; // Will not be serialized
8
9 public User(String name, String password) {
10 this.name = name;
11 this.password = password;
12 }
13
14 public static void main(String[] args) {
15 // Serialization
16 try (ObjectOutputStream oos =
17 new ObjectOutputStream(new FileOutputStream("user.ser"))) {
18
19 User user = new User("Alice", "secret123");
20 oos.writeObject(user);
21
22 } catch (IOException e) {
23 e.printStackTrace();
24 }
25
26 // Deserialization
27 try (ObjectInputStream ois =
28 new ObjectInputStream(new FileInputStream("user.ser"))) {
29
30 User user = (User) ois.readObject();
31 System.out.println(user.name); // Prints "Alice"
32 System.out.println(user.password); // Prints null (transient)
33
34 } catch (IOException | ClassNotFoundException e) {
35 e.printStackTrace();
36 }
37 }
38}You can override writeObject() and readObject() within your class to add custom logic, such as encryption or validation during serialization and deserialization.
If a parent class implements Serializable, all of its subclasses are automatically serializable. However, if a parent class is not serializable, its no-argument constructor will be invoked during deserialization.
Externalizable Interface: An alternative that gives you full control over the serialization process. You must explicitly implement writeExternal() and readExternal().
Security Note: Deserializing untrusted data is a serious security risk and can lead to remote code execution vulnerabilities. It is recommended to use serialization filters (introduced in Java 9) or safer alternatives such as JSON or Protocol Buffers when handling untrusted data.
Platform Threads vs Virtual Threads
Prior to Project Loom, every Java thread was a platform thread, which maps directly to an operating system (OS) thread. Since each Java thread owns an OS thread for its entire lifetime, creating large numbers of threads is expensive in terms of memory consumption and operating system resources.
Java 21 introduced virtual threads as a stable feature. Unlike platform threads, virtual threads are managed by the JVM rather than the operating system. A virtual thread is mounted onto an available platform thread only while it is actively executing. When it blocks on most I/O operations, it is automatically unmounted, allowing the platform thread (known as the carrier thread) to execute another virtual thread.
This decoupling allows applications to retain the familiar thread-per-request programming model while scaling to hundreds of thousands—or even millions—of concurrent tasks without requiring the same number of operating system threads.
Platform threads
Every platform thread has a one-to-one relationship with an OS thread. The operating system is responsible for scheduling, context switching, and resource management.
Each platform thread reserves its own native stack (typically around 1–2 MB), making thread creation relatively expensive. Creating too many platform threads can eventually exhaust system resources or result in an OutOfMemoryError.
Platform threads are well suited for CPU-intensive workloads or applications where the number of concurrently executing threads is relatively small.
Virtual threads
Virtual threads are lightweight threads scheduled by the JVM instead of the operating system. Thousands—or even millions—of virtual threads can share a much smaller pool of platform threads.
When a virtual thread performs a blocking operation, such as waiting for a database query, reading from a socket, or accessing the file system, the JVM suspends the virtual thread and releases its carrier thread to execute other virtual threads. Once the blocking operation completes, the virtual thread is remounted onto an available carrier thread and resumes execution.
Because virtual threads have a much smaller memory footprint and are inexpensive to create, they are ideal for applications that spend most of their time waiting on I/O rather than performing CPU-intensive computations.
1// Create and start a virtual thread
2Thread.startVirtualThread(() -> {
3 System.out.println("Running in a virtual thread");
4});
5
6// Or use an ExecutorService
7try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
8
9 executor.submit(() -> {
10 System.out.println("Task executed");
11 });
12
13}Things to watch out for
Virtual threads are designed to be extremely lightweight. Unlike platform threads, they should generally not be pooled. Creating a new virtual thread for each task is both cheap and the recommended programming model.
A virtual thread may become pinned if it blocks while executing inside a synchronized block or while executing native (JNI) code. During this time, the carrier thread cannot be released to execute other virtual threads, reducing the scalability benefits of virtual threads.
Virtual threads are primarily designed for I/O-bound workloads. They do not make CPU-bound computations execute faster, since CPU-intensive tasks are ultimately limited by the number of available processor cores.
When should you use virtual threads?
High-concurrency web servers that need to process thousands of simultaneous HTTP requests.
Applications that perform frequent blocking operations such as database queries, REST API calls, file I/O, or interactions with message queues.
Systems where you want to write simple, sequential blocking code instead of adopting asynchronous or reactive programming models, while still achieving excellent scalability.
Chronicle Bytes
net.openhft.chronicle.bytes.Bytes is the core abstraction of the Chronicle Bytes library. It provides a high-performance API for reading from and writing to contiguous regions of memory, whether that memory resides on the Java heap, off-heap (direct memory), or within a memory-mapped file. The library is widely used in low-latency applications such as trading systems, market data processing, and high-performance messaging.
Unlike Java's ByteBuffer, which has a relatively limited API and requires careful buffer management,Bytes provides a much richer interface for working with binary data. It supports sequential and random access, variable-length encodings, efficient UTF-8 string handling, and numerous methods for reading and writing primitive data types.
One of the library's primary goals is to minimize garbage collection. By storing data in off-heap memory, applications can avoid allocating large numbers of temporary Java objects, reducing GC pauses and providing more predictable latency. This makes Chronicle Bytes particularly well suited for applications where consistent response times are more important than raw throughput.
The library also supports elastic buffers, which grow automatically as additional data is written. This removes much of the manual capacity management normally required when working with fixed-size buffers, while still allowing developers to allocate fixed-capacity buffers when deterministic memory usage is preferred.
Another important feature is support for memory-mapped files. Instead of copying data between the JVM and the operating system, Chronicle Bytes can map a file directly into memory, allowing applications to access persistent data as though it were ordinary memory. This significantly reduces I/O overhead and forms the foundation of other Chronicle libraries such as Chronicle Queue and Chronicle Map.
Because off-heap memory is not managed by the Java garbage collector, Chronicle Bytes uses a reference counting mechanism to deterministically release resources when they are no longer needed. This helps prevent native memory leaks while maintaining the performance benefits of off-heap allocation.
1import net.openhft.chronicle.bytes.Bytes;
2
3public class Example {
4 public static void main(String[] args) {
5 Bytes<?> bytes = Bytes.elasticHeapByteBuffer();
6 bytes.writeUtf8("Hello, World!");
7
8 String message = bytes.readUtf8();
9 System.out.println(message);
10 bytes.releaseLast();
11 }
12}Why use Chronicle Bytes?
It provides significantly lower allocation overhead than creating many temporary Java objects, helping reduce garbage collection pauses in latency-sensitive applications.
It offers a richer and more efficient API than ByteBuffer, making it easier to serialize and deserialize binary data.
It integrates seamlessly with off-heap memory and memory-mapped files, making it a common building block for high-performance persistence, messaging, and inter-process communication.
Common use cases
Building low-latency trading systems, market data feeds, and messaging platforms where minimizing GC pauses is critical.
Implementing high-performance serialization, binary protocols, and network communication.
Working with memory-mapped files for fast persistence and sharing data efficiently between multiple processes.