Skip to content

Commit 56d4857

Browse files
xlorneclaude
andcommitted
fix: 修复临时脚本线程泄漏(OOM 根因)并升级版本至 2.10.55
- TempGroovyScriptContext 改用共享 daemon 调度线程,根治每脚本 new Timer() 的原生线程泄漏 - 覆盖/删除/清空/cache-miss 时 cancel 定时任务,ConcurrentHashMap 防并发 - 修正绝对到期时间戳误传为相对延迟的清理 bug(原实现约 55 年后才触发,自动清理从未生效) - 原子化 remove(key, value) 防止过期任务误删被刷新脚本 - 新增回归测试 TempGroovyScriptContextTest(线程数不增长 + 到期自动清理) - 版本号 2.10.54 → 2.10.55(全部 pom + CLAUDE.md) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d18185c commit 56d4857

10 files changed

Lines changed: 164 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## 项目概览
66

7-
`com.codingapi.springboot:springboot-parent`(v2.10.54)是一套基于 Spring Boot 2.7.18 + JDK 8 的**领域驱动设计(DDD)落地框架**,围绕"事件风暴 + 流程编排 + 数据权限 + 动态脚本"四大支柱提供可插拔的 starter 模块。框架代码本身即是规范,业务方按需引入 starter 即可获得对应能力,无需重复造轮子。
7+
`com.codingapi.springboot:springboot-parent`(v2.10.55)是一套基于 Spring Boot 2.7.18 + JDK 8 的**领域驱动设计(DDD)落地框架**,围绕"事件风暴 + 流程编排 + 数据权限 + 动态脚本"四大支柱提供可插拔的 starter 模块。框架代码本身即是规范,业务方按需引入 starter 即可获得对应能力,无需重复造轮子。
88

99
详细能力文档见 `docs/capabilities/index.md`(共 9 篇),开发规范见 `docs/conventions/index.md`
1010

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
<groupId>com.codingapi.springboot</groupId>
1717
<artifactId>springboot-parent</artifactId>
18-
<version>2.10.54</version>
18+
<version>2.10.55</version>
1919

2020
<url>https://github.com/codingapi/springboot-framewrok</url>
2121
<name>springboot-parent</name>

springboot-starter-data-authorization/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<parent>
77
<artifactId>springboot-parent</artifactId>
88
<groupId>com.codingapi.springboot</groupId>
9-
<version>2.10.54</version>
9+
<version>2.10.55</version>
1010
</parent>
1111

1212
<name>springboot-starter-data-authorization</name>

springboot-starter-data-fast/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<parent>
66
<artifactId>springboot-parent</artifactId>
77
<groupId>com.codingapi.springboot</groupId>
8-
<version>2.10.54</version>
8+
<version>2.10.55</version>
99
</parent>
1010
<modelVersion>4.0.0</modelVersion>
1111

springboot-starter-flow/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<parent>
77
<artifactId>springboot-parent</artifactId>
88
<groupId>com.codingapi.springboot</groupId>
9-
<version>2.10.54</version>
9+
<version>2.10.55</version>
1010
</parent>
1111

1212
<name>springboot-starter-flow</name>

springboot-starter-script/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<parent>
77
<groupId>com.codingapi.springboot</groupId>
88
<artifactId>springboot-parent</artifactId>
9-
<version>2.10.54</version>
9+
<version>2.10.55</version>
1010
</parent>
1111

1212
<name>springboot-starter-script</name>

springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
import com.codingapi.springboot.script.repository.TempGroovyScriptRepositoryContext;
66
import lombok.Getter;
77

8-
import java.util.*;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.concurrent.ConcurrentHashMap;
11+
import java.util.concurrent.Executors;
12+
import java.util.concurrent.ScheduledExecutorService;
13+
import java.util.concurrent.ScheduledFuture;
14+
import java.util.concurrent.TimeUnit;
915
import java.util.stream.Collectors;
1016

1117
/**
@@ -16,41 +22,58 @@ public class TempGroovyScriptContext {
1622
@Getter
1723
private final static TempGroovyScriptContext instance = new TempGroovyScriptContext();
1824

25+
/**
26+
* 共享的清理调度线程(daemon)
27+
* 所有临时脚本共用一个调度线程,避免每个脚本创建原生线程导致线程泄漏
28+
*/
29+
private final static ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> {
30+
Thread thread = new Thread(r, "temp-groovy-script-clear");
31+
thread.setDaemon(true);
32+
return thread;
33+
});
34+
1935
private final Map<String, ClearJob> cache;
2036

2137
private TempGroovyScriptContext() {
22-
this.cache = new HashMap<>();
38+
this.cache = new ConcurrentHashMap<>();
2339
}
2440

2541
private static class ClearJob {
2642

2743
@Getter
2844
private final TempGroovyScript tempGroovyScript;
2945

30-
private final Timer timer;
46+
private final ScheduledFuture<?> future;
3147

3248
public ClearJob(TempGroovyScript groovyScript) {
3349
this.tempGroovyScript = groovyScript;
34-
this.timer = new Timer();
35-
this.initTimer();
50+
// clearTime 为绝对到期时间戳,需换算为相对延迟
51+
long delay = groovyScript.getClearTime() - System.currentTimeMillis();
52+
this.future = SCHEDULER.schedule(this::expire, delay, TimeUnit.MILLISECONDS);
3653
}
3754

3855
public GroovyScript getGroovyScript() {
3956
return this.tempGroovyScript.getGroovyScript();
4057
}
4158

42-
private void initTimer() {
43-
this.timer.schedule(new TimerTask() {
44-
@Override
45-
public void run() {
46-
TempGroovyScriptContext.getInstance().remove(getKey());
47-
}
48-
}, tempGroovyScript.getClearTime());
49-
}
50-
5159
public String getKey() {
5260
return tempGroovyScript.getKey();
5361
}
62+
63+
/**
64+
* 到期清理
65+
* 仅当缓存中仍是当前任务时才删除,避免过期任务误删已被刷新的脚本
66+
*/
67+
private void expire() {
68+
TempGroovyScriptContext.getInstance().removeIfCurrent(getKey(), this);
69+
}
70+
71+
/**
72+
* 取消定时清理任务
73+
*/
74+
private void cancel() {
75+
this.future.cancel(false);
76+
}
5477
}
5578

5679
/**
@@ -61,7 +84,17 @@ public String getKey() {
6184
public void save(GroovyScript script) {
6285
if (script != null) {
6386
long tempValidTime = PropertiesContext.getInstance().getTempValidTime();
64-
this.cache.put(script.getKey(), new ClearJob(new TempGroovyScript(script, tempValidTime + System.currentTimeMillis())));
87+
this.put(script.getKey(), new ClearJob(new TempGroovyScript(script, tempValidTime + System.currentTimeMillis())));
88+
}
89+
}
90+
91+
/**
92+
* 写入缓存,覆盖时取消旧任务的定时清理
93+
*/
94+
private void put(String key, ClearJob job) {
95+
ClearJob previous = this.cache.put(key, job);
96+
if (previous != null) {
97+
previous.cancel();
6598
}
6699
}
67100

@@ -74,7 +107,7 @@ public void loadAll(List<TempGroovyScript> groovyScripts) {
74107
if (groovyScript.isExpired()) {
75108
this.remove(groovyScript.getKey());
76109
} else {
77-
this.cache.put(groovyScript.getKey(), new ClearJob(groovyScript));
110+
this.put(groovyScript.getKey(), new ClearJob(groovyScript));
78111
}
79112
}
80113
}
@@ -94,10 +127,23 @@ public List<TempGroovyScript> findAll() {
94127
* @param key 脚本key
95128
*/
96129
public void remove(String key) {
97-
this.cache.remove(key);
130+
ClearJob job = this.cache.remove(key);
131+
if (job != null) {
132+
job.cancel();
133+
}
98134
TempGroovyScriptRepositoryContext.getInstance().delete(key);
99135
}
100136

137+
/**
138+
* 仅当缓存中的任务仍为当前任务时删除,防止过期任务误删已被刷新的脚本。
139+
* 使用 ConcurrentHashMap.remove(key, value) 原子判断 + 删除,避免 check-then-act 竞态
140+
*/
141+
private void removeIfCurrent(String key, ClearJob job) {
142+
if (this.cache.remove(key, job)) {
143+
job.cancel();
144+
TempGroovyScriptRepositoryContext.getInstance().delete(key);
145+
}
146+
}
101147

102148
/**
103149
* 脚本总数量
@@ -119,7 +165,11 @@ public GroovyScript getGroovyScript(String key) {
119165
if (job == null) {
120166
TempGroovyScript groovyScript = TempGroovyScriptRepositoryContext.getInstance().get(key);
121167
if (groovyScript != null) {
122-
this.cache.put(key, new ClearJob(groovyScript));
168+
if (groovyScript.isExpired()) {
169+
this.remove(key);
170+
return null;
171+
}
172+
this.put(key, new ClearJob(groovyScript));
123173
return groovyScript.getGroovyScript();
124174
}
125175
return null;
@@ -132,6 +182,9 @@ public GroovyScript getGroovyScript(String key) {
132182
* 清空脚本数据
133183
*/
134184
public void clear() {
185+
for (ClearJob job : this.cache.values()) {
186+
job.cancel();
187+
}
135188
this.cache.clear();
136189
}
137-
}
190+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.codingapi.springboot.script.temp;
2+
3+
import com.codingapi.springboot.script.GroovyScript;
4+
import com.codingapi.springboot.script.properties.GroovyScriptProperties;
5+
import com.codingapi.springboot.script.properties.PropertiesContext;
6+
import org.junit.jupiter.api.AfterEach;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
10+
import static org.junit.jupiter.api.Assertions.assertEquals;
11+
12+
/**
13+
* 回归测试:临时脚本不得为每个脚本创建原生线程(OOM 根因),
14+
* 且脚本到期后应被自动清理。
15+
*/
16+
class TempGroovyScriptContextTest {
17+
18+
private TempGroovyScriptContext context;
19+
20+
@BeforeEach
21+
void setUp() {
22+
context = TempGroovyScriptContext.getInstance();
23+
// 清理上一用例残留的定时任务
24+
context.clear();
25+
// 预热共享调度线程,避免首个任务创建线程影响基线统计
26+
context.save(script("__warmup__"));
27+
context.remove("__warmup__");
28+
}
29+
30+
@AfterEach
31+
void tearDown() {
32+
// 清理定时任务并恢复默认配置,避免泄漏到同 JVM 的其他测试
33+
context.clear();
34+
PropertiesContext.getInstance().setProperties(new GroovyScriptProperties());
35+
}
36+
37+
private GroovyScript script(String key) {
38+
return GroovyScript.builder(key).script("return 1;").build();
39+
}
40+
41+
@Test
42+
void saveManyScriptsShouldNotCreateThreads() {
43+
int threadsBefore = Thread.getAllStackTraces().size();
44+
45+
for (int i = 0; i < 200; i++) {
46+
context.save(script("key-" + i));
47+
}
48+
49+
assertEquals(200, context.count());
50+
assertEquals(threadsBefore, Thread.getAllStackTraces().size(),
51+
"注册 200 个临时脚本不应创建任何新线程");
52+
}
53+
54+
@Test
55+
void overwriteRemoveClearShouldNotCreateThreads() {
56+
int threadsBefore = Thread.getAllStackTraces().size();
57+
58+
for (int i = 0; i < 100; i++) {
59+
context.save(script("key-" + i));
60+
context.save(script("key-" + i)); // 覆盖刷新
61+
}
62+
for (int i = 0; i < 100; i++) {
63+
context.remove("key-" + i);
64+
}
65+
context.clear();
66+
67+
assertEquals(0, context.count());
68+
assertEquals(threadsBefore, Thread.getAllStackTraces().size(),
69+
"覆盖/删除/清空不应创建新线程");
70+
}
71+
72+
@Test
73+
void expiredScriptShouldBeRemovedAutomatically() throws InterruptedException {
74+
GroovyScriptProperties properties = new GroovyScriptProperties();
75+
properties.setTempValidTime(300);
76+
PropertiesContext.getInstance().setProperties(properties);
77+
78+
context.save(script("expire-key"));
79+
assertEquals(1, context.count());
80+
81+
Thread.sleep(1500);
82+
assertEquals(0, context.count(), "脚本到期后应被自动清理");
83+
}
84+
}

springboot-starter-security/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<parent>
77
<groupId>com.codingapi.springboot</groupId>
88
<artifactId>springboot-parent</artifactId>
9-
<version>2.10.54</version>
9+
<version>2.10.55</version>
1010
</parent>
1111

1212
<artifactId>springboot-starter-security</artifactId>

springboot-starter/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<parent>
66
<groupId>com.codingapi.springboot</groupId>
77
<artifactId>springboot-parent</artifactId>
8-
<version>2.10.54</version>
8+
<version>2.10.55</version>
99
</parent>
1010
<artifactId>springboot-starter</artifactId>
1111

0 commit comments

Comments
 (0)