美文网首页程序员面经集
深入解析JVM 基础篇

深入解析JVM 基础篇

作者: JAVA架构师的圈子 | 来源:发表于2021-02-20 20:41 被阅读0次

    1. 什么是JVM:

    Java 虚拟机, 是class文件的运行载体。JVM可以实现一次编译,到处运行。 在这里插入图片描述
    JVM不是只供Java专用的,JVM是解析class文件的,只要语言编译成class文件就能被JVM解析。 在这里插入图片描述

    2. JVM的架构图

    在这里插入图片描述

    3. JVM的运行模式

    JVM有两种运行模式:Server模式和Client模式

    两种模式的区别:

    1. Client模式启动速度快, Server模式启动较慢

    2. 进入运行稳定期后,Server模式的程序运行速度要比Client快很多。

    3. Server模式启动的JVM是重量级的虚拟机,对程序采用了更多的优化。而Client模式使用的是轻量级的虚拟机。所以Server模式启动慢,但稳定后速度就比Client快很多。

    4. JVM的执行流程

    在这里插入图片描述
    解释器和JIT的工作流程图: 在这里插入图片描述

    JVM一开始是用解释器来逐条解释字节码的,当出现热点代码后才会用JIT一次编译成机器码,并保存在方法区中提供后续的执行。 JIT是一种动态编译技术,即运行时编译。

    这里的热点代码怎么定义?

    热点代码主要分为两种:

    1. 被多次调用的方法

    2. 被多次执行的循环体

    这两种情况,编译器都是以整个方法作为编译对象。 这种编译方法因为编译发生在方法的执行过程中,因此被称为栈上替换。

    那通过什么方法来探测代码是否为热点代码:

    目前主要的热点探测方式有以下两种:

    1. 基于采样的热点探测

    采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

    1. 基于计数器的热点探测

    采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。

    在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

    方法调用计数器:这个计数器用于统计方法被调用的次数。 在这里插入图片描述

    在JVM client模式下的阀值是1500次,Server是10 000次。可以通过虚拟机参数: -XX:CompileThreshold设置。但是JVM还存在热度衰减,时间段内调用方法的次数减少,计数器就减小

    在这里插入图片描述

    回边计数器 :它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。

    5. 为什么要使用解释器与编译器并存的架构

    市面上主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。

    解释器与编译器特点

    当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
    当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,因为编译器编译的机器码会存储在内存,反之可以使用编译执行来提升效率。
    编译的时间开销
    解释器的执行,抽象的看是这样的:
    输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
    而要JIT编译然后再执行的话,抽象的看则是:
    输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果

    说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。

    怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”
    1、只被调用一次,例如类的构造器(class initializer,())
    2、没有循环
    //加入Java开发交流君样:756584822一起吹水聊天

    对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
    对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。
    只有对频繁执行的代码,JIT编译才能保证有正面的收益。

    编译的空间开销

    对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

    为何要实现两个不同的即时编译器

    HotSpot虚拟机中内置了两个即时编译器:Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。

    目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。

    用Client Complier获取更高的编译速度,用Server Complier 来获取更好的编译质量。为什么提供多个即时编译器与为什么提供多个垃圾收集器类似,都是为了适应不同的应用场景。

    如何编译为本地代码?

    Server Compiler和Client Compiler两个编译器的编译过程是不一样的。

    对Client Compiler来说,它是一个简单快速的编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段。

    而Server Compiler则是专门面向服务器端的,并为服务端的性能配置特别调整过的编译器,是一个充分优化过的高级编译器。

    6. JIT的优化

    HotSpot对JIT进行了很多优化,这里介绍常见的几种优化策略。

    1. 公共子表达式的消除

    公共子表达式的定义:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共表达式。公共表达式就没有必要再去重新计算。

    如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination)
    如果这种优化范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。

    举个简单的例子来说明他的优化过程,假设存在如下代码:

    1
    int d = (c*b)*12+a+(a+b*c);
    javac编译后的字节码为:

     1 iload_1 //c
     2 iload_2 // b
     3 imul // 计算b*c
     4 bipush 12 // 推入12
     5 imul // 计算(c*b)*12
     6 iload_1 // a
     7 iadd // 计算(c*b)*12+a
     8 iload_1 // a
     9 iload_2 // b
    10 iload_3 // c
    //加入Java开发交流君样:756584822一起吹水聊天
    11 imul // 计算b*c
    12 iadd // 计算a+b*c
    13 iadd // 计算(c*b)*12+a+(a+b*c)
    14 istore 4
    

    当这段代码进入到虚拟机即时编译器后,他将进行如下优化:编译器检测到”cb“与”bc“是一样的表达式,而且在计算期间b与c的值是不变的。因此,这条表达式就可能被视为:

    int d = E*12+a+(a+E);
    甚至采用代数简化,表达式变为

    int d = E13+a2;
    表达式简化后,计算时间就减少了。

    2. 方法内联

    在使用JIT进行即时编译时,将方法调用直接使用方法体中的代码进行替换,这就是方法内联,减少了方法调用过程中压栈与入栈的开销。同时为之后的一些优化手段提供条件。如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。

    比如下面的方法调用

    private int add(int x1, int x2, int x3, int x4) {
        return add2(x1, x2) + add2(x3, x4);
    }
    //加入Java开发交流君样:756584822一起吹水聊天
    private int add2(int x1, int x2) {
        return x1 + x2;
    }
    
    运行一段时间后JVM会把add2方法去掉,并把代码编译成:
    
    private int add4(int x1, int x2, int x3, int x4) {
          return x1 + x2 + x3 + x4;
    }
    

    3. 逃逸分析

    逃逸分析(EscapeAnalysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

    逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

    逃逸分析包括:

    全局变量赋值逃逸
    方法返回值逃逸
    实例引用发生逃逸
    线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量

    public class EscapeAnalysis {
    //全局变量
    public static Object object;
    public void globalVariableEscape(){//全局变量赋值逃逸
        object = new Object();
    }
    public Object methodEscape(){ //方法返回值逃逸
        return new Object();
    }
    public void instancePassEscape(){ //实例引用发生逃逸
        this.speak(this);
    }
    //加入Java开发交流君样:756584822一起吹水聊天
    public void speak(EscapeAnalysis escapeAnalysis){
        System.out.println("Escape Hello");
    }
    

    方法逃逸:StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

    public static StringBuffer craeteStringBuffer(String s1, String s2) {
         StringBuffer sb = new StringBuffer();
         sb.append(s1);
         sb.append(s2);
         return sb;
    }
    

    上述代码如果想要StringBuffer sb不逃出方法,不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。

    public static String createStringBuffer(String s1, String s2) {         
            StringBuffer sb = new StringBuffer(); 
            sb.append(s1); sb.append(s2); 
            return sb.toString(); 
    } 
     
    

    使用逃逸分析,编译器可以对代码做哪些优化?

    同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作的同步是没有必要的,编译器会进行同步消除。
    将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对 象可能是栈分配的候选,而不是堆分配。
    分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象 的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

    从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定 -XX:-DoEscapeAnalysis。 可以通过JVM参数来开启或关闭逃逸分析

    1
    2
    -XX:+DoEscapeAnalysis : 表示开启逃逸分析
    -XX:-DoEscapeAnalysis : 表示关闭逃逸分析

    1. 对象栈上内存分配

    在没有开启逃逸分析前,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日 渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决 定是否可以将对象的内存分配从堆转化为栈。

    如下代码:正常是会创建1000000个User对象。但是这些对象都是方法体内的,没有外部引用,经过逃逸分析后,发现可以对内存进行优化。

    public class EscapeAnalysisTest {
        public static void main(String[] args) {
            long a1 = System.currentTimeMillis();
            for (int i = 0; i < 1000000; i++) {
                alloc();
            }
    // 查看执行时间
    //加入Java开发交流君样:756584822一起吹水聊天
            long a2 = System.currentTimeMillis();
            System.out.println("cost " + (a2 - a1) + " ms");
    // 为了方便查看堆内存中对象个数,线程sleep
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e1) {
                e1.printStackTrace();
            }
        }
        private static void alloc() {
            User user = new User();
        }
        static class User {
        }
    
    下面通过开启逃逸分析(JDK7以上的默认开启),用jmap -histo pid 进行内存分析,发现user对象之创建了99686个, 只有 1 / 10 之一。 在这里插入图片描述 如果JVM运行参数把逃逸分析关闭 -XX:-DoEscapeAnalysis -XX:+PrintGCDetails - XX:+HeapDumpOnOutOfMemoryError 。 此时User对象创建了1000000个。 在这里插入图片描述

    5. 标量替换

    标量(Scalar)是指一个无法再分解成更小的数据的数据 。

    在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对 象拆解成若干个其中包含的若干个成员变量来代替。

    举例:

    //有一个类A
    public class A{
        public int a=1;
        public int b=2
    }
    //方法getAB使用类A里面的a,b
    private void getAB(){
        A x = new A();
        x.a;
        x.b;
    }
    //JVM在编译的时候会直接编译成
    private void getAB(){
        a = 1;
        b = 2;
    //加入Java开发交流君样:756584822一起吹水聊天
    }
    //这就是标量替换
    
    1. 同步锁消除

    同样基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的完全没有必要加锁。 在JIT编译时期就 可以将同步锁去掉,以减少加锁与解锁造成的资源开销。

    public class TestLockEliminate {
        public static String getString(String s1, String s2) {
             StringBuffer sb = new StringBuffer();
             sb.append(s1);
             sb.append(s2);
             return sb.toString();
        }
        public static void main(String[] args) {
              long tsStart = System.currentTimeMillis();
              for (int i = 0; i < 10000000; i++) {
                    getString("TestLockEliminate ", "Suffix");
              }
              System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");
        }
    }           
    //加入Java开发交流君样:756584822一起吹水聊天     
    

    getString()方法中的StringBuffer数以函数内部的局部变量,进作用于方法内部,不可能逃逸 出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的 append操作却需要执行同步操作,代码如下:

    @Override
    public synchronized StringBuffer append(String str) {
         toStringCache = null;
         super.append(str);
         return this;
    }
    

    逃逸分析和锁消除分别可以使用参数 -XX:+DoEscapeAnalysis 和 -XX:+EliminateLocks (锁消除必须 在-server模式下)开启。

    使用如下参数运行上面的程序: -XX:+DoEscapeAnalysis -XX:-EliminateLocks , 不使用锁消除。

    运行3次的结果为:

    //加入Java开发交流君样:756584822一起吹水聊天
    一共耗费:847 ms

    一共耗费:774 ms

    一共耗费:788 ms

    使用如下参数运行上面的程序: -XX:+DoEscapeAnalysis -XX:+EliminateLocks 后

    运行3次的结果为:

    一共耗费:506 ms

    一共耗费:504 ms`

    一共耗费:499 ms

    image

    最新2020整理收集的一些高频面试题(都整理成文档),有很多干货,包含mysql,netty,spring,线程,spring cloud、jvm、源码、算法等详细讲解,也有详细的学习规划图,面试题整理等,需要获取这些内容的朋友请加Q君样:756584822

    相关文章

      网友评论

        本文标题:深入解析JVM 基础篇

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