前几篇文章主要是去理解JVM类加载的原理和应用,这一回讲一个可以自己动手的例子,希望能从头到尾的理解类加载以及执行的整个过程。
这个例子是从周志明的著作《深入理解Java虚拟机》第9章里抄来的。原作者因为有丰富的经验,可以站在一个很高的高度去描述整个过程。而我只能以现有的水平,简单的理解这个例子。
如果读者感觉不错,那都是原作者的智慧;如果觉得不过尔尔,那就是我水平有限。
先说说日志。原先,我特别不喜欢在自己的程序里输出日志。写的时候那叫一个爽,可是一旦运行出错,那就麻烦了。因为不知道具体执行到哪一步出的错,所以就要调试一大片代码。尤其是大的项目,是要经常去分析日志的。所以,我们都尽量在代码里输出详细的日志。
但是,我们不可能把所有的情况考虑到。也就是说,当程序在服务器上跑的时候,我们想查看某个运行时的状态和数据,如果没有日志输出,就无能为力。
当然,并不是真的无能为力。这篇文章就是教你一些思考,以及解决这个问题的一个思路。
说白了,要是服务器能够临时去执行一段代码,输出日志,问题迎刃而解。有了前面类加载的知识,我们应该会想到:我们自己写一个类,然后动态加载到服务器的JVM进程的方法区,最后反射调用输出日志的那个方法。
但是,仔细想想,需要考虑的事情还有许多:
1)这个类可能会经常的被修改,经常的被加载,所以,执行完之后,要能够从方法区卸载。而能够被卸载的条件之一,就是它的类加载器被回收。之前已经加载了多个类的类加载器,是不可能那么快被回收的。所以,这里要自定义一个类加载器去加载待执行的类。
2)待执行的类要能够访问原来项目里的类,比如说WEB-INF下面的那些类。那怎么办呢?就要用到双亲委派模型了,将自定义类加载器的父类加载器设置为加载这个类加载器的类加载器。听起来有点绕,没关系,直接上代码
/** * 为了多次载入执行类而加入的加载器<br> * 把defineClass方法开放出来,只有外部显式调用的时候才会使用到loadByte方法 * 由虚拟机调用时,仍然按照原有的双亲委派规则使用loadClass方法进行类加载 * * @author zzm */ public class HotSwapClassLoader extends ClassLoader { public HotSwapClassLoader() { // 设置父类加载器,用以访问JVM进程中的原来的类 super(HotSwapClassLoader.class.getClassLoader()); } /** * 加载待执行的类 */ public Class loadByte(byte[] classByte) { return defineClass(null, classByte, 0, classByte.length); } }
3)待执行类的方法里面的日志输出到哪里?你可能脱口而出,System.out.println()。但是System.out是标准输出,是整个JVM进程的资源,也不利于查看。也许,你会想通过System.setOut()指定一个文件作为输出。可是,一旦设定,那以后整个JVM进程的输出都会写到这个文件里面,这样就影响了原来的程序,这不是我们想要的。所以,我们必须写一个类来代替System类的作用。
/** * 为JavaClass劫持java.lang.System提供支持 * 除了out和err外,其余的都直接转发给System处理 * * @author zzm */ 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); } // 下面所有的方法都与java.lang.System的名称一样 // 实现都是字节转调System的对应方法 // 因版面原因,省略了其他方法 }
那就有人问了,既然能代替System类,就直接用这个类不就完了呗,也没有System类的事了?问得好,这就是下面第4点。
4)我们在客户端编写待执行类时,不能依赖特定的类;如果依赖了特定的类,就只有在能够访问到特定类的地方才能编译通过,受限制太多。也就是说,我们在写执行类时,不能用到HackSystem类,但是执行的时候,却又必须是HackSystem类。所以思路应该是这样的:在执行类里面输出时,还是用System.out,编译完成后,再去修改编译成的class文件,将常量池中java.lang.System这个符号替换成HackSystem。这里的难点是在程序中修改class文件,需要你特别熟悉class文件的每个数据项。
/** * 修改Class文件,暂时只提供修改常量池常量的功能 * @author zzm */ public class ClassModifier { /** * Class文件中常量池的起始偏移 */ private static final int CONSTANT_POOL_COUNT_INDEX = 8; /** * CONSTANT_Utf8_info常量的tag标志 */ private static final int CONSTANT_Utf8_info = 1; /** * 常量池中11种常量所占的长度,CONSTANT_Utf8_info型常量除外,因为它不是定长的 */ 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常量的内容 * @param oldStr 修改前的字符串 * @param newStr 修改后的字符串 * @return 修改结果 */ public byte[] modifyUTF8Constant(String oldStr, String newStr) { int cpc = getConstantPoolCount(); int offset = CONSTANT_POOL_COUNT_INDEX + u2; for (int i = 0; i < cpc; i++) { int tag = ByteUtils.bytes2Int(classByte, offset, u1); if (tag == CONSTANT_Utf8_info) { int len = ByteUtils.bytes2Int(classByte, offset + u1, u2); offset += (u1 + u2); String str = ByteUtils.bytes2String(classByte, offset, len); if (str.equalsIgnoreCase(oldStr)) { 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; } /** * 获取常量池中常量的数量 * @return 常量池数量 */ public int getConstantPoolCount() { return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2); } } /** * Bytes数组处理工具 * @author */ public class ByteUtils { 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; } 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; } public static String bytes2String(byte[] b, int start, int len) { return new String(b, start, len); } public static byte[] string2Bytes(String str) { return str.getBytes(); } 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; } }
最后,来看看实现替换符号引用以及得到输出日志的类
/** * JavaClass执行工具 * * @author zzm */ public class JavaClassExecuter { /** * 执行外部传过来的代表一个Java类的Byte数组<br> * 将输入类的byte数组中代表java.lang.System的CONSTANT_Utf8_info常量修改为劫持后的HackSystem类 * 执行方法为该类的static main(String[] args)方法,输出结果为该类向System.out/err输出的信息 * @param classByte 代表一个Java类的Byte数组 * @return 执行结果 */ public static String execute(byte[] classByte) { HackSystem.clearBuffer(); ClassModifier cm = new ClassModifier(classByte); byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem"); HotSwapClassLoader loader = new HotSwapClassLoader(); Class clazz = loader.loadByte(modiBytes); try { 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(); } }
传进来待执行类的class文件的字节数组,先将符号替换,然后加载该类,反射调用该类的main方法,最后将HackSystem类收集到的输出日志返回。
为了更直观的看到运行的结果,可以写一个jsp文件,通过浏览器去访问。
<%@ page import="java.lang.*" %> <%@ page import="java.io.*" %> <%@ page import="org.fenixsoft.classloading.execute.*" %> <% 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>"); %>
这里将待执行类TestClass.class放到服务器的C盘。只要TestClass里面main方法,有调用System.out,就可以将输出内容展现到页面上。我自己在Tomcat上面的项目里也测试了一把,现在把代码也贴出来
public class TestClass { public static void main(String[] args) { System.out.println("hello world!!!"); ClassLoader cl = TestClass.class.getClassLoader(); System.out.println("self: " + cl); while (cl.getParent() != null) { System.out.println(cl.getParent().getClass()); cl = cl.getParent(); } } }
大家可以那我这个类去试一试,而且还可以根据输出结果去温习一下Tomcat的类加载体系。
整体流程讲完了,感觉还是很烧脑。不经意间,我们就充当了一回黑客,将系统类的调用变成了调用我们自己的逻辑。Java引入JVM的目的就是提高灵活性,可以动态的运行,但是也引入了一定的安全问题。
回想整个流程,其实也有可替代的方案。比如jdk1.6引入了动态编译,可以在运行时动态的编译和执行我们的待执行类,但还是依赖了特定类。
我这里只是抛砖引玉,推荐大家去看原作者的书,去看看更详细的讲解。