Skip to content

Commit 8dde1c8

Browse files
committed
GROOVY-11966: AnnotationNode.isTargetAllowed introduces concurrent write to shared ListHashMap
1 parent cd3f157 commit 8dde1c8

4 files changed

Lines changed: 165 additions & 25 deletions

File tree

src/main/java/org/codehaus/groovy/ast/ASTNode.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public class ASTNode implements NodeMetaDataHandler {
4646
private int lastLineNumber = -1;
4747
private int lastColumnNumber = -1;
4848

49-
private Map<?, ?> metaDataMap;
49+
private volatile Map<?, ?> metaDataMap;
5050

5151
public void visit(final GroovyCodeVisitor visitor) {
5252
throw new RuntimeException("No visit() method implemented for class: " + getClass().getName());

src/main/java/org/codehaus/groovy/ast/CompileUnit.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public class CompileUnit implements NodeMetaDataHandler {
4444
private final CompilerConfiguration config;
4545
private final GroovyClassLoader loader;
4646
private final CodeSource codeSource;
47-
private Map<?, ?> metaDataMap;
47+
private volatile Map<?, ?> metaDataMap;
4848

4949
private final List<ModuleNode> modules = new ArrayList<>();
5050
private final Map<String, ClassNode> classes = new LinkedHashMap<>();

src/main/java/org/codehaus/groovy/ast/NodeMetaDataHandler.java

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,20 @@
2222
import org.codehaus.groovy.util.ListHashMap;
2323

2424
import java.util.Collections;
25+
import java.util.HashMap;
2526
import java.util.Map;
2627
import java.util.function.Function;
2728

2829
/**
2930
* An interface to mark a node being able to handle metadata.
31+
* <p>
32+
* The default {@link #newMetaDataMap()} returns a {@link ListHashMap} wrapped in
33+
* {@link Collections#synchronizedMap}, so concurrent compiles sharing an AST node
34+
* (e.g. built-in annotation {@code ClassNode}s cached by {@code ClassHelper})
35+
* cannot trip an {@code ArrayIndexOutOfBoundsException} during the
36+
* array-to-{@link HashMap} transition in {@code ListHashMap.put}. Implementers
37+
* that store the map in a field should declare it {@code volatile} so the
38+
* unsynchronized fast-path read in the default methods sees publish-time writes.
3039
*
3140
* @since 3.0.0
3241
*/
@@ -57,12 +66,7 @@ default <T> T getNodeMetaData(Object key) {
5766
default <T> T getNodeMetaData(Object key, Function<?, ? extends T> valFn) {
5867
if (key == null) throw new GroovyBugError("Tried to get/set meta data with null key on " + this + ".");
5968

60-
Map metaDataMap = this.getMetaDataMap();
61-
if (metaDataMap == null) {
62-
metaDataMap = this.newMetaDataMap();
63-
this.setMetaDataMap(metaDataMap);
64-
}
65-
return (T) metaDataMap.computeIfAbsent(key, valFn);
69+
return (T) getOrCreateMetaDataMap().computeIfAbsent(key, valFn);
6670
}
6771

6872
/**
@@ -75,13 +79,13 @@ default void copyNodeMetaData(NodeMetaDataHandler other) {
7579
if (otherMetaDataMap == null) {
7680
return;
7781
}
78-
Map metaDataMap = this.getMetaDataMap();
79-
if (metaDataMap == null) {
80-
metaDataMap = this.newMetaDataMap();
81-
this.setMetaDataMap(metaDataMap);
82+
// snapshot under the source map's mutex to honour the synchronizedMap iteration contract
83+
Map snapshot;
84+
synchronized (otherMetaDataMap) {
85+
if (otherMetaDataMap.isEmpty()) return;
86+
snapshot = new HashMap<>(otherMetaDataMap);
8287
}
83-
84-
metaDataMap.putAll(otherMetaDataMap);
88+
getOrCreateMetaDataMap().putAll(snapshot);
8589
}
8690

8791
/**
@@ -107,15 +111,11 @@ default void setNodeMetaData(Object key, Object value) {
107111
default Object putNodeMetaData(Object key, Object value) {
108112
if (key == null) throw new GroovyBugError("Tried to set meta data with null key on " + this + ".");
109113

110-
Map metaDataMap = this.getMetaDataMap();
111-
if (metaDataMap == null) {
112-
if (value == null) return null;
113-
metaDataMap = newMetaDataMap();
114-
this.setMetaDataMap(metaDataMap);
115-
} else if (value == null) {
116-
return metaDataMap.remove(key);
114+
if (value == null) {
115+
Map metaDataMap = this.getMetaDataMap();
116+
return metaDataMap == null ? null : metaDataMap.remove(key);
117117
}
118-
return metaDataMap.put(key, value);
118+
return getOrCreateMetaDataMap().put(key, value);
119119
}
120120

121121
/**
@@ -135,7 +135,7 @@ default void removeNodeMetaData(Object key) {
135135
}
136136

137137
/**
138-
* Returns an unmodifiable view of the current node metadata.
138+
* Returns an unmodifiable snapshot of the current node metadata.
139139
*
140140
* @return the node metadata. Always not null.
141141
*/
@@ -144,18 +144,55 @@ default void removeNodeMetaData(Object key) {
144144
if (metaDataMap == null) {
145145
return Collections.emptyMap();
146146
}
147-
return Collections.unmodifiableMap(metaDataMap);
147+
// snapshot under the map's mutex to honour the synchronizedMap iteration contract
148+
synchronized (metaDataMap) {
149+
return Collections.unmodifiableMap(new HashMap<>(metaDataMap));
150+
}
151+
}
152+
153+
/**
154+
* Returns the existing metadata map, creating one via {@link #newMetaDataMap()}
155+
* on first use. Lazy creation is guarded by a brief lock on {@code this} so
156+
* concurrent first-callers agree on a single map; subsequent callers see the
157+
* map via the (volatile) field read and skip the lock entirely.
158+
*/
159+
private Map getOrCreateMetaDataMap() {
160+
Map metaDataMap = this.getMetaDataMap();
161+
if (metaDataMap != null) return metaDataMap;
162+
synchronized (this) {
163+
metaDataMap = this.getMetaDataMap();
164+
if (metaDataMap == null) {
165+
metaDataMap = this.newMetaDataMap();
166+
this.setMetaDataMap(metaDataMap);
167+
}
168+
return metaDataMap;
169+
}
148170
}
149171

150172
//--------------------------------------------------------------------------
151173

174+
/**
175+
* Returns the underlying metadata map. The map returned by the default
176+
* {@link #newMetaDataMap()} is internally synchronized, so individual
177+
* {@code get}/{@code put}/{@code remove} calls are thread-safe; however,
178+
* per the {@link Collections#synchronizedMap} contract, iteration over the
179+
* returned map (or any of its {@code keySet}, {@code values}, or
180+
* {@code entrySet} views) must be done inside a
181+
* {@code synchronized (map) { ... }} block to avoid
182+
* {@code ConcurrentModificationException}.
183+
*/
152184
Map<?, ?> getMetaDataMap();
153185

154186
/**
187+
* Creates the backing metadata map. The default returns a {@link ListHashMap}
188+
* wrapped in {@link Collections#synchronizedMap} for thread-safe per-entry
189+
* access; subclasses may override to supply an alternative map (e.g. for
190+
* different memory/concurrency trade-offs).
191+
*
155192
* @since 5.0.0
156193
*/
157194
default Map<?, ?> newMetaDataMap() {
158-
return new ListHashMap();
195+
return Collections.synchronizedMap(new ListHashMap());
159196
}
160197

161198
void setMetaDataMap(Map<?, ?> metaDataMap);
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.codehaus.groovy.ast;
20+
21+
import org.apache.groovy.stress.util.ThreadUtils;
22+
import org.junit.Test;
23+
24+
import java.util.List;
25+
import java.util.concurrent.CopyOnWriteArrayList;
26+
import java.util.concurrent.CyclicBarrier;
27+
import java.util.concurrent.ExecutorService;
28+
import java.util.concurrent.Executors;
29+
import java.util.concurrent.TimeUnit;
30+
31+
import static org.junit.Assert.assertEquals;
32+
33+
/**
34+
* Exercises concurrent access to {@link NodeMetaDataHandler}'s default methods on a
35+
* shared AST node. The underlying {@code ListHashMap} is not thread-safe, so without
36+
* external protection concurrent compiles sharing a {@link ClassNode} (e.g. built-in
37+
* annotation nodes cached by {@link ClassHelper}) trip {@code ArrayIndexOutOfBoundsException}
38+
* during the array-to-{@code HashMap} transition in {@code ListHashMap.put}. The default
39+
* {@code newMetaDataMap()} now wraps the {@code ListHashMap} in
40+
* {@link java.util.Collections#synchronizedMap}, making individual {@code get}/{@code put}/
41+
* {@code remove}/{@code computeIfAbsent} operations thread-safe; this test verifies that
42+
* guarantee under contention.
43+
*/
44+
public class NodeMetaDataHandlerStressTest {
45+
46+
private static final int THREADS = 16;
47+
private static final int ITERATIONS = 5_000;
48+
private static final int KEY_SPACE = 4;
49+
50+
@Test
51+
public void testConcurrentAccessOnSharedNode() throws Exception {
52+
// ClassHelper.makeCached returns a process-wide shared ClassNode; this is
53+
// the path that built-in annotation ClassNodes reach in real compilations.
54+
// Use a unique target class so other tests in the same JVM don't interfere.
55+
ClassNode shared = ClassHelper.makeCached(SharedTarget.class);
56+
57+
CyclicBarrier start = new CyclicBarrier(THREADS);
58+
List<Throwable> errors = new CopyOnWriteArrayList<>();
59+
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
60+
try {
61+
for (int t = 0; t < THREADS; t++) {
62+
final int threadId = t;
63+
pool.submit(() -> {
64+
try {
65+
ThreadUtils.await(start);
66+
for (int i = 0; i < ITERATIONS; i++) {
67+
final int iter = i;
68+
String key = "k" + (i % KEY_SPACE);
69+
String otherKey = "k" + ((i + 1) % KEY_SPACE);
70+
71+
// factory variant — the path used by AnnotationNode.isTargetAllowed
72+
shared.getNodeMetaData(key, k -> "v-" + threadId + "-" + iter);
73+
// plain read
74+
shared.getNodeMetaData(otherKey);
75+
// explicit put / remove to force the array<->HashMap transition
76+
if (i % 7 == 0) shared.putNodeMetaData(key, "p-" + threadId + "-" + i);
77+
if (i % 11 == 0) shared.removeNodeMetaData(key);
78+
}
79+
} catch (Throwable th) {
80+
errors.add(th);
81+
}
82+
});
83+
}
84+
pool.shutdown();
85+
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
86+
throw new AssertionError("stress test did not complete within 60s");
87+
}
88+
} finally {
89+
pool.shutdownNow();
90+
}
91+
92+
if (!errors.isEmpty()) {
93+
AssertionError ae = new AssertionError(
94+
"concurrent NodeMetaDataHandler access produced " + errors.size() + " errors; first: " + errors.get(0));
95+
ae.initCause(errors.get(0));
96+
throw ae;
97+
}
98+
assertEquals(0, errors.size());
99+
}
100+
101+
/** Dedicated target class so we don't poison metadata on a shared standard ClassNode. */
102+
private static final class SharedTarget { }
103+
}

0 commit comments

Comments
 (0)