From 3e41807e1d5561256424f5f2b098f74bcc706e40 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Fri, 4 Jul 2025 20:36:43 +0900 Subject: [PATCH 1/2] Optimize DevTools resource lookup performance The resource resolver in DevTools can cause performance degradation during application restarts in large projects. Key methods like isDeleted() and getAdditionalResources() rely on nested loops, leading to O(n*m) complexity. This commit refactors ClassLoaderFiles to use a pre-computed, flattened map. This provides O(1) complexity for direct lookups and allows for efficient single-loop iteration. The ClassLoaderFilesResourcePatternResolver is updated to leverage this new, efficient structure: - getFile() and size() are improved from O(n) to O(1). - isDeleted() and getAdditionalResources() are improved from O(n*m) to O(m) by eliminating nested loops. - Data consistency is maintained across all operations. This optimization significantly improves restart performance with a minimal memory footprint, while preserving the existing API and exception handling behavior. See gh-46289 Signed-off-by: DongHoon Lee --- ...assLoaderFilesResourcePatternResolver.java | 38 +++++++++---------- .../restart/classloader/ClassLoaderFiles.java | 35 ++++++++++------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java index 9a894cbdcc4..b383b116aeb 100644 --- a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java +++ b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java @@ -125,15 +125,13 @@ final class ClassLoaderFilesResourcePatternResolver implements ResourcePatternRe private List getAdditionalResources(String locationPattern) throws MalformedURLException { List additionalResources = new ArrayList<>(); String trimmedLocationPattern = trimLocationPattern(locationPattern); - for (SourceDirectory sourceDirectory : this.classLoaderFiles.getSourceDirectories()) { - for (Entry entry : sourceDirectory.getFilesEntrySet()) { - String name = entry.getKey(); - ClassLoaderFile file = entry.getValue(); - if (file.getKind() != Kind.DELETED && this.antPathMatcher.match(trimmedLocationPattern, name)) { - URL url = new URL("reloaded", null, -1, "/" + name, new ClassLoaderFileURLStreamHandler(file)); - UrlResource resource = new UrlResource(url); - additionalResources.add(resource); - } + for (Entry entry : this.classLoaderFiles.getFileEntries()) { + String name = entry.getKey(); + ClassLoaderFile file = entry.getValue(); + if (file.getKind() != Kind.DELETED && this.antPathMatcher.match(trimmedLocationPattern, name)) { + URL url = new URL("reloaded", null, -1, "/" + name, new ClassLoaderFileURLStreamHandler(file)); + UrlResource resource = new UrlResource(url); + additionalResources.add(resource); } } return additionalResources; @@ -149,20 +147,18 @@ final class ClassLoaderFilesResourcePatternResolver implements ResourcePatternRe } private boolean isDeleted(Resource resource) { - for (SourceDirectory sourceDirectory : this.classLoaderFiles.getSourceDirectories()) { - for (Entry entry : sourceDirectory.getFilesEntrySet()) { - try { - String name = entry.getKey(); - ClassLoaderFile file = entry.getValue(); - if (file.getKind() == Kind.DELETED && resource.exists() - && resource.getURI().toString().endsWith(name)) { - return true; - } - } - catch (IOException ex) { - throw new IllegalStateException("Failed to retrieve URI from '" + resource + "'", ex); + for (Entry entry : this.classLoaderFiles.getFileEntries()) { + try { + String name = entry.getKey(); + ClassLoaderFile file = entry.getValue(); + if (file.getKind() == Kind.DELETED && resource.exists() + && resource.getURI().toString().endsWith(name)) { + return true; } } + catch (IOException ex) { + throw new IllegalStateException("Failed to retrieve URI from '" + resource + "'", ex); + } } return false; } diff --git a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java index a4d28604633..3b1669ff159 100644 --- a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java +++ b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java @@ -46,11 +46,18 @@ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable private final Map sourceDirectories; + /** + * A flattened map of all files from all source directories for fast, O(1) lookups. + * The key is the file's relative path, and the value is the ClassLoaderFile. + */ + private final Map filesByName; + /** * Create a new {@link ClassLoaderFiles} instance. */ public ClassLoaderFiles() { this.sourceDirectories = new LinkedHashMap<>(); + this.filesByName = new LinkedHashMap<>(); } /** @@ -60,6 +67,7 @@ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable public ClassLoaderFiles(ClassLoaderFiles classLoaderFiles) { Assert.notNull(classLoaderFiles, "'classLoaderFiles' must not be null"); this.sourceDirectories = new LinkedHashMap<>(classLoaderFiles.sourceDirectories); + this.filesByName = new LinkedHashMap<>(classLoaderFiles.filesByName); } /** @@ -97,12 +105,14 @@ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable Assert.notNull(file, "'file' must not be null"); removeAll(name); getOrCreateSourceDirectory(sourceDirectory).add(name, file); + this.filesByName.put(name, file); } private void removeAll(String name) { for (SourceDirectory sourceDirectory : this.sourceDirectories.values()) { sourceDirectory.remove(name); } + this.filesByName.remove(name); } /** @@ -128,22 +138,21 @@ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable * @return the size of the collection */ public int size() { - int size = 0; - for (SourceDirectory sourceDirectory : this.sourceDirectories.values()) { - size += sourceDirectory.getFiles().size(); - } - return size; + return this.filesByName.size(); } @Override - public @Nullable ClassLoaderFile getFile(String name) { - for (SourceDirectory sourceDirectory : this.sourceDirectories.values()) { - ClassLoaderFile file = sourceDirectory.get(name); - if (file != null) { - return file; - } - } - return null; + public ClassLoaderFile getFile(String name) { + return this.filesByName.get(name); + } + + /** + * Returns a set of all file entries across all source directories for efficient + * iteration. + * @return a set of all file entries + */ + public Set> getFileEntries() { + return Collections.unmodifiableSet(this.filesByName.entrySet()); } /** From 2c4d162f406465b1099247858397b2cd53505ba7 Mon Sep 17 00:00:00 2001 From: Moritz Halbritter Date: Fri, 29 Aug 2025 11:31:07 +0200 Subject: [PATCH 2/2] Polish "Optimize DevTools resource lookup performance" See gh-46289 --- .../restart/ClassLoaderFilesResourcePatternResolver.java | 1 - .../boot/devtools/restart/classloader/ClassLoaderFiles.java | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java index b383b116aeb..7541b0947c4 100644 --- a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java +++ b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java @@ -33,7 +33,6 @@ import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile.Kind; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFileURLStreamHandler; import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles; -import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles.SourceDirectory; import org.springframework.context.ApplicationContext; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.core.io.AbstractResource; diff --git a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java index 3b1669ff159..95797d03817 100644 --- a/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java +++ b/module/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java @@ -42,6 +42,7 @@ import org.springframework.util.Assert; */ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable { + @Serial private static final long serialVersionUID = 1; private final Map sourceDirectories; @@ -142,7 +143,7 @@ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable } @Override - public ClassLoaderFile getFile(String name) { + public @Nullable ClassLoaderFile getFile(String name) { return this.filesByName.get(name); } @@ -150,6 +151,7 @@ public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable * Returns a set of all file entries across all source directories for efficient * iteration. * @return a set of all file entries + * @since 4.0.0 */ public Set> getFileEntries() { return Collections.unmodifiableSet(this.filesByName.entrySet());