如何把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只需要对于方法的事件进行处理。
// BEFORE
try {
/*
* do something...
*/
// RETURN
return;
} catch (Throwable cause) {
// THROWS
}
在沙箱的世界观中,任何一个Java方法的调用都可以分解为
BEFORE
、RETURN
和THROWS
三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。基于
BEFORE
、RETURN
和THROWS
三个环节事件分离,沙箱的模块可以完成很多类AOP的操作。
- 可以感知和改变方法调用的入参
- 可以感知和改变方法调用返回值和抛出的异常
- 可以改变方法执行的流程
- 在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行
- 在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常
- 在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回
一切都是事件驱动的,这一点你可能很迷糊,但是在下文的实战环节中,可以帮助你理解。
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
如何把Java代码玩出花?JVM Sandbox入门教程与原理浅谈的更多相关文章
- Java线上问题排查神器Arthas快速上手与原理浅谈
前言 当你兴冲冲地开始运行自己的Java项目时,你是否遇到过如下问题: 程序在稳定运行了,可是实现的功能点了没反应. 为了修复Bug而上线的新版本,上线后发现Bug依然在,却想不通哪里有问题? 想到可 ...
- 2018-06-21 中文代码示例视频演示Python入门教程第五章 数据结构
知乎原链 续前作: 中文代码示例视频演示Python入门教程第四章 控制流 对应在线文档: 5. Data Structures 这一章起初还是采取了尽量与原例程相近的汉化方式, 但有些语义较偏(如T ...
- 2018-06-20 中文代码示例视频演示Python入门教程第四章 控制流
知乎原链 续前作: 中文代码示例视频演示Python入门教程第三章 简介Python 对应在线文档: 4. More Control Flow Tools 录制中出了不少岔子. 另外, 输入法确实是一 ...
- JAVA代码中获取JVM信息
一.JAVA中获取JVM的信息 原理,利用JavaSDK自带的ManagementFactory类来获取. 二.获取信息 1.获取进程ID @Test public void test1() { Ru ...
- 2018-06-20 中文代码示例视频演示Python入门教程第三章 简介Python
知乎原链 Python 3.6.5官方入门教程中示例代码汉化后演示 对应在线文档: 3. An Informal Introduction to Python 不知如何合集, 请指教. 中文代码示例P ...
- 在java代码中获取JVM参数(转)
近日关注性能调优,关注JMX,发现java.lang.management.*之强大.同时查阅了资料,整合一版关于JVM参数获取的note,仅供参考: MemoryMXBean memorymbean ...
- JAVA CAS原理浅谈
java.util.concurrent包完全建立在CAS之上的,没有CAS就不会有此包.可见CAS的重要性. CAS CAS:Compare and Swap, 翻译成比较并交换. java.uti ...
- java中Switch的实现原理浅谈
switch的转换和具体系统实现有关,如果分支比较少,可能会转换为跳转指令(条件跳转指令和无条件跳转指令).但如果分支比较多,使用条件跳转会进行很多次的比较运算,效率比较低,可能会使用一种更为高效的方 ...
- Java中的SPI原理浅谈
在面向对象的程序设计中,模块之间交互采用接口编程,通常情况下调用方不需要知道被调用方的内部实现细节,因为一旦涉及到了具体实现,如果需要换一种实现就需要修改代码,这违反了程序设计的"开闭原则& ...
随机推荐
- 第九十九篇:JS闭包
好家伙,总是要来的,去面对那些晦涩难懂的原理,它就在那里,等着我去搞定它 首先我要去补充一些最基本的概念, 1.什么是内存? 新华字典永远的神, 但这个解释显然不够 去看看百度百科: 内存: CP ...
- 截取url后缀扩展名方法
原本使用 Path(_['video']['downloadUrl']).suffix 获取文件扩展名,没想到出错了,查明原因发现某视频链接是https://xx.xxx.xxx/xx/xxxx.mp ...
- KingbaseES 的 Lateral 连接
一.什么是 Lateral 连接 根据文档,它的作用是: LATERAL 关键字可以位于子 SELECT FROM 项之前.这允许子 SELECT 引用 FROM 列表中出现在它之前的 FROM 项的 ...
- 【读书笔记】C#高级编程 第十二章 动态语言扩展
(一)DLR C#4的动态功能是Dynamic Language Runtime(动态语言运行时,DLR)的一部分.DLR是添加到CLR的一系列服务. (二)dynamic类型 dynamic类型允许 ...
- 《Markdown常用语法及快捷键》
Markdown常用语法及快捷键 [```]+空格--代码格式 [---]+空格--水平分割线 [&emsp]+[;]--空格 [shift]+[tab]--清除当前行的格式
- 我的Go gRPC之旅、01 初识gRPC,感受gRPC的强大魅力
微服务架构 微服务是一种开发软件的架构和组织方法,其中软件由通过明确定义的API 进行通信的小型独立服务组成. 这些服务由各个小型独立团队负责. 微服务架构使应用程序更易于扩展和更快地开发,从而加速创 ...
- 配置git环境与项目创建
主要用于记录上课笔记,方便以后复习 acgit的地址:https://git.acwing.com/wyw/kob1/ 1. 项目模块的包含 1.1 采用前后端分离 Web端大概框架 2. git环境 ...
- 尝试阅读理解一份linux shell脚本
以下内容为本人的学习笔记,如需要转载,请声明原文链接微信公众号「englyf」https://www.cnblogs.com/englyf/p/16721350.html 从头一二去阅读语法和命令说明 ...
- ingress-nginx 的使用 =》 部署在 Kubernetes 集群中的应用暴露给外部的用户使用
文章转载自:https://mp.weixin.qq.com/s?__biz=MzU4MjQ0MTU4Ng==&mid=2247488189&idx=1&sn=8175f067 ...
- Prometheus与服务发现
这种按需的资源使用方式对于监控系统而言就意味着没有了一个固定的监控目标,所有的监控对象(基础设施.应用.服务)都在动态的变化.对于Prometheus这一类基于Pull模式的监控系统,显然也无法继续使 ...