This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new eae89fc69 FastDateFormat static caches grow without bound per distinct
pattern/zone/locale, untrusted patterns pin several KB each, forever (f033).
eae89fc69 is described below
commit eae89fc698f9a7acbba1903b836451041b417618
Author: Gary Gregory <[email protected]>
AuthorDate: Sun Sep 6 17:06:56 2026 -0400
FastDateFormat static caches grow without bound per distinct
pattern/zone/locale, untrusted patterns pin several KB each, forever
(f033).
---
src/changes/changes.xml | 1 +
.../commons/lang3/time/AbstractFormatCache.java | 14 ++++++++++
.../apache/commons/lang3/time/FastDateFormat.java | 29 +++++++++++++++++++--
.../apache/commons/lang3/time/FastDatePrinter.java | 5 ++++
.../commons/lang3/time/FastDateFormatTest.java | 30 ++++++++++++++++++++++
5 files changed, 77 insertions(+), 2 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index ff1854cc8..00defba20 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -279,6 +279,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">ThresholdCircuitBreaker accepts negative increments and overflows its
accumulator, silently keeping the breaker closed (f027).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">ExceptionUtils.getThrowableList() cycle check is O(n^2) over deep
cause chains; all chain-walking consumers inherit it (f031).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">Reflection builders' cycle registry unregisters per-visit: cycle-SAFE
but DAG-exponential, a 40-level reference diamond drives ~2^40 traversals and
unbounded output (f032).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">FastDateFormat static caches grow without bound per distinct
pattern/zone/locale, untrusted patterns pin several KB each, forever
(f033).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git
a/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
b/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
index d897893d3..914d22fb3 100644
--- a/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
+++ b/src/main/java/org/apache/commons/lang3/time/AbstractFormatCache.java
@@ -79,6 +79,15 @@ public int hashCode() {
*/
static final int NONE = -1;
+ /**
+ * Maximum number of entries a static cache in this package may hold
before it is flushed.
+ * These caches live for the lifetime of the JVM and are keyed by
caller-supplied values
+ * (pattern, time zone, locale), so without a bound, unbounded-cardinality
inputs would pin
+ * memory forever. The bound is approximate under concurrency; flushing
only costs
+ * re-creation of evicted entries.
+ */
+ static final int MAX_CACHE_SIZE = 1024;
+
private static final ConcurrentMap<ArrayKey, String> dateTimeInstanceCache
= new ConcurrentHashMap<>(7);
/**
@@ -213,6 +222,11 @@ public F getInstance(final String pattern, final TimeZone
timeZone, final Locale
final TimeZone actualTimeZone = TimeZones.toTimeZone(timeZone);
final Locale actualLocale = LocaleUtils.toLocale(locale);
final ArrayKey key = new ArrayKey(pattern, actualTimeZone,
actualLocale);
+ // Bound the cache: it is static and process-lifetime, so
unbounded-cardinality keys
+ // (for example patterns derived from caller input) would otherwise
pin memory forever.
+ if (instanceCache.size() >= MAX_CACHE_SIZE &&
!instanceCache.containsKey(key)) {
+ instanceCache.clear();
+ }
return instanceCache.computeIfAbsent(key, k -> createInstance(pattern,
actualTimeZone, actualLocale));
}
diff --git a/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
b/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
index 224b4a578..0a08f38fd 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDateFormat.java
@@ -50,6 +50,13 @@
* </p>
*
* <p>
+ * Note on memory retention: unlike {@code new SimpleDateFormat(pattern)},
instances obtained from the static factory methods are held in a static cache
keyed
+ * by (pattern, time zone, locale). The cache is bounded (it is flushed when
it exceeds an internal limit) and can be flushed explicitly with
+ * {@link #clear()}, but each distinct key retains its instance for the
lifetime of the JVM until then. Prefer fixed, application-defined patterns; do
not
+ * pass unvalidated caller-supplied pattern, time zone, or locale values to
the factory methods.
+ * </p>
+ *
+ * <p>
* All patterns are compatible with SimpleDateFormat (except time zones and
some year patterns - see below).
* </p>
*
@@ -113,12 +120,30 @@ protected FastDateFormat createInstance(final String
pattern, final TimeZone tim
};
/**
- * Clears the cache.
+ * Clears the caches.
+ * <p>
+ * Clears the static caches used by {@link FastDateFormat}: the (pattern,
time zone, locale) to instance cache and the time zone display-name cache.
+ * Cached instances already obtained by callers remain valid; subsequent
factory calls simply create and cache new instances. This can be used for
+ * operational relief if many distinct patterns, time zones, or locales
have been used.
+ * </p>
*/
static void clear() {
AbstractFormatCache.clear();
CACHE.clearInstance();
- }
+ FastDatePrinter.clear();
+ }
+
+// /**
+// * Clears the static caches used by {@link FastDateFormat}: the
(pattern, time zone, locale) to instance cache and the time zone display-name
cache.
+// * Cached instances already obtained by callers remain valid; subsequent
factory calls simply create and cache new instances. This can be used for
+// * operational relief if many distinct patterns, time zones, or locales
have been used.
+// *
+// * @since 3.21.0
+// */
+// public static void clearCache() {
+// clear();
+// FastDatePrinter.clear();
+// }
/**
* Gets a date formatter instance using the specified style in the default
time zone and locale.
diff --git a/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
b/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
index 2163ab26d..876704fef 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDatePrinter.java
@@ -1021,6 +1021,11 @@ static void clear() {
*/
static String getTimeZoneDisplay(final TimeZone tz, final boolean
daylight, final int style, final Locale locale) {
final TimeZoneDisplayKey key = new TimeZoneDisplayKey(tz, daylight,
style, locale);
+ // Bound the cache: it is static and process-lifetime, and custom time
zone IDs give the
+ // key unbounded cardinality, which would otherwise pin memory forever.
+ if (timeZoneDisplayCache.size() >= AbstractFormatCache.MAX_CACHE_SIZE
&& !timeZoneDisplayCache.containsKey(key)) {
+ timeZoneDisplayCache.clear();
+ }
// This is a very slow call, so cache the results.
return timeZoneDisplayCache.computeIfAbsent(key, k ->
tz.getDisplayName(daylight, style, locale));
}
diff --git
a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
index 4023b8e30..09b937626 100644
--- a/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/FastDateFormatTest.java
@@ -219,6 +219,36 @@ void test_getInstance_String_TimeZone_Locale() {
assertEquals(Locale.GERMANY, format3.getLocale());
}
+ /**
+ * The instance cache is bounded: once it reaches its size limit, it is
flushed rather than
+ * growing without bound per distinct pattern.
+ */
+ @Test
+ void test_instanceCacheIsBounded() {
+ FastDateFormat.clear();
+ final FastDateFormat first = FastDateFormat.getInstance("yyyy-MM-dd");
+ assertSame(first, FastDateFormat.getInstance("yyyy-MM-dd"));
+ // Exceed the cache bound with distinct patterns; the cache must
flush, not grow forever.
+ for (int i = 0; i <= AbstractFormatCache.MAX_CACHE_SIZE; i++) {
+ FastDateFormat.getInstance("'p" + i + "'yyyy");
+ }
+ assertNotSame(first, FastDateFormat.getInstance("yyyy-MM-dd"), "Cache
was not flushed at its bound");
+ FastDateFormat.clear();
+ }
+
+ /**
+ * Tests the public cache-flush entry point.
+ */
+ @Test
+ void test_publicClearCache() {
+ final FastDateFormat format1 =
FastDateFormat.getInstance("yyyy-MM-dd'T'HH");
+ assertSame(format1, FastDateFormat.getInstance("yyyy-MM-dd'T'HH"));
+ FastDateFormat.clear();
+ final FastDateFormat format2 =
FastDateFormat.getInstance("yyyy-MM-dd'T'HH");
+ assertNotSame(format1, format2);
+ assertEquals(format1, format2);
+ }
+
@Test
@ReadsDefaultLocale
@ReadsDefaultTimeZone