Skip to content

Check resource existence once when looking for deleted files - #51841

Open
move-hoon wants to merge 1 commit into
spring-projects:mainfrom
move-hoon:devtools-isdeleted-uri-once
Open

move-hoon wants to merge 1 commit into
spring-projects:mainfrom
move-hoon:devtools-isdeleted-uri-once

Conversation

@move-hoon

@move-hoon move-hoon commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #46289.

ClassLoaderFilesResourcePatternResolver.isDeleted() calls resource.exists() and resource.getURI() inside the loop over the uploaded files, so both are repeated once per DELETED entry although neither depends on the entry:

for (Entry<String, ClassLoaderFile> entry : this.classLoaderFiles.getFileEntries()) {
	...
	if (file.getKind() == Kind.DELETED && resource.exists()
			&& resource.getURI().toString().endsWith(name)) {

Every resource the context resolves while it is being built goes through this method — about 1,300 times per restart in the application I measured — so a session that has accumulated 100 deleted files makes around 130,000 file system lookups where 1,300 are enough. The resource is now inspected at most once per call, on the first DELETED entry, and, as before, not at all when nothing is deleted.

The loop also walks the per-directory entry sets again, as ClassLoaderFiles.addAll() and RestartServer do, instead of the flattened view added in #46289. That view is a Collections.unmodifiableSet(...) whose iterator is JDK code shared by every unmodifiable collection in the application; in a running application C2 stops inlining its delegate calls, in a loop that runs tens of millions of times per restart. That is a regression I introduced in #46289: with many accumulated entries and nothing deleted, restarts became slower than they were before it.

The flattened map itself stays — it serves getFile(), and isDeleted() could not use it for a lookup anyway, since it matches a resource URI by suffix. isDeleted() returns a boolean, so the order in which entries are visited cannot change its result.

getAdditionalResources() is left on the flattened view: it runs twice per restart, not 1,300 times.

Measurements

An application driven through the remote restart path, timed from the restart request to ApplicationReadyEvent. 12 JVMs per variant for each row, interleaved, 20 restarts each, medians.

Source directories Accumulated entries Deleted Before After
100 10,000 10 278ms 200ms
100 10,000 100 891ms 201ms
100 10,000 500 3,536ms 200ms
100 50,000 0 431ms 341ms
none 150ms 148ms

The last row is local DevTools, where ClassLoaderFiles stays empty and neither version does any work; it doubles as the control for the measurement. The number of source directories does not matter here: with 100 deleted entries the saving is 690ms with one directory and 687ms with two hundred.

The fourth row is the regression. Measured separately in that configuration with the intermediate variants, 8 JVMs each: 376ms before #46289, 435ms on current main, 351ms with this change, and 339ms with only the unmodifiableSet wrapper removed and the loop otherwise untouched — which is what identifies the wrapper rather than the map as the cause.

Behaviour

The devtools tests pass on current main with JDK 25, and three are added: for a deletion recorded in another source directory, for inspecting the resource only once, and for not touching it when nothing is deleted. A failing getURI() is still reported as the same IllegalStateException.

I also compared the two implementations exhaustively over a closed alphabet (1,885 ClassLoaderFiles states x 14 resources) and under randomised operation sequences. They agree everywhere except on a resolver built over a ClassLoaderFiles copy whose original is mutated afterwards, which Restarter never builds: it constructs the resolver over its own instance, and the copy goes to RestartClassLoader, which only calls getFile().

Notes

main, 4.1.x and 4.0.x carry identical copies of both files, so I am happy to retarget.

Scanning the map's entry set without the wrapper is about 30ms faster still with 50,000 accumulated entries, but it would hand out a mutable view of filesByName, so I kept the read-only accessor and changed the loop instead.

ClassLoaderFilesResourcePatternResolver.isDeleted() calls
resource.exists() and resource.getURI() inside the loop over the
uploaded files, so both are repeated once per DELETED entry although
neither depends on the entry. Every resource lookup made while the
application context is being built goes through this method - about
1300 times per restart in the application I measured - so a session
that has accumulated 100 deleted files performs around 130 000 file
system lookups where 1300 are enough.

This commit hoists both calls out of the loop: the resource is
inspected at most once per call, on the first DELETED entry, and the
URI comparison then uses the cached value. The method still returns on
the first match and still reports a failing getURI() as an
IllegalStateException, so behaviour is unchanged.

The loop now walks the per-directory entry sets, which is what
ClassLoaderFiles.addAll() and RestartServer already do, instead of the
flattened view added in spring-projectsgh-46289. That view is wrapped in
Collections.unmodifiableSet(), whose iterator is shared JDK code; in a
running application its delegate calls are megamorphic and C2 stops
inlining them, which made restarts with many accumulated files and no
deletions slower than before spring-projectsgh-46289. isDeleted() returns a boolean,
so the visiting order of the entries cannot change its result.

Measured on a Spring Boot application driven through the remote restart
path (restart request to ApplicationReadyEvent), 12 JVMs per variant
and 20 restarts per JVM, medians:

  100 source directories, 10 000 entries, 10 deleted:  278 ms -> 200 ms
  100 source directories, 10 000 entries, 100 deleted: 891 ms -> 201 ms
  100 source directories, 10 000 entries, 500 deleted: 3536 ms -> 200 ms
  100 source directories, 50 000 entries, none deleted: 431 ms -> 341 ms
  100 source directories, 10 000 entries, none deleted: 202 ms -> 192 ms
  no uploaded files:                                   150 ms -> 148 ms

Signed-off-by: DongHoon Lee <dhl1924@naver.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants