Java & Spring Interview Questions: What Really Happens Under the Hood
There is a phase in every developer's career where you can use something without actually understanding what is happening underneath.
You know how to use ConcurrentHashMap.
You know what @Transactional does.
You know that Spring injects dependencies.
You know @SpringBootApplication starts your application.
And then the interviewer asks:
"Okay, but what actually happens internally?"
Suddenly, knowing the syntax isn't enough.
That is exactly what this blog is about.
I'm going to break down some of the Java and Spring concepts that are frequently asked in interviews, but more importantly, I'll try to understand why they work the way they do.
Let's start with one of my favourites.
1. ConcurrentHashMap in Java 8: What Actually Happens?
Let's first establish the problem.
A normal HashMap is not thread-safe.
Imagine two threads doing this at the same time:
map.put("A", 10);
map.put("B", 20);
The problem isn't simply that both threads are writing.
The bigger issue is that the internal structure of the map can be modified concurrently, and without proper synchronization, another thread may observe an inconsistent state.
So naturally, the next question is:
Why not just synchronize the entire HashMap?
We could.
But then every operation would effectively wait for every other operation.
That's where ConcurrentHashMap comes in.
Java 7 vs Java 8
This is an important interview distinction.
Older implementations of ConcurrentHashMap used segments.
Java 8 moved away from the segment-based design.
In Java 8, the structure is broadly:
ConcurrentHashMap
|
v
Node[] table
|
+---- Node
|
+---- Node
|
+---- TreeBin
The table contains buckets, similar to HashMap.
Each bucket can contain:
a linked list of nodes
or a tree structure when collisions become large
So where is the concurrency?
This is the interesting part.
ConcurrentHashMap does not lock the entire map for every operation.
For many reads, it uses non-blocking techniques.
For updates, synchronization is performed at a much smaller granularity, typically around the affected bucket/bin.
Conceptually:
Thread 1 -> Bucket 3 -> lock/update
Thread 2 -> Bucket 7 -> lock/update
Both can proceed
Instead of:
Thread 1 -> lock entire map
Thread 2 -> WAIT
This dramatically improves concurrency.
CAS also plays a major role
Java 8's implementation makes heavy use of CAS (Compare-And-Swap).
For example, if a bucket is empty, a thread can attempt to place a new node there atomically.
Conceptually:
if bucket == null
CAS(bucket, null, newNode)
If another thread changed the bucket first, the CAS fails and the operation retries using the updated state.
So the implementation combines techniques such as:
CAS
volatilesynchronized blocks on individual bins when necessary
special nodes for resizing/tree structures
What about reads?
One of the biggest advantages is that reads generally don't require locking.
That's extremely useful when your application has:
1000 readers
100 writers
instead of making every reader fight for a global lock.
One important interview point
ConcurrentHashMap does not allow null keys or null values.
Why?
Because in a concurrent map, null can create ambiguity.
Consider:
map.get("A")
If the result is null, does that mean:
1. Key doesn't exist
or:
2. Key exists and its value is null
In a concurrent environment, that ambiguity becomes problematic, so ConcurrentHashMap simply doesn't allow nulls.
Interview answer
If I had to answer this in an interview:
"ConcurrentHashMap provides thread-safe access without synchronizing the entire map. In Java 8, it uses a bucket-based structure with CAS for some operations and synchronized locking at the bin level for updates when required. Reads are generally non-blocking, which gives much better concurrency than synchronizing a complete HashMap."
That is usually a much stronger answer than:
"ConcurrentHashMap is a thread-safe HashMap."
2. HashMap vs ConcurrentHashMap: Why Do We Need It?
Let's make the difference practical.
Suppose we have:
Map<String, Integer> map = new HashMap<>();
If multiple threads modify this map concurrently, there is no guarantee of thread safety.
You might think:
"Fine, I'll just use
Collections.synchronizedMap()."
That works for basic thread safety, but it uses synchronization around map operations.
Conceptually:
Thread A
|
v
[ LOCK ]
|
HashMap
|
[UNLOCK]
Thread B
|
v
WAIT
With ConcurrentHashMap, the implementation allows much greater concurrency.
Quick comparison
| Feature | HashMap | ConcurrentHashMap |
|---|---|---|
| Thread-safe | No | Yes |
| Null key | Yes | No |
| Null values | Yes | No |
| Concurrent reads | Unsafe contract | Supported |
| Concurrent writes | Unsafe | Supported |
| Locking | None | Fine-grained/internal concurrency mechanisms |
| Typical use | Single-threaded/local data | Shared concurrent data |
The important thing isn't:
"Which one is faster?"
The important question is:
"Do multiple threads access and modify this data?"
If yes, thread safety becomes part of the design.
3. Fail-Fast vs Fail-Safe Iterators
This question sounds simple until someone asks:
"What actually makes an iterator fail-fast?"
Let's look at ArrayList.
Internally, ArrayList maintains a modification count:
modCount
When the collection is structurally modified, the count changes.
An iterator captures the expected modification count:
ArrayList
|
modCount = 5
Iterator
|
expectedModCount = 5
Now imagine:
List<Integer> list = new ArrayList<>();
list.add(10);
list.add(20);
Iterator<Integer> iterator = list.iterator();
list.add(30);
The collection's:
modCount = 6
But the iterator still has:
expectedModCount = 5
When the iterator performs its next operation, it checks something conceptually similar to:
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
That's the basic idea behind fail-fast behavior.
But there is an important catch
Fail-fast is best effort.
It is not a thread-safety mechanism.
You should never say:
"ArrayList is safe because it throws ConcurrentModificationException."
No.
The exception is basically there to help detect unexpected structural modification during iteration.
What is fail-safe then?
"Fail-safe" is a commonly used interview term, although the Java documentation doesn't formally define every concurrent iterator under that exact category.
The idea is that the iterator doesn't directly depend on a structure that is being modified in the same way.
For example:
CopyOnWriteArrayList<Integer> list =
new CopyOnWriteArrayList<>();
When the list is modified, a new underlying array can be created.
Existing iterators continue operating on the snapshot they already obtained.
So conceptually:
Original array
[10, 20, 30]
|
+---- Iterator sees this
Modification
|
v
New array
[10, 20, 30, 40]
The iterator doesn't suddenly start walking the new array.
Interview answer
"Fail-fast iterators generally detect structural modification using a modification count and may throw ConcurrentModificationException. Fail-safe is commonly used to describe iterators such as CopyOnWriteArrayList's iterator, which can iterate over a snapshot and therefore doesn't fail in the same way when the collection is modified."
4. Functional Interface: Can It Have Default and Static Methods?
A functional interface is an interface with exactly one abstract method.
For example:
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
}
Now we can do:
Calculator addition = (a, b) -> a + b;
That's where lambdas come in.
But here's the interview trap.
Someone asks:
"Can a functional interface contain other methods?"
Yes.
It can contain:
exactly one abstract method
multiple default methods
multiple static methods
For example:
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
default void print() {
System.out.println("Calculator");
}
static void info() {
System.out.println("Utility method");
}
}
This is still a functional interface because it has only one abstract method.
Why?
Because default and static methods already have implementations.
The lambda only needs to provide the implementation for the single abstract method.
Common examples
Java provides many functional interfaces:
Predicate<T>
Function<T, R>
Consumer<T>
Supplier<T>
For example:
Predicate<Integer> isEven =
n -> n % 2 == 0;
This becomes extremely useful with Streams.
5. Try-With-Resources: What Happens When Both Try and Close Throw?
This is one of those questions where the interviewer is checking whether you actually understand Java exception handling.
Consider:
try (MyResource resource = new MyResource()) {
throw new RuntimeException("Try failed");
}
And suppose:
resource.close();
also throws:
RuntimeException("Close failed");
Which exception do we get?
The exception from the try block is the primary exception.
The exception from close() becomes a suppressed exception.
Conceptually:
Primary Exception
|
+--- suppressed: Close Exception
You can retrieve suppressed exceptions using:
exception.getSuppressed();
Why was this designed this way?
Because the original operation is usually more important.
Imagine:
Database operation failed
+
Connection cleanup failed
The actual business failure shouldn't disappear just because cleanup also failed.
What does try-with-resources require?
The resource must implement:
AutoCloseable
or:
Closeable
For example:
try (BufferedReader reader =
new BufferedReader(new FileReader("data.txt"))) {
// use resource
}
Java automatically calls:
reader.close();
even if an exception occurs.
And if multiple resources exist, they are closed in reverse order.
try (
ResourceA a = ...;
ResourceB b = ...
) {
}
Closing order:
B
A
6. Major Java 8 Features and Where We Actually Use Them
Java 8 was a pretty big shift in how Java code was written.
The features I would be ready to discuss in an interview are:
Lambda Expressions
Before:
Collections.sort(list, new Comparator<User>() {
@Override
public int compare(User a, User b) {
return a.getAge() - b.getAge();
}
});
After:
list.sort((a, b) -> a.getAge() - b.getAge());
Useful when:
passing behavior
callbacks
collection operations
streams
Functional Interfaces
They provide the target type for lambdas.
Predicate<User> adult =
user -> user.getAge() >= 18;
Stream API
Instead of manually iterating:
List<String> names = new ArrayList<>();
for (User user : users) {
if (user.getAge() > 18) {
names.add(user.getName());
}
}
We can express the operation as:
List<String> names = users.stream()
.filter(user -> user.getAge() > 18)
.map(User::getName)
.toList();
The key idea isn't:
"Streams are shorter."
The real advantage is that they allow us to express what we want to do rather than manually controlling every iteration step.
Optional
Optional was introduced to represent the possible absence of a value.
Instead of:
User user = repository.findById(id);
if (user != null) {
...
}
we can have:
Optional<User> user =
repository.findById(id);
And:
user.map(User::getName)
.orElse("Unknown");
It doesn't magically eliminate every NullPointerException, but it can make absence explicit in APIs.
Default and Static Interface Methods
Default methods allowed interfaces to evolve without forcing every existing implementation to immediately implement a new method.
interface Vehicle {
default void start() {
System.out.println("Starting");
}
}
This was especially important for evolving Java's collection APIs.
New Date and Time API
Instead of the older problematic date APIs:
Date
Calendar
Java 8 introduced:
LocalDate
LocalDateTime
Instant
ZonedDateTime
Duration
Period
Example:
LocalDate today = LocalDate.now();
Much cleaner and generally safer because the new API is immutable and better designed.
7. What Happens When @SpringBootApplication Is Executed?
This is where Spring Boot starts becoming interesting.
We write:
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
And somehow...
Spring creates the application.
Finds beans.
Injects dependencies.
Starts Tomcat.
Loads configuration.
Runs our application.
But what actually happened?
Let's break it down.
Step 1: main() executes
Java starts here:
SpringApplication.run(Application.class, args);
Spring Boot creates a SpringApplication instance.
Step 2: Application context is created
Spring creates the appropriate ApplicationContext.
For a web application, this eventually becomes a web-aware application context.
Think of the ApplicationContext as the container responsible for managing our Spring beans.
Step 3: Configuration is discovered
This annotation:
@SpringBootApplication
is effectively a combination of:
@Configuration
@EnableAutoConfiguration
@ComponentScan
That's a very common interview question.
@Configuration
Tells Spring:
This class can provide bean definitions.
@ComponentScan
Tells Spring to scan packages for components such as:
@Component
@Service
@Repository
@Controller
@RestController
@EnableAutoConfiguration
This is where Spring Boot becomes powerful.
Spring Boot looks at:
classpath dependencies
configuration
conditions
and automatically configures appropriate beans.
For example, if Spring MVC dependencies are present, Spring Boot can configure the infrastructure required for a web application.
Step 4: Bean definitions are created
Spring identifies beans and registers their definitions in the container.
For example:
@Service
public class UserService {
}
Spring knows:
Bean name -> userService
Bean type -> UserService
Scope -> singleton by default
Step 5: Dependencies are resolved
Suppose:
@Service
class UserService {
private final UserRepository repository;
UserService(UserRepository repository) {
this.repository = repository;
}
}
Spring sees:
UserService
|
v
UserRepository
It resolves the dependency and creates the object graph.
Step 6: Embedded server starts
For a Spring Boot web application, an embedded server such as Tomcat is started.
So instead of manually deploying a WAR into an external server, your application can start with:
java -jar application.jar
and the embedded server comes up as part of application startup.
8. How Does Spring Dependency Injection Actually Work?
Let's say we have:
@Service
class OrderService {
private final PaymentService paymentService;
OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
We didn't write:
new PaymentService();
So who created it?
Spring.
The basic process is:
Application starts
|
v
Component scanning
|
v
Bean definitions
|
v
Dependency resolution
|
v
Bean creation
|
v
Dependency injection
Spring's BeanFactory / ApplicationContext infrastructure manages this process.
Constructor injection
When Spring needs to create OrderService, it sees that its constructor requires:
PaymentService
Spring checks its container.
If a suitable bean exists:
PaymentService bean
|
v
OrderService constructor
Spring passes that bean into the constructor.
This is why constructor injection is generally preferred.
The dependencies become explicit, and the object can be created only when all required dependencies are available.
9. @Transactional and Spring Proxies
This one is probably responsible for more interview confusion than almost anything else in Spring.
You write:
@Transactional
public void transferMoney() {
...
}
And Spring magically manages:
BEGIN TRANSACTION
|
method()
|
COMMIT
But how?
The answer is:
A proxy.
Conceptually:
Your code
|
v
Spring Proxy
|
+-- Begin transaction
|
v
Actual Service
|
+-- execute method
|
v
Commit / Rollback
Spring creates a proxy around the bean.
When another bean calls:
service.transferMoney();
the call goes through the proxy.
The proxy's transaction interceptor detects @Transactional.
Then:
1. Start transaction
2. Invoke actual method
3. If successful -> commit
4. If appropriate exception -> rollback
5. End transaction
The famous self-invocation problem
Consider:
public void methodA() {
methodB();
}
@Transactional
public void methodB() {
}
If methodA() and methodB() belong to the same object, the call:
methodB();
is effectively:
this.methodB()
It does not go through the Spring proxy.
Therefore, the transactional interceptor may never get a chance to intercept that call.
This is why self-invocation can cause @Transactional to appear as if it isn't working.
Interview answer
"Spring generally implements declarative transactions using proxies and transaction interceptors. The proxy intercepts calls to transactional methods, starts or joins a transaction, invokes the target method, and then commits or rolls back based on the outcome. A common limitation is self-invocation because an internal this.method() call bypasses the proxy."
That answer shows actual understanding.
10. Spring Bean Lifecycle
Here's another question where memorizing annotations isn't enough.
Suppose Spring creates:
@Service
public class PaymentService {
}
What happens?
Conceptually:
Bean Definition
|
v
Instantiate
|
v
Populate dependencies
|
v
Aware callbacks
|
v
BeanPostProcessor - Before
|
v
@PostConstruct
|
v
BeanPostProcessor - After
|
v
Bean Ready
|
v
Application running
|
v
@PreDestroy
|
v
Bean destroyed
Let's simplify it into something interview-friendly.
1. Instantiation
Spring creates the object.
new PaymentService(...)
conceptually.
2. Dependency injection
Spring provides required dependencies.
3. Aware interfaces
If the bean implements interfaces such as:
BeanNameAware
ApplicationContextAware
Spring can provide corresponding container information.
4. BeanPostProcessor
Spring gives post-processors a chance to modify or wrap the bean.
This is extremely important because Spring uses this infrastructure for many features.
5. @PostConstruct
After dependency injection, initialization logic can execute:
@PostConstruct
public void init() {
}
6. Bean is ready
The bean can now be used by the application.
7. Destruction
When the application context shuts down:
@PreDestroy
public void cleanup() {
}
can be called for applicable beans.
11. Spring Bean Scopes
The default scope is:
singleton
But singleton here means:
One bean instance per Spring ApplicationContext.
It does not necessarily mean one object for the entire JVM.
Common scopes include:
Singleton
@Scope("singleton")
One instance per container.
Prototype
@Scope("prototype")
A new instance is created each time the bean is requested from the container.
Request
One instance per HTTP request.
Useful in web applications.
Session
One instance per HTTP session.
Application
One instance per ServletContext.
WebSocket
One instance per WebSocket lifecycle.
The important interview distinction is:
Singleton and prototype describe Spring bean lifecycle management, not the Gang of Four Singleton design pattern.
12. JPA N+1 Query Problem
Now let's move to something that can quietly destroy application performance.
Suppose we have:
@Entity
class Department {
@OneToMany
private List<Employee> employees;
}
And we fetch:
List<Department> departments =
departmentRepository.findAll();
Suppose we have 100 departments.
You might expect:
1 query
But then accessing:
department.getEmployees()
for each department can trigger:
1 query -> fetch departments
100 queries -> fetch employees
Total:
101 queries
That's the N+1 problem.
And here's the dangerous part:
Your Java code looks completely innocent.
for (Department department : departments) {
department.getEmployees();
}
But underneath, Hibernate may be firing query after query.
How do we solve it?
There isn't one universal solution.
The right solution depends on the use case.
1. JOIN FETCH
For example:
@Query("""
SELECT d
FROM Department d
JOIN FETCH d.employees
""")
List<Department> findDepartmentsWithEmployees();
This tells Hibernate to fetch the relationship in the same query.
Conceptually:
Before:
Department query
|
+--> Employee query
+--> Employee query
+--> Employee query
+--> ...
After:
Department JOIN Employee
2. Entity Graph
You can use:
@EntityGraph(attributePaths = {"employees"})
This can be cleaner when you want to control fetching without writing custom JPQL for every case.
3. Batch fetching
Hibernate can fetch related entities in batches rather than one at a time.
Instead of:
1 + 100 queries
you may reduce the number significantly by fetching groups of relationships.
4. DTO projections
And this is one I particularly like when building APIs.
If your endpoint only needs:
Department ID
Department Name
Employee Count
why load the entire entity graph?
Use a projection/DTO that retrieves exactly what the API needs.
That's not just an N+1 solution.
It's often a better architectural decision.
The Bigger Picture
Notice something interesting.
These questions look unrelated:
ConcurrentHashMap
Try-with-resources
Functional interfaces
Spring DI
@Transactional
Bean lifecycle
JPA N+1
But they're actually testing the same thing.
Do you understand what your code is causing underneath?
When you write:
map.put()
what happens inside the data structure?
When you write:
@Transactional
who actually intercepts your method?
When you write:
@Autowired
who creates the object?
When you access:
department.getEmployees()
how many SQL queries are actually going to the database?
That's the level at which framework knowledge starts becoming engineering knowledge.
A Simple Interview Strategy
One thing that has helped me is answering these questions in layers.
Don't immediately dump everything you know.
Start with the simple answer.
Then explain the internal mechanism.
Then mention the important edge case.
For example:
Interviewer:
"How does @Transactional work?"
Weak answer:
"It manages transactions automatically."
Better:
"Spring uses proxies and transaction interceptors to manage declarative transactions."
Stronger:
"When a transactional method is invoked through the Spring proxy, the transaction interceptor starts or joins a transaction, invokes the target method, and commits or rolls back depending on the outcome. Self-invocation is a common limitation because internal calls bypass the proxy."
That progression shows something much more valuable than memorization.
It shows that you understand what happens behind the annotation.
And honestly, that's where most framework interviews eventually go.
Because knowing:
@Transactional
is easy.
Understanding why it sometimes doesn't work?
That's the real interview question.
Comments
Post a Comment