Fundamentals & The Java Thread Life Cycle Link to heading
1. Process vs. Thread Link to heading
- Process: An isolated execution environment allocated by the OS with its own memory space (Heap).
- Thread: A lightweight subprocess. Threads within the same process share the Heap memory but have their own private Stack (local variables, method execution frames).
2. Creating Threads: Thread vs. Runnable
Link to heading
Never inherit the Thread class unless you are modifying the base behavior. Always implement Runnable (or use lambdas) to keep your code decoupled and adhere to the single responsibility principle.
// The modern, clean approach using Lambdas
public class ThreadBasics {
public static void main(String[] args) {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println("Hello from " + threadName);
};
Thread thread = new Thread(task, "Worker-Thread");
thread.start(); // Allocates system resources and triggers run() asynchronously
}
}
3. The Lifecycle States Link to heading
A Java thread always exists in one of the states defined by the Thread.State enum:
- NEW: Created but
start()not called yet. - RUNNABLE: Executing in the JVM (might be waiting for OS CPU scheduling).
- BLOCKED: Waiting for a monitor lock to enter a
synchronizedblock. - WAITING: Waiting indefinitely for another thread to perform an action (
object.wait(),thread.join()). - TIMED_WAITING: Waiting for a specified period (
Thread.sleep(ms),object.wait(ms)). - TERMINATED: Execution finished.
Data Sharing, Race Conditions, & Synchronization Link to heading
1. The Core Problem: Race Conditions Link to heading
When multiple threads read and write to a shared variable concurrently, operations interleave unpredictably. count++ is not atomic. It consists of three steps: Read, Modify, Write.
2. The Solution: synchronized & Monitor Locks
Link to heading
Every object in Java has an intrinsic lock (Monitor).
public class SynchronizedCounter {
private int count = 0;
// Locks the intrinsic monitor of "this" instance
public synchronized void increment() {
count++;
}
// Equivalent block-level synchronization
public void incrementBlock() {
synchronized(this) {
count++;
}
}
public synchronized int getCount() {
return count;
}
}
3. Thread Communication: wait() and notify()
Link to heading
Used for coordinating dependent tasks. Must always be called inside a synchronized context on the object being locked, wrapped in a while loop to protect against spurious wakeups.
// Classic Bounded Buffer / Producer-Consumer Snippet
public class SharedQueue {
private final List<Integer> buffer = new ArrayList<>();
private final int LIMIT = 5;
public synchronized void produce(int value) throws InterruptedException {
while (buffer.size() == LIMIT) {
wait(); // Releases lock, enters WAITING state
}
buffer.add(value);
notifyAll(); // Wakes up consumers
}
public synchronized int consume() throws InterruptedException {
while (buffer.isEmpty()) {
wait();
}
int value = buffer.remove(0);
notifyAll(); // Wakes up producers
return value;
}
}
The Java Memory Model (JMM) & Advanced Locking Link to heading
1. Visibility & The volatile Keyword
Link to heading
CPUs have local caches (L1, L2, L3). A thread might update a variable in its CPU cache, leaving another thread running on a different core completely unaware.
volatileguarantees Visibility: All reads and writes go directly to Main Memory.- It establishes a happens-before relationship.
- Crucial Catch:
volatiledoes not guarantee atomicity (e.g.,volatile int count++is still broken).
2. Lock-Free Programming: Atomics & CAS Link to heading
Atomic classes (like AtomicInteger) leverage CPU-level Compare-And-Swap (CAS) instructions. This is non-blocking and highly performant under low-to-medium contention.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private final AtomicInteger count = new AtomicInteger(0);
public void safeIncrement() {
// Loops internally using CAS until it safely updates without a heavy OS lock
count.incrementAndGet();
}
}
3. Explicit Locks: ReentrantLock
Link to heading
Provides greater flexibility than standard synchronized keywords: timed lock polling, interruptible lock attempts, and fairness choices.
import java.util.concurrent.locks.ReentrantLock;
public class ExplicitLocking {
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
// Critical Section
} finally {
lock.unlock(); // Crucial: Always release in finally block to prevent deadlocks
}
}
}
The Concurrency Utilities Framework Link to heading
Production code almost never spawns manual new Thread() objects. We orchestrate resources via java.util.concurrent.
1. Thread Pools via ExecutorService
Link to heading
Recycles a fixed set of threads to run incoming tasks, preventing resource exhaustion.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolMastery {
public static void main(String[] args) {
// Pool with 4 active threads
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("Executing task " + taskId + " via " + Thread.currentThread().getName());
});
}
executor.shutdown(); // Gracefully stops accepting new tasks
}
}
2. Fetching Results: Callable & Future
Link to heading
Unlike Runnable, Callable<V> returns a value and throws checked exceptions.
import java.util.concurrent.*;
public class FutureExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<String> fetchTask = () -> {
Thread.sleep(1000); // Simulate network latency
return "API Data Payload";
};
Future<String> futureResult = executor.submit(fetchTask);
// Do other independent work here...
// Blocks until the thread finishes and yields the result
String data = futureResult.get();
System.out.println("Received: " + data);
executor.shutdown();
}
}
3. Concurrent Collections cheat sheet Link to heading
Never use Collections.synchronizedList() or Hashtable for high-throughput code.
ConcurrentHashMap: Uses advanced bucket-level locking (lock striping) so multiple reads/writes happen simultaneously without blocking the entire map.CopyOnWriteArrayList: Creates a fresh copy of the underlying array whenever it’s mutated. Phenomenal for read-heavy, write-rare scenarios (like listener lists).
Examples Link to heading
Thread Spawning & Lifecycle Tracing Link to heading
This solution spawns multiple threads using lambdas and captures their states using a monitoring thread to observe how they transition from RUNNABLE to TIMED_WAITING.
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class LifecycleMastery {
public static void main(String[] args) throws InterruptedException {
// 1. Define a heavy/sleeping worker task
Runnable workerTask = () -> {
try {
String name = Thread.currentThread().getName();
System.out.println("[ " + name + " ] Starting execution...");
// Forces the thread into TIMED_WAITING state
Thread.sleep(2000);
System.out.println("[ " + name + " ] Finished execution.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
// 2. Instantiate the thread (State: NEW)
Thread worker = new Thread(workerTask, "Production-Worker-1");
System.out.println("State immediately after creation: " + worker.getState());
// 3. Move to RUNNABLE
worker.start();
System.out.println("State immediately after start(): " + worker.getState());
// 4. Monitor thread to capture the state mid-flight
Thread.sleep(500); // Give the worker a moment to enter sleep
System.out.println("State during Thread.sleep(): " + worker.getState());
// 5. Block main until worker completes
worker.join();
System.out.println("State after completion: " + worker.getState());
}
}
// Sample Output
State immediately after creation: NEW
State immediately after start(): RUNNABLE
[ Production-Worker-1 ] Starting execution...
State during Thread.sleep(): TIMED_WAITING
[ Production-Worker-1 ] Finished execution.
State after completion: TERMINATE
Bounded Buffer Link to heading
This is the standard architectural pattern for a custom Producer-Consumer queue. It fixes race conditions and visibility bugs using an explicit synchronized block, a tracking monitor, and standard loop guard-conditions.
import java.util.LinkedList;
import java.util.Queue;
public class BoundedBuffer<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
// Producer Method
public synchronized void put(T item) throws InterruptedException {
// Always use a while loop, never an if statement, due to spurious wakeups
while (queue.size() == capacity) {
System.out.println(Thread.currentThread().getName() + " : Buffer full. Waiting...");
wait(); // Releases lock, thread enters WAITING state
}
queue.add(item);
System.out.println(Thread.currentThread().getName() + " produced item. Size: " + queue.size());
// Notify all waiting consumers that data is available
notifyAll();
}
// Consumer Method
public synchronized T take() throws InterruptedException {
while (queue.isEmpty()) {
System.out.println(Thread.currentThread().getName() + " : Buffer empty. Waiting...");
wait();
}
T item = queue.poll();
System.out.println(Thread.currentThread().getName() + " consumed item. Size: " + queue.size());
// Notify all waiting producers that space has opened up
notifyAll();
return item;
}
}
High-Performance Benchmarking (Locking vs. CAS) Link to heading
This script sets up a race to show you the performance metrics between heavy intrinsic synchronization (ReentrantLock) and low-level hardware orchestration (AtomicInteger).
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class PerformanceBenchmark {
private static final int NUM_THREADS = 4;
private static final int TARGET_COUNT = 10_000_000;
private static int lockCounter = 0;
private static final ReentrantLock lock = new ReentrantLock();
private static final AtomicInteger atomicCounter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// --- Benchmark 1: ReentrantLock ---
long startLock = System.currentTimeMillis();
Thread[] lockThreads = new Thread[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
lockThreads[i] = new Thread(() -> {
for (int j = 0; j < TARGET_COUNT / NUM_THREADS; j++) {
lock.lock();
try {
lockCounter++;
} finally {
lock.unlock();
}
}
});
lockThreads[i].start();
}
for (Thread t : lockThreads) t.join();
long endLock = System.currentTimeMillis();
// --- Benchmark 2: AtomicInteger (CAS) ---
long startAtomic = System.currentTimeMillis();
Thread[] atomicThreads = new Thread[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
atomicThreads[i] = new Thread(() -> {
for (int j = 0; j < TARGET_COUNT / NUM_THREADS; j++) {
atomicCounter.incrementAndGet(); // Non-blocking CPU CAS
}
});
atomicThreads[i].start();
}
for (Thread t : atomicThreads) t.join();
long endAtomic = System.currentTimeMillis();
// Print Out Metrics
System.out.println("=== BENCHMARK RESULTS ===");
System.out.println("ReentrantLock Time : " + (endLock - startLock) + " ms | Final Count: " + lockCounter);
System.out.println("AtomicInteger Time : " + (endAtomic - startAtomic) + " ms | Final Count: " + atomicCounter.get());
}
}
// Sample Output
=== BENCHMARK RESULTS ===
ReentrantLock Time : 131 ms | Final Count: 10000000
AtomicInteger Time : 80 ms | Final Count: 10000000
Multi-Threaded File Processor Engine Link to heading
This combines thread pools (ExecutorService), asynchronous tracking (Future), and thread-safe hash collections (ConcurrentHashMap) to map processing statuses concurrently.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class ConcurrentFileProcessor {
public static class FileMetrics {
public final int linesProcessed;
public final long executionTimeMs;
public FileMetrics(int linesProcessed, long executionTimeMs) {
this.linesProcessed = linesProcessed;
this.executionTimeMs = executionTimeMs;
}
}
public static void main(String[] args) {
int totalFiles = 20;
int coreCount = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(coreCount);
ConcurrentHashMap<String, FileMetrics> globalRegistry = new ConcurrentHashMap<>();
List<Future<String>> futuresList = new ArrayList<>();
int successfullyAggregated = 0;
boolean interrupted = false;
System.out.println("Initializing thread pool with " + coreCount + " workers.");
try {
for (int i = 1; i <= totalFiles; i++) {
final String fileName = "Log_Archive_00" + i + ".txt";
Callable<String> task = () -> {
long taskStart = System.currentTimeMillis();
Thread.sleep((long) (Math.random() * 400 + 100));
int linesParsed = (int) (Math.random() * 5000 + 500);
long duration = System.currentTimeMillis() - taskStart;
globalRegistry.put(fileName, new FileMetrics(linesParsed, duration));
return fileName;
};
futuresList.add(executor.submit(task));
}
for (Future<String> future : futuresList) {
try {
String finishedFile = future.get();
FileMetrics metrics = globalRegistry.get(finishedFile);
System.out.println("Successfully aggregated data from: " + finishedFile
+ " (" + metrics.linesProcessed + " lines)");
successfullyAggregated++;
} catch (InterruptedException e) {
System.err.println("Main thread interrupted while waiting for tasks.");
interrupted = true;
break;
} catch (ExecutionException e) {
System.err.println("Task execution failure caught: " + e.getCause());
}
}
} finally {
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
}
}
} catch (InterruptedException ie) {
executor.shutdownNow();
interrupted = true;
}
// Restore interrupted status for the main thread if it was tripped anywhere
if (interrupted) {
Thread.currentThread().interrupt();
}
}
if (successfullyAggregated < totalFiles || Thread.currentThread().isInterrupted()) {
System.out.println("\nProcessing aborted or incomplete. Successfully aggregated: "
+ successfullyAggregated + "/" + totalFiles);
} else {
System.out.println("\nAll tasks completed cleanly. Final metrics map size: " + globalRegistry.size());
}
}
}
// Sample Output
Initializing thread pool with 12 workers.
Successfully aggregated data from: Log_Archive_001.txt (1641 lines)
Successfully aggregated data from: Log_Archive_002.txt (1586 lines)
Successfully aggregated data from: Log_Archive_003.txt (4064 lines)
Successfully aggregated data from: Log_Archive_004.txt (3463 lines)
Successfully aggregated data from: Log_Archive_005.txt (1594 lines)
Successfully aggregated data from: Log_Archive_006.txt (1858 lines)
Successfully aggregated data from: Log_Archive_007.txt (1511 lines)
Successfully aggregated data from: Log_Archive_008.txt (1276 lines)
Successfully aggregated data from: Log_Archive_009.txt (560 lines)
Successfully aggregated data from: Log_Archive_0010.txt (763 lines)
Successfully aggregated data from: Log_Archive_0011.txt (1175 lines)
Successfully aggregated data from: Log_Archive_0012.txt (1480 lines)
Successfully aggregated data from: Log_Archive_0013.txt (3886 lines)
Successfully aggregated data from: Log_Archive_0014.txt (2323 lines)
Successfully aggregated data from: Log_Archive_0015.txt (3824 lines)
Successfully aggregated data from: Log_Archive_0016.txt (1424 lines)
Successfully aggregated data from: Log_Archive_0017.txt (2784 lines)
Successfully aggregated data from: Log_Archive_0018.txt (5022 lines)
Successfully aggregated data from: Log_Archive_0019.txt (2664 lines)
Successfully aggregated data from: Log_Archive_0020.txt (2149 lines)
All tasks completed cleanly. Final metrics map size: 20
notify() vs notifyAll() Link to heading
In Java multi-threading, both notify() and notifyAll() are methods of the Object class used to wake up threads that are waiting on a specific object’s monitor (lock).
The core difference lies in how many waiting threads they wake up.
The Core Differences Link to heading
| Feature | notify() |
notifyAll() |
|---|---|---|
| Behavior | Wakes up a single random thread waiting on the object’s monitor. | Wakes up all threads waiting on the object’s monitor. |
| Lock Acquisition | The single awakened thread moves to the “Blocked” state and competes for the lock. | All awakened threads move to the “Blocked” state and compete for the lock. |
| Risk of Deadlock | High. If the chosen thread cannot proceed and dies/waits again, other threads remain asleep forever. | Low. Ensures that every waiting thread gets a chance to check its condition. |
| Performance Overhead | Lower (only one thread is context-switched). | Higher (causes a “thundering herd” effect where all threads compete, but only one wins). |
How They Work (Under the Hood) Link to heading
When a thread calls object.wait(), it releases the lock and enters the object’s Wait Set.
notify()picks exactly one thread from the Wait Set and moves it to the Entry Set (the blocked state, waiting for the lock). Java does not guarantee which thread is picked; it is completely dependent on the JVM’s scheduling policy.notifyAll()moves all threads from the Wait Set to the Entry Set.
⚠️ Crucial Note: Neither method releases the lock immediately. The current thread continues executing until it exits the
synchronizedblock. Only then can the notified thread(s) actually acquire the lock.
The Danger of notify(): Starvation and Deadlocks
Link to heading
Using notify() is an optimization that can easily introduce subtle, hard-to-debug defects.
Imagine a Producer-Consumer queue with two Consumer threads (C1, C2) and one Producer thread (P1).
- The queue is empty. C1 and C2 both call
wait()and are asleep. - P1 adds an item to the queue, calls
notify(), and exits the synchronized block. - The JVM happens to wake up C1.
- C1 processes the item. The queue is now empty again.
- C1 wants to consume another item, but the queue is empty, so it calls
wait()and goes back to sleep. - The Deadlock: P1 is done, C1 is asleep, and C2 was never woken up. The system is permanently stuck.
If P1 had used notifyAll(), both consumers would have woken up. Even if C1 consumed the item first, C2 would wake up, realize the queue is empty, and safely go back to sleep, keeping the application active.
Best Practices Link to heading
- **Default to
notifyAll()**: Unless you have a highly optimized, benchmarked reason to do otherwise, always prefernotifyAll(). The safety against deadlocks almost always outweighs the minor performance cost of thread contention. - Always wait in a loop: Never use
if (condition) wait();. Always use awhileloop to protect against spurious wakeups and state changes before a thread re-acquires the lock:
synchronized(lock) {
while (!condition) {
lock.wait();
}
// Proceed with logic
}
- Use
notify()only when:
- All waiting threads are executing the exact same logic (uniform waiters).
- A single notification can satisfy at most one waiting thread (e.g., adding exactly one item to a queue where one item = one consumer).
Future vs CompletableFuture Link to heading
Both Future and CompletableFuture are tools designed to handle asynchronous computations, but they belong to entirely different generations of Java development.
Here is the breakdown of how they differ and why you’ll almost always want to use CompletableFuture in modern applications.
The Core Difference Link to heading
Future(Introduced in Java 5): Represents the result of an asynchronous computation. Think of it as a lottery ticket. You submit a task to anExecutorService, get aFutureback, and eventually go check if you won. However, it is passive—you have to manually wait for it to finish.CompletableFuture(Introduced in Java 8): Implements bothFutureandCompletionStage. It is active and reactive. It allows you to manually complete it, chain multiple asynchronous tasks together, and handle errors gracefully without blocking your main thread.
Detailed Comparison Link to heading
| Feature | Future |
CompletableFuture |
|---|---|---|
| Blocking vs. Non-Blocking | Blocking. You must use .get(), which pauses the current thread until the result is ready. |
Non-Blocking. Supports callbacks (thenAccept, thenApply) that execute automatically when the result is ready. |
| Manual Completion | No. You cannot manually mark it as “done” or force a specific return value. | Yes. You can explicitly complete it using .complete(value) from any thread. |
| Chaining / Piping | No. You cannot easily say “do Task A, then pass the result to Task B.” | Yes. Seamlessly chains tasks using methods like thenCompose() and thenCombine(). |
| Error Handling | Poor. You have to catch exceptions inside a try-catch block around the .get() method. |
Excellent. Offers functional error handling via .exceptionally() or .handle(). |
| Combining Multiple Futures | No. You have to loop through a list of Futures and blockingly check each one. | Yes. You can combine multiple futures using allOf() (wait for all) or anyOf() (wait for the fastest). |
How They Look in Code Link to heading
1. The Old Way: Future (Blocking)
Link to heading
With a standard Future, if you want to do something with the result, your main thread has to sit around and wait.
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Data from API";
});
// This line BLOCKS the main thread until the 1 second is up
try {
String result = future.get();
System.out.println("Processing: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
2. The Modern Way: CompletableFuture (Non-Blocking & Chainable)
Link to heading
With CompletableFuture, you define a pipeline. The main thread kicks off the task and immediately moves on. When the background task finishes, the next steps trigger automatically.
CompletableFuture.supplyAsync(() -> {
// Simulating fetching data
return "Data from API";
})
.thenApply(data -> data + " (Processed)") // Transforms the data asynchronously
.thenAccept(finalResult -> System.out.println("Result: " + finalResult)) // Consumes the data
.exceptionally(ex -> {
System.out.println("Something went wrong: " + ex.getMessage());
return null;
});
// The main thread keeps running without waiting!
Why CompletableFuture is the Clear Winner
Link to heading
The biggest flaw of the original Future interface is that blocking destroys the purpose of asynchronous programming. If you have to call .get() and freeze a thread anyway, you lose the efficiency benefits of concurrency.
CompletableFuture brings the power of functional programming (similar to JavaScript Promises or Streams) to Java concurrency. It lets you write complex, multi-step asynchronous workflows that are clean, readable, and highly efficient.
By default, both Future (when used with standard ExecutorService beans) and CompletableFuture (when using supplyAsync) will lose the Spring Security context.
This happens because Spring Security’s context is bound to a ThreadLocal variable. When a task hops to a new background thread, that ThreadLocal storage is empty, meaning your background thread won’t know who the logged-in user is.
Here is how you ensure the security context passes smoothly in Spring Boot depending on how you use them.
1. Using @Async (Returns a Future or CompletableFuture)
Link to heading
If you are using Spring’s native @Async annotation to return a Future or CompletableFuture, Spring makes it incredibly easy to propagate the context. You just need to configure a DelegatingSecurityContextAsyncTaskExecutor.
The Fix: Configure your Async Executor Link to heading
Override your async executor bean so that it wraps the threads with Spring Security’s context:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "asyncExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.initialize();
// This is the magic line that copies the Security Context to new threads
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
}
Now, when you use @Async("asyncExecutor"), the logged-in user’s authentication will be fully accessible inside the asynchronous method.
2. Using Manual CompletableFuture (e.g., CompletableFuture.supplyAsync)
Link to heading
If you bypass @Async and instantiate CompletableFuture manually in your service code, calling CompletableFuture.supplyAsync(() -> { ... }) will use the global ForkJoinPool.commonPool() by default. This pool knows nothing about Spring.
The Fix: Pass the Secured Executor Explicitly Link to heading
You must pass your wrapped security executor bean directly into the CompletableFuture method as the second argument:
@Autowired
@Qualifier("asyncExecutor") // The wrapped executor we defined above
private Executor securedExecutor;
public CompletableFuture<String> doSomethingSecured() {
return CompletableFuture.supplyAsync(() -> {
// This runs in a background thread BUT has access to the Security Context!
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "Hello " + auth.getName();
}, securedExecutor); // <-- Crucial: Pass the executor here
}
3. Alternative: Global Security Context Strategy Link to heading
If you don’t want to mess with wrapping executors, you can tell Spring Security to change its underlying storage strategy from ThreadLocal to an InheritableThreadLocal. This automatically copies the context to any child threads created by the parent thread.
You can set this globally in your application’s main method or via a property:
public static void main(String[] args) {
// Allows child threads to inherit the security context
System.setProperty(SecurityContextHolder.SYSTEM_PROPERTY, SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
SpringApplication.run(YourApplication.class, args);
}
⚠️ Warning on
MODE_INHERITABLETHREADLOCAL: This approach is convenient but can be risky if you are using thread pools (likeForkJoinPoolor Tomcat’s request pool). Because threads are reused, a thread might accidentally retain the security context of a previous user if not cleaned up properly. Wrapping your executor (Method 1 & 2) is considered the best practice for production.