在深入理解Java虚拟机一书中,在讲解类加载及其执行子系统一章中有个例子,例子对技术的总结非常的有代表性,这里记录、讲解以下。
我们知道,想要调试运行的代码,比较容易的方法是给服务器上传一个jsp,通过在jsp中的java代码来调试。书中给的例子也是通过jsp来触发,但是其可以在jsp中动态执行我们的java调试代码。通过这个例子可以很好的理解ClassLoader以及字节码结构,尤其是常量池。
先说下例子
书中的例子是,可以在不停服务的情况下,在服务端可以执行我们上传的一段Java代码,并可以获取到代码执行结果。同时还有个要求,即调试类可以被加载多次
设计、思路
- 本地编写调试的Java代码,并在本地编译成Class文件,上传到服务器
- 服务器通过自定义ClassLoader来记载调试java文件
- 调试信息的输出,采用直接修改字节码的方式,把标准输出替换自定义的输出
- 最后通过jsp来触发类的加载
实现
有四个辅助类来帮助实现这个问题,如下:
- 首先定义类加载器HotSwapClassLoader。
那么为什么要自定义类加载器呢。如果把测试类放在服务端程序的classpath,系统类加载器也是可以加载的,这个没有问题。但是需求中有一个需要能重复加载类,那系统类加载器就实现不了了。那怎么来重复加载一个类呢,其实就是用不同的类加载器实例就可以了,后面写测试jsp的时候可以看到。
package com.huyeah.jvm;
public class HotSwapClassLoader extends ClassLoader{
/**
* 设置父加载器,其实也就是系统加载器,因为HotSwapClassLoader 类是由系统加载器来加载的
*/
public HotSwapClassLoader() {
super(HotSwapClassLoader.class.getClassLoader());
}
/**
* 将defindClass暴露出来,供外部调用
* @param classByte
* @return
*/
public Class<?> loadByte(byte[] classByte){
return defineClass(null,classByte,0,classByte.length);
}
}
- 字节码修改类ClassModifier
package com.huyeah.jvm;
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;
//这块做了一个hash,对应不同常量池中的类型对应的长度,因为utf8类型的长度是不确定的,数组的下表索引对应常量池的tag
private static final int[] CONSTANT_ITEM_LENGTH = {-1,-1,-1,5,5,9,9,3,3,5,5,5,5};
//表示1个字节
private static final int u1 = 1;
//表示两个字节
private static final int u2 = 2;
//传进来的字节数组
private byte[] classByte;
public ClassModifier(byte[] classByte){
this.classByte = classByte;
}
public byte[] modifyUTF8Constant(String oldStr, String newStr){
//首先获取常量池的长度,从class文件偏移8个字节开始的2个字节表示常量池的长度
int cpc = getConstantPoolCount();
//计算常量池中常量的开始地址,也即是8 + 2(偏移+长度占用的2个字节)
int offset = CONSTANT_POOL_COUNT_INDEX + u2;
//开始一个一个遍历常量池
for(int i = 0; i < cpc; i++){
//先取tag,占用1个字节
int tag = ByteUtils.bytes2Int(classByte,offset, u1);
//比较,如果是utf8类型
if(tag == CONSTANT_Utf8_info){
//utf8类型tag后的两个字段,表示字符串的长度,因为utf8类型的长度不是固定的,所以字节码用两个字节表示长度
int len = ByteUtils.bytes2Int(classByte, offset + u1, u2);
//再计算偏移量
offset += (u1 + u2);
//根据字符串长度取出utf8字符串
String str = ByteUtils.bytes2String(classByte, offset, len);
if(str.equalsIgnoreCase(oldStr)){
//比较,如果是System类的字符引用,则替换成我们自己写的输出类
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{
//如果不是System的符号应用,则继续遍历
offset += len;
}
}else{
//修改偏移量
offset += CONSTANT_ITEM_LENGTH[tag];
}
}
return classByte;
}
public int getConstantPoolCount(){
return ByteUtils.bytes2Int(classByte, CONSTANT_POOL_COUNT_INDEX, u2);
}
}
工具类ByteUtils,工具类没什么好说的,就是普通的字节与int,String之间的相互转换
package com.huyeah.jvm;
public class ByteUtils {
/**
* 字节转换成整形
* @param b
* @param start
* @param len
* @return
*/
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;
}
}
- 自己的输出类HackSystem 这个类的主要目的就是可以在调试类中使用标准输出,并且可以获取其内容,作者是通过修改测试类的字节码,把System类的符号引用替换成自定义的类来实现的。思路非常巧妙!!
package com.huyeah.jvm;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.PrintStream;
public class HackSystem {
public final static InputStream in = System.in;
//主要是这句,将out的输出,输出到了数组输出流,这样我们可以在数组输出流中获取到内容
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 currentTimeMills(){
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);
}
}
- 最后对外提供入口类,组装逻辑JavaClassExecuter
package com.huyeah.jvm;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class JavaClassExecuter {
public static String execute(byte[] classByte) {
HackSystem.clearBuffer();
//修改字节码
ClassModifier cm = new ClassModifier(classByte);
//替换系统的System为自定义的类
byte[] modiBytes = cm.modifyUTF8Constant("java/lang/System", "com/huyeah/jvm/HackSystem");
//每次都重新实现了类加载器实例,所以可以重复的加载测试类
HotSwapClassLoader loader = new HotSwapClassLoader();
//加载,通过这个方法,避开了双亲委托模式
Class clz = loader.loadByte(modiBytes);
try {
//反射调用入口方法
Method method = clz.getMethod("main", new Class[]{String[].class});
method.invoke(null, new String[]{null});
} catch (Throwable e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return HackSystem.getBufferString();
}
}
调试java文件
这个测试文件,可以在本地javac编译好,然后上传到服务器的指定目录,jsp在加载时加载指定文件即可。 这个调试文件可以随便的编写,可以引用服务端项目的类,因为自定义类加载器的父加载器是系统类加载器,所以不会出现类找不到的情况。
我这举个例子,HelloWorld是项目中的类,将其copy出来,放到了调试类的目录中,为了是调试类能编译通过,之后就可以调用HelloWorld中的方法了。当时也可以使用反射来,我这么做的目的就是为了验证下类加载的过程。
package com.sxt.io;
import com.huyeah.jvm.HelloWorld;
public class TestClass {
public static void main(String []args) {
System.out.println("----");
System.out.println("======aa");
System.out.println("------------" + HelloWorld.a);
}
}
测试的jsp文件
<%@ page import="java.lang.*" %>
<%@ page import="java.io.*" %>
<%@ page import="com.huyeah.jvm.*" %>
<%
InputStream is = new FileInputStream("/Users/zxw/develop/server_workspace/IO_study01/src/com/sxt/io/TestClass.class");
byte[] b = new byte[is.available()];
is.read(b);
is.close();
out.println("<textarea style='width:1000;heigth=2000'>");
out.println(JavaClassExecuter.execute(b));
out.println("</textarea>");
%>
总结
从上面可以看出,这个实例主要是使用了类加载、字节码修改的技术。可以说技术偏底层,需要类加载的过程,class文件的格式。这两部分也是非常重要的内容。 如果对字节码修改部分有疑问的话,需要详细的了解下字节码文件结构,其实可以通过jdk提供的javap工具来看看class文件的结构的。 这块举个例子如下: Constant pool就是常量池,我们修改的地方也是这块。
Classfile /Users/zxw/develop/server_workspace/IO_study01/src/com/sxt/io/TestClass.class
Last modified 2019-12-17; size 470 bytes
MD5 checksum 5af1034b41c6867f7c0c783a49c7bb1a
Compiled from "TestClass.java"
public class TestClass
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#16 // java/lang/Object."<init>":()V
#2 = Fieldref #17.#18 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #19 // ----this is test class out println
#4 = Methodref #20.#21 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = String #22 // ======
#6 = Class #23 // TestClass
#7 = Class #24 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 main
#13 = Utf8 ([Ljava/lang/String;)V
#14 = Utf8 SourceFile
#15 = Utf8 TestClass.java
#16 = NameAndType #8:#9 // "<init>":()V
#17 = Class #25 // java/lang/System
#18 = NameAndType #26:#27 // out:Ljava/io/PrintStream;
#19 = Utf8 ----this is test class out println
#20 = Class #28 // java/io/PrintStream
#21 = NameAndType #29:#30 // println:(Ljava/lang/String;)V
#22 = Utf8 ======
#23 = Utf8 TestClass
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/System
#26 = Utf8 out
#27 = Utf8 Ljava/io/PrintStream;
#28 = Utf8 java/io/PrintStream
#29 = Utf8 println
#30 = Utf8 (Ljava/lang/String;)V
{
public TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String ----this is test class out println
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #5 // String ======
13: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: return
LineNumberTable:
line 5: 0
line 6: 8
line 7: 16
}
SourceFile: "TestClass.java"