写点什么

如何把 Java 代码玩出花?JVM Sandbox 入门教程与原理浅谈

作者:Zhendong
  • 2022-11-14
    浙江
  • 本文字数:6357 字

    阅读完需:约 21 分钟

如何把Java代码玩出花?JVM Sandbox入门教程与原理浅谈

在日常业务代码开发中,我们经常接触到 AOP,比如熟知的 Spring AOP。我们用它来做业务切面,比如登录校验,日志记录,性能监控,全局过滤器等。但 Spring AOP 有一个局限性,并不是所有的类都托管在 Spring 容器中,例如很多中间件代码、三方包代码,Java 原生代码,都不能被 Spring AOP 代理到。如此一来,一旦你想要做的切面逻辑并不属于 Spring 的管辖范围,或者你想实现脱离 Spring 限制的切面功能,就无法实现了。


那对于 Java 后端应用,有没有一种更为通用的 AOP 方式呢?答案是有的,Java 自身提供了 JVM TI,Instrumentation 等功能,允许使用者以通过一系列 API 完成对 JVM 的复杂控制。自此衍生出了很多著名的框架,比如 Btrace,Arthas 等等,帮助开发者们实现更多更复杂的 Java 功能。


JVM Sandbox 也是其中的一员。当然,不同框架的设计目的和使命是不一样的,JVM-Sandbox 的设计目的是实现一种在不重启、不侵入目标 JVM 应用情况下的 AOP 解决方案。


是不是看到这里还是不清楚我在讲什么?别急,我举几个典型的 JVM-Sandbox 应用场景:


  • 流量回放:如何录制线上应用每次接口请求的入参和出参?改动应用代码固然可以,但成本太大,通过 JVM-Sandbox,可以直接在不修改代码的情况下,直接抓取接口的出入参。

  • 安全漏洞热修复:假设某个三方包(例如出名的 fastjson)又出现了漏洞,集团内那么多应用,一个个发布新版本修复,漏洞已经造成了大量破坏。通过 JVM-Sandbox,直接修改替换有漏洞的代码,及时止损。

  • 接口故障模拟:想要模拟某个接口超时 5s 后返回 false 的情况,JVM-Sandbox 很轻松就能实现。

  • 故障定位:像 Arthas 类似的功能。

  • 接口限流:动态对指定的接口做限流。

  • 日志打印

  • ...


可以看到,借助 JVM-Sandbox,你可以实现很多之前在业务代码中做不了的事,大大拓展了可操作的范围。


本文围绕 JVM SandBox 展开,主要介绍如下内容:


  • JVM SandBox 诞生背景

  • JVM SandBox 架构设计

  • JVM SandBox 代码实战

  • JVM SandBox 底层技术

  • 总结与展望

JVM Sandbox 诞生背景

JVM Sandbox 诞生的技术背景在引言中已经赘述完毕,下面是作者开发该框架的一些业务背景,以下描述引用自文章:


JVM SandBox 是阿里开源的一款 JVM 平台非侵入式运行期 AOP 解决方案,本质上是一种 AOP 落地形式。那么可能有同学会问:已有成熟的 Spring AOP 解决方案,阿里巴巴为什么还要“重复造轮子”?这个问题要回到 JVM SandBox 诞生的背景中来回答。在 2016 年中,天猫双十一催动了阿里巴巴内部大量业务系统的改动,恰逢徐冬晨(阿里巴巴测试开发专家)所在的团队调整,测试资源保障严重不足,迫使他们必须考虑更精准、更便捷的老业务测试回归验证方案。开发团队面临的是新接手的老系统,老的业务代码架构难以满足可测性的要求,很多现有测试框架也无法应用到老的业务系统架构中,于是需要新的测试思路和测试框架。

为什么不采用 Spring AOP 方案呢?Spring AOP 方案的痛点在于不是所有业务代码都托管在 Spring 容器中,而且更底层的中间件代码、三方包代码无法纳入到回归测试范围,更糟糕的是测试框架会引入自身所依赖的类库,经常与业务代码的类库产生冲突,因此,JVM SandBox 应运而生。

JVM Sandbox 整体架构

本章节不详细讲述 JVM SandBox 的所有架构设计,只讲其中几个最重要的特性。详细的架构设计可以看原框架代码仓库的 Wiki。

类隔离

很多框架通过破坏双亲委派(我更愿意称之为直系亲属委派)来实现类隔离,SandBox 也不例外。它通过自定义的 SandboxClassLoader 破坏了双亲委派的约定,实现了几个隔离特性:


  • 和目标应用的类隔离:不用担心加载沙箱会引起原应用的类污染、冲突。

  • 模块之间类隔离:做到模块与模块之间、模块和沙箱之间、模块和应用之间互不干扰。


无侵入 AOP 与事件驱动

JVM-SANDBOX 属于基于 Instrumentation 的动态编织类的 AOP 框架,通过精心构造了字节码增强逻辑,使得沙箱的模块能在不违反 JDK 约束情况下实现对目标应用方法的无侵入运行时 AOP 拦截



从上图中,可以看到一个方法的整个执行周期都被代码“加强”了,能够带来的好处就是你在使用 JVM SandBox 只需要对于方法的事件进行处理。


// BEFOREtry {
/* * do something... */
// RETURN return;
} catch (Throwable cause) { // THROWS}
复制代码


在沙箱的世界观中,任何一个 Java 方法的调用都可以分解为BEFORERETURNTHROWS三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。

基于BEFORERETURNTHROWS三个环节事件分离,沙箱的模块可以完成很多类 AOP 的操作。

  1. 可以感知和改变方法调用的入参

  2. 可以感知和改变方法调用返回值和抛出的异常

  3. 可以改变方法执行的流程

  4. 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行

  5. 在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常

  6. 在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回


一切都是事件驱动的,这一点你可能很迷糊,但是在下文的实战环节中,可以帮助你理解。

JVM Sandbox 代码实战

我将实战章节提前到这里,目的是方便大家快速了解使用 JVM SandBox 开发是一件多么舒服的事情(相比于自己使用字节码替换等工具)。


使用版本:JVM-Sandbox 1.2.0


官方源码:https://github.com/alibaba/jvm-sandbox


我们来实现一个小工具,在日常工作中,我们总会遇到一些巨大的 Spring 工程,里面有茫茫多的 Bean 和业务代码,启动一个工程可能需要 5 分钟甚至更久,严重拖累开发效率。


我们尝试使用 JVM Sandbox 来开发一个工具,对应用的 Spring Bean 启动耗时进行一次统计。这样能一目了然的发现工程启动慢的主要原因,避免去盲人摸象的优化。


最终效果如图:



图中统计了一个应用从启动开始到所有 SpringBean 的启动耗时,按照从高到低排序,我由于是 demo 应用,Bean 的耗时都偏低(也没有太多业务 Bean),但在实际应用中会有非常多几秒甚至十几秒才完成初始化的 Bean,可以进行针对性优化。


在 JVMSandBox 中如何实现上面的工具?其实非常简单。


先贴上思路的整体流程:



首先新建 Maven 工程,在 Maven 依赖中引用 JVM SandBox,官方推荐独立工程使用 parent 方式。


<parent>    <groupId>com.alibaba.jvm.sandbox</groupId>    <artifactId>sandbox-module-starter</artifactId>    <version>1.2.0</version></parent>
复制代码


新建一个类作为一个 JVM SandBox 模块,如下图:



使用 @Infomation 声明 mode 为 AGENT 模式,一共有两种模式 Agent 和 Attach。


  • Agent:随着 JVM 启动一起启动

  • Attach:在已经运行的 JVM 进程中,动态的插入


我们由于是监控 JVM 启动数据,所以需要 AGENT 模式。


其次,继承 com.alibaba.jvm.sandbox.api.Module 和 com.alibaba.jvm.sandbox.api.ModuleLifecycle。


其中 ModuleLifecycle 包含了整个模块的生命周期回调函数。


  • onLoad:模块加载,模块开始加载之前调用!模块加载是模块生命周期的开始,在模块生命中期中有且只会调用一次。 这里抛出异常将会是阻止模块被加载的唯一方式,如果模块判定加载失败,将会释放掉所有预申请的资源,模块也不会被沙箱所感知

  • onUnload:模块卸载,模块开始卸载之前调用!模块卸载是模块生命周期的结束,在模块生命中期中有且只会调用一次。 这里抛出异常将会是阻止模块被卸载的唯一方式,如果模块判定卸载失败,将不会造成任何资源的提前关闭与释放,模块将能继续正常工作

  • onActive:模块被激活后,模块所增强的类将会被激活,所有 com.alibaba.jvm.sandbox.api.listener.EventListener 将开始收到对应的事件

  • onFrozen:模块被冻结后,模块所持有的所有 com.alibaba.jvm.sandbox.api.listener.EventListener 将被静默,无法收到对应的事件。 需要注意的是,模块冻结后虽然不再收到相关事件,但沙箱给对应类织入的增强代码仍然还在。

  • loadCompleted:模块加载完成,模块完成加载后调用!模块完成加载是在模块完成所有资源加载、分配之后的回调,在模块生命中期中有且只会调用一次。 这里抛出异常不会影响模块被加载成功的结果。模块加载完成之后,所有的基于模块的操作都可以在这个回调中进行


最常用的是 loadCompleted,所以我们重写 loadCompleted 类,在里面开启我们的监控类 SpringBeanStartMonitor 线程。


而 SpringBeanStartMonitor 的核心代码如下图:



使用 Sandbox 的 doClassFilter 过滤出匹配的类,这里我们是 BeanFactory。


使用 doMethodFilter 过滤出要监听的方法,这里是 initializeBean。


里取 initializeBean 作为统计耗时的切入方法。具体为什么选择该方法,涉及到 SpringBean 的启动生命周期,不在本文赘述范围内。(本文作者:蛮三刀酱)



接着使用moduleEventWatcher.watch(springBeanFilter, springBeanInitListener, Event.Type.BEFORE, Event.Type.RETURN);


将我们的 springBeanInitListener 监听器绑定到被观测的方法上。这样每次 initializeBean 被调用,都会走到我们的监听器逻辑。


监听器的主要逻辑如下:



代码有点长,不必细看,主要就是在原方法的 BeforeEvent(进入前)和 ReturnEvent(执行正常返回后)执行上述的切面逻辑,我这里便是使用了一个 MAP 存储每个 Bean 的初始化开始和结束时间,最终统计出初始化耗时。


最终,我们还需要一个方法来知道我们的原始 Spring 应用已经启动完毕,这样我们可以手动卸载我们的 Sandbox 模块,毕竟他已经完成了他的历史使命,不需要再依附在主进程上。


我们通过一个简陋的办法,检查http://127.0.0.1:8080/是否会返回小于 500 的状态码,来判断 Spring 容器是否已经启动。当然如果你的 Spring 没有使用 Web 框架,就不能用这个方法来判断启动完成,你也许可以通过 Spring 自己的生命周期钩子函数来实现,这里我是偷了个懒。


整个 SpringBean 监听模块的开发就完成了,你可以感受到,你的开发和日常业务开发几乎没有区别,这就是 JVM Sandbox 带给你的最大好处。


上述源码放在了我的 Github 仓库:


https://github.com/monitor4all/javaMonitor

JVM Sandbox 底层技术

整个 JVM Sandbox 的入门使用基本上讲完了,上文提到了一些 JVM 技术名词,可能小伙伴们听过但不是特别了解。这里简单阐述几个重要的概念,理清楚这几个概念之间的关系,以便大家更好的理解 JVM Sandbox 底层的实现。

JVMTI

JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 native 编程接口,JVMTI 可以用来开发并监控虚拟机,可以查看 JVM 内部的状态,并控制 JVM 应用程序的执行。可实现的功能包括但不限于:调试、监控、线程分析、覆盖率分析工具等。


很多 java 监控、诊断工具都是基于这种形式来工作的。如果 arthas、jinfo、brace 等,虽然这些工具底层是 JVM TI,但是它们还使用到了上层工具 JavaAgent。

JavaAgent 和 Instrumentation

Javaagent 是 java 命令的一个参数。参数 javaagent 可以用于指定一个 jar 包。


-agentlib:<libname>[=<选项>] 加载本机代理库 <libname>, 例如 -agentlib:hprof  另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help-agentpath:<pathname>[=<选项>]  按完整路径名加载本机代理库-javaagent:<jarpath>[=<选项>]  加载 Java 编程语言代理, 请参阅 java.lang.instrument
复制代码


在上面-javaagent参数中提到了参阅java.lang.instrument,这是在rt.jar 中定义的一个包,该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Javaagent。从名字上看,似乎是个 Java 代理之类的,而实际上,他的功能更像是一个 Class 类型的转换器,他可以在运行时接受重新外部请求,对 Class 类型进行修改。


Instrumentation 的底层实现依赖于 JVMTI。


JVM 会优先加载 带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。


Instrumentation 支持的接口:


public interface Instrumentation {    //添加一个ClassFileTransformer    //之后类加载时都会经过这个ClassFileTransformer转换    void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
void addTransformer(ClassFileTransformer transformer); //移除ClassFileTransformer boolean removeTransformer(ClassFileTransformer transformer);
boolean isRetransformClassesSupported(); //将一些已经加载过的类重新拿出来经过注册好的ClassFileTransformer转换 //retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性 void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
boolean isRedefineClassesSupported();
//重新定义某个类 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;
boolean isModifiableClass(Class<?> theClass);
@SuppressWarnings("rawtypes") Class[] getAllLoadedClasses();
@SuppressWarnings("rawtypes") Class[] getInitiatedClasses(ClassLoader loader);
long getObjectSize(Object objectToSize);
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
void appendToSystemClassLoaderSearch(JarFile jarfile);
boolean isNativeMethodPrefixSupported();
void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);}
复制代码


Instrumentation 的局限性:


  • 不能通过字节码文件和自定义的类名重新定义一个本来不存在的类

  • 增强类和老类必须遵循很多限制:比如新类和老类的父类必须相同;新类和老类实现的接口数也要相同,并且是相同的接口;新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;新类和老类新增或删除的方法必须是 private static/final 修饰的;


更详细的原理阐述可以看下文:


https://www.cnblogs.com/rickiyang/p/11368932.html

再谈 Attach 和 Agent

上面的实战章节中已经提到了 attach 和 agent 两者的区别,这里再展开聊聊。


在 Instrumentation 中,Agent 模式是通过-javaagent:<jarpath>[=<选项>]从应用启动时候就插桩,随着应用一起启动。它要求指定的类中必须要有 premain()方法,并且对 premain 方法的签名也有要求,签名必须满足以下两种格式:


public static void premain(String agentArgs, Instrumentation inst)    public static void premain(String agentArgs)
复制代码


一个 java 程序中-javaagent参数的个数是没有限制的,所以可以添加任意多个 javaagent。所有的 java agent 会按照你定义的顺序执行,例如:


java -javaagent:agent1.jar -javaagent:agent2.jar -jar MyProgram.jar
复制代码


上面介绍 Agent 模式的 Instrumentation 是在 JDK 1.5 中提供的,在 1.6 中,提供了 attach 方式的 Instrumentation,你需要的是 agentmain 方法,并且签名如下:


public static void agentmain (String agentArgs, Instrumentation inst)
public static void agentmain (String agentArgs)
复制代码


这两种方式各有不同用途,一般来说,Attach 方式适合于动态的对代码进行功能修改,在排查问题的时候用的比较多。而 Agent 模式随着应用启动,所以经常用于提前实现一些增强功能,比如我上面实战中的启动观测,应用防火墙,限流策略等等。

总结

本文花了较短的篇幅重点介绍了 JVM Sandbox 的功能,实际用法,以及基础原理。它通过封装一些底层 JVM 控制的框架,使得对 JVM 层面的 AOP 开发变的异常简单,就像作者自己所说“JVM-SANDBOX 还能帮助你做很多很多,取决于你的脑洞有多大了。


笔者在公司内部也通过它实现了很多小工具,比如上面的应用启动数据观测(公司内部是一个更为稳定复杂的版本,还监控了大量中间件的数据),帮助了很多部门同事,优化他们应用的启动速度。所以如果对 JVM 感兴趣,不妨大开脑洞,想一想 JVM Sandbox 还能在哪里帮助到你的工作,给自己的工作添彩。

参考

https://www.infoq.cn/article/tsy4lgjvsfweuxebw*gp


https://www.cnblogs.com/rickiyang/p/11368932.html


https://www.jianshu.com/p/eff047d4480a

发布于: 刚刚阅读数: 3
用户头像

Zhendong

关注

公众号:后端技术漫谈、蛮三刀酱 2020-07-17 加入

公众号:后端技术漫谈、蛮三刀酱

评论

发布
暂无评论
如何把Java代码玩出花?JVM Sandbox入门教程与原理浅谈_Java_Zhendong_InfoQ写作社区