美文网首页
Java中的编译与反编译

Java中的编译与反编译

作者: still_loving | 来源:发表于2018-07-27 18:34 被阅读0次

    编译

           编程语言有高级和低级之分。所谓低级语言就是指可以被计算机理解并执行的语言,例如机器语言。而高级语言计算机无法直接理解执行,所以才会有中间的“翻译”过程。在计算机发展过程中,一开始使用的都是机器语言,但是机器语言无论是阅读还是编写都极其不方便,而且一旦出现错误,很难排查,所以进一步发展出了汇编语言,相比有机器语言,它有一定的可读性,但是它仍然会有大量的指令,编写很复杂,后来出现了C语言,Java语言等等这些高级语言。

           高级语言的出现,方便了程序员的编码和代码理解,而且出现问题能够及时方便地排查问题,高级语言的语义更加倾向于人类自然语言,所以编码变得不再那么复杂。但是相应的,它的效率相对低下,因为需要有“翻译”的过程。高级语言无法被计算机直接解读,只能借助于编译器,将高级语言写出的源代码编译成计算机可以理解并执行的指令代码,这中间的转化就会浪费一定的时间。这种将高级语言转化成计算机能够理解执行的指令代码的过程就是编译。

    Java的编译介绍

           Java的编译有些不同,因为Java的特性是一次编写,到处运行。做到这种效果的主要依据就是JVM。Java的编译是分为两个阶段的,首先,利用JDK自带的编译器,将源代码经过词法分析,语法分析直至语义分析,然后就会产生一个class文件。这段过程称之为前端编译,此时产生的class文件还无法被计算机识别执行,只能算是整个编译过程中产生的一个中间产物。

           然后JVM将读取到的二进制文件进行深度编译,将其编译成与具体平台相关的指令代码,这个过程是后端编译,它主要依赖于JVM。前端编译是与操作系统平台无关的,最终生成的class文件是可以在各个JVM平台进行深度编译;而后端编译就需要跟具体操作系统平台相关了,因为JVM有不同平台的版本,可以将这种统一格式的class文件进一步深度编译,将其转换成与具体平台相关的指令代码。

    前后端编译过程.png

           对于编译器,Java内置的有javac工具,此外很多IDE工具也内置了编译工具,但是这些都是前端编译器,主要功能就是把【.java】文件编程成【.class】文件。

    词法分析器

           这个阶段是将源程序文件从左到右一个字符一个字符地读入,将字符序列转换为标记(token)序列的过程。这里的标记是一个字符串,是构成源代码的最小单位,该过程中,词法分析器还会对标记进行分类。

           词法分析器通常不会关心标记之间的关系(分析关系主要在语法分析阶段)。如:源程序中会有很多括号,但是词法分析器只是将其识别为标记,但是它并不关心这些括号是否能正确匹配(即:如果只有“{”,而没有“}”,词法分析中不会发现问题)。

    语法分析器

           它是在词法分析的基础上,将单词序列组合成各种短语,如“程序”、“语句”、“表达式”等等。语法分析能够判断源程序在结构上是否正确。

    语义分析

           该阶段是程序编译的一个逻辑阶段。它是对结构正确的源程序进行上下文有关性质的审查。它主要是审查源程序是否含有语义错误,同时也为代码的生成阶段收集类型信息。

           语义分析中的一个很重要的部分就是类型审查,比如很多语言要求数组下标必须为整数,如果使用浮点数作为下标,编译器就会报错;再比如很多程序允许某些类型之间能够进行自动转换等等。

           经历过上面的过程之后,就开始生成中间代码,中间代码具有两个很重要的性质:易于生成、能够轻松翻译成目标机器上的语言。著名的解语法糖操作就是在javac中完成的

    后端编译

           Java在经历过前端编译之后,如果需要执行编译后的class文件,需要借助于JVM,JVM会将class文件中的内容逐条翻译成机器指令,这个解释的过程就是JVM的解释器(Interpreter)的功劳。很明显,这个解释是比较浪费时间的,为了提高这种解释的效率,Java引入了JIT技术。

           虽然引入了JIT,Java仍然使用解释器进行代码解释,但是在解释的过程中,随着代码的不断执行,会识别出代码中执行比较频繁的代码段,这段代码就会被标记为“热点代码”。JIT就会将这段热点代码编译后的机器代码进行优化后,缓存起来,下次再执行到这段代码,直接跳过编译过程,使用缓存的机器码。

           HotSpot虚拟机中内置了两种JIT编译器:Client Compiler和Server Compiler。目前主流的方式就是采用其中一种编译器与解释器配合工作的方式。那为什么不将其全部编译成热点代码呢?主要是出于资源最大化利用的考虑:在程序中,不可能所有代码都是热点代码,真正频繁执行的代码只是占据很少一部分,如果将那些只执行了一遍就再也不执行的代码也进行缓存,完全就是在浪费缓存资源。另外在将代码转换成热点代码过程中是需要经过一个编译过程的,如果这种只执行一次的代码也要编译,其实也是浪费时间。

    JIT工作原理.png

    热点检测

    要想触发JIT编译,就必须满足热点代码的检测,目前主要有两种热点代码探测的方式:

    • 基于采样的方式探测(Simple Based Hot Spot Detection):周期性的检测各个线程的栈顶,如果发现某个方法经常出现在栈顶,就可以认为是热点方法。它的缺点很明显:无法精确确认一个方法的热度,另外也容易受到线程阻塞或者别的原因干扰。

    • 基于计数器的热点探测(Counter Based Hot Spot Detection):虚拟机会为每个方法,甚至是每个代码块建立计数器,统计方法和代码块的执行次数,一旦超过某个阈值就认为是热点方法,触发JIT编译。

    HotSpot虚拟机采用的就是上面第二种探测方式,准备了两个计数器:方法计数器和回边计数器。它们分别对应方法调用次数统计和代码循环执行次数统计。

    编译优化

           其实JIT除了具有缓存的功能,还会对代码做各种优化,例如:逃逸分析、锁消除、锁膨胀、方法内联、空值检查消除、类型检测消除、公共子表达式消除等等。可以搜索相关概念介绍,了解其原理,这里暂时不再赘述。

    反编译

           反编译顾名思义就是编译的逆向过程,将编译后的class文件转换成最初编写的源代码的形式,这个过程就是反编译。JDK内部有一个反编译的工具javap,此外还有jad和cfr等反编译工具,根据习惯,可以选用不同的反编译工具。反编译一般很少用到,只有在进行底层源码分析的时候才会用到。

    javap

           javap是JDK自带的一个反编译工具,但是javap与其他两个反编译工具最大的特点就是:javap反编译出来的并不是java文件,生成的反编译后的内容也不像其他两个工具那样容易理解。例如这里以JDK7中的switch新添加了对String的支持为例,我们可以看看JDK到底是如何支持String的:

    public class SwitchDemoString {
       public static void main(String[] args) {
         String str = "world";
         switch(str){
           case "hello":
             System.out.println("Hello");
             break;
           case "world":
             System.out.println("World");
             break;
           default:
             break;
         }
       }
    }
    

    执行编译并进行反编译后控制台得到如下结果:

    root@ubuntu:/usr/local/workspace/demo2# javap SwitchDemoString
    Compiled from "SwitchDemoString.java"
    public class SwitchDemoString {
     public SwitchDemoString();
     public static void main(java.lang.String[]);
    }
    

    默认情况下,如果没有指定参数,它会打印出类中public和protected修饰的方法和字段。javap有一个常用的参数是“-c”,执行后结果如下:

    root@ubuntu:/usr/local/workspace/demo2# javap -c SwitchDemoString
    Compiled from "SwitchDemoString.java"
    public class SwitchDemoString {
     public SwitchDemoString();
     Code:
     0: aload_0
     1: invokespecial #1                  // Method java/lang/Object."<init>":()V
     4: return
    ​
     public static void main(java.lang.String[]);
     Code:
     0: ldc           #2                  // String world
     2: astore_1
     3: aload_1
     4: astore_2
     5: iconst_m1
     6: istore_3
     7: aload_2
     8: invokevirtual #3                  // Method java/lang/String.hashCode:()I
     11: lookupswitch  { // 2
     99162322: 36
     113318802: 50
     default: 61
     }
     36: aload_2
     37: ldc           #4                  // String hello
     39: invokevirtual #5                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
     42: ifeq          61
     45: iconst_0
     46: istore_3
     47: goto          61
     50: aload_2
     51: ldc           #2                  // String world
     53: invokevirtual #5                  // Method java/lang/String.equals:(Ljava/lang/Object;)Z
     56: ifeq          61
     59: iconst_1
     60: istore_3
     61: iload_3
     62: lookupswitch  { // 2
     0: 88
     1: 99
     default: 110
     }
     88: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
     91: ldc           #7                  // String Hello
     93: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
     96: goto          110
     99: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
     102: ldc           #9                  // String World
     104: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
     107: goto          110
     110: return
    }
    

           可以看到如果稍微有点指令基础的,这段代码还是可以看到一点眉目的,但是就大多数人而言,可能阅读起来就比较费晦涩。javap还有很多参数,可以运行javap -help获取详细使用介绍。

    jad

           因为jad很久没有更新了,所以对于JDK7之前的版本,反编译的时候问题不大,但是JDK7中就已经出现了偶尔不支持的问题,到JDK8的lambda表达式的时候,它就彻底失效了,如果有兴趣可以去网上下载jad工具。使用就跟javap正常使用一样,利用命令即可 jad Xxx.class

    cfr

           cfr是一个jar包工具,它需要利用java命令,结合参数jar来使用,另外它还有很多参数,可以运行java -jar cfr_0_132.jar --help来获取各个参数的使用说明。

    Switch反编译

    对于JDK7中switch引入了String类型的支持,来看以下反编译后的结果:

    java -jar cfr_0_132.jar SwitchDemoString.class --decodestringswitch false
    /*
    * Decompiled with CFR 0_132.
    */
    import java.io.PrintStream;
    
    public class SwitchDemoString {
       public static void main(String[] arrstring) {
           String string;
           String string2 = string = "world";
           int n = -1;
           switch (string2.hashCode()) {
               case 99162322: {
                   if (!string2.equals("hello")) break;
                   n = 0;
                   break;
               }
               case 113318802: {
                   if (!string2.equals("world")) break;
                   n = 1;
               }
           }
           switch (n) {
               case 0: {
                   System.out.println("Hello");
                   break;
               }
               case 1: {
                   System.out.println("World");
                   break;
               }
           }
       }
    }
    

           可以看到,它编译出来的内容就比较好理解了,而且我们也看到了,实际上switch对String的支持,最终还是去比较String对应的hashCode。不过因为hashCode可能会出现碰撞的情况,所以在case里面还使用了equals比较。其实对于switch-case语句,最终底部比较的都是int数据,无论是char、byte、short其实都是可以转换成int类型的,而String的hashCode正好返回的也是一个int类型数据。

    lambda表达式反编译

    public class LambdaDemo {
    
        public static void main(String[] args) {
            Thread t = new Thread(() -> System.out.println("线程已经运行"), "t1");
            t.start();
        }
    
    }
    

    反编译后的效果如下:

    root@ubuntu:/usr/local/workspace/demo2# java -jar cfr_0_132.jar LambdaDemo.class --decodelambdas false
    /*
     * Decompiled with CFR 0_132.
     */
    import java.io.PrintStream;
    import java.lang.invoke.LambdaMetafactory;
    
    public class LambdaDemo {
        public static void main(String[] arrstring) {
            Thread thread = new Thread((Runnable)LambdaMetafactory.metafactory(null, null, null, ()V, lambda$main$0(), ()V)(), "t1");
            thread.start();
        }
    
        private static /* synthetic */ void lambda$main$0() {
            System.out.println("\u7ebf\u7a0b\u5df2\u7ecf\u8fd0\u884c");
        }
    }
    

    lambda的表达式实际上是借助LambdaMetafactory来实现的,具体不做详细分析,以后会单独介绍。

    String的+运算反编译

    public class StringDemo {
    
        public static void main(String[] args) {
            String a = "Hello";
            String b = "World";
            String c = "Hello" + "World";
            String d = a + "World";
            String e = a + b;
        }
    
    }
    

    这里我使用了两种反编译工具:

    cfrjar包的反编译结果:

    root@ubuntu:/usr/local/workspace/demo2# java -jar cfr_0_132.jar StringDemo.class
    /*
     * Decompiled with CFR 0_132.
     */
    import java.lang.invoke.CallSite;
    import java.lang.invoke.StringConcatFactory;
    
    public class StringDemo {
        public static void main(String[] arrstring) {
            String string = "Hello";
            String string2 = "World";
            String string3 = "HelloWorld";
            CallSite callSite = StringConcatFactory.makeConcatWithConstants(new Object[]{"\u0001World"}, string);
            CallSite callSite2 = StringConcatFactory.makeConcatWithConstants(new Object[]{"\u0001\u0001"}, string, string2);
        }
    }
    

    运用idea的一个asm-bytecode插件得到的结果是:

    /*
     * Decompiled with CFR 0_124.
     */
    package com.still_loving.interview.thread;
    
    public class StringDemo {
        public static void main(String[] args) {
            String a = "Hello";
            String b = "World";
            String c = "HelloWorld";
            String d = new StringBuilder().append((String)a).append((String)"World").toString();
            String e = new StringBuilder().append((String)a).append((String)b).toString();
        }
    }
    

           由此可以得出结论,对于String字面量上的相加,编译后直接就会变成拼接后的字符串,而对于存在字符串变量的相加,会new StringBuilder,利用它的append方法拼接,然后再toString返回。

    java10中本地变量的反编译

    public class VarDemo {
        public static void main(String[] args) {
            //初始化局部变量
            var str = "hello";
            var list = new ArrayList<String>();
            
            list.add("Java");
            list.add("Python");
    
            //使用增强型for循环
            for (var s : list) {
                System.out.println(s);
            }
    
            //传统for循环
            for (int i = 0, len = list.size(); i < len; i++) {
                System.out.println(list.get(i));
            }
        }
    }
    

    cfr反编译后的结果如下:

    root@ubuntu:/usr/local/workspace/demo2# java -jar cfr_0_132.jar VarDemo.class
    /*
     * Decompiled with CFR 0_132.
     */
    import java.io.PrintStream;
    import java.util.ArrayList;
    
    public class VarDemo {
        public static void main(String[] arrstring) {
            String string = "hello";
            ArrayList<String> arrayList = new ArrayList<String>();
            arrayList.add("Java");
            arrayList.add("Python");
            for (String string2 : arrayList) {
                System.out.println(string2);
            }
            int n = arrayList.size();
            for (int i = 0; i < n; ++i) {
                System.out.println((String)arrayList.get(i));
            }
        }
    }
    

    其实反编译后的结果跟我们正常传统写法一样,没有太大区别,所以var只是一种语法糖。

    语法糖(Syntactic Sugar),也称糖衣语法,由英国计算机科学家Peter.J.Landin发明的一个术语,指在计算机语言中添加某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用,语法糖让程序更加简洁,有更高的可读性。

    相关文章

      网友评论

          本文标题:Java中的编译与反编译

          本文链接:https://www.haomeiwen.com/subject/soigmftx.html