热更新原理及实践注意

首先要说明几个概念,不要混用,热部署,热加载;

热部署:就是已经运行了项目,更改之后,不需要重新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,这样就能控制类加载的时机,从而实现热部署。目前的加载机制,称为双亲委派;

双亲委派模型:

  • 三种系统提供的类加载器:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader);
  • 双亲委派模型要求除了顶层的启动类加载器外,其他的类加载器都应当有自己的父类加载器,这里一般不会以继承的关系来实现,而是使用组合的关系来复用父加载器的代码;
  • 其工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,只有父类加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载;
  • 这样的好处是Java类随着它的类加载器具备了一种带有优先级的层次关系,对保证Java程序的稳定运作很重要;
  • 实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass方法中,逻辑清晰易懂;

具体的过程如下:在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,加载需要监听改变的类,在 class 文件发生改变的时候,重新加载该类。
  • 改变创建对象的行为,使他们在创建时使用自定义 classloader 加载的 class。

在实现自定义classloader的时候有个问题就是同名的类没办法同时加载,只能在虚拟机停止前销毁已经加载的类,但这样classloader 就无法加载更新后的类了。所以只有一种方法可行了,就是直接修改生成的class文件。里用ASM修改class文件。

 

2.1.4、利用ASM修改class文件

class文件包括以下几类信息:1、类基本信息,包含了访问权限信息,类名信息,父类信息,接口信息;2、类变量信息;3、类方法信息。 ASM会加载一个class文件,然后严格顺序读取类各项信息。用户可以自定义增强修改这些信息,最后输出一个新的class。

 

2.1.5、类加载的过程

1)加载

  • 通过一个类的全限定名来获取定义此类的二进制字节流;
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;

2)验证

  • 验证是连接阶段的第一步,其目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全;编译阶段如果文件有错误就会给出提示。类似IDEA 编译的时候不通过一样。比如我在文件中写了一句语法不对的话如 int ; 就会报错如下:

 

  • 验证阶段是非常重要的,这个阶段是否严谨决定了Java虚拟机是否能承受恶意代码的攻击;
  • 校验动作:文件格式验证(基于二进制字节流)、元数据验证(对类的元数据语义分析)、字节码验证(对方法体语义分析)、符号引用验证(对类自身以外的信息进行匹配性校验);

这一步保证了程序的安全性,使得Class文件注入并不影响程序的安全性和稳定性。

3)准备

  • 正式为变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在这个方法区中进行分配;
  • 需要强调两点:这时候内存分配的仅包括类变量,而不包括类实例变量;这里所说的初始化通常情况下是数据类型的零值,真正的赋值是在初始化阶段,如果是static final的则是直接赋值;

4)解析

  • 解析阶段是虚拟机将常量池内的符号引用(如CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等7种)替换为直接引用的过程;
  • 符号引用可以是任何形式的字面量,与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中;而直接引用是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,它和虚拟机实现的内存布局相关,引用的目标必定以及在内存中存在;
  • 对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存;

5) 初始化

  • 是类加载过程的最后一步,真正开始执行类中定义的Java程序代码(或者说是字节码);
  • 初始化阶段是执行类构造器方法的过程,该方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的;
  • 方法与类的构造函数(或者说是实例构造器方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的方法执行之前,父类的方法已执行完毕;
  • 执行接口的方法不需要先执行父接口的方法,只有当父接口中定义的变量使用时父接口才会初始化,接口的实现类在初始化时也一样不会执行接口的方法;
  • 方法初始化是加锁阻塞等待的,应当避免在方法中有耗时很长的操作;

 

热更新的时候已经加载的类都在内存中,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文件中

<plugin>

<artifactId>maven-assembly-plugin</artifactId>

<version>3.1.0</version>

<configuration>

<!--指定最后产生 jar 名字-->

<finalName>hotswap-jdk</finalName>

<appendAssemblyId>false</appendAssemblyId>

<descriptorRefs>

<!--将工程依赖 jar 一块打包-->

<descriptorRef>jar-with-dependencies</descriptorRef>

</descriptorRefs>

<archive>

<manifestEntries>

<!--指定 class 名字-->

<Agent-Class>

com.app.AgentMain

</Agent-Class>

<Can-Redefine-Classes>

true

</Can-Redefine-Classes>

</manifestEntries>

<manifest>

<!--指定 mian 类名字,下面将会使用到-->

<mainClass>com.app.JvmAttachMain</mainClass>

</manifest>

</archive>

</configuration>

<executions>

<execution>

<id>make-assembly</id> <!-- this is used for inheritance merges -->

<phase>package</phase> <!-- bind to the packaging phase -->

<goals>

<goal>single</goal>

</goals>

</execution>

</executions>

</plugin>

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方法。

 

 

 

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值