Skip to content

Race condition in ReflectionUtils.findSubclasses() causes NoClassDefFoundError during concurrent class initialization #156

@fgennai

Description

@fgennai

Description

JpaSort and JpaCondition both use ReflectionUtils.findSubclasses() in static field initializers (<clinit>). When both classes are loaded concurrently (which happens in Spring Boot applications during bean initialization), the underlying org.reflections.Reflections library encounters a race condition on shared ZipFile resources, causing an IllegalStateException: zip file closed that escalates to a permanent NoClassDefFoundError.

Affected versions

  • 3.5.0 and later (when JpaSort was refactored to abstract class with findSubclasses())
  • 3.6.0 — same code, same issue
  • 3.6.1-SNAPSHOT — same code, same issue

Root cause

In querity-jpa-common, both classes trigger classpath scanning in their <clinit>:

// JpaSort.java (line 19)
private static final Set<Class<? extends JpaSort>> JPA_SORT_IMPLEMENTATIONS = findSubclasses(JpaSort.class);

// JpaCondition.java (line 19)
private static final Set<Class<? extends JpaCondition>> JPA_CONDITION_IMPLEMENTATIONS = findSubclasses(JpaCondition.class);

ReflectionUtils.findSubclasses() creates a new Reflections instance each time:

public static <T> Set<Class<? extends T>> findSubclasses(Class<T> baseClass) {
    return new Reflections(baseClass.getPackage().getName())
        .getSubTypesOf(baseClass).stream()
        .filter(ReflectionUtils::isConcreteClass)
        .collect(Collectors.toSet());
}

The org.reflections library (v0.10.2) internally uses java.util.zip.ZipFile to read JAR entries during classpath scanning. When two Reflections instances scan concurrently from different threads, they share underlying JVM ZipFile handles (via ClassLoader.getResources()). One thread closing its ZipFile while another is reading from it causes:

java.lang.IllegalStateException: zip file closed
    at java.base/java.util.zip.ZipFile.ensureOpen(ZipFile.java:831)
    at java.base/java.util.zip.ZipFile$ZipEntryIterator.hasNext(ZipFile.java:505)

This triggers ExceptionInInitializerError on the first class to fail, and since JVM <clinit> failures are permanent, all subsequent attempts to use the class produce NoClassDefFoundError.

How to reproduce

The issue is timing-dependent and more likely in applications with:

  • Many JARs on the classpath (more ZipFile operations = wider race window)
  • Spring Boot with parallel bean initialization
  • Multiple beans depending on querity in their constructors (triggering concurrent JpaSort / JpaCondition class loading)

A typical error sequence in logs:

Caused by: java.lang.ExceptionInInitializerError
    at io.github.queritylib.querity.jpa.JpaCondition.of(JpaCondition.java:19)
    ...
Caused by: java.lang.IllegalStateException: zip file closed
    at java.base/java.util.zip.ZipFile.ensureOpen(ZipFile.java:831)
    ...

// Then permanently:
Caused by: java.lang.NoClassDefFoundError: Could not initialize class io.github.queritylib.querity.jpa.JpaSort

Suggested fix

Option A: Synchronize class initialization (minimal change)

Add a shared lock to ensure only one Reflections scan happens at a time:

public class ReflectionUtils {
    private static final Object SCAN_LOCK = new Object();

    public static <T> Set<Class<? extends T>> findSubclasses(Class<T> baseClass) {
        synchronized (SCAN_LOCK) {
            return new Reflections(baseClass.getPackage().getName())
                .getSubTypesOf(baseClass).stream()
                .filter(ReflectionUtils::isConcreteClass)
                .collect(Collectors.toSet());
        }
    }
}

Option B: Eagerly initialize at a known point

Provide a single initialization method that triggers all class loading sequentially, e.g., via a Spring auto-configuration @PostConstruct:

@PostConstruct
void eagerInit() {
    Class.forName("io.github.queritylib.querity.jpa.JpaSort");
    Class.forName("io.github.queritylib.querity.jpa.JpaCondition");
}

Option C: Cache the Reflections instance (best performance)

Since the classpath doesn't change at runtime, cache and reuse a single Reflections scanner:

public class ReflectionUtils {
    private static final Map<String, Reflections> CACHE = new ConcurrentHashMap<>();

    public static <T> Set<Class<? extends T>> findSubclasses(Class<T> baseClass) {
        Reflections reflections = CACHE.computeIfAbsent(
            baseClass.getPackage().getName(),
            pkg -> new Reflections(pkg)
        );
        return reflections.getSubTypesOf(baseClass).stream()
            .filter(ReflectionUtils::isConcreteClass)
            .collect(Collectors.toSet());
    }
}

Note: computeIfAbsent on ConcurrentHashMap guarantees that only one Reflections instance is created per package, but concurrent threads could still trigger parallel scans for different packages. Combining Option A + C would be safest.

Current workaround

We force sequential class initialization in a Spring @Configuration class:

@Configuration
public class QuerityEagerInitConfig {
    @PostConstruct
    void eagerlyInitializeQuerityClasses() {
        try {
            Class.forName("io.github.queritylib.querity.jpa.JpaSort");
            Class.forName("io.github.queritylib.querity.jpa.JpaCondition");
        } catch (ClassNotFoundException e) {
            throw new IllegalStateException("Failed to eagerly initialize Querity classes", e);
        }
    }
}

This works because @PostConstruct runs on the main thread before parallel bean initialization, ensuring <clinit> completes sequentially.

Environment

  • Java 21 (Temurin)
  • Spring Boot 3.4.x
  • querity-spring-data-jpa 3.5.0 / 3.6.0
  • Large classpath (~200 JARs)
  • macOS + Linux (both affected)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions