JVM 透視神器:把“對象拓?fù)鋱D”塞進(jìn)你的 Spring Boot
內(nèi)存問題向來詭譎:明明 GC 在跑,堆卻在漲;明明寫得很純凈,偏偏對象不釋放。經(jīng)典工具(jmap + MAT、VisualVM)都很能打,但流程要么割裂、要么難以嵌入,線上臨時(shí)接管還會(huì)牽扯權(quán)限與風(fēng)險(xiǎn)。
很多時(shí)候我們想要的其實(shí)很樸素:在服務(wù)本機(jī)打開一個(gè)網(wǎng)頁,點(diǎn)一下按鈕,就能看到 JVM 內(nèi)部的“對象世界地圖”——哪些類占得多、誰指著誰、引用鏈大體走向如何,能快速做初步判斷。
于是,這個(gè)方案就誕生了:在 Spring Boot 里嵌一個(gè)“內(nèi)存對象拓?fù)浞?wù)”。只要訪問 /memviz.html 就能在瀏覽器里看到對象圖;支持類/包過濾、按對象大小高亮、點(diǎn)擊看詳情;默認(rèn)零開銷,只有你點(diǎn)擊“生成快照”時(shí)才工作,足夠線上可用。
你將獲得什么
- 一個(gè)可嵌入任何 Spring Boot 的“內(nèi)存對象拓?fù)浞?wù)”
訪問:/memviz.html
功能:按類/包名過濾、按對象大小高亮、點(diǎn)擊節(jié)點(diǎn)看詳情
- 線上可用、日常零成本:默認(rèn)不做任何事,只有你點(diǎn)“生成快照”才會(huì) dump→解析→可視化
- 真實(shí)引用鏈:基于 HPROF 堆快照解析出的對象級別/類級別關(guān)系
為什么不用傳統(tǒng)工具直接上?
- jmap + MAT:強(qiáng),但離線、割裂、跨機(jī)拷文件麻煩;用來“深挖”很好,用來“隨手看一眼”太重。
- VisualVM:不便嵌入業(yè)務(wù),線上權(quán)限和安全邊界是剛需考量。
- 線上需求更簡單:在服務(wù)本機(jī)開網(wǎng)頁 → 一鍵 dump → 立刻在本頁面可視化 → 初步定位。
我們的方案就是:點(diǎn)按鈕 → dump → 解析 → D3 可視化,全都在應(yīng)用自己的 Web 界面完成。
架構(gòu)設(shè)計(jì):為什么是“HPROF 快照 + 在線解析”
目標(biāo):
- 全量對象 + 真實(shí)引用鏈
- 無需預(yù)埋、無需重啟
- 只在手動(dòng)觸發(fā)時(shí)才消耗資源
方案:
- 用 HotSpotDiagnosticMXBean 在線觸發(fā) 堆快照(HPROF),可選 live=true/false
- 在應(yīng)用內(nèi)使用輕量解析庫解析 HPROF,構(gòu)建 nodes/links 的 Graph JSON
- 前端用 純 HTML + JS + D3 力導(dǎo)向圖渲染;支持搜索、過濾、點(diǎn)擊詳情、大小高亮
解析庫示例使用 org.gridkit.jvmtool:hprof-heap。由于社區(qū)里很多人也使用 org.netbeans.lib.profiler.heap API(下文示例采用該風(fēng)格),如果你的依賴環(huán)境不同,可按需替換為同等能力的 HPROF 解析實(shí)現(xiàn)。
可運(yùn)行代碼
項(xiàng)目結(jié)構(gòu)
memviz/
├─ pom.xml
├─ src/main/java/com/icoderoad/memviz/
│ ├─ MemvizApplication.java
│ ├─ controller/MemvizController.java
│ ├─ service/HeapDumpService.java
│ ├─ service/HprofParseService.java
│ ├─ model/GraphModel.java
│ └─ util/SafeExecs.java
└─ src/main/resources/static/
└─ memviz.html注:示例中保留 hprof-heap 與 jackson-databind。如果你使用 org.netbeans.lib.profiler.heap API,請根據(jù)你的制品庫加上相應(yīng)依賴(不同公司/倉庫坐標(biāo)可能不同)。下方示例僅展示一個(gè)常見組合,真實(shí)項(xiàng)目請按你環(huán)境調(diào)整。
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.icoderoad</groupId>
<artifactId>memviz</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<spring-boot.version>3.3.2</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 輕量 HPROF 解析器(GridKit jvmtool) -->
<dependency>
<groupId>org.gridkit.jvmtool</groupId>
<artifactId>hprof-heap</artifactId>
<version>0.16</version>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 如需使用 org.netbeans.lib.profiler.heap API,請按你的倉庫坐標(biāo)引入
<dependency>
<groupId>org.netbeans.lib.profiler</groupId>
<artifactId>profiler</artifactId>
<version>YOUR_VERSION</version>
</dependency>
-->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>應(yīng)用入口 MemvizApplication.java
package com.icoderoad.memviz;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MemvizApplication {
public static void main(String[] args) {
SpringApplication.run(MemvizApplication.class, args);
}
}領(lǐng)域模型 GraphModel.java
package com.icoderoad.memviz.model;
import java.util.*;
public class GraphModel {
public static class Node {
public String id; // objectId 或 class@id
public String label; // 類名(短)
public String className; // 類名(全)
public long shallowSize; // 淺表大小
public String category; // JDK/第三方/業(yè)務(wù)
public int instanceCount; // 該類實(shí)例總數(shù)
public String formattedSize; // 格式化顯示
public String packageName; // 包名
public boolean isArray; // 是否數(shù)組
public String objectType; // 對象類型
public Node(String id, String label, String className, long shallowSize, String category) {
this.id = id;
this.label = label;
this.className = className;
this.shallowSize = shallowSize;
this.category = category;
}
public Node(String id, String label, String className, long shallowSize, String category,
int instanceCount, String formattedSize, String packageName, boolean isArray, String objectType) {
this(id, label, className, shallowSize, category);
this.instanceCount = instanceCount;
this.formattedSize = formattedSize;
this.packageName = packageName;
this.isArray = isArray;
this.objectType = objectType;
}
}
public static class Link {
public String source;
public String target;
public String field; // 通過哪個(gè)字段/元素引用
public Link(String s, String t, String field) {
this.source = s;
this.target = t;
this.field = field;
}
}
// Top100類統(tǒng)計(jì)
public static class TopClassStat {
public String className;
public String shortName;
public String packageName;
public String category;
public int instanceCount;
public long totalSize;
public String formattedTotalSize;
public long totalDeepSize;
public String formattedTotalDeepSize;
public long avgSize;
public String formattedAvgSize;
public long avgDeepSize;
public String formattedAvgDeepSize;
public int rank;
public List<ClassInstance> topInstances;
public TopClassStat(String className, String shortName, String packageName, String category,
int instanceCount, long totalSize, String formattedTotalSize,
long totalDeepSize, String formattedTotalDeepSize,
long avgSize, String formattedAvgSize,
long avgDeepSize, String formattedAvgDeepSize,
int rank, List<ClassInstance> topInstances) {
this.className = className;
this.shortName = shortName;
this.packageName = packageName;
this.category = category;
this.instanceCount = instanceCount;
this.totalSize = totalSize;
this.formattedTotalSize = formattedTotalSize;
this.totalDeepSize = totalDeepSize;
this.formattedTotalDeepSize = formattedTotalDeepSize;
this.avgSize = avgSize;
this.formattedAvgSize = formattedAvgSize;
this.avgDeepSize = avgDeepSize;
this.formattedAvgDeepSize = formattedAvgDeepSize;
this.rank = rank;
this.topInstances = topInstances != null ? topInstances : new ArrayList<>();
}
}
public static class ClassInstance {
public String id;
public long size;
public String formattedSize;
public int rank;
public String packageName;
public String objectType;
public boolean isArray;
public double sizePercentInClass;
public ClassInstance(String id, long size, String formattedSize, int rank,
String packageName, String objectType, boolean isArray, double sizePercentInClass) {
this.id = id;
this.size = size;
this.formattedSize = formattedSize;
this.rank = rank;
this.packageName = packageName;
this.objectType = objectType;
this.isArray = isArray;
this.sizePercentInClass = sizePercentInClass;
}
}
public List<Node> nodes = new ArrayList<>();
public List<Link> links = new ArrayList<>();
public List<TopClassStat> top100Classes = new ArrayList<>();
public int totalObjects;
public long totalMemory;
public String formattedTotalMemory;
}觸發(fā)堆快照 HeapDumpService.java
package com.icoderoad.memviz.service;
import com.icoderoad.memviz.util.SafeExecs;
import org.springframework.stereotype.Service;
import javax.management.MBeanServer;
import javax.management.ObjectName;
import java.io.File;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Service
public class HeapDumpService {
private static final String HOTSPOT_BEAN = "com.sun.management:type=HotSpotDiagnostic";
private static final String DUMP_METHOD = "dumpHeap";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
/**
* 生成 HPROF 快照文件
* @param live 是否僅包含存活對象(會(huì)觸發(fā)一次 STW)
* @param dir 目錄(建議掛到獨(dú)立磁盤/大空間)
*/
public File dump(boolean live, File dir) throws Exception {
if (!dir.exists() && !dir.mkdirs()) {
throw new IllegalStateException("Cannot create dump dir: " + dir);
}
String name = "heap_" + LocalDateTime.now().format(FMT) + (live ? "_live" : "") + ".hprof";
File out = new File(dir, name);
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
ObjectName objName = new ObjectName(HOTSPOT_BEAN);
// 防御:限制最大文件空間
SafeExecs.assertDiskHasSpace(dir.toPath(), 512L * 1024 * 1024);
server.invoke(objName, DUMP_METHOD,
new Object[]{ out.getAbsolutePath(), live },
new String[]{ "java.lang.String", "boolean" });
return out;
}
}解析 HPROF → 生成圖 HprofParseService.java
下方示例采用 org.netbeans.lib.profiler.heap.* 風(fēng)格的 API(你可替換為等價(jià)的 HPROF 解析實(shí)現(xiàn))。核心邏輯:
- 收集實(shí)例(可按類名過濾)
- 統(tǒng)計(jì) Top 類與大小
- 生成類級節(jié)點(diǎn)與引用邊(限制數(shù)量防止前端崩潰)
- 可選折疊集合類型節(jié)點(diǎn),降低噪點(diǎn)
package com.icoderoad.memviz.service;
import com.icoderoad.memviz.model.GraphModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
// 如用 NetBeans Heap API:import org.netbeans.lib.profiler.heap.*;
// 如你改用 GridKit hprof-heap,請?zhí)鎿Q為其 API 并保持同等語義
// 這里僅保留方法簽名與邏輯說明
import java.io.File;
import java.util.*;
import java.util.function.Predicate;
@Service
public class HprofParseService {
private static final Logger log = LoggerFactory.getLogger(HprofParseService.class);
private static final int MAX_GRAPH_NODES = 100; // 圖上顯示的類數(shù)
private static final int MAX_LINKS = 200; // 連線上限(防止前端卡頓)
public GraphModel parseToGraph(File hprofFile,
Predicate<String> classNameFilter,
boolean collapseCollections) throws Exception {
// ===== 這里替換為你的 HPROF 解析實(shí)現(xiàn) =====
// 偽代碼結(jié)構(gòu)(請按你選用的庫實(shí)現(xiàn)):
//
// Heap heap = HeapFactory.createHeap(hprofFile);
// List<JavaClass> classes = heap.getAllClasses();
// 按過濾器收集統(tǒng)計(jì),并創(chuàng)建 GraphModel
//
// 下面給出一個(gè)結(jié)構(gòu)化的“參考實(shí)現(xiàn)骨架”(偽代碼 + 注釋)
GraphModel graph = new GraphModel();
// 1) 假設(shè)我們拿到了所有類與其實(shí)例,這里只演示構(gòu)圖策略:
// - 統(tǒng)計(jì)每個(gè)類的總 shallow size、實(shí)例數(shù)
// - 取 Top100 類作為可視化節(jié)點(diǎn)
// - 基于對象引用關(guān)系推導(dǎo)類之間的引用邊(受限于 MAX_LINKS)
// ====== demo 構(gòu)造一些假數(shù)據(jù)結(jié)構(gòu),實(shí)際請用解析結(jié)果填充 ======
Map<String, ClassStatAgg> agg = new HashMap<>();
// ... 從堆中迭代對象、按類名聚合 totalSize/instanceCount
// ... 并記錄類間引用關(guān)系 refEdges: Map<String, Set<String>>
// 模擬聚合結(jié)果(請刪除)
agg.put("java.util.HashMap", new ClassStatAgg("java.util.HashMap", "JDK", "java.util", 1234, 120_000_000L));
agg.put("com.icoderoad.demo.User", new ClassStatAgg("com.icoderoad.demo.User", "業(yè)務(wù)代碼", "com.icoderoad.demo", 20000, 80_000_000L));
agg.put("org.slf4j.Logger", new ClassStatAgg("org.slf4j.Logger", "第三方", "org.slf4j", 3000, 30_000_000L));
List<ClassStatAgg> top = new ArrayList<>(agg.values());
top.sort(Comparator.comparingLong((ClassStatAgg s) -> s.totalSize).reversed());
int nodeCount = Math.min(MAX_GRAPH_NODES, top.size());
for (int i = 0; i < nodeCount; i++) {
ClassStatAgg s = top.get(i);
String label = String.format("%s (%d個(gè)實(shí)例, %s)", shortName(s.className), s.instanceCount, formatSize(s.totalSize));
GraphModel.Node n = new GraphModel.Node(
"class_" + s.className.hashCode(),
label,
s.className,
s.totalSize,
s.category,
s.instanceCount,
formatSize(s.totalSize),
s.packageName,
s.className.contains("["),
determineObjectType(s.className)
);
graph.nodes.add(n);
}
// 模擬類間引用邊(實(shí)際根據(jù)對象引用聚合成類引用)
int links = 0;
for (int i = 0; i < Math.min(3, nodeCount); i++) {
for (int j = i + 1; j < Math.min(6, nodeCount); j++) {
if (links >= MAX_LINKS) break;
GraphModel.Node a = graph.nodes.get(i);
GraphModel.Node b = graph.nodes.get(j);
graph.links.add(new GraphModel.Link(a.id, b.id, "引用"));
links++;
}
}
// 匯總統(tǒng)計(jì)(總對象數(shù)、總內(nèi)存)——實(shí)際按解析結(jié)果填入
graph.totalObjects = 123456;
graph.totalMemory = 120_000_000L + 80_000_000L + 30_000_000L;
graph.formattedTotalMemory = formatSize(graph.totalMemory);
return graph;
}
// === 下方是一些工具方法 + 演示用內(nèi)部聚合類 ===
private static class ClassStatAgg {
String className;
String category; // JDK / 第三方 / 業(yè)務(wù)代碼
String packageName;
int instanceCount;
long totalSize;
ClassStatAgg(String c, String category, String pkg, int cnt, long size) {
this.className = c;
this.category = category;
this.packageName = pkg;
this.instanceCount = cnt;
this.totalSize = size;
}
}
private static String shortName(String fqcn) {
int p = fqcn.lastIndexOf('.');
return p >= 0 ? fqcn.substring(p + 1) : fqcn;
}
private static String determineObjectType(String className) {
if (className.contains("[")) return "數(shù)組";
if (className.contains("$")) return className.contains("Lambda") ? "Lambda表達(dá)式" : "內(nèi)部類";
if (className.startsWith("java.util.") &&
(className.contains("List") || className.contains("Set") || className.contains("Map"))) return "集合類";
if (className.startsWith("java.lang.")) return "基礎(chǔ)類型";
return "普通類";
}
private static String formatSize(long sizeInBytes) {
if (sizeInBytes < 1024) return sizeInBytes + "B";
if (sizeInBytes < 1024 * 1024) return String.format("%.1fKB", sizeInBytes / 1024.0);
if (sizeInBytes < 1024L * 1024 * 1024) return String.format("%.2fMB", sizeInBytes / (1024.0 * 1024));
return String.format("%.2fGB", sizeInBytes / (1024.0 * 1024 * 1024));
}
}說明:為便于你快速集成,我保留了“完整可視化側(cè) API 結(jié)構(gòu)與字段定義”。你只需把 parseToGraph 中標(biāo)注的“偽實(shí)現(xiàn)”替換為真實(shí) HPROF 解析(無論 GridKit 還是 NetBeans Heap API),按注釋填入對象、類與引用關(guān)系即可。
安全工具 SafeExecs.java
package com.icoderoad.memviz.util;
import java.io.IOException;
import java.nio.file.*;
public class SafeExecs {
public static void assertDiskHasSpace(Path dir, long minBytes) throws IOException {
FileStore store = Files.getFileStore(dir);
long usable = store.getUsableSpace();
if (usable < minBytes) {
throw new IllegalStateException("Disk space low: need " + minBytes + " bytes, but only " + usable + " usable");
}
}
}控制器 MemvizController.java
前端將調(diào)用以下 REST 接口:
- POST /api/memviz/snapshot?live=true&filter=xxx&collapse=false:一鍵 dump + 解析 + 返回圖
- GET /memviz.html:靜態(tài)頁面(在 resources/static 下,Spring Boot 會(huì)自動(dòng)映射)
package com.icoderoad.memviz.controller;
import com.icoderoad.memviz.model.GraphModel;
import com.icoderoad.memviz.service.HeapDumpService;
import com.icoderoad.memviz.service.HprofParseService;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import java.io.File;
import java.util.function.Predicate;
@RestController
@RequestMapping("/api/memviz")
public class MemvizController {
private final HeapDumpService dumpService;
private final HprofParseService parseService;
public MemvizController(HeapDumpService dumpService, HprofParseService parseService) {
this.dumpService = dumpService;
this.parseService = parseService;
}
@Value("${memviz.dump.dir:./heap-dumps}")
private String dumpDir;
/**
* 一鍵快照(dump + parse)
*/
@PostMapping(value = "/snapshot", produces = MediaType.APPLICATION_JSON_VALUE)
public GraphModel snapshot(@RequestParam(defaultValue = "true") boolean live,
@RequestParam(required = false) String filter,
@RequestParam(defaultValue = "false") boolean collapse) throws Exception {
File dir = new File(dumpDir);
File hprof = dumpService.dump(live, dir);
Predicate<String> classFilter = (filter == null || filter.isBlank())
? null
: (name -> name.contains(filter));
return parseService.parseToGraph(hprof, classFilter, collapse);
}
}前端頁面 src/main/resources/static/memviz.html(D3.js 力導(dǎo)向圖)
功能點(diǎn):
- 觸發(fā)快照(live 開關(guān))
- 類/包名過濾(后端過濾)
- 節(jié)點(diǎn)按大小映射半徑,并可基于“最小顯示大小閾值”進(jìn)行前端過濾
- 顏色按類別(JDK/第三方/業(yè)務(wù))區(qū)分
- 搜索定位、點(diǎn)擊彈出詳情面板
- 懸停高亮相鄰節(jié)點(diǎn)與邊
- 自適應(yīng)窗口大小、Zoom/Pan
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>MemViz - JVM 對象拓?fù)鋱D</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial; }
header { display: flex; gap: 12px; align-items: center; padding: 10px 14px; box-shadow: 0 1px 8px rgba(0,0,0,0.06); position: sticky; top: 0; background: #fff; z-index: 10; }
header .title { font-weight: 700; }
header .meta { margin-left: auto; font-size: 12px; color: #666; }
.toolbar input[type="text"] { padding: 6px 10px; border: 1px solid #ddd; border-radius: 8px; width: 220px; }
.toolbar label { font-size: 13px; color: #333; }
.toolbar button { padding: 8px 12px; border: 0; border-radius: 10px; box-shadow: 0 4px 14px rgba(0,0,0,0.08); cursor: pointer; }
.toolbar button.primary { background: #2563eb; color: #fff; }
.toolbar button.ghost { background: #f9fafb; color: #111827; }
.container { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 60px); }
.graph { background: #fff; }
.side { border-left: 1px solid #eee; padding: 12px; overflow: auto; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
.badge.jdk { background: #e0f2fe; color: #075985; }
.badge.third { background: #ecfccb; color: #3f6212; }
.badge.app { background: #fee2e2; color: #991b1b; }
.legend { display:flex; gap: 8px; align-items:center; font-size: 12px; color:#444; }
.legend .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
.legend .dot.jdk { background:#60a5fa; }
.legend .dot.third { background:#86efac; }
.legend .dot.app { background:#fca5a5; }
.hint { font-size: 12px; color: #666; }
.kv { font-size: 13px; line-height: 1.6; }
.footer { font-size: 12px; color: #999; padding: 8px 0; }
.slider-row { display:flex; align-items:center; gap:10px; font-size:12px; color:#333; }
.slider-row input[type="range"] { width: 160px; }
/* link/node styles */
.link { stroke: #aaa; stroke-opacity: 0.6; }
.link.highlight { stroke: #111827; stroke-width: 2.2px; }
.node { cursor: pointer; stroke: #fff; stroke-width: 1.2px; }
.node.highlight { stroke: #111827; stroke-width: 2px; }
.label { pointer-events:none; user-select:none; font-size: 11px; fill:#374151; }
.overlay { fill: none; pointer-events: all; }
</style>
</head>
<body>
<header>
<div class="title">MemViz - JVM 內(nèi)存對象拓?fù)鋱D</div>
<div class="toolbar">
<label>過濾(類名/包名包含):</label>
<input id="filterInput" type="text" placeholder="例如:com.icoderoad 或 HashMap" />
<label style="margin-left:10px;"><input id="liveChk" type="checkbox" checked /> 只保留存活對象(live)</label>
<label style="margin-left:10px;"><input id="collapseChk" type="checkbox" /> 折疊集合節(jié)點(diǎn)</label>
<button id="snapshotBtn" class="primary">生成快照并可視化</button>
<button id="resetBtn" class="ghost">重置視圖</button>
</div>
<div class="meta">
<span id="metaTotal">總對象數(shù):--</span> |
<span id="metaMem">總內(nèi)存:--</span>
</div>
</header>
<div class="container">
<div class="graph" id="graph"></div>
<aside class="side">
<div class="legend" style="margin-bottom:10px;">
<span class="dot jdk"></span>JDK
<span class="dot third" style="margin-left:10px;"></span>第三方
<span class="dot app" style="margin-left:10px;"></span>業(yè)務(wù)代碼
</div>
<div class="slider-row" style="margin:8px 0 16px;">
<span>大小閾值(最小顯示):</span>
<input type="range" id="sizeSlider" min="0" max="10" step="1" value="0" />
<span id="sizeLabel">0 MB</span>
</div>
<div class="kv">
<h3>節(jié)點(diǎn)詳情</h3>
<div id="detail">點(diǎn)擊圖中的節(jié)點(diǎn)查看詳情</div>
</div>
<div class="footer">
提示:拖拽節(jié)點(diǎn)可重新布局,滾輪縮放,拖拽空白處平移。
</div>
</aside>
</div>
<!-- D3 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js" integrity="sha512-F4mJFwS8cZBqOqV5v4FQXn3y0H4PUq9y4hfy0zCPI5F+uJXb8M9W1Qy7uWz2wOqX8kMsQh3mZ1jWwAZV2TTJYg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
(() => {
const graphEl = document.getElementById('graph');
const detailEl = document.getElementById('detail');
const snapshotBtn = document.getElementById('snapshotBtn');
const resetBtn = document.getElementById('resetBtn');
const filterInput = document.getElementById('filterInput');
const liveChk = document.getElementById('liveChk');
const collapseChk = document.getElementById('collapseChk');
const metaTotal = document.getElementById('metaTotal');
const metaMem = document.getElementById('metaMem');
const sizeSlider = document.getElementById('sizeSlider');
const sizeLabel = document.getElementById('sizeLabel');
let width = graphEl.clientWidth || window.innerWidth - 320;
let height = graphEl.clientHeight || (window.innerHeight - 60);
const svg = d3.select('#graph').append('svg')
.attr('width', width)
.attr('height', height);
const overlay = svg.append('rect')
.attr('class','overlay')
.attr('width', width)
.attr('height', height)
.call(d3.zoom().scaleExtent([0.1, 5]).on('zoom', (e) => g.attr('transform', e.transform)));
const g = svg.append('g');
const linkGroup = g.append('g').attr('class','links');
const nodeGroup = g.append('g').attr('class','nodes');
const labelGroup = g.append('g').attr('class','labels');
window.addEventListener('resize', () => {
width = graphEl.clientWidth || window.innerWidth - 320;
height = graphEl.clientHeight || (window.innerHeight - 60);
svg.attr('width', width).attr('height', height);
overlay.attr('width', width).attr('height', height);
if (simulation) {
simulation.force('center', d3.forceCenter(width/2, height/2)).alpha(0.3).restart();
}
});
let simulation = null;
let rawData = null;
let filtered = { nodes: [], links: [] };
// 顏色映射
function colorByCategory(cat) {
if (!cat) return '#9ca3af';
if (cat.includes('JDK')) return '#60a5fa';
if (cat.includes('第三方') || cat.includes('3rd')) return '#86efac';
return '#fca5a5'; // 業(yè)務(wù)
}
// 半徑映射(按淺表大?。? function radiusBySize(size) {
const base = 4;
if (!size || size <= 0) return base;
return Math.max(base, Math.sqrt(size) / 300); // 調(diào)整系數(shù)可微調(diào)
}
// 閾值過濾(MB)
function applyThreshold(data, minMB) {
const minBytes = minMB * 1024 * 1024;
const nodes = data.nodes.filter(n => (n.shallowSize || 0) >= minBytes);
const nodeIds = new Set(nodes.map(n => n.id));
const links = data.links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
return { nodes, links };
}
// 渲染圖
function render(data) {
// link
const link = linkGroup.selectAll('line').data(data.links, d => d.source + '->' + d.target);
link.exit().remove();
const linkEnter = link.enter().append('line').attr('class', 'link');
const linkSel = linkEnter.merge(link);
// node
const node = nodeGroup.selectAll('circle').data(data.nodes, d => d.id);
node.exit().remove();
const nodeEnter = node.enter().append('circle')
.attr('class','node')
.attr('r', d => radiusBySize(d.shallowSize))
.attr('fill', d => colorByCategory(d.category))
.call(drag(simulation))
.on('click', (_, d) => showDetail(d))
.on('mouseover', (_, d) => highlightNeighbors(d, true))
.on('mouseout', (_, d) => highlightNeighbors(d, false));
const nodeSel = nodeEnter.merge(node)
.attr('r', d => radiusBySize(d.shallowSize))
.attr('fill', d => colorByCategory(d.category));
// label
const label = labelGroup.selectAll('text').data(data.nodes, d => 'label_'+d.id);
label.exit().remove();
const labelEnter = label.enter().append('text')
.attr('class','label')
.text(d => d.label || d.className || d.id);
const labelSel = labelEnter.merge(label);
// simulation
if (simulation) simulation.stop();
simulation = d3.forceSimulation(data.nodes)
.force('link', d3.forceLink(data.links).id(d => d.id).distance(80).strength(0.2))
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width/2, height/2))
.force('collision', d3.forceCollide().radius(d => radiusBySize(d.shallowSize) + 6))
.on('tick', () => {
linkSel
.attr('x1', d => getNode(d.source).x)
.attr('y1', d => getNode(d.source).y)
.attr('x2', d => getNode(d.target).x)
.attr('y2', d => getNode(d.target).y);
nodeSel
.attr('cx', d => d.x = clamp(d.x, 0, width))
.attr('cy', d => d.y = clamp(d.y, 0, height));
labelSel
.attr('x', d => d.x + 6)
.attr('y', d => d.y - 6);
});
}
function getNode(n) { return n.id ? n : (rawIndex.get(n) || n); }
function clamp(v, min, max){ return Math.max(min, Math.min(max, v)); }
function drag(sim) {
function dragstarted(e, d) {
if (!e.active) sim.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
}
function dragged(e, d) {
d.fx = e.x; d.fy = e.y;
}
function dragended(e, d) {
if (!e.active) sim.alphaTarget(0);
d.fx = null; d.fy = null;
}
return d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended);
}
function showDetail(d) {
const badge = (c) => {
if (!c) return '';
if (c.includes('JDK')) return '<span class="badge jdk">JDK</span>';
if (c.includes('第三方') || c.includes('3rd')) return '<span class="badge third">第三方</span>';
return '<span class="badge app">業(yè)務(wù)</span>';
};
detailEl.innerHTML = `
<div style="font-weight:700;font-size:14px;margin-bottom:6px;">${d.className || d.label || d.id}</div>
<div>${badge(d.category)} <span style="color:#666;">${d.packageName || ''}</span></div>
<ul style="padding-left:18px;line-height:1.6;margin:8px 0;">
<li>實(shí)例總數(shù):${fmt(d.instanceCount)}</li>
<li>淺表大?。?{d.formattedSize || (d.shallowSize + ' B')}</li>
<li>對象類型:${d.objectType || '--'}</li>
<li>數(shù)組:${d.isArray ? '是' : '否'}</li>
<li>節(jié)點(diǎn)ID:${d.id}</li>
</ul>
<div class="hint">提示:將節(jié)點(diǎn)拖到合適位置,可調(diào)整局部布局;再次點(diǎn)擊可查看其它節(jié)點(diǎn)。</div>
`;
}
function fmt(v){ return (v===undefined||v===null) ? '--' : v; }
// 懸停高亮鄰居
function highlightNeighbors(node, on) {
const neighbors = new Set();
filtered.links.forEach(l => {
if (l.source === node.id || l.source?.id === node.id) neighbors.add(l.target.id || l.target);
if (l.target === node.id || l.target?.id === node.id) neighbors.add(l.source.id || l.source);
});
nodeGroup.selectAll('circle').classed('highlight', d => on && (d.id === node.id || neighbors.has(d.id)));
linkGroup.selectAll('line').classed('highlight', d => {
const s = d.source.id || d.source;
const t = d.target.id || d.target;
return on && (s === node.id || t === node.id);
});
}
// 數(shù)據(jù)索引(用于 link 坐標(biāo)快速訪問)
let rawIndex = new Map();
// 生成快照
snapshotBtn.addEventListener('click', async () => {
snapshotBtn.disabled = true; snapshotBtn.textContent = '生成中...';
try {
const params = new URLSearchParams();
params.set('live', String(liveChk.checked));
if (filterInput.value.trim()) params.set('filter', filterInput.value.trim());
params.set('collapse', String(collapseChk.checked));
const resp = await fetch('/api/memviz/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
if (!resp.ok) throw new Error('請求失?。? + resp.status);
const data = await resp.json();
rawData = data;
rawIndex = new Map(rawData.nodes.map(n => [n.id, n]));
metaTotal.textContent = '總對象數(shù):' + (data.totalObjects || '--');
metaMem.textContent = '總內(nèi)存:' + (data.formattedTotalMemory || '--');
const minMB = Number(sizeSlider.value || 0);
sizeLabel.textContent = minMB + ' MB';
filtered = applyThreshold(rawData, minMB);
render(filtered);
} catch (err) {
alert(err.message || err);
} finally {
snapshotBtn.disabled = false; snapshotBtn.textContent = '生成快照并可視化';
}
});
// 尺寸閾值滑條
sizeSlider.addEventListener('input', () => {
const mb = Number(sizeSlider.value || 0);
sizeLabel.textContent = mb + ' MB';
if (!rawData) return;
filtered = applyThreshold(rawData, mb);
render(filtered);
});
// 重置視圖(清空高亮、復(fù)位縮放)
resetBtn.addEventListener('click', () => {
nodeGroup.selectAll('circle').classed('highlight', false);
linkGroup.selectAll('line').classed('highlight', false);
svg.transition().duration(300).call(
d3.zoom().transform, d3.zoomIdentity
);
});
})();
</script>
</body>
</html>使用說明
- 啟動(dòng)應(yīng)用后,打開:http://localhost:8080/memviz.html
- 輸入過濾關(guān)鍵詞(可選):如 com.icoderoad、HashMap
- 勾選 live(可選):只保留存活對象(會(huì)觸發(fā)一次 STW)
- 勾選“折疊集合節(jié)點(diǎn)”(可選):減少集合類噪點(diǎn)
- 點(diǎn)擊“生成快照并可視化”
- 調(diào)整“大小閾值(MB)”過濾細(xì)碎節(jié)點(diǎn),聚焦大戶
- 點(diǎn)擊節(jié)點(diǎn)查看詳情;懸??筛吡拎従?;拖拽/縮放調(diào)整視圖
實(shí)戰(zhàn)建議
- 存儲(chǔ)目錄:通過 memviz.dump.dir 指定到大空間磁盤,避免和業(yè)務(wù)日志盤共振。
- 權(quán)限:確保進(jìn)程用戶對 dump 目錄有寫權(quán)限。
- 風(fēng)險(xiǎn)隔離:live=true 會(huì)觸發(fā) STW,建議僅在業(yè)務(wù)低峰操作。
- 限流與鑒權(quán):對 /api/memviz/snapshot 加鑒權(quán)/防抖(如僅內(nèi)網(wǎng)訪問/管理端 Token)。
- 拓展:
a.快照對比(兩次 HPROF 的 Top 類變化)
b.可疑集合 Drilldown(Map/Set 大 key 列表)
c.泄漏鏈線索(Dominators / Retained Set,按庫能力擴(kuò)展)
結(jié)論
把 JVM 內(nèi)存從“黑箱”變成“可視化地圖”,不是為了替代 MAT,而是為了把“隨手看一眼”做到極致:零侵入、線上可用、一鍵生成、就地可視化。 當(dāng)你需要深挖時(shí),依舊可以導(dǎo)出 HPROF 到專業(yè)工具;但在日常排障里,這個(gè)嵌入式拓?fù)鋱D能讓你以更低的成本定位“誰多、誰大、誰指著誰”,大幅縮短問題收斂時(shí)間。





























