From ca25a15b025f7702e8328d99189bd6e373c72fc8 Mon Sep 17 00:00:00 2001 From: DongHoon Lee Date: Fri, 4 Jul 2025 20:36:43 +0900 Subject: [PATCH] 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. Signed-off-by: DongHoon Lee --- ...assLoaderFilesResourcePatternResolver.java | 38 +++++++++---------- .../restart/classloader/ClassLoaderFiles.java | 33 ++++++++++------ 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java index 4d827d9810ee..da8aa7392574 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java @@ -123,15 +123,13 @@ public Resource[] getResources(String locationPattern) throws IOException { 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; @@ -147,20 +145,18 @@ private String trimLocationPattern(String pattern) { } 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/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java index 1def6fd05b1f..8b4ee7e5c5d3 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFiles.java @@ -43,11 +43,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<>(); } /** @@ -57,6 +64,7 @@ public ClassLoaderFiles() { public ClassLoaderFiles(ClassLoaderFiles classLoaderFiles) { Assert.notNull(classLoaderFiles, "'classLoaderFiles' must not be null"); this.sourceDirectories = new LinkedHashMap<>(classLoaderFiles.sourceDirectories); + this.filesByName = new LinkedHashMap<>(classLoaderFiles.filesByName); } /** @@ -94,12 +102,14 @@ public void addFile(String sourceDirectory, String name, ClassLoaderFile file) { 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); } /** @@ -125,22 +135,21 @@ public Collection getSourceDirectories() { * @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 ClassLoaderFile getFile(String name) { - for (SourceDirectory sourceDirectory : this.sourceDirectories.values()) { - ClassLoaderFile file = sourceDirectory.get(name); - if (file != null) { - return file; - } - } - return null; + 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()); } /**