- Published on
Spring Boot 3.x Complete Guide 2025: Virtual Threads, GraalVM Native, Spring AI
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction
- 1. Spring Boot 3.x Big Picture
- 2. Java 21 Virtual Threads in Practice
- 3. GraalVM Native Image
- 4. Spring AI — LLM Integration
- 5. Spring Security 6
- 6. Data Access: JPA, R2DBC, QueryDSL
- 7. Testcontainers Integration
- 8. Observability: Micrometer + OpenTelemetry
- 9. Docker and Kubernetes Deployment
- 10. Production Checklist
- 11. Interview Questions (15)
- 12. Quiz
- References
Introduction
Spring Boot 3.x represents the most significant evolution in the Java ecosystem. With the Jakarta EE 10 migration, Java 17+ baseline, and Java 21 Virtual Threads native integration, it sets a new standard for enterprise backend development.
This guide covers everything you need to build production-ready applications with Spring Boot 3.x in 2025 — from Virtual Threads and GraalVM Native Image to Spring AI, Security 6, Testcontainers, and full observability.
1. Spring Boot 3.x Big Picture
1.1 Jakarta EE Migration
The most breaking change in Spring Boot 3.x is the namespace migration from javax to jakarta.
| Component | Before (2.x) | After (3.x) |
|---|---|---|
| Servlet | javax.servlet | jakarta.servlet |
| JPA | javax.persistence | jakarta.persistence |
| Validation | javax.validation | jakarta.validation |
| javax.mail | jakarta.mail | |
| Inject | javax.inject | jakarta.inject |
// Before: Spring Boot 2.x
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;
// After: Spring Boot 3.x
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotNull;
Automated migration with OpenRewrite:
# build.gradle
plugins {
id("org.openrewrite.rewrite") version "6.24.0"
}
rewrite {
activeRecipe("org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3")
}
# Execute migration
./gradlew rewriteRun
1.2 Java 21 Baseline
Spring Boot 3.x requires Java 17 minimum but is fully optimized for Java 21:
// Java 21 features you can use with Spring Boot 3.x
// 1. Record Patterns
public ResponseEntity<?> processPayment(Object payment) {
return switch (payment) {
case CreditCardPayment(var card, var amount)
-> processCard(card, amount);
case BankTransfer(var account, var amount)
-> processTransfer(account, amount);
default -> ResponseEntity.badRequest().build();
};
}
// 2. Sealed Interfaces for domain modeling
public sealed interface PaymentResult
permits PaymentSuccess, PaymentFailure, PaymentPending {
}
public record PaymentSuccess(String transactionId) implements PaymentResult {}
public record PaymentFailure(String reason) implements PaymentResult {}
public record PaymentPending(String retryAfter) implements PaymentResult {}
1.3 Recommended Module Structure
my-app/
app-api/ # REST controllers, DTOs
app-domain/ # Entities, repositories, services
app-infra/ # External integrations, configs
app-common/ # Shared utilities
build.gradle # Root build configuration
2. Java 21 Virtual Threads in Practice
2.1 Virtual Threads vs OS Threads
Virtual Threads are lightweight threads managed by the JVM, not the OS. They enable massive concurrency without the memory overhead of traditional platform threads.
// Platform Thread: 1:1 mapping to OS thread, ~1MB stack each
// 10,000 concurrent threads -> 10GB memory
// Virtual Thread: JVM-managed, ~1KB stack each
// 1,000,000 concurrent threads -> ~1GB memory
// Creating Virtual Threads
Thread.ofVirtual().start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
// Executor for Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
})
);
}
2.2 Enabling Virtual Threads in Spring Boot
One line is all you need:
# application.yml
spring:
threads:
virtual:
enabled: true
This replaces the Tomcat thread pool with Virtual Threads for all request handling.
2.3 Benchmark: Traditional vs Virtual Threads
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class VirtualThreadBenchmarkTest {
@Autowired
TestRestTemplate restTemplate;
@Test
void benchmark10kConcurrentRequests() throws Exception {
int concurrency = 10_000;
CountDownLatch latch = new CountDownLatch(concurrency);
AtomicInteger success = new AtomicInteger(0);
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < concurrency; i++) {
executor.submit(() -> {
try {
ResponseEntity<String> response =
restTemplate.getForEntity("/api/slow-endpoint", String.class);
if (response.getStatusCode().is2xxSuccessful()) {
success.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await(60, TimeUnit.SECONDS);
}
long elapsed = Duration.ofNanos(System.nanoTime() - start).toMillis();
System.out.printf("10K requests: %dms, success: %d%n", elapsed, success.get());
// Virtual Threads: ~2,500ms with 100ms DB calls
// Platform Threads (200 pool): ~50,000ms
}
}
2.4 Virtual Threads vs WebFlux
// === Reactive approach (WebFlux) ===
@GetMapping("/users-reactive")
public Mono<List<User>> getUsersReactive() {
return webClient.get()
.uri("/api/users")
.retrieve()
.bodyToFlux(User.class)
.collectList()
.flatMap(users ->
externalService.enrich(users)
.map(enriched -> enriched)
);
}
// === Virtual Thread approach (synchronous code, async performance) ===
@GetMapping("/users-virtual")
public List<User> getUsersVirtual() {
List<User> users = restClient.get()
.uri("/api/users")
.retrieve()
.body(new ParameterizedTypeReference<>() {});
return externalService.enrich(users); // Blocking is fine!
}
Key insight: Virtual Threads let you write synchronous code while achieving throughput comparable to Reactive programming. Debugging is dramatically simpler.
2.5 Virtual Thread Pitfalls
// 1. NEVER use synchronized -> use ReentrantLock
// Bad: Virtual Thread gets pinned to carrier thread
public synchronized void updateCounter() {
counter++;
}
// Good: ReentrantLock allows yielding
private final ReentrantLock lock = new ReentrantLock();
public void updateCounter() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
// 2. Minimize ThreadLocal usage
// Virtual Threads can number in millions
// ThreadLocal abuse causes memory explosion
// -> Use ScopedValue (Preview) instead
// 3. CPU-bound tasks still benefit from Platform Threads
@Bean
TaskExecutor cpuIntensiveExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2);
return executor;
}
// 4. Detect pinning with JVM flag
// -Djdk.tracePinnedThreads=short
3. GraalVM Native Image
3.1 Why Native Image?
GraalVM Native Image compiles Spring Boot apps to native binaries via AOT (Ahead-of-Time) compilation. The results are remarkable:
| Metric | JVM Mode | Native Image |
|---|---|---|
| Startup time | 2-5s | 0.05-0.2s |
| Memory (RSS) | 200-500MB | 30-80MB |
| Peak throughput | Higher | Moderate-High |
| Build time | Fast | 3-10min |
| Reflection | Unrestricted | Requires hints |
3.2 Native Image Build Configuration
// build.gradle
plugins {
id 'org.graalvm.buildtools.native' version '0.10.3'
}
graalvmNative {
binaries {
main {
imageName = 'my-app'
mainClass = 'com.example.MyApplication'
buildArgs.addAll(
'--no-fallback',
'-H:+ReportExceptionStackTraces',
'--enable-url-protocols=http,https'
)
}
}
}
# Build native image
./gradlew nativeCompile
# Run
./build/native/nativeCompile/my-app
# Started in 0.087 seconds!
3.3 Reflection Hints
@Configuration
@ImportRuntimeHints(AppRuntimeHints.class)
public class NativeImageConfig {
}
public class AppRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Register types for reflection
hints.reflection()
.registerType(UserDto.class, MemberCategory.values())
.registerType(OrderDto.class, MemberCategory.values());
// Register resources
hints.resources()
.registerPattern("templates/*")
.registerPattern("static/**")
.registerPattern("messages/*.properties");
// Register JDK proxies
hints.proxies()
.registerJdkProxy(MyRepository.class);
// Register serialization
hints.serialization()
.registerType(MyEvent.class);
}
}
// Simpler alternative: annotation-based
@RegisterReflectionForBinding({UserDto.class, OrderDto.class})
@Configuration
public class ReflectionConfig {
}
3.4 Docker Multi-Stage Native Build
# Stage 1: Build native image
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./gradlew nativeCompile --no-daemon
# Stage 2: Minimal runtime
FROM debian:bookworm-slim
RUN addgroup --system spring && adduser --system --ingroup spring spring
USER spring:spring
COPY /app/build/native/nativeCompile/my-app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
docker build -t my-app:native .
docker run -p 8080:8080 my-app:native
# Started in 0.071 seconds, 42MB RSS
3.5 Native Image Limitations and Workarounds
// Issue: Dynamic class loading won't work
// Solution: Register at build time
// Issue: Reflection-heavy libraries (Jackson, Hibernate)
// Solution: Spring AOT auto-generates most hints
// Issue: Build time is long (5-10 minutes)
// Solution: Use JVM mode for dev, Native for production
// Use Cloud Native Buildpacks for CI/CD:
// ./gradlew bootBuildImage --imageName=my-app:native
4. Spring AI — LLM Integration
4.1 Spring AI Overview
Spring AI provides a consistent abstraction over multiple LLM providers (OpenAI, Anthropic, Ollama, etc.) with familiar Spring patterns.
dependencies {
// Choose your provider
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
// implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter'
// implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter'
// Vector Store for RAG
implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter'
}
# application.yml
spring:
ai:
openai:
api-key: your-api-key-here
chat:
options:
model: gpt-4o
temperature: 0.7
4.2 ChatClient Usage
@RestController
@RequiredArgsConstructor
public class AiController {
private final ChatClient.Builder chatClientBuilder;
// Simple chat
@GetMapping("/ai/chat")
public String chat(@RequestParam String message) {
return chatClientBuilder.build()
.prompt()
.user(message)
.call()
.content();
}
// Streaming response
@GetMapping(value = "/ai/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String message) {
return chatClientBuilder.build()
.prompt()
.user(message)
.stream()
.content();
}
// Structured output
@GetMapping("/ai/analyze")
public SentimentResult analyzeSentiment(@RequestParam String text) {
return chatClientBuilder.build()
.prompt()
.user("Analyze the sentiment of this text: " + text)
.call()
.entity(SentimentResult.class);
}
}
record SentimentResult(String sentiment, double confidence, String explanation) {}
4.3 RAG (Retrieval-Augmented Generation)
@Service
@RequiredArgsConstructor
public class RagService {
private final ChatClient.Builder chatClientBuilder;
private final VectorStore vectorStore;
// Ingest documents into vector store
public void ingestDocuments(List<Document> documents) {
vectorStore.add(documents);
}
// Query with RAG context
public String queryWithContext(String question) {
// 1. Similarity search for relevant documents
List<Document> relevantDocs = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(5)
);
// 2. Build context from results
String context = relevantDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
// 3. Query LLM with context
return chatClientBuilder.build()
.prompt()
.system(s -> s.text("""
You are a helpful assistant. Answer based on the provided context.
If the answer is not in the context, say you don't know.
Context:
{context}
""").param("context", context))
.user(question)
.call()
.content();
}
}
4.4 Function Calling (Tool Calling)
@Configuration
public class AiFunctionConfig {
@Bean
@Description("Get current weather for a given city")
public Function<WeatherRequest, WeatherResponse> currentWeather() {
return request -> {
// Call actual weather API
return new WeatherResponse(request.city(), 25.0, "Sunny");
};
}
@Bean
@Description("Search products in the catalog")
public Function<ProductSearchRequest, List<Product>> searchProducts(
ProductRepository productRepository) {
return request -> productRepository
.findByNameContaining(request.query());
}
}
record WeatherRequest(String city) {}
record WeatherResponse(String city, double temperature, String condition) {}
record ProductSearchRequest(String query) {}
// Usage: LLM automatically calls registered functions
@GetMapping("/ai/assistant")
public String assistant(@RequestParam String question) {
return chatClientBuilder.build()
.prompt()
.user(question) // "What's the weather in Seoul?"
.functions("currentWeather", "searchProducts")
.call()
.content();
}
5. Spring Security 6
5.1 SecurityFilterChain Configuration
Spring Security 6 uses Lambda DSL exclusively and removes WebSecurityConfigurerAdapter.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/posts/**").authenticated()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtDecoder(jwtDecoder()))
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.build();
}
}
5.2 Method Security
@Configuration
@EnableMethodSecurity // replaces @EnableGlobalMethodSecurity
public class MethodSecurityConfig {}
@Service
public class PostService {
@PreAuthorize("hasRole('ADMIN') or #authorId == authentication.principal.id")
public void deletePost(Long postId, Long authorId) {
// Only admin or the author can delete
}
@PostAuthorize("returnObject.author.id == authentication.principal.id")
public Post getMyPost(Long postId) {
return postRepository.findById(postId).orElseThrow();
}
}
5.3 Security Headers for Production
@Bean
@Profile("prod")
public SecurityFilterChain productionSecurity(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'"))
.frameOptions(frame -> frame.deny())
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
.permissionsPolicy(pp -> pp
.policy("camera=(), microphone=(), geolocation=()"))
)
.build();
}
6. Data Access: JPA, R2DBC, QueryDSL
6.1 Spring Data JPA with Hibernate 6
@Entity
@Table(name = "posts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@Enumerated(EnumType.STRING)
private PostStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
// Business method
public void publish() {
if (this.status != PostStatus.DRAFT) {
throw new IllegalStateException("Only draft posts can be published");
}
this.status = PostStatus.PUBLISHED;
}
}
6.2 Solving the N+1 Problem
public interface PostRepository extends JpaRepository<Post, Long> {
// Solution 1: EntityGraph
@EntityGraph(attributePaths = {"author", "comments"})
List<Post> findByStatus(PostStatus status);
// Solution 2: Fetch Join
@Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.author WHERE p.status = :status")
List<Post> findByStatusWithAuthor(@Param("status") PostStatus status);
// Solution 3: Batch Size (global in application.yml)
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100
}
6.3 QueryDSL Integration
@Repository
@RequiredArgsConstructor
public class PostQueryRepository {
private final JPAQueryFactory queryFactory;
public Page<PostDto> searchPosts(PostSearchCondition condition, Pageable pageable) {
QPost post = QPost.post;
QUser user = QUser.user;
List<PostDto> content = queryFactory
.select(Projections.constructor(PostDto.class,
post.id, post.title, post.status,
user.name, post.createdAt))
.from(post)
.join(post.author, user)
.where(
titleContains(condition.title()),
statusEquals(condition.status()),
createdAfter(condition.fromDate())
)
.orderBy(post.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(post.count())
.from(post)
.where(
titleContains(condition.title()),
statusEquals(condition.status()),
createdAfter(condition.fromDate())
)
.fetchOne();
return new PageImpl<>(content, pageable, total != null ? total : 0);
}
private BooleanExpression titleContains(String title) {
return title != null ? QPost.post.title.contains(title) : null;
}
private BooleanExpression statusEquals(PostStatus status) {
return status != null ? QPost.post.status.eq(status) : null;
}
private BooleanExpression createdAfter(LocalDateTime from) {
return from != null ? QPost.post.createdAt.after(from) : null;
}
}
6.4 R2DBC (Reactive Data Access)
public interface PostR2dbcRepository extends ReactiveCrudRepository<Post, Long> {
Flux<Post> findByStatus(PostStatus status);
@Query("SELECT * FROM posts WHERE title LIKE :keyword ORDER BY created_at DESC LIMIT :limit")
Flux<Post> searchByTitle(String keyword, int limit);
}
7. Testcontainers Integration
7.1 ServiceConnection Auto-Configuration
@SpringBootTest
@Testcontainers
class PostServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Autowired
private PostService postService;
@Test
void shouldCreateAndRetrievePost() {
CreatePostRequest request = new CreatePostRequest("Title", "Content");
Post created = postService.createPost(request);
Post found = postService.getPost(created.getId());
assertThat(found.getTitle()).isEqualTo("Title");
assertThat(found.getStatus()).isEqualTo(PostStatus.DRAFT);
}
}
7.2 Reusable Test Containers
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);
}
@Bean
@ServiceConnection(name = "redis")
GenericContainer<?> redisContainer() {
return new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);
}
}
// Run as development server with test containers
public class TestMyApplication {
public static void main(String[] args) {
SpringApplication.from(MyApplication::main)
.with(TestcontainersConfig.class)
.run(args);
}
}
7.3 Kafka Testcontainer
@SpringBootTest
@Testcontainers
class KafkaIntegrationTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Test
void shouldProcessOrderEvent() throws Exception {
OrderEvent event = new OrderEvent("order-123", "CREATED", BigDecimal.valueOf(99.99));
kafkaTemplate.send("orders", event).get();
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() ->
assertThat(consumer.getProcessedOrders())
.hasSize(1)
.first()
.extracting(OrderEvent::orderId)
.isEqualTo("order-123")
);
}
}
8. Observability: Micrometer + OpenTelemetry
8.1 OTLP Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
tags:
application: my-spring-app
tracing:
sampling:
probability: 1.0
otlp:
metrics:
export:
url: http://otel-collector:4318/v1/metrics
tracing:
endpoint: http://otel-collector:4318/v1/traces
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
}
8.2 Custom Metrics
@Service
public class OrderService {
private final Counter orderCounter;
private final Timer orderProcessingTimer;
private final MeterRegistry meterRegistry;
public OrderService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.orderCounter = Counter.builder("orders.created")
.description("Total orders created")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.time")
.description("Order processing duration")
.register(meterRegistry);
}
public Order createOrder(CreateOrderRequest request) {
return orderProcessingTimer.record(() -> {
Order order = processOrder(request);
orderCounter.increment();
meterRegistry.gauge("orders.pending",
orderRepository.countByStatus(OrderStatus.PENDING));
return order;
});
}
}
8.3 Distributed Tracing
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final ObservationRegistry observationRegistry;
@PostMapping("/api/orders")
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
return Observation.createNotStarted("order.creation", observationRegistry)
.lowCardinalityKeyValue("order.type", request.type())
.observe(() -> {
Order order = orderService.createOrder(request);
return ResponseEntity.created(
URI.create("/api/orders/" + order.getId())
).body(order);
});
}
}
8.4 Grafana Stack (Docker Compose)
# docker-compose-observability.yml
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- "4317:4317"
- "4318:4318"
volumes:
- ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
tempo:
image: grafana/tempo:latest
ports:
- "3200:3200"
command: [ "-config.file=/etc/tempo.yaml" ]
9. Docker and Kubernetes Deployment
9.1 Optimized Layered Dockerfile
# Use layered JAR for optimal Docker caching
FROM eclipse-temurin:21-jre-alpine AS builder
WORKDIR /app
COPY build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
COPY /app/dependencies/ ./
COPY /app/spring-boot-loader/ ./
COPY /app/snapshot-dependencies/ ./
COPY /app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
9.2 Cloud Native Buildpacks
# No Dockerfile needed!
./gradlew bootBuildImage --imageName=myregistry/my-app:latest
# For native image
./gradlew bootBuildImage \
--imageName=myregistry/my-app:native \
--builder=paketobuildpacks/builder-jammy-tiny
9.3 Kubernetes Manifests
apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-app
spec:
replicas: 3
selector:
matchLabels:
app: spring-app
template:
metadata:
labels:
app: spring-app
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/actuator/prometheus"
spec:
containers:
- name: spring-app
image: myregistry/spring-app:latest
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "prod"
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75.0 -XX:+UseZGC"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
startupProbe:
httpGet:
path: /actuator/health
port: 8080
failureThreshold: 30
periodSeconds: 2
---
apiVersion: v1
kind: Service
metadata:
name: spring-app-service
spec:
selector:
app: spring-app
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: spring-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: spring-app
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
9.4 Graceful Shutdown
# application-prod.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
@Component
@Slf4j
public class GracefulShutdownHandler {
@PreDestroy
public void onShutdown() {
log.info("Shutting down gracefully...");
// Finish in-flight requests
// Close connection pools
// Flush metrics
}
}
10. Production Checklist
Security
- CSRF protection for browser-based APIs
- CORS restricted to allowed origins
- Security headers (CSP, HSTS, X-Frame-Options)
- Secrets externalized via environment variables or vault
- Actuator endpoints secured and restricted
Performance
- Virtual Threads enabled for I/O-heavy workloads
- HikariCP pool size tuned (CPU cores x 2 + disk spindles)
- JPA open-in-view set to false
- Batch fetch size configured (100)
- Connection timeouts configured (3s)
Reliability
- Graceful shutdown enabled
- Health probes configured (readiness + liveness)
- Circuit breaker with Resilience4j
- Retry with exponential backoff
- DB migrations managed with Flyway
Operations
- Structured JSON logging
- Distributed tracing with OTLP
- Prometheus metrics exposed
- Alerting rules configured
- Regular dependency updates with Renovate/Dependabot
Production application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
jpa:
open-in-view: false
hibernate:
ddl-auto: none
properties:
hibernate:
default_batch_fetch_size: 100
jdbc.batch_size: 50
order_inserts: true
order_updates: true
threads:
virtual:
enabled: true
server:
shutdown: graceful
tomcat:
max-connections: 10000
accept-count: 200
connection-timeout: 5s
logging:
level:
root: WARN
com.myapp: INFO
pattern:
console: "%d [%thread] %-5level %logger - traceId=%X{traceId} - %msg%n"
11. Interview Questions (15)
Fundamentals (1-5)
Q1. What are the major breaking changes in Spring Boot 3.x?
Jakarta EE migration (javax to jakarta), Java 17 minimum requirement, removal of WebSecurityConfigurerAdapter, Spring Security 6 Lambda DSL, and requestMatchers replacing antMatchers.
Q2. How do Virtual Threads differ from Reactive Programming?
Virtual Threads maintain a synchronous programming model while achieving high concurrency through JVM-managed lightweight threads. Reactive uses an asynchronous non-blocking model with operators. Virtual Threads excel at I/O-bound work; Reactive excels at streaming scenarios with backpressure.
Q3. What are the limitations of GraalVM Native Image?
Reflection, dynamic proxies, and JNI require explicit configuration via RuntimeHints. Build times are long (5-10 min). Peak throughput may be lower than JVM due to lack of JIT optimization. Some libraries may not be compatible.
Q4. Why was WebSecurityConfigurerAdapter removed?
To enable component-based security configuration using SecurityFilterChain beans, which is more flexible, testable, and allows multiple security filter chains for different URL patterns.
Q5. How does ServiceConnection work in Testcontainers?
It automatically detects the container type and maps its dynamic port and host information to Spring Boot auto-configuration properties, eliminating the need for manual DynamicPropertySource configuration.
Intermediate (6-10)
Q6. What is thread pinning in Virtual Threads?
When a Virtual Thread enters a synchronized block or performs a native call, it becomes pinned to its carrier platform thread, preventing other Virtual Threads from using that carrier. Use ReentrantLock instead of synchronized.
Q7. Explain the RAG pattern in Spring AI.
Documents are embedded into vectors and stored in a VectorStore. On query, similar documents are retrieved via similarity search, then passed as context to the LLM prompt along with the user question, grounding the response in factual data.
Q8. Three ways to solve the N+1 problem in JPA?
EntityGraph (declarative), Fetch Join (JPQL control), and Batch Size (global setting that groups lazy loading into IN clauses). Each has trade-offs regarding flexibility and query complexity.
Q9. What is the relationship between Micrometer and OpenTelemetry?
Micrometer is a metrics abstraction layer. OpenTelemetry is an observability standard (traces, metrics, logs). Spring Boot 3.x bridges them via Micrometer Tracing, exporting to OTLP-compatible backends.
Q10. Why set open-in-view to false?
When true (default), the persistence context stays open through the view layer, causing lazy-loaded queries during rendering and prolonged database connection usage. For API servers, this should always be false.
Advanced (11-15)
Q11. How does AOT processing work in Spring Boot?
At build time, Spring analyzes bean definitions, generates proxy classes, collects reflection metadata, and creates optimized bean factory code. This is a prerequisite for GraalVM Native Image and also speeds up JVM mode startup.
Q12. What is the difference between readiness and liveness probes?
Readiness probe (actuator/health/readiness) checks if the app can receive traffic. Failure removes it from the Service. Liveness probe (actuator/health/liveness) checks if the app is alive. Failure triggers a pod restart.
Q13. How to optimize HikariCP connection pool?
Set maximum-pool-size to CPU cores x 2 + disk spindles. Keep connection-timeout under 3 seconds. Set idle-timeout to 10 minutes and max-lifetime shorter than the database wait_timeout.
Q14. Explain Graceful Shutdown in Kubernetes context.
On SIGTERM, Spring stops accepting new requests while completing in-flight ones within the configured timeout. Combined with preStop hooks and readiness probe failure, this ensures zero-downtime rolling updates.
Q15. When to choose Virtual Threads over WebFlux?
Choose Virtual Threads for: existing synchronous codebases, simpler debugging needs, I/O-bound workloads. Choose WebFlux for: streaming use cases, backpressure requirements, existing reactive infrastructure, or when using non-blocking drivers exclusively.
12. Quiz
Q1. What is the one-line config to enable Virtual Threads in Spring Boot?
spring.threads.virtual.enabled=true in application.yml. This replaces the Tomcat thread pool with Virtual Threads for all request handling. Requires Spring Boot 3.2+ and Java 21+.
Q2. Why does reflection fail in GraalVM Native Image by default?
Native Image performs AOT compilation, which means class metadata is not available at runtime for dynamic reflection. You must register reflection targets via RuntimeHintsRegistrar or reflect-config.json at build time.
Q3. What replaced antMatchers() in Spring Security 6?
requestMatchers() replaced antMatchers(). This unified API works across both Servlet and Reactive environments, removing the tight coupling to the Servlet API that antMatchers had.
Q4. What does Testcontainers withReuse(true) do?
It reuses Docker containers between test runs instead of creating new ones each time, dramatically speeding up test execution. Requires testcontainers.reuse.enable=true in HOME/.testcontainers.properties.
Q5. How does Spring AI Function Calling work?
The LLM analyzes the user question and selects appropriate registered functions, extracting parameters. Spring AI executes the corresponding Java method and feeds the result back to the LLM, which generates the final response incorporating the function output.
References
- Spring Boot 3.3 Release Notes
- Spring Framework 6 Documentation
- JEP 444: Virtual Threads
- GraalVM Native Image Guide
- Spring AI Reference
- Spring Security 6 Migration Guide
- Testcontainers Spring Boot
- Micrometer Documentation
- OpenTelemetry Java SDK
- Spring Data JPA Reference
- HikariCP Configuration
- Spring Boot Docker Guide
- Kubernetes Spring Boot Best Practices
- OpenRewrite Spring Boot 3 Migration
- Baeldung Spring Boot 3 Tutorials
- Spring Boot Actuator Guide