Summary

When resolving a resource path Spring creates a class path resource for the URI path. In use cases where the application is hit with a variety of request paths that are not mapped in a controller, this results in memory being leaked over time as these resources are not collected.

Steps to reproduce

Create a new spring boot application with a single controller endpoint. Use a script to generate HTTP requests to hit your spring boot app. The request paths must be randomly generated and miss your controller, resulting in a 404 response from Spring. With request rates of 1000 req/s it took me only a few minutes to see massive amounts of leaked memory.

Likely Cause

If you look at PathResourceResolver::getResource on the line that reads Resource resource = location.createRelative(resourcePath);, this creates a class path resource for every incoming request. After a while, this builds up resulting in lots of leaked resource paths.

My setup

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.7</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>playground</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>playground</name>
    <description>playground</description>
    <properties>
        <java.version>11</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

PlaygroundApplication.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import reactor.core.publisher.Mono;

@SpringBootApplication
@Controller("/")
public class PlaygroundApplication {

    @GetMapping("test")
    public Mono<String> doTest() {
        return Mono.just("test");
    }

    public static void main(String[] args) {
        SpringApplication.run(PlaygroundApplication.class, args);
    }
}

Class I wrote to generate requests

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Random;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class QuickTester {
    private static final Random RND = new Random();
    private static final ScheduledExecutorService timerExecutor = Executors.newScheduledThreadPool(40);

    public static void main(String[] args) {
        HttpClient client = HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_1_1)
                .build();

        timerExecutor.scheduleAtFixedRate(() -> {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(String.format("http://localhost:8080/%d",
                            RND.nextInt(1000000000))))
                    .timeout(Duration.ofSeconds(5))
                    .GET()
                    .build();

            try {
                client.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (Exception ignored) {}
        }, 0, 1, TimeUnit.MILLISECONDS);
    }
}

Comment From: bclozel

What do you mean by "they are not collected"? Are those never cleaned by the garbage collector? Do you know what's keeping a reference to those? Did you connect the application to a profiler? If you gathered information there, could you join them to this issue?

Comment From: LoganEarl

Yea, I have a heap dump handy. Here Screen Shot 2022-07-01 at 1 15 54 PM It looks like I might have barked up the wrong tree, I see reactor netty keeping references.

Comment From: bclozel

@LoganEarl that's strange, this shouldn't be keeping references. Are those weak references maybe? Do they go away if you trigger the GC manually in your profiler?