mcp-jvm-diagnostics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/build/analyzers/contention.js +64 -0
- package/build/analyzers/deadlock.js +89 -0
- package/build/analyzers/gc-pressure.js +111 -0
- package/build/analyzers/heap-diff.js +152 -0
- package/build/index.js +474 -0
- package/build/license.js +114 -0
- package/build/parsers/gc-log.js +100 -0
- package/build/parsers/heap-histo.js +121 -0
- package/build/parsers/jfr-summary.js +160 -0
- package/build/parsers/thread-dump.js +118 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmytro Lisnichenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/mcp-jvm-diagnostics)
|
|
2
|
+
[](https://opensource.org/licenses/MIT)
|
|
3
|
+
|
|
4
|
+
# MCP JVM Diagnostics
|
|
5
|
+
|
|
6
|
+
A Model Context Protocol (MCP) server that gives AI assistants the ability to analyze JVM thread dumps and GC logs. It detects deadlocks, identifies lock contention hotspots, analyzes garbage collection pressure, and recommends JVM tuning parameters.
|
|
7
|
+
|
|
8
|
+
## Why This Tool?
|
|
9
|
+
|
|
10
|
+
JVM diagnostic MCP servers exist (TDA, jfr-mcp, Arthas) — but they're all **Java-based**, requiring a JVM runtime just to diagnose JVM problems. This tool runs on **Node.js** via `npx` — no JVM, no Docker, no SSH.
|
|
11
|
+
|
|
12
|
+
It analyzes **offline** artifacts (thread dumps, GC logs, heap histograms) rather than requiring a running JVM. Paste a thread dump or GC log and get instant analysis. Part of the [MCP Java Backend Suite](https://www.npmjs.com/package/mcp-java-backend-suite) — 5 tools covering databases, JVM, migrations, Spring Boot, and Redis for Java backend developers.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **6 MCP tools** for comprehensive JVM diagnostics
|
|
17
|
+
- **Thread dump analysis** — deadlock detection, contention hotspots, thread state breakdown
|
|
18
|
+
- **GC log analysis** — pause statistics, heap trends, memory leak detection
|
|
19
|
+
- **Heap histogram analysis** — memory leak candidates, classloader leaks, finalization issues
|
|
20
|
+
- **JFR summary** — Java Flight Recorder file analysis
|
|
21
|
+
- **Unified diagnosis** — cross-correlates thread and GC data
|
|
22
|
+
- **Supports** G1, ZGC, Parallel, Serial, and Shenandoah GC formats
|
|
23
|
+
- **No external dependencies** — works on local text input, no API keys needed
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx mcp-jvm-diagnostics
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install globally:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g mcp-jvm-diagnostics
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
### Claude Desktop
|
|
40
|
+
|
|
41
|
+
Add to `~/.claude/claude_desktop_config.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"jvm-diagnostics": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "mcp-jvm-diagnostics"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
No environment variables needed — this tool works on text input you provide.
|
|
55
|
+
|
|
56
|
+
## Quick Demo
|
|
57
|
+
|
|
58
|
+
Try these prompts in Claude (paste your JVM output):
|
|
59
|
+
|
|
60
|
+
1. **"Analyze this thread dump: [paste jstack output]"** — Detects deadlocks, contention hotspots, and thread state breakdown
|
|
61
|
+
2. **"Analyze this GC log: [paste GC log]"** — Shows pause statistics, heap trends, and tuning recommendations
|
|
62
|
+
3. **"Compare these heap histograms to find memory leaks: BEFORE: [paste] AFTER: [paste]"** — Identifies growing classes, classloader leaks, and finalizer issues
|
|
63
|
+
|
|
64
|
+
## Tools
|
|
65
|
+
|
|
66
|
+
### `analyze_thread_dump`
|
|
67
|
+
|
|
68
|
+
Parse a JVM thread dump and analyze thread states, detect deadlocks, and identify lock contention.
|
|
69
|
+
|
|
70
|
+
**Parameters:**
|
|
71
|
+
- `thread_dump` — The full thread dump text (from `jstack <pid>`, `kill -3`, or VisualVM)
|
|
72
|
+
|
|
73
|
+
**Example prompt:** "Analyze this thread dump and tell me if there are any deadlocks"
|
|
74
|
+
|
|
75
|
+
### `analyze_gc_log`
|
|
76
|
+
|
|
77
|
+
Parse a GC log and analyze garbage collection patterns, pause times, and memory pressure.
|
|
78
|
+
|
|
79
|
+
**Parameters:**
|
|
80
|
+
- `gc_log` — The GC log text (from `-Xlog:gc*` or `-verbose:gc`)
|
|
81
|
+
|
|
82
|
+
**Example prompt:** "Analyze this GC log and tell me if there are any performance issues"
|
|
83
|
+
|
|
84
|
+
### `analyze_heap_histo`
|
|
85
|
+
|
|
86
|
+
Parse `jmap -histo` output and detect memory leak candidates, object creation hotspots, and classloader leaks.
|
|
87
|
+
|
|
88
|
+
**Parameters:**
|
|
89
|
+
- `histo` — The jmap -histo output text (from `jmap -histo <pid>` or `jmap -histo:live <pid>`)
|
|
90
|
+
|
|
91
|
+
**Example prompt:** "Analyze this heap histogram and tell me if there are any memory leak candidates"
|
|
92
|
+
|
|
93
|
+
### `diagnose_jvm`
|
|
94
|
+
|
|
95
|
+
Unified JVM diagnosis combining thread dump and GC log analysis for comprehensive root cause analysis.
|
|
96
|
+
|
|
97
|
+
**Parameters:**
|
|
98
|
+
- `thread_dump` (optional) — Thread dump text
|
|
99
|
+
- `gc_log` (optional) — GC log text
|
|
100
|
+
|
|
101
|
+
**Example prompt:** "I have both a thread dump and GC log from the same time — diagnose what's wrong"
|
|
102
|
+
|
|
103
|
+
### `compare_heap_histos`
|
|
104
|
+
|
|
105
|
+
Compare two `jmap -histo` snapshots taken at different times to detect memory growth patterns and leak candidates.
|
|
106
|
+
|
|
107
|
+
**Parameters:**
|
|
108
|
+
- `before` — The first (earlier) jmap -histo output
|
|
109
|
+
- `after` — The second (later) jmap -histo output
|
|
110
|
+
|
|
111
|
+
**Example prompt:** "Compare these two heap histograms and tell me what's growing"
|
|
112
|
+
|
|
113
|
+
**Detects:**
|
|
114
|
+
- Classes with growing instance/byte counts (leak candidates)
|
|
115
|
+
- New classes that appeared between snapshots
|
|
116
|
+
- Classes that disappeared (GC'd or unloaded)
|
|
117
|
+
- Overall heap growth rate
|
|
118
|
+
- Classloader leaks and finalizer queue growth
|
|
119
|
+
|
|
120
|
+
## Collecting JVM Diagnostics
|
|
121
|
+
|
|
122
|
+
### Thread Dump
|
|
123
|
+
```bash
|
|
124
|
+
# Using jstack
|
|
125
|
+
jstack <pid> > thread-dump.txt
|
|
126
|
+
|
|
127
|
+
# Using kill signal (Linux/Mac)
|
|
128
|
+
kill -3 <pid>
|
|
129
|
+
|
|
130
|
+
# Using jcmd
|
|
131
|
+
jcmd <pid> Thread.print > thread-dump.txt
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### GC Log
|
|
135
|
+
Add these JVM flags to your application:
|
|
136
|
+
```bash
|
|
137
|
+
# Java 9+ (unified logging)
|
|
138
|
+
-Xlog:gc*:file=gc.log:time,level,tags
|
|
139
|
+
|
|
140
|
+
# Java 8
|
|
141
|
+
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Contributing
|
|
145
|
+
|
|
146
|
+
1. Clone the repo
|
|
147
|
+
2. `npm install`
|
|
148
|
+
3. `npm run build`
|
|
149
|
+
4. `npm test`
|
|
150
|
+
|
|
151
|
+
## Limitations & Known Issues
|
|
152
|
+
|
|
153
|
+
- **Text input only**: Thread dumps, GC logs, and heap histograms must be provided as text. The server cannot attach to a running JVM or capture data automatically.
|
|
154
|
+
- **Java 9+ GC logs**: The GC log parser is optimized for unified logging format (`-Xlog:gc*`). Legacy `-verbose:gc` format (Java 8) has basic support but may miss some events.
|
|
155
|
+
- **Shenandoah GC**: Limited support. G1, ZGC, Parallel, and Serial are fully supported.
|
|
156
|
+
- **Virtual threads (Java 21+)**: Thread dump parser handles virtual threads but analysis recommendations are tuned for platform threads.
|
|
157
|
+
- **Heap histo comparison**: Requires standard `jmap -histo` or `jcmd GC.class_histogram` format. Custom formats or truncated output may not parse correctly.
|
|
158
|
+
- **Deadlock detection**: Detects monitor-based deadlocks. ReentrantLock deadlocks may not be detected if lock addresses are not visible in the thread dump.
|
|
159
|
+
- **No historical analysis**: Each analysis is a point-in-time snapshot. For trend analysis, compare multiple snapshots manually.
|
|
160
|
+
- **HotSpot/OpenJDK only**: Parser targets HotSpot/OpenJDK thread dump format. GraalVM native-image or Eclipse OpenJ9 dumps may parse incompletely.
|
|
161
|
+
- **Classloader leak detection**: Heap analysis flags growing ClassLoader instances but cannot definitively prove a leak without memory profiler data.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock contention analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Identifies the most-contended locks and most-blocked threads.
|
|
5
|
+
* Generates actionable recommendations.
|
|
6
|
+
*/
|
|
7
|
+
export function analyzeContention(threads) {
|
|
8
|
+
// Map: lock address → thread holding it
|
|
9
|
+
const lockHolders = new Map();
|
|
10
|
+
for (const t of threads) {
|
|
11
|
+
for (const lock of t.holdsLocks) {
|
|
12
|
+
lockHolders.set(lock, t.name);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Map: lock address → threads blocked on it
|
|
16
|
+
const blockedOn = new Map();
|
|
17
|
+
for (const t of threads) {
|
|
18
|
+
if (t.state === "BLOCKED" && t.blockedBy) {
|
|
19
|
+
const existing = blockedOn.get(t.blockedBy) || [];
|
|
20
|
+
existing.push(t.name);
|
|
21
|
+
blockedOn.set(t.blockedBy, existing);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Build hotspot list sorted by blocked count
|
|
25
|
+
const hotspots = [];
|
|
26
|
+
for (const [lock, waitingThreads] of blockedOn.entries()) {
|
|
27
|
+
hotspots.push({
|
|
28
|
+
lock,
|
|
29
|
+
blockedCount: waitingThreads.length,
|
|
30
|
+
holderThread: lockHolders.get(lock) || "unknown",
|
|
31
|
+
waitingThreads,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
hotspots.sort((a, b) => b.blockedCount - a.blockedCount);
|
|
35
|
+
// Generate recommendations
|
|
36
|
+
const recommendations = [];
|
|
37
|
+
const totalBlocked = threads.filter(t => t.state === "BLOCKED").length;
|
|
38
|
+
const totalThreads = threads.length;
|
|
39
|
+
if (totalBlocked > 0) {
|
|
40
|
+
const pct = ((totalBlocked / totalThreads) * 100).toFixed(0);
|
|
41
|
+
recommendations.push(`${totalBlocked} of ${totalThreads} threads (${pct}%) are BLOCKED — investigate contended locks.`);
|
|
42
|
+
}
|
|
43
|
+
if (hotspots.length > 0 && hotspots[0].blockedCount >= 5) {
|
|
44
|
+
recommendations.push(`Lock \`${hotspots[0].lock}\` held by "${hotspots[0].holderThread}" is blocking ${hotspots[0].blockedCount} threads. This is a critical hotspot — consider reducing the lock scope or using a read-write lock.`);
|
|
45
|
+
}
|
|
46
|
+
if (hotspots.length > 3) {
|
|
47
|
+
recommendations.push("Multiple contention hotspots detected. Consider redesigning synchronization — ConcurrentHashMap, read-write locks, or lock-free data structures may reduce contention.");
|
|
48
|
+
}
|
|
49
|
+
// Check for thread pool exhaustion
|
|
50
|
+
const poolPatterns = ["pool-", "http-", "exec-", "ForkJoin", "worker-"];
|
|
51
|
+
for (const pattern of poolPatterns) {
|
|
52
|
+
const poolThreads = threads.filter(t => t.name.includes(pattern));
|
|
53
|
+
const poolBlocked = poolThreads.filter(t => t.state === "BLOCKED");
|
|
54
|
+
if (poolThreads.length > 0 && poolBlocked.length > poolThreads.length * 0.5) {
|
|
55
|
+
recommendations.push(`Thread pool "${pattern}*" has ${poolBlocked.length}/${poolThreads.length} threads BLOCKED — pool exhaustion risk. Consider increasing pool size or reducing lock hold time.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Check for WAITING threads that might indicate starvation
|
|
59
|
+
const waitingCount = threads.filter(t => t.state === "WAITING").length;
|
|
60
|
+
if (waitingCount > totalThreads * 0.7) {
|
|
61
|
+
recommendations.push(`${waitingCount} of ${totalThreads} threads are WAITING — possible thread starvation. Check if producer threads are stuck or undersized.`);
|
|
62
|
+
}
|
|
63
|
+
return { hotspots, recommendations };
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deadlock detector.
|
|
3
|
+
*
|
|
4
|
+
* Builds a lock wait graph from thread dump data and detects cycles
|
|
5
|
+
* indicating deadlocks.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Detect deadlocks by finding cycles in the lock wait graph.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm:
|
|
11
|
+
* 1. Build a map: lock address → holding thread
|
|
12
|
+
* 2. Build a map: thread → lock it's waiting on
|
|
13
|
+
* 3. For each waiting thread, follow the chain:
|
|
14
|
+
* thread waits on lock → lock held by thread2 → thread2 waits on lock2 → ...
|
|
15
|
+
* If we revisit a thread, we found a cycle (deadlock).
|
|
16
|
+
*/
|
|
17
|
+
export function detectDeadlocks(threads) {
|
|
18
|
+
// Map: lock address → thread that holds it
|
|
19
|
+
const lockHolders = new Map();
|
|
20
|
+
for (const t of threads) {
|
|
21
|
+
for (const lock of t.holdsLocks) {
|
|
22
|
+
lockHolders.set(lock, t);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Only consider threads that are waiting on a lock AND hold at least one lock
|
|
26
|
+
// (a deadlock cycle requires each thread to hold one lock while waiting for another)
|
|
27
|
+
// Exclude threads waiting on a lock they already hold — this is Object.wait()
|
|
28
|
+
// (the thread temporarily releases the monitor while waiting on a condition)
|
|
29
|
+
const waitingThreads = threads.filter(t => t.waitingOn !== null &&
|
|
30
|
+
t.holdsLocks.length > 0 &&
|
|
31
|
+
!t.holdsLocks.includes(t.waitingOn));
|
|
32
|
+
const deadlocks = [];
|
|
33
|
+
const visited = new Set();
|
|
34
|
+
for (const startThread of waitingThreads) {
|
|
35
|
+
if (visited.has(startThread.name))
|
|
36
|
+
continue;
|
|
37
|
+
const chain = [];
|
|
38
|
+
const chainNames = new Set();
|
|
39
|
+
let current = startThread;
|
|
40
|
+
while (current && !chainNames.has(current.name)) {
|
|
41
|
+
chain.push(current);
|
|
42
|
+
chainNames.add(current.name);
|
|
43
|
+
// Find who holds the lock this thread is waiting on
|
|
44
|
+
const waitingOnLock = current.waitingOn;
|
|
45
|
+
if (!waitingOnLock)
|
|
46
|
+
break;
|
|
47
|
+
const holder = lockHolders.get(waitingOnLock);
|
|
48
|
+
if (!holder || !holder.waitingOn)
|
|
49
|
+
break;
|
|
50
|
+
current = holder;
|
|
51
|
+
}
|
|
52
|
+
// Check if we found a cycle
|
|
53
|
+
if (current && chainNames.has(current.name)) {
|
|
54
|
+
// Extract just the cycle portion
|
|
55
|
+
const cycleStartIdx = chain.findIndex(t => t.name === current.name);
|
|
56
|
+
const cycleThreads = chain.slice(cycleStartIdx);
|
|
57
|
+
// Skip if we've already found this deadlock
|
|
58
|
+
const cycleKey = cycleThreads.map(t => t.name).sort().join(",");
|
|
59
|
+
if (visited.has(cycleKey))
|
|
60
|
+
continue;
|
|
61
|
+
visited.add(cycleKey);
|
|
62
|
+
// Mark all threads in cycle as visited
|
|
63
|
+
for (const t of cycleThreads) {
|
|
64
|
+
visited.add(t.name);
|
|
65
|
+
}
|
|
66
|
+
const dlThreads = cycleThreads.map(t => ({
|
|
67
|
+
name: t.name,
|
|
68
|
+
holdsLock: t.holdsLocks[0] || "unknown",
|
|
69
|
+
waitingOn: t.waitingOn || "unknown",
|
|
70
|
+
}));
|
|
71
|
+
deadlocks.push({
|
|
72
|
+
threads: dlThreads,
|
|
73
|
+
recommendation: generateRecommendation(cycleThreads),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return deadlocks;
|
|
78
|
+
}
|
|
79
|
+
function generateRecommendation(threads) {
|
|
80
|
+
// Analyze the stack traces to give a meaningful recommendation
|
|
81
|
+
const stackMethods = threads.flatMap(t => t.stackTrace).join("\n");
|
|
82
|
+
if (stackMethods.includes("synchronized")) {
|
|
83
|
+
return "Use java.util.concurrent locks with tryLock() and timeout instead of synchronized blocks. Ensure consistent lock ordering across all threads.";
|
|
84
|
+
}
|
|
85
|
+
if (stackMethods.includes("ReentrantLock")) {
|
|
86
|
+
return "Use tryLock(timeout) instead of lock() to prevent indefinite blocking. Review lock ordering for consistency.";
|
|
87
|
+
}
|
|
88
|
+
return "Ensure consistent lock ordering across all threads. Consider using java.util.concurrent locks with tryLock(timeout) to prevent indefinite blocking.";
|
|
89
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC pressure analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Detects excessive pauses, promotion failures, memory pressure patterns.
|
|
5
|
+
* Recommends JVM flags for tuning.
|
|
6
|
+
*/
|
|
7
|
+
export function analyzeGcPressure(log) {
|
|
8
|
+
const pauseEvents = log.events.filter(e => e.pauseMs > 0);
|
|
9
|
+
const pauses = pauseEvents.map(e => e.pauseMs).sort((a, b) => a - b);
|
|
10
|
+
const result = {
|
|
11
|
+
minPauseMs: 0,
|
|
12
|
+
maxPauseMs: 0,
|
|
13
|
+
avgPauseMs: 0,
|
|
14
|
+
p95PauseMs: 0,
|
|
15
|
+
totalPauseMs: 0,
|
|
16
|
+
gcOverheadPct: 0,
|
|
17
|
+
heapBeforeMb: 0,
|
|
18
|
+
heapAfterMb: 0,
|
|
19
|
+
issues: [],
|
|
20
|
+
recommendations: [],
|
|
21
|
+
};
|
|
22
|
+
if (pauses.length === 0)
|
|
23
|
+
return result;
|
|
24
|
+
// Pause statistics
|
|
25
|
+
result.minPauseMs = pauses[0];
|
|
26
|
+
result.maxPauseMs = pauses[pauses.length - 1];
|
|
27
|
+
result.totalPauseMs = pauses.reduce((a, b) => a + b, 0);
|
|
28
|
+
result.avgPauseMs = result.totalPauseMs / pauses.length;
|
|
29
|
+
result.p95PauseMs = pauses[Math.floor(pauses.length * 0.95)] || result.maxPauseMs;
|
|
30
|
+
// GC overhead
|
|
31
|
+
if (log.timeSpanMs > 0) {
|
|
32
|
+
result.gcOverheadPct = (result.totalPauseMs / log.timeSpanMs) * 100;
|
|
33
|
+
}
|
|
34
|
+
// Heap stats (average before/after)
|
|
35
|
+
const heapEvents = log.events.filter(e => e.heapBeforeMb > 0);
|
|
36
|
+
if (heapEvents.length > 0) {
|
|
37
|
+
result.heapBeforeMb =
|
|
38
|
+
heapEvents.reduce((s, e) => s + e.heapBeforeMb, 0) / heapEvents.length;
|
|
39
|
+
result.heapAfterMb =
|
|
40
|
+
heapEvents.reduce((s, e) => s + e.heapAfterMb, 0) / heapEvents.length;
|
|
41
|
+
}
|
|
42
|
+
// Issue detection
|
|
43
|
+
detectIssues(log, result);
|
|
44
|
+
// Recommendations
|
|
45
|
+
generateRecommendations(log, result);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function detectIssues(log, result) {
|
|
49
|
+
// High GC overhead
|
|
50
|
+
if (result.gcOverheadPct > 15) {
|
|
51
|
+
result.issues.push(`GC overhead is ${result.gcOverheadPct.toFixed(1)}% — above 15% threshold. Application is spending too much time in GC.`);
|
|
52
|
+
}
|
|
53
|
+
// Long pauses
|
|
54
|
+
if (result.maxPauseMs > 1000) {
|
|
55
|
+
result.issues.push(`Maximum GC pause of ${result.maxPauseMs.toFixed(0)}ms exceeds 1 second — causes visible latency spikes.`);
|
|
56
|
+
}
|
|
57
|
+
// Full GC events
|
|
58
|
+
const fullGcCount = log.events.filter(e => e.type.includes("Full") || e.type.includes("Major")).length;
|
|
59
|
+
if (fullGcCount > 0) {
|
|
60
|
+
result.issues.push(`${fullGcCount} Full GC event(s) detected — these cause the longest pauses. May indicate heap pressure or explicit System.gc() calls.`);
|
|
61
|
+
}
|
|
62
|
+
// Heap not shrinking (possible memory leak)
|
|
63
|
+
const heapEvents = log.events.filter(e => e.heapAfterMb > 0);
|
|
64
|
+
if (heapEvents.length >= 5) {
|
|
65
|
+
const firstHalf = heapEvents.slice(0, Math.floor(heapEvents.length / 2));
|
|
66
|
+
const secondHalf = heapEvents.slice(Math.floor(heapEvents.length / 2));
|
|
67
|
+
const avgFirst = firstHalf.reduce((s, e) => s + e.heapAfterMb, 0) / firstHalf.length;
|
|
68
|
+
const avgSecond = secondHalf.reduce((s, e) => s + e.heapAfterMb, 0) / secondHalf.length;
|
|
69
|
+
if (avgSecond > avgFirst * 1.3) {
|
|
70
|
+
result.issues.push(`Heap after GC is growing over time (${avgFirst.toFixed(0)}MB → ${avgSecond.toFixed(0)}MB) — possible memory leak.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Low reclaim ratio
|
|
74
|
+
if (result.heapBeforeMb > 0 && result.heapAfterMb > 0) {
|
|
75
|
+
const reclaimPct = ((result.heapBeforeMb - result.heapAfterMb) / result.heapBeforeMb) * 100;
|
|
76
|
+
if (reclaimPct < 10) {
|
|
77
|
+
result.issues.push(`GC reclaims only ${reclaimPct.toFixed(0)}% of heap per collection — most objects survive. Heap may be too small or there's a memory leak.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function generateRecommendations(log, result) {
|
|
82
|
+
// High overhead → increase heap
|
|
83
|
+
if (result.gcOverheadPct > 15) {
|
|
84
|
+
result.recommendations.push("Increase heap size (-Xmx) to reduce GC frequency. Start with 2x current value.");
|
|
85
|
+
}
|
|
86
|
+
// Long pauses → switch to low-latency collector
|
|
87
|
+
if (result.maxPauseMs > 500) {
|
|
88
|
+
if (log.algorithm === "Parallel" || log.algorithm === "Serial") {
|
|
89
|
+
result.recommendations.push(`Switch from ${log.algorithm} GC to G1 (-XX:+UseG1GC) or ZGC (-XX:+UseZGC) for lower pause times.`);
|
|
90
|
+
}
|
|
91
|
+
else if (log.algorithm === "G1") {
|
|
92
|
+
result.recommendations.push("Consider tuning G1 target pause time: -XX:MaxGCPauseMillis=200. If pauses still too long, evaluate ZGC.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Full GCs → tune thresholds
|
|
96
|
+
const fullGcCount = log.events.filter(e => e.type.includes("Full")).length;
|
|
97
|
+
if (fullGcCount > 3) {
|
|
98
|
+
result.recommendations.push("Frequent Full GCs indicate heap pressure. Increase -Xmx or tune -XX:InitiatingHeapOccupancyPercent (G1) to start concurrent marking earlier.");
|
|
99
|
+
}
|
|
100
|
+
// Low reclaim → check for leaks
|
|
101
|
+
if (result.heapBeforeMb > 0) {
|
|
102
|
+
const reclaimPct = ((result.heapBeforeMb - result.heapAfterMb) / result.heapBeforeMb) * 100;
|
|
103
|
+
if (reclaimPct < 10) {
|
|
104
|
+
result.recommendations.push("Very low reclaim ratio suggests most objects are long-lived. Take a heap dump and analyze with Eclipse MAT or jmap -histo to identify memory leaks.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// General best practices
|
|
108
|
+
if (result.recommendations.length === 0) {
|
|
109
|
+
result.recommendations.push("GC behavior looks healthy. For production, ensure -Xms equals -Xmx to avoid heap resizing pauses.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heap histogram diff analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Compares two jmap -histo outputs to detect memory growth patterns.
|
|
5
|
+
* Identifies:
|
|
6
|
+
* - Classes with growing instance/byte counts (leak candidates)
|
|
7
|
+
* - Classes that appeared in the second snapshot but not the first (new allocations)
|
|
8
|
+
* - Classes that disappeared (GC'd or unloaded)
|
|
9
|
+
* - Overall heap growth rate
|
|
10
|
+
*/
|
|
11
|
+
import { parseHeapHisto } from "../parsers/heap-histo.js";
|
|
12
|
+
// Common JDK internal classes — growth in these is usually not a direct leak
|
|
13
|
+
const JDK_INTERNALS = new Set([
|
|
14
|
+
"[B", "[C", "[I", "[J", "[S", "[Z", "[D", "[F",
|
|
15
|
+
"java.lang.String", "java.lang.Object[]", "java.lang.Class",
|
|
16
|
+
"java.util.HashMap$Node", "java.util.concurrent.ConcurrentHashMap$Node",
|
|
17
|
+
"java.lang.reflect.Method", "java.lang.ref.Finalizer",
|
|
18
|
+
]);
|
|
19
|
+
export function compareHeapHistos(before, after) {
|
|
20
|
+
const reportBefore = parseHeapHisto(before);
|
|
21
|
+
const reportAfter = parseHeapHisto(after);
|
|
22
|
+
// Build lookup maps
|
|
23
|
+
const beforeMap = new Map();
|
|
24
|
+
for (const e of reportBefore.entries) {
|
|
25
|
+
beforeMap.set(e.className, e);
|
|
26
|
+
}
|
|
27
|
+
const afterMap = new Map();
|
|
28
|
+
for (const e of reportAfter.entries) {
|
|
29
|
+
afterMap.set(e.className, e);
|
|
30
|
+
}
|
|
31
|
+
const growing = [];
|
|
32
|
+
const shrinking = [];
|
|
33
|
+
const newClasses = [];
|
|
34
|
+
const removedClasses = [];
|
|
35
|
+
// Compare entries present in both
|
|
36
|
+
for (const [className, afterEntry] of afterMap) {
|
|
37
|
+
const beforeEntry = beforeMap.get(className);
|
|
38
|
+
if (!beforeEntry) {
|
|
39
|
+
// New class in second snapshot
|
|
40
|
+
newClasses.push({
|
|
41
|
+
className,
|
|
42
|
+
instancesBefore: 0,
|
|
43
|
+
instancesAfter: afterEntry.instances,
|
|
44
|
+
instancesDelta: afterEntry.instances,
|
|
45
|
+
bytesBefore: 0,
|
|
46
|
+
bytesAfter: afterEntry.bytes,
|
|
47
|
+
bytesDelta: afterEntry.bytes,
|
|
48
|
+
growthPct: 100,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const bytesDelta = afterEntry.bytes - beforeEntry.bytes;
|
|
53
|
+
const instancesDelta = afterEntry.instances - beforeEntry.instances;
|
|
54
|
+
const growthPct = beforeEntry.bytes > 0 ? (bytesDelta / beforeEntry.bytes) * 100 : 0;
|
|
55
|
+
const entry = {
|
|
56
|
+
className,
|
|
57
|
+
instancesBefore: beforeEntry.instances,
|
|
58
|
+
instancesAfter: afterEntry.instances,
|
|
59
|
+
instancesDelta,
|
|
60
|
+
bytesBefore: beforeEntry.bytes,
|
|
61
|
+
bytesAfter: afterEntry.bytes,
|
|
62
|
+
bytesDelta,
|
|
63
|
+
growthPct,
|
|
64
|
+
};
|
|
65
|
+
if (bytesDelta > 0) {
|
|
66
|
+
growing.push(entry);
|
|
67
|
+
}
|
|
68
|
+
else if (bytesDelta < 0) {
|
|
69
|
+
shrinking.push(entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Find removed classes (in before but not after)
|
|
73
|
+
for (const [className, beforeEntry] of beforeMap) {
|
|
74
|
+
if (!afterMap.has(className)) {
|
|
75
|
+
removedClasses.push({
|
|
76
|
+
className,
|
|
77
|
+
instancesBefore: beforeEntry.instances,
|
|
78
|
+
instancesAfter: 0,
|
|
79
|
+
instancesDelta: -beforeEntry.instances,
|
|
80
|
+
bytesBefore: beforeEntry.bytes,
|
|
81
|
+
bytesAfter: 0,
|
|
82
|
+
bytesDelta: -beforeEntry.bytes,
|
|
83
|
+
growthPct: -100,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Sort by bytes delta (largest growth first)
|
|
88
|
+
growing.sort((a, b) => b.bytesDelta - a.bytesDelta);
|
|
89
|
+
shrinking.sort((a, b) => a.bytesDelta - b.bytesDelta);
|
|
90
|
+
newClasses.sort((a, b) => b.bytesAfter - a.bytesAfter);
|
|
91
|
+
const totalBytesBefore = reportBefore.totalBytes;
|
|
92
|
+
const totalBytesAfter = reportAfter.totalBytes;
|
|
93
|
+
const totalBytesDelta = totalBytesAfter - totalBytesBefore;
|
|
94
|
+
// Analyze for issues
|
|
95
|
+
const issues = [];
|
|
96
|
+
const recommendations = [];
|
|
97
|
+
// Check for significant heap growth
|
|
98
|
+
if (totalBytesBefore > 0) {
|
|
99
|
+
const overallGrowth = (totalBytesDelta / totalBytesBefore) * 100;
|
|
100
|
+
if (overallGrowth > 50) {
|
|
101
|
+
issues.push(`Heap grew ${overallGrowth.toFixed(0)}% between snapshots — significant memory growth detected`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Check for non-JDK classes with significant growth
|
|
105
|
+
const suspiciousGrowing = growing.filter(e => !JDK_INTERNALS.has(e.className) && e.bytesDelta > 1_000_000);
|
|
106
|
+
for (const entry of suspiciousGrowing.slice(0, 5)) {
|
|
107
|
+
issues.push(`${entry.className}: +${formatBytes(entry.bytesDelta)} (+${entry.instancesDelta} instances, ${entry.growthPct.toFixed(0)}% growth) — potential memory leak`);
|
|
108
|
+
recommendations.push(`Investigate ${entry.className} retention — capture heap dump and trace GC roots with Eclipse MAT`);
|
|
109
|
+
}
|
|
110
|
+
// Check for classloader leaks
|
|
111
|
+
const classEntry = growing.find(e => e.className === "java.lang.Class");
|
|
112
|
+
if (classEntry && classEntry.instancesDelta > 1000) {
|
|
113
|
+
issues.push(`java.lang.Class grew by ${classEntry.instancesDelta} instances — possible classloader leak (hot redeploy, dynamic proxies)`);
|
|
114
|
+
recommendations.push("Check for classloader leaks if using hot deployment. Consider full restart instead of redeploy.");
|
|
115
|
+
}
|
|
116
|
+
// Check for finalizer queue growth
|
|
117
|
+
const finalizerEntry = growing.find(e => e.className === "java.lang.ref.Finalizer");
|
|
118
|
+
if (finalizerEntry && finalizerEntry.instancesDelta > 5000) {
|
|
119
|
+
issues.push(`Finalizer queue grew by ${finalizerEntry.instancesDelta} — finalization is backing up`);
|
|
120
|
+
recommendations.push("Replace finalize() with Cleaner or try-with-resources.");
|
|
121
|
+
}
|
|
122
|
+
if (issues.length === 0 && totalBytesDelta <= 0) {
|
|
123
|
+
recommendations.push("Heap is stable or shrinking — no memory leak indicators detected.");
|
|
124
|
+
}
|
|
125
|
+
if (issues.length === 0 && totalBytesDelta > 0 && suspiciousGrowing.length === 0) {
|
|
126
|
+
recommendations.push("Growth appears to be in JDK internal classes — likely normal String/array allocation. Monitor for sustained growth across multiple snapshots.");
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
growing,
|
|
130
|
+
shrinking,
|
|
131
|
+
newClasses,
|
|
132
|
+
removedClasses,
|
|
133
|
+
totalBytesBefore,
|
|
134
|
+
totalBytesAfter,
|
|
135
|
+
totalBytesDelta,
|
|
136
|
+
totalInstancesBefore: reportBefore.totalInstances,
|
|
137
|
+
totalInstancesAfter: reportAfter.totalInstances,
|
|
138
|
+
issues,
|
|
139
|
+
recommendations,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function formatBytes(bytes) {
|
|
143
|
+
const abs = Math.abs(bytes);
|
|
144
|
+
const sign = bytes < 0 ? "-" : "";
|
|
145
|
+
if (abs >= 1_073_741_824)
|
|
146
|
+
return `${sign}${(abs / 1_073_741_824).toFixed(1)} GB`;
|
|
147
|
+
if (abs >= 1_048_576)
|
|
148
|
+
return `${sign}${(abs / 1_048_576).toFixed(1)} MB`;
|
|
149
|
+
if (abs >= 1024)
|
|
150
|
+
return `${sign}${(abs / 1024).toFixed(0)} KB`;
|
|
151
|
+
return `${sign}${abs} B`;
|
|
152
|
+
}
|