首先要说明几个概念,不要混用,热部署,热加载;
热部署:就是已经运行了项目,更改之后,不需要重新tomcat,但是会清空内存,重新打包,重新解压war包运行,可能好处是一个tomcat多个项目,不必因为tomcat停止而停止其他的项目。直接重新加载整个应用;热部署是将context重新建立一个新的context实例, 监控的目录是caltalina/localhost 下面的xml文件。如果修改server.xml没用;
热加载:是基于字节码进行更改的,不释放内存,热加载也可以叫热更新。在运行时重新加载class;热加载实现是将webappclassloader 清空,然后new一个新的实例出来,加载类。
一、Arthas热更新步骤
arthas是阿里的一个开源的诊断项目,使用起来还是比较方便的。其中功能也涵盖了热发布的功能,如果不自己实现一些classloader,可以直接用arthas来发布更新。其中分为以下几步:
1、反编译代码
arthas通过jad对源码进行反编译,将jar包中的class文件编译成java文件。
示例:
jad --source-only com.autohome.HelloService > /tmp/HelloService.java
2、修改代码
通过 vi 命令修改
3、查找该类的ClassLoader
使用sc命令查找加载修改类的ClassLoader ,运行下面命令得到ClassLoader的哈希值。
sc -d com.app.HelloService | grep classLoaderHash
返回:classLoaderHash 4f8e5cde
3、内存编译源码
arthas提供了mc命令可以在内存中编译源码,最终生成class文件。mc可以通过classloader获取class的信息。之所以要用同一个classload来加载就是因为如果被不同classloader加载就变成不同的类了。
mc -c 4f8e5cde /tmp/HelloService.java -d /tmp
4、热更新代码
redefine /tmp/com/app/HelloService.class
二、热更新原理
2.1、基础知识
2.1.1、java编译原理
java编译就是将java源码编译成字节码,字节码不是机器语言,要想让机器能够执行,还需要把字节码翻译成机器指令。jvm的工作就是这个。将源代码翻译成机器指令需要以下几个步骤:
前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成。
后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等。
.java文件编译成.class的编译过程称之为前端编译。把将.class文件翻译成机器指令的编译过程称之为后端编译。
通常通过 javac 将程序源代码编译,转换成 java 字节码。JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT( just in time , 也就是即时编译编译器)会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
热点探测方法:
1)基于采样的方式探测
周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,就认为是热点方法。好处就是简单,缺点就是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。
2)基于计数器的热点探测
采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。
在HotSpot虚拟机中使用的是第二种,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。
方法计数器:记录一个方法被调用次数的计数器。
回边计数器:记录方法中的for或者while的运行次数的计数器。
2.1.2、类加载
类加载器可以细分为:
启动(Bootstrap)类加载器:负责将 Java_Home/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。无法获取如:java.lang.String的classloader信息。
标准扩展(Extension)类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将Java_Home /lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
应用程序(Application)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统(System)加载器。
Java 类是通过 Java 虚拟机加载的,某个类的 class 文件在被 classloader 加载后,会生成对应的 Class 对象,之后就可以创建该类的实例。如果要实现热部署,最根本的方式是修改虚拟机的源代码,改变 classloader 的加载行为,使虚拟机能监听 class 文件的更新,重新加载 class 文件,这样的行为破环行很大,另一种方法是创建自己的classloader来加载需要监听的class,这样就能控制类加载的时机,从而实现热部署。目前的加载机制,称为双亲委派;
双亲委派模型:
具体的过程如下:在java.lang.ClassLoader的loadClass()方法中,先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
对于任意一个类,都需要由加载它的类加载器和这个类本身来一同确立其在Java虚拟机中的唯一性。为了保证相同的class文件,在使用的时候,是相同的对象,jvm设计的时候,采用了双亲委派的方式来加载类。
2.1.3、为什么双亲委托模型更加安全?
因为在此模型下用户自定义的类装载器不可能装载应该由父类加载器加载的可靠类(Extension加载器(jre/lib/ext)和Bootstrap加载器(rt.jar)下的class,这2个classloader加载最核心的class),从而防止不可靠甚至恶意的代码代替由父亲装载器装载的可靠代码。因为Bootstrp加载器指挥去rt.jar里面去寻找是否存在该类。Extension去ext里面查找是否加载了该类,外部过来的就直接不会加载进来。
比如用户自定义自己的一个名为String的恶意类,想要替换rt.jar下面java.lang.String,加载时,由于双亲委托模型,首先请求到App ClassLoader,然后再到Extension ClassLoader,再到Bootstrap ClassLoader,由于已经加载过java.lang.String, java.lang包的String类不会再替换。
也就是重要的核心类和公共类都被Bootstrap和Extension加载了,不会被恶意类来替换这两个加载器加载的类。Application里面加载得是自己写的代码。
1)保证唯一性:试想,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的Object类,那么类之间的比较结果及类的唯一性将无法保证.
2)保证安全:由于所有的用户类都会先通过bootstrapclassloader 查看里面有没有该类资源,有则直接使用,,从而保证了底层的类一定是预先加载的,这样可以对虚拟机的安全得到了很好的保证。而在加载过程中会进行安全验证,具体请看2.1.5
为什么需要破坏双亲委派?jdbc、线程上下文类加载器
因为在某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。
如何控制自定义的classload的优先级是比较困难的。
在实现自定义classloader的时候有个问题就是同名的类没办法同时加载,只能在虚拟机停止前销毁已经加载的类,但这样classloader 就无法加载更新后的类了。所以只有一种方法可行了,就是直接修改生成的class文件。里用ASM修改class文件。
2.1.4、利用ASM修改class文件
class文件包括以下几类信息:1、类基本信息,包含了访问权限信息,类名信息,父类信息,接口信息;2、类变量信息;3、类方法信息。 ASM会加载一个class文件,然后严格顺序读取类各项信息。用户可以自定义增强修改这些信息,最后输出一个新的class。
2.1.5、类加载的过程
1)加载
2)验证
这一步保证了程序的安全性,使得Class文件注入并不影响程序的安全性和稳定性。
3)准备
4)解析
5) 初始化
热更新的时候已经加载的类都在内存中,redefine并不会去更改内存的东西,只是替换了class的内容,当该类被引用到的时候就会读取新的class文件。
2.2、Instrumentation 与 attach 机制
Arthas 热更新功能看起来很神奇,实际上离不开 JDK 一些 API,分别为 instrument API 与 attach API。
2.2.1 Instrumentation
使用这组接口,我们可以获取到正在运行 JVM 相关信息,使用这些信息我们构建相关监控程序检测 JVM。另外, 最重要我们可以替换和修改类,这样就实现了热更新。
Instrumentation 存在两种使用方式,一种为 pre-main 方式,这种方式需要在虚拟机参数指定 Instrumentation 程序,程序启动之前将会完成修改或替换类。使用方式如下:
java -javaagent:jar Instrumentation_jar -jar xxx.jar
因为在程序启动之前进行加载,所以存在一定的局限性。
另一种是agent-main 方式。在程序启动之后在运行Instrumentation程序。程序启动之后只有连上相应的应用才能做出相应的改动。
2.2.2、Attach API
Attach API 位于 tools.jar 包,可以用来连接目标 JVM。Attach API 非常简单,内部只有两个主要的类,VirtualMachine 与 VirtualMachineDescriptor
VirtualMachine 代表一个 JVM 实例, 使用它提供 attach 方法,我们就可以连接上目标 JVM。
VirtualMachine vm = VirtualMachine.attach(pid);
VirtualMachineDescriptor 则是一个描述虚拟机的容器类,通过该实例我们可以获取到 JVM PID(进程 ID),该实例主要通过 VirtualMachine#list 方法获取。
for (VirtualMachineDescriptor descriptor : VirtualMachine.list()){
System.out.println(descriptor.id());
}
三、热更新实现
arthas的实现是:在运行时,使用Instrumentation.redefineClasses方法来替换掉原来的字节码。使用的classloader是当前运行该方法的classloader。
实现热更新使用的是Instrumentation 的agent-main方式。下面介绍一个比较简单的例子;
1)实现agent-main
编写一个类,包含以下两个方法:
public static void agentmain (String agentArgs, Instrumentation inst); [1]
public static void agentmain (String agentArgs); [2]
如果两个都实现,【1】的优先级大于【2】会优先执行;
接着读取外部传入 class 文件,调用 Instrumentation#redefineClasses,这个方法将会使用新 class 替换当前正在运行的 class,这样我们就完成了类的修改。一个简单的原理实现代码:
public class AgentMain {
/**
*
* @param agentArgs 外部传入的参数,类似于 main 函数 args
* @param inst
*/
public static void agentmain(String agentArgs, Instrumentation inst) {
// 从 agentArgs 获取外部参数
System.out.println("开始热更新代码");
// 这里将会传入 class 文件路径
String path = agentArgs;
try {
// 读取 class 文件字节码
RandomAccessFile f = new RandomAccessFile(path, "r");
final byte[] bytes = new byte[(int) f.length()];
f.readFully(bytes);
// 使用 asm 框架获取类名
final String clazzName = readClassName(bytes);
// inst.getAllLoadedClasses 方法将会获取所有已加载的 class
for (Class clazz : inst.getAllLoadedClasses()) {
// 匹配需要替换 class
if (clazz.getName().equals(clazzName)) {
ClassDefinition definition = new ClassDefinition(clazz, bytes);
// 使用指定的 class 替换当前系统正在使用 class
inst.redefineClasses(definition);
}
}
} catch (UnmodifiableClassException | IOException | ClassNotFoundException e) {
System.out.println("热更新数据失败");
}
}
/**
* 使用 asm 读取类名
*/
private static String readClassName(final byte[] bytes) {
return new ClassReader(bytes).getClassName().replace("/", ".");
}
}
2)完成代码之后,我们还需要往 jar 包 manifest 写入以下属性。
## 指定 agent-main 全名
Agent-Class: com.app.AgentMain
## 设置权限,默认为 false,没有权限替换 class
Can-Redefine-Classes: true
我们使用 maven-assembly-plugin,将上面的属性写入文件中。在pom.xml文件中
com.app.AgentMain
true
3、接着使用 Attach API,连接目标虚拟机,触发热更新的代码
public class JvmAttachMain {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
// 输入参数,第一个参数为需要 Attach jvm pid 第二参数为 class 路径
if(args==null||args.length<2){
System.out.println("请输入必要参数,第一个参数为 pid,第二参数为 class 绝对路径");
return;
}
String pid=args[0];
String classPath=args[1];
System.out.println("当前需要热更新 jvm pid 为 "+pid);
System.out.println("更换 class 绝对路径为 "+classPath);
// 获取当前 jar 路径
URL jarUrl=JvmAttachMain.class.getProtectionDomain().getCodeSource().getLocation();
String jarPath=jarUrl.getPath();
System.out.println("当前热更新工具 jar 路径为 "+jarPath);
VirtualMachine vm = VirtualMachine.attach(pid);
// 运行最终 AgentMain 中方法
vm.loadAgent(jarPath, classPath);
}
}
在这个启动类,我们最终调用 VirtualMachine#loadAgent,JVM 将会使用上面 AgentMain 方法使用传入 class 文件替换正在运行 class。
四、热更新的限制
redefine只是替换了代码,并不会改变内存,和对象是否实例化没关系。实例化的对象有指针指向对应的 Class 信息, 只要 Class 里的方法字节码安全替换,实例化的对象下次就会用 Class 里最新的, 并不是每个对象都会单独存一份 Class 的字节码信息。
并不是所有改动热更新都将会成功,当前使用 Instrumentation#redefineClasses 还是存在一些限制。我们仅只能修改方法内部逻辑,属性值等,不能添加,删除方法或字段,也不能更改方法的签名或继承关系。
用 lombok 的编译不了,先看下 lombok的工作原理。使用lambok的方法如使用了@ Slf4j上传的源码会编译少个static{}。但是使用jad到处的源码时存在。使用@Data的找不到get方法。