我破解了 JVM,以在不触碰源代码的情况下可视化算法
Source: Dev.to
问题
我使用的每个算法可视化工具都有同样的问题——你必须改写代码以使用它们的 API。这样你不再学习算法本身,而是在学习它们的框架。
我想要点不一样的东西。写普通的 Java,直接看到可视化。无需 SDK、无需追踪调用、无需任何额外代码。
于是我构建了 AlgoFlow(AlgoPad 背后的引擎)。它同时支持 Java 和 Python——本文聚焦于 Java 部分。
// 这就是你全部需要写的代码。真的。
int[] arr = {5, 2, 8, 1};
arr[0] = 10; // ← 自动可视化
@Tree TreeNode root = new TreeNode(1);
root.left = new TreeNode(2); // ← 树实时更新没有 tracer.patch(0, 10)。没有 visualize(arr)。只有代码。
为什么使用字节码?
显而易见的 Java 方法是 AST 转换——解析源代码,注入跟踪调用,编译修改后的源代码。这是大多数工具的做法,对简单情况也能很好地工作。
但我想拦截 所有:
- 每一次数组读取。
- 每一次数组写入。
- 每一次树节点字段的变更。
- 每一次
list.add()。 - 每一次
map.put()。
……而且我希望它能够在用户自然编写的代码上工作,用户根本不需要考虑可视化。
Java 字节码可以做到这一点。 JVM 为我关心的每一种操作都提供了特定的 opcode:
| Opcode | 含义 |
|---|---|
IASTORE / IALOAD | 数组元素写入 / 读取 |
PUTFIELD / GETFIELD | 对象字段变更 / 访问 |
PUTSTATIC | 静态字段赋值 |
INVOKEVIRTUAL | 对集合的成员方法调用 |
当你的 Java 代码执行 arr[3] = 42 时,编译器会生成一条 IASTORE 指令。我在类加载之前 拦截 这条指令,在其前后注入可视化回调,原始代码仍然按原样运行。用户的代码根本不知道自己被监视。
AST 转换无法如此干净地实现。根本原因在于:Java 源代码的语义庞大,而字节码的语义极小。
考虑在 Java 源码中写数组写入的多种方式:
arr[i] = 5;
arr[i + 1] = arr[j - 1] + arr[k] * 2;
arr[getIndex()] = computeValue();
arr[map.get(key)] = list.get(i) > list.get(j) ? x : y;使用 AST 转换时,每一种写法都是不同的树结构。你必须匹配所有模式,处理嵌套表达式、三元运算符、方法调用作为索引、方法调用作为值——组合空间庞大。漏掉一种模式就会导致可视化失效。新增 Java 语言特性又会产生新的模式需要处理。
在 字节码层面,它们都是同一件事。无论源表达式多么复杂,编译器都会把它们降到:
push array ref
push index
push value
IASTORE一个 opcode。一个拦截点。完成。
字段访问也是同理。源码中可以写 this.left、node.left、getNode().left、nodes[i].left——在字节码里始终是 GETFIELD。集合操作无论接收者或参数如何计算,都会走 INVOKEVIRTUAL。
编译器已经完成了把 Java 丰富语法展平成一小套固定操作的艰苦工作。字节码操作让我能够直接利用这一步,而不是重新实现它。
我只拦截少数几个 opcode,就能覆盖几乎无限的用户代码表面。这就是字节码为你提供的杠杆。
Source: …
那么它在实际中是如何工作的?
底层实现
该引擎是一个 Java agent —— 在用户的类加载之前挂钩 JVM,并使用 ByteBuddy 和原始 ASM 访问者对字节码进行改写。
下面是处理流程:
User writes code
↓
JVM loads the class
↓
Agent intercepts class loading (premain)
↓
ByteBuddy + ASM rewrite bytecode
↓
Transformed class runs normally
↓
Intercepted operations emit visualization commands
↓
Frontend renders step‑by‑step animation拦截数组访问
这是我最先实现的功能。难点并不在数组本身,而是在底层的 ASM 栈操作。当 JVM 执行 IASTORE(整数数组存储)时,栈的状态如下:
Stack: [array_ref, index, value]我需要捕获这三个值,调用可视化回调,然后再让原始的存储操作继续执行。问题在于不能直接“窥视” JVM 栈——必须把值弹出,保存到局部变量槽中,完成工作后再压回栈。
// Simplified version of what ArrayAccessWrapper does:
//
// 1. Pop value and index into temporary slots
super.visitVarInsn(Opcodes.ISTORE, valueSlot);
super.visitVarInsn(Opcodes.ISTORE, indexSlot);
super.visitVarInsn(Opcodes.ASTORE, arraySlot);
// 2. Call VisualizerRegistry.onArraySet(array, [index, value, lineNumber])
// ... pack args into Object[], invoke static method ...
// 3. Restore stack and execute original IASTORE
super.visitVarInsn(Opcodes.ALOAD, arraySlot);
super.visitVarInsn(Opcodes.ILOAD, indexSlot);
super.visitVarInsn(Opcodes.ILOAD, valueSlot);
super.visitInsn(Opcodes.IASTORE); // original operation这段代码会在用户代码中的 每一次 数组访问时执行。相同的原理也适用于其他指令(GETFIELD、PUTFIELD、INVOKEVIRTUAL 等),从而提供一种通用、语言无关的算法执行可视化方式,无需任何专门的 SDK 或追踪调用。
boolean[]、double[]——所有数组类型都支持。用户写 arr[i] = arr[j] 时,读取(IALOAD)和写入(IASTORE)都会触发可视化事件。
拦截字段变更(树和链表)
当有人写 node.left = new TreeNode(5) 时,编译后会生成 PUTFIELD 指令。我以同样的方式拦截它——捕获所有者对象、字段名以及行号,然后调用 VisualizerRegistry.onFieldSet()。
注册中心会判断 node 属于哪种树可视化器,而树可视化器则知道如何把该字段变更转换为前端的 “添加节点、添加边” 命令。
用户只需在根节点上加上 @Tree 注解,像往常一样编写树代码。引擎会自动检测节点类的结构——找到两个自引用字段(子节点)和数值字段。任何符合二叉树节点形态的类都能工作。
@Tree TreeNode root = new TreeNode(1);
root.left = new TreeNode(2); // PUTFIELD intercepted → "add node 2, add edge 1→2"
root.right = new TreeNode(3); // PUTFIELD intercepted → "add node 3, add edge 1→3"试一试
AlgoFlow 已上线于 algopad.dev。编写排序算法、构建图、实现树遍历——并逐步观察其执行。
代码是开源的:github.com/vish-chan/AlgoFlow
在下一篇文章中,我将介绍更棘手的问题——我如何让字节码拦截在 JDK 引导类(如 ArrayList 和 HashMap)上工作,自动检测系统如何消除手动注册,以及调试 JVM 栈操作时的血泪史。敬请期待。
