Skip to content

Commit 54bdd0d

Browse files
committed
nts_java_memory: инструмент анализа кучи и аллокаций
- JavaMemoryTool: discover, snapshot, compare, alloc - HeapSnapshot/HeapComparisonReport: модели данных для снапшотов кучи - JfrReport: форматирование отчётов профайлера (отдельный класс) - JfrComparisonReport: дельта-отчёты между записями - AnalysisOptions: конфигурация фокуса анализа (cpu, memory, contention, gc, io) - Тесты: JavaMemoryToolTest
1 parent 5de2030 commit 54bdd0d

7 files changed

Lines changed: 2316 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2026 Aristo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package ru.nts.tools.mcp.tools.system;
17+
18+
import java.util.Set;
19+
20+
/**
21+
* Internal configuration for JFR analysis.
22+
* Not exposed in MCP schema — constructed from the minimal user-facing parameters.
23+
*/
24+
record AnalysisOptions(
25+
int topN,
26+
int stackDepth,
27+
String focus,
28+
Set<String> collapsePackages
29+
) {
30+
31+
private static final Set<String> DEFAULT_COLLAPSE = Set.of("java.", "jdk.", "sun.", "javax.");
32+
private static final Set<String> VALID_FOCUS = Set.of("cpu", "cpu-time", "memory", "contention", "gc", "io", "all");
33+
34+
static AnalysisOptions defaults() {
35+
return new AnalysisOptions(8, 10, "all", DEFAULT_COLLAPSE);
36+
}
37+
38+
static AnalysisOptions withFocus(String focus) {
39+
String normalized = focus == null || focus.isBlank() ? "all" : focus.trim().toLowerCase();
40+
if (!VALID_FOCUS.contains(normalized)) {
41+
normalized = "all";
42+
}
43+
return new AnalysisOptions(8, 10, normalized, DEFAULT_COLLAPSE);
44+
}
45+
46+
boolean includes(String area) {
47+
return "all".equals(focus) || focus.equals(area);
48+
}
49+
50+
boolean shouldCollapse(String className) {
51+
if (className == null) {
52+
return false;
53+
}
54+
for (String prefix : collapsePackages) {
55+
if (className.startsWith(prefix)) {
56+
return true;
57+
}
58+
}
59+
return false;
60+
}
61+
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/*
2+
* Copyright 2026 Aristo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package ru.nts.tools.mcp.tools.system;
17+
18+
import java.time.Duration;
19+
import java.time.Instant;
20+
import java.util.ArrayList;
21+
import java.util.Comparator;
22+
import java.util.LinkedHashMap;
23+
import java.util.List;
24+
import java.util.Locale;
25+
import java.util.Map;
26+
27+
/**
28+
* Delta report comparing two {@link HeapSnapshot}s.
29+
* Shows classes with biggest growth in bytes and instances.
30+
*/
31+
record HeapComparisonReport(
32+
String baselineId,
33+
String targetId,
34+
Instant baselineTimestamp,
35+
Instant targetTimestamp,
36+
long baselineTotalBytes,
37+
long targetTotalBytes,
38+
long baselineTotalInstances,
39+
long targetTotalInstances,
40+
List<ClassDelta> deltas,
41+
List<String> warnings
42+
) {
43+
44+
record ClassDelta(
45+
String className,
46+
long baselineBytes,
47+
long targetBytes,
48+
long deltaBytes,
49+
long baselineInstances,
50+
long targetInstances,
51+
long deltaInstances
52+
) {}
53+
54+
/**
55+
* Compares two snapshots and produces a delta report.
56+
* Sorted by absolute byte growth descending.
57+
*/
58+
static HeapComparisonReport compare(HeapSnapshot baseline, HeapSnapshot target) {
59+
Map<String, long[]> baseMap = new it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap<>();
60+
Map<String, long[]> targetMap = new it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap<>();
61+
62+
for (HeapSnapshot.ClassEntry e : baseline.classes()) {
63+
baseMap.put(e.className(), new long[]{e.bytes(), e.instances()});
64+
}
65+
for (HeapSnapshot.ClassEntry e : target.classes()) {
66+
targetMap.put(e.className(), new long[]{e.bytes(), e.instances()});
67+
}
68+
69+
List<ClassDelta> deltas = new it.unimi.dsi.fastutil.objects.ObjectArrayList<>();
70+
// Union of all class names
71+
var allClasses = new LinkedHashMap<String, Boolean>();
72+
targetMap.keySet().forEach(k -> allClasses.put(k, true));
73+
baseMap.keySet().forEach(k -> allClasses.putIfAbsent(k, true));
74+
75+
for (String className : allClasses.keySet()) {
76+
long[] base = baseMap.getOrDefault(className, new long[]{0, 0});
77+
long[] tgt = targetMap.getOrDefault(className, new long[]{0, 0});
78+
long deltaBytes = tgt[0] - base[0];
79+
long deltaInstances = tgt[1] - base[1];
80+
if (deltaBytes != 0 || deltaInstances != 0) {
81+
deltas.add(new ClassDelta(className,
82+
base[0], tgt[0], deltaBytes,
83+
base[1], tgt[1], deltaInstances));
84+
}
85+
}
86+
87+
// Sort by absolute byte delta descending
88+
deltas.sort(Comparator.comparingLong((ClassDelta d) -> Math.abs(d.deltaBytes())).reversed());
89+
90+
List<String> warnings = new it.unimi.dsi.fastutil.objects.ObjectArrayList<>();
91+
if (baseline.liveOnly() != target.liveOnly()) {
92+
warnings.add("Snapshots have different liveOnly settings — comparison may be misleading.");
93+
}
94+
if (baseline.pid() != target.pid()) {
95+
warnings.add("Snapshots are from different PIDs (" + baseline.pid() + " vs " + target.pid() + ").");
96+
}
97+
98+
return new HeapComparisonReport(
99+
baseline.snapshotId(), target.snapshotId(),
100+
baseline.timestamp(), target.timestamp(),
101+
baseline.totalBytes(), target.totalBytes(),
102+
baseline.totalInstances(), target.totalInstances(),
103+
deltas, warnings
104+
);
105+
}
106+
107+
String renderMarkdown(int topN) {
108+
StringBuilder sb = new StringBuilder();
109+
sb.append("### Heap Comparison\n");
110+
sb.append("Baseline: ").append(baselineId)
111+
.append(" (").append(JfrReport.formatBytes(baselineTotalBytes))
112+
.append(", ").append(String.format(Locale.ROOT, "%,d", baselineTotalInstances))
113+
.append(" instances)\n");
114+
sb.append("Target: ").append(targetId)
115+
.append(" (").append(JfrReport.formatBytes(targetTotalBytes))
116+
.append(", ").append(String.format(Locale.ROOT, "%,d", targetTotalInstances))
117+
.append(" instances)\n");
118+
119+
// Time elapsed
120+
Duration elapsed = (baselineTimestamp != null && targetTimestamp != null)
121+
? Duration.between(baselineTimestamp, targetTimestamp) : null;
122+
if (elapsed != null) {
123+
sb.append("Elapsed: ").append(JfrReport.formatDuration(elapsed)).append("\n");
124+
}
125+
126+
// Byte delta
127+
long bytesDelta = targetTotalBytes - baselineTotalBytes;
128+
String bytesSign = bytesDelta >= 0 ? "+" : "";
129+
double bytesPct = baselineTotalBytes > 0 ? (bytesDelta * 100.0) / baselineTotalBytes : 0;
130+
sb.append("Bytes delta: ").append(bytesSign).append(JfrReport.formatBytes(Math.abs(bytesDelta)));
131+
if (bytesDelta < 0) sb.append(" (shrunk)");
132+
sb.append(String.format(Locale.ROOT, " (%s%.1f%%)", bytesSign, bytesPct));
133+
if (elapsed != null && elapsed.toSeconds() > 0) {
134+
long bytesPerSec = Math.abs(bytesDelta) / elapsed.toSeconds();
135+
sb.append(" — ").append(bytesSign).append(JfrReport.formatBytes(bytesPerSec)).append("/s");
136+
}
137+
sb.append("\n");
138+
139+
// Instance delta
140+
long instDelta = targetTotalInstances - baselineTotalInstances;
141+
String instSign = instDelta >= 0 ? "+" : "";
142+
sb.append("Instances delta: ").append(instSign)
143+
.append(String.format(Locale.ROOT, "%,d", instDelta));
144+
if (elapsed != null && elapsed.toSeconds() > 0) {
145+
long instPerSec = Math.abs(instDelta) / elapsed.toSeconds();
146+
sb.append(" — ").append(instSign).append(String.format(Locale.ROOT, "%,d", instPerSec)).append("/s");
147+
}
148+
sb.append("\n");
149+
150+
if (!warnings.isEmpty()) {
151+
sb.append("\n**Warnings:**\n");
152+
for (String w : warnings) {
153+
sb.append("- ").append(w).append("\n");
154+
}
155+
}
156+
157+
if (!deltas.isEmpty()) {
158+
// NEW classes (only in target, not in baseline) — strongest leak signal
159+
List<ClassDelta> newClasses = deltas.stream()
160+
.filter(d -> d.baselineBytes() == 0 && d.targetBytes() > 0)
161+
.sorted(Comparator.comparingLong(ClassDelta::targetBytes).reversed())
162+
.limit(topN)
163+
.toList();
164+
if (!newClasses.isEmpty()) {
165+
sb.append("\n**⚠ New Classes (absent in baseline):**\n");
166+
for (ClassDelta d : newClasses) {
167+
sb.append(String.format(Locale.ROOT, "- **%s**: %s (%,d instances) — NEW\n",
168+
d.className(), JfrReport.formatBytes(d.targetBytes()), d.targetInstances()));
169+
}
170+
}
171+
172+
// Top growers — with % growth and rate
173+
List<ClassDelta> growers = deltas.stream()
174+
.filter(d -> d.deltaBytes() > 0 && d.baselineBytes() > 0) // exclude new classes
175+
.sorted(Comparator.comparingLong(ClassDelta::deltaBytes).reversed())
176+
.limit(topN)
177+
.toList();
178+
if (!growers.isEmpty()) {
179+
sb.append("\n**Top Growers:**\n");
180+
for (ClassDelta d : growers) {
181+
double growthPct = d.baselineBytes() > 0 ? (d.deltaBytes() * 100.0) / d.baselineBytes() : 0;
182+
sb.append(String.format(Locale.ROOT, "- %s: +%s (+%.0f%%, +%,d instances)",
183+
d.className(), JfrReport.formatBytes(d.deltaBytes()), growthPct, d.deltaInstances()));
184+
if (elapsed != null && elapsed.toSeconds() > 0) {
185+
sb.append(String.format(Locale.ROOT, " — %s/s",
186+
JfrReport.formatBytes(d.deltaBytes() / elapsed.toSeconds())));
187+
}
188+
sb.append("\n");
189+
}
190+
}
191+
192+
// Suspicious growth: high % increase even if small absolute
193+
List<ClassDelta> suspicious = deltas.stream()
194+
.filter(d -> d.baselineBytes() > 0 && d.deltaBytes() > 0)
195+
.filter(d -> {
196+
double pct = (d.deltaBytes() * 100.0) / d.baselineBytes();
197+
return pct > 100 && d.deltaBytes() > 1024; // >100% growth and at least 1KB
198+
})
199+
.sorted(Comparator.comparingDouble((ClassDelta d) ->
200+
(d.deltaBytes() * 1.0) / Math.max(1, d.baselineBytes())).reversed())
201+
.limit(5)
202+
.toList();
203+
// Only show if different from top growers
204+
List<String> growerNames = growers.stream().map(ClassDelta::className).toList();
205+
suspicious = suspicious.stream().filter(d -> !growerNames.contains(d.className())).toList();
206+
if (!suspicious.isEmpty()) {
207+
sb.append("\n**⚠ Suspicious Growth (high % increase):**\n");
208+
for (ClassDelta d : suspicious) {
209+
double growthPct = (d.deltaBytes() * 100.0) / d.baselineBytes();
210+
sb.append(String.format(Locale.ROOT, "- %s: %s → %s (+%.0f%%)\n",
211+
d.className(), JfrReport.formatBytes(d.baselineBytes()),
212+
JfrReport.formatBytes(d.targetBytes()), growthPct));
213+
}
214+
}
215+
216+
// Top shrinkers (compact)
217+
List<ClassDelta> shrinkers = deltas.stream()
218+
.filter(d -> d.deltaBytes() < 0)
219+
.sorted(Comparator.comparingLong(ClassDelta::deltaBytes))
220+
.limit(topN)
221+
.toList();
222+
if (!shrinkers.isEmpty()) {
223+
sb.append("\n**Top Shrinkers:**\n");
224+
for (ClassDelta d : shrinkers) {
225+
sb.append(String.format(Locale.ROOT, "- %s: -%s (%,d instances)\n",
226+
d.className(), JfrReport.formatBytes(Math.abs(d.deltaBytes())), d.deltaInstances()));
227+
}
228+
}
229+
} else {
230+
sb.append("\nNo class-level changes detected.\n");
231+
}
232+
233+
// Leak extrapolation
234+
if (bytesDelta > 0 && elapsed != null && elapsed.toSeconds() > 5) {
235+
// Only meaningful if we know heap max (estimate from target total as proxy)
236+
long heapEstimate = targetTotalBytes * 3; // rough: assume heap ~3x current usage
237+
long secondsToFull = (heapEstimate - targetTotalBytes) / Math.max(1, bytesDelta / elapsed.toSeconds());
238+
if (secondsToFull > 0 && secondsToFull < 86400) { // less than 24h
239+
sb.append(String.format(Locale.ROOT,
240+
"\n**⚠ Leak Extrapolation:** At current growth rate (%s/s), heap pressure will ~triple in %s.\n",
241+
JfrReport.formatBytes(bytesDelta / elapsed.toSeconds()),
242+
JfrReport.formatDuration(Duration.ofSeconds(secondsToFull))));
243+
sb.append("> This is a rough estimate. Use `nts_java_profiler(action='profile', focus='gc')` for actual heap max.\n");
244+
}
245+
}
246+
247+
// Insights
248+
sb.append("\n**Insights:**\n");
249+
if (bytesDelta > 0) {
250+
sb.append("- Heap grew. If this is under steady-state load, it may indicate a memory leak.\n");
251+
sb.append("- Verify with `nts_java_memory(action='snapshot', liveOnly=true)` — if growth survives Full GC, it's retained.\n");
252+
} else if (bytesDelta < 0) {
253+
sb.append("- Heap shrank — GC reclaimed memory. No leak signal.\n");
254+
} else {
255+
sb.append("- No heap change. Memory is stable.\n");
256+
}
257+
sb.append("- To find allocation sources: `nts_java_memory(action='alloc')`\n");
258+
sb.append("- To see GC impact: `nts_java_profiler(action='profile', focus='gc')`\n");
259+
260+
return sb.toString().stripTrailing();
261+
}
262+
}

0 commit comments

Comments
 (0)