所谓的字节码增强,即通过程序对.class
文件进行修改。因为字节码较java代码更加统一化,所以修改.class
文件也更加的方便.
增强目标:在每次使用
new
关键字创建实例后,调用提前写好的native函数,将实例的引用o
、变量名objName
、new
的java代码行数lineNumber
作为参数传入。这个navtive函数长这个样子:
1
2
3
4
5 public class NativeC {
public static native void newObj(Object o ,int lineNumber,String objName);
public static native void newArr(Object o ,int lineNumber,String objName);
}需要处理的问题:
- 找到
new
关键字的位置及其在java代码中的行数- 找到变量名
- 插入函数调用,调用native函数。
基于栈的指令-字节码指令
在广大的语言世界中,有两种指令集。一种是像汇编一样的基于寄存器的指令,另一种是像字节码这样的基于栈的指令。
在字节码指令集中,有的指令后需要操作数(有的操作数是一个字节,有的是两个字节甚至更多),而有的不需要。几乎所有的字节码指令都会对操作栈产生影响:或是将一些数据压入栈中,或是将数据弹出,或是弹出一些数进行操作后再将新结果压入栈中。这不废话嘛
什么是操作数栈?
操作数栈是一种栈,栈中存储着一些数据,即操作数。它是栈帧中的一个数据结构。起到了和汇编中寄存器一样的作用。
个人感觉,基于栈的指令还是很好懂的。(不过在找到官方的字节码指令文档之前还是很迷糊,文档中给出了每条指令对操作栈的影响)
以下是一些字节码指令的例子。
字节码指令
iconst_1
当调用字节码指令
iconst_1
时,会将常量1压栈iadd
当调用字节码指令
iadd
时,会将栈顶两个数据弹栈,如果都是int型变量则会进行加法运算,最后将运算结果压入栈中。详细的介绍可以看《深入理解Java虚拟机》8.5.3基于栈的解释器执行过程。
调用函数的字节码指令
invokedynamic
invokeinterface
invokespecial
invokestatic
invokevirtual
其中
invokespecial
是调用构造函数的指令 (也可以调用一些其他的特殊函数 )invokestatic
指令则可以调用静态函数。这两个函数后都要跟两字节的操作数,用来表示被调用函数在常量池中的索引。同时,如果想要成功调用函数,需要保证操作栈栈顶有调用函数所需的参数。
invokespecial
调用函数前要依次将,调用该函数的对象的引用、所有的函数参数(从左到右)压栈。
实例在new之后并不会初始化,只是分配了一块空间,还需要调用<init>函数(即构造函数),该函数由
invokespecial
指令调用invokestatic
调用函数前要依次将,从左到右所有的函数参数压栈。
其他的一些会用到的指令
dup
将一个和操作栈栈顶相同的数据压栈。
new
与newarray
与anewarray
与multianewarray
new指令会将新生成的对象引用压栈。
newarray指令会将长度出栈,数组引用压栈。创建基本类型数组时调用。
anewarray创建引用类型数组时调用。
multianewarray创建多维数组时调用。
astore
与aload
这两条指令会影响操作栈和本地变量表。非匿名对象都会存在本地变量表中。
astore
后跟一个大于3的单字节操作数,执行后将弹出栈顶数据,存入指定位置的本地变量表。小于等于3的使用
astore_0
,astore_1
,astore_2
,astore_3
。
aload
后跟一个大于3的单字节操作数,执行后将指定位置的本地变量表存入栈顶。小于等于3的使用
aload_0
,aload_1
,aload_2
,aload_3
一些字节码和java代码对应的例子。
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
1 | 0 invokestatic #3 <java/lang/Thread.currentThread> |
1 | 0 new #3 <T> |
1 | 0 new #3 <T> |
Class文件的结构
.class文件是将java文件编译后生成的,其中包含了类的各种信息。详细内容可见《深入理解JAVA虚拟机》第六章内容。
根据本次增强目标,我们需要在每一个实例创建后,调用提前写好的static native方法 newObj
(该方法作为NativeC.java的静态函数,调用时需要在常量池中可以找到),并将该实例作为函数参数传入,只需要了解Class文件中方法表 、常量池的部分即可。
关于方法表的要求
方法表项是个复杂的结构,其包含如访问标记、描述符、属性表集合等信息,而我们的字节码就存储在属性表中。属性表中提供给方法表使用的属性有很多,其中我们需要使用到的为Code。
code属性
code属性中有很多信息,其中我们涉及到的有:max_stack(方法中允许使用的操作栈层数),LocalVariableTable(方法中的局部变量表),以及最重要的code(字节码)。
要求
- 因为需要自己插入新的代码,会影响操作栈层数,必须保证max_stack够
- 需要找到新实例生成的代码,并用到该实例,调用native方法,需要清楚字节码指令的执行方式。
关于常量池的要求(其实在后续代码中也没有用到常量池,javassist工具偷偷的修改了文件的常量池)
假设待增强文件为A.class,如果想要能够在A的代码中调用其他类的函数,需要将这个函数的字符引用加入到常量池中(即CONSTANT_Methodref_info),而因为该常量需要存有两个引用,一是指向声明该函数的类的描述符(即CONSTANT_Class_info),二是指向名称及类型描述符(即CONSTANT_NameAndType),所以理论上如果要能调用该native函数需要添加这三项到常量池。
下图为函数的字符引用信息,通过jclasslib工具得到,右侧上下分别为声明该函数的类的描述符和指向名称及类型描述符。#64,#65代表他们在常量池中的索引位
插桩方案
使用javassist工具,遍历方法中的字节码,找到创建实例的指令。
调整操作栈顶的数据,使得可以调用native函数
插入字节码指令。