CoreHRx Jobs

Apply in one click, save jobs and take free mock tests.

Register free Log in
Hiring? Post jobs with CoreHRx
My account
user

View & update profile

Java and Spring Boot Interview Questions and Answers: 60 Questions from Easy to Hard

This guide is for Indian job seekers preparing for Java backend roles, from freshers facing campus and first-job interviews to experienced developers moving into senior positions at product companies and IT services firms. It covers what interviewers ask most in 2025 and 2026 on Java 17 and 21 and Spring Boot 3: OOP, collections, equals and hashCode, streams, exceptions, concurrency and JVM memory, then dependency injection, auto-configuration, REST controllers, validation, JPA and Hibernate, security, microservices and testing. Questions are grouped as easy, medium and hard. Read each answer, compile the code yourself and practise explaining it aloud.

Updated 1 November 2026 · CoreHRx Jobs · 1,728 open Java and Spring Boot jobs

What Java and Spring Boot interviews cover

  • A core Java round on OOP, String immutability, equals and hashCode, collections internals such as HashMap, exceptions and Java 8+ streams.
  • Concurrency and JVM questions: thread pools, volatile and synchronized, CompletableFuture, virtual threads in Java 21, memory areas and garbage collection.
  • Spring Boot fundamentals: dependency injection, bean scopes, auto-configuration, profiles, REST controllers, validation and global exception handling.
  • Data access with Spring Data JPA and Hibernate, especially the N+1 problem, lazy loading, transactions and locking, which are favourite topics in Indian interviews.
  • For senior roles, microservices design: resilience, sagas, tracing, security with JWT, and a live coding or design problem.

How freshers should prepare

  • Master core Java before Spring: OOP, Strings, collections, exceptions and streams make up most of a fresher interview at service companies.
  • Solve common coding problems in Java, such as string reversal, duplicates in an array and frequency counts with HashMap, writing clean, compiling code.
  • Build one Spring Boot CRUD project with validation, JPA, MySQL and global error handling, and be ready to explain every layer.
  • Learn to explain how HashMap works and why equals and hashCode matter, because this question appears in almost every Java interview.
  • Use Java 17 or 21 and Spring Boot 3 in your project so your answers about records, jakarta packages and switch expressions are current.

How experienced candidates should prepare

  • Prepare production stories with numbers: a slow API you fixed, an N+1 problem, a memory leak or a scaling change, and what you measured.
  • Revise transaction propagation, proxy-based AOP, bean lifecycle and auto-configuration in depth, because senior rounds ask why Spring behaves as it does.
  • Be ready to discuss microservice trade-offs: synchronous versus event-driven calls, sagas and outbox, circuit breakers, tracing and API versioning.
  • Practise concurrency questions with code: ConcurrentHashMap, deadlocks, CompletableFuture composition and where virtual threads help or do not.
  • Know your JVM tooling, such as jcmd, thread dumps, heap dumps and GC logs, and how you used them to debug a real incident.

Common mistakes to avoid

  • Comparing strings or Integer objects with == and then being unable to explain the string pool and Integer cache when the interviewer asks.
  • Saying @Transactional always works, without knowing about self-invocation, checked exceptions and the fact that it relies on proxies.
  • Using field injection with @Autowired everywhere instead of constructor injection, and returning JPA entities directly from REST controllers.
  • Making associations EAGER to avoid LazyInitializationException, which hides N+1 queries and loads far more data than each request needs.
  • Describing outdated APIs such as WebSecurityConfigurerAdapter, javax.* packages or Spring Cloud Sleuth as current in Spring Boot 3 projects.

Java and Spring Boot interview questions: Easy questions (freshers)

1. What are the four pillars of OOP, with a Java example of each?

Encapsulation means keeping fields private and exposing behaviour through methods, so a BankAccount can refuse a negative withdrawal instead of letting callers set the balance directly. Abstraction means exposing what an object does while hiding how, for example a PaymentGateway interface with a pay() method that hides each provider's details. Inheritance lets one class reuse and specialise another with extends, such as SavingsAccount extends Account. Polymorphism lets one reference type point to different objects, and the right overridden method runs at runtime, so a loop over a List<Account> can call calculateInterest() on each account. In interviews, add that composition is often preferred over deep inheritance trees.

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

An abstract class can have instance fields, constructors, and both abstract and concrete methods with any access modifier, and a class can extend only one. An interface defines a contract: its fields are implicitly public static final, it can have default and static methods since Java 8 and private methods since Java 9, but it holds no instance state. A class can implement many interfaces. Use an interface for a capability that unrelated classes share, such as Comparable or a PaymentGateway, and an abstract class when closely related classes share state and code, such as a base Report class with a common template method.

3. What is the difference between method overloading and method overriding?

Overloading means several methods in the same class share a name but differ in their parameter lists. The compiler picks one at compile time from the argument types, so it is called compile-time polymorphism; a different return type alone is not enough. Overriding means a subclass provides its own implementation of an inherited instance method with the same signature, and the JVM chooses the version at runtime from the actual object, which is runtime polymorphism. An override cannot reduce visibility or throw broader checked exceptions, may return a subtype, and should carry @Override so the compiler catches mistakes. Static and private methods are not overridden.

class Notifier {
    void send(String msg) { System.out.println("Base: " + msg); }
    void send(String msg, int retries) { System.out.println("Retries: " + retries); } // overload
}

class SmsNotifier extends Notifier {
    @Override
    void send(String msg) { System.out.println("SMS: " + msg); } // override
}

// Notifier n = new SmsNotifier(); n.send("OTP sent");  prints "SMS: OTP sent"

4. What is the difference between == and equals() in Java?

For primitives, == compares values; for objects, it compares references, that is, whether both variables point to the same object. equals() compares logical content when the class overrides it, as String, Integer and most value classes do; the default Object.equals() behaves like ==. String literals are interned, so two identical literals often look equal with ==, which hides bugs: a string built at runtime or with new String() is a different object. Always compare strings with equals(), or Objects.equals(a, b) when either side may be null. Integer has the same trap: values from -128 to 127 are cached, so == works for small numbers and fails for larger ones.

String a = "java";
String b = new String("java");
System.out.println(a == b);        // false: different objects
System.out.println(a.equals(b));   // true: same content

Integer x = 127, y = 127, p = 1000, q = 1000;
System.out.println(x == y);        // true: cached
System.out.println(p == q);        // false: separate objects

5. Why is String immutable in Java?

Once a String object is created, its characters cannot change; methods like toUpperCase() or concat() return new strings. The design has several benefits. The string pool can safely share one literal among many references. Strings are the most common HashMap keys, and caching the hash code is safe because the value never changes. Strings are thread-safe without synchronisation. They are also secure to pass around as file paths, URLs or class names, because no caller can change the value after it has been validated. The cost is that repeated concatenation in a loop creates many temporary objects, which is why StringBuilder exists.

6. What is the difference between String, StringBuilder and StringBuffer?

String is immutable, so every modification creates a new object. StringBuilder is a mutable sequence of characters: append(), insert() and reverse() change it in place, which makes it the right choice for building a string in a loop. StringBuffer has the same API, but its methods are synchronized, so it is thread-safe and slower; it is rarely needed because a string is usually built by one thread inside one method. For a simple expression like "Hello " + name, the compiler already generates efficient code, so StringBuilder is not needed there; use it when you append inside a loop.

StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 5; i++) {
    sb.append(i).append(i < 5 ? "," : "");
}
System.out.println(sb); // 1,2,3,4,5

7. What is the difference between final, finally and finalize?

final is a modifier: a final variable can be assigned only once, a final method cannot be overridden, and a final class, such as String, cannot be extended. A final reference to a list still lets you change the list's contents. finally is a block after try or catch that always runs, whether or not an exception was thrown, and is used for cleanup; it is skipped only in cases like System.exit() or a JVM crash. finalize() was an Object method the garbage collector might call before reclaiming an object. It was unpredictable and slow, and it has been deprecated for removal since Java 18; use try-with-resources or a Cleaner instead.

8. What is the difference between ArrayList and LinkedList?

ArrayList is backed by a resizable array, so get(index) is O(1) and appending is amortised O(1), but inserting or removing in the middle shifts elements, which is O(n). It grows by about 50% when full. LinkedList is a doubly linked list: adding or removing at either end is O(1) and it implements Deque, but get(index) walks the nodes, which is O(n), and every element carries a separate node object. In practice ArrayList is faster for almost every workload because its memory is contiguous and cache-friendly. When you need a queue or a stack, choose ArrayDeque rather than LinkedList.

9. What is the difference between List, Set and Map?

All three belong to the Collections Framework, though Map does not extend Collection. A List is an ordered collection that allows duplicates and access by index; ArrayList is the usual choice. A Set holds unique elements: HashSet has no guaranteed order, LinkedHashSet keeps insertion order and TreeSet keeps elements sorted. Uniqueness is decided by equals() and hashCode(), or by compareTo() for TreeSet. A Map stores key-value pairs with unique keys: HashMap for fast lookups, LinkedHashMap for insertion order and TreeMap for sorted keys. Think of a list of order items, a set of unique PIN codes and a map from employee ID to employee.

10. What is the difference between checked and unchecked exceptions?

Checked exceptions extend Exception but not RuntimeException, such as IOException and SQLException. The compiler forces you to catch them or declare them with throws, because they represent conditions a caller can reasonably handle, like a missing file. Unchecked exceptions extend RuntimeException, such as NullPointerException, IllegalArgumentException and IllegalStateException; the compiler does not force handling, and they usually signal bugs or invalid input. Errors such as OutOfMemoryError are also unchecked and should normally not be caught. Modern frameworks favour unchecked exceptions; Spring, for example, translates SQLException into its unchecked DataAccessException hierarchy.

11. What is try-with-resources, and why is it better than closing resources in finally?

try-with-resources, added in Java 7, declares one or more resources in parentheses after try, and Java closes them automatically when the block exits, normally or through an exception. Any class that implements AutoCloseable works, including streams, readers and JDBC connections. Resources close in reverse order of declaration. It beats a manual finally block because it is shorter, you cannot forget to close, and if both the body and close() throw, the close exception is attached as a suppressed exception instead of hiding the original one. Since Java 9 you can also put an existing effectively final variable in the parentheses.

try (BufferedReader reader = Files.newBufferedReader(Path.of("orders.csv"))) {
    String header = reader.readLine();
    System.out.println("Columns: " + header);
} catch (IOException e) {
    System.err.println("Could not read file: " + e.getMessage());
}

12. What is a lambda expression, and what is a functional interface?

A functional interface has exactly one abstract method, such as Runnable, Comparator, Predicate, Function or Supplier; the optional @FunctionalInterface annotation makes the compiler check that. A lambda is a compact way to implement that single method without writing an anonymous class, for example (a, b) -> a.compareTo(b), with parameter types inferred from the target type. A lambda can use local variables from the enclosing scope only if they are effectively final. Method references such as String::length or System.out::println are an even shorter form when the lambda just calls an existing method.

List<String> cities = new ArrayList<>(List.of("Pune", "Delhi", "Bengaluru"));
cities.sort(Comparator.comparing(String::length));
Predicate<String> longName = c -> c.length() > 5;
cities.stream().filter(longName).forEach(System.out::println); // Bengaluru

13. What is the Stream API, and what is the difference between intermediate and terminal operations?

A stream is a pipeline that processes a sequence of elements declaratively, for example from a collection. Intermediate operations such as filter, map, sorted and distinct return a new stream and are lazy: nothing runs until a terminal operation is called. Terminal operations such as collect, toList, forEach, count, reduce and findFirst produce a result or side effect and close the stream. Laziness allows short-circuiting, so findFirst stops at the first match. A stream cannot be reused after its terminal operation, and it does not modify the source collection. Stream.toList(), added in Java 16, returns an unmodifiable list.

record Employee(String name, String dept, double salary) {}

List<Employee> staff = List.of(
    new Employee("Asha", "IT", 90000),
    new Employee("Ravi", "HR", 50000),
    new Employee("Neha", "IT", 120000));

List<String> itNames = staff.stream()
    .filter(e -> e.dept().equals("IT"))
    .map(Employee::name)
    .sorted()
    .toList(); // [Asha, Neha]

14. What is Optional, and how should you use it?

Optional<T> is a container that either holds a value or is empty. It was added in Java 8 to make "no result" explicit in a method's return type instead of returning null. Callers handle the empty case with orElse(), orElseGet(), orElseThrow(), map() or ifPresent(). Spring Data's findById() returns an Optional for this reason. Use it as a return type; avoid it for fields, method parameters and collections, and return an empty list rather than an Optional of a list. Do not call get() without checking first, because that just turns a NullPointerException into a NoSuchElementException.

Optional<String> email = Optional.ofNullable(System.getenv("ALERT_EMAIL"));
String to = email.map(String::trim).orElse("support@example.com");
System.out.println(to);

15. What is the difference between JDK, JRE and JVM?

The JVM, or Java Virtual Machine, loads bytecode from .class files, verifies it and runs it with an interpreter and a JIT compiler, while managing memory and garbage collection; it is what lets the same bytecode run on any operating system. The JRE, or Java Runtime Environment, is the JVM plus the standard class libraries needed to run applications. The JDK, or Java Development Kit, adds development tools such as javac, jar, javadoc, jshell, jcmd and jstack. Since Java 11, Oracle no longer ships a separate JRE, and you can build a small custom runtime for your application with jlink. For Spring Boot 3, Java 17 or 21 LTS is the usual target.

16. What are the ways to create a thread in Java, and which is preferred?

You can extend Thread and override run(), or implement Runnable, or Callable, which returns a value and can throw checked exceptions, and hand it to a thread. Implementing Runnable is better than extending Thread because your class stays free to extend something else and the task is separate from the mechanism that runs it. In real applications you rarely create threads by hand: you submit tasks to an ExecutorService, which reuses a pool of threads, limits concurrency and returns Futures. Always call start(), not run(); run() simply executes the code on the current thread. On Java 21, virtual threads are another option for blocking I/O tasks.

ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> result = pool.submit(() -> 6 * 7);  // Callable
pool.submit(() -> System.out.println("Running in " + Thread.currentThread().getName()));
System.out.println(result.get()); // 42
pool.shutdown();

17. What are dependency injection and Inversion of Control in Spring?

Inversion of Control means your classes do not create their own dependencies; the Spring container creates the objects, called beans, and wires them together. Dependency injection is how it does that: the container passes the required beans in through a constructor, a setter or a field. Constructor injection is preferred because dependencies can be final, the object is never half-built, a missing bean fails at startup, and unit tests can pass mocks with a plain new. If a class has a single constructor, Spring uses it without @Autowired. The result is loose coupling: OrderService depends on a PaymentGateway interface, so the implementation can be swapped or mocked.

@Service
public class OrderService {
    private final PaymentGateway gateway;
    private final OrderRepository orders;

    public OrderService(PaymentGateway gateway, OrderRepository orders) {
        this.gateway = gateway; // injected by Spring, no @Autowired needed
        this.orders = orders;
    }
}

18. What is Spring Boot, and how is it different from the Spring Framework?

The Spring Framework provides the core features, such as dependency injection, web MVC, data access and transactions, but on its own it needs a lot of configuration. Spring Boot sits on top of it and gets an application running quickly with sensible defaults. It provides starters, such as spring-boot-starter-web, that pull in a tested set of dependencies; auto-configuration, which creates beans based on what is on the classpath; an embedded server, Tomcat by default, so the app runs with java -jar; externalized configuration through application.properties or YAML; and Actuator for health checks and metrics. Spring Boot 3 requires Java 17 or later and uses the jakarta.* packages.

19. What is the difference between @Component, @Service, @Repository and @Controller?

All four mark a class as a Spring bean that component scanning will pick up, and @Service, @Repository and @Controller are specialisations of @Component. @Service marks business logic; it adds no behaviour of its own but documents intent. @Repository marks data-access classes and enables translation of persistence exceptions into Spring's DataAccessException hierarchy. @Controller marks a web MVC controller whose methods usually return view names, while @RestController combines @Controller and @ResponseBody, so return values are written straight to the response, usually as JSON. Use the most specific annotation so the layers of the code are clear.

20. How do you create a simple REST endpoint in Spring Boot?

Annotate a class with @RestController and map methods with @GetMapping, @PostMapping, @PutMapping, @PatchMapping or @DeleteMapping. @PathVariable binds a segment of the URL, @RequestParam binds a query parameter, optionally with a default value, and @RequestBody converts the JSON body into an object using Jackson. Return the object directly for a 200 response with JSON, or return ResponseEntity when you need to control the status and headers, such as 201 Created with a Location header. Keep controllers thin: validate input, call a service and map the result.

@RestController
@RequestMapping("/api/employees")
public class EmployeeController {
    private final EmployeeService service;
    public EmployeeController(EmployeeService service) { this.service = service; }

    @GetMapping("/{id}")
    public EmployeeDto get(@PathVariable long id) { return service.find(id); }

    @GetMapping
    public List<EmployeeDto> list(@RequestParam(defaultValue = "0") int page) {
        return service.list(page);
    }
}

Java and Spring Boot interview questions: Medium questions (1-3 years)

21. What is the contract between equals() and hashCode(), and what breaks if you override only equals()?

If two objects are equal according to equals(), they must return the same hashCode(); unequal objects may share a hash code, though fewer collisions means better performance. equals() must also be reflexive, symmetric, transitive and consistent, and return false for null. Hash-based collections compare hash codes first and call equals() only on matches. If you override only equals(), two logically equal objects keep different identity hash codes, so a HashSet stores both as separate elements and map.get() with an equal key returns null. Use the same fields in both methods, or use a record, which generates both correctly.

final class Pan {
    private final String number;
    Pan(String number) { this.number = number; }

    @Override public boolean equals(Object o) {
        return o instanceof Pan p && number.equals(p.number);
    }
    @Override public int hashCode() { return number.hashCode(); }
}

// new HashSet<>(List.of(new Pan("ABCDE1234F"))).contains(new Pan("ABCDE1234F")) is true

22. How does HashMap work internally in Java 8 and later?

HashMap stores entries in an array of buckets. For a key it calls hashCode(), mixes the high bits into the low bits with h ^ (h >>> 16), and picks the bucket with (n - 1) & hash, where the table size n is always a power of two. Colliding entries share a bucket as a linked list. When a list grows past eight entries and the table has at least 64 buckets, it is converted into a red-black tree, so worst-case lookup becomes O(log n) instead of O(n); in a smaller table, HashMap resizes instead. When the size exceeds capacity times load factor, 16 x 0.75 = 12 by default, the table doubles and entries are redistributed. One null key is allowed, and HashMap is not thread-safe.

23. How do you secure a REST API with Spring Security 6?

You declare a SecurityFilterChain bean; the old WebSecurityConfigurerAdapter was removed in Spring Security 6. Using the lambda DSL, you state which paths are public, which need a role and that everything else needs authentication. For a stateless API that uses JWTs, set the session policy to STATELESS and configure the app as an OAuth2 resource server (spring-boot-starter-oauth2-resource-server plus the issuer-uri property), so Spring validates each token's signature, expiry and issuer. CSRF can be disabled here only because no session cookies are used. Add @PreAuthorize method security for finer rules, and remember that hasRole("ADMIN") checks for the authority ROLE_ADMIN.

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
}

24. How do you create an immutable class in Java?

Make the class final so subclasses cannot add mutable state, make every field private and final, provide no setters, and set all fields in the constructor. For fields that hold mutable objects, such as lists or arrays, make defensive copies on the way in and never expose the internal object on the way out; List.copyOf() gives an unmodifiable copy. Methods that "modify" the object should return a new instance, as String does. Since Java 16, a record gives you final fields, a constructor, accessors, equals, hashCode and toString in one line, but you still copy mutable components in a compact constructor. Immutable objects are thread-safe and safe to use as map keys.

public record Order(String id, List<String> items) {
    public Order {
        Objects.requireNonNull(id);
        items = List.copyOf(items); // defensive, unmodifiable copy
    }
}

25. What are sealed classes and pattern matching for switch in Java 21?

A sealed class or interface uses permits to list exactly which classes may extend or implement it, and each of those must be final, sealed or non-sealed. That gives the compiler a closed set of types. Pattern matching for switch, final in Java 21, tests the type of a value and binds it to a variable in one step, and record patterns can take a record's components apart. Together, the compiler knows a switch over a sealed type is exhaustive, so no default branch is needed, and adding a new subtype later causes a compile error at every switch that does not handle it. This replaces long instanceof chains.

sealed interface Payment permits Upi, Card, Cash {}
record Upi(String vpa) implements Payment {}
record Card(String last4, boolean international) implements Payment {}
record Cash() implements Payment {}

static double fee(Payment p, double amount) {
    return switch (p) {
        case Upi u -> 0;
        case Card(String last4, boolean intl) when intl -> amount * 0.035;
        case Card c -> amount * 0.02;
        case Cash c -> 0;
    };
}

26. What is the difference between map() and flatMap() in streams?

map() turns each element into exactly one new element, so mapping a Stream<Order> with Order::customer gives a Stream<Customer>. flatMap() turns each element into a stream of zero or more elements and then flattens them all into one stream. Use it when each element contains a collection: for every item across a list of orders, map would give a Stream<List<String>>, while flatMap gives a Stream<String>. Optional has the same pair of methods; use flatMap when the mapping function itself returns an Optional. Java 16 added mapMulti() as an alternative when each element expands into only a few others.

record Order(String id, List<String> items) {}

List<Order> orders = List.of(
    new Order("A1", List.of("pen", "book")),
    new Order("A2", List.of("bag")));

List<String> allItems = orders.stream()
    .flatMap(o -> o.items().stream())
    .toList(); // [pen, book, bag]

27. How do you group employees by department and calculate the count and average salary with streams?

Use Collectors.groupingBy with a downstream collector. groupingBy(Employee::dept) alone gives a Map<String, List<Employee>>; adding counting() as the downstream gives counts, and averagingDouble() gives averages. By default the result is a HashMap with no defined order, so pass TreeMap::new as the map factory when you want departments sorted. For several statistics in one pass, summarizingDouble() returns count, sum, min, average and max together. Interviewers often follow up with partitioningBy(), which splits elements into exactly two groups by a predicate, such as salary above or below Rs 1 lakh a month.

record Employee(String name, String dept, double salary) {}

List<Employee> staff = List.of(new Employee("Asha", "IT", 90000),
    new Employee("Ravi", "HR", 50000), new Employee("Neha", "IT", 120000));

Map<String, Long> countByDept = staff.stream()
    .collect(Collectors.groupingBy(Employee::dept, TreeMap::new, Collectors.counting()));
Map<String, Double> avgByDept = staff.stream()
    .collect(Collectors.groupingBy(Employee::dept, Collectors.averagingDouble(Employee::salary)));

System.out.println(countByDept); // {HR=1, IT=2}
System.out.println(avgByDept);   // HR=50000.0, IT=105000.0 (HashMap order not guaranteed)

28. What is the difference between @SpringBootTest, @WebMvcTest and @DataJpaTest?

@SpringBootTest starts the full application context, optionally with a real embedded server, so it suits end-to-end integration tests but is the slowest. @WebMvcTest is a slice test: it loads only the web layer, such as controllers, controller advice, filters and JSON conversion, and gives you MockMvc, so services must be mocked. @DataJpaTest loads only JPA components and repositories, wraps each test in a transaction that rolls back, and uses an embedded database unless configured otherwise. Since Spring Boot 3.4, @MockBean is deprecated in favour of Spring Framework's @MockitoBean. Use slices for speed and a few full tests to check the wiring.

@WebMvcTest(EmployeeController.class)
class EmployeeControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean EmployeeService service;

    @Test
    void returnsEmployee() throws Exception {
        when(service.find(1L)).thenReturn(new EmployeeDto(1L, "Asha"));
        mvc.perform(get("/api/employees/1"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.name").value("Asha"));
    }
}

29. How does garbage collection work in the JVM, and which collectors are available in Java 17 and 21?

The garbage collector frees heap memory held by objects that are no longer reachable from GC roots such as thread stacks and static fields. Most collectors are generational: new objects go into the young generation, which is collected often and cheaply because most objects die young, and survivors are promoted to the old generation. G1 has been the default since Java 9; it splits the heap into regions and aims at a pause-time goal, 200 ms by default. Parallel GC maximises throughput for batch jobs. ZGC gives sub-millisecond pauses on large heaps, and Java 21 added a generational mode with -XX:+ZGenerational. Serial GC suits very small containers.

30. What is the difference between volatile, synchronized and atomic classes?

volatile guarantees visibility and ordering: a write to a volatile field is seen by every thread that reads it afterwards, and it is not reordered with surrounding reads and writes. It does not make compound actions like count++ atomic, because that is a read, an add and a write. synchronized gives mutual exclusion plus visibility: one thread at a time holds the monitor, and its changes are visible to the next thread that acquires it. Atomic classes such as AtomicInteger use lock-free compare-and-swap for single-variable updates, and LongAdder suits heavily contended counters. Use volatile for flags, atomics for counters and locks for rules that span several fields.

class Stats {
    private volatile boolean running = true;           // flag: visibility only
    private final AtomicInteger hits = new AtomicInteger();
    private int total;                                  // guarded by this

    void stop() { running = false; }
    void hit() { hits.incrementAndGet(); }
    synchronized void add(int n) { total += n; }
}

31. How does CompletableFuture help you run tasks in parallel, and what should you watch out for?

CompletableFuture represents an asynchronous result that you can compose without blocking. supplyAsync() starts a task, thenApply() transforms the result, thenCompose() chains another async call, thenCombine() merges two independent results, allOf() waits for many, and exceptionally() or handle() deal with failures. A typical use is calling a pricing service and an inventory service in parallel, so the total time is the slower call rather than the sum. Watch out: without an executor argument, tasks run on the shared common ForkJoinPool, so pass your own executor for blocking I/O. Add timeouts with orTimeout(), and remember that join() blocks the calling thread.

ExecutorService io = Executors.newFixedThreadPool(8);

CompletableFuture<Double> price = CompletableFuture.supplyAsync(() -> 499.0, io);
CompletableFuture<Integer> stock = CompletableFuture.supplyAsync(() -> 12, io);

String summary = price.thenCombine(stock, (p, s) -> "Rs " + p + ", " + s + " left")
    .orTimeout(2, TimeUnit.SECONDS)
    .exceptionally(ex -> "Unavailable")
    .join();
System.out.println(summary); // Rs 499.0, 12 left
io.shutdown();

32. What are virtual threads in Java 21, and when should you use them?

Virtual threads are lightweight threads managed by the JVM instead of the operating system. Many of them run on a small pool of platform carrier threads; when a virtual thread blocks on I/O, it is unmounted and the carrier runs another one. You can create millions, so the simple thread-per-request style scales for I/O-bound work such as database and REST calls, without reactive code. They do not make CPU-bound work faster. Do not pool them; create one per task. In Spring Boot 3.2 and later, spring.threads.virtual.enabled=true makes Tomcat and the task executors use them. Watch the database pool, because many more requests can now reach it at once.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        int id = i;
        executor.submit(() -> {
            Thread.sleep(Duration.ofMillis(100)); // blocking is cheap here
            return id;
        });
    }
} // close() waits for all tasks to finish

33. What bean scopes does Spring support, and what happens when you inject a prototype bean into a singleton?

The default scope is singleton: one instance per application context, shared by every caller. Prototype creates a new instance each time the bean is requested from the container. Web applications also get request, session, application and websocket scopes. The trap: when a singleton receives a prototype bean through its constructor, injection happens only once, so the singleton keeps that same instance forever. To get a fresh one each time, inject ObjectProvider<T> and call getObject(), use a lookup method or use a scoped proxy. Because singletons are shared across request threads, they must be stateless or thread-safe.

@Component
@Scope("prototype")
class ReportBuilder { /* holds per-report state */ }

@Service
class ReportService {
    private final ObjectProvider<ReportBuilder> builders;
    ReportService(ObjectProvider<ReportBuilder> builders) { this.builders = builders; }

    String build() {
        ReportBuilder b = builders.getObject(); // new instance per call
        return b.toString();
    }
}

34. How does Spring Boot auto-configuration work?

@SpringBootApplication combines @SpringBootConfiguration, @EnableAutoConfiguration and @ComponentScan. @EnableAutoConfiguration loads candidate classes listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports inside the jars on the classpath; in Boot 3 this file replaced the old spring.factories entries. Each class is guarded by conditions such as @ConditionalOnClass, @ConditionalOnMissingBean and @ConditionalOnProperty. So adding spring-boot-starter-data-jpa creates a DataSource and an EntityManagerFactory, but if you define your own DataSource bean, Boot backs off. Run with --debug to print the conditions evaluation report, or exclude a class with @SpringBootApplication(exclude = ...).

35. How do you manage environment-specific configuration in Spring Boot?

Put shared settings in application.yml and environment-specific ones in profile files such as application-dev.yml and application-prod.yml, then activate a profile with spring.profiles.active, usually through the SPRING_PROFILES_ACTIVE environment variable. Externalized configuration has a fixed precedence: command-line arguments and environment variables override the files packaged in the jar, so secrets such as database passwords come from the environment or a vault, never from Git. For reading settings, prefer type-safe @ConfigurationProperties classes, which can be records and can be validated, over scattered @Value strings. @Profile on a bean registers it only for certain profiles.

@ConfigurationProperties(prefix = "sms")
public record SmsProperties(String senderId, int retryCount, Duration timeout) {}

// application.yml
// sms:
//   sender-id: CHRXIN
//   retry-count: 3
//   timeout: 5s
// Register it with @ConfigurationPropertiesScan on the main class.

36. How do you validate request data in a Spring Boot REST API?

Add spring-boot-starter-validation, put Jakarta Bean Validation annotations such as @NotBlank, @Email, @Size, @Min and @Pattern on the request DTO, and mark the controller parameter with @Valid. If validation of a @RequestBody fails, Spring throws MethodArgumentNotValidException and returns 400 Bad Request before your method runs; handle it in a @RestControllerAdvice to return a clear list of field errors. Since Spring Framework 6.1, constraints placed directly on path and query parameters are also checked by built-in method validation. Use validation groups when create and update need different rules. Validation stops bad input early, but it does not replace authorization checks.

public record CreateEmployeeRequest(
    @NotBlank String name,
    @Email @NotBlank String email,
    @Pattern(regexp = "[6-9]\\d{9}") String mobile,
    @Min(0) BigDecimal monthlySalary) {}

@PostMapping("/api/employees")
public ResponseEntity<EmployeeDto> create(@Valid @RequestBody CreateEmployeeRequest req) {
    EmployeeDto saved = service.create(req);
    return ResponseEntity.created(URI.create("/api/employees/" + saved.id())).body(saved);
}

37. How do you handle exceptions globally in a Spring Boot REST API?

Create a class annotated with @RestControllerAdvice and add @ExceptionHandler methods for each exception type you want to map, such as a custom ResourceNotFoundException to 404 or a business-rule violation to 409. Controllers then stay free of try/catch, and every endpoint returns the same error format. Spring 6 supports the problem details standard (RFC 9457, which replaced RFC 7807) through the ProblemDetail class, giving JSON with type, title, status and detail; set spring.mvc.problemdetails.enabled=true to use it for Spring's own exceptions as well. Add a fallback handler for Exception that logs the stack trace and returns a generic 500 message, so internal details never reach the client.

@RestControllerAdvice
public class ApiExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail notFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
    @ExceptionHandler(Exception.class)
    public ProblemDetail unexpected(Exception ex) {
        log.error("Unhandled error", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong");
    }
}

38. What is the N+1 query problem in Hibernate, and how do you fix it?

It happens when one query loads N parent entities and then touching a lazy association on each parent fires one more query per parent: one query for 100 invoices plus 100 queries for their customers. Each query is fast, so it is easy to miss, but together they make an endpoint slow. Detect it with SQL logging or Hibernate statistics in tests. Fix it by fetching what the use case needs in one query, with a JPQL JOIN FETCH, an @EntityGraph on the repository method or a DTO projection. For collections, @BatchSize or hibernate.default_batch_fetch_size loads children in batches with IN queries. Making everything EAGER is not a fix; it just moves the problem.

public interface InvoiceRepository extends JpaRepository<Invoice, Long> {

    @Query("select i from Invoice i join fetch i.customer where i.status = :status")
    List<Invoice> findWithCustomerByStatus(@Param("status") InvoiceStatus status);

    @EntityGraph(attributePaths = "lines")
    List<Invoice> findByCustomerId(Long customerId);
}

39. How does @Transactional work in Spring, and when does it roll back?

@Transactional makes Spring wrap the bean in a proxy that starts a transaction before the method runs and commits it afterwards. By default it rolls back only for unchecked exceptions, meaning RuntimeException and Error; a checked exception still commits unless you add rollbackFor = Exception.class. The default propagation is REQUIRED, which joins an existing transaction or starts a new one, while REQUIRES_NEW suspends the current transaction and runs a separate one, which is useful for audit records that must survive a rollback. Put it on service methods that form one unit of work, not on controllers, and use readOnly = true for queries. Catching an exception inside the method without rethrowing it means no rollback.

40. What is the difference between FetchType.LAZY and EAGER, and what causes LazyInitializationException?

LAZY loads an association only when you first access it, through a proxy or a lazy collection; EAGER loads it together with the owning entity. The JPA defaults are EAGER for @ManyToOne and @OneToOne and LAZY for @OneToMany and @ManyToMany. A good practice is to make every association LAZY and fetch what each use case needs with JOIN FETCH or an entity graph. LazyInitializationException occurs when a lazy association is touched after the persistence context has closed, for example while Jackson serialises an entity returned from a controller. Fix it by loading the data inside the transaction and returning DTOs, rather than relying on open-in-view, which Spring Boot enables by default and warns about at startup.

Java and Spring Boot interview questions: Hard questions (experienced)

41. Why does @Transactional sometimes have no effect?

Spring implements it with a proxy, and the proxy only sees calls that pass through it. The most common case is self-invocation: a method calls this.saveAudit() in the same class, the call skips the proxy, and the annotation on saveAudit(), including any REQUIRES_NEW, is ignored. Other causes: the method is private or final, so the proxy cannot intercept it; the object was created with new instead of being a Spring bean; the exception was checked or was caught and swallowed; or the work ran on another thread, which does not share the transaction. Fix it by moving the method to a separate bean or by using TransactionTemplate for programmatic control.

42. How do optimistic and pessimistic locking work in JPA, and when would you use each?

Optimistic locking assumes conflicts are rare. You add a @Version field; each update includes WHERE version = ? and increments it, and if another transaction changed the row first, no row matches and the update fails with an optimistic locking exception, which Spring surfaces as ObjectOptimisticLockingFailureException. The caller retries or tells the user the data changed. It holds no database locks, so it scales well for cases like editing a profile. Pessimistic locking takes a row lock with SELECT ... FOR UPDATE, through @Lock(LockModeType.PESSIMISTIC_WRITE), so other writers wait. Use it for hot rows where conflicts are frequent and retries are costly, such as stock counts or wallet balances, and keep those transactions short.

@Entity
public class Wallet {
    @Id private Long id;
    private BigDecimal balance;
    @Version private long version;   // optimistic locking
}

public interface WalletRepository extends JpaRepository<Wallet, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select w from Wallet w where w.id = :id")
    Optional<Wallet> findForUpdate(@Param("id") Long id);
}

43. Why is it a problem to paginate a query that uses JOIN FETCH on a collection?

A join fetch on a one-to-many collection returns one SQL row per child, so the database cannot apply LIMIT to parents: a page of invoices could cut one invoice's lines in half. Hibernate therefore leaves LIMIT out of the SQL, loads the whole result into memory and paginates there, logging a warning; on a large table that means heavy memory use and slow responses. You can make it fail instead with hibernate.query.fail_on_pagination_over_collection_fetch=true. The fix is two queries: first fetch one page of parent IDs with ORDER BY and LIMIT, then load those parents with their collections using JOIN FETCH and WHERE id IN (:ids). Batch fetching of the lazy collection also works.

44. How does ConcurrentHashMap achieve thread safety in Java 8 and later?

It dropped the segment locks used in Java 7. Reads are lock-free: the table and node values are read with volatile semantics, so get() never blocks. When a bucket is empty, put() inserts the first node with a compare-and-swap; when it already has nodes, the thread locks only that bucket's first node, so writes to different buckets proceed in parallel. Large buckets become trees, as in HashMap, and several threads cooperate during a resize. Null keys and values are not allowed, because null would be ambiguous between absent and mapped to null. Use compute(), merge() or putIfAbsent() for atomic read-modify-write; a get followed by a put is not atomic.

ConcurrentHashMap<String, Integer> hits = new ConcurrentHashMap<>();

// Wrong: two threads can read the same old value
// hits.put(page, hits.getOrDefault(page, 0) + 1);

hits.merge("/jobs", 1, Integer::sum);   // atomic increment
hits.computeIfAbsent("/home", k -> 0);
System.out.println(hits);

45. What causes a deadlock, and how do you detect and prevent it in Java?

A deadlock needs four conditions at once: mutual exclusion, hold and wait, no preemption and a circular wait. For example, thread 1 holds lock A and waits for B while thread 2 holds B and waits for A, as in two transfers between the same accounts in opposite directions that each lock the source first. Detect it with a thread dump from jstack <pid> or jcmd <pid> Thread.print, which reports "Found one Java-level deadlock", or with ThreadMXBean.findDeadlockedThreads(). Prevent it by always taking locks in one global order, such as by account ID, by using tryLock with a timeout and backing off, by keeping critical sections small, and by never calling external code while holding a lock.

void transfer(Account from, Account to, BigDecimal amount) {
    Account first = from.id() < to.id() ? from : to;   // fixed lock order
    Account second = first == from ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

46. What are the pitfalls of virtual threads in Java 21?

The main one is pinning. In Java 21, a virtual thread that blocks inside a synchronized block or method stays attached to its carrier thread, and since there are only about as many carriers as cores, a few such waits can stall the application. Java 24 removed this limitation for synchronized (JEP 491), but on 21 use ReentrantLock around blocking calls, and find pinning with -Djdk.tracePinnedThreads=full or the JFR jdk.VirtualThreadPinned event. Other pitfalls: ThreadLocal caches get expensive with millions of threads, pooling virtual threads defeats their purpose, and unlimited concurrency just moves the bottleneck to the database, so guard scarce resources with a Semaphore or pool limits. CPU-bound work gains nothing.

47. Why does double-checked locking need volatile, and what does happens-before mean?

The Java Memory Model lets the compiler and CPU reorder instructions and keep values in caches, so one thread may not see another's writes unless a happens-before relationship links them. Such links come from releasing and then acquiring the same lock, writing and then reading the same volatile field, Thread.start(), Thread.join() and similar actions. In double-checked locking without volatile, the write of the reference can become visible before the writes made in the constructor, so a second thread can skip the lock and use a partly built object. Declaring the field volatile fixes this. In practice, an enum or a holder class is a simpler lazy singleton.

public final class Config {
    private static volatile Config instance;
    private Config() { /* load settings */ }
    public static Config get() {
        Config local = instance;
        if (local == null) {
            synchronized (Config.class) {
                local = instance;
                if (local == null) instance = local = new Config();
            }
        }
        return local;
    }
}

48. A Spring Boot service fails with OutOfMemoryError in production. How do you investigate it?

Read the exact message first, because it tells you which memory ran out. "Java heap space" means live objects exceeded the maximum heap; "Metaspace" points to class metadata, often a classloader leak; "GC overhead limit exceeded" means the JVM spends nearly all its time collecting; "unable to create native thread" is about operating-system thread limits, not the heap. For heap problems, run with -XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath, or take a dump with jcmd <pid> GC.heap_dump, and open it in Eclipse MAT to see the dominator tree. Typical culprits are unbounded caches, static maps and loading whole tables into memory. In containers, size the heap with -XX:MaxRAMPercentage.

49. How should a Spring Boot application store passwords, and when is it safe to disable CSRF protection?

Never store passwords in plain text or with reversible encryption. Use a slow, salted, adaptive hash through Spring Security's PasswordEncoder, such as BCrypt, Argon2 or SCrypt. PasswordEncoderFactories.createDelegatingPasswordEncoder() prefixes each hash with its algorithm ID, such as {bcrypt}, so you can move to a stronger algorithm later and still verify old hashes. CSRF attacks work because browsers attach cookies automatically to cross-site requests. If your API is stateless and clients send a bearer token in the Authorization header, there is no automatic credential to abuse, so disabling CSRF is reasonable. If authentication relies on a session or JWT cookie, keep CSRF protection on, with SameSite cookies as an extra layer.

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default
}

// String hash = passwordEncoder.encode(rawPassword);
// boolean ok = passwordEncoder.matches(rawPassword, hash);

50. What happens if you change a field used in hashCode() after putting the object into a HashSet or HashMap?

The object becomes effectively lost inside the collection. The set stored it in the bucket chosen by the old hash code. After the field changes, lookups compute the new hash and search elsewhere, so contains() and get() report it missing even though it is still there, and remove() fails, which leads to memory leaks and duplicates. Iterating still shows the element. That is why map keys and set elements should be immutable, or at least the fields used in equals() and hashCode() must not change while the object is in a hash-based collection. Strings and records with immutable components are safe keys; mutable JPA entities are a classic trap.

class Slot {
    String time;
    Slot(String time) { this.time = time; }
    @Override public boolean equals(Object o) { return o instanceof Slot s && time.equals(s.time); }
    @Override public int hashCode() { return time.hashCode(); }
}

Set<Slot> booked = new HashSet<>();
Slot s = new Slot("10:00");
booked.add(s);
s.time = "11:00";
System.out.println(booked.contains(s));   // false
System.out.println(booked.size());        // 1

51. Write an LRU cache with a fixed capacity in Java.

The simplest correct answer extends LinkedHashMap with accessOrder set to true, which moves an entry to the end of its internal list whenever it is read or written, and overrides removeEldestEntry() to evict the least recently used entry once the size exceeds the capacity. get and put stay O(1). Interviewers may then ask you to build it from scratch with a HashMap plus a doubly linked list, which is what LinkedHashMap does internally. This version is not thread-safe: wrap it with Collections.synchronizedMap() or, in production, use a library such as Caffeine for a fast concurrent cache.

class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    LruCache(int capacity) {
        super(16, 0.75f, true); // accessOrder = true
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}
// With capacity 2: put(1), put(2), get(1), put(3) evicts key 2

52. When can parallel streams hurt performance or correctness?

Parallel streams split work across the common ForkJoinPool, which has one thread fewer than the number of cores and is shared by the whole JVM. They help only for large, CPU-bound, stateless work on sources that split well, such as arrays and ArrayList. They hurt when tasks block on I/O, such as database or HTTP calls, because that starves the shared pool for everyone; when the data set is small and splitting costs more than it saves; with sources like LinkedList that split poorly; and with order-dependent operations such as findFirst or limit. Correctness breaks with shared mutable state, such as adding to an ArrayList from forEach. Measure with JMH before turning it on.

List<Integer> unsafe = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(unsafe::add); // race: lost items or exception

List<Integer> safe = IntStream.range(0, 10_000).parallel().boxed().toList();
System.out.println(safe.size()); // 10000

53. How does Spring Boot handle circular dependencies, and how do you fix them?

A circular dependency means bean A needs B and B needs A, directly or through a chain. With constructor injection Spring cannot create either one first, so startup fails with a BeanCurrentlyInCreationException. Since Spring Boot 2.6, circular references are prohibited by default even with field or setter injection, which older versions resolved silently; spring.main.allow-circular-references=true switches that back on, but it hides a design problem. Proper fixes are to extract the logic both beans need into a third bean, to publish an application event instead of calling back, or to invert one dependency through an interface. As a stopgap, @Lazy on one constructor parameter injects a proxy and breaks the cycle.

54. Explain the Spring bean lifecycle and where AOP proxies are created.

The container first reads bean definitions, which BeanFactoryPostProcessors can modify; this is how property placeholders get resolved. For each bean it then creates the instance, injects dependencies and calls Aware callbacks such as BeanNameAware and ApplicationContextAware. Next come the BeanPostProcessor before-initialization hooks, then initialization: @PostConstruct, InitializingBean.afterPropertiesSet() and any custom init method, in that order. Then the after-initialization hooks run, and this is where Spring wraps the bean in a proxy for @Transactional, @Async or @Cacheable, so other beans receive the proxy rather than the raw object. On shutdown, @PreDestroy, destroy() and custom destroy methods run for singletons; prototype beans get no destroy callbacks.

55. How would you create your own Spring Boot starter with auto-configuration?

Create an autoconfigure module with a class annotated @AutoConfiguration, and list its fully qualified name in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Guard each bean with conditions so it activates only when it makes sense: @ConditionalOnClass for the required library, @ConditionalOnProperty for an enable switch, and @ConditionalOnMissingBean so applications can replace your bean with their own. Bind settings with @ConfigurationProperties and enable them on the auto-configuration class. Then publish a thin starter artifact that depends on the autoconfigure module and the library. Test it with ApplicationContextRunner, which checks which beans exist under different properties without starting a full application.

@AutoConfiguration
@ConditionalOnClass(SmsClient.class)
@EnableConfigurationProperties(SmsProperties.class)
public class SmsAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "sms", name = "enabled", havingValue = "true", matchIfMissing = true)
    SmsClient smsClient(SmsProperties props) {
        return new SmsClient(props.senderId(), props.timeout());
    }
}

56. How do you keep one slow or failing microservice from bringing down the others?

Assume every remote call can be slow or fail. Set connect and read timeouts on every HTTP client, because waiting forever is the most common way failures spread. Retry only idempotent operations, a few times, with exponential backoff and jitter, so retries do not multiply the load. Add a circuit breaker, typically Resilience4j: once the failure rate crosses a threshold it opens and fails fast, then lets a few trial calls through before closing again. Use bulkheads, meaning separate thread pools or concurrency limits per dependency, so one slow service cannot use up every request thread. Provide fallbacks such as cached data, and watch these patterns through Micrometer metrics.

@CircuitBreaker(name = "inventory", fallbackMethod = "stockFallback")
@Retry(name = "inventory")
public int stock(String sku) {
    return inventoryClient.getStock(sku);
}

private int stockFallback(String sku, Throwable ex) {
    log.warn("Inventory down, using cached stock for {}", sku);
    return stockCache.getOrDefault(sku, 0);
}

57. How do you maintain data consistency across microservices without distributed transactions?

Two-phase commit across services is fragile and poorly supported by message brokers, so most systems accept eventual consistency and use the saga pattern. A saga splits the business transaction into local transactions, one per service; each step publishes an event or command that triggers the next, and on failure, compensating actions undo the earlier steps, such as releasing reserved stock and refunding the payment when shipping fails. Sagas can be choreographed through events or orchestrated by a coordinator. To publish events reliably, use a transactional outbox: write the business change and the event row in the same local transaction, then a relay publishes them to Kafka or RabbitMQ. Consumers must be idempotent, because messages can arrive twice.

58. How do Spring Boot 3 services call each other, and how do you trace a request across them?

For synchronous calls, Spring Framework 6.1 added RestClient, a fluent blocking client recommended over RestTemplate for new code. WebClient is the reactive option, and HTTP interfaces annotated with @GetExchange or @PostExchange give a declarative, Feign-like client on top of either. Inject the RestClient.Builder that Boot auto-configures so observability is applied, use service discovery or Kubernetes service names, and always set timeouts. For tracing, Spring Cloud Sleuth does not support Boot 3; use Micrometer Tracing with an OpenTelemetry or Brave bridge. It propagates trace IDs in W3C traceparent headers, adds them to log lines and exports spans to Zipkin or Jaeger.

RestClient client = restClientBuilder   // injected, auto-configured by Boot
    .baseUrl("http://inventory-service")
    .build();

StockDto stock = client.get()
    .uri("/api/stock/{sku}", sku)
    .retrieve()
    .body(StockDto.class);

59. How do you write reliable integration tests for a Spring Boot service that uses a real database?

Test against the same database engine as production rather than H2, because SQL dialects, constraints and locking behave differently. Testcontainers starts a real MySQL or PostgreSQL in Docker for the test run, and since Spring Boot 3.1, annotating the container field with @ServiceConnection wires its URL and credentials into the application with no @DynamicPropertySource. Share one container across test classes to keep the suite fast, apply the schema through Flyway or Liquibase so migrations are tested too, and clean data between tests. Combine this with slice tests for controllers and plain unit tests for services, so only a few tests need the full context.

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4");

    @Autowired OrderRepository orders;

    @Test
    void savesAndFindsOrder() {
        // exercise the repository against a real MySQL
    }
}

60. A Spring Boot API is slow under load. How do you find the bottleneck?

Measure before changing anything. Actuator with Micrometer exposes metrics such as http.server.requests latency, JVM memory and GC, and HikariCP pool usage; send them to Prometheus and Grafana. If hikaricp.connections.pending climbs, threads are waiting for database connections: the pool, 10 by default, is exhausted or queries are slow, so check slow query logs, missing indexes and N+1 queries. If Tomcat's threads are all busy, take a few thread dumps with jcmd to see where they wait, often on a remote call with no timeout. High CPU calls for a profiler such as async-profiler or Java Flight Recorder. Check GC logs for long pauses, fix the biggest cause and load test again.

Ready to apply?

There are 1,728 open jobs asking for Java and Spring Boot on CoreHRx right now. Create a free profile once and apply with it.