简介:面向Java外挂开发学习者的内存修改程序实例,定位为类似CE的进程内存搜索与改写工具,适合具备一定Java基础、希望理解内存读写与进程交互原理的开发者研究参考。压缩包共86个文件,约25.27MB,包含32个java源文件、46个class编译文件、3个jar依赖与成品包,以及1个exe安装包和Eclipse工程配置等,兼顾源码研读、直接运行与无Java环境部署三种形态。源码可直接导入Eclipse,配合JNA完成进程内存访问,并提供可执行JAR和EXE安装包,便于在不同环境中验证功能。已有4954人学习下载,说明该主题具有较高关注度。读者可从中获得一套较完整的Java内存修改工具实现,涵盖界面、依赖组织与工程结构,适合用于技术研究、课程实践和工具原型参考。
1. 从一个"改了三次还是弹回去"的数值说起
标题里写着「Java 外挂开发之内存修改程序」,剥掉那层唬人的壳,它本质上是一个用 Java 实现的进程内存扫描与修改工具,功能骨架和 CE 是同一套思路:列出进程、枚举可读内存页、按数据类型精确扫描、二次过滤缩小候选地址集、最后把值写回目标进程。我第一次拿到这份源码,是为了给一个跑在 Windows 上的单机程序做数值调试,用现成工具能手工定位到地址,但要把定位流程固化下来、批量跑、再叠上自己的判断条件,就必须自己写一层扫描引擎。这套源码解决的正是这件事:原生调用封装、区域枚举、结果集压缩、写入与冻结,四块都齐了。它适合写过 Java、对指针和内存布局有基本概念、又想搞清楚"为什么改完过几秒数值又弹回去"的人。
2. 工程结构拆解:JNA 直连 Win32,扫描引擎单独成层
先把整个工程按职责切开看,不然后面读代码会被 JNA 的Pointer和Structure绕晕。这份源码的分层其实很朴素,但每一层边界划得比较干净,二次开发时改动点也很明确。
2.1 四层结构:UI、引擎、原生、模型
按包名从外往里数,大致是这么四层:
- UI 层:Swing 写的窗口,进程下拉框、扫描值输入框、类型选择、结果表格、冻结勾选框。它只负责收集输入和刷表格,不做任何内存操作。
- 引擎层:
Scanner、PointerScanner、Freezer三个类。扫描状态(候选地址集、上次扫描的值、数据类型)全部由ScanSession持有,UI 每点一次"下一次扫描"其实是往ScanSession里塞一个新条件。 - 原生层:
Kernel32、Psapi两个 JNA 接口,加上MEMORY_BASIC_INFORMATION这类结构体映射。所有OpenProcess、ReadProcessMemory、VirtualQueryEx都收在这层,业务代码不直接碰。 - 模型层:
ValueType枚举(byte/short/int/long/float/double/字符串)、MemRegion(基址+大小+保护属性)、PointerPath(基址模块 + 偏移数组)。
这么切的直接好处是:换平台(比如想接 Linux 的process_vm_readv)只需要替换原生层,扫描逻辑一行不用动;反过来想给扫描加并行,也只动引擎层。我一般做这类工具都会先把这个边界定死,否则后面Pointer会从 UI 一路漏到业务里,改起来是血泪经验。
2.2 为什么用 JNA 而不是 JNI 手写 C
这是拿到源码后第一个会问的问题。JNI 性能确实更好,但在这个场景里性能瓶颈根本不在 Java 到 C 的调用开销上——一次ReadProcessMemory读 64KB,跨语言那点开销在总耗时里占比很小。JNA 的Native.load直接把 DLL 导出的函数映射成接口方法,改一个参数不用重新编译 C 再System.loadLibrary,调试成本低太多。
核心接口定义大致长这样:
public interface Kernel32 extends Library { // kernel32.dll 直接按名字映射,DEFAULT_OPTIONS 会自动做 W 后缀和编码转换 Kernel32 INSTANCE = Native.load("kernel32", Kernel32.class, W32APIOptions.DEFAULT_OPTIONS); HANDLE OpenProcess(int dwDesiredAccess, boolean bInheritHandle, int dwProcessId); boolean ReadProcessMemory(HANDLE hProcess, Pointer lpBaseAddress, Pointer lpBuffer, int nSize, IntByReference lpNumberOfBytesRead); boolean WriteProcessMemory(HANDLE hProcess, Pointer lpBaseAddress, Pointer lpBuffer, int nSize, IntByReference lpNumberOfBytesWritten); // 返回实际写入结构体的字节数,0 表示失败,需要配合 GetLastError 看原因 int VirtualQueryEx(HANDLE hProcess, Pointer lpAddress, MEMORY_BASIC_INFORMATION lpBuffer, int dwLength); boolean CloseHandle(HANDLE hObject); }参数说明几个容易写错的点:OpenProcess的dwDesiredAccess是位或出来的权限组合,不是单个枚举;ReadProcessMemory的nSize是"想读多少",返回的lpNumberOfBytesRead才是"实际读到多少",两者不等时要按实际值解析,不能按nSize解析,否则缓冲区尾部全是脏数据。VirtualQueryEx的返回值是结构体大小,等于 0 表示这个地址已经越界,循环必须在这里 break。
MEMORY_BASIC_INFORMATION在 64 位下有个SIZE_T字段,Java 里用long接,同时必须写@FieldOrder,顺序错一个字段整个结构体全歪:
@FieldOrder({"BaseAddress", "AllocationBase", "AllocationProtect", "RegionSize", "State", "Protect", "Type"}) public class MEMORY_BASIC_INFORMATION extends Structure { public Pointer BaseAddress; // 区域起始地址 public Pointer AllocationBase; // 分配时的基址,用于反查模块 public int AllocationProtect; public long RegionSize; // SIZE_T,64 位下是 8 字节 public int State; // MEM_COMMIT / MEM_FREE / MEM_RESERVE public int Protect; // 页保护属性 public int Type; }2.3 进程枚举与句柄获取:权限组合决定你能干什么
进程列表用的是EnumProcesses拿 PID 数组,再用OpenProcess加QueryFullProcessImageName取可执行名。真正要小心的是权限常量,这里直接决定后面读或写会不会失败:
| 常量 | 值 | 用途 |
|---|---|---|
| PROCESS_QUERY_INFORMATION | 0x0400 | 查询进程信息,取模块列表必需 |
| PROCESS_VM_READ | 0x0010 | 读内存,扫描必需 |
| PROCESS_VM_WRITE | 0x0020 | 写内存,改值必需 |
| PROCESS_VM_OPERATION | 0x0008 | 配合写入,部分场景必需 |
只扫描的话0x0400 | 0x0010就够;一旦要写,四个都得或上。很多新手只加WRITE不加OPERATION,结果WriteProcessMemory一直返回 false,错误码还查不出所以然,这是后面避坑章节要展开的一条。
句柄是系统资源,HANDLE用完必须CloseHandle。源码里用了一个ProcessHandle包装类实现AutoCloseable,try-with-resources打开,扫描结束自动释放,这个写法建议保留——CE 那类工具长时间挂着不释放句柄,被保护的目标进程会直接拒绝后续所有打开请求。
2.4 内存区域枚举:把 64 位地址空间走一遍
扫描不是从 0 读到最大地址,那样既慢又会撞上一堆不可读页。正确做法是先用VirtualQueryEx把整个地址空间切成一段段区域,只保留MEM_COMMIT且保护属性可读、且没被PAGE_GUARD标记的:
public List<MemRegion> enumRegions(HANDLE h) { List<MemRegion> regions = new ArrayList<>(); long addr = 0x10000L; // 跳过低 64KB 保留区 MEMORY_BASIC_INFORMATION mbi = new MEMORY_BASIC_INFORMATION(); while (true) { int ret = Kernel32.INSTANCE.VirtualQueryEx( h, Pointer.createConstant(addr), mbi, mbi.size()); if (ret == 0) break; // 走到地址空间边界 boolean committed = mbi.State == 0x1000; // MEM_COMMIT boolean readable = (mbi.Protect & 0xEE) != 0; // READONLY/RW/EXEC_READ/EXEC_RW boolean guarded = (mbi.Protect & 0x100) != 0; // PAGE_GUARD,读了会触发异常 if (committed && readable && !guarded) { regions.add(new MemRegion(Pointer.nativeValue(mbi.BaseAddress), mbi.RegionSize)); } addr = Pointer.nativeValue(mbi.BaseAddress) + mbi.RegionSize; } return regions; }这里的逻辑说明:VirtualQueryEx返回的是"从 addr 开始这一段连续同属性区域"的信息,所以下一轮直接把addr推到BaseAddress + RegionSize,而不是 +1,否则要循环几十万次。0xEE这个掩码覆盖了PAGE_READONLY(0x02)、PAGE_READWRITE(0x04)、PAGE_EXECUTE_READ(0x20)、PAGE_EXECUTE_READWRITE(0x40)、PAGE_WRITECOPY(0x08)、PAGE_EXECUTE_WRITECOPY(0x80),基本就是"能读的页"。PAGE_GUARD必须排除,这种页是栈增长用的哨兵页,读它会让目标进程收到异常,表现就是"扫一下游戏崩了"。
枚举出来的区域数量通常在几百到几千之间,总大小从几十 MB 到几个 GB 不等。这份源码在 UI 上会先把区域列表打出来,让你看到"这次要扫多少 MB",再决定要不要扫,避免一上来就卡死。
3. 扫描引擎拆解:精确扫描到二次过滤的完整链路
引擎层是这套源码里最值得读的部分。它的设计思路和 CE 一致:第一次扫描把整个可读内存过一遍,产出候选地址集;之后每次"下一次扫描"只在这个集合上做过滤,候选集会迅速收敛到几十个甚至几个。
3.1 分块读取与对齐步长
不能一个地址一个地址地ReadProcessMemory,那样光是系统调用开销就够你等到天亮。源码里的做法是按 64KB 分块读进一块Memory缓冲区,然后在缓冲区里用偏移定位:
public LongList firstScan(HANDLE h, List<MemRegion> regions, long target, ValueType type) { LongList hits = new LongList(); // 自己实现的 long 动态数组,避免装箱 final int CHUNK = 64 * 1024; Memory buf = new Memory(CHUNK); IntByReference read = new IntByReference(); int step = type.size(); // 按类型宽度步进 for (MemRegion r : regions) { long off = 0; while (off < r.size) { int want = (int) Math.min(CHUNK, r.size - off); boolean ok = Kernel32.INSTANCE.ReadProcessMemory( h, Pointer.createConstant(r.base + off), buf, want, read); if (ok) { int n = read.getValue(); // 只在真正读到的字节范围内解析,尾部脏数据一律跳过 for (int i = 0; i + step <= n; i += step) { if (type.read(buf, i) == target) { hits.add(r.base + off + i); } } } off += want; // 注意按 want 推进,不是按实际读到的 n } } return hits; }逻辑说明:CHUNK取 64KB 是个经验值,太小系统调用次数多,太大单次失败重试成本高,而且跨页边界时部分读取的概率上升。块内按step步进,int是 4、double是 8,这样保证读到的值是对齐的;如果你要找的是 1 字节的byte或者打包在结构体里没对齐的字段,把step改成 1,代价是候选集爆炸式膨胀,后面过滤会更累。
参数上有个细节:外层off += want而不是off += n。因为ReadProcessMemory可能只读到一部分(跨到不可读页时会截断),但地址游标必须按请求的长度推进,否则会重复读同一段,候选集里出现重复地址,后续过滤会误判。
3.2 数据类型解释器:把 8 个字节变回一个值
ValueType枚举是引擎的核心抽象,它把"从缓冲区第 i 个字节开始读一个值"和"把这个值写进缓冲区"这两件事封在一起:
public enum ValueType { BYTE(1) { long read(Memory m, int o) { return m.getByte(o); } }, SHORT(2) { long read(Memory m, int o) { return m.getShort(o); } }, INT(4) { long read(Memory m, int o) { return m.getInt(o); } }, LONG(8) { long read(Memory m, int o) { return m.getLong(o); } }, FLOAT(4) { long read(Memory m, int o) { return Float.floatToRawIntBits(m.getFloat(o)); } }, DOUBLE(8) { long read(Memory m, int o) { return Double.doubleToRawLongBits(m.getDouble(o)); } }; public final int size; ValueType(int size) { this.size = size; } public abstract long read(Memory m, int offset); }浮点类型这里用floatToRawIntBits/doubleToRawLongBits转成位模式再比较,这是关键。看起来绕了一圈,但直接比较float会在0.1f这类值上翻车——你以为扫的是0.1,实际内存里存的是0.100000001490116,转化成 bit 之后比较就是精确的位比较,反而不会漏。用rawIntBits而不是intBits是为了保留 NaN 的原始位模式,有些程序会用特定的 NaN payload 做标记值。
字符串类型不在这个枚举里,单独处理:先按目标编码(GBK 或 UTF-8)把输入转成字节数组,然后在块内做子串搜索,命中的地址就是字符串起始地址。这块源码写得比较糙,只支持单字节编码,二次开发时可以补上宽字符。
3.3 二次扫描:候选集收敛的三个策略
第一次扫描可能产出几十万到几百万个地址,第二次扫描开始就是纯过滤。源码里实现了三种策略,对应 UI 上的三个按钮:
- 精确值:值和上次一样,直接
type.read(...) == target过滤。 - 变动/未变动:不输入值,只判断这次读到的和上次读到的位模式是否相同,用来找那些"显示在界面上但我不知道具体数值"的字段,比如血条。
- 增加/减少:记录上次的值做大小比较,适合找经验值、金币这类单调变化的量。
实现上共用一套骨架:
public void nextScan(HANDLE h, LongList addrs, long target, ValueType type) { Memory buf = new Memory(type.size()); IntByReference read = new IntByReference(); int w = 0; // 写游标,原地压缩 for (int i = 0; i < addrs.size; i++) { long a = addrs.get(i); boolean ok = Kernel32.INSTANCE.ReadProcessMemory( h, Pointer.createConstant(a), buf, type.size(), read); // 读失败或读不全,说明这块内存已经被释放,直接从候选集剔除 if (!ok || read.getValue() != type.size()) continue; if (type.read(buf, 0) == target) { addrs.set(w++, a); } } addrs.truncate(w); // 截断尾部残留 }逻辑说明:这里用了原地压缩——满足条件的地址往前面挪,最后截断。好处是不额外分配内存,几百万条候选集下这个优化很实在。ReadProcessMemory失败在这里不是错误,是正常现象:目标进程可能刚刚free了那块内存,候选集里的旧地址就成了野指针,必须静默丢掉。如果你把失败当异常往外抛,扫描第二次就会中断。
3.4 结果集存储:别用 ArrayList<Long> 装几百万个地址
这一点是性能分水岭。ArrayList<Long>每个元素都是一个对象,几百万个地址意味着几百万个Long实例加一个指针数组,光是 GC 压力就能让 UI 卡成幻灯片。源码里自己写了一个LongList,底层就是long[],扩容按 1.5 倍走:
public class LongList { private long[] data = new long[1024]; private int size = 0; public void add(long v) { if (size == data.length) { long[] bigger = new long[data.length + (data.length >> 1)]; System.arraycopy(data, 0, bigger, 0, size); data = bigger; } data[size++] = v; } public long get(int i) { return data[i]; } public void truncate(int n) { size = n; } }同样的候选集规模,用long[]的内存占用大约是ArrayList<Long>的五分之一到八分之一,而且遍历时没有拆箱。这份源码在 UI 上还限制了最多展示前 1000 条结果,剩下的只报数量——表格控件塞十万行一样会卡,展示和存储要分开。
4. 写入与指针链:从"改一次就回落"到锁定基址加偏移
扫描出地址只是第一步,真正让人抓狂的是"改完过几秒又变回去"。这一章讲写入怎么做,以及为什么必须走指针链。
4.1 WriteProcessMemory 与内存保护属性
写入的封装和读取对称,但多一个坑:目标页可能是只读的。WriteProcessMemory内部会临时改保护属性,绝大多数情况能成功,但遇到PAGE_EXECUTE_READ或者被反调试逻辑锁定的页,就得先用VirtualProtectEx手动放开:
public boolean write(HANDLE h, long addr, long value, ValueType type) { Memory buf = new Memory(type.size()); type.write(buf, 0, value); // 按类型把值序列化进缓冲区 IntByReference written = new IntByReference(); boolean ok = Kernel32.INSTANCE.WriteProcessMemory( h, Pointer.createConstant(addr), buf, type.size(), written); if (!ok) { // 常见失败原因:页只读、地址已释放、句柄缺 PROCESS_VM_OPERATION int err = Kernel32.INSTANCE.GetLastError(); log.warn("write failed addr=0x{} err={}", Long.toHexString(addr), err); } return ok && written.getValue() == type.size(); }参数说明:addr用long而不是int存,64 位进程的地址超过Integer.MAX_VALUE是常态,用int直接变成负数,写到一个莫名其妙的地方去,这是新手最容易踩的一脚。返回值要同时判ok和实际写入字节数,部分写入在跨页时会发生。
4.2 指针扫描:还原多级偏移
为什么地址会变?因为程序里的对象是动态分配的,每次启动地址都不同,但"从模块基址出发、经过固定几级偏移能到达这个字段"这个结构是稳定的。指针扫描干的事就是反过来找这条路径。
单级扫描的思路:遍历所有可读区域,把每个 4 字节(32 位)或 8 字节(64 位)位置的值读出来,如果这个值落在某个模块的地址范围内,就记下"位置 → 指向的地址"这条边。然后从目标地址出发,反查谁的指向等于它,再对找到的位置继续反查,直到深度用完:
public List<Long> findPointersTo(HANDLE h, List<MemRegion> regions, long target, int ptrSize) { List<Long> found = new ArrayList<>(); final int CHUNK = 64 * 1024; Memory buf = new Memory(CHUNK); IntByReference read = new IntByReference(); for (MemRegion r : regions) { long off = 0; while (off < r.size) { int want = (int) Math.min(CHUNK, r.size - off); if (Kernel32.INSTANCE.ReadProcessMemory( h, Pointer.createConstant(r.base + off), buf, want, read)) { int n = read.getValue(); for (int i = 0; i + ptrSize <= n; i += ptrSize) { long v = (ptrSize == 4) ? buf.getInt(i) & 0xFFFFFFFFL : buf.getLong(i); if (v == target) { found.add(r.base + off + i); // 这里存的是"指针所在位置" } } } off += want; } } return found; }逻辑说明:buf.getInt(i) & 0xFFFFFFFFL这个掩码不能省,Java 的int是有符号的,直接用会让高地址变成负数,跟目标地址永远对不上。多级指针就是把这个函数递归调用,第二层的target换成第一层找到的位置,深度一般设 3 到 5 层,再深就基本是数据结构里的链表了,静态偏移链走不到。
最终得到的PointerPath是「模块名 + 偏移数组」,运行时先取模块基址,逐级解引用加偏移,就能算出这一次的绝对地址。
4.3 冻结写回:独立线程加最小写频率
冻结就是每隔一小段时间把值重新写一遍,对抗程序自己的回写逻辑。源码里用了一个ScheduledExecutorService,每个冻结项一个任务:
scheduler.scheduleAtFixedRate(() -> { long addr = resolve(pointerPath); // 每次重算,防止地址漂移 if (addr != 0 && !writer.write(handle, addr, frozenValue, type)) { // 连续失败说明路径失效,停掉任务避免刷日志 failCount.incrementAndGet(); } }, 0, 100, TimeUnit.MILLISECONDS);参数上,100ms 是个折中:太快(10ms 以下)会明显拉高目标进程 CPU,太慢(1s 以上)会出现肉眼可见的数值跳变。真正要注意的是每次都重新resolve路径而不是缓存绝对地址,对象一旦被重新分配,缓存的地址就写到垃圾数据上去了,表现是"越冻结程序越卡然后崩"。
4.4 另一条路:JVM 进程的 attach 与字节码改写
如果目标本身是 Java 进程,那根本不用碰ReadProcessMemory。JDK 自带的 Attach API 可以直接挂上去,用Instrumentation做类重定义:
VirtualMachine vm = VirtualMachine.attach(String.valueOf(pid)); try { vm.loadAgent(agentJarPath, "com.demo.agent.AgentMain"); } finally { vm.detach(); }Agent 侧拿到Instrumentation之后,用retransformClasses把目标方法里的字段读取逻辑改掉:
public static void agentmain(String args, Instrumentation inst) { inst.addTransformer(new ClassFileTransformer() { @Override public byte[] transform(ClassLoader loader, String className, Class<?> c, ProtectionDomain pd, byte[] bytes) { if (!"com/demo/state/PlayerState".equals(className)) return null; return bytecodePatch(bytes); // 改写 getHp(),直接返回固定值 } }, true); for (Class<?> c : inst.getAllLoadedClasses()) { if ("com.demo.state.PlayerState".equals(c.getName())) { inst.retransformClasses(c); // 已加载的类必须手动触发重定义 } } }两条路的差别很直接:内存修改是"从外部改数据",目标一刷新就没了;字节码改写是"从内部改逻辑",刷新逻辑本身被换掉了,稳定性完全不在一个量级。但字节码改写需要-javaagent或者 attach 权限,目标进程如果开了-XX:+DisableAttachMechanism就直接堵死。做工具时两条路都留着,先探测目标是不是 JVM,是就走 attach,不是再回落到原生扫描。
5. 避坑与排查:句柄、地址漂移和位数这三类坑
这一章是踩出来的,按「现象 → 原因 → 解决」记,遇到问题先对着这几条过一遍。
5.1 句柄与权限类
现象一:OpenProcess返回 0,GetLastError是 5(拒绝访问)。
原因通常有三种:目标进程以更高权限运行、目标进程有保护机制、或者你只给了PROCESS_VM_READ却想调VirtualQueryEx。有些进程还会主动拒绝PROCESS_QUERY_INFORMATION,只接受PROCESS_QUERY_LIMITED_INFORMATION(0x1000)。解决顺序是:先确认当前进程权限是否和目标同级,再试0x1000替代0x0400,最后才考虑是不是被保护了。别一上来就怀疑代码写错,这类失败九成是权限问题。
现象二:WriteProcessMemory一直返回 false,但读内存完全正常。
原因基本是权限常量没给全。只加了PROCESS_VM_WRITE而漏了PROCESS_VM_OPERATION,写入会被系统拒绝。解决就是四个常量一起或上,别省。
现象三:扫了半小时突然目标进程崩了。
PAGE_GUARD页没排除。这种页是线程栈的哨兵页,读它会触发目标进程的栈增长异常处理,运气不好就是访问违例。解决是在区域枚举里把Protect & 0x100 != 0的全部过滤掉,同时把PAGE_NOACCESS(0x01)也排掉。
5.2 地址与数据类型类
现象四:扫描出来的地址,重启程序后全部失效。
动态分配加地址空间随机化,这是正常现象,不是 bug。解决方案是别存绝对地址,存「模块基址 + 偏移」或者完整的指针链,启动时重新解析。判断一个地址稳不稳定很简单:关掉程序重开,再用同样的值扫一次,如果新地址和旧地址差值正好等于模块基址的差值,那就是纯偏移,可以直接换算。
现象五:64 位进程扫出来的地址是负数,写入直接崩。
Java 的int是有符号 32 位,存不下 64 位地址。所有地址变量、Pointer.createConstant的参数、缓冲区里的指针读取,统一用long,并且读 4 字节指针时记得& 0xFFFFFFFFL。这条我踩过不止一次,症状就是"明明是合法地址,写进去就崩",查半天最后发现是符号扩展。
现象六:VirtualQueryEx返回的结构体里字段全是乱的。
两个可能:@FieldOrder顺序和 C 结构体定义不一致,或者调用前没write()、调用后没read()。JNA 的Structure是手动同步的,传给原生函数前必须write()把 Java 字段刷进本地内存,拿回来必须read()把本地内存刷回 Java 字段。少一步拿到的就是全零或者上一轮的值。
6. 二次开发:把这个扫描器改成能用的调试工具
源码跑通之后,真正有意思的是改。我按自己改造的顺序列几个方向,都是投入小、见效快的。
第一件事:把扫描和目标进程解耦,做成可脚本化的。现在的扫描逻辑和 Swing 面板绑得比较紧,按钮里直接 newScanner。抽一个ScanService接口出来,参数用配置对象传:目标进程、扫描类型、对齐步长、区域过滤规则、最大结果数。抽完之后可以写配置文件,一条命令跑一套预设扫描流程,比手点快一个数量级。验证方法是拿一个自己写的靶子程序——里面塞 1000 个随机int,其中一个固定是0x5F5E0FF,跑扫描看能不能精确命中且只有一条结果。
第二件事:加内存快照对比。CE 最有用的功能其实不是精确扫描,是"前后两次快照做差集"。实现上就是枚举区域后把整段内存 dump 成文件,两次 dump 做字节级 diff,输出变动的地址区间。这个功能对找"按下按钮后哪里变了"极其有效,代码量不大,一个RandomAccessFile加 mmap 就能搞定。我做的时候先在一段 200MB 的靶子内存上测,diff 一次控制在 2 秒内才算合格。
第三件事:把指针扫描的结果持久化成可复用的路径文件。扫描出的PointerPath序列化成 JSON,字段包括模块名、偏移数组、目标类型、备注。下次启动直接加载,不用重新扫。这里要注意模块名的取法:从AllocationBase反查所属模块,取文件名而不是完整路径,路径在不同机器上会变。
第四件事:加并发。区域枚举是个天然可并行的任务,每个区域一个任务丢进ForkJoinPool,结果用ConcurrentLinkedQueue收。注意HANDLE是进程级的,多线程共享读没问题,但Memory缓冲区必须每线程一个,共用会读到别人的数据。这个改动在我的机器上把首次扫描从 40 秒压到 9 秒左右,收益很直接。
改造时的验证习惯:每改一版,都先拿靶子程序回归一遍「首次扫描命中数 → 二次过滤命中数 → 写入后读回值」,三步都对了再上真实目标。有一次我为了图快跳过了回归,直接拿真实进程试,结果写入地址算错一位,把相邻字段覆盖了,排查了整整一个下午才定位到是指针路径解析时少加了一级偏移。从那以后我每次动到地址计算相关的代码,都强制走一遍靶子回归,哪怕只改了一行。
源码在这里,想自己拆一遍或者接着往上加功能的,拿去跑就是了。希望帮到你。
本文还有配套的精品资源,点击获取