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 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
+ [![npm version](https://img.shields.io/npm/v/mcp-jvm-diagnostics)](https://www.npmjs.com/package/mcp-jvm-diagnostics)
2
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+ }