Skip to content

Unmap memory-mapped files deterministically on Java 22+ (FFM, multi-release jar) - #1634

Open
cech12 wants to merge 3 commits into
LibrePDF:masterfrom
cech12:FFM
Open

cech12 wants to merge 3 commits into
LibrePDF:masterfrom
cech12:FFM

Conversation

@cech12

@cech12 cech12 commented Sep 21, 2026

Copy link
Copy Markdown

Description of the new Feature/Bugfix

Since the switch to LongMappedByteBuffer (#1319), MappedRandomAccessFile.close() only closes the FileChannel.
The mapping itself stays alive until the MappedByteBuffer chunks are garbage collected. On Windows, a mapped file
is locked, so it cannot be deleted or moved after PdfReader.close() (#1112, #1517). The only workaround today is
System.gc(), which is also what SmallPdfReadTest does.

In #1439, restoring the Unsafe based cleaner was declined, and the FFM API (JEP 454) was named as the proper
long-term solution. This PR implements exactly that, without raising the Java 21 baseline, by using a
multi-release jar (the parent POM already declares Multi-Release: true):

  • src/main/java22/org/openpdf/text/utils/LongMappedByteBuffer.java (new, used on Java 22+): maps the file into a
    single MemorySegment with FileChannel.map(mode, 0, size, Arena.ofShared()). close() closes the arena, which
    unmaps the file immediately. The public API is identical to the Java 21 class (checked with jar --validate),
    including the exceptions (BufferUnderflowException at EOF, ReadOnlyBufferException for read-only mappings).
    A shared arena is used because a PdfReader may be created and read in different threads. Access after close()
    throws IllegalStateException; unmapped memory is never touched. Only supported, final Java APIs are used — no
    Unsafe, no reflection, no --add-opens.
  • LongMappedByteBuffer (Java 21): implements AutoCloseable; close() drops the chunk references. Behavior on
    Java 21 is otherwise unchanged.
  • MappedRandomAccessFile: close() now closes the buffer (so the Javadoc "Cleans the mapped bytebuffer" is true
    again). The channel is also closed if mapping fails in the constructor; previously the file handle leaked.
  • openpdf-core/pom.xml: profile multi-release-java22, active on JDK 22+. It compiles src/main/java22 into
    META-INF/versions/22 and runs the relevant tests with failsafe against the packaged jar (surefire uses
    target/classes, where the JVM ignores META-INF/versions). With JDK 21 the build is unchanged.

Related Issue: #1112, #1517 (see also #1439)

Unit-Tests for the new Feature/Bugfix

  • Unit-Tests added to reproduce the bug

  • Unit-Tests added to the added feature

  • LongMappedByteBufferTest: the contract both implementations must fulfil (EOF handling, bulk reads, bounds,
    empty files, read-only and read-write mappings, idempotent close()). Runs with surefire (Java 21 classes) and,
    on JDK 22+, again with failsafe against the multi-release jar.

  • MappedFileReleaseTest: verifies that the file is released right after close() — for MappedRandomAccessFile,
    PdfReader(String) and a partial PdfReader(RandomAccessFileOrArray, ...) — deliberately without
    System.gc(). On Windows via Files.delete(), on Linux via /proc/self/maps. Skipped on Java < 22.

Compatibilities Issues

  • No breaking changes. Only addition: LongMappedByteBuffer implements AutoCloseable with a new close().
  • On Java 22+, using a LongMappedByteBuffer after close() throws IllegalStateException (before: it kept working
    or failed with a NullPointerException, depending on the caller).
  • Releases must be built with JDK 22+ to contain the Java 22 classes; a JDK 21 build silently produces the Java 21
    variant only. Maybe worth an enforcer rule in the release profile — happy to add it if you prefer.

Your real name

Christian Voss

Testing details

Tested locally on Windows 11 (the platform #1112/#1517 are actually about) with Temurin 21.0.12 and Temurin
25.0.4, via mvnw -B clean install -pl openpdf-core:

JVM classes LongMappedByteBufferTest + SmallPdfReadTest (surefire) MappedFileReleaseTest + LongMappedByteBufferTest (failsafe, JDK 22+ only)
21 (Temurin 21.0.12.1) jar 10 passed not run (profile inactive, as designed)
25 (Temurin 25.0.4.1) multi-release jar 10 passed 13 passed (4 IT + 9 contract)

Full reactor run (openpdf-core): 2089 tests, 0 failures on both JDKs (49.9 s on JDK 21, 54.4 s wall clock on
JDK 25).

Bug reproduction on Windows. To confirm MappedFileReleaseTest actually detects the bug (and isn't just green by
construction), I re-ran the failsafe execution with classesDirectory pointed at target/classes instead of the
packaged jar — i.e. the same code path a plain classpath run (or a build without the multi-release jar) would take,
since a directory on the classpath doesn't get the META-INF/versions treatment that a real jar gets. Result: 1
failure + 3 errors, all java.nio.file.FileSystemException:

C:\Users\...\Temp\junit-....\data.bin: Der Prozess kann nicht auf die Datei zugreifen,
da sie von einem anderen Prozess verwendet wird

("The process cannot access the file because it is being used by another process" — the exact symptom in #1112 and
#1517.) With classesDirectory restored to the jar, the same 4 tests pass. pom.xml was reverted immediately after
(git status clean, no diff left behind).

Other checks:

  • jar tf openpdf-core/target/openpdf-3.0.6-SNAPSHOT.jar | findstr versions → contains
    META-INF/versions/22/org/openpdf/text/utils/LongMappedByteBuffer.class.
  • jar --validate --file openpdf-core/target/openpdf-3.0.6-SNAPSHOT.jar → no output, exit code 0.
  • mvnw checkstyle:check -pl openpdf-core → "You have 0 Checkstyle violations." (checkstyle 13.5.0, project config).

Performance, Windows 11, 256 MB file, via MappedRandomAccessFile (single run, ad-hoc benchmark, not JMH — see
below for caveats):

JDK 21 (MappedByteBuffer, before) JDK 25 (FFM, after)
sequential, byte by byte ~1077 ms ~539 ms
500k random 512-byte reads ~236 ms ~292 ms
open + close per small file (avg of 20k) ~2.0 ms ~0.56 ms
delete file immediately after close() fails (FileSystemException, file still locked) succeeds

Notes on the performance numbers: single run on a dev laptop, JIT warmup not isolated, no JMH — treat as a rough,
directional, Windows-specific data point rather than a rigorous benchmark. Two things stood out compared to the
Linux numbers quoted in earlier drafts of this PR: on this Windows machine the FFM path was not slower for
open+close (it was faster, likely because it avoids holding a Windows file handle open until GC), and random reads
were slightly slower with FFM (~+24%) — plausibly a JIT/memory-segment-bounds-check effect rather than something
inherent to the approach. Reviewers who care about the random-read cost may want to re-check with a proper
benchmark harness before relying on these numbers.

The open + close / delete after close() row is the one that matters for #1112/#1517: on JDK 21, deleting the
256 MB file (and, separately, the small 4 KiB file used in the open/close loop) right after close() fails with
FileSystemException every time on Windows; on JDK 25 with the multi-release jar it succeeds every time — with no
System.gc() anywhere in the benchmark.

To reproduce the bug directly on Windows: remove System.gc()/Thread.sleep(100) from SmallPdfReadTest, or run
MappedFileReleaseTest with failsafe's classesDirectory pointed at target/classes instead of the jar (see above).

Note on AI assistance

This PR was prepared with AI assistance: Claude (Anthropic) drafted the initial patch and description and I used it to verify it — rebuilding and running the full test suite on Windows with both JDKs, reproducing the underlying bug independently (by pointing classesDirectory at target/classes), and running the ad-hoc benchmark above. All results in this description reflect actual local runs, not generated text. I've reviewed the code changes and take responsibility for this contribution.

@codacy-production

codacy-production Bot commented Sep 21, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 53 complexity · 8 duplication

Metric Results
Complexity 53
Duplication 8

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@cech12

cech12 commented Sep 21, 2026

Copy link
Copy Markdown
Author

On the SonarCloud duplication gate (6.5% > 3%):

The duplication is an inherent consequence of the multi-release jar approach used here, not an oversight. src/main/java22/.../LongMappedByteBuffer.java is a brand-new file that necessarily re-implements the same public API as the Java 21 LongMappedByteBuffer — that's the whole point of a multi-release jar: two classes with an identical contract, chosen by JVM version at load time. A handful of trivial delegating methods (get(), get(byte[], int, int), put(byte), read(byte[], int, int), position(), position(long), size(), limit()) end up textually identical between the two versions because their behavior is version-independent — only the low-level primitives they delegate to (get(long), get(long, byte[], int, int), put(long, byte), close(), …) differ, and those are exactly the FFM-based parts. On top of that, the new file carries the project's standard ~49-line MPL license header, which — being a brand-new file — SonarCloud counts entirely as "new code" and matches against the same header present in effectively every other file in the codebase.

Without raising the Java 21 baseline (explicitly out of scope per #1439) or dropping the deterministic-unmap fix for Java 22+, I don't see a way to avoid this class-level duplication — the two implementations must exist as separate classes for the multi-release jar mechanism to work at all. Happy to factor the ~8 identical delegating methods out into a small package-private shared base class if that's preferred, but that only removes a part of the flagged lines (the license header duplication would remain), and it's a structural change I'd rather make deliberately if you'd like to see it, rather than as a reflex to the gate. Let me know how you'd like to proceed — including whether an exclusion for this file on the SonarCloud project side would be the preferred route.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
6.3% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants