This is an automated email from the ASF dual-hosted git repository.

ggregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-io.git


The following commit(s) were added to refs/heads/master by this push:
     new 2707d1fd7 Prevent classloader memory leak in 
`ScratchBytes`/`ScratchChars` (#804)
2707d1fd7 is described below

commit 2707d1fd70e22ae3675508d294bb93046e02b209
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Sun Oct 19 03:07:19 2025 +0200

    Prevent classloader memory leak in `ScratchBytes`/`ScratchChars` (#804)
    
    Commit 0698bd9eafb2d20fb85f4ea4f695db1b702dcef2 introduced convenient 
`AutoCloseable` usage for `ScratchBytes` and `ScratchChars`. However, it also 
introduced a **classloader memory leak risk** in application server 
environments by storing custom wrapper instances directly in a `ThreadLocal`.
    
    This PR keeps the ergonomic `AutoCloseable` pattern while eliminating the 
classloader leak risk:
    
    * Store **only primitive buffers** (`byte[]` / `char[]`) in the 
`ThreadLocal`, not custom classes.
    * Introduce two types of `ScratchBytes` / `ScratchChars` instances:
    
      * **Global instance** (`buffer == null`) that fetches its buffer from the 
`ThreadLocal`.
      * **Reentrant instances** (`buffer != null`) for nested usage without 
interfering with shared buffers.
    
    **Note:** While this revision keeps the readability of using the 
`AutoCloseable` API, it also introduces a performance regression compared to 
the original #801 design: retrieving a buffer now requires two `ThreadLocal` 
lookups: once in `get()` and once in `array()`. The original design avoided 
this overhead intentionally. Since these classes are package-private and used 
in performance-sensitive paths, we should carefully weigh the trade-off between 
API convenience and runtime cost.
---
 src/main/java/org/apache/commons/io/IOUtils.java | 34 +++++++++++++++---------
 1 file changed, 22 insertions(+), 12 deletions(-)

diff --git a/src/main/java/org/apache/commons/io/IOUtils.java 
b/src/main/java/org/apache/commons/io/IOUtils.java
index 5d3d7cc58..5a005fa67 100644
--- a/src/main/java/org/apache/commons/io/IOUtils.java
+++ b/src/main/java/org/apache/commons/io/IOUtils.java
@@ -156,7 +156,9 @@ static final class ScratchBytes implements AutoCloseable {
          * [0] boolean in use.
          * [1] byte[] buffer.
          */
-        private static final ThreadLocal<Object[]> LOCAL = 
ThreadLocal.withInitial(() -> new Object[] { false, new 
ScratchBytes(byteArray()) });
+        private static final ThreadLocal<Object[]> LOCAL = 
ThreadLocal.withInitial(() -> new Object[] { false, byteArray() });
+
+        private static final ScratchBytes INSTANCE = new ScratchBytes(null);
 
         /**
          * Gets the internal byte array buffer.
@@ -170,9 +172,12 @@ static ScratchBytes get() {
                 return new ScratchBytes(byteArray());
             }
             holder[0] = true;
-            return (ScratchBytes) holder[1];
+            return INSTANCE;
         }
 
+        /**
+         * The buffer, or null if using the thread-local buffer.
+         */
         private final byte[] buffer;
 
         private ScratchBytes(final byte[] buffer) {
@@ -180,7 +185,7 @@ private ScratchBytes(final byte[] buffer) {
         }
 
         byte[] array() {
-            return buffer;
+            return buffer != null ? buffer : (byte[]) LOCAL.get()[1];
         }
 
         /**
@@ -188,9 +193,9 @@ byte[] array() {
          */
         @Override
         public void close() {
-            final Object[] holder = LOCAL.get();
-            if (buffer == ((ScratchBytes) holder[1]).buffer) {
-                Arrays.fill(buffer, (byte) 0);
+            if (buffer == null) {
+                final Object[] holder = LOCAL.get();
+                Arrays.fill((byte[]) holder[1], (byte) 0);
                 holder[0] = false;
             }
         }
@@ -220,7 +225,9 @@ static final class ScratchChars implements AutoCloseable {
          * [0] boolean in use.
          * [1] char[] buffer.
          */
-        private static final ThreadLocal<Object[]> LOCAL = 
ThreadLocal.withInitial(() -> new Object[] { false, new 
ScratchChars(charArray()) });
+        private static final ThreadLocal<Object[]> LOCAL = 
ThreadLocal.withInitial(() -> new Object[] { false, charArray() });
+
+        private static final ScratchChars INSTANCE = new ScratchChars(null);
 
         /**
          * Gets the internal char array buffer.
@@ -234,9 +241,12 @@ static ScratchChars get() {
                 return new ScratchChars(charArray());
             }
             holder[0] = true;
-            return (ScratchChars) holder[1];
+            return INSTANCE;
         }
 
+        /**
+         * The buffer, or null if using the thread-local buffer.
+         */
         private final char[] buffer;
 
         private ScratchChars(final char[] buffer) {
@@ -244,7 +254,7 @@ private ScratchChars(final char[] buffer) {
         }
 
         char[] array() {
-            return buffer;
+            return buffer != null ? buffer : (char[]) LOCAL.get()[1];
         }
 
         /**
@@ -252,9 +262,9 @@ char[] array() {
          */
         @Override
         public void close() {
-            final Object[] holder = LOCAL.get();
-            if (buffer == ((ScratchChars) holder[1]).buffer) {
-                Arrays.fill(buffer, (char) 0);
+            if (buffer == null) {
+                final Object[] holder = LOCAL.get();
+                Arrays.fill((char[]) holder[1], (char) 0);
                 holder[0] = false;
             }
         }

Reply via email to