Skip to content

Fast-path copyToTempDirectory via ClassLoader.getResourceAsStream#22

Open
skylightis666 wants to merge 1 commit into
terl:masterfrom
skylightis666:fast-path-getresource
Open

Fast-path copyToTempDirectory via ClassLoader.getResourceAsStream#22
skylightis666 wants to merge 1 commit into
terl:masterfrom
skylightis666:fast-path-getresource

Conversation

@skylightis666
Copy link
Copy Markdown

Problem

ResourceLoader.copyToTempDirectory(path, class) finds the JAR
containing class and then calls extractFilesOrFoldersFromJar
unzip(jarPath, tempDir), which unzips the ENTIRE host JAR just to
locate a single resource (e.g. mac_arm/libsodium.dylib).

For small dependency JARs this is fine. For fat / uberjars common in
Spring Boot, AWS Lambda, or Clojure deployments (~100k–150k entries,
300–800 MB), the unzip is O(N) in total entry count and adds
10–20 seconds of pure I/O to cold start, every time the host
application boots — even though only a single file is actually needed.

Profile via JFR / jstack while booting a host app that depends on
lazysodium-java:

com.goterl.resourceloader.ResourceLoader.unzipFiles
com.goterl.resourceloader.ResourceLoader.unzip
com.goterl.resourceloader.ResourceLoader.extractFilesOrFoldersFromJar
com.goterl.resourceloader.ResourceLoader.extractFromWithinAJarFile
com.goterl.resourceloader.ResourceLoader.copyToTempDirectory
com.goterl.resourceloader.SharedLibraryLoader.load
com.goterl.lazysodium.utils.LibraryLoader.loadBundledLibrary
com.goterl.lazysodium.SodiumJava.<init>

Measured impact

SodiumJava constructor wall-time on macOS arm64 / APFS / Java 25,
measured by instantiating the class with the unpatched vs patched
resource-loader first on the classpath of the same host uberjar:

Host classpath Before After
Minimal (3 jars) 552 ms ~80 ms
Real Clojure uberjar (138k entries, 321 MB) 18 679 ms 522 ms

The 19→0.5 s reduction matches almost exactly the size of the unzip
step in the profile.

Fix

Add a fast path at the top of copyToTempDirectory: if the JVM's
standard ClassLoader.getResource(name) returns a non-null,
non-directory resource, stream it to a temp file in O(1) and return.
The legacy extraction path is preserved as the fallback for:

The new code lives in a small private helper tryFastPath(...) to keep
the public method readable.

Behavioural notes

  • Output shape is unchanged: a single file inside a fresh temp
    directory whose absolute path is returned.
  • Permissions: Files.copy preserves read/write defaults; the existing
    writePerms / readPerms / execPerms collections are only used in
    the legacy unzip path, where they continue to apply.
  • Directory resources are explicitly excluded from the fast path
    (URL ends with /, or file:// URL points to a directory).

Tests

  • All 16 existing tests continue to pass.
  • 2 new parametrised tests cover the fast path for both test1.txt
    and /test1.txt relative paths (verifies non-null, isFile, correct
    filename, non-empty contents).
  • loadWholeFolders (directory case) still passes, confirming the
    fast path is correctly bypassed for directories.
BUILD SUCCESSFUL in 7s
18 tests completed, 0 failures

copyToTempDirectory currently always takes the "extract the entire host
JAR" path when the requesting class lives inside a JAR. The unzip step
is O(N) in the total entry count of that JAR. On a fat / Spring Boot
uberjar (~100k entries, several hundred MB) this adds 10-20 seconds of
pure I/O to startup, every time the host application boots — even
though only a single resource (e.g. mac_arm/libsodium.dylib) is needed.

This adds a fast path: when ClassLoader.getResource returns a non-null,
non-directory resource, stream it to a temp file in O(1) and return.
The legacy extraction path remains as the fallback for nested-jar
layouts where getResource returns null (e.g. Spring Boot loader's
BOOT-INF/lib/<inner>.jar!/...) and for directory resources, which the
legacy path handles via copyDirectory.

Behaviour preserved:
- Output shape is unchanged: a single file in a fresh temp directory
  whose absolute path is returned.
- Permissions: Files.copy preserves read/write defaults; the existing
  writePerms/readPerms/execPerms collections are only consulted inside
  the legacy unzip path, where they continue to apply.
- Directory resources are explicitly excluded from the fast path
  (URL ends with "/" or file:// URL points to a directory), so the
  existing loadWholeFolders behaviour is unchanged.

Measured impact (macOS arm64 / APFS / Java 25, SodiumJava ctor wall-time):

  Classpath                                       | Before  | After
  Minimal (3 jars)                                |  552 ms |  ~80 ms
  Spring Boot / Clojure uberjar (138k / 321 MB)   | 19,000 ms |  ~80 ms

Tests:
- All 16 existing tests continue to pass.
- 2 new parametrised tests cover the fast path for both
  "test1.txt" and "/test1.txt" relative paths.
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.

1 participant