在java的世界里,如果想做热升级,无外乎两种方案,一种是基于自定义的ClassLoader来做,比如SPI插件机制等等;而另一种则是基于java agent技术方案来做,比如全链路跟踪方案。由于在这些实现过程中,或多或少都掺杂着对字节码的运用,所以基于字节码的插桩技术,在这里也是大行其道。

说道热升级,其实包含的意思挺多的,不仅可以指类的热交换,比如类A的实现被修改了,然后想在运行时对类A的逻辑进行热替换。也可以指jar包的热部署,比如用户打了个jar包,然后部署完毕并启动,之后业务方变动了jar包中的一些逻辑,然后想在运行时对jar包进行热部署,等等诸如在运行时对逻辑进行修改的场景,我这里统一称为热升级吧。

由于热升级方案有两种且方式还不太一样,所以这里我们就一一道来。

双亲委派模型

开篇之前,需要特别说道的就是类加载器的双亲委派模型,相信很多人听过这个模型,也看过这个模型,具体图示如下:

c42aa99e-9a19-4e9c-ba57-2b460d195f32

1. findClass(loadClass)被调用

2. 进入App ClassLoader中,先检查缓存中是否存在,如果存在,则直接返回

3. 缓存中不存在,则被代理到父加载器,即Extension ClassLoader

4. 检查Extension ClassLoader缓存中是否存在

5. 缓存中不存在,则被代理到父加载器,即Bootstrap ClassLoader

6. 检查Bootstrap ClassLoader缓存中是否存在

7. 缓存中不存在,则从Bootstrap ClassLoader的类搜索路径下的文件中寻找,一般为rt.jar等,如果找不到,则抛出ClassNotFound Exception

8. Extension ClassLoader会捕捉ClassNotFound错误,然后从Extension ClassLoader的类搜索路径下的文件中寻找,一般为环境变量$JRE_HOME/lib/ext路径下,如果也找不到,则抛出ClassNotFound Exception

9. App ClassLoader会捕捉ClassNotFound错误,然后从App ClassLoader的类搜索路径下的文件中寻找,一般为环境变量$CLASSPATH路径下,如果找到,则将其读入字节数组

10. App ClassLoader调用defineClass()方法

通过上面的整体流程描述,是不是感觉双亲委派机制也不是那么难理解。本质就是先查缓存,缓存中没有就委托给父加载器查询缓存,直至查到Bootstrap加载器,如果Bottstrap加载器再缓存中也找不到,就抛错,然后这个错误再被一层层的捕捉,捕捉到错误后就查自己的类搜索路径,仅此而已。

自定义ClassLoader

基于自定义的ClassLoader的方案,比如类的热交换或者jar包的热部署等,其实质上是利用自定义的ClassLoader的这种双亲委派机制来进行操作的。遵循上面的流程,我们很容易的来实现利用自定义的ClassLoader来实现类的热交换功能:

public class CustomClassLoader extends ClassLoader {
    //需要该类加载器直接加载的类文件的基目录
    private String baseDir;
    //需要由该类加载器直接加载的类名
    private HashSet classSet;
    public CustomClassLoader(String baseDir, String[] classes) throws IOException {
        super();
        this.baseDir = baseDir;
        this.classSet = new HashSet();
        loadClassByMe(classes);
    }
    private void loadClassByMe(String[] classes) throws IOException {
        for (int i = 0; i < classes.length; i++) {
            findClass(classes[i]);
            classSet.add(classes[i]);
        }
    }
    /**
     * 重写findclass方法
     *
     * 在ClassLoader中,loadClass方法先从缓存中找,缓存中没有,会代理给父类查找,如果父类中也找不到,就会调用此用户实现的findClass方法
     *
     * @param name
     * @return
     */
    @Override
    protected Class findClass(String name) {
        Class clazz = null;
        StringBuffer stringBuffer = new StringBuffer(baseDir);
        String className = name.replace('.', File.separatorChar) + ".class";
        stringBuffer.append(File.separator + className);
        File classF = new File(stringBuffer.toString());
        try {
            clazz = instantiateClass(name, new FileInputStream(classF), classF.length());
        } catch (IOException e) {
            e.printStackTrace();
        }
        return clazz;
    }
    private Class instantiateClass(String name, InputStream fin, long len) throws IOException {
        byte[] raw = new byte[(int) len];
        fin.read(raw);
        fin.close();
        return defineClass(name, raw, 0, raw.length);
    }
}

上面这段代码,我们就实现了一个最简单的自定义类加载器,但是能映射出双亲委派模型呢?

首先点开ClassLoader类,在里面翻到这个方法:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);
                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

这个就是双亲委派模型,对应之前提到的1-8步骤。

而自定义类加载器中的findClass方法如下代码,则对应步骤9:

clazz = instantiateClass(name, new FileInputStream(classF), classF.length());

而自定义类加载器中的instantiateClass方法如下代码,则对应步骤10:

 return defineClass(name, raw, 0, raw.length);

看看,整体是不是很清晰?

基于自定义类加载器实现类的热交换

写完自定义类加载器,来看看具体的用法吧,我们创建一个类,拥有如下内容:

package com.tw.client;
public class Foo {
    public Foo() {
    }
    public void sayHello() {
        System.out.println("hello world22222! (version 11)");
    }
}

顾名思义,此类只要调用sayHello方法,便会打印出hello world22222! (version 11)出来。

热交换处理过程如下:

public static void main(String[] args) throws Exception {
        while (true) {
            runBy2();
            Thread.sleep(1000);
        }
    }
    /**
     * ClassLoader用来加载class类文件的,实现类的热替换
     * 注意,需要在swap目录下,一层层建立目录com/tw/client/,然后将Foo.class放进去
     * @throws Exception
     */
    public static void runBy1() throws Exception {
        CustomClassLoader customClassLoader = new CustomClassLoader("swap", new String[]{"com.tw.client.Foo"});
        Class clazz = customClassLoader.loadClass("com.tw.client.Foo");
        Object foo = clazz.newInstance();
        Method method = foo.getClass().getMethod("sayHello", new Class[]{});
        method.invoke(foo, new Object[]{});
    }

当我们运行起来后,我们会将提前准备好的另一个Foo.class来替换当前这个,来看看结果吧(直接将新的Foo.class类拷贝过去覆盖即可):

hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world2222! (version 2)
hello world2222! (version 2)
hello world2222! (version 2)
hello world2222! (version 2)

可以看到,当我们替换掉原来运行的类的时候,输出也就变了,变成了新类的输出结果。整体类的热交换成功。

不知道我们注意到一个细节没有,在上述代码中,我们先创建出Object的类对象,然后利用Method.invoke方法来调用类:

Object foo = clazz.newInstance();
 Method method = foo.getClass().getMethod("sayHello", new Class[]{});
 method.invoke(foo, new Object[]{});

有人在这里会疑惑,为啥不直接转换为Foo类,然后调用类的Foo.sayHello方法呢?像下面这种方式:

 Foo foo2 = (Foo) clazz.newInstance();
  foo2.sayHello();

这种方式是不行的,但是大家知道为啥不行吗?

我们知道,我们写的类,一般都是被AppClassloader加载的,也就是说,你写在main启动类中的所有类,只要你写出来,那么就会被AppClassloader加载,所以,如果这里我们强转为Foo类型,那铁定是会被AppClassloader加载的,但是由于我们的clazz对象是由CustomerClassloader加载的,所以这里就会出现这样的错误:

java.lang.ClassCastException: com.tw.client.Foo cannot be cast to com.tw.client.Foo

那有什么方法可以解决这个问题吗?其实是有的,就是对Foo对象抽象出一个Interface,比如说IFoo,然后转换的时候,转换成接口,就不会有这种问题了:

IFoo foo2 = (IFoo) clazz.newInstance();
foo2.sayHello();

通过接口这种方式,我们就很容易对运行中的组件进行类的热交换了,属实方便。

需要注意的是,主线程的类加载器,一般都是AppClassLoader,但是当我们创建出子线程后,其类加载器都会继承自其创建者的类加载器,但是在某些业务中,我想在子线程中使用自己的类加载器,有什么办法吗?

由于Thread对象中已经附带了ContextClassLoader属性,所以这里我们可以很方便的进行设置和获取:

//设置操作
Thread t = Thread.currentThread();
t.setContextClassLoader(loader);
//获取操作
Thread t = Thread.currentThread();
ClassLoader loader = t.getContextClassLoader();
Class<?> cl = loader.loadClass(className);

基于SPI实现类的热交换

SPI相信大家都听过,因为这种模型是集成在java中的,其内部机制也是利用了自定义的类加载器,然后进行了良好的封装暴露给用户。

这里我们写个简单的例子:

public interface HelloService {
    void sayHello(String name);
}
public class HelloServiceProvider implements HelloService {
    @Override
    public void sayHello(String name) {
        System.out.println("Hello " + name);
    }
}
public class NameServiceProvider implements HelloService{
    @Override
    public void sayHello(String name) {
        System.out.println("Hi, your name is " + name);
    }
}

然后我们基于接口的包名+类名作为路径,创建出com.tinywhale.deploy.spi.HelloService文件到resources中的META-INF.services文件夹,里面放入如下内容:

com.tinywhale.deploy.spi.HelloServiceProvider
com.tinywhale.deploy.spi.NameServiceProvider

然后在启动类中运行:

public static void main(String...args) throws Exception {
        while(true) {
            runBy1();
            Thread.sleep(1000);
        }
    }
    private static void runBy1(){
        ServiceLoader<HelloService> serviceLoader = ServiceLoader.load(HelloService.class);
        for (HelloService helloWorldService : serviceLoader) {
            helloWorldService.sayHello("myname");
        }
    }

可以看到,在启动类中,我们利用ServiceLoader类来遍历META-INF.services文件夹下面的provider,然后执行,则输出结果为两个类的输出结果。之后在执行过程中,我们去target文件夹中,将com.tinywhale.deploy.spi.HelloService文件中的NameServiceProvider拿掉,然后保存,就可以看到只有一个类的输出结果了。

Hello myname
Hi, your name is myname
Hello myname
Hi, your name is myname
Hello myname
Hi, your name is myname
Hello myname
Hello myname
Hello myname
Hello myname

这种基于SPI类的热交换,比自己自定义加载器更加简便,非常推荐使用。

Jar包的热部署

上面讲解的内容,一般是类的热交换,但是如果我们需要对整个jar包进行热部署,该怎么做呢?虽然现在有很成熟的技术,比如OSGI等,但是这里我将从原理层面来讲解如何对Jar包进行热部署操作。

由于内置的URLClassLoader本身可以对jar进行操作,所以我们只需要自定义一个基于URLClassLoader的类加载器即可:

public class BizClassLoader extends URLClassLoader {
    public BizClassLoader(URL[] urls) {
        super(urls);
    }
}

注意,我们打的jar包,最好打成fat jar,这样处理起来方便,不至于少打东西:

         <plugin>
                  <groupId>org.apache.maven.plugins</groupId>
                  <artifactId>maven-shade-plugin</artifactId>
                  <version>2.4.3</version>
                  <configuration>
                      <!-- 自动将所有不使用的类排除-->
                      <minimizeJar>true</minimizeJar>
                  </configuration>
                  <executions>
                      <execution>
                          <phase>package</phase>
                          <goals>
                              <goal>shade</goal>
                          </goals>
                          <configuration>
                              <shadedArtifactAttached>true</shadedArtifactAttached>
                              <shadedClassifierName>biz</shadedClassifierName>
                          </configuration>
                      </execution>
                  </executions>
              </plugin>

之后,我们就可以使用了:

 public static void main(String... args) throws Exception {
        while (true) {
            loadJarFile();
            Thread.sleep(1000);
        }
    }
    /**
     * URLClassLoader 用来加载Jar文件, 直接放在swap目录下即可
     *
     * 动态改变jar中类,可以实现热加载
     *
     * @throws Exception
     */
    public static void loadJarFile() throws Exception {
        File moduleFile = new File("swap\\tinywhale-client-0.0.1-SNAPSHOT-biz.jar");
        URL moduleURL = moduleFile.toURI().toURL();
        URL[] urls = new URL[] { moduleURL };
        BizClassLoader bizClassLoader = new BizClassLoader(urls);
        Class clazz = bizClassLoader.loadClass("com.tw.client.Bar");
        Object foo = clazz.newInstance();
        Method method = foo.getClass().getMethod("sayBar", new Class[]{});
        method.invoke(foo, new Object[]{});
        bizClassLoader.close();
    }

启动起来,看下输出,之后用一个新的jar覆盖掉,来看看结果吧:

I am bar, Foo's sister, can you catch me ?????????????
I am bar, Foo's sister, can you catch me ?????????????
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!

可以看到,jar包被自动替换了。当然,如果想卸载此包,我们可以调用如下语句进行卸载:

 bizClassLoader.close();

需要注意的是,jar包中不应有长时间运行的任务或者子线程等,因为调用类加载器的close方法后,会释放一些资源,但是长时间运行的任务并不会终止。所以这种情况下,如果你卸载了旧包,然后马上加载新包,且包中有长时间的任务,请确认做好业务防重,否则会引发不可知的业务问题。

由于Spring中已经有对jar包进行操作的类,我们可以配合上自己的annotation实现特定的功能,比如扩展点实现,插件实现,服务检测等等等等,用途非常广泛,大家可以自行发掘。

上面讲解的基本是原理部分,由于目前市面上有很多成熟的组件,比如OSGI等,已经实现了热部署热交换等的功能,所以很推荐大家去用一用。

Java Agent热

话说在JDK中,一直有一个比较重要的jar包,名称为rt.jar,他是java运行时环境中,最核心和最底层的类库的来源。比如java.lang.String, java.lang.Thread, java.util.ArrayList or java.io.InputStream等均来源于这个类库。今天我们所要讲解的角色是rt.jar中的java.lang.instrument包,此包提供的功能,可以让我们在运行时环境中动态的修改系统中的类,而Java Agent作为其中一个重要的组件,极具特色。

现在我们有个场景,比如说,每次请求过来,我都想把jvm数据信息或者调用量上报上来,由于应用已经上线,无法更改代码了,那么有什么办法来实现吗?当然有,这也是Java Agent最擅长的场合,当然也不仅仅只有这种场合,诸如大名鼎鼎的热部署JRebel,阿里的arthas,线上诊断工具btrace,UT覆盖工具JaCoCo等,不一而足。

在使用Java Agent前,我们需要了解其两个重要的方法:

/**
 * main方法执行之前执行,manifest需要配置属性Premain-Class,参数配置方式载入
 */
public static void premain(String agentArgs, Instrumentation inst);
/**
 * 程序启动后执行,manifest需要配置属性Agent-Class,Attach附加方式载入
 */
public static void agentmain(String agentArgs, Instrumentation inst);

还有个必不可少的东西是MANIFEST.MF文件,此文件需要放置到resources/META-INF文件夹下,此文件一般包含如下内容:

Premain-class                : main方法执行前执行的agent类.
Agent-class                  : 程序启动后执行的agent类.
Can-Redefine-Classes         : agent是否具有redifine类能力的开关,true表示可以,false表示不可以.
Can-Retransform-Classes      : agent是否具有retransform类能力的开关,true表示可以,false表示不可以.
Can-Set-Native-Method-Prefix : agent是否具有生成本地方法前缀能力的开关,trie表示可以,false表示不可以.
Boot-Class-Path              : 此路径会被加入到BootstrapClassLoader的搜索路径.

在对jar进行打包的时候,最好打成fat jar,可以减少很多不必要的麻烦,maven加入如下打包内容:

 <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <executions>
            <execution>
                <phase>package</phase>
                <goals>
                    <goal>shade</goal>
                </goals>
            </execution>
        </executions>
    </plugin>

而MF配置文件,可以利用如下的maven内容进行自动生成:

<plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        <configuration>
            <archive>
                <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
            </archive>
        </configuration>
    </plugin>

工欲善其事必先利其器,准备好了之后,先来手写个Java Agent尝鲜吧,模拟premain调用,main调用和agentmain调用。

首先是premain调用类 ,agentmain调用类,main调用类:

public class AgentPre {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("execute premain method");
    }
}
public class App4a {
    public static void main(String... args) throws Exception {
        System.out.println("execute main method ");
    }
}
public class AgentMain {
    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("execute agentmain method");
    }
}

可以看到,逻辑很简单,输出了方法执行体中打印的内容。之后编译jar包,则会生成fat jar。需要注意的是,MANIFEST.MF文件需要手动创建下,里面加入如下内容:

Manifest-Version: 1.0
Premain-Class: com.tinywhale.deploy.javaAgent.AgentPre
Agent-Class: com.tinywhale.deploy.javaAgent.AgentMain

由于代码是在IDEA中启动,所以想要执行premain,需要在App4a启动类上右击:Run App4a.main(),之后IDEA顶部会出现App4a的执行配置:

d359234c-e988-4dea-8aad-6dfb89d8f4df

我们需要点击Edit Configurations选项,然后在VM options中填入如下命令:

-javaagent:D:\app\tinywhale\tinywhale-deploy\target\tinywhale-deploy-1.0-SNAPSHOT.jar

之后启动App4a,就可以看到输出结果了。

execute premain method
execute main method 

但是这里的话,我们看不到agentmain输出,是因为agentmain的运行,是需要进行attach的,这里我们来操作一下。

首先,需要在premain中列出启动前的vm列表信息,这里为了方便将premain中的数据传入到main中进行计算,我们使用ThreadLocal类来进行:

public class ThreadHelper {
    public static ThreadLocal threadLocal = new ThreadLocal();
}
public class AgentPre {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("execute premain method");
        //获取当前系统中vm数量
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        ThreadHelper.threadLocal.set(list);
    }
}

可以看到,premain中,我将vm列表信息放到了ThreadLocal中。

之后,在main方法中,我们对agentmain进行attach:

public class App4a {
    public static void main(String... args) throws Exception {
        System.out.println("execute main method ");
        attach();
    }
    private static void attach() {
        File agentFile = Paths.get("D:\\app\\tinywhale\\tinywhale-deploy\\target\\tinywhale-deploy-1.0-SNAPSHOT.jar").toFile();
        try {
            VirtualMachine jvm = VirtualMachine.attach(getPid());
            jvm.loadAgent(agentFile.getAbsolutePath());
            //jvm.detach();
        } catch (Exception e) {
            System.out.println(e);
        }
    }
    private static String getPid() {
        //启动前vm数量
        List<VirtualMachineDescriptor> originlList = (List<VirtualMachineDescriptor>) ThreadHelper.threadLocal.get();
        //启动后vm数量
        List<VirtualMachineDescriptor> currentList = VirtualMachine.list();
        //差值即为当前启动的vm
        currentList.removeAll(originlList);
        //返回pid
        return currentList.get(0).id()+"";
    }
}

这里需要说明一下,VirtualMachine.attach方法是attach到指定pid号的进程上。由于我们不知道IDEA启动后,我们的vm是哪个,所以我这里就又获取了一次vm列表,然后和之前的vm列表求差值,则差值就是咱们刚启动的这个VM,返回其pid号即可。

启动app4a后,得到的结果为:

execute premain method
execute main method 
execute agentmain method

可以看到,整个执行都被串起来了。

讲到这里,相信大家基本上理解java agent的执行顺序和配置了吧, premain执行需要配置-javaagent启动参数,而agentmain执行需要attach vm pid。

看到这里,相信大家对原理部分都有了解了,那么想实现一个基于调用链跟踪的工具,也不是什么难事了吧。这里,我封装了一个基于java agent的组件,tiny-upgrade,目前正在完善,感兴趣的,可以一起完善完善。

 

 

参考文章

参考文章:谈谈Java Intrumentation和相关应用

posted on 2021-06-03 19:00  程序诗人  阅读(418)  评论(2编辑  收藏  举报