What is a multithreaded step in Spring Batch, and how do you enable it?
answer
- taskExecutor(...) on the step builder
- one shared reader across threads
- SynchronizedItemStreamReader for stateful readers
- throttleLimit = chunks in flight (deprecated in 5)
- no ordering / weak restart
basics
~20 sA multithreaded step processes chunks on several threads inside one JVM instead of one. You enable it by giving the step a TaskExecutor (e.g. step.taskExecutor(...)), so each chunk runs on a separate thread from the pool.
solid answer
~40 sA multithreaded step is Spring Batch's simplest scaling technique: within a single step and single JVM, chunks are dispatched to a thread pool instead of being processed sequentially on one thread. You configure it in the StepBuilder with .taskExecutor(taskExecutor), passing a Spring TaskExecutor such as a ThreadPoolTaskExecutor (never SyncTaskExecutor, which stays single-threaded). Each worker thread reads a chunk, processes and writes it concurrently with other threads. Because the same ItemReader instance is shared across threads, the big caveat is that the reader (and any stateful component) must be thread-safe — most built-in readers are not, so you wrap them in SynchronizedItemStreamReader. throttleLimit historically capped the number of concurrent chunks. It boosts throughput on I/O-bound steps without the setup cost of partitioning or remote workers.
code
java · 22 lines@Bean
public Step multithreadedStep(JobRepository jobRepository,
PlatformTransactionManager tx,
ItemReader<String> delegate,
ItemWriter<String> writer) {
// Wrap the stateful reader so read() is synchronized
SynchronizedItemStreamReader<String> safeReader = new SynchronizedItemStreamReader<>();
// NOTE: delegate must be an ItemStreamReader for this to compile
// safeReader.setDelegate((ItemStreamReader<String>) delegate);
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(8);
executor.afterPropertiesSet();
return new StepBuilder("multithreadedStep", jobRepository)
.<String, String>chunk(100, tx)
.reader(safeReader)
.writer(writer)
.taskExecutor(executor) // dispatch chunks across the pool
.build();
}go deeper
Know it's the simplest scaling option: add a TaskExecutor to the step so chunks run on multiple threads in one JVM.
Explain the shared-reader hazard and SynchronizedItemStreamReader; know ThreadPoolTaskExecutor vs SyncTaskExecutor.
Discuss throttleLimit (deprecated in 5), ordering loss, weakened restartability, and I/O-bound vs CPU-bound suitability.
Weigh multithreaded step against partitioning/remote chunking; reason about restart semantics, idempotency, and when the shared-state risk outweighs the simplicity.
## The problem it solves By default a Spring Batch **chunk-oriented step** runs on a single thread: it reads N items (the chunk size), processes them, writes them in one transaction, commits, then repeats — strictly sequentially. If each write is I/O-bound (a DB insert, an HTTP call), the single thread spends most of its time waiting, leaving CPU idle. A **multithreaded step** lets multiple chunks be processed **concurrently on several threads inside the same JVM**. ## How to enable it You attach a Spring `org.springframework.core.task.TaskExecutor` to the step via the builder: ```java new StepBuilder("myStep", jobRepository) .<In, Out>chunk(100, transactionManager) .reader(reader).processor(processor).writer(writer) .taskExecutor(taskExecutor) // <-- makes it multithreaded .build(); ``` Internally the step's `RepeatTemplate` (a `TaskExecutorRepeatTemplate`) submits each chunk iteration to the executor. Use a real pooled executor like `ThreadPoolTaskExecutor`. If you pass `SyncTaskExecutor` (the default) it runs on the caller thread and stays single-threaded. ## The core gotcha: shared, stateful components There is exactly **one** `ItemReader` instance for the step, shared by every worker thread. Most built-in readers are **stateful** — e.g. `JdbcCursorItemReader` walks a JDBC cursor, `FlatFileItemReader` tracks the current line, `JpaPagingItemReader` tracks a page counter. Calling `read()` from multiple threads without synchronization corrupts that state: skipped rows, duplicates, `ArrayIndexOutOfBoundsException`, etc. The fix is `org.springframework.batch.item.support.SynchronizedItemStreamReader`, which wraps your reader and makes `read()` `synchronized`: ```java SynchronizedItemStreamReader<T> safe = new SynchronizedItemStreamReader<>(); safe.setDelegate(realReader); ``` (There is a matching `SynchronizedItemStreamWriter` if your writer is not thread-safe.) Note synchronizing `read()` serializes the reading — the parallelism win comes from processing and writing running concurrently, not reading. ## throttleLimit `throttleLimit` bounds how many chunks may be **in flight** (submitted but not yet completed) at once, independent of the executor's own pool size. Classically it defaulted to 4. In Spring Batch 5 the `throttleLimit(int)` builder method is **deprecated**; the recommended control is to size the `TaskExecutor`'s pool (core/max pool size, queue capacity) directly. Conceptually it prevents the step from flooding the executor's queue faster than workers can drain it. ## Ordering and restart caveats - **No ordering guarantee.** Chunks are processed concurrently, so items are read, written and committed out of order. Don't use it if downstream logic depends on input order. - **Restartability is weakened.** Spring Batch tracks progress by a single read-count in `BATCH_STEP_EXECUTION`. With concurrency, if the job fails mid-flight there can be gaps (chunk 5 committed but chunk 4 didn't), so re-running may reprocess or skip. For this reason multithreaded steps are best for **restart-from-scratch** / idempotent workloads, and `saveState(false)` is sometimes set on readers. - **Not fault-tolerant-friendly.** Combining with skip/retry and stateful readers multiplies the concurrency hazards. ## When to use vs alternatives Use a multithreaded step for a quick throughput win on **one JVM**, **I/O-bound** steps, where strict ordering and exact restart aren't required. When you need bounded data partitions, per-partition restartability, or to scale across JVMs, prefer **local/remote partitioning** (`Partitioner` + `PartitionHandler`) or **remote chunking**, which give each worker its own reader instance and avoid the shared-state problem entirely.
- Why can't you just pass any TaskExecutor and expect a speedup?SyncTaskExecutor (the default) runs the chunk on the caller's thread, so it stays single-threaded. You need a truly pooled executor like ThreadPoolTaskExecutor, and the step must actually be I/O-bound to benefit.
- What breaks if the reader isn't made thread-safe?Multiple threads call read() on one stateful reader concurrently, corrupting its internal cursor/line/page counter — causing duplicate items, skipped items, or exceptions. Wrap it in SynchronizedItemStreamReader.
saying these in an interview costs you the question
- Thinking a multithreaded step spins up multiple JVMs (it's one JVM, one process)
- Assuming each thread gets its own reader instance (there's one shared reader)
- Believing SyncTaskExecutor gives parallelism
- Expecting items to stay in input order