Performance, Memory & Threading
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Baseline date: 2026-10-09. This page was written against Apache POI 5.5.1 (the latest release on that date; 6.0 is in development and has not been released, so every 6.0 remark is an expectation), Java 21 and later, Spring Boot 4.x on Spring Framework 7 and Jakarta EE 11. The Java classes of this page were compiled against POI 5.5.1 and the timings and allocation figures marked as observed were measured on one developer laptop with JDK 26; they show the shape of each effect, not a benchmark, and the hardware is deliberately not described because the numbers must not be reused as general facts. Measure on your own server with your own files.
Apache POI is a library that favours correctness and completeness over speed, and its default memory use is high: the
Spreadsheet How-To lists high memory use for large sheets among the things still to improve.
A server that exports reports on request or accepts uploads must therefore make a handful of decisions before the
first release: which API to use for which size, how to open a file, how many documents may be open at the same time, and
what to clean when a worker thread goes back to a pool. This page collects them in the order in which they usually
matter. It does not repeat what other pages explain in depth; it links to them:
Streaming Writes with SXSSF,
Streaming Reads: Event & SAX APIs,
Cell Styles, Fonts & Formats,
Formulas & Evaluation and
Security & Untrusted Files. The scenario is the Bookshelf
of the section landing page: books-inventory.xlsx (small, rich),
loans-report.xlsx (large, plain) and books-import.xlsx (uploaded by users).
Choosing an API by file size
POI offers three levels of Excel API, introduced in Introduction & Architecture. They differ in how much of the file is in memory at once, and that is the whole performance story:
| API | Memory profile | What you give up | Typical Bookshelf use |
|---|---|---|---|
Usermodel ( |
The whole document is a tree of objects. Memory grows with the number of cells; the object tree is much larger than the compressed file. |
Nothing. Random access to every cell, formulas, charts, pivot tables, auto-size, templates. |
|
SXSSF ( |
A sliding window of rows (100 by default) plus the parts that stay in memory (styles, merged regions, shared strings if enabled). The rest goes to a temporary file. |
Reading back or editing a flushed row; some features and auto-size need extra care. |
|
SAX / event API ( |
One row or even one cell at a time, plus the shared strings table. Memory is nearly independent of the number of rows. |
No object model: you receive callbacks and keep only what you need. No random access, no writing. |
|
The official FAQ puts the gain of the event model on the read side at more than a factor of ten in memory compared with the usermodel. POI publishes no memory-per-cell figure, because the cost depends on the content (long distinct strings cost far more than repeated numbers), so the decision should come from an experiment with a fixed heap, not from a rule of thumb. The experiment is described in the section on measuring below.
The decision as a flow:
The same choice as a table of triggers:
| Situation | Choose | Why |
|---|---|---|
Template filled with a few hundred rows, styles, formulas, chart |
Usermodel |
The model is needed and the memory is small. Streaming would remove features you need. |
Export of an unbounded query result ( |
SXSSF |
Memory is bounded by the window, not by the result size. Disk is bounded by the temporary file. |
Export that also needs a pivot table, chart or merged cell per row |
Usermodel on a limited export, or SXSSF for the data sheet and a separate small file for the rest |
SXSSF keeps merged regions, hyperlinks and comments in memory until |
Upload read once from top to bottom ( |
SAX event API, or usermodel from a |
You rarely need the tree; a capped usermodel read is simpler to write and test. |
Edit a large existing workbook |
Usermodel with enough heap |
Streaming cannot update flushed rows. See Templates & Modifying Workbooks and the section "SXSSF and templates" of Streaming Writes with SXSSF. |
Legacy |
|
The format caps a sheet at 65,536 rows, so the file size is bounded anyway. |
A useful pattern is to keep the decision in one place and drive it from a measured budget. The class below decides on the number of cells of an export; the limit is a constructor argument that you set from your own measurements (it is not a POI constant), and it refuses to stream an export that needs random access.
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
/** Picks the workbook implementation for an export from a cell budget measured on the real server. */
public class ExportApiChooser {
/** Largest export (rows x columns) that the usermodel API may build in memory; set it from your own measurements. */
private final long maxUsermodelCells;
public ExportApiChooser(long maxUsermodelCells) {
this.maxUsermodelCells = maxUsermodelCells;
}
public Workbook create(long rows, int columns, boolean needsRandomAccess) {
if (needsRandomAccess && rows * columns > maxUsermodelCells) {
throw new IllegalArgumentException("Too large for random access: split the export or relax the layout");
}
return rows * columns <= maxUsermodelCells || needsRandomAccess
? new XSSFWorkbook() // full model: formulas, auto-size, pivot tables, charts
: new SXSSFWorkbook(100); // sliding window of 100 rows
}
public void export(List<String[]> data, OutputStream out) throws IOException {
int columns = data.isEmpty() ? 0 : data.get(0).length;
try (Workbook workbook = create(data.size(), columns, false)) {
Sheet sheet = workbook.createSheet("Loans");
int r = 0;
for (String[] line : data) {
Row row = sheet.createRow(r++);
for (int c = 0; c < line.length; c++) {
row.createCell(c).setCellValue(line[c]);
}
}
workbook.write(out);
}
}
}
File versus InputStream
The most effective one-line change for reading is to give POI a File instead of an InputStream. The Javadoc of
XSSFWorkbook is explicit: the InputStream constructor buffers the whole stream into memory before opening the
package, needs more memory than a File, and opening from a file has the lower memory footprint. The Javadoc of POIFSFileSystem says the same for .xls and .doc files. With a file POI
reads zip entries on demand from disk; with a stream it must have the whole archive in hand first.
| You have | Do | Notes |
|---|---|---|
A |
|
|
A |
Copy it to a private temporary file, then open the file |
One extra disk write, one copy of the bytes less in the heap. Delete the file in a |
A stream that cannot be copied (for example a decrypted stream) |
|
The stream constructors close the stream by default; the overload with a |
When a stream cannot be avoided, ZipInputStreamZipEntrySource.setThresholdBytesForTempFiles(int) (since POI 5.1.0)
tells POI to keep decompressed zip entries larger than the threshold in temporary files instead of the heap. The default
is -1, meaning no temp files, and then an entry above 2 GB after decompression fails. The Javadoc recommends something
like 50,000,000 bytes. setEncryptTempFiles(true) encrypts those temporary files. Both are static, so set them once at
start-up; the second example of the temp-file section below shows the calls.
The helper below is the pattern for the Bookshelf import: the upload goes to a directory you control, POI reads it from there, and the file is removed whatever happens. The size cap and the other upload checks belong before this step; they are in Security & Untrusted Files.
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
public class UploadedWorkbook {
/** Copies the upload to a private directory first, then lets POI read the package from the file. */
public static int countRows(InputStream upload, Path uploadDir) throws IOException {
Path file = Files.createTempFile(uploadDir, "books-import-", ".xlsx");
try {
Files.copy(upload, file, StandardCopyOption.REPLACE_EXISTING);
// readOnly = true: the package is opened read-only and nothing can be saved back to the same file
try (Workbook workbook = WorkbookFactory.create(file.toFile(), null, true)) {
Sheet sheet = workbook.getSheetAt(0);
return sheet.getPhysicalNumberOfRows();
}
} finally {
Files.deleteIfExists(file);
}
}
}
A measurement of the effect (observed, one run on one machine): a books-import.xlsx of 100,000 rows by three columns
(a 1.97 MB file) was opened and its last row number read with each constructor, and the allocation of the thread was
read with com.sun.management.ThreadMXBean.getThreadAllocatedBytes. The File constructor allocated about 450 MB and
the InputStream constructor about 515 MB, in two consecutive rounds each. The difference is the buffered copy of the
archive; the bulk of both numbers is the object model, which is why a large upload belongs to the SAX API and not to a
better constructor. Allocation is not retention, so read it as "relative work", not as heap in use.
The logging slowdown
The POI FAQ answers the question "why is reading so slow" with logging: verbose logging, it says, "will reduce performance by a factor of at least 100", and the logging page of the project says that levels noisier than WARN are only for debugging and must never be enabled on a production system. Since POI 5.1.0 POI logs through the Log4j 2 API only (see Setup & Dependencies for the bridge), so the cost depends on what is behind the API: a Logback or Log4j configuration that writes DEBUG records to the console in a container is expensive, and with no implementation on the classpath Log4j falls back to a minimal console logger.
The rule is simple and cheap to enforce: pin the POI loggers at WARN in production, whatever the root level is. With
Spring Boot, which routes the Log4j API to Logback through log4j-to-slf4j:
logging.level.org.apache.poi=WARN
logging.level.org.apache.xmlbeans=WARN
With a native Log4j 2 configuration the equivalent is a Logger element for org.apache.poi with level="WARN", as in
the official logging page.
An honest note on the size of the effect. The FAQ number is the project’s statement, and it was written for the old
logger. A probe on the same machine as the rest of this page, with POI 5.5.1, Log4j Core 2.25.2 and a TRACE level on
org.apache.poi, did not reproduce a slowdown of that size: opening a 20,000-row workbook and evaluating 20,000 formulas
took about 250 ms to 300 ms with a WARN configuration (file appender), a TRACE configuration with a file appender
(11.8 MB of log written, mostly by the write phase of the setup) and a TRACE configuration with a console appender.
The probe logs mostly while writing and little while reading, so it proves one thing: the penalty depends on the amount
of logging that your workload triggers and on the appender (synchronous console output in a slow container log driver is
the worst case). Treat the factor as an upper-end warning, keep the level at WARN, and look at the log volume
(grep -c on a sample) before concluding anything about your own service.
Style and font reuse
Every CellStyle and Font created through the workbook is stored in the workbook and written to styles.xml. A
.xlsx can hold at most 64,000 cell styles, and each new one costs memory and time. The classic mistake is creating a
style per cell inside the row loop. The measured effect (observed, 20,000 date cells written with an XSSFWorkbook):
| Strategy | Styles in the workbook | Time for the loop |
|---|---|---|
One shared |
2 |
85 ms |
|
20,002 |
353 ms |
The time difference is small here and grows with the size of the style table, but the real damage is the limit: a
loop that creates a style per cell fails after 64,000 cells with IllegalStateException: The maximum number of Cell
Styles was exceeded. You can define up to 64000 style in a .xlsx Workbook (observed at style number 64,001). An export
of the loans-report.xlsx size reaches it in a single sheet.
The fix is to create every style and font once, per workbook, before the first row, and hand them to the row writer. A small holder class makes the rule visible in code review. Never share it between workbooks: a style belongs to the workbook that created it.
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.CreationHelper;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Workbook;
/** Creates every style and font of one workbook once, before the first row is written. */
public final class BookshelfStyles {
public final CellStyle header;
public final CellStyle date;
public final CellStyle money;
public BookshelfStyles(Workbook workbook) {
CreationHelper helper = workbook.getCreationHelper();
Font bold = workbook.createFont();
bold.setBold(true);
header = workbook.createCellStyle();
header.setFont(bold);
header.setBorderBottom(BorderStyle.THIN);
date = workbook.createCellStyle();
date.setDataFormat(helper.createDataFormat().getFormat("yyyy-mm-dd"));
money = workbook.createCellStyle();
money.setDataFormat(helper.createDataFormat().getFormat("#,##0.00"));
}
}
The same rule applies to fonts (workbook.createFont() per cell is the same bug) and to data formats, where
DataFormat.getFormat(String) should likewise be resolved once per format, not per cell. The page Cell Styles, Fonts &
Formats explains the style cache pattern and the style-sharing figure in detail.
The cost of autoSizeColumn
Sheet.autoSizeColumn measures the text of every cell of the column with font metrics. The Javadoc warns that it can be slow on large sheets and should normally be called once per column, after the last row. It also ignores merged cells unless you pass useMergedCells. The danger is calling it inside the row
loop, which turns a linear export into a quadratic one. Observed with a XSSFWorkbook and five text columns:
| Workload | Time |
|---|---|
500 rows, |
144 ms |
5,000 rows, once at the end |
913 ms |
500 rows, |
23,003 ms |
Tips that follow from the numbers:
-
Call it once per column, after the last row, and only for the columns that need it (ISBN, title, member). Numbers and dates have predictable widths: use
setColumnWidthwith a fixed value, which costs nothing. -
Run with
-Djava.awt.headless=trueand make sure the container has a font; the text measurement usesjava.awt. A minimal image without fonts fails or measures wrongly. -
In SXSSF flushed rows are gone, so the columns must be tracked before the rows are written (
trackColumnForAutoSizing), and tracking itself has a cost per written cell. Track only the columns you need and calluntrackAllColumnsForAutoSizingafterwards. See Streaming Writes with SXSSF.
import java.io.IOException;
import java.util.List;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
public class AutoSizeOnce {
/** Usermodel: write all rows first, then fit the few text columns once. */
static void usermodel(List<String[]> rows) throws IOException {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("Books");
int r = 0;
for (String[] line : rows) {
Row row = sheet.createRow(r++);
for (int c = 0; c < line.length; c++) row.createCell(c).setCellValue(line[c]);
}
sheet.autoSizeColumn(0); // ISBN
sheet.autoSizeColumn(1); // title
sheet.setColumnWidth(2, 12 * 256); // numbers and dates: a fixed width costs nothing
}
}
/** SXSSF: only tracked columns are measured, and tracking must start before the rows are written. */
static void streaming(List<String[]> rows) throws IOException {
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
SXSSFSheet sheet = workbook.createSheet("Loans");
sheet.trackColumnForAutoSizing(0);
sheet.trackColumnForAutoSizing(1);
int r = 0;
for (String[] line : rows) {
Row row = sheet.createRow(r++);
for (int c = 0; c < line.length; c++) row.createCell(c).setCellValue(line[c]);
}
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
sheet.untrackAllColumnsForAutoSizing();
}
}
public static void main(String[] args) throws IOException {
System.setProperty("java.awt.headless", "true");
List<String[]> rows = List.<String[]>of(new String[] {"978-84-376-0494-7", "Don Quijote", "12.50"});
usermodel(rows);
streaming(rows);
System.out.println("ok");
}
}
Evaluator caching
A FormulaEvaluator caches the result of every formula cell it evaluates, so a cell that fifty other formulas depend
on is calculated once. That cache lives in the evaluator, which is why the evaluator should be created once per
workbook and reused: workbook.getCreationHelper().createFormulaEvaluator() inside a loop throws the cache away every
time. Observed with a workbook of 1,500 rows in which column C holds SUM($B$1:Bn) over a column of formulas, run twice
in the same JVM:
| Strategy | Round 1 | Round 2 |
|---|---|---|
One evaluator for the workbook |
312 ms |
200 ms |
A new evaluator for every cell |
2,097 ms |
1,728 ms |
import java.io.IOException;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
public class EvaluatorReuse {
public static void main(String[] args) throws IOException {
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("Loans");
for (int r = 0; r < 1000; r++) {
Row row = sheet.createRow(r);
row.createCell(0).setCellValue(r);
row.createCell(1).setCellFormula("SUM($A$1:A" + (r + 1) + ")");
}
// One evaluator for the whole workbook: it caches every result it computes
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
for (Row row : sheet) {
evaluator.evaluateFormulaCell(row.getCell(1));
}
System.out.println(sheet.getRow(999).getCell(1).getNumericCellValue()); // 499500.0
}
}
}
The other half of the story is staleness: the cache does not notice that you changed an input cell. After changing cells
or formulas, call notifyUpdateCell, notifySetFormula or clearAllCachedResultValues, as shown in
Formulas & Evaluation. Two further points for servers:
-
Evaluating is a CPU cost at export time. If readers open the file in Excel, which calculates on load, you can skip
evaluateAlland save the cost; do it only when downstream readers (a preview, a script using POI) need cached values. -
An evaluator is bound to one workbook and is not thread safe, like everything else in POI (next section).
One document per thread
The FAQ answers the threading question in two sentences: handling different document objects in different threads
works; accessing the same document from several threads does not, and even touching different sheets of one workbook concurrently can cause errors that are very hard to track. Treat Workbook, Sheet, Row, Cell, CellStyle,
FormulaEvaluator, XWPFDocument and DataFormatter as confined to the thread (or virtual thread) that created them.
The source of POIXMLTypeLoader carries a comment that shows why a "just make it faster" switch is not available: the
XMLBeans option setUnsynchronized() is present but commented out, because enabling it caused unexplained NullPointerExceptions and other problems when properties were read from separate workbooks in several threads. The SSPerformanceTest class has an
--unsynchronized-xmlbeans flag that sets the option for an experiment; do not enable it in production.
What this means for the Bookshelf server:
-
One request, one workbook: create it, fill it, write it and close it in the same method (try-with-resources). Nothing about a workbook is stored in a field of a singleton service.
-
What you may share between threads is data that is immutable on your side: the list of rows, the configuration, the template bytes. If a template is read from disk for every export, keep the bytes in memory and open a new document from a
ByteArrayInputStreamper request. -
To parallelise a very large export, give each worker its own workbook (for example one month per worker), then combine the files, or deliver a ZIP. Do not write different sheets of one workbook from different threads.
-
With virtual threads each task has its own thread, so thread-locals disappear with the thread; the pool-related advice in the next section matters for platform thread pools. See Virtual Threads.
-
Cap the number of exports that run at once. The memory of a server is spent by concurrent exports, and a gate turns an unbounded spike into a short wait or a
429 Too Many RequestswithRetry-After, the same answer as the job queue of Exporting Reports from a Server. The class below is the minimum; the resilience patterns are in Bulkhead.
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/** Caps the number of exports that run at the same time, so the heap and the temp disk can be sized. */
public class ExportGate {
public interface Export {
void writeTo(OutputStream out) throws IOException;
}
private final Semaphore permits;
public ExportGate(int maxConcurrentExports) {
this.permits = new Semaphore(maxConcurrentExports, true);
}
/** Returns false when the server is busy, so the controller can answer 429 with Retry-After. */
public boolean run(Export export, OutputStream out) throws IOException, InterruptedException {
if (!permits.tryAcquire(2, TimeUnit.SECONDS)) {
return false;
}
try {
export.writeTo(out);
return true;
} finally {
permits.release();
}
}
}
ThreadLocalUtil.clearAllThreadLocals in servlet containers
POI keeps some state in ThreadLocal fields. In a container that reuses its threads (Tomcat, Jetty, a Spring
ThreadPoolTaskExecutor) that state outlives the request, and when the web application is undeployed the thread still
references classes of the old class loader. Tomcat then reports a thread-local leak, and a redeploy can leak the class loader. The FAQ describes the remedy: since POI 5.2.4 the utility org.apache.poi.util.ThreadLocalUtil provides clearAllThreadLocals(), which clears the objects held in thread-local variables and avoids those warnings.
The Javadoc of the method in 5.5.1 gives the precise contract: it clears the thread-locals of the current thread, to clean out a thread before it goes back to a thread pool or to a web container such as Tomcat, and it adds that org.apache.xmlbeans.ThreadLocalUtil#clearAllThreadLocals() should be called as well, because XMLBeans keeps its own thread-locals. From the source of 5.5.1 the cleaners that POI registers cover these state holders:
| Class | Thread-local state it clears |
|---|---|
|
The per-thread |
|
The password for |
|
The user locale and time zone set for the thread. |
|
The last date-format check, cached to avoid re-checking the same format (see |
|
The last format records and data-format string looked up by the HSSF cell style. |
|
The per-thread preference for event-based extractors. |
|
The default draw factory of the slide renderer. |
Three consequences follow. First, a cleaner is registered when its class is initialised, so the call cleans the classes
that the thread has used. Second, the call must run on the worker thread: calling it from a shutdown hook of the
application cleans only the thread that calls it. Third, the registry ThreadLocalUtil keeps is a plain static list, so
call it from the same classloader that loaded POI (inside the web application, not from the container).
Two places cover almost every container. A servlet Filter cleans the request thread after every request that may have
used POI; the filter is registered with @WebFilter, in web.xml or, in Spring Boot, as a FilterRegistrationBean:
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import java.io.IOException;
import org.apache.poi.util.ThreadLocalUtil;
/** Clears the thread-locals that POI and XMLBeans left on the request thread. */
public class PoiThreadCleanupFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
} finally {
ThreadLocalUtil.clearAllThreadLocals(); // POI: poi jar and modules that registered cleaners
org.apache.xmlbeans.ThreadLocalUtil.clearAllThreadLocals(); // XMLBeans: its own caches
}
}
}
Background work runs on pools you create. A ThreadPoolExecutor subclass cleans in afterExecute, which the
executor calls on the worker thread after each task. The example below also shows the one-workbook-per-task rule: eight
exports run on four workers and nothing is shared.
import java.util.concurrent.*;
import org.apache.poi.util.ThreadLocalUtil;
/** A worker pool for Bookshelf exports that cleans each worker thread after every task. */
public class CleaningExecutor extends ThreadPoolExecutor {
public CleaningExecutor(int threads) {
super(threads, threads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy());
}
@Override
protected void afterExecute(Runnable task, Throwable error) {
// afterExecute runs on the worker thread that ran the task, which is the thread to clean
ThreadLocalUtil.clearAllThreadLocals();
org.apache.xmlbeans.ThreadLocalUtil.clearAllThreadLocals();
super.afterExecute(task, error);
}
}
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Future;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
public class OneDocumentPerThread {
/** Each task builds, writes and closes its own workbook: nothing is shared between threads. */
static Path export(int month) throws IOException {
Path out = Files.createTempFile("loans-" + month + "-", ".xlsx");
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
var stream = Files.newOutputStream(out)) {
Sheet sheet = workbook.createSheet("Loans");
for (int r = 0; r < 5_000; r++) {
Row row = sheet.createRow(r);
row.createCell(0).setCellValue("loan-" + month + "-" + r);
}
workbook.write(stream);
}
return out;
}
public static void main(String[] args) throws Exception {
CleaningExecutor pool = new CleaningExecutor(4);
List<Future<Path>> results = new ArrayList<>();
for (int month = 1; month <= 8; month++) {
final int m = month;
results.add(pool.submit(() -> export(m)));
}
for (Future<Path> f : results) {
Path p = f.get();
System.out.println(p.getFileName() + " " + Files.size(p) + " bytes");
Files.delete(p);
}
pool.shutdown();
}
}
The output is one line per export (observed):
loans-1-2639838793003793069.xlsx 41687 bytes
loans-2-4732096277473806964.xlsx 41687 bytes
...
loans-8-2120040645442865413.xlsx 41687 bytes
The call is cheap, so clean after every task rather than trying to detect whether POI was used. It does not replace
closing the documents: a Workbook that is not closed keeps a file handle and, for SXSSF, a temporary file.
Temp-file strategies
POI creates temporary files in three situations: SXSSF flushing rows (poi-sxssf-sheet*.xml), the zip-entry temp files
of the InputStream route above, and a few other internals. All of them go through org.apache.poi.util.TempFile,
which asks a TempFileCreationStrategy. The default, DefaultTempFileCreationStrategy, uses the directory
poifiles below java.io.tmpdir, creates it on demand and, unless the JVM property poi.delete.tmp.files.on.exit is
set, does not register the files for deletion on exit. The Javadoc warns that this property can cause an OutOfMemoryError through the frequent use of File.deleteOnExit. Hence the cleanup is close(), as described
in Streaming Writes with SXSSF.
| Strategy | API | When to use it |
|---|---|---|
JVM wide directory |
|
One dedicated, size-limited, permission-restricted volume for the whole application. Set it once at start-up. |
Per thread, for good |
|
A pool of workers that each own a directory. |
Scoped to one task |
|
Exports and imports that need different directories (for example uploads on an encrypted volume, exports on a fast disk). The previous strategy is restored when the task ends, normally or with an exception. |
Zip entries of an |
|
Streams that you cannot turn into a file. Both settings are static. |
The example uses all three levels. It creates two directories, installs the global strategy, runs a task with its own strategy and verifies that the task wrote its SXSSF temp file into the task directory.
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
import org.apache.poi.openxml4j.util.ZipInputStreamZipEntrySource;
import org.apache.poi.util.DefaultTempFileCreationStrategy;
import org.apache.poi.util.TempFile;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
public class TempFileStrategies {
public static void main(String[] args) throws IOException {
File exportDir = Files.createTempDirectory("bookshelf-exports").toFile();
// 1. Application wide, once at start-up
TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(exportDir));
// 2. Scoped to one task and one thread (since POI 5.5.0); the previous strategy is restored afterwards
File uploadDir = Files.createTempDirectory("bookshelf-uploads").toFile();
String listing = TempFile.withStrategy(new DefaultTempFileCreationStrategy(uploadDir), () -> {
try (SXSSFWorkbook workbook = new SXSSFWorkbook(10)) {
workbook.createSheet("Loans").createRow(0).createCell(0).setCellValue("ok");
return Arrays.toString(uploadDir.list());
} catch (IOException e) {
throw new IllegalStateException(e);
}
});
System.out.println("scoped task used its own directory: " + listing.contains("poi-sxssf-sheet"));
// 3. Uploads opened from an InputStream: keep big zip entries on disk instead of in the heap
ZipInputStreamZipEntrySource.setThresholdBytesForTempFiles(50_000_000);
ZipInputStreamZipEntrySource.setEncryptTempFiles(true);
System.out.println("threshold: " + ZipInputStreamZipEntrySource.getThresholdBytesForTempFiles());
}
}
scoped task used its own directory: true
threshold: 50000000
Operational rules for the directory: put it on a volume sized for the number of concurrent exports multiplied by the
uncompressed size of a sheet (which is several times the finished .xlsx); on Kubernetes use an emptyDir volume with a
sizeLimit when you can and remember that a tmpfs or medium: Memory volume counts against the memory limit; clean the
directory at start-up because a killed JVM never runs close(); and restrict its permissions, since the files hold the
report data in clear text. The tuning of setCompressTempFiles is a CPU-for-disk trade covered in the SXSSF page.
Measuring with SSPerformanceTest
The FAQ points at one class for measuring file generation, SSPerformanceTest in the examples package. It is org.apache.poi.examples.ss.SSPerformanceTest in the poi-examples artifact, and it
creates a workbook of the chosen implementation with the given number of rows and columns (numbers, dates, percentages,
styled headers and a merged title), optionally saving it. Its usage text in 5.5.1 is:
usage: java SSPerformanceTest HSSF|XSSF|SXSSF rows cols saveFile (0|1)? [--unsynchronized-xmlbeans] [--with-warmup-run]
The FAQ adds a yardstick of its own: if you cannot write 50,000 rows by 50 columns within a few seconds, the problem is with your environment, not with POI. Run it from a throwaway project that depends on poi-examples, with
the heap you intend to give the application:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-examples</artifactId>
<version>5.5.1</version>
</dependency>
mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
java -Xmx256m -cp "$(cat cp.txt)" org.apache.poi.examples.ss.SSPerformanceTest SXSSF 50000 50 0 --with-warmup-run
java -Xmx256m -cp "$(cat cp.txt)" org.apache.poi.examples.ss.SSPerformanceTest XSSF 50000 50 0
Observed results on the machine of this page (an illustration of the method; the figures say nothing about your
hardware): SXSSF with a warm-up run reported "Elapsed 0.73 seconds before save" for the measured run (1.42 seconds for
the warm-up), and XSSF with the same arguments and -Xmx256m ended with java.lang.OutOfMemoryError: Java heap
space. That is the experiment the choice table above recommends: the same workload, one fixed heap, two
implementations. Notes on reading it:
-
--with-warmup-runruns the workload twice and reports the second; without it the first run includes JIT warm-up and class loading. Compare like with like. -
saveFileset to1adds the cost ofwriteand the disk, which is the cost your users feel; use0to isolate the generation. -
The workload is synthetic. For the real export, wrap your own service method in the same harness: fixed
-Xmx, warm-up run, then the measured run, and record peak memory with GC logging (-Xlog:gc*writes the heap after each collection) or with Java Flight Recorder. -
A heap dump on failure (
-XX:+HeapDumpOnOutOfMemoryError) is the cheapest evidence in production; open it and look at which classes hold the memory before changing anything. -
The tool is a throwaway: keep it out of the application’s runtime dependencies.
Container memory sizing
A container limit bounds the whole process, not only the Java heap. For a service that uses POI, budget these items:
| Item | How to think about it |
|---|---|
Java heap |
Set it explicitly. Without |
Peak heap of one operation |
The measured peak of the largest export or import, taken with the experiment above and the heap sized for the implementation you chose. For SXSSF it is the window plus the parts that stay in memory; for usermodel imports it is the object model of the biggest allowed upload. |
Concurrency |
Peak heap of one operation multiplied by the number of operations allowed at the same time (the gate above), plus the baseline of the application with no exports running. |
Metaspace, code cache, thread stacks |
XMLBeans and the OOXML classes are large; add a fixed margin for class metadata and for each thread’s stack. Do not
leave |
Native and direct memory |
Zip inflaters, AWT font handling for |
Temporary files |
Disk, unless the directory is a |
Safety margin |
The container limit minus everything above must stay positive at the peak of the largest allowed workload. |
A starting point for a Kubernetes deployment of the Bookshelf service follows. The numbers are placeholders to be replaced by your measurements, not recommendations: the point is the relationship between the heap setting, the memory limit and the temp-file volume.
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:MaxRAMPercentage=70
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps
-Djava.awt.headless=true
resources:
requests:
memory: "<measured steady state plus peak of one export>"
limits:
memory: "<requests plus margin for native memory>"
volumeMounts:
- name: poi-temp
mountPath: /var/poi-temp
volumes:
- name: poi-temp
emptyDir:
sizeLimit: "<concurrent exports times uncompressed sheet size>"
Combine this with the concurrency gate, a maximum upload size in the web layer, and the limits of the security page, so that the worst case is something you have measured. For the surrounding practices see Kubernetes resource management, Java and Spring Boot images and Metrics & Observability (heap, GC and temp-directory metrics are worth alerting on). Logging configuration of the application is in Logging in Spring Boot.
Pitfalls
| Symptom | Likely cause and fix |
|---|---|
|
|
|
|
An export that takes minutes |
|
|
A |
Slow reads and a growing log volume |
Debug logging of |
Tomcat reports a |
POI or XMLBeans state left on pooled threads. Call both |
Random exceptions under load |
One workbook, style or evaluator shared by several threads. Confine every document to one thread. |
|
Workbooks not closed, a JVM killed during exports, or a |
|
The column was not tracked before the rows were written. Track it first. |
What POI does not provide
-
No memory estimator: there is no API that predicts the heap for a file, so the measurement is yours.
-
No built-in concurrency control or back-pressure: limiting simultaneous exports and uploads is the application’s job.
-
No thread-safe document: the FAQ says so, and the XMLBeans synchronisation option is not a way around it.
-
No automatic cleanup of thread-locals: the container integration is the explicit
clearAllThreadLocalscall. -
No
.xlsxor.docxconversion to PDF, and no incremental write of Word documents; XWPF is a full in-memory model, so large Word documents need the heap, not a streaming API. See Word (XWPF) Basics.
Changes between versions
| Version | Change relevant to performance, memory and threading |
|---|---|
4.x |
Logging through |
5.1.0 |
Logging moves to the Log4j 2 API only. |
5.2.4 |
|
5.2.5 |
|
5.5.0 |
|
6.0 (in development) |
Not released on 2026-10-09. Nothing here depends on it; re-read the release notes before upgrading, because the removal of deprecated APIs can affect code that uses the older entry points. |
Related pages
-
Streaming Writes with SXSSF for the writer that bounds memory.
-
Streaming Reads: Event & SAX APIs for the reader that bounds memory.
-
Cell Styles, Fonts & Formats for the style cache.
-
Formulas & Evaluation for the evaluator and its cache.
-
Security & Untrusted Files for the limits that protect the heap from hostile files.
-
File Formats: OOXML & OLE2 for what the memory is spent on.
References
-
Apache POI FAQ (logging, memory, multithreading, ThreadLocalUtil, SSPerformanceTest): https://poi.apache.org/help/faq.html
-
Apache POI, logging framework: https://poi.apache.org/components/logging.html
-
Spreadsheet How-To (SXSSF, event API): https://poi.apache.org/components/spreadsheet/how-to.html
-
Spreadsheet Quick Guide: https://poi.apache.org/components/spreadsheet/quick-guide.html
-
Spreadsheet, formula evaluation: https://poi.apache.org/components/spreadsheet/eval.html
-
Spreadsheet limitations: https://poi.apache.org/components/spreadsheet/limitations.html
-
Configuration: https://poi.apache.org/components/configuration.html
-
ThreadLocalUtil Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/util/ThreadLocalUtil.html
-
TempFile Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/util/TempFile.html
-
TempFileCreationStrategy Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/util/TempFileCreationStrategy.html
-
XSSFWorkbook Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/usermodel/XSSFWorkbook.html
-
SXSSFWorkbook Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/streaming/SXSSFWorkbook.html
-
ZipInputStreamZipEntrySource Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/openxml4j/util/ZipInputStreamZipEntrySource.html
-
FormulaEvaluator Javadoc: https://poi.apache.org/apidocs/dev/org/apache/poi/ss/usermodel/FormulaEvaluator.html
-
SSPerformanceTest source: https://github.com/apache/poi/blob/trunk/poi-examples/src/main/java/org/apache/poi/examples/ss/SSPerformanceTest.java
-
Official examples: https://github.com/apache/poi/tree/trunk/poi-examples/src/main/java/org/apache/poi/examples/
-
Log4j 2 API: https://logging.apache.org/log4j/2.x/manual/api.html
-
Spring Boot application properties: https://docs.spring.io/spring-boot/appendix/application-properties/index.html