美文网首页Java 虚拟机
【Java 虚拟机笔记】编译与代码优化相关整理

【Java 虚拟机笔记】编译与代码优化相关整理

作者: 58bc06151329 | 来源:发表于2019-05-21 15:04 被阅读3次

    文前说明

    作为码农中的一员,需要不断的学习,我工作之余将一些分析总结和学习笔记写成博客与大家一起交流,也希望采用这种方式记录自己的学习之旅。

    本文仅供学习交流使用,侵权必删。
    不用于商业目的,转载请注明出处。

    1. 早期(编译期)优化

    • Java 语言的编译期是一段不确定的操作过程,它可以分为三类编译过程。
      • 前端编译:把 .java 文件转变为 .class 文件。
      • 后端编译:把字节码转变为机器码。
      • 静态提前编译:直接把 *.java 文件编译成本地机器码。
    • 从 JDK 1.3 开始,虚拟机设计团队把对性能的优化集中到了后端的即时编译器(JIT)中,这样可以让不是由 javac 编译器产生的 class 文件(如 JRuby、Groovy 等语言编译的 class 文件)也同样能享受到编译器优化所带来的好处。
    • javac 做了许多针对编码过程的优化措施来改善程序员的编码风格和提高编码效率。许多新生的 Java 语法特性,都是靠编译器的 语法糖 来实现,而不是依赖虚拟机的底层改进来支持,Java 中即时编译在运行期的优化过程对于程序运行来说更重要,而前端编译期在编译期的优化过程对于程序编码来说关系更加密切
    • javac 编译器的编译过程大致可分为三个步骤。
      • 解析与填充符号表过程。
      • 插入式注解处理器的注解处理过程。
      • 语义分析与字节码生成过程。

    1.1 解析与填充符号表

    词法、语法分析

    • 解析步骤包含了 词法分析语法分析 两个过程。
      • 词法分析 是将源代码的字符流转变成为标记(token)集合。
        • 单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、 变量名、 字面量、 运算符都可以成为标记。
        • token 不可再拆分。
        • javac 中由 com.sun.tools.javac.parser.Scanner 类实现。
    /**
         * 读取一个标识符
         */
        private void scanIdent() {
            boolean isJavaIdentifierPart;
            char high;
            do {
                if (sp == sbuf.length) putChar(ch); else sbuf[sp++] = ch;
                // optimization, was: putChar(ch);
    
                scanChar();
                switch (ch) {
                case 'A': case 'B': case 'C': case 'D': case 'E':
                case 'F': case 'G': case 'H': case 'I': case 'J':
                case 'K': case 'L': case 'M': case 'N': case 'O':
                case 'P': case 'Q': case 'R': case 'S': case 'T':
                case 'U': case 'V': case 'W': case 'X': case 'Y':
                case 'Z':
                case 'a': case 'b': case 'c': case 'd': case 'e':
                case 'f': case 'g': case 'h': case 'i': case 'j':
                case 'k': case 'l': case 'm': case 'n': case 'o':
                case 'p': case 'q': case 'r': case 's': case 't':
                case 'u': case 'v': case 'w': case 'x': case 'y':
                case 'z':
                case '$': case '_':
                case '0': case '1': case '2': case '3': case '4':
                case '5': case '6': case '7': case '8': case '9':
                case '\u0000': case '\u0001': case '\u0002': case '\u0003':
                case '\u0004': case '\u0005': case '\u0006': case '\u0007':
                case '\u0008': case '\u000E': case '\u000F': case '\u0010':
                case '\u0011': case '\u0012': case '\u0013': case '\u0014':
                case '\u0015': case '\u0016': case '\u0017':
                case '\u0018': case '\u0019': case '\u001B':
                case '\u007F':
                    break;
                case '\u001A': // EOI is also a legal identifier part
                    if (bp >= buflen) {
                        name = names.fromChars(sbuf, 0, sp);
                        token = keywords.key(name);
                        return;
                    }
                    break;
                default:
                    if (ch < '\u0080') {
                        // all ASCII range chars already handled, above
                        isJavaIdentifierPart = false;
                    } else {
                        high = scanSurrogates();
                        if (high != 0) {
                            if (sp == sbuf.length) {
                                putChar(high);
                            } else {
                                sbuf[sp++] = high;
                            }
                            isJavaIdentifierPart = Character.isJavaIdentifierPart(
                                Character.toCodePoint(high, ch));
                        } else {
                            isJavaIdentifierPart = Character.isJavaIdentifierPart(ch);
                        }
                    }
                    if (!isJavaIdentifierPart) {
                        name = names.fromChars(sbuf, 0, sp);
                        token = keywords.key(name);
                        return;
                    }
                }
            } while (true);
    }
    
    • 语法分析 是根据(token)序列构造 抽象语法树(Abstract SyntaxTree,AST)的过程(一种用来描述程序代码语法结构的树状表示方式)。
      • 语法树中的每一个节点都代表着程序代码中的语法结构(Construct)。
      • 语法分析过程由 com.sun.tools.javac.parser.Parser 类实现。
      • 这个阶段产生出的抽象语法树由 com.sun.tools.javac.tree.JCTree 类表示。

    填充符号表

    • 完成词法分析和语法分析之后,下一步是填充符号表。
    • JavaCompiler.enterTrees() 方法,符号表是由一组 符号地址符号信息 构成的表格,符号表中所登记的信息在编译的不同阶段都要用到。(比如语义分析中符号表所登记的内容将用于语义检查和产生中间代码,目标代码生成阶段当对符号名进行地址分配时,符号表是地址分配的依据)。
      • javac 源码中由 com.sun.tools.javac.comp.Enter 类实现。
    enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects)))
    
    public List<JCCompilationUnit> enterTrees(List<JCCompilationUnit> roots) {
            //enter symbols for all files
            if (taskListener != null) {
                for (JCCompilationUnit unit: roots) {
                    TaskEvent e = new TaskEvent(TaskEvent.Kind.ENTER, unit);
                    taskListener.started(e);
                }
            }
            
            /**
             * 填充符号表
             */
            enter.main(roots);
    
            if (taskListener != null) {
                for (JCCompilationUnit unit: roots) {
                    TaskEvent e = new TaskEvent(TaskEvent.Kind.ENTER, unit);
                    taskListener.finished(e);
                }
            }
    
            //If generating source, remember the classes declared in
            //the original compilation units listed on the command line.
            if (sourceOutput || stubOutput) {
                ListBuffer<JCClassDecl> cdefs = lb();
                for (JCCompilationUnit unit : roots) {
                    for (List<JCTree> defs = unit.defs;
                         defs.nonEmpty();
                         defs = defs.tail) {
                        if (defs.head instanceof JCClassDecl)
                            cdefs.append((JCClassDecl)defs.head);
                    }
                }
                rootClasses = cdefs.toList();
            }
            return roots;
    }
    

    1.2 插入式注解处理器的注解处理过程

    • 在 JDK 1.6 中实现了 JSR-269 规范,注解在运行期间发挥作用,提供了一组插入式注解处理器的标准 API 在编译期间对注解进行处理,可以把它看做是一组编译器的插件,在这些插件里面,可以读取、 修改、 添加抽象语法树中的任意元素。
    • 如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个 Round。
    • javac 源码中插入式注解处理器的初始化过程是在 JavaCompiler.initProcessAnnotations() 方法中完成的,而它的执行过程在 JavaCompiler.processAnnotation() 方法中完成。
    initProcessAnnotations(processors);
    
    public void initProcessAnnotations(Iterable<? extends Processor> processors)
                    throws IOException {
            // Process annotations if processing is not disabled and there
            // is at least one Processor available.
            Options options = Options.instance(context);
            if (options.get("-proc:none") != null) {
                processAnnotations = false;
            } else if (procEnvImpl == null) {
                procEnvImpl = new JavacProcessingEnvironment(context, processors);
                processAnnotations = procEnvImpl.atLeastOneProcessor();
    
                if (processAnnotations) {
                    if (context.get(Scanner.Factory.scannerFactoryKey) == null)
                        DocCommentScanner.Factory.preRegister(context);
                    options.put("save-parameter-names", "save-parameter-names");
                    reader.saveParameterNames = true;
                    keepComments = true;
                    if (taskListener != null)
                        taskListener.started(new TaskEvent(TaskEvent.Kind.ANNOTATION_PROCESSING));
    
    
                } else { // free resources
                    procEnvImpl.close();
                }
            }
    }
    
    public JavaCompiler processAnnotations(List<JCCompilationUnit> roots)
                throws IOException {
            return processAnnotations(roots, List.<String>nil());
    }
    

    1.3 语义分析与字节码生成过程

    • 语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查(是否合乎语义逻辑必须限定在具体的语言与具体的上下文环境之中才有意义)。

    1.3.1 标注检查

    • JavaCompiler.attribute() 方法,标注检查步骤检查的内容包括诸如变量使用前是否已经被声明、变量与赋值之间的数据类型是否够匹配以及常量折叠。
    • javac 中实现类是 com.sun.tools.javac.comp.Attr 类和 com.sun.tools.javac.comp.Check 类。
    /**
         * Attribute a parse tree.
         * @returns the attributed parse tree
         */
    public Env<AttrContext> attribute(Env<AttrContext> env) {
            if (compileStates.isDone(env, CompileState.ATTR))
                return env;
    
            if (verboseCompilePolicy)
                log.printLines(log.noticeWriter, "[attribute " + env.enclClass.sym + "]");
            if (verbose)
                printVerbose("checking.attribution", env.enclClass.sym);
    
            if (taskListener != null) {
                TaskEvent e = new TaskEvent(TaskEvent.Kind.ANALYZE, env.toplevel, env.enclClass.sym);
                taskListener.started(e);
            }
    
            JavaFileObject prev = log.useSource(
                                      env.enclClass.sym.sourcefile != null ?
                                      env.enclClass.sym.sourcefile :
                                      env.toplevel.sourcefile);
            try {
                attr.attribClass(env.tree.pos(), env.enclClass.sym);
                compileStates.put(env, CompileState.ATTR);
            }
            finally {
                log.useSource(prev);
            }
    
            return env;
    }
    

    1.3.2 数据及控制流分析

    • JavaCompiler.flow() 数据流方法,对程序上下文逻辑更进一步的验证,可以检查出诸如程序局部变量在使用前是否赋值、方法的每条路径是否都有返回值、是否所有的受检查异常都被正确处理了问题。
      • 局部变量在常量池中没有 CONSTANT_Fieldref_info 的符号引用,自然没有访问标志的信息,甚至可能连名称都不会保存下来。
      • 将局部变量声明为 final,对运行期没有影响,变量的不变性仅仅由编译器在编译期间保障。

    1.3.3 解语法糖

    • 也称糖衣语法,指在计算机中添加某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用,通常来说,使用语法唐能够增加程序的可读性,从而减少程序代码出错的机会。
      • Java 中最常用的是泛型、变长参数、自动装箱/拆箱等(在编译阶段就被还原成简单的语法结构(比如 List<String> 和 List<Integer> 在运行期间其实是同一个类)。

    泛型与类擦除

    • 本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。
      • 这种参数类型可以用在类、 接口和方法的创建中,分别称为泛型类、 泛型接口和泛型方法。
    • 在编译期间,编译器无法检查这个 Object 的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期之中。
    • C# 中泛型无论在程序源码中、 编译后的 IL 中(Intermediate Language,中间语言,这时候泛型是一个占位符),或是运行期的 CLR 中,都是切实存在的,List<int> 与 List<String> 是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为 真实泛型
    • Java 语言中的泛型只在程序源码中存在,在编译后的字节码文件中,已经替换为原来的原生类型(Raw Type,也称为裸类型),并且在 相应的地方插入了强制转型代码,对于运行期的 Java 语言来说,ArrayList<int> 与 ArrayList<String> 是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为 伪泛型
    • 在 class 文件格式中,只要描述符不是完全一致的两个方法就可以共存,也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那他们也是可以合法地共存于一个 class 文件中。
    • Signature 是解决伴随泛型而来的参数类型的识别问题中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。
    • 擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。

    自动装箱、拆箱、循环遍历

    • 自动装箱、拆箱在编译之后会被转化成对应的包装和还原方法,如 Integer.valueOf()Integer.intValue()
    • 遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。
    • 变长参数会变成数组类型的参数。
    • 然而包装类的 " == " 运算在不遇到算术运算的情况下不会自动拆箱,以及它们的 equals() 方法不处理数据转型的关系。

    条件编译

    • C、 C++ 中使用预处理器指示符(#ifdef)来完成条件编译。 C、 C++ 的预处理器最初的任务是解决编译时的代码依赖关系(如非常常用的 #include 预处理命令)。
    • 在 Java 语言中并没有使用预处理器,因为 Java 语言天然的编译方式(编译器并非一个个的编译 Java 文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。
      • 条件编译的实现方式使用了 if 语句,所以它必须遵循最基本的 Java 语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个 Java 类的结构。
      • Java 中根据布尔常量值的真假,编译器会把分支中不成立的代码块擦除掉,这一工作将在编译器解析语法糖阶段完成。

    1.3.4 字节码生成

    • 此过程是 javac 编译过程的最后一个阶段,字节码生成阶段将之前各个步骤所生成的信息转化成字节码写到磁盘中,另外还进行少量的代码添加和转换工作。
      • javac 源码里由 com.sun.tools.javac.jvm.Gen 类来完成。
      • 保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。

    2. 晚期(运行期)优化

    • Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为 热点代码(Hot Spot Code)。
    • 为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,简称 JIT 编译器)。
    • 它是虚拟机中最核心且最能体现虚拟机水平的部分。
    • 众多主流的虚拟机都同时包含 解释器JIT 编译器,解释器与 JIT 编译器各有优势。
      • 当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。
      • 当程序运行后,随着时间的推移,JIT 编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
    • 默认设置下,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。
      • 当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的地址,下一次调用该方法时就会使用已编译的版本。
      • 在编译器还未完成之前,执行引擎仍按照解释方式继续执行,而编译动作则在后台的编译线程中进行。

    2.1 HotSpot 虚拟机内的即时编译器

    2.1.1 解释器与编译器

    解释器与编译器交互
    • HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler(C1 编译器)和 Server Compiler(C2 编译器),默认采用解释器与其中一个编译器直接配合的方式工作,使用哪个编译器取决于虚拟机运行的模式,也可以自行指定。
      • 可以通过 -client-server 参数强制指定虚拟机运行在 Client 模式或 Server 模式。
    • 解释器与编译器混搭配使用的方式在虚拟机中称为 混合模式
      • 可以使用参数 -Xint 强制虚拟机运行于 解释模式(Interpreted Mode),这时编译器完全不介入工作,全部代码都使用解释方式执行。
      • 使用参数 -Xcomp 强制虚拟机运行于 编译模式(Compiled Mode),这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

    分层编译策略

    • 分层编译策略作为默认编译策略在 JDK 1.7 的 Server 模式虚拟机中被开启。
      • 第 0 层,程序解释执行,解释器不开启性能监控功能,可触发第 1 层编译。
      • 第 1 层,C1 编译,将字节码编译成本地代码,进行简单可靠的优化,如有必要将加入性能监控的逻辑。
      • 第 2 层,C2 编译,也是将字节码编译成本地代码,但是会启动一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
    • 实施分层编译后,C1 和 C2 将会同时工作,C1(Client Compiler)获取更高的编译速度,C2(Server Compiler)获取更好的编译质量,在解释执行的时候也无须再承担性能监控信息的任务。

    2.1.2 编译对象与触发条件

    会被即时编译器编译的热点代码

    • 被多次调用的方法体。
    • 被多次调用的循环体。
    • 即时编译器会以整个方法作为编译对象,将其编译成机器码。这种编译方式因为编译发生在方法执行过程之中,因此被称作栈上替换(OSR)。

    判断一段代码是否是热点代码的方式(热点探测)

    • 基于采样的热点探测
      • 此方法会周期性检查各个线程的栈顶,如果发现某个或某些方法经常出现在栈顶,那么这个方法就是热点方法。
      • 此方法的缺点是很难精确地确认一个方法的热度,容易受到诸如线程阻塞等因素影响。
    • 基于计数器的热点探测
      • 此方法会为每个方法甚至是代码块建立计数器,统计方法的执行次数,如果执行次数超过一个阀值就认为它是热点方法。此方法统计结果更加精确和严谨。
    • HotSpot 中为每个方法准备了两类计数器。
      • 方法调用计数器回边计数器
      • 两个计数器都有一个确定的阈值,当计数器超过阈值时就触发 JIT 编译。

    方法调用计数器

    • 方法调用计数器默认情况下 CLient 模式下是 1500 次,Server 模式下是 10000 次。
      • 可以通过 -XX:CompileThreshold 设置。
    方法调用计数器触发即时编译
    • 如果不做任何设置,执行引擎会继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成,下次调用才会使用已编译的版本。
    • 方法调用计数器的值不是一个绝对的次数,是一个执行的相对频率(一段时间之内方法被调用的次数),当超过一定的时间限度,调用次数仍不足以将其提交给编译器编译,那么方法的调用计数器就会被减少一半,这种方法叫做 计算器热度的衰减(Counter Decay)。
      • 进行衰减的动作是在虚拟机进行垃圾收集时顺便进行的。
      • 可以使用参数 -XX:-UseCounterDecay 关闭热度衰减。
      • 也可以通过 -XX:CounterHalfLiftTime 参数设置半衰周期的时间,单位秒。

    回边计数器

    • 统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为 " 回边 ",建立回边计数器统计的目的就是为了触发 OSR 编译。
      • 回边计数器阈值计算公式。
        • Client,方法调用计数器阈值(CompileThreshold) X OSR 比率(OnStackReplacePercentage) / 100
          • 其中 OnStackReplacePercentage 默认值是 933,若都取默认值 Client 模式虚拟机的回边计数器阈值为 13995。
        • Server,方法调用计数器阈值(CompileThreshold) X (OSR 比率(OnStackReplacePercentage) - 解释器监控比率(InterpreterProfilePercentage)) / 100
          • 其中 OnStackReplacePercentage 默认值是 140,InterpreterProfilePercentage 默认值是 33,若都取默认值,阈值为 10700 。
    回边计数器触发即时编译
    • 回边计数器没有技术热度衰减的过程,因此这个计数器统计的就是该方法循环执行的 绝对次数,当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候会执行标准编译过程。

    2.1.3 编译过程

    • 虚拟机在代码编译器未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。
      • 也可以禁止后台编译,禁止后,一旦达到 JIT 的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。

    Client Compiler

    • Client Compiler 模式下是一个三段式编译。
      • 第一阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR),HIR 使用静态单分配(SSA)的形式来代表代码值,这可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。
        • 在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等。
        • 优化将会在字节码被构造成 HIR 之前完成。
      • 第二阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示 LIR。
      • 最后阶段,是在平台相关的后端使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码。
    Client Compiler 模式

    Server Compiler

    • Server Compiler 则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,他会执行所有经典的优化动作,如:无用代码消除,循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一项与 Java 语言特性密切相关的优化技术,如范围消除、控制检查消除。
    • Server Compiler 的寄存器分配器是全局图着色分配器,从即时编译器来看它无疑是比较缓慢的,但他的编译速度依然远远超过传统静态优化编译器,而相对于 Client Compiler 编译输出的代码质量有所提高,可以减少本地代码执行时间。

    2.1.4 查看及分析即时编译结果

    • -XX:+PrintCompilation 要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来。
    • -XX:+PrintInlining 要求虚拟机输出方法内联信息。
    • -XX:+PrintAssembly 要求虚拟机打印编译方法的汇编代码。
    • -XX:+PrintOptoAssembly(用于 Server VM)或 -XX:+PrintLIR(用于 Client VM)来输出比较接近最终结果的中间代码表示。

    2.2 编译优化技术

    • 对代码的所有优化措施都集中在即时编译器中,即时编译器产生的本地代码会比 Javac 产生的字节码更加优秀。
      • 优化包含方法内联、冗余访问消除、覆写传播、无用代码消除等。

    公共子表达式消除

    • 如果一个表达式 E 已经计算过,并且从先前到现在 E 中所有变量的值没有发生变化,那 E 的这次出现就成为了公共子表达式,对这种表达式没必要再花时间对它进行计算,只需要直接用前面计算过的表达式结构结果代替即可,如果这种优化仅限于程序的基本块内,便称为 局部公共子表达式消除,如果覆盖了多个基本块则称为 全局公共子表达式消除

    数组边界检查消除

    • 自动装箱消除、安全点消除、消除反射等。
    • Java 语言中访问数组元素都要进行上下界的范围检查,每次读写都有一次条件判定操作,这无疑是一种负担。编译器只要通过数据流分析就可以判定循环变量的取值范围永远在数组长度以内,那么整个循环中就可以把上下界检查消除,这样可以省很多次的条件判断操作。

    方法内联

    • 只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法以及使用 invokestatic 指令进行调用的静态方法才是在编译器进行解析的,其他 Java 方法都需要在运行时进行方法接收者的多态选择。
    • Java 语言默认的实例方法是虚方法。
    • 方法内联能去除方法调用的成本,同时也为其他优化建立了良好的基础,因此各种编译器一般会把内联优化放在优化序列的最靠前位置,然而由于 Java 对象的方法默认都是虚方法,因此方法调用都需要在运行时进行多态选择,为了解决虚方法的内联问题,首先引入了 " 类型继承关系分析(CHA) " 的技术。
      • 在内联时,若是非虚方法,则可以直接内联。
      • 遇到虚方法,首先根据 CHA 判断此方法是否有多个目标版本,若只有一个,可以直接内联,但是需要预留一个 " 逃生门 ",称为守护内联,若在程序的后续执行过程中,加载了导致继承关系发生变化的新类,就需要抛弃已经编译的代码,退回到解释状态执行,或者重新编译。
      • 若 CHA 判断此方法有多个目标版本,则编译器会使用 " 内联缓存 ",第一次调用缓存记录下方法接收者的版本信息,并且每次调用都比较版本,若一致则可以一直使用,若不一致则取消内联,查找虚方法表进行方法分派。

    逃逸分析

    • 逃逸分析的基本行为就是分析对象动态作用域,当一个对象被外部方法所引用,称为方法逃逸。
    • 当被外部线程访问,称为线程逃逸。
    • 若能证明一个对象不会被外部方法或进程引用,则可以为这个变量进行一些优化。
      • 栈上分配,如果确定一个对象不会逃逸,则可以让它分配在栈上,对象所占用的内存空间就可以随栈帧出栈而销毁。这样可以减小垃圾收集系统的压力。
      • 同步消除,线程同步相对耗时,如果确定一个变量不会逃逸出线程,那这个变量的读写不会有竞争,则对这个变量实施的同步措施也就可以消除掉。
      • 标量替换,如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那么程序真正执行的时候可以不创建这个对象,改为直接创建它的成员变量,这样就可以在栈上分配。
    • -XX:+DoEscapeAnalysis 手动开启逃逸分析。
    • -XX:+PrintEscapeAnalysis 查看分析结果。
    • -XX:+EliminateAllocations 开启标量替换。
    • -XX:+EliminateLocks 开启同步消除。
    • -XX:+PrintEliminateAllocations 查看标量。

    2.3 Java 与 C/C++ 编译器对比

    • Java 虚拟机的即时编译器与 C/C++ 的静态编译器相比,可能会由于下面的原因导致输出的本地代码有一些劣势。
      • 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,因此不敢随便引入大规模的优化技术。
      • Java 语言是动态的类型安全语言,虚拟器需要频繁的进行动态检查,如空指针,上下界范围,继承关系等。
      • Java 中使用虚方法频率远高于 C++,则需要进行多态选择的频率远高于 C++。
      • Java 是可以动态扩展的语言,运行时加载新的类可能改变原有的继承关系,许多全局的优化措施只能以激进优化的方式来完成。
      • Java 语言的对象内存都在堆上分配,垃圾回收的压力比 C++ 大。
    • 然而,Java 语言这些性能上的劣势换取了开发效率上的优势,并且由于 C++ 编译器所有优化都是在编译期完成的,以运行期性能监控为基础的优化措施都无法进行,这也是 Java 编译器独有的优势。

    相关文章

      网友评论

        本文标题:【Java 虚拟机笔记】编译与代码优化相关整理

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