JVM之类加载及执行子系统的案例与实战(九)

时间:2022-12-25 14:23:18

在Class文件格式与执行引擎这部分中,用户能直接影响的内容并不多。能通过程序进行操作的,主要是字节码生成和类加载器这两部分功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为了许多常用的功能和程序实现的基础。
关于类加载器和字节码案例:
一、正统的类加载器架构
主流的java Web服务器如tomcat、Jetty、WebLogic、WebSphere等,都实现了自己定义的类加载器(一般不止一个),因为一个健全的web服务器要解决如下几个问题:
》部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应当保证两个应用程序的类库可以互相独立使用。
》部署在同一个服务器上的两个Web应用程序所使用的Java类库可以相互共享。这个需求也很常见;用户可能有10个使用Spring组织的应用程序部署在同一台服务器上,如果10份都分别放在各个应用程序的隔离目录中,将会是很大的资源量费-并非磁盘浪费,而是类库被加载到内存,如果不共享,虚拟机的方法区就会很容易出现过度膨胀。
》服务器要尽可能的保证自身的安全不受不熟的Web应用程序的影响。比如服务器所使用的类库与应用程序所使用的类库相互独立。
》支持JSP应用的Web服务器,大多数都需要支持HotSwap功能。虽然JSP最终要被编译成class文件才能被虚拟机执行,但是JSP文件由于其纯文本存储的特点,其运行时修改的概率远远大于第三方类库或程序自身的class文件。像ASP、PHP和JSP这些网页应用把修改后无需重启作为一个很大的优势来看待,因此主流的Web服务器都会支持JSP生成类的热替换。(非主流生产模式下的WebLogic服务器默认不会处理JSP文件的变化)
由于存在上述问题,所以单独一个ClassPath就无法满足需求了,所以各种Web服务器都提供了好几个ClassPath路径供用户存放第三方类库,这些类库一般以lib或classes命名。不同的路径中的类库,具备不同的访问范围和服务对象,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。
图中三个黑色粗线的类加载器是JDK默认提供的类加载器,而下面的四个则是Tomcat自己定义的类加载器:CommonClassLoader(/common/)、CatalinaClassLoader(/server/)、SharedClassLoader(/shared/)、WebAppClassLoader(/WebApp/WEB-INF/) 其中WebApp和JSP类加载器通常会存在多个实例(前面的目录在tomcat6以后都合在一起放在lib中了)
Tomcat服务器的类加载架构
JVM之类加载及执行子系统的案例与实战(九)
从委托关系可以看出,CommonClassLoader能加载的类都可以被CtatlinaClassLoader和SharedClassLoader使用,而CtatlinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。WeAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离(这里面用户使用第三方库且版本可能不一样),JasperLoader的加载范围仅仅是JSP文件所编译出来的那一个Class,他出现的目的仅仅是为了被丢弃:当服务器检测到JSP文件被修改时,会替换当前的jasperLoader的实例(多个实例),并通过再建立一个新的JSP类加载器去实现JSP文件的HotSwap功能。
但对于tomcat6及以后的版本,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader 项才会真正建立CtatlinaClassLoader和SharedClassLoader实例,否则将会用到他们的地方被CommonClassLoader的实例所代替,默认配置没有设置这两项,所以把/common/、/server/、/shared/*的目录都一起变成一个/lib目录,相当于以前的/Common目录
字节码生成技术和动态代理实现请看另一篇https://blog.csdn.net/qq_26564827/article/details/80539276
自己动手实现远程执行功能
排查问题的时候希望服务器能执行临时代码,来定位或排除问题。
JDK1.6之后提供了Compiler API,可以动态地编译Java程序,虽然达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决。在JDK1.6之前也可以通过比如写一个jsp文件上传到服务器,然后再浏览器中运行它,或者在服务器端程序中加入一个BeanShell Script、javascript等的执行引擎去动态执行脚本。
这里使用类加载及虚拟机执行子系统的知识去实现在服务器端执行临时代码的功能。
程序实现的过程中需要解决三个问题:
如何编译即将提交到服务器的java代码?
一种思路:可以使用tools.jar包中的com.sun.tools.javac.Mian类来编译java文件,和使用javac命令编译一样,但是这样是引入额外的jar,并把程序绑死在sun的JDK上了,部署到其他公司的JDK中还得把tools.jar带上,然后在服务器端编译。
另一种思路:直接在客户端编译好,把字节码文件传到服务端。
如何执行编译之后的代码?
不能简单的使用类加载器加载然后反射调用某个方法(可以借助main)。因为一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复的修改、提交、执行。另外提交的类要能访问服务端的其他类库才行。还有是因为是临时代码,所以提交的java类在执行完后,应当可以卸载和回收。
如何收集Java代码执行的结果?
标准的输出设备是整个虚拟机进程的全局共享的资源,如果使用System.setOut()/System.setErr()方法把输出重向到自己定义的PrintStream对象上,但是这样会影响其他的程序。这里我们只把执行类的中对System.out的符号引用替换为我们准备的PrintStream的符号引用(替换字节码中的符号引用)。
实现:

package com.jvm.classloaderpratice;
/** * 用来操作字节码文件字节数组 修改字节码文件 * @author lr * */
public class ByteUtils {
    /** *读取 (字节码文件中)字节数组中指定字节值,返回十进制显示值 * @param b 操作字节数组对象 * @param start 字节开始的下标 * @param len 从开始下标到len的长度字节数 * @return 读取(从start开始)len长度字节数组显示十进制值并返回 */
    public static int bytes2Int(byte[] b,int start,int len){
        int sum=0;
        int end=start+len;
        for(int i=start;i<end;i++){
            int n=((int)b[i])& 0xff;
            n<<=(--len)*8;
            sum=n+sum;
        }
        return sum;
    }
    /** * 把十进制值转为字节数组 * @param value * @param len * @return */
    public static byte[] int2Bytes(int value,int len){
        byte[] b=new byte[len];
        for(int i=0;i<len;i++){
            b[len-i-1]=(byte)((value>>8*i)&0xff);
        }
        return b;
    }
    /** * 字节数组中指定位置和长度的字节转为字符串 * @param b * @param start * @param len * @return */
    public static String bytes2String(byte[]b,int start,int len){
        return new String(b,start,len);
    }
    /** * 把字符串转为字节数组 * @param str * @return */
    public static byte[] string2Bytes(String str){
        return str.getBytes();
    }
    /** * 用字节数组replaceBytes替换字节数组originalBytes中指定位置的值,并非简单的替换,因为被替换部分与替换部分长度不一样,所以原被替换部分后面的值都要往后或者往前移动 * @param originalBytes 原字节数组 * @param offset 替换originalBytes中指定部分的值开始的下标 * @param len 原originalBytes中被替换部分的长度 * @param replaceBytes 替换的内容 * @return */
    public static byte[] bytesReplace(byte[] originalBytes,int offset,int len,byte[] replaceBytes){
        byte[]newBytes=new byte[originalBytes.length+(replaceBytes.length-len)];
        System.arraycopy(originalBytes, 0, newBytes, 0, offset);
        System.arraycopy(replaceBytes, 0, newBytes, offset, replaceBytes.length);
        System.arraycopy(originalBytes, offset+len, newBytes, offset+replaceBytes.length, originalBytes.length-offset-len);
        return newBytes;
    }
}
package com.jvm.classloaderpratice;

public class ClassModifier {
    /** * Class文件中常量池的起始偏移量 开头标记+版本号 */
    private static final int CONSTANT_POOL_COUNT_INDEX=8;
    /** * CONSTANT_Uft8_info常量的tag标记 */
    private static final int CONSTANT_Utf8_info=1;
    /** * 常量池中11种常量池所占的长度,CONSTANT_Utf8_info型常量除外,因为它不是定长的 * 前面下标从0-2为负数是因为都不会用到 只有下标(tag)为1会用到,但长度不固定,所以就不能算进去,比如下标为3的tag表示CONSTANT_Integer_info它的长度为u4+u1 所以是5 以此类推 */
    private static final int[] CONSTANT_ITEM_LENGTH={-1,-1,-1,5,5,9,9,3,3,5,5,5,5};

    private static final int u1=1;
    private static final int u2=2;

    private byte[] classByte;

    public ClassModifier( byte[] classByte) {
        this.classByte=classByte;
    }

    /* * 修改常量池中CONSTANT_Utf8_info常量的内容 * * */
    public byte[] modifyUTF8Constant(String oldStr,String newStr){
        int cpc=getConstantPoolCount();
        int offset=CONSTANT_POOL_COUNT_INDEX+2;
        for(int i=0;i<cpc;i++){
            //计算tag哪一种常量池中的项
            int tag=ByteUtils.bytes2Int(classByte,offset,u1);
            if(tag==CONSTANT_Utf8_info){
                //计算CONSTANT_Utf8_info中第二项u2表示内容的长度
                int len=ByteUtils.bytes2Int(classByte,offset+u1,u2);
                //下标移到CONSTANT_Utf8_info内容部分
                offset+=(u1+u2);
                //读取CONSTANT_Utf8_info内容
                String str=ByteUtils.bytes2String(classByte,offset,len);
                if(str.equalsIgnoreCase(oldStr)){
                    //把指定的新的字符串转为字节数组,然后替换原来的部分,并把CONSTANT_Utf8_info中表示内容长度的u2项替换为新的字节数组长度,同时替换内容
                    byte[] strBytes=ByteUtils.string2Bytes(newStr);
                    byte[] strLen=ByteUtils.int2Bytes(newStr.length(),u2);
                    classByte=ByteUtils.bytesReplace(classByte,offset-u2,u2,strLen);
                    classByte=ByteUtils.bytesReplace(classByte,offset,len,strBytes);
                    return classByte;
                }else{
                    offset+=len;
                }
            }else{
                offset+=CONSTANT_ITEM_LENGTH[tag];
            }
        }
        return classByte;
    }
    public int getConstantPoolCount(){
        //常量池开头部分u2个字节表示常量池中项数+1
        return ByteUtils.bytes2Int(classByte,CONSTANT_POOL_COUNT_INDEX,u2);
    }
}
package com.jvm.classloaderpratice;

public class HotSwapClassLoader extends ClassLoader {
    private String s;

    public HotSwapClassLoader() {
        super(HotSwapClassLoader.class.getClassLoader());
    }
    public Class loadByte(byte[] classByte){
        return defineClass(null,classByte,0,classByte.length);
    }
}
package com.jvm.classloaderpratice;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.PrintStream;
/** * 把标准输出对象替换为指定的PrintStream对象,这样就可以在网页中输出 * 这个就是我们要替换的目标"java/lang/System"替换为 "com/jvm/classloaderpratice/HackSystem" * @author lr * */
public class HackSystem {
    public final static InputStream in=System.in;
    private static ByteArrayOutputStream buffer=new ByteArrayOutputStream();

    public final static PrintStream out =new PrintStream(buffer);
    public final static PrintStream err=out;

    public static String getBufferString(){
        return buffer.toString();
    }
    public static void clearBuffer(){
        buffer.reset();
    }

    public static void setSecurityManager(final SecurityManager s){
        System.setSecurityManager(s);
    }
    public static SecurityManager getSecurityManager(){
        return System.getSecurityManager();
    }
    public static long currentTimeMillis(){
        return System.currentTimeMillis();
    }
    public static void arrayCopy(Object src,int srcPos,Object dest,int destPos,int length){
        System.arraycopy(src, srcPos, dest, destPos, length);
    }
    public static int identityHashCode(Object x){
        return System.identityHashCode(x);
    }
}


方法执行引擎

package com.jvm.classloaderpratice;

import java.lang.reflect.Method;

public class JavaClassExecuter {
    public static String execute(byte[] classByte){
        HackSystem.clearBuffer();
        //替换常量池中标准输出引用符号
        ClassModifier cm=new ClassModifier(classByte);
        byte[] modiBytes=cm.modifyUTF8Constant("java/lang/System", "com/jvm/classloaderpratice/HackSystem");
        //类加载字节数组返回class对象,由于加载器是使用的HotSwapClassLoader的加载器,所以返回的对象可以访问其他对象
        HotSwapClassLoader loader=new HotSwapClassLoader();
        Class clazz=loader.loadByte(modiBytes);
        try{
            //执行main方法
            Method method=clazz.getMethod("main",new Class[]{String[].class});
            method.invoke(null, new String[]{null});    
        }catch(Throwable e){
            e.printStackTrace(HackSystem.out);
        }
        return HackSystem.getBufferString();
    }
}

需要在服务器执行的代码,用来测试排查服务器中程序的问题

package com.jvm.classloaderpratice;
public class TestClass {
 public static void main(String[] args) {
    // 要测试执行排查服务器错误的的代码
    System.out.println("okokok");
}
}

需要触发方法执行,这里是用jsp文件中嵌入java代码来自动启动方法执行引擎;
当然有更好的方法来执行,做一个插件可以随时把编译的class文件上传到服务器并且执行。

<%@ page language="java" import="java.util.*" %>
<%@ page language="java" import="java.io.*" %>
<%@ page language="java" import="com.jvm.classloaderpratice.*" %>

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
  <head>


    <title>My JSP 'test.jsp' starting page</title>

    <meta http-equiv="pragma" content="no-cache">
    <meta http-equiv="cache-control" content="no-cache">
    <meta http-equiv="expires" content="0">    
    <meta http-equiv="keywords" content="keyword1,keyword2,keyword3">
    <meta http-equiv="description" content="This is my page">
    <!-- <link rel="stylesheet" type="text/css" href="styles.css"> -->

  </head>

  <body>
       <% InputStream is=new FileInputStream("c:/TestClass.class"); byte[] b=new byte[is.available()]; is.read(b); is.close(); out.println("<textarea style='width:1000;height:800'>"); out.println(JavaClassExecuter.execute(b)); out.println("</textarea>"); %>
  </body>
</html>

JVM之类加载及执行子系统的案例与实战(九)
运行代码需要执行的操作:
1、把上面的文件全部编译成class文件,可以使用javac命令来编译,可以使用开发工具。
我这里就是使用myeclipse新建一个工程,然后建了包名和文件,保存后开发工具自动编译成class文件:E:\UniWorkspace\Test\WebRoot\WEB-INF\classes\ 下的所有文件(包含包名和class文件)。
2、把上面编译的文件包含包名(文件夹名)一块放到服务器(tomcat)webapps\正在运行的工程\WEB-INF\classes\目录下
3、把其中的TestClass.class文件放到C盘下(别的地方也行,只要上面能加载到路径)
4、把jsp文件放到服务器(tomcat)webapps\正在运行的工程\WEB-INF\(这里是可以访问到jsp页面的地方即可)
5、浏览器中访问自己写的那个jsp文件,然后就可以看出输出结果