}
/**
- * Create a new {@link MergedAnnotations} instance from the specified
- * collection of directly present annotations. This method allows a
- * {@code MergedAnnotations} instance to be created from annotations that
- * are not necessarily loaded using reflection. The provided annotations
- * must all be {@link MergedAnnotation#isDirectlyPresent() directly present}
- * and must have an {@link MergedAnnotation#getAggregateIndex() aggregate
- * index} of {@code 0}.
+ * Create a new {@link MergedAnnotations} instance from the provided
+ * collection of annotations.
+ * This method allows a {@code MergedAnnotations} instance to be created
+ * from annotations that are not necessarily loaded using reflection.
+ *
The provided annotations must all be
+ * {@link MergedAnnotation#isDirectlyPresent() directly present} and must have
+ * an {@link MergedAnnotation#getAggregateIndex() aggregate index} of {@code 0}.
*
The resulting {@code MergedAnnotations} instance will contain both the
- * specified annotations and any meta-annotations that can be read using
+ * provided annotations and any meta-annotations that can be read using
* reflection.
* @param annotations the annotations to include
* @return a {@code MergedAnnotations} instance containing the annotations
diff --git a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java
index 8ec1dfd573d..33d6250d0f2 100644
--- a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java
+++ b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java
@@ -22,7 +22,8 @@ import org.springframework.core.type.ClassMetadata;
/**
* Simple facade for accessing class metadata,
- * as read by an ASM {@link org.springframework.asm.ClassReader}.
+ * as read by an ASM {@link org.springframework.asm.ClassReader} or the
+ * {@code java.lang.classfile.ClassFile} API on Java 24 and higher.
*
* @author Juergen Hoeller
* @since 2.5
diff --git a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java
index 3f62f8e2a2b..39c2ff83f88 100644
--- a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java
+++ b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java
@@ -22,7 +22,7 @@ import org.springframework.core.io.ResourceLoader;
/**
* Internal delegate for instantiating {@link MetadataReaderFactory} implementations.
- * For JDK < 24, the {@link SimpleMetadataReaderFactory} is being used.
+ * For JDK < 24, the {@link SimpleMetadataReaderFactory} is used.
*
* @author Brian Clozel
* @since 7.0
diff --git a/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java b/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java
index 875b9f79ffb..77de126692c 100644
--- a/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java
+++ b/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java
@@ -22,7 +22,7 @@ import org.springframework.core.io.ResourceLoader;
/**
* Internal delegate for instantiating {@link MetadataReaderFactory} implementations.
- * For JDK >= 24, the {@link ClassFileMetadataReaderFactory} is being used.
+ * For JDK >= 24, the {@link ClassFileMetadataReaderFactory} is used.
*
* @author Brian Clozel
* @since 7.0
From 38464a15dc20fdd8cc6e9f139b248983279aa1c4 Mon Sep 17 00:00:00 2001
From: Sam Brannen <104798+sbrannen@users.noreply.github.com>
Date: Sat, 4 Apr 2026 17:17:27 +0200
Subject: [PATCH 2/2] Retain source declaration order in AnnotatedTypeMetadata
on Java 24+
Prior to this commit, ClassFileAnnotationDelegate created
MergedAnnotations from a HashSet, which resulted in a non-deterministic
iteration order and lost the original source declaration order of the
annotations.
To address that, this commit revises ClassFileAnnotationDelegate to
create MergedAnnotations from a List.
In addition, this commit updates all related tests to use the
containsExactly() assertion instead of containsExactlyInAnyOrder() to
ensure we consistently adhere to the "source declaration order"
requirement.
Closes gh-36598
---
.../core/annotation/MergedAnnotations.java | 2 ++
.../ClassFileAnnotationDelegate.java | 6 +++---
.../type/AbstractAnnotationMetadataTests.java | 20 +++++++++++++------
.../type/AbstractMethodMetadataTests.java | 10 +++++++---
.../core/type/AnnotationMetadataTests.java | 4 +++-
...tedAnnotationsAnnotationMetadataTests.java | 18 ++++++++++++-----
6 files changed, 42 insertions(+), 18 deletions(-)
diff --git a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java
index c5b84c9021a..c33e0fd9514 100644
--- a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java
+++ b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java
@@ -453,6 +453,8 @@ public interface MergedAnnotations extends Iterable
* The provided annotations must all be
* {@link MergedAnnotation#isDirectlyPresent() directly present} and must have
* an {@link MergedAnnotation#getAggregateIndex() aggregate index} of {@code 0}.
+ * In addition, the provided collection must retain the source declaration order
+ * of the annotations — for example, a {@link java.util.List}.
*
The resulting {@code MergedAnnotations} instance will contain both the
* provided annotations and any meta-annotations that can be read using
* reflection.
diff --git a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java
index 810636236da..d5ce6e21941 100644
--- a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java
+++ b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java
@@ -26,7 +26,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -43,6 +42,7 @@ import org.springframework.util.ClassUtils;
*
* @author Brian Clozel
* @author Juergen Hoeller
+ * @author Sam Brannen
* @since 7.0
*/
abstract class ClassFileAnnotationDelegate {
@@ -50,11 +50,11 @@ abstract class ClassFileAnnotationDelegate {
static MergedAnnotations createMergedAnnotations(
String className, RuntimeVisibleAnnotationsAttribute annotationAttribute, @Nullable ClassLoader classLoader) {
- Set> annotations = annotationAttribute.annotations()
+ List> annotations = annotationAttribute.annotations()
.stream()
.map(ann -> createMergedAnnotation(className, ann, classLoader))
.filter(Objects::nonNull)
- .collect(Collectors.toSet());
+ .collect(Collectors.toList());
return MergedAnnotations.of(annotations);
}
diff --git a/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java
index f5d163797e9..48681542e4a 100644
--- a/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java
+++ b/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java
@@ -261,7 +261,9 @@ public abstract class AbstractAnnotationMetadataTests {
assertThat(get(WithDirectAnnotations.class).getAnnotations().stream())
.filteredOn(MergedAnnotation::isDirectlyPresent)
.extracting(a -> a.getType().getName())
- .containsExactlyInAnyOrder(DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName());
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ .containsExactly(DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName());
}
@Test
@@ -290,8 +292,10 @@ public abstract class AbstractAnnotationMetadataTests {
MultiValueMap attributes =
get(WithMetaAnnotationAttributes.class).getAllAnnotationAttributes(AnnotationAttributes.class.getName());
assertThat(attributes).containsOnlyKeys("name", "size");
- assertThat(attributes.get("name")).containsExactlyInAnyOrder("m1", "m2");
- assertThat(attributes.get("size")).containsExactlyInAnyOrder(1, 2);
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ assertThat(attributes.get("name")).containsExactly("m1", "m2");
+ assertThat(attributes.get("size")).containsExactly(1, 2);
}
@Test
@@ -335,15 +339,19 @@ public abstract class AbstractAnnotationMetadataTests {
@Test
void getAnnotationTypesReturnsDirectAnnotations() {
AnnotationMetadata metadata = get(WithDirectAnnotations.class);
- assertThat(metadata.getAnnotationTypes()).containsExactlyInAnyOrder(
- DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName());
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ assertThat(metadata.getAnnotationTypes())
+ .containsExactly(DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName());
}
@Test
void getMetaAnnotationTypesReturnsMetaAnnotations() {
AnnotationMetadata metadata = get(WithMetaAnnotations.class);
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
assertThat(metadata.getMetaAnnotationTypes(MetaAnnotationRoot.class.getName()))
- .containsExactlyInAnyOrder(MetaAnnotation1.class.getName(), MetaAnnotation2.class.getName());
+ .containsExactly(MetaAnnotation1.class.getName(), MetaAnnotation2.class.getName());
}
@Test
diff --git a/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java
index e21dee73006..799626afd1b 100644
--- a/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java
+++ b/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java
@@ -224,7 +224,9 @@ public abstract class AbstractMethodMetadataTests {
Stream> types = metadata.getAnnotations().stream()
.filter(MergedAnnotation::isDirectlyPresent)
.map(MergedAnnotation::getType);
- assertThat(types).containsExactlyInAnyOrder(Tag.class, DirectAnnotation.class);
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ assertThat(types).containsExactly(Tag.class, DirectAnnotation.class);
}
@Test
@@ -254,8 +256,10 @@ public abstract class AbstractMethodMetadataTests {
MultiValueMap attributes = getTagged(WithMetaAnnotationAttributes.class)
.getAllAnnotationAttributes(AnnotationAttributes.class.getName());
assertThat(attributes).containsOnlyKeys("name", "size");
- assertThat(attributes.get("name")).containsExactlyInAnyOrder("m1", "m2");
- assertThat(attributes.get("size")).containsExactlyInAnyOrder(1, 2);
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ assertThat(attributes.get("name")).containsExactly("m1", "m2");
+ assertThat(attributes.get("size")).containsExactly(1, 2);
}
@Test // gh-24375
diff --git a/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java
index 878734aaeac..1185a34ef38 100644
--- a/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java
+++ b/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java
@@ -405,7 +405,9 @@ class AnnotationMetadataTests {
assertThat(metadata.hasAnnotation(SpecialAttr.class.getName())).isTrue();
assertThat(metadata.hasAnnotation(NamedComposedAnnotation.class.getName())).isTrue();
- assertThat(metadata.getAnnotationTypes()).containsExactlyInAnyOrder(
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ assertThat(metadata.getAnnotationTypes()).containsExactly(
Component.class.getName(), Scope.class.getName(),
SpecialAttr.class.getName(), DirectAnnotation.class.getName(),
MetaMetaAnnotation.class.getName(), EnumSubclasses.class.getName(),
diff --git a/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java
index 83c819216de..e3c20e314c9 100644
--- a/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java
+++ b/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java
@@ -57,7 +57,9 @@ class InheritedAnnotationsAnnotationMetadataTests {
@Test
void getAnnotationTypes() {
- assertThat(standardMetadata.getAnnotationTypes()).containsExactlyInAnyOrder(
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order.
+ assertThat(standardMetadata.getAnnotationTypes()).containsExactly(
NamedAnnotation3.class.getName(),
InheritedComposedAnnotation.class.getName());
@@ -81,11 +83,14 @@ class InheritedAnnotationsAnnotationMetadataTests {
Set metaAnnotationTypes;
metaAnnotationTypes = standardMetadata.getMetaAnnotationTypes(InheritedComposedAnnotation.class.getName());
- assertThat(metaAnnotationTypes).containsExactlyInAnyOrder(
- MetaAnnotation.class.getName(),
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order, with local annotations
+ // before meta-annotations.
+ assertThat(metaAnnotationTypes).containsExactly(
NamedAnnotation1.class.getName(),
NamedAnnotation2.class.getName(),
- NamedAnnotation3.class.getName());
+ NamedAnnotation3.class.getName(),
+ MetaAnnotation.class.getName());
metaAnnotationTypes = asmMetadata.getMetaAnnotationTypes(InheritedComposedAnnotation.class.getName());
assertThat(metaAnnotationTypes).isEmpty();
@@ -138,7 +143,10 @@ class InheritedAnnotationsAnnotationMetadataTests {
annotationAttributes = standardMetadata.getAllAnnotationAttributes(NamedAnnotation3.class.getName());
assertThat(annotationAttributes).containsKey("name");
- assertThat(annotationAttributes.get("name")).containsExactlyInAnyOrder("name 3", "local");
+ // We do not use containsExactlyInAnyOrder(), because annotations
+ // must be returned in source declaration order, with local annotations
+ // before meta-annotations.
+ assertThat(annotationAttributes.get("name")).containsExactly("local", "name 3");
annotationAttributes = asmMetadata.getAllAnnotationAttributes(NamedAnnotation1.class.getName());
assertThat(annotationAttributes).isNull();