我破解了 JVM,以在不触碰源代码的情况下可视化算法

发布: (2026年4月4日 GMT+8 06:32)
8 分钟阅读
原文: Dev.to

Source: Dev.to

问题

我使用的每个算法可视化工具都有同样的问题——你必须改写代码以使用它们的 API。这样你不再学习算法本身,而是在学习它们的框架。

我想要点不一样的东西。写普通的 Java,直接看到可视化。无需 SDK、无需追踪调用、无需任何额外代码。

于是我构建了 AlgoFlowAlgoPad 背后的引擎)。它同时支持 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)。只有代码。

AlgoFlow 演示

为什么使用字节码?

显而易见的 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.leftnode.leftgetNode().leftnodes[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

这段代码会在用户代码中的 每一次 数组访问时执行。相同的原理也适用于其他指令(GETFIELDPUTFIELDINVOKEVIRTUAL 等),从而提供一种通用、语言无关的算法执行可视化方式,无需任何专门的 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 引导类(如 ArrayListHashMap)上工作,自动检测系统如何消除手动注册,以及调试 JVM 栈操作时的血泪史。敬请期待。

0 浏览
Back to Blog

相关文章

阅读更多 »