java编译与反编译

时间:2024-02-20 17:06:35
 记录学习编译与反编译知识,并且使用cfr反编译工具,深入了解java常用语法糖
一.编程语言
二.编译
1.编译过程
2.JIT hotspot
三.反编译
四:如何防止反编译
五.反编译实践
1.switch
2.String "+"
3.lambda
4.枚举
5.自动拆装箱
6.try-with-resource
7.条件编译
8.foreach
9.变长参数
一.编程语言
编程语言一般分为低级编程语言与高级编程语言.
(1 低级语言:直接使用计算机指令编写程序,汇编语言与机器语言就属于这一种.
(2 高级语言 :使用语句(statement)编写程序,语句是计算机指令的抽象表示.
简单的理解:低级语言是计算机认识的语言,高级语言是程序员认识的语言.
而将高级语言转换为低级语言的过程称为编译.
二.编译
将便于人编写、阅读、维护的高级计算机语言所写作的源代码程序,翻译为计算机能解读、运行的低阶机器语言的程序的过程就是编译。负责这一过程的处理的工具叫做编译器.
java中的编译器:javac是jdk中的JAVA语言编译器,使用javac命令可以将以.java结尾的源文件编译成以.class结尾的能够由jvm识别的字节码.
但是如果要想由计算机来执行程序,jvm必须再将字节码转换成机器码,也被称为深层次的编译.
1.编译过程
根据完成任务不同,可以将编译器的组成部分划分为前端(Front End)与后端(Back End)。
 
(1)前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成,即将.java编译成.class 的过程。
词法分析
词法分析阶段是编译过程的第一个阶段。即从左到右一个字符一个字符地读入源程序,将字符序列转换为标记(token)序列的过程。标记是一个字符串,是构成源代码的最小单位。在这个过程中,还会对标记进行分类。
词法分析器通常不会关心标记之间的关系(属于语法分析的范畴),举例来说:词法分析器能够将括号识别为标记,但并不保证括号是否匹配。
语法分析
语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等.语法分析程序判断源程序在结构上是否正确.源程序的结构由上下文无关文法描述。
语义分析
语义分析是编译过程的一个逻辑阶段, 语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查,进行类型审查。语义分析是审查源程序有无语义错误,为代码生成阶段收集类型信息。
语义分析的一个重要部分就是类型检查。比如很多语言要求数组下标必须为整数,如果使用浮点数作为下标,编译器就必须报错。再比如,很多语言允许某些类型转换,称为自动类型转换。
中间代码生成
在源程序的语法分析和语义分析完成之后,很多编译器生成一个明确的低级的或类机器语言的中间表示。该中间表示有两个重要的性质: 1.易于生成; 2.能够轻松地翻译为目标机器上的语言。
(2)后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等,即 将.class 文件翻译成机器码的过程 。
主要由jvm解释器来进行执行,通过解释java字节码,将其翻译成对应的机器码指令,逐条读入,逐条解释翻译.但在效率上会比可执行的二进制字节码程序慢许多.为了解决效率问题,java特别引入JIT技术.
(3)编译解释:
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。
2.JIT hotspot
时间开销:一般所说的JIT比解释快,说的是上述提到的"执行编译后的代码比解释器解释执行快".实际上,编译过程比直接解释执行多了一步编译的步骤.因此对于执行一次的代码,解释执行总是比编译快的多.因此只有对多次执行的代码进行即时编译,才有正面收益.
空间开销:编译后的中间代码比较字节码的大小,膨胀比时常达到10x.从空间开销来讲,只有对多次执行的代码,才不会因为编译所有代码而造成"代码爆炸".
原理:当jvm发现某个方法或代码块运行十分频繁,便认为这段代码是 "热点代码".JIT便将热点代码进行优化后,直接编译成本地机器相关的机器码缓存起来.
HotSpot虚拟机中内置了两个JIT编译器:Client Complier和Server Complier,分别用在客户端和服务端,目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作,即查看java版本时看到 虚拟机标注的 mixed mode。

 

热点检测
目前主要的热点代码识别方式是热点探测(Hot Spot Detection),有以下两种:
(1)基于采样的方式探测(Sample Based Hot Spot Detection) :周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,就认为是热点方法。好处就是简单,缺点就是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。
(2)基于计数器的热点探测(Counter Based Hot Spot Detection)。采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。
(3)在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器,分别用于方法与循环代码块.
方法计数器:记录一个方法被调用次数的计数器。
  • 默认阈值,在client模式下为1500,server下是10000,可通过"-XX:CompilerThreshold"参数调节
  • 每次调用方法,判断是否存在本地机器码.若存在,直接执行本地机器码.若不存在,则将方法计数器+1,并判断"方法计数器和回边计数器之和"是否超过阈值.是则执行栈上替换的相应步骤.
  • 热度是会衰减的.一段时间内没有被调用,计数器减半,一般发生在GC的时候.
回边计数器:记录方法中的for或者while的运行次数的计数器;执行到"}"进行计数.
  • 默认阈值:Client下13995,Server下10700
  • 调用逻辑与方法计数器相似,但没有热度衰减.
即时编译:
一旦判定代码段是热点代码,则解释器将发送一次请求编译器,进行编译,在编译成功之前解释器仍旧运行着。等编译完成后,直接将pc寄存器中方法的调用地址进行替换,替换为编译后的方法地址。
这一过程就是 栈上替换---OSR.
三.反编译
即将已编译的编程语言还原到未编译的状态.再java中,就是将.class文件 转换成.java文件.
反编译工具:javap,jad,cfr,具体可以查看参考资料
1.javap:javap是jdk自带的一个工具,可以对代码反编译,也可以查看java编译器生成的字节码。但并没把.class文件反编译成源文件,而是一种相对易于理解的字节码文件,在一定程度上,与汇编语言类似.
2.jad:一种比较不错的反编译工具,支持用户界面操作.但已经很久没有更新,在对java7的字节码操作时偶尔会出现不支持的情况.在对java8的lambda表达式反编译时彻底失败.
3.crf:这种反编译工具的语法相对复杂很多,具备许多可选的参数设置.例如是否开启对一些java语法糖的细节解析.
四:如何防止反编译
  • 隔离Java程序
    • 让用户接触不到你的Class文件
  • 对Class文件进行加密
    • 提到破解难度
  • 代码混淆
    • 将代码转换成功能上等价,但是难于阅读和理解的形式
五.反编译实践
一直对java的语法糖比较感兴趣,这边会使用cfr反编译工具,对java常见的一些语法糖代码进行反编译,希望能对其有更深的认识.
1.switch
Java 7中,switch的参数可以是String类型了,这是一个很方便的改进。到目前为止switch支持这样几种数据类型:byte short int char String 。
(1)String
String s = "hello";
switch (s){
    case "hello":
        System.out.println("hello");
        break;
    case "world":
        System.out.println("world");
        break;
}

底层依旧是通过int实现选择,通过对hashcode进行比较,为避免hashcode碰撞,加一层equals的判断.根据结果的int值,执行原先封装在case下的语句.
(2)byte short char
如果在switch中使用上述的三种类型,反编译的结果都会显示底层依然使用int类型来实现case的选择.byte/short转换为int类型,char根据ASCII码转化成对应的int值.
byte b = 1;
    switch (b) {
        case 1:
            System.out.println(1);
            break;
        case 2:
            System.out.println(2);
            break;
    }
    short t = 3;
    switch (t) {
        case 3:
            System.out.println(3);
            break;
        case 4:
            System.out.println(4);
            break;
    }
    char c = \'a\';
    switch (c) {
        case \'a\':
            System.out.println(\'a\');
            break;
        case \'b\':
            System.out.println(\'b\');
            break;
    }
}

(3)enum
Test test = Test.TEST1;
switch (test) {
 case TEST1: 
System.out.println("test1");
 break;
 case TEST2:
 System.out.println("test2");
 break;
}

实际上利用枚举的ordinal属性在编译期内构建了一个局部变量数组,在switch比较时,根据ordinal()获取的值作为数组的下标,获取一个代表着该对象在enum类中位置的int值,从而实现switch..case分支选择.1,2代表着枚举实例声明的顺序

 

所以虽然是switch支持了上述的多种类型,但实际上,switch只支持int类型
2.String "+"
public void StringAdd(){
 String s = "hello";
 System.out.println( " world" + " hh" + "asdasdad");
 System.out.println(s + " world" + " hh" + "asdasdad");
System.out.println(s + " world" + " hh" + "asdasdad" + s);
}

只有对string类型变量使用"+"进行字符串拼接时,才会去创建一个StringBuilder对象使用append方法拼接字符串.如果直接使用字符串字面量连拼接时,会在编译期内将字符串直接编译成一个结果字符串.
3.lambda
源代码如下:
public void test() {
 List<Integer> list = new ArrayList<>(16);
 list.add(1);
 list.add(2);
 list.add(3);
 list.forEach(i -> {
 System.out.println(i);
 });
 list.forEach(i -> {
 System.out.println(i + num);
 });
}
编译结果如下:

 

实质就是编译器会根据lambda表达式生成一个私有的 命名格式为lambda$方法名$序号的函数,根据是否调用类成员变量决定是否为静态函数.
但是创建之后如何调用呢?
 
实际上由上面这段代码为lambda表达式创建了内部类,在所创建的内部类中的方法实际调用了生成的lambda函数.
限于经验有限,讲的不是很直观,而且更深入的一些分析也不是很理解.可以点击第一个参考资料,对lambda有更直观的认识.
4.枚举
实质上创建的枚举类继承自Enum类,并且是final类型.在其中声明的实例是public static final类型,并且在声明一个存放实例的数组,在静态代码块中将创建的实例放入.此外创建了两个方法对数组进行获取数组或获取实例的操作.
public enum EnumTest {
 TEST1(1),TEST2(2);
 int i = 0;
 EnumTest(int i){
 this.i = i;
 }
}
 
5.自动拆装箱
装箱:实际调用包装器的valueOf()方法;拆箱:实际使用包装器的xxxValue()方法.

Integer integer = 10; int i = integer; 编译结果如下: Integer integer = Integer.valueOf((int)10); int i = integer.intValue();
6.try-with-resource
自1.7后,运行通过在try中创建流来达到自动关闭流的作用.
File file = new File("out.xlsx");
try(BufferedReader br = new BufferedReader(new FileReader(file))){
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    // handle exception
}

编译结果如下:
File file = new File((String)"out.xlsx");
        try {
            BufferedReader br = new BufferedReader((Reader)new FileReader((File)file));
            Throwable throwable = null;
            try {
                String line;
                while ((line = br.readLine()) != null) {
                    System.out.println((String)line);
                }
            }
            catch (Throwable line) {
                throwable = line;
                throw line;
            }
            finally {
                if (br != null) {
                    if (throwable != null) {
                        try {
                            br.close();
                        }
                        catch (Throwable line) {
                            throwable.addSuppressed((Throwable)line);
                        }
                    } else {
                        br.close();
                    }
                }
            }
        }
        catch (IOException br) {
            // empty catch block
        }

 

实际上,会安全的判断流是否为空,如不为空则尝试关闭流,并且安全处理异常.
在1.9以后,允许在try之外创建流的对象,只需在try()声明流的变量,就可以保证安全的关闭流.但前提是变量必须是final修饰或者等同于final的.即成员变量必须是final,或者局部变量(等同于final)
File file = new File("out.xlsx");
BufferedReader br = new BufferedReader(new FileReader(file));;
try(br){
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    // handle exception
}
7.条件编译
1 public static void main(String[] args) {
 2     if (true) {
 3         System.out.println("true");
 4     } else {
 5         System.out.println("false");
 6     }
 7 }
 8 编译结果如下:
 9 public static void main(String[] args) {
10         boolean flag = true;
11         System.out.println((String)"true");
12  }

 

Java语法的条件编译,是通过判断条件为常量的if语句实现的。其原理也是Java语言的语法糖。根据if判断条件的真假,编译器直接把分支为false的代码块消除。通过该方式实现的条件编译,必须在方法体内实现,而无法在正整个Java类的结构或者类的属性上进行条件编译.
8.foreach
实际上对数组就是使用 普通的for循环,而对于集合则使用迭代器来进行遍历
public static void main(String[] args) {
 int[] ints = {1,2,3,4};
 List<Integer> list = Arrays.asList(1,2,3,4);
 for (int i : ints){
 System.out.println(i);
 }
 for (int i :list) {
 System.out.println(i);
 }
}

编译结果如下:

public static void main(String[] args) {
 int[] ints = new int[]{1, 2, 3, 4};
 List<Integer> list = Arrays.asList(Integer.valueOf((int)1), Integer.valueOf((int)2), Integer.valueOf((int)3), Integer.valueOf((int)4));
 Object object = ints;
 int n = object.length;
 for (int i = 0; i < n; ++i) {
   int i2 = object[i];
   System.out.println((int)i2);
 }
 object = list.iterator();
 while (object.hasNext()) {
   int i = ((Integer)object.next()).intValue();
   System.out.println((int)i);
 }
}
9.变长参数
public static void main(String[] args) {
        get(1,2,3,4,5);
    }
public static void get(int... var) {
        for(int i : var){
            System.out.println(i);
        }
}


反编译结果:
    public static void main(String[] args) {
        VarLength.get((int[])new int[]{1, 2, 3, 4, 5});
    }

    public static /* varargs */ void get(int ... var) {
        int[] arrn = var;
        int n = arrn.length;
        for (int i = 0; i < n; ++i) {
            int i2 = arrn[i];
            System.out.println((int)i2);
        }
    }

 

在方法参数声明的地方,实际声明的是一个数组参数.而在传入参数时,会新建一个与参数个数等长的数组,并将所有参数放入后传给方法.
 
参考资料: