`ConcurrentModificationException` in `SchemaPropertyDeprecatingConverter.isDeprecated`, and `DEPRECATED_ANNOTATIONS` gains a duplicate on every context start

クローズ 初心者向け
#3,400 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 9 日以内に返信

@awinishere がすでに取り組んでいます。

2026年11月1日 から。

  • #3402 @awinishere による — オープン

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
85/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
java, kotlin

調査の方向性

SchemaPropertyDeprecatingConverter.isDeprecated と springdoc-openapi-starter-common 内の DEPRECATED_ANNOTATIONS 宣言から始め、これらのコレクションの動作を、issue で言及されている copy-on-write コレクションと比較してください。issue に記載された再現方法を使って、並行登録と繰り返し登録を確認してください。並行呼び出しで例外が発生せず、繰り返し登録後に期待される2つのアノテーション型だけが残れば完了です。

索引モデルが issue の本文から書いたものです。

説明

Describe the bug

SchemaPropertyDeprecatingConverter keeps the deprecated annotation types in a JVM-wide static Collections.synchronizedList. Two problems in springdoc-openapi-starter-common:

  1. isDeprecated(Method) calls DEPRECATED_ANNOTATIONS.stream() without holding the list's lock. A synchronized list only synchronizes its own methods, and the Javadoc of Collections.synchronizedList requires a manual lock for traversal by iterator, spliterator or stream. A concurrent add makes the stream throw a ConcurrentModificationException.
  2. SpringDocKotlinConfiguration's init block calls SpringDocUtils.getConfig().addDeprecatedType(Deprecated::class.java) on every application context start. The list never deduplicates, so it grows by one entry per context. Every isDeprecated call (once per handler method per /v3/api-docs generation) then iterates the duplicates.

Together they make /v3/api-docs fail with a 500 whenever one context in the JVM starts while another generates its docs:

java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$ArrayListSpliterator.tryAdvance(ArrayList.java:1703)
	at java.base/java.util.stream.ReferencePipeline.forEachWithCancel(ReferencePipeline.java:147)
	...
	at java.base/java.util.stream.ReferencePipeline.anyMatch(ReferencePipeline.java:668)
	at org.springdoc.core.converters.SchemaPropertyDeprecatingConverter.isDeprecated(SchemaPropertyDeprecatingConverter.java:87)
	at org.springdoc.api.AbstractOpenApiResource...

We hit this in a Kotlin Spring Boot application's integration tests: JUnit 5 runs test classes in parallel (junit.jupiter.execution.parallel.mode.classes.default=concurrent), test classes with different configurations start their own contexts, and a test that requests /v3/api-docs fails intermittently. A single production context is not affected, because its one add happens before the server serves requests.

The other static lists of this kind were already fixed for the same reason (#1641): HIDDEN_REST_CONTROLLERS, ADDITIONAL_REST_CONTROLLERS and ANNOTATIONS_TO_IGNORE copy a snapshot under the lock, and the ConverterUtils, AbstractRequestService and PolymorphicModelConverter collections are CopyOnWriteArrayList/CopyOnWriteArraySet. DEPRECATED_ANNOTATIONS is the remaining synchronizedList that is streamed without the lock.

To Reproduce

  • spring-boot: not needed for the reproduction; we see it with 4.1.1
  • springdoc-openapi: springdoc-openapi-starter-common 3.2.0 (we first saw it on 3.0.3)

build.gradle.kts:

plugins { application }

repositories { mavenCentral() }

dependencies {
  implementation("org.springdoc:springdoc-openapi-starter-common:3.2.0")
  implementation("org.jetbrains.kotlin:kotlin-stdlib:2.2.20")
  implementation("org.springframework:spring-web:7.0.0")
}

application { mainClass = "Repro" }

src/main/java/Repro.java:

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import org.springdoc.core.configuration.SpringDocKotlinConfiguration;
import org.springdoc.core.converters.SchemaPropertyDeprecatingConverter;

public class Repro {

	@Deprecated
	public void endpoint() {
	}

	public static void main(String[] args) throws Exception {
		// 1. Every Spring context start constructs SpringDocKotlinConfiguration, whose init block
		//    calls addDeprecatedType(kotlin.Deprecated) again: the static list gains a duplicate each time.
		for (int i = 0; i < 100; i++) {
			new SpringDocKotlinConfiguration();
		}
		System.out.println("DEPRECATED_ANNOTATIONS after 100 context starts: " + deprecatedAnnotations().size());

		// 2. One context starting (writer) while another serves /v3/api-docs (reader, which calls isDeprecated
		//    for every handler method). isDeprecated streams the synchronizedList without holding its lock.
		Method method = Repro.class.getMethod("endpoint");
		ExecutorService pool = Executors.newFixedThreadPool(2);
		Future<?> writer = pool.submit(() -> {
			for (int i = 0; i < 20_000; i++) {
				new SpringDocKotlinConfiguration();
			}
		});
		Future<?> reader = pool.submit(() -> {
			while (!writer.isDone()) {
				SchemaPropertyDeprecatingConverter.isDeprecated(method);
			}
		});
		try {
			reader.get();
			System.out.println("No exception this run");
		}
		finally {
			pool.shutdownNow();
		}
	}

	private static Collection<?> deprecatedAnnotations() throws ReflectiveOperationException {
		Field field = SchemaPropertyDeprecatingConverter.class.getDeclaredField("DEPRECATED_ANNOTATIONS");
		field.setAccessible(true);
		return (Collection<?>) field.get(null);
	}
}

gradle run on 3.2.0 (JDK 26), 5 out of 5 runs:

DEPRECATED_ANNOTATIONS after 100 context starts: 101
Exception in thread "main" java.util.concurrent.ExecutionException: java.util.ConcurrentModificationException
	...
Caused by: java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$ArrayListSpliterator.tryAdvance(ArrayList.java:1703)
	...
	at org.springdoc.core.converters.SchemaPropertyDeprecatingConverter.isDeprecated(SchemaPropertyDeprecatingConverter.java:87)

new SpringDocKotlinConfiguration() stands in for a context start: it runs the same init block. The reflection only prints the size.

Expected behavior

  • isDeprecated does not throw while another context registers deprecated types.
  • Registering a type that is already registered does not grow the list: 100 context starts leave two entries (java.lang.Deprecated and kotlin.Deprecated).

Suggested fix

Make DEPRECATED_ANNOTATIONS a CopyOnWriteArraySet, like PolymorphicModelConverter.PARENT_TYPES_TO_IGNORE. That fixes both: iteration works on a snapshot, and add ignores a duplicate. The list is written a few times at startup and read on every docs generation, which suits copy-on-write. Every use (add, contains, stream) is also on Set, so nothing else changes. With that change the reproduction prints 2 and passes 5 out of 5 runs.

主要言語
Java
スター
3.7k
フォーク
605
平均マージ
9日 11時間
マージ済み PR(30日)
14

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

springdoc/springdoc-openapi のほかの issue

springdoc/springdoc-openapi の issue をすべて見る

似ている issue

Java の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。