Java Interview Questions for 3 Years Experience 2026
Preparing for java interview questions for 3 years experience requires a different strategy than entry-level interviews. By the time you’ve spent three years working with Java, interviewers expect you to understand not just the syntax, but the practical application of design patterns, performance optimization, and real-world problem-solving. This guide covers what TCS, Infosys, Wipro, EPAM, and top startups are asking mid-level Java developers in 2026.
![]()
⏱️ 26 min read | 📚 Updated June 2026
💡 Quick Tip: Need fast answers? Jump directly to the FAQ section below.
After three years in the field, you’re no longer expected to explain basic concepts like loops or conditional statements. Instead, interviewers focus on your ability to design scalable systems, write thread-safe code, optimize application performance, and make architectural decisions. The questions in this guide reflect actual interview scenarios from India’s largest IT companies and fast-growing tech startups.
This comprehensive article breaks down java interview questions for 3 years experience into practical sections with real code examples, performance analysis, and the exact answers that have helped developers crack interviews at major companies. Whether you’re preparing for your next opportunity or advancing within your current organization, this resource covers everything you need to know.
Table of Contents
- Why Interviewers Ask These Questions
- Skills Comparison: Entry vs Mid-Level
- Core Concepts for 3 Years Experience
- Top Interview Questions & Answers
- Advanced Code Examples
- Common Mistakes to Avoid
- Best Practices for Interview Success
- Final Recommendations
- Frequently Asked Questions
Why Interviewers Ask These Questions

At the 3-year mark, interviewers need to assess whether you’ve genuinely grown as a developer or simply repeated the same year of experience three times. The java interview questions for 3 years experience are designed to separate developers who understand architectural patterns from those who just know syntax. Companies like TCS and Infosys are looking for developers who can mentor juniors, contribute to design decisions, and solve complex production issues.
The questions you’ll face test four critical competencies: (1) System Design — can you design scalable applications?, (2) Concurrency and Threading — do you understand multi-threaded programming and race conditions?, (3) Performance Optimization — can you identify and fix bottlenecks?, and (4) Production Readiness — have you dealt with real-world issues like memory leaks, thread deadlocks, and database connections?.
Startups often ask deeper technical questions because they have smaller teams where every engineer must handle full-stack responsibilities. They expect you to understand database indexing, caching strategies, and REST API design. EPAM, being a boutique consulting firm, focuses heavily on architectural thinking and your ability to communicate technical decisions to non-technical stakeholders.
Skills Comparison: Entry vs Mid-Level Java Developer
| Skill Area | 0-1 Years (Entry-Level) | 3 Years (Mid-Level) | Expected in Interview |
|---|---|---|---|
| Core Java | Loops, conditionals, basic OOP | Generics, collections internals, streams | Implement custom data structures, optimize algorithms |
| Concurrency | Know what threading is | Threads, synchronization, thread pools | Design thread-safe systems, resolve deadlocks |
| Database | Basic CRUD operations | Indexing, query optimization, transactions | Explain N+1 problem, design efficient schemas |
| Frameworks | Spring basics | Spring Boot, Hibernate, REST APIs | Design microservices, handle configuration |
| System Design | Not expected | Basic distributed systems concepts | Design scalable systems, explain trade-offs |
| Problem Solving | Simple algorithms (O(n) or O(n²)) | Complex data structures, optimization | Optimize to O(log n) or O(1), handle edge cases |
Core Concepts for 3 Years Experience
Advanced Collections Framework and Internals
By three years of experience, you should understand not just how to use collections, but why they’re implemented a certain way. The java interview questions for 3 years experience often include detailed questions about HashMap internals, ConcurrentHashMap behavior, and when to use which collection.
For instance, interviewers at Infosys commonly ask: “Why does HashMap use linked lists in buckets when there are hash collisions? How does this change in Java 8?” A three-year developer should know that Java 8 introduced tree-ification — when a bucket’s linked list exceeds 8 elements, it converts to a Red-Black tree for O(log n) lookup instead of O(n). You should also understand the load factor concept and why the default 0.75 is used.
Another critical concept is ConcurrentHashMap’s segment-based locking versus HashTable’s full table locking. Startups specifically test this because it directly impacts application throughput under load. Understanding bucket count, rehashing, and concurrent modification exceptions will help you answer follow-up questions confidently.
Threading, Synchronization, and Concurrent Utilities
Three years in, you should have encountered at least one threading issue in production. Questions about Java interview questions for 3 years experience almost always include threading patterns, lock mechanisms, and atomic operations. Companies like TCS and Wipro ask about the differences between synchronized blocks, ReentrantLock, and ReadWriteLock.
The key distinction interviewers look for is your understanding of happens-before relationships in Java’s memory model. You should be able to explain why synchronized blocks work across cores, how volatile variables prevent instruction reordering, and when AtomicInteger is preferable to synchronized counters. Real-world scenario: If you’re designing a cache that multiple threads access, can you explain why ConcurrentHashMap is better than a synchronized HashMap?
EPAM and boutique consulting firms often ask scenario-based threading questions: “Design a thread pool that handles millions of short-lived tasks efficiently.” This tests your knowledge of ExecutorService, BlockingQueue, and rejection policies. You should also understand the difference between daemon and non-daemon threads and how they affect application shutdown.
Stream API and Functional Programming
The Stream API revolutionized Java 8+, and after three years, you should be comfortable with it. Interviewers test your understanding of lazy evaluation, intermediate vs terminal operations, and when to use streams vs traditional loops. A common follow-up: “What’s the performance implication of using streams with large collections?”
The answer shows experience: streams have overhead for small collections (< 1000 elements), but their parallel stream capability can significantly speed up processing of large datasets. Understanding java interview questions for 3 years experience means knowing how parallel streams work internally — they use ForkJoinPool and divide-and-conquer approach. You should also know when stateless vs stateful operations matter, and why collecting into a stream can cause parallel issues.
Memory Management and Performance Optimization
By three years, you’ve likely faced memory leaks or application slowdowns in production. Questions about garbage collection, heap vs stack, and escape analysis are common in mid-level interviews. Startups frequently ask: “How would you debug an OutOfMemoryError? What tools would you use?”
Understanding the difference between G1GC, ZGC, and CMS garbage collectors shows you’ve worked with large-scale applications. You should be able to discuss heap dumps, JVM profiling tools like YourKit or JProfiler, and how to optimize garbage collection pauses. TCS and Infosys often ask about object pooling, weak references, and soft references — concepts that directly impact production stability.
Design Patterns and Architectural Thinking
Java interview questions for 3 years experience expect you to understand design patterns beyond the textbook. Instead of just reciting the definition of Singleton, interviewers want to know: “How do you make a Singleton thread-safe? Why is the double-checked locking pattern problematic?” You should also understand when NOT to use a pattern — premature pattern application is a red flag.
Companies like EPAM specifically test your ability to refactor code using design patterns. They might show you poorly written code and ask how you’d apply Factory, Strategy, or Decorator patterns. You should also understand architectural patterns like MVC, MVP, and microservices, and be able to explain trade-offs between monolithic and distributed systems.
Top Interview Questions & Answers for 3 Years Experience
1. What’s the difference between HashMap and ConcurrentHashMap? When would you use each?
Why this matters: This question tests your understanding of thread safety and real-world application design. In a three-year career, you’ve likely worked on systems handling concurrent requests.
The Answer: HashMap is not thread-safe — when multiple threads modify it simultaneously, you’ll get data corruption or infinite loops. ConcurrentHashMap uses bucket-level locking (segment locking in older versions, or fine-grained locking in newer versions) to allow multiple threads to read/write different buckets simultaneously.
Use HashMap when: single-threaded access only, or when you externally synchronize access. Use ConcurrentHashMap when: multiple threads read and write concurrently (typical in web applications). A critical detail: ConcurrentHashMap doesn’t allow null keys or values — if your code requires nulls, HashMap wrapped with Collections.synchronizedMap() is necessary, but it’s slower due to table-level locking.
Real scenario from Infosys interviews: “We have a cache that caches user sessions. Thousands of users access it simultaneously. Which would you choose?” Answer: ConcurrentHashMap, because read performance is crucial and multiple threads access it constantly. Also mention that you’d add TTL (Time-To-Live) and eviction policies, which ConcurrentHashMap alone doesn’t provide — you’d wrap it with a custom cache implementation or use Caffeine or Guava Cache.
2. Explain the happens-before relationship. Why does volatile matter in multithreading?
Why this matters: This tests deep Java memory model understanding — the difference between theory and working production code.
The Answer: The happens-before relationship guarantees visibility of changes across threads. Without proper synchronization, one thread might not see changes made by another thread due to instruction reordering and caching by the CPU.
A volatile variable ensures: (1) reads/writes go directly to main memory, not cached in registers, and (2) happens-before relationship with subsequent reads. This prevents instruction reordering. For example:
// Without volatile — race condition
private int value = 0;
private boolean ready = false;
// Thread 1
value = 42; // instruction 1
ready = true; // instruction 2
// Thread 2
if (ready) { // might see ready=true but value=0 (reordered!)
print(value);
}
// With volatile — happens-before
private volatile boolean ready = false;
private int value = 0;
// Thread 1
value = 42; // instruction 1
ready = true; // instruction 2 — all prior writes are visible
// Thread 2
if (ready) { // sees value=42 — guaranteed ordering
print(value);
}
Key point: volatile is NOT a replacement for synchronization. It doesn’t provide mutual exclusion — only visibility and ordering. Use volatile for flags and shutdown signals, but synchronized or atomic operations for counters and state management.
3. How would you design a thread pool? What’s the relationship between queue size, thread count, and rejection policy?
Why this matters: This is a staple question at TCS, Infosys, and startups because it combines practical knowledge with architectural thinking.
The Answer: A thread pool manages a fixed number of threads reusing them for multiple tasks, avoiding the overhead of thread creation/destruction. The ThreadPoolExecutor parameters are:
- corePoolSize: Always-running threads (even if idle)
- maxPoolSize: Maximum threads created during load
- queue: Tasks wait here if core threads are busy
- rejectionPolicy: What happens when queue is full and max threads reached
The flow is: new task → if core threads < corePoolSize, create new thread → if all core threads busy, queue task → if queue full, create up to maxPoolSize threads → if maxPoolSize reached and queue full, reject task.
ExecutorService executor = new ThreadPoolExecutor(
5, // corePoolSize
20, // maxPoolSize
60L, // keepAliveTime
TimeUnit.SECONDS, // timeUnit
new LinkedBlockingQueue<>(1000), // queue
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
// CallerRunsPolicy: if queue is full, the calling thread executes the task
// Other policies: AbortPolicy (throws exception), DiscardPolicy (silent drop),
// DiscardOldestPolicy (removes oldest task and adds new one)
Real-world consideration: For web applications, use CallerRunsPolicy or a custom policy with alerting. AbortPolicy causes application errors (undesirable). For high-throughput systems (like message processing), use bounded queues with DiscardOldestPolicy. Unbounded queues (LinkedBlockingQueue with no capacity) can cause OutOfMemoryError under extreme load.
4. Explain the N+1 query problem in Hibernate. How do you solve it?
Why this matters: This is the most common performance issue developers encounter. Your answer shows real production experience.
The Answer: N+1 queries occur when loading related entities. For example, if you load 100 users and then iterate over each user to fetch their posts, you execute 1 query for users + 100 queries for posts = 101 queries total.
// N+1 PROBLEM:
List users = session.createQuery("from User").list(); // 1 query
for (User user : users) {
Set posts = user.getPosts(); // 100 additional queries!
posts.forEach(p -> print(p.getTitle()));
}
// SOLUTION 1: JOIN FETCH (eager loading)
List users = session.createQuery(
"select distinct u from User u left join fetch u.posts"
).list(); // 1 query with JOIN
for (User user : users) {
user.getPosts().forEach(p -> print(p.getTitle())); // no additional queries
}
// SOLUTION 2: @BatchSize (batch loading)
@Entity
@Table(name = "users")
public class User {
@Id
private Long id;
@OneToMany
@BatchSize(size = 10) // load 10 users' posts in 1 query
private Set posts;
}
// Now: 1 query for users + 10 queries for posts = 11 queries (instead of 101)
// SOLUTION 3: Separate queries with pagination
List posts = session.createQuery(
"from Post where user.id in :userIds order by user.id"
).setParameterList("userIds", userIds).list();
// 1 query for users + 1 query for all posts = 2 queries
Choose the solution based on your data: JOIN FETCH works for one-to-many relationships with small related collections. @BatchSize is better when you have many entities with large collections (pagination friendly). For massive datasets, query separately and assemble in application code.
5. You have a collection of 1 million elements. Iterating takes 10 seconds. How would you optimize?
Why this matters: This tests practical optimization thinking, not just theoretical knowledge.
The Answer: First, understand your problem: Is iteration slow, or is the work inside iteration slow? Use JMH (Java Microbenchmark Harness) or YourKit profiler to measure. Assuming iteration itself is slow:
Step 1 – Check collection type: If using LinkedList and random access, switch to ArrayList. LinkedList has O(n) get() — accessing 1 million elements sequentially is slow.
Step 2 – Use parallel streams (if work is parallelizable):
// Sequential: 10 seconds
List numbers = new ArrayList<>(1_000_000);
// ... populate ...
long sum = numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.reduce(0, Integer::sum);
// Parallel: 2-3 seconds (on 4-core system)
long sum = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.reduce(0, Integer::sum);
Step 3 – Check if work is I/O-bound: If each iteration does network calls or database queries, parallelism helps. If work is CPU-bound computation, parallelism helps up to the number of cores. If work is memory-bound (traversing nested structures), parallelism might hurt due to cache misses.
Step 4 – Consider algorithmic optimization: Can you precompute, cache, or use a smarter algorithm? Sometimes filtering before iteration reduces the dataset size dramatically.
6. Your production application is experiencing GC pauses of 2 seconds every minute. How do you debug and fix this?
Why this matters: This question separates developers with production experience from those who haven’t faced real problems.
The Answer: A 2-second GC pause every minute indicates a full GC (major collection) happening too frequently. Follow this debugging process:
Step 1 – Enable GC logging:
// JVM arguments
-Xms2G -Xmx2G
-XX:+UseG1GC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=100M
// Example output:
// 2024-12-21T10:15:30.500+0530: [Full GC ... 1234M->890M(2048M), 2.000 secs]
// Full GC every 1 minute = heap is filling up too quickly
Step 2 – Analyze heap dump:
// During high load, trigger heap dump
jmap -dump:live,format=b,file=heap.bin
// Analyze with Eclipse MAT or JProfiler
// Look for: large objects, high retention, object count anomalies
// Common causes:
// - Unbounded caches (e.g., HashMap in memory growing forever)
// - String concatenation in loops creating many String objects
// - Static collections holding application lifetime references
Step 3 – Root cause options and fixes:
- Memory leak: Fix unbounded caches using Caffeine with TTL/size limits, or WeakHashMap for transient caches.
- Spike pattern: If load increases predictably, increase heap size or add cache warming.
- GC tuning: Switch from CMS to G1GC for better pause times, or ZGC/Shenandoah for sub-millisecond pauses.
- Object creation rate: Profile using async-profiler to find high-allocation hotspots and reduce object churn.
7. When would you create a custom exception? Show an example with proper use.
Why this matters: This assesses exception handling philosophy and API design thinking.
The Answer: Create custom exceptions when: (1) you need to communicate domain-specific error states, (2) callers need to handle specific errors differently, (3) you want to preserve error context.
// Good custom exception with context
public class PaymentFailedException extends Exception {
private final PaymentStatus status;
private final TransactionId transactionId;
private final BigDecimal amount;
public PaymentFailedException(
String message,
PaymentStatus status,
TransactionId transactionId,
BigDecimal amount
) {
super(message);
this.status = status;
this.transactionId = transactionId;
this.amount = amount;
}
public PaymentStatus getStatus() { return status; }
public TransactionId getTransactionId() { return transactionId; }
public BigDecimal getAmount() { return amount; }
}
// Usage showing domain-specific handling
@Service
public class PaymentService {
public void processPayment(Payment payment) throws PaymentFailedException {
try {
gatewayClient.charge(payment.getAmount());
} catch (GatewayTimeoutException ex) {
throw new PaymentFailedException(
"Payment gateway timeout. Please retry.",
PaymentStatus.PENDING,
payment.getTransactionId(),
payment.getAmount()
);
}
}
}
// Caller can now handle gracefully
try {
paymentService.processPayment(payment);
} catch (PaymentFailedException ex) {
if (ex.getStatus() == PaymentStatus.PENDING) {
// Retry logic with exponential backoff
retryPayment(ex.getTransactionId(), ex.getAmount());
}
}
Key principle: Make exceptions unchecked (extend RuntimeException) if callers can’t meaningfully recover. Make them checked (extend Exception) only if callers MUST handle them. Avoid creating a custom exception for every error — group related errors and let callers examine properties to determine handling.
8. Design a REST API endpoint for getting user posts with pagination, filtering, and sorting. Discuss trade-offs.
Why this matters: REST API design tests your understanding of HTTP semantics, scalability, and API usability.
The Answer:
@RestController
@RequestMapping("/api/v1/users/{userId}/posts")
public class PostController {
@GetMapping
public ResponseEntity<PagedResponse> getPosts(
@PathVariable Long userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String sort,
@RequestParam(required = false) String status
) {
Pageable pageable = PageRequest.of(
page,
size,
Sort.by(parseSort(sort))
);
Page posts = postService.getUserPosts(userId, status, pageable);
return ResponseEntity.ok(
new PagedResponse<>(
posts.getContent().stream()
.map(PostDTO::from)
.collect(Collectors.toList()),
posts.getNumber(),
posts.getSize(),
posts.getTotalElements(),
posts.getTotalPages()
)
);
}
}
// Response structure
public record PagedResponse(
List data,
int currentPage,
int pageSize,
long totalElements,
int totalPages
) {}
// Usage examples
// GET /api/v1/users/123/posts?page=0&size=20&sort=createdAt,desc&status=published
// GET /api/v1/users/123/posts?page=1&size=50&sort=title,asc
Design trade-offs:
| Concern | Decision | Rationale |
|---|---|---|
| Pagination | Offset-based (page/size) | Easy to implement, but slow for large offsets. Cursor-based is better for real-time feeds but more complex. |
| Sorting | Field name + direction in query param | Flexible, but vulnerable to injection. Validate allowed fields in backend. |
| Filtering | Specific query params (status) | Type-safe, but doesn’t scale to many filters. For complex filtering, use specialized query language (like GraphQL). |
| Response structure | Wrap in metadata object | Provides pagination info for client-side infinite scroll, but increases payload. Consider omitting totalElements if it’s expensive to calculate. |
9. Explain ACID properties. How do you ensure consistency in Spring Transactional methods?
Why this matters: Transactions are fundamental to data consistency. Your answer shows whether you understand real-world data corruption risks.
The Answer: ACID stands for:
- Atomicity: Transaction fully completes or fully rolls back. No partial writes.
- Consistency: Data moves from one valid state to another. Constraints are enforced.
- Isolation: Concurrent transactions don’t interfere. Determined by isolation level.
- Durability: Committed data survives failures.
@Service
public class TransferService {
@Transactional
public void transferMoney(Long fromAccountId, Long toAccountId, BigDecimal amount) {
// Spring wraps this method in a transaction
Account from = accountRepository.findById(fromAccountId)
.orElseThrow(() -> new AccountNotFoundException());
Account to = accountRepository.findById(toAccountId)
.orElseThrow(() -> new AccountNotFoundException());
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(); // rollback entire transaction
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountRepository.save(from);
accountRepository.save(to);
// If this method completes, transaction commits
// If exception occurs, entire transaction rolls back
}
}
// Controlling isolation level
@Transactional(isolation = Isolation.SERIALIZABLE) // highest but slowest
public void criticalOperation() { }
// Isolation levels:
// READ_UNCOMMITTED: dirty reads possible (avoid)
// READ_COMMITTED: default, prevents dirty reads
// REPEATABLE_READ: prevents dirty + non-repeatable reads
// SERIALIZABLE: full isolation but severe performance cost
Common pitfall: @Transactional only works on public methods called from outside the object (proxy limitation). Calling @Transactional method from within the same class bypasses the proxy, and transaction doesn’t apply.
10. When would you use an interface vs abstract class? Show a practical example.
Why this matters: This tests your understanding of abstraction levels and API design philosophy.
The Answer: Use interfaces for contracts describing “what” an object does (capabilities/behavior). Use abstract classes for “is-a” relationships with shared implementation.
// INTERFACE: Contract for payment processors
// A payment processor CAN process payments
public interface PaymentProcessor {
PaymentResult process(Payment payment);
boolean supports(PaymentMethod method);
}
// ABSTRACT CLASS: Base for real payment processors
// Stripe, PayPal, Square ARE payment processors
public abstract class BasePaymentProcessor implements PaymentProcessor {
protected final Logger logger = LoggerFactory.getLogger(this.getClass());
protected final HttpClient httpClient;
protected final ConfigProvider config;
protected BasePaymentProcessor(HttpClient httpClient, ConfigProvider config) {
this.httpClient = httpClient;
this.config = config;
}
// Shared template method
public final PaymentResult process(Payment payment) {
validatePayment(payment);
try {
return executePayment(payment);
} catch (NetworkException ex) {
logger.error("Payment failed for {}", payment.getId(), ex);
return new PaymentResult(PaymentStatus.FAILED, ex.getMessage());
}
}
protected abstract PaymentResult executePayment(Payment payment);
protected void validatePayment(Payment payment) {
if (payment.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidPaymentException("Amount must be positive");
}
}
}
// CONCRETE IMPLEMENTATION
public class StripePaymentProcessor extends BasePaymentProcessor {
@Override
public PaymentResult executePayment(Payment payment) {
// Stripe-specific implementation
StripeClient client = new StripeClient(config.getStripeApiKey());
return client.charge(payment);
}
@Override
public boolean supports(PaymentMethod method) {
return method == PaymentMethod.CREDIT_CARD;
}
}
// Usage
@Component
public class PaymentServiceImpl implements PaymentService {
private final List processors;
public PaymentResult processPayment(Payment payment) {
PaymentProcessor processor = processors.stream()
.filter(p -> p.supports(payment.getMethod()))
.findFirst()
.orElseThrow(() -> new ProcessorNotFoundException());
return processor.process(payment);
}
}
Decision matrix: Use interface when you need multiple unrelated implementations (Strategy pattern). Use abstract class when implementations share significant code or state. Multiple interface implementation is allowed; multiple inheritance is not. In Java 8+, interfaces can have default methods and static methods, blurring the distinction — but the semantic intent still matters for API clarity.
Advanced Code Examples
Thread-Safe Immutable Class Design
Immutability is one of the best ways to handle concurrency. Here’s how to design a proper immutable class:
public final class UserProfile {
private final String userId;
private final String name;
private final List roles; // mutable collection
private final Map<String, String> metadata;
public UserProfile(
String userId,
String name,
List roles,
Map<String, String> metadata
) {
this.userId = Objects.requireNonNull(userId);
this.name = Objects.requireNonNull(name);
// Defensive copy to prevent external modification
this.roles = List.copyOf(roles);
this.metadata = Map.copyOf(metadata);
}
public String getUserId() { return userId; }
public String getName() { return name; }
public List getRoles() { return roles; }
public Map<String, String> getMetadata() { return metadata; }
// Builder for complex construction
public static UserProfileBuilder builder() {
return new UserProfileBuilder();
}
public static class UserProfileBuilder {
private String userId;
private String name;
private List roles = new ArrayList<>();
private Map<String, String> metadata = new HashMap<>();
public UserProfileBuilder userId(String userId) {
this.userId = userId;
return this;
}
public UserProfileBuilder name(String name) {
this.name = name;
return this;
}
public UserProfileBuilder addRole(String role) {
this.roles.add(role);
return this;
}
public UserProfileBuilder metadata(String key, String value) {
this.metadata.put(key, value);
return this;
}
public UserProfile build() {
return new UserProfile(userId, name, roles, metadata);
}
}
}
// Usage — thread-safe without synchronization
UserProfile profile = UserProfile.builder()
.userId("user-123")
.name("John Doe")
.addRole("ADMIN")
.addRole("USER")
.metadata("department", "Engineering")
.build();
// Can be safely shared across threads
executor.submit(() -> {
String name = profile.getName(); // safe read
List roles = profile.getRoles(); // returns immutable list
});
Custom Cache Implementation with Expiration
This example shows a thread-safe cache with TTL (Time-To-Live) expiration:
public class ExpiringCache<K, V> {
private static class CacheEntry {
private final V value;
private final long expiresAt;
CacheEntry(V value, long ttlMillis) {
this.value = value;
this.expiresAt = System.currentTimeMillis() + ttlMillis;
}
boolean isExpired() {
return System.currentTimeMillis() > expiresAt;
}
}
private final ConcurrentHashMap<K, CacheEntry> cache;
private final long defaultTtlMillis;
private final ScheduledExecutorService cleaner;
public ExpiringCache(long defaultTtlMillis) {
this.cache = new ConcurrentHashMap<>();
this.defaultTtlMillis = defaultTtlMillis;
this.cleaner = Executors.newScheduledThreadPool(1, r -> {
Thread t = new Thread(r, "Cache-Cleaner");
t.setDaemon(true);
return t;
});
// Periodically remove expired entries
cleaner.scheduleAtFixedRate(
this::removeExpiredEntries,
defaultTtlMillis,
defaultTtlMillis,
TimeUnit.MILLISECONDS
);
}
public void put(K key, V value) {
cache.put(key, new CacheEntry<>(value, defaultTtlMillis));
}
public Optional get(K key) {
CacheEntry entry = cache.get(key);
if (entry == null) {
return Optional.empty();
}
if (entry.isExpired()) {
cache.remove(key);
return Optional.empty();
}
return Optional.of(entry.value);
}
private void removeExpiredEntries() {
cache.entrySet().removeIf(e -> e.getValue().isExpired());
}
public void shutdown() {
cleaner.shutdown();
}
}
// Usage
ExpiringCache<String, UserData> userCache = new ExpiringCache<>(60_000); // 60s TTL
userCache.put("user-123", userData);
Optional cached = userCache.get("user-123"); // returns value if not expired
Common Mistakes to Avoid
- Assuming synchronization everywhere: Not all code needs synchronization. Excessive locking causes bottlenecks. Use concurrent collections and immutable objects instead.
- Ignoring database query performance: Many developers optimize Java code but ignore N+1 queries costing 10x more time. Always profile database calls.
- Mixing transactional concerns with business logic: Use Spring’s @Transactional on service layer boundaries, not on every method. Transaction overhead is real.
- Not validating input in REST APIs: Always validate request parameters. Use JSR-303 annotations (@Valid, @NotNull, @Min, etc.) and proper error responses.
- Catching generic exceptions: Catching Exception or Throwable hides real errors. Catch specific exceptions and let others bubble up for proper logging.
- Using LinkedList for sequential access: LinkedList has O(n) get() for random access. ArrayList is almost always faster. Use LinkedList only if you heavily modify the list in the middle.
- Not understanding garbage collection: Many developers tune JVM flags randomly. Understand your application’s memory pattern before tuning GC.
- Forgetting null safety: Use Optional or assertions to handle nulls. NullPointerException is usually a sign of poor design.
Best Practices for Interview Success
- Explain your thought process: Interviewers care more about how you think than the exact answer. “I would check X, because Y” is better than a silent, correct answer.
- Ask clarifying questions: Don’t assume requirements. Ask about scale, consistency requirements, acceptable latency. Shows maturity.
- Discuss trade-offs explicitly: Every solution has pros and cons. “We could use X, but it’s slower than Y. Y is better for us because…”
- Code defensively: Use null checks, validate inputs, handle edge cases. Don’t assume happy path.
- Know the frameworks you use: If you mention Spring Boot, be able to explain dependency injection, how component scanning works, and profiles.
- Discuss monitoring and observability: Production code needs logging, metrics, and alerting. Mention tools like SLF4J, Prometheus, and structured logging.
- Be honest about what you don’t know: “I haven’t used ZGC, but I understand it’s a low-latency garbage collector. I could research it” is better than bluffing.
- Optimize for readability first: Show you care about maintainability. Future developers (including yourself) need to understand your code.
- Know your projects deeply: Be ready to discuss your real projects. “We fixed an N+1 query issue that reduced page load from 5s to 200ms” is powerful evidence of real experience.
- Practice live coding: Interview coding is different from production. Use a simple text editor without IDE autocomplete to simulate the interview environment.
Final Recommendations
By three years into your Java career, you’ve moved beyond learning language features to solving real-world problems. The java interview questions for 3 years experience reflect this shift in expectation. Interviewers want to see evidence that you’ve dealt with production systems, learned from mistakes, and can design scalable solutions.
Your preparation should focus on understanding the “why” behind design decisions, not memorizing answers. When you can explain why ConcurrentHashMap uses segment-based locking, or why the N+1 problem occurs, or how to debug garbage collection issues, you demonstrate genuine experience that goes beyond reading documentation.
TCS, Infosys, Wipro, EPAM, and startups all value candidates who can take ownership of problems, think systematically about solutions, and communicate clearly. They don’t expect you to know everything — they expect you to learn quickly and apply your knowledge effectively.
Spend time reviewing your past projects and documenting the challenges you faced and how you solved them. These stories are worth more than generic technical knowledge in interviews. Also, keep learning — Java continues to evolve (virtual threads, records, sealed classes), and staying current shows you’re engaged with the ecosystem.
For more structured preparation, check out our mock interview platform where you can practice with real interview scenarios and get feedback on your answers.
Frequently Asked Questions
Q: What’s the difference between volatile and synchronized in Java?
Volatile ensures visibility of changes across threads and prevents instruction reordering, but does NOT provide mutual exclusion. Synchronized provides both visibility AND mutual exclusion — only one thread can enter a synchronized block at a time. Use volatile for flags (boolean shutdown switches), use synchronized for protecting state changes. In modern Java, use AtomicInteger or ReentrantLock for more precise control.
Q: How do you detect and fix memory leaks in Java?
Enable GC logging with -Xloggc and look for full GC happening too frequently. Take heap dumps using jmap and analyze with Eclipse MAT or JProfiler to find objects with high retention. Common causes: unbounded caches (fix with Caffeine + TTL), static collections holding references, listeners not being unregistered, and database connections not closed. Use try-with-resources for AutoCloseable resources and consider using weak references for caches that should not prevent garbage collection.
Q: When should I use ArrayList vs LinkedList?
Use ArrayList 95% of the time. It has O(1) random access and O(1) append, making it faster for most operations. LinkedList has O(n) random access and O(1) middle insertion only if you already have the node reference. Use LinkedList only if you’re heavily inserting/removing from the middle of the list using an iterator (not index-based). Modern benchmarks show ArrayDeque is better than LinkedList for queue operations, so prefer ArrayDeque or ArrayBlockingQueue for concurrent scenarios.
Q: How do you handle transaction rollback in Spring @Transactional methods?
Spring only rolls back on unchecked exceptions (RuntimeException subclasses) by default. If you throw a checked exception, Spring commits the transaction. You can change this with @Transactional(rollbackFor = CheckedException.class). Best practice: use unchecked exceptions for transactional methods, or explicitly configure rollbackFor. Also note that @Transactional must be on public methods and called from outside the class (proxy limitation) to work correctly.
Q: What’s the performance impact of using parallel streams?
Parallel streams have overhead for small collections (< 1000 elements) due to thread creation and synchronization. For large collections and CPU-intensive operations, parallelism helps up to the number of available CPU cores. For I/O-bound operations (network calls, database queries), parallel streams can help even with fewer elements. Always benchmark with JMH rather than guessing. Also remember that parallel streams use ForkJoinPool with a default size of cores-1, which can be limited in container environments.
Q: How do you design an API endpoint that returns large datasets?
Use pagination (offset/limit or cursor-based) to avoid returning millions of rows at once. Consider cursor-based pagination for better performance on large datasets (less memory in database). Include total count in response only if cheap to calculate — for Elasticsearch, use track_total_hits to disable expensive exact counts. Support sorting and filtering, but validate all sort fields to prevent injection. Consider gzip compression for responses and implement caching with Cache-Control headers where applicable.
Q: What’s the best way to handle exceptions in REST APIs?
Create a global exception handler using @ControllerAdvice that maps exceptions to appropriate HTTP status codes and JSON responses. Return 4xx for client errors (validation failures, not found, unauthorized) and 5xx for server errors. Include an error code, message, and ideally a request ID for tracing. Don’t expose internal stack traces to clients — log them server-side. Use specific exception types so callers can handle different error scenarios (BadRequestException, ResourceNotFoundException, UnauthorizedException).
Q: How do you ensure your code is thread-safe without using synchronized everywhere?
Prefer immutable objects and concurrent collections (ConcurrentHashMap, CopyOnWriteArrayList) over synchronized methods. Use AtomicInteger/Long for counters instead of synchronized blocks. Design classes to be confined to a single thread where possible (thread confinement pattern). Use volatile for visibility without needing locks. For complex state, use ReentrantLock or ReadWriteLock. Most importantly, reduce the amount of shared mutable state — less sharing means fewer synchronization issues.
Q: What should I include in log messages for production systems?
Use structured logging with SLF4J and a JSON formatter (like Logstash). Include context: user ID, request ID, trace ID for distributed tracing. Log at appropriate levels: ERROR for failures, WARN for suspicious situations, INFO for important events, DEBUG for detailed tracing. Don’t log sensitive data (passwords, credit cards, PII). Use a correlation ID to track a single user request across multiple services. Include timestamps, log level, logger name, and stack traces for exceptions. Store logs centrally (ELK stack, Datadog, CloudWatch) for searchability.
Q: How do you approach optimizing a slow database query?
First, capture the actual query execution time and plan using EXPLAIN. Look for full table scans — add indexes on commonly filtered and sorted columns. Check for N+1 queries and use JOIN FETCH or batch loading in Hibernate. Analyze query selectivity — if a query returns 99% of rows, an index doesn’t help. Consider denormalization for analytical queries. Watch out for LIKE ‘%pattern’ which doesn’t use indexes. Use database-specific tools: MySQL’s EXPLAIN, PostgreSQL’s ANALYZE, or database profilers. Remember that sometimes rewriting the query to use different logic (subqueries vs joins) is faster than indexing.
Also read our Java basics interview questions.
Also read our Java advanced interview questions.
Also read our book a mock interview.
