From 67e5ea9509d079c6b2083abf844d80b717a907c7 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 22 Jun 2026 21:50:56 +0200 Subject: [PATCH] Revise resource bundle caching for common locales Closes gh-36957 --- .../context/support/MessageSourceSupport.java | 57 ++++++--- ...ReloadableResourceBundleMessageSource.java | 112 +++++++++++------- .../support/ResourceBundleMessageSource.java | 50 ++++---- .../org/springframework/util/StringUtils.java | 1 + 4 files changed, 136 insertions(+), 84 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java b/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java index 93ca113e077..64a59bd4780 100644 --- a/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java +++ b/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java @@ -17,15 +17,19 @@ package org.springframework.context.support; import java.text.MessageFormat; +import java.util.Arrays; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * Base class for message source implementations, providing support infrastructure @@ -41,6 +45,9 @@ import org.springframework.util.ObjectUtils; */ public abstract class MessageSourceSupport { + static final Set JVM_LOCALES = Arrays.stream(Locale.getAvailableLocales()). + filter(l -> StringUtils.hasLength(l.getLanguage())).collect(Collectors.toSet()); + private static final MessageFormat INVALID_MESSAGE_FORMAT = new MessageFormat(""); /** Logger available to subclasses. */ @@ -116,22 +123,17 @@ public abstract class MessageSourceSupport { if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) { return msg; } - Map messageFormatsPerLocale = this.messageFormatsPerMessage - .computeIfAbsent(msg, key -> new ConcurrentHashMap<>()); - MessageFormat messageFormat = messageFormatsPerLocale.computeIfAbsent(locale, key -> { - try { - return createMessageFormat(msg, locale); - } - catch (IllegalArgumentException ex) { - // Invalid message format - probably not intended for formatting, - // rather using a message structure with no arguments involved... - if (isAlwaysUseMessageFormat()) { - throw ex; - } - // Silently proceed with raw message if format not enforced... - return INVALID_MESSAGE_FORMAT; - } - }); + + MessageFormat messageFormat; + if (locale != null && JVM_LOCALES.contains(locale)) { + Map messageFormatsPerLocale = this.messageFormatsPerMessage + .computeIfAbsent(msg, key -> new ConcurrentHashMap<>()); + messageFormat = messageFormatsPerLocale.computeIfAbsent(locale, key -> resolveMessageFormat(msg, key)); + } + else { + messageFormat = resolveMessageFormat(msg, locale); + } + if (messageFormat == INVALID_MESSAGE_FORMAT) { return msg; } @@ -140,6 +142,29 @@ public abstract class MessageSourceSupport { } } + /** + * Resolve a {@code MessageFormat} for the given message and Locale. + * @param msg the message to create a {@code MessageFormat} for + * @param locale the Locale to create a {@code MessageFormat} for + * @return the {@code MessageFormat} instance, or otherwise + * {@link #INVALID_MESSAGE_FORMAT} if not resolvable + * @see #createMessageFormat + */ + private MessageFormat resolveMessageFormat(String msg, @Nullable Locale locale) { + try { + return createMessageFormat(msg, locale); + } + catch (IllegalArgumentException ex) { + // Invalid message format - probably not intended for formatting, + // rather using a message structure with no arguments involved... + if (isAlwaysUseMessageFormat()) { + throw ex; + } + // Silently proceed with raw message if format not enforced... + return INVALID_MESSAGE_FORMAT; + } + } + /** * Create a {@code MessageFormat} for the given message and Locale. * @param msg the message to create a {@code MessageFormat} for diff --git a/spring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.java b/spring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.java index 851aeed9da9..8ddb37abe63 100644 --- a/spring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/support/ReloadableResourceBundleMessageSource.java @@ -40,6 +40,7 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.ConcurrentLruCache; import org.springframework.util.DefaultPropertiesPersister; import org.springframework.util.PropertiesPersister; import org.springframework.util.StringUtils; @@ -110,15 +111,19 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased private ResourceLoader resourceLoader = new DefaultResourceLoader(); - // Cache to hold filename lists per Locale + // Cache to hold filename lists per Locale. private final ConcurrentMap>> cachedFilenames = new ConcurrentHashMap<>(); - // Cache to hold already loaded properties per filename + // Cache to hold already loaded properties per filename. private final ConcurrentMap cachedProperties = new ConcurrentHashMap<>(); - // Cache to hold already loaded properties per filename + // Cache to hold already merged properties per Locale. private final ConcurrentMap cachedMergedProperties = new ConcurrentHashMap<>(); + // Cache to hold merged properties per non-JVM Locale. + private final ConcurrentLruCache customLocaleProperties = + new ConcurrentLruCache<>(64, locale -> mergeProperties(collectPropertiesToMerge(locale))); + /** * Set the list of supported file extensions. @@ -197,20 +202,16 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased protected @Nullable String resolveCodeWithoutArguments(String code, Locale locale) { if (getCacheMillis() < 0) { PropertiesHolder propHolder = getMergedProperties(locale); - String result = propHolder.getProperty(code); - if (result != null) { - return result; - } + return propHolder.getProperty(code); } - else { - for (String basename : getBasenameSet()) { - List filenames = calculateAllFilenames(basename, locale); - for (String filename : filenames) { - PropertiesHolder propHolder = getProperties(filename); - String result = propHolder.getProperty(code); - if (result != null) { - return result; - } + + for (String basename : getBasenameSet()) { + List filenames = calculateAllFilenames(basename, locale); + for (String filename : filenames) { + PropertiesHolder propHolder = getProperties(filename, locale); + String result = propHolder.getProperty(code); + if (result != null) { + return result; } } } @@ -225,20 +226,16 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased protected @Nullable MessageFormat resolveCode(String code, Locale locale) { if (getCacheMillis() < 0) { PropertiesHolder propHolder = getMergedProperties(locale); - MessageFormat result = propHolder.getMessageFormat(code, locale); - if (result != null) { - return result; - } + return propHolder.getMessageFormat(code, locale); } - else { - for (String basename : getBasenameSet()) { - List filenames = calculateAllFilenames(basename, locale); - for (String filename : filenames) { - PropertiesHolder propHolder = getProperties(filename); - MessageFormat result = propHolder.getMessageFormat(code, locale); - if (result != null) { - return result; - } + + for (String basename : getBasenameSet()) { + List filenames = calculateAllFilenames(basename, locale); + for (String filename : filenames) { + PropertiesHolder propHolder = getProperties(filename, locale); + MessageFormat result = propHolder.getMessageFormat(code, locale); + if (result != null) { + return result; } } } @@ -261,12 +258,18 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased if (mergedHolder != null) { return mergedHolder; } - mergedHolder = mergeProperties(collectPropertiesToMerge(locale)); - PropertiesHolder existing = this.cachedMergedProperties.putIfAbsent(locale, mergedHolder); - if (existing != null) { - mergedHolder = existing; + + if (JVM_LOCALES.contains(locale)) { + mergedHolder = mergeProperties(collectPropertiesToMerge(locale)); + PropertiesHolder existing = this.cachedMergedProperties.putIfAbsent(locale, mergedHolder); + if (existing != null) { + mergedHolder = existing; + } + return mergedHolder; + } + else { + return this.customLocaleProperties.get(locale); } - return mergedHolder; } /** @@ -285,7 +288,7 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased List filenames = calculateAllFilenames(basenames[i], locale); for (int j = filenames.size() - 1; j >= 0; j--) { String filename = filenames.get(j); - PropertiesHolder propHolder = getProperties(filename); + PropertiesHolder propHolder = getProperties(filename, locale); if (propHolder.getProperties() != null) { holders.add(propHolder); } @@ -334,11 +337,11 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased } } - // Filenames for given Locale + // Filenames for given Locale. List filenames = new ArrayList<>(7); filenames.addAll(calculateFilenamesForLocale(basename, locale)); - // Filenames for default Locale, if any + // Filenames for default Locale, if any. Locale defaultLocale = getDefaultLocale(); if (defaultLocale != null && !defaultLocale.equals(locale)) { List fallbackFilenames = calculateFilenamesForLocale(basename, defaultLocale); @@ -350,24 +353,27 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased } } - // Filename for default bundle file + // Filename for default bundle file. filenames.add(basename); - if (localeMap == null) { - localeMap = new ConcurrentHashMap<>(); - Map> existing = this.cachedFilenames.putIfAbsent(basename, localeMap); - if (existing != null) { - localeMap = existing; + if (JVM_LOCALES.contains(locale)) { + if (localeMap == null) { + localeMap = new ConcurrentHashMap<>(); + Map> existing = this.cachedFilenames.putIfAbsent(basename, localeMap); + if (existing != null) { + localeMap = existing; + } } + localeMap.put(locale, filenames); } - localeMap.put(locale, filenames); + return filenames; } /** * Calculate the filenames for the given bundle basename and Locale, * appending language code, country code, and variant code. - *

For example, basename "messages", Locale "de_AT_oo" → "messages_de_AT_OO", + *

For example, basename "messages", Locale "de_AT_OO" → "messages_de_AT_OO", * "messages_de_AT", "messages_de". *

Follows the rules defined by {@link java.util.Locale#toString()}. * @param basename the basename of the bundle @@ -402,6 +408,22 @@ public class ReloadableResourceBundleMessageSource extends AbstractResourceBased } + /** + * Get a PropertiesHolder for the given filename, either from the + * cache or freshly loaded. + * @param filename the bundle filename (basename + Locale) + * @param locale the requested locale (for cache filtering) + * @return the current PropertiesHolder for the bundle + * @see #getProperties(String) + */ + private PropertiesHolder getProperties(String filename, Locale locale) { + PropertiesHolder propHolder = getProperties(filename); + if (propHolder.getProperties() == null && !JVM_LOCALES.contains(locale)) { + this.cachedProperties.remove(filename); + } + return propHolder; + } + /** * Get a PropertiesHolder for the given filename, either from the * cache or freshly loaded. diff --git a/spring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.java b/spring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.java index 954d6edb780..2b02ec479e9 100644 --- a/spring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/support/ResourceBundleMessageSource.java @@ -188,36 +188,35 @@ public class ResourceBundleMessageSource extends AbstractResourceBasedMessageSou * found for the given basename and Locale */ protected @Nullable ResourceBundle getResourceBundle(String basename, Locale locale) { - if (getCacheMillis() >= 0) { + if (getCacheMillis() >= 0 || !JVM_LOCALES.contains(locale)) { // Fresh ResourceBundle.getBundle call in order to let ResourceBundle // do its native caching, at the expense of more extensive lookup steps. return doGetBundle(basename, locale); } - else { - // Cache forever: prefer locale cache over repeated getBundle calls. - Map localeMap = this.cachedResourceBundles.get(basename); - if (localeMap != null) { - ResourceBundle bundle = localeMap.get(locale); - if (bundle != null) { - return bundle; - } - } - try { - ResourceBundle bundle = doGetBundle(basename, locale); - if (localeMap == null) { - localeMap = this.cachedResourceBundles.computeIfAbsent(basename, bn -> new ConcurrentHashMap<>()); - } - localeMap.put(locale, bundle); + + // Cache forever: prefer local cache over repeated getBundle calls. + Map localeMap = this.cachedResourceBundles.get(basename); + if (localeMap != null) { + ResourceBundle bundle = localeMap.get(locale); + if (bundle != null) { return bundle; } - catch (MissingResourceException ex) { - if (logger.isWarnEnabled()) { - logger.warn("ResourceBundle [" + basename + "] not found for MessageSource: " + ex.getMessage()); - } - // Assume bundle not found - // -> do NOT throw the exception to allow for checking parent message source. - return null; + } + try { + ResourceBundle bundle = doGetBundle(basename, locale); + if (localeMap == null) { + localeMap = this.cachedResourceBundles.computeIfAbsent(basename, bn -> new ConcurrentHashMap<>()); } + localeMap.put(locale, bundle); + return bundle; + } + catch (MissingResourceException ex) { + if (logger.isWarnEnabled()) { + logger.warn("ResourceBundle [" + basename + "] not found for MessageSource: " + ex.getMessage()); + } + // Assume bundle not found + // -> do NOT throw the exception to allow for checking parent message source. + return null; } } @@ -311,6 +310,11 @@ public class ResourceBundleMessageSource extends AbstractResourceBasedMessageSou protected @Nullable MessageFormat getMessageFormat(ResourceBundle bundle, String code, Locale locale) throws MissingResourceException { + if (!JVM_LOCALES.contains(locale)) { + String msg = getStringOrNull(bundle, code); + return (msg != null ? createMessageFormat(msg, locale) : null); + } + Map> codeMap = this.cachedBundleMessageFormats.get(bundle); Map localeMap = null; if (codeMap != null) { diff --git a/spring-core/src/main/java/org/springframework/util/StringUtils.java b/spring-core/src/main/java/org/springframework/util/StringUtils.java index 84d455a61eb..ea787d7cdd0 100644 --- a/spring-core/src/main/java/org/springframework/util/StringUtils.java +++ b/spring-core/src/main/java/org/springframework/util/StringUtils.java @@ -956,6 +956,7 @@ public abstract class StringUtils { String country = tokens[1]; validateLocalePart(country); String variant = Arrays.stream(tokens).skip(2).collect(Collectors.joining(delimiter)); + validateLocalePart(variant); return new Locale(language, country, variant); }