JVM内存分布及JIT运行优化

1,485 阅读18分钟

JVM内存模型定义

  • JVM不仅承担了Java字节码的分析(JIT)和执行(Runtime),同时也内置了自动内存分配管理机制
  • 内存模型图解
  • 堆是jvm内存中最大的一块内存空间,该空间被所有线程共享,几乎所有的对象和数组都被分配到了堆内存中: 堆被划分为新生代和老年代,新生代划分为Eden和Survivor区,Suvivor是由From Survivor和To Survivor组成
    • java6中,永久代在非堆内存去
    • java7中,永久代的静态变量和运行时常量池被合并到 了堆中
    • java8中,永久代被元空间取代了,元空间存储静态变量和运行时常量池跟java7永久代一样儿,都移到了堆中中
程序计数器
  • 是一块很小的内存空间,主要用来记录各个线程执行的字节码地址 例如:分支,循环,跳转,异常,线程恢复都能依赖于计数器
  • 注意: 每个线程有一个单独的程序计数器来记录下一条运行的指令
方法区
  • 在HotSpot虚拟机使用永久代来实现方法区,在其他虚拟机中不是这样的,只是在HotSpot虚拟机中,设计人员使用了永久代实现了JVM规范的方法区
  • 方法区主要用来存放已被虚拟机加载的类相关信息 : 类信息,运行时常量池,字符串常量池(class、运行时常量池、字段、方法、代码、JIT代码等)
    • 类信息包括了类的版本,字段,方法,接口和父类等信息
    • JVM执行类加载步骤:加载,连接(验证,准备,解析三个阶段),初始化,在加载类的时候,JVM会先加载class文件,在class文件中除了有类的版本,字段,方法和接口等描述信息外,还有一项信息是常量池,用于存放编译期间生成的各种字面量和符合引用
      • 字面量包括字符串(String a = "hello"),基本类型的常量(final修饰的变量)
      • 符号引用包括类和方法的全限定名(如String为Java/lang/String),字段的名称和描述符以及方法的名称和描述符
    • 当类加载到内存中后,JVM就会将class文件常量池中的内容存放到运行时常量池中,在解析阶段,JVM会把符号引用替换为直接引用(对象的索引值)
      • 比如:类中的一个字符串常量在class文件中时,存放在class文件常量池中的
    • 在JVM加载完类后,JVM会将这个字符串常量放到运行时常量池中,并在解析阶段,指定改字符串对象的索引值
    • 运行时常量池是全局共享的,多个类中共用一个运行时常量池,class文件中常量池多个相同的字符串在运行时常量池中只会存在一份
    • 方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的,如果有两个线程试图都访问方法区中的一个类信息,而这个类还没有装入JVM中,那么此时就只允许一个线程去加载它,另一个线程必须等待
    • 永久代:包括静态变量和运行时常量池,永久代的类等数据
      • Java7中将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中(当依然在JVM内存中)
      • Java8中将方法区中实现的永久代去掉,使用元空间替代,并且元空间的存储位置为本地内存(不在JVM内存中,而是直接存在内存中的),之前永久代的类的元数据存储在了元空间,而永久代的静态变量以及运行时常量池跟Java7一样转移到了堆中
      • 元空间:存储的是类的元数据信息:关于数据的数据或者叫做用来描述数据的数据:就是最小的数据单元,元数据可以为数据说明其元素或属性(名称,大小,数据类型等),其结构(长度,字段,数据列),或其相关数据(位于何处,如何联系,拥有者等)
      • 为何使用元数据区替代永久代
        1. 字符串存在永久代中,容易出现性能问题和内存溢出
        2. 类及方法的信息等都比较难确定其大小,因此对于永久代的大小指定比较困难(默认8M),大小容易出现永久代溢出,太大则容易导致老年代溢出
        3. 永久代会为GC带来不必要的复杂度,并且回收效率偏低
        4. 最重要的是Oracle想将HotSpot与JRockit(没有永久代概念)虚拟机合二为一
虚拟机栈
  • Java虚拟机栈是线程私有的内存空间,它跟Java线程一起被创建,当创建一个线程时,会在虚拟机栈中申请一个栈帧,用来保存方法的局部变量,操作数栈,动态链接方法和返回地址等信息,并参与方法的调用和返回
  • 每个方法的调用都是一个入栈操作,方法的返回则是栈帧的出栈操作
本地方法栈
  • 同Java虚拟机栈功能类似,Java虚拟机栈用来管理java函数调用的,本地方法栈用来管理本地方法的调用,是由C语言实现的

JIT运行时编译(优化Java)

类编译加载执行过程
  • Java编译到运行过程
  1. 类编译
    • 将.java文件编译成.class文件(使用javac命令生成),编译后的字节码文件主要包括常量池和方法表集合这两个部分
    • 常量池主要记录的是类文件中出现的字面量以及符号引用
      • 字面常量包括字符串常量,声明为final的属性以及一些基本类型的属性
      • 符号引用包括类和接口的全限定名,类引用,方法引用以及成员变量引用(如String Str = "abc",其中str就是成员变量引用)
  2. 类加载
    • 当一个类被创建实例或者被其他对象引用时,虚拟机在没有加载过该类情况下,会通过类加载器将字节码文件加载到内存中
    • 不同的实现类有不同的类加载器加载,JDK中本地方法类一般由根加载器加载,JDK中内部实现的扩展类一般由扩展加载器实现加载,程序中的类文件则由系统加载器实现加载
    • 在类加载后,class类文件中的常量池信息以及其他数据会被保存到JVM内存的方法区中
  3. 类链接: 验证,准备,解析
    • 验证: 验证类符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全
    • 准备: 为类的静态变量分配内存,初始化为系统的初始值,对于final static修饰的常量,直接赋值为用户定义值,对于static修饰变量会赋值为默认初始值
      private final static int value = 123 //赋值为123
      private static int value = 123 //赋值为0
      
    • 解析: 将符号引用转为直接引用的过程:编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替
      • 类结构文件的常量池中存储了符号引用:包括类和接口的全限定名,类引用,方法引用以及成员变量引用等,如果需要使用以上类和方法,就西药将他们转化为JVM可以直接获取的内存地址或指针,即直接引用;
  4. 类初始化
    • 类初始化是类加载的最后一个阶段,初始化时,JVM首先将执行构造器方法,编译器会将.java文件编译成.class文件时,收集所有类初始化代码,包括静态变量赋值语句,静态代码块,静态方法,收集在一起成为方法
    • 初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和Java源码从上到下的顺序一致
    • 子类初始化时会首先调用父类的()方法,在执行子类的方法
    • JVM会保证()方法的线程安全,保证同一时间只有一个线程执行
    • JVM在初始化执行代码时,如果实例化一个新对象,会调用方法对实例变量就行初始化,并执行对应的构造方法内的代码
  • 思考题: 反射中Class.forName()和ClassLoader.loadClass()的区别
装载:通过累的全限定名获取二进制字节流,将二进制字节流转换成方法区中的运行时数据结构,在内存中生成Java.lang.class对象; 
链接:执行下面的校验、准备和解析步骤,其中解析步骤是可以选择的; 
    校验:检查导入类或接口的二进制数据的正确性;(文件格式验证,元数据验证,字节码验证,符号引用验证) 
    准备:给类的静态变量分配并初始化存储空间; 
    解析:将常量池中的符号引用转成直接引用;
初始化:激活类的静态变量的初始化Java代码和静态Java代码块,并初始化程序员设置的变量值。

Class.forName(className)方法,内部实际调用的方法是  Class.forName(className,true,classloader);
第2boolean参数表示类是否需要初始化,  Class.forName(className)默认是需要初始化。
一旦初始化,就会触发目标对象的 static块代码执行,static参数也也会被再次初始化。

ClassLoader.loadClass(className)方法,内部实际调用的方法是  ClassLoader.loadClass(className,false);
第2boolean参数,表示目标对象是否进行链接,false表示不进行链接,由上面介绍可以,
不进行链接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会得到执行
即时编译
  • 初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,即为即时编译
    1. 虚拟机中的字节码是由解释器(Interpreter)完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为热点代码
    2. 为了提高热点代码的执行效率,在运行时 JIT会把这些代码编译成与本地平台相关的机器码,并进行层次的优化,然后保存到内存中
  1. 即时编译器类型
    • HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,他们的过程不同
      • C1编译器是一个简单快速的编译器,主要关注点在局部性的优化,适用于执行时间较短或对启动性能有要求的程序,如GUI应用对界面启动速度有一定要求
      • C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能优要求的程序,这两种编译器也被称为Client Compiler和Server Compiler
    • Java7之前,根据程序特性来选择对应的JIT,虚拟机默认采用解释器和其中一个编译器配合工作
    • Java7引入了分层编译,综合了C1启动性能优势和C2的峰值性能优势,通过设置参数可强制更改
    • 分层将JVM的执行状态分为5个层次
      • 第0层: 程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译
      • 第1层: 可称为C1编译,将字节码编译为本地代码,进行简单可靠的优化,不开启Profiling
      • **第2层:**也称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数Profiling
      • 第3层 也称为C1编译,执行所有带Profiling的C1编译
      • 第4层 可称为C2编译,也是将字节码编译成本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
      • 通过 java -version 命令行可查看当前系统使用的编译模式
        java --version -> mixed mode :混合编译模式
        java -Xint -version -> interpreted mode : 只有解释器编译,关闭JIT
        java -Xcomp -version -> compiled mode: 只有JIT编译,关闭解释器编译
        
热点探测:JVM编译优化条件
  • HotSpot虚拟机的热点探测是基于计数器的热点探测,虚拟机会为每个方法建立计数器统计方法的执行次数,如果次数超过一定的阈值就认为为热点方法
  • 虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter) ,在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过这个阈值就会触发JIT编译
  • 方法调用计数器: 用于统计方法被调用的次数,默认阈值在C1模式下1500次,在C2模式下是1万次,而在分层编译下,将会根据当前待编译的方法数以及编译线程数来动态调整
  • 回边计数器: 用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边(Back Edge),在不开启分层编译的情况下,C1默认13995,C2默认10700,在分层情况下,将根据当前编译的方法数以及编译线程数来动态调整
  • 建立回边计数器主要目的是为了出发OSR(On StackReplacement)编译,即栈上编译,对于一些循环周期比较长的代码段,当循环达到回边计数器阈值时,JVM认为这段是热点代码,JIT编译器就会将其编译成机器语言并缓存,并在该循环时间段内,执行缓存的机器语言

编译优化技术

方法内联
  • 由于调用一个方法通常要经历压栈和出栈:调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完成后,在返回到执行该方法前的位置
  • 这样执行要求执行前保护线程并记忆执行的地址,执行后恢复现场并按照原来保存的地址继续执行该方法调用会缠上一定的时间和空间方面的开销
  • 但是对于方法体代码不大有频繁调用的方法,这个开销就很大了
  • 方法内联的优化就是将目标方法的代码复制到发起调用的方法之中,避免发生真是的方法调用,如kotlin扩展函数中的inline关键字
  • JVM会自动识别热点方法,并对它们使用方法内联进行优化,但是热点方法并不一定会被JVM做内联优化,如果这个方法太大将不会执行内联操作
    • 经常执行的方法,默认情况下,方法体大小小于325字节都会进行内联,可设置
    • 不是经常执行的方法,默认情况下,方法大小小于35字节才会进行内联,可设置
    • 我们可以通过配置JVM参数参看(Intellij 类上Edit configurations 中设置VM options)
      -XX:+PrintCompilation // 在控制台打印编译过程信息
      -XX:+UnlockDiagnosticVMOptions // 解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断
      -XX:+PrintInlining // 将内联方法打印出来
      
  • 热点方法内联优化可以有效提高系统性能,我们有一下方法提高:
    • 通过设置JVM参数来减小热点阈值或增加方法体阈值,但是需要占用更多的内存
    • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体
    • 尽量使用final,private ,static关键字修饰方法,编码方法因为继承,会需要额外的类型检查
逃逸分析
  • 逃逸分析基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,他可能被外部所引用,例如作为参数传递到其他地方中,称为方法逃逸
     public static StringBuffer craeteStringBuffer(String s1, String s2) { //sb对象逃逸了
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb;
    }
    
    public static String createStringBuffer(String s1, String s2) { //sb对象没有逃逸
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
    
  • 使用逃逸分析,编译器可以做一下优化:(Jdk 1.7开始默认开启)
    1. 同步省略: 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作就可以不考虑同步
    2. 将堆分配转化为栈分配:如果一个对象在子程序中被分配,要使其指向改对象的指针永远不会逃逸,对象可以在栈上分配而不是堆分配
    3. 分离对象或标量替换: 有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在CPU寄存器中
同步省略(锁消除)
  • 在动态编译同步块时,JIT编译器会借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问儿没有被发布到其他线程,如果是只能被一个线程访问,则会取消这部分代码的同步,比如在使用synchronized时,如果JIT经过逃逸分析发现并无线程安全问题,就会做锁消除
    public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();//只在当前线程,所以会取消同步操作,锁消除
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
    
    public void f() {
        Object hollis = new Object();
        synchronized(hollis) { //锁方法内部对象,会锁消除
            System.out.println(hollis);
        }
    }
    //相当于
    public void f() {
        System.out.println(hollis);
    }
    
栈上分配
  • Java默认创建一个对象在堆中分配内存的,当对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对于分配在栈中的对象的创建和销毁来说,更加消耗时间和性能.这个时候逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上
  • 遗憾的是:HotSpot虚拟机目前的实现导致栈上分配实现比较复杂,暂时没有实现这项优化,相信不久将来会实现的
  • 虽然这项技术并不十分成熟,但是她也是即时编译器优化技术中一个十分重要的手段
标量替换
  • 标量(Scalar)是指一个无法再分解成更小的数据的数据,Java中的原始数据类型就是标量
  • 聚合量:相对于标量那些还可以分解的数据叫做聚合量,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量(如String为 char[] 数组和int hash)
  • 应用: 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问,那么经过JIT优化,就会吧这个对象拆分成若干个其中包含的若干个成员变量来代替(当程序真正执行时不用创建这个对象,而是直接创建他的成员变量来代替,拆分后,可以分配对象的成员变量在栈或寄存器上,则原本的对象就无需分配内存空间了),这个过程就是标量替换
    public static void main(String[] args) {
        Point point = new Point(1,2);
          System.out.println("point.x="+point.x+"; point.y="+point.y);
    }
    
    class Point{
        public int x;
        public int y;
        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    //Point对象会被替换成两个int型
    public static void main(String[] args) {
        x = 1;
        y = 2;
        System.out.println("point.x="+ x +"; point.y="+ y);
    }
    
  • 逃逸分析测试代码
    public class HelloTest {
        public static void alloc() {
            byte[] b = new byte[2];
            b[0] = 1;
        }
    
        public static void main(String[] args) {
            long b = System.currentTimeMillis();
                for (int i = 0; i < 100000000; i++) {
                    alloc();
                }
                long e = System.currentTimeMillis();
                System.out.println(e - b);
            }
        }
    }
    
  • 使用下方命令配置JVM(上面有如何在IDEA中配置,本身默认开启了,可关闭查看数据)
    //C1编译器参数 -client C2编译器 -server
    //开/关 逃逸分析(JDK 6u23以上) 开/关锁消除        开/关标量替换                打印GC日志
    //-XX:+DoEscapeAnalysis -XX:+EliminateLocks -XX:+EliminateAllocations -XX:+PrintGCDetails -Xmx10m -Xms10m
    //-XX:-DoEscapeAnalysis -XX:-EliminateLocks -XX:-EliminateAllocations -XX:+PrintGCDetails -Xmx10m -Xms10m
    
    //开启标量替换结果
    [GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->672K(9728K), 0.0014005 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    [GC (Allocation Failure) [PSYoungGen: 2536K->488K(2560K)] 2720K->712K(9728K), 0.0007950 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    [GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 2760K->736K(9728K), 0.0015657 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    10
    //关闭标量替换结果
    无数次GC,运行时长 1873毫秒
    
  • 总结: 栈上的空间一般而言是非常小的,只能存放若干变化和小的数据结构,大容量的存储结构是做不到。这里的例子是一个极端的千万次级的循环,突出了通过逃逸分析,让其直接从栈上分配,从而极大降低了GC的次数,提升了程序整体的执行效能。所以,逃逸分析的效果只能在特定场景下,满足高频和高数量的容量比较小的变量分配结构,才可以生效!