一.背景

在深入理解Java虚拟机的过程中,理解java程序在虚拟机层次如何执行十分重要。了解了深层次的东西,才可以实现一般情况下做不到的特殊功能,而这种特殊功能面向的对象往往是程序员本身。下面我们通过一个实例进行学习。

二.需求

已有一个编译好的class文件,这个文件中只有一个类,并且有一个main方法。这个方法中调用了System.out.println()输出了一些信息。现在我们想运行这个程序,确切的说是调用这个文件的main方法,将输出的信息打印到一个文件中,但是与此同时,为了不影响其他程序的正常输出,不能改变System的标准输出对象。此外,我们还希望在向文件中输出信息时,可以在每条信息前加上序号。另外我们没有这个class文件的源代码。

三.思路

要运行这个类的main方法,可以使用反射的方式。难点在于标准输出重定向,如果直接使用System.setOut()方法,会改变System的标准输出对象,因此不能采用。
这里通过一个偷梁换柱的方式,直接修改class文件,将对System类的调用指向我们自己定义的HackSystem类,并重新封装一个PrintStream类作为HackSystem的out对象,从而实现添加行号的功能。

四.实现

字节工具类


package main;import java.io.UnsupportedEncodingException;import javax.xml.stream.events.StartDocument;public class BytesUtil
{   /*** * @param b 字节数组高位在前,第0个字节是最高位字节* @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;// 考虑到一个字节八位,将高位字节的值左移 右侧字节个数*8位n <<= (--len) * 8;sum += n;}return sum;}/*** 将value用len长度的字节数组表示,要求value为无符号整数,字节数组高位在前* @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++){//从低位到高位填充字节数组//只考虑无符号情况,不考虑value为负b[len-1-i] = (byte) (value >>> (8*i));}return b;}/*** 返回字节数组UTF-8解码后的字符串* @param b* @param start* @param len* @return*/public static String bytes2String(byte[] b,int start,int len){try{return new String(b,start,len,"UTF-8");} catch (UnsupportedEncodingException e){e.printStackTrace();return null;}}/*** * @param string* @return*/public static byte[] string2Bytes(String string){try{return string.getBytes("UTF-8");} catch (UnsupportedEncodingException e){e.printStackTrace();return null;}}/*** 用给定的字节数组替换指定字节数组中的部分字节* @param src* @param offset* @param length* @param replaceBytes* @return*/public static byte[] replaceBytes(byte[] src,int offset,int length,byte[] replaceBytes){   //计算替换后长度,建立新数组byte[] newBytes = new byte[src.length-length+replaceBytes.length];//前System.arraycopy(src, 0, newBytes, 0, offset);//中System.arraycopy(replaceBytes,0,newBytes,offset, replaceBytes.length);//后System.arraycopy(src, offset+length, newBytes, offset+replaceBytes.length,src.length-offset-length);return newBytes;}
}

修改class文件工具类


package main;public class ClassModifier
{private static final int CONSTANT_POOL_COUNT_INDEX = 8;private static final int CONSTANT_UTF8_INFO = 1;//相应tag的常量池中的数据结构对应的字节数,-1表示扫描常量池时不使用该tag的长度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[] classBytes;public ClassModifier(byte[] classBytes){this.classBytes = classBytes;}public byte[] modifyUTF8Constant(String oldStr,String newString){byte[] newBytes = classBytes;//读取常量池长度int len = BytesUtil.bytes2Int(classBytes, CONSTANT_POOL_COUNT_INDEX, u2);int index = CONSTANT_POOL_COUNT_INDEX+u2;for (int i = 0; i < len; i++){int tag = BytesUtil.bytes2Int(classBytes, index, u1);if(tag == CONSTANT_UTF8_INFO) //是UTF-8类型变量{   int oldStringLength = BytesUtil.bytes2Int(classBytes, index+u1, u2);String content = BytesUtil.bytes2String(classBytes, index+3, oldStringLength);System.out.println("utf8常量:"+content);if(content.equalsIgnoreCase(oldStr)){   //发现目标//新字符串字节byte[] newStringBytes = BytesUtil.string2Bytes(newString);//新字符串长度字节byte[] newLengthBytes = BytesUtil.int2Bytes(newStringBytes.length, u2);//替换长度newBytes = BytesUtil.replaceBytes(classBytes, index+1,2, newLengthBytes);//替换字符串newBytes = BytesUtil.replaceBytes(newBytes, index+3,oldStringLength, newStringBytes);break;}index += (3+oldStringLength);}else {//其他类型常量,直接跳过index+=CONSTANT_ITEM_LENGTH[tag];}}//如果没找到目标字符串,直接返回原始字节数组return newBytes;}
}

用来替换System类的HackSystem


package main;import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintStream;public class HackSystem
{   public static PrintStream out ;static{try{out = new MyPrintStream(new File("C:/Users/Administrator/Desktop/out.txt"));} catch (FileNotFoundException e){e.printStackTrace();}}
}

自定义的类加载器,主要目的是开放出defineClass方法,将.class文件转化为一个Class对象


package main;public class MyClassLoader extends ClassLoader
{public MyClassLoader(){super(MyClassLoader.class.getClassLoader());}public Class loadByte(byte[] classBytes){   return defineClass(null,classBytes, 0,classBytes.length);}
}

自定义PrintStream


package main;import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintStream;public class MyPrintStream extends PrintStream
{int i = 0;public MyPrintStream(File file) throws FileNotFoundException{super(file);}@Overridepublic void println(String string){   super.println((++i)+"."+string);}
}

主类


package main;import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;public class Main
{   public static void main(String[] args){System.out.println("第一行");System.out.println("第二行");System.out.println("第三行");}public static void exec() throws IOException{   InputStream inputStream = new FileInputStream("C:/Users/Administrator/Desktop/Main.class");byte[] classBytes = new byte[inputStream.available()];inputStream.read(classBytes);inputStream.close();//偷梁换柱ClassModifier classModifier = new ClassModifier(classBytes);classBytes = classModifier.modifyUTF8Constant("java/lang/System","main/HackSystem");//输出查看OutputStream outputStream = new FileOutputStream("C:/Users/Administrator/Desktop/Main2.class");outputStream.write(classBytes);outputStream.flush();outputStream.close();//MyClassLoader loader = new MyClassLoader();Class clazz = loader.loadByte(classBytes);try{Method method = clazz.getMethod("main",new Class[]{String[].class});method.invoke(null,new String[]{null});} catch (NoSuchMethodException e){// TODO Auto-generated catch blocke.printStackTrace();} catch (SecurityException e){// TODO Auto-generated catch blocke.printStackTrace();} catch (IllegalAccessException e){// TODO Auto-generated catch blocke.printStackTrace();} catch (IllegalArgumentException e){// TODO Auto-generated catch blocke.printStackTrace();} catch (InvocationTargetException e){// TODO Auto-generated catch blocke.printStackTrace();}}}

五.操作流程

首先,我们将main方法这么写

    public static void main(String[] args){System.out.println("第一行");System.out.println("第二行");System.out.println("第三行");}

然后编译,生成Main.class文件,将这个文件复制到桌面,路径为C:/Users/Administrator/Desktop/Main.class。
之后将main方法改为如下

public static void main(String[] args){
//      System.out.println("第一行");
//      System.out.println("第二行");
//      System.out.println("第三行");try{exec();} catch (IOException e){e.printStackTrace();}}

直接运行,获得输出结果
控制台内容如下

utf8常量:main/Main
utf8常量:java/lang/Object
utf8常量:《init》 //实际上是左右尖括号,不知为何markdown转义不了
utf8常量:()V
utf8常量:Code
utf8常量:LineNumberTable
utf8常量:LocalVariableTable
utf8常量:this
utf8常量:Lmain/Main;
utf8常量:main
utf8常量:([Ljava/lang/String;)V
utf8常量:java/lang/System

out.txt内容

1.第一行
2.第二行
3.第三行

很显然,目标达成

六.原理分析


1.常量池全限定类名替换

首先,我们需要知道,.class文件中存在一个常量池,这个常量池中存在11种类型的数据,包括一个UTF-8编码的字符串,五种字面量,五种符号引用。它们的结构如下图:

此外,常量池总是在class文件的第八个字节开始,并首先使用两个字节表述常量池中数据项的数量,之后则是各个数据项。
了解常量池的结构以后,我们就可以找到字符串为java/lang/System的UTF-8类型常量,并将其替换为main/HackSystem
完成这一操作的类是ClassModifier,需要注意的是,在《深入理解Java虚拟机第三版》一书中,作者给出的代码在ClassModifier类中有这样一段:

private static final int[] CONSTANT_ITEM_LENGTH = {-1,-1,5,-1,5,9,9,3,3,5,5,5,5};

我在参考常量池数据结构后,改为

    private static final int[] CONSTANT_ITEM_LENGTH = {-1,-1,-1,5,5,9,9,3,3,5,5,5,5};

大家可以思索一下有无道理,如有疑问欢迎指正。
在ClassModifier的方法modifyUTF8Constant(String oldStr,String newString)中,我们首先读取了常量池的数据个数

//读取常量池长度int len = BytesUtil.bytes2Int(classBytes, CONSTANT_POOL_COUNT_INDEX, u2);

之后扫描所有常量池数据,根据tag判断是否是UTF-8类型,不是的话移动相应长度索引

//其他类型常量,直接跳过
index+=CONSTANT_ITEM_LENGTH[tag];

如果是,打印该字符串(仅仅是查看一下而已,实际没啥用),并判断该字符串是不是我们要找的全限定类名,如果是,则进行替换

if(tag == CONSTANT_UTF8_INFO) //是UTF-8类型变量
{   int oldStringLength = BytesUtil.bytes2Int(classBytes, index+u1, u2);String content = BytesUtil.bytes2String(classBytes, index+3, oldStringLength);System.out.println("utf8常量:"+content);if(content.equalsIgnoreCase(oldStr)){   //发现目标//新字符串字节byte[] newStringBytes = BytesUtil.string2Bytes(newString);//新字符串长度字节byte[] newLengthBytes = BytesUtil.int2Bytes(newStringBytes.length, u2);//替换长度newBytes = BytesUtil.replaceBytes(classBytes, index+1,2, newLengthBytes);//替换字符串newBytes = BytesUtil.replaceBytes(newBytes, index+3,oldStringLength, newStringBytes);break;}index += (3+oldStringLength);
}

最后返回修改后的字节数组。

2.替换后的实际效果分析

在class文件中,每个方法的字节码都存储在相应方法的Code属性属性中,有些字节码在使用时需要传递参数,这些参数很多都是常量池中常量的索引,因此,我们替换了常量池中System类的全限定类名,就等于把Main类中所有对System类的调用转化成对HackSystem类的调用。
我们首先使用javap来看一下Main.class修改前的内容。
这是main方法的内容

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 #16 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #22 // String 第一行
5: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #30 // String 第二行
13: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
19: ldc #32 // String 第三行
21: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: return

以`

System.out.println("第一行");

这句代码为例,首先访问了System类的静态成员out,之后使用invokevirtual调用了虚方法println(),并传入了字符串作为参数 。关键的地方是,在getstatic字节码中,传入了一个指向常量池中field类型数据的索引,在field数据中,会有一个字段指向这个field所属的class类型数据,在class类型数据中,又会有一个字段指向一个UTF-8类型常量,这个字段就是这个类的全限定类名。getstatic字节码执行的过程实际就是对out字段的解析过程。解析就是把class文件中的符号引用在内存中的直接引用。在字段解析过程中,首先需要完成对类的符号解析。如果该类不是一个数组,虚拟机会把全限定类名交给类加载器去加载相应的类, 之后检查类的访问权限,如果访问权限不满足,则会报错。类解析完毕后,会在解析好的类中查找简单名称描述符和待解析字段相同的字段,如果找到,则返回该字段的直接引用,解析成功,否则会在父类和接口中查找。
这里有一个重要的知识点,字段的描述符是什么?其实就是由该字段的类型来决定,如果是方法的描述符的话,则由方法的参数列表和返回值决定。
那么我们自定义的HackSystem中的out字段,字段类型也必须是PrintStream才行,如果改成MyPrintStream,则会出错。
下面看看替换后的结果,是不是改变了getstatic字节码最终使用的全限定类名。

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 #16 // Field main/HackSystem.out:Ljava/io/PrintStream;
3: ldc #22 // String 第一行
5: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #16 // Field main/HackSystem.out:Ljava/io/PrintStream;
11: ldc #30 // String 第二行
13: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: getstatic #16 // Field main/HackSystem.out:Ljava/io/PrintStream;
19: ldc #32 // String 第三行
21: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: return

可以看到,最终要解析的类的全限定类名已经改变。

三.方法重写的本质

我们的out字段实际上已经指向我们自定义的MyPrintStream对象了,在执行的时候,为什么执行的是我们重写的方法,而不是原来的println呢?
首先引入静态类型和实际类型的概念,静态类型就是字段被声明的类型,编译器可知,而实际类型则是字段所指向的对象的类型。
另外介绍非虚方法和虚方法,非虚方法包括静态方法,private方法,init方法,父类方法和final修饰的方法,这些方法在解析阶段就可以确定其直接引用,而不会被覆盖,其他的如public实例方法则属于虚方法,可能被覆盖,只有运行期才能知道其直接引用,说白了就是到底调用哪个println。
注意到字节码invokevirtual,这个指令是调用虚方法,会找到操作数栈顶的第一个元素所指向的实际类型,实际上就是找到我们自定义的那个MyPrintStream,如果在实际类型中找到方法名称和描述符均符合的方法,且通过访问权限校验,则返回该方法的直接引用,否则去父类中查找,这就是方法重写的本质,也是我们重写的println被成功调用的原因。

四.进一步扩展

那么我们可以更进一步,假如我们不让MyPrintStream类继承PrintStream类,直接把out声明为MyPrintStream,有没有办法能够成功实现功能呢?当然有,实现的关键在于成功通过字段解析,字段解析会对描述符进行校验,那么我们只要把原来的描述符Ljava/io/PrintStream替换为Lmain/MyPrintStream即可,更确切地说,是把值为Ljava/io/PrintStream的UTF-8常量替换为Lmain/MyPrintStream

深入理解虚拟机实战:修改class文件实现System标准输出重定向相关推荐

  1. 虚拟机使用的是此版本 VMware Workstation 不支持的硬件版本。 模块“Upgrade”启动失败。 未能启动虚拟机(修改.vmx文件virtualHW.version = “xx“字段)

    1.找到并通过记事本程序打开虚拟机的.vmx文件: 2.找到文件中的virtualHW.version = "19"字段,修改该字段为virtualHW.version = &qu ...

  2. Linux 基础I/O :文件描述符,重定向,文件系统,软链接和硬链接,动态库和静态库

    文件描述符 重定向 文件系统 软链接和硬链接 动态库和静态库 文件描述符 上面两个接口分别是c语言的fread接口和linux的read接口,当我们在使用的时,可能会有疑问,为什么linux的io接口 ...

  3. vm怎么修改虚拟机设置选项高级文件位置配置

    初学Linux的时候,虚拟机放在了固态硬盘G盘中,但是由于后来的学习,G盘内存不够.于是想将虚拟机迁移到H盘,但是迁移了vmk和iso镜像后,虚拟机仍无法打开.在仔细检查后,发现在虚拟机设置中,有一个 ...

  4. Docker小白入门教程--docker理解与实战(懵逼三连--Docker是什么,为什么要使用Docker,如何使用Docker?)

    站在巨佬的肩膀上,才能看的更远!(没错这篇文章就是大量援引国内外网上各位大佬的博客,外加我这个小菜初入docker的一些理解,整理一下所学,也是为了方便我自己在暑假过完后,被别人问起docker问到我 ...

  5. java怎编写么解析一个类型_DAY3:你必须知道的java虚拟机之类篇——类文件的结构...

    马上过年啦,不知道大家今年有没有投资基金股票呢?是赚的盆满钵满还是拍断大腿,可以评论区一起交流交流,秀一秀哈哈,反正我是没来得及上车. 暴富西不可能暴富的啦,打工人嘛几能写写文章啦-记得点赞➕关注呀 ...

  6. linux查找最近访问的文件,教您在Linux系统中查找最近修改的文件/文件夹

    如果您使用Linux系统进行日常操作,则主目录文件将随时间急剧增加.如果您有成千上万个文件,很可能不记得最近更改的文件名,本文将教您在Linux系统中查找最近修改的文件/文件夹.另外,如果要检查出于任 ...

  7. 虚拟机中安装GHO文件配置说明

    前言: 我们知道如何使用虚拟机安装ISO的文件,这个比较简单:但是我们如果安装GHO的文件呢?工作中有的时候需要还原客户环境的系统镜像去寻找问题,这就使用到GHO的文件系统镜像了.那么我们该如何将GH ...

  8. linux git 修改文件,关于linux:Git删除所有未修改的文件

    我在Linux平台的项目中使用git. 我在特定目录中有很多文件. 我在该目录中修改了上面的约50个文件,但未暂存并提交它. 我要从该目录删除所有其他未修改的文件吗? 有没有办法使用git和Linux ...

  9. Mac OS系统修改Hosts文件的方法

    一.直接修改 1.1打开 Finder,然后点击菜单栏中的 前往--前往文件夹(或者快捷键 Shift+Command+G) 1.2在路径中输入 /private,进入之后在 etc 文件夹上点击右键 ...

最新文章

  1. MySQL高效分页解决方案集
  2. 机器学习:范数及代码实现
  3. AngularJs+bootstrap搭载前台框架——准备工作
  4. 作为一名前端开发工程师,你必须掌握的WEB模板引擎:Handlebars
  5. 「长文」2022年企业数字化转型的八大趋势
  6. hadoop fs –ls /hbase 无内容_Hadoop大数据实战系列文章之HDFS文件系统
  7. STM32学习——高级定时器
  8. 家里的所有网线都集中到了弱电箱怎么组网?
  9. 机器人弹古筝图片_古筝维修:40年的牡丹牌古筝,动了个外科手术
  10. linux下复制catalog信息指令,基於Linux下catalog方式的 Oracle 備份策略(RMAN)
  11. Linux电源管理系统架构和驱动(1)-Linux电源管理全局架构
  12. webshell流量隐蔽CS和MSF联动
  13. 遥感数据处理之哨兵5P数据处理
  14. 对待事物,乐观积极。
  15. latex表格水平垂直居中
  16. MicroBlaze系列教程(2):AXI_INTC的使用
  17. android /system/lib/ so库解析
  18. Android实战技巧之四十七:不用预览拍照与图片缩放剪裁
  19. 蓝牙4.0--Android BLE(二)BleLib开源库
  20. android 9.0 添加自定义恢复出厂设置标识

热门文章

  1. YC创始人格雷厄姆:如何才能发现创业思路?
  2. c语言实现的异或加解密,C语言实现视频异或加/解密
  3. UDA:万亿级碎片化数据价值市场的撬动者
  4. 《沉默的云》.读书笔记(一)
  5. C++调用微软TTS实现文字有声阅读
  6. 美标C70600镍白铜无缝管 锻件C70600与其他美标白铜性能对比
  7. 【docker】镜像的导出保存及载入
  8. Linux下的tmp目录:
  9. Aegisub时间码格式详解
  10. 修改pip为国内的源