JVM内存模型及JIT运行优化

JVM内存模型定义

  • JVM不仅承担了Java字节码的分析(JIT)和执行(Runtime),同时也内置了自动内存分配管理机制
  • 内存模型图解


    image
  • 堆是jvm内存中最大的一块内存空间,该空间被所有线程共享,几乎所有的对象和数组都被分配到了堆内存中: 堆被划分为新生代和老年代,新生代划分为Eden和Survivor区,Suvivor是由From Survivor和To Survivor组成
    • java6中,永久代在非堆内存去
    • java7中,永久代的静态变量和运行时常量池被合并到 了堆中
    • java8中,永久代被元空间取代了,元空间存储静态变量和运行时常量池跟java7永久代一样儿,都移到了堆中中


      image
程序计数器
  • 是一块很小的内存空间,主要用来记录各个线程执行的字节码地址 例如:分支,循环,跳转,异常,线程恢复都能依赖于计数器
  • 注意: 每个线程有一个单独的程序计数器来记录下一条运行的指令
方法区
  • 在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线程一起被创建,当创建一个线程时,会在虚拟机栈中申请一个栈帧,用来保存方法的局部变量,操作数栈,动态链接方法和返回地址等信息,并参与方法的调用和返回
  • 每个方法的调用都是一个入栈操作,方法的返回则是栈帧的出栈操作
    • 局部变量表存放了编译期可知的各种基本数据类型(bloolean,byte,char,short,int ,float ,long,double: 64位的long和double类型数据占用2个局部变量空间Slot,其余都占1个),对象引用refrencele:不是对象本身,可能是一个指向对象起始位置的引用指针,也可能是指向一个代表对象的句柄, returnAddress类型,指向一条字节码指令的地址
    • 每8个bit组成1byte字节,cpu每次只能访问1个字节,而不能单独访问具体的1个小格子,1byte就是内存的最小的IO单元,所以在对象中有个对其填充,对象不满8bit,需要补齐
    • 直接寻址技术: 是计算机软硬件的标准技术之一,cpu只要知道要访问的数据的内存地址,就能直接到内存的对应位置去访问数据
    • 计算32位操作系统的最大内存: 一个内存地址占用1byte字节,2^32 /1024/1024 = 4096M = 4G内存
    • 计算机操作系统会给内存每1个字节分配1个内存地址,CPU只需要知道某个数据类型地址,就可以直接去读取内存位置上提取数据了
    • 64位操作系统支持 4G * 4G = 17亿多GB内存,实际上16G已经不错了
    • 对于32位操作系统一个对象指针(内存地址为 32位bit值,所以1个指针就需要4byte字节),对于一个64位操作系统来说,一个内存地址是64位的二进制数,占用8个字节存放1个地址指针
  • 局部变量表所需的内存空间在编译期间完成分配,方法需要分配的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小
本地方法栈
  • 同Java虚拟机栈功能类似,Java虚拟机栈用来管理java函数调用的,本地方法栈用来管理本地方法的调用,是由C语言实现的
扩展: Java类中数据存储位置
  1. 全局变量: 全部存放在静态存储区,在程序开始执行时给全局变量分配存储区,程序完毕就释放,在执行过程中占据固定的存储单元:在类加载过程中的准备阶段进行初始化为默认值,在初始化中赋值成程序给与的指定值,所以全局变量可以不用指定值,但是局部变量必须初始化指定值方可使用,他们在栈帧中分配不参与类初始化中
  2. 局部变量: 栈帧中分配,一个栈帧需要多大内存在编译器就确定完成,程序运行阶段不会更改(栈帧中包括: 局部变量表, 操作数栈, 动态链接,方法返回地址及附加信息)局部变量存储在局部变量表中(相当于数组,可以通过正整数索引引用,通常索引0位this类),在编译阶段确定其大小long,double在32位中占用两个Slot,Refrence对象指针占用一个Slot(32位4个字节,64位8个字节,只存在局部变量表中,如果在堆中跟实际类型一致)

JIT运行时编译(优化Java)

类编译加载执行过程
  • Java编译到运行过程


    image
  1. 类编译
    • 将.java文件编译成.class文件(使用javac命令生成),编译后的字节码文件主要包括常量池和方法表集合这两个部分
    • 常量池主要记录的是类文件中出现的字面量以及符号引用
      • 字面常量包括字符串常量,声明为final的属性以及一些基本类型的属性
      • 符号引用包括类和接口的全限定名,类引用,方法引用以及成员变量引用(如String str = "abc",其中str就是成员变量引用,通过javap -verbose -p可查看类常量表中的全限定名等)
  2. 类加载
    • 当一个类被创建实例或者被其他对象引用时,虚拟机在没有加载过该类情况下,会通过类加载器将字节码文件加载到内存中
    • 不同的实现类有不同的类加载器加载,JDK中本地方法类一般由根加载器加载,JDK中内部实现的扩展类一般由扩展加载器实现加载,程序中的类文件则由系统加载器实现加载
    • 在类加载后,class类文件中的常量池信息以及其他数据会被保存到JVM内存的方法区中
    • 主要包括以下步骤:
      1. 通过一个类的全限定名来获取定义此类的二进制字节流
      2. 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
      3. 在内存中生成一个代表这类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
    • 应用:
      1. 从zip包中读取class,最终成为日后Jar包,ear,war格式基础
      2. 从网路中获取,热更新等
      3. 运行时计算生成,动态代理,将特定接口生成如$Proxy代理类的二进制字节流
  3. 类链接: 验证,准备,解析
    • 验证: 验证类符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全,因此验证阶段是否严谨直接决定了JVM是否能够承受恶意代码攻击
      • 四个阶段验证:
      1. 文件格式验证: 验证class文件字节流是否符合class文件格式,并能够被当前版本的JVM处理(魔数,主版本,次版本,常量池(检查常量的tag是否有不被支持的(acc_synchronized等))等校验)
        • 目的是为了保证输入的字节流能正确的解析并存储于方法区之中,只有通过这个阶段,下面三个才能进行,不会在直接操作字节流
      2. 元数据验证:主要对字节码描述信息进行语义分析保证其描述信息符合java语言规范要求
        • 这类是否有父类
        • 这个类的父类是否不允许继承(被final修饰)
        • 如果这个类不是抽象类,是否实现了其父类或接口之中要求必须实现的所有方法
        • 类中的字段,方法是否同父类产生冲突(覆盖了父类的final方法,或者出现不符合的方法重载,方法参数一致,只有返回值不同这种方法签名是一致的,注意同桥接方法中JVM的方法签名区分:泛型中子类重写父类的get属性具体参考https://www.cnblogs.com/jixp/articles/10264034.html)
      3. 字节码验证: 通过数据流和控制流分析,确定程序语义是合法,符合逻辑的,当2中对元数据信息中的数据类型进行校验分析后,这个阶段保证被校验的类的方法在运行时不会做出危害JVM安全事件
        • 保证操作数栈的数据类型与指令代码序列配合工作,比如iconst_1,其后却 lload_0导致数据类型不一致
        • 保证跳转指令不会跳转到方法体以外的字节码指令上
        • 保证方法体的类型转化是有效的,如子类赋值给父类安全,父类不能赋值给子类等
      4. 符号引用验证: JVM将符号引用转化为直接引用阶段,主要是对类自身以外信息进行校验,确保解析动作能够完成
        1. 符号引用中通过字符串描述的全限定名是否能够找到对应的类
        2. 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段(类中是否有方法名等)
        3. 符号引用中的类,字段,方法访问性(public,protected,private,default)是否能够被当前类所访问
        4. 符号引用验证为了确保解析动作能够正常执行,如果不能通过,则会抛出IllegalAccessError,NoSuchFieldError,NoSuchMethodError等异常;
      • 如果我们确定代码没有问题,可以使用 -Xverfity:none来关闭大部分验证从而节省时间
    • 准备: 为类的静态变量分配内存,初始化为系统的初始值,对于final static修饰的常量,直接赋值为用户定义值,对于static修饰变量会赋值为默认初始值
      private final static int value = 123 //赋值为123
      private static int lala = 123 //赋值为0
      
      • 注意: 内存分配的仅包括类变量,不包括实例变量(方法时,同对象的实例化随着对象一起分配在堆中的)
      • 当被final修饰类变量时,比如上方代码解析字段属性表为:编译时javac会为value生成ConstantValue属性的
          #17 = Utf8               value //value值有ConstantValue属性在准备阶段初始化为指定值123,
          //而下方的lala则被初始化为默认值0
          #18 = Utf8               I
          #19 = Utf8               ConstantValue
          #20 = Integer            123  //value初始化为123
          #21 = Utf8               lala  //初始化lala 为 0 
        
    • 解析: 将符号引用转为直接引用的过程:编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替
      • 类结构文件的常量池中存储了符号引用:包括类和接口的全限定名,类引用,方法引用以及成员变量引用等,如果需要使用以上类和方法,就西药将他们转化为JVM可以直接获取的内存地址或指针,即直接引用;
      • 解析动作主要针对 类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用限定符的符号引用
  4. 类初始化
    • 类初始化是类加载的最后一个阶段,初始化时,JVM首先将执行构造器方法,编译器会将.java文件编译成.class文件时,收集所有类初始化代码,包括静态变量赋值语句,静态代码块,静态方法,收集在一起成为方法
    • 初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和Java源码从上到下的顺序一致(在准备阶段设置过默认值,在方法中设置用户定义的值)
    • 子类初始化时会首先调用父类的()方法,在执行子类的方法
    • JVM会保证()方法的线程安全,保证同一时间只有一个线程执行,不论是静态内部类还是非静态内部类都是在第一次使用时才会被加载
      • 注意一个小例子: 在序列化和反序列化静态变量都是不会参与的,因为序列化保存的是对象的状态,而静态变量属于类的状态,因此序列化并不会保存静态变量
      //使用于静态内部类生成单例模式
      public class SingleTonFactory {
      
          private SingleTonFactory(){}
          //静态内部类不会引用外部类对象,不会内存泄漏
          //当引用类的静态变量才会加载该Instance类,且赋值操作在方法内,JVM保证其线程安全,且只执行一次,得到的自然是单例对象
          public static class Instance{ 
              static SingleTonFactory instance = new SingleTonFactory();
          }
          public SingleTonFactory getInstance(){
              return Instance.instance ; 
          }
      }
      
    • 对于类和接口来说并不是一定的,如果一个类没有静态代码块,也没有对类变量的赋值操作,则编译器不会为该类生成方法
    • JVM在初始化执行代码时,如果实例化一个新对象,会调用方法对实例变量就行初始化,并执行对应的构造方法内的代码
    • 注意: 类变量或者static修饰的类的静态代码块执行顺序先执行父类在执行子类,同一个类由代码顺序决定的,注意方法由于方法,所以构造函数后与static执行,也是由父类->子类,有一个特殊的情况
      static {
          i = 1 ; //对后面定义的类变量赋值是允许的,但是不能访问,下面的句子报错
      //  System.out.println(i);
      }
      public static int i = 2 ;
      
      • 解释: 通过上面我们知道i的默认初始值是在准备阶段设置为 i = 0了,而初始化阶段执行的是静态代码块的执行和静态变量的赋值,因此当对i=1赋值时,此时i存在且为默认值0 ,但是却不能访问,因为他违反了happened before原则,不能通过编译,是一个JVM规范
      • 这个报的错是非法向前引用。这其实是一个语法规定,对于静态变量,你可以在它的声明前面赋值,但是不允许你在它的声明前面访问
      • 静态代码块 -> 非静态代码块 -> 构造函数 ,且静态代码块只在加载类时执行一次,而非静态代码块同构造函数是随着对象执行的
初始化时机,当且仅有以下几种情况:
  1. 创建类的实例: new方式
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值(被final修饰已在编译器放入常量池的静态字段除外)
  3. 调用类的静态方法
  4. 反射调用
  5. 初始化某个类的子类,则其父类也会被初始化
  6. jvm启动时被标明为启动类的类(javaTest),直接使用java.exe命令来运行某个主类
  • 注意一下情况不会初始化类:
    1. 通过子类类名调用父类的静态代码,只会初始化父类,不会触发子类的初始化
    2. 通过数组来创建对象不会触发此类的初始化 StringTest[] strArray = new StringTest[10]并不会初始化类
    3. 通过调用静态常量不会触发初始化,比如调用final static修饰的其他类编译器已经替换成常量了,并不会调用其他类的初始化
  • 上面实例说到了final,那么我们就看看final的含义吧?

final关键字

  • 首先记住两点:
    1. final修饰的类不可被继承,但该类可以继承其他类;
    2. final修饰的方法不可以被覆盖,但可以被继承使用;
      • 1,2反编译class文件发现无论类属性,还是方法属性中的flags都有ACC_FINAL属性标识
    3. final修饰的变量
      1. 如果是基本数据类型,只能被赋值一次,不能在改变;
      2. 如果是指向对象的引用,地址值不能改变,但地址值被的属性对象可以更改;
  • final 方法在编译阶段绑定,称为静态绑定
  • 将类,方法,变量声明为final能够提高性能,JVM有机会进行优化
    1. 提高了性能,常量池中缓存final变量
    2. final变量多线程并发安全,无需同步
    3. final方法时静态编译的,提高了调用速度,不存在多态动态分配
    4. final类创建的对象时只读的,在多线程可以安全共享
final用法
  1. 空白final
    • final修饰的成员变量并不一定要立刻赋值,而是可以先声明,而不给初始值,这种变量就是final空白,编译器确保空白final在使用之前必须被初始化;同时给与final使用上更大的灵活性,根据对象不同而改变,却有保持其值一旦赋值永恒不变特性
      public class FinalNull{
          final int a ;  //这个就是空白final
          final int b=3; 
          public FinalNull(int i){
              a = i ; //可以正常赋值的
              //b = 2 ; //不能再次赋值了
          }
      }
      
匿名内部类访问局部变量必须加final
  • 主流去有两种观点:
内部类对象的生命周期和局部变量的生命周期不一致
  • 局部变量定义在方法内,是在java栈上分配的,随着方法运行结束而终结,但是内部类此时并不一定结束,当方法结束时,局部变量已死亡,但内部类中在引用就会失败,所以要求只要匿名内部类对象还活着,那么栈中的数据就不能死亡
  • 解决方式: 将方法的局部变量使用final修饰,定义为final后,编译器会把匿名内部类对象要访问的所有final类型局部变量,都拷贝一份作为该对象的成员变量,这样即使栈中局部变量已死亡,匿名内部类中依然可以拿到局部变量的值,因为他自己拷贝了一份,且与原来局部变量值始终保持一致(final类型不可变)
    • 基本数据类型,直接拷贝值
    • 引用类型表示两个指针对应相同位置,当方法的局部变量死亡,但内部类中依然可以通过指针找到对应的引用对象(this.value = 外部value (被final修饰的))
    • Java8中如果局部变量被匿名类使用,会自动使用final修饰,只要你不在匿名内部类中修改值,即使不适用final修饰他也是不会报错的
    • https://blog.csdn.net/jiao_zg/article/details/78911469
匿名内部类访问局部变量,局部变量的拷贝会导致不一致
  • 匿名内部类来自外部闭包环境的自由变量必须是final的
    • 如果不是final的,则内部类会拷贝一份外部对象,但是在其内部可以重新指向新的地址,导致变量不同步,指向了不同的对象
    • 由于一个拷贝,导致内外两个变量无法实时同步,其中一方修改,另外一方都无法同步修改,所以要求final限制不能修改
    • 为何要拷贝呢? JVM的局部变量是在虚拟机栈上,这个变量无法进行共享,因此匿名内部类无法直接访问,因此只能通过值传递的方式,传递到匿名内部类中
    • 当变量时成员变量时,则会分配到堆中,他是一个共享数据区,我们可以通过this.成员变量获取,因此在创建内部类时,无需进行拷贝,甚至都无需将这个变量传递给内部类,直接通过this即可获取也就不用final修饰了
自由变量
  • 在A作用域中使用的变量X,却没有在A作用域中声明,而是在其他作用域中声明的,则对于A作用域来说X就是一个自由变量
    • 因此在内部类中使用的外边变量对于内部类来说就是一个自由变量
外部闭包
  • 外部环境如果持有内部函数所使用的自由变量,就会对内部函数形成闭包
  • 匿名内部类来自外部闭包环境的自由变量必须是final,除非自由变量来自类的成员变量(分配在堆上,是共享数据)
  • 思考: 成员内部类中不能有静态变量和静态方法?
    • 成员内部类是属于外部对象,不是属于类的,跟着外部对象的生命周期一致,而静态变量和静态方法是属于类的变量和方法,自然不能存在静态的了

思考题

  • 思考题: 反射中Class.forName()和ClassLoader.loadClass()的区别
装载:通过累的全限定名获取二进制字节流,将二进制字节流转换成方法区中的运行时数据结构,在内存中生成Java.lang.class对象; 
链接:执行下面的校验、准备和解析步骤,其中解析步骤是可以选择的; 
    校验:检查导入类或接口的二进制数据的正确性;(文件格式验证,元数据验证,字节码验证,符号引用验证) 
    准备:给类的静态变量分配并初始化存储空间; 
    解析:将常量池中的符号引用转成直接引用;
初始化:激活类的静态变量的初始化Java代码和静态Java代码块,并初始化程序员设置的变量值。

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

ClassLoader.loadClass(className)方法,内部实际调用的方法是  ClassLoader.loadClass(className,false);
第2个 boolean参数,表示目标对象是否进行链接,false表示不进行链接,由上面介绍可以,
不进行链接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会得到执行
Java对象的创建
  • 虚拟机遇到一条new指令,首先检查这个指令的参数是否能在常量池中定位一个类的符号引用,并检查该符号引用代表的类是否已被加载,解析和初始化过,如果没有就先执行类加载过程
  • 类加载过以后,虚拟机就会为新生对象分配内存: 对象所需内存大小在类加载完成后便可完全确定,为对象分配空间就相当于把一块确定大小的内存从Java堆中划分出来
    • 对于使用Serial,ParNew等带有整理过程的GC,系统采用分配算法是指针碰撞: 堆中内存是绝对规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,当分配内存时就将指针向空闲空间那边移动一段与对象大小相等的距离;
    • 对于使用CMS这种局域Mark_Sweep算法GC,通常采用空闲列表: Java堆内存并不规整,已使用内存和空闲内存相互交错,虚拟机必须维护一个列表记录那些内存块是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
  • 当对象分配内存可能带来多线程安全性为题: 解决方式有两种
    1. 使用CAS失败重试保证更新分配操作的原子性
    2. 按照线程划分在不同的空间之中进行,每个线程在Java堆中预先分配一小块内存即TLAB线程本地缓存区,只有不足时才需要同步锁定,是否使用 -Xx:+/-UseTLAB
  • 对象访问方式: 一个对象已经在堆中创建成功,那么栈中如何访问它呢?
    1. 使用句柄: 在Java堆中划分出一块内存作为句柄池,reference存储的就是对象的句柄地址,而句柄中包括了对象实例数据和类型数据各自的具体地址信息
      • JNI引用java采用这种方式,这是为了GC回收不用遍历JNI方法找了,而是直接通过句柄池中指引的对象都不回收
      • 优点: 存储的是稳定的句柄地址,当对象被移动时(GC回收移动)只会改变句柄中实例数据的指针,而reference本身不需要修改,但是需要两次引用,开销大
    2. 直接指针: Java栈中使用,reference中存储的是对象在堆中的地址,同时注意还加上对象类型数据的指针(类型数据在方法区中,如:Class对象信息)
      • 优点: 速度更快,节省了一次指针定位的开销,Hotspot使用该方式!
并发情况下创建对象的安全性
  • 当一个线程正在给对象A分配内存,指针还没来得及修改,对象B有同时使用了原来的指针来分配内存了,怎么解决呢?
    • 分配内存是进行同步处理(CAS保证更新操作的原子性)'
    • 将内存分配的操作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),那个线程分配内存就在它自己的TLAB上分配,只有当TLAB用来重新分配新的TLAB时,才需要同步锁定
  • 当内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作在TLAB分配时进行,保证了对象实例子段在Java代码中可以不赋初始值直接使用,程序能访问到这些字段的数据类型所对应的零值,全局变量不用赋值就可使用
  • 虚拟机对对象进行必要的设置:放在对象头里面
  • 作为以上工作,一个新的对象产生了,但是从Java程序看,对象创建才刚刚开始,需要执行方法,所有字段都还为零,一般执行new指令之后会接着执行方法,把对象按照我们的意愿进行初始化
对象的访问定位
  • Java程序通过栈上的reference数据来操作堆上的具体对象,那么如何访问主流方式有 使用句柄和直接指针
    • 句柄访问: Java堆中划分一块内存作为句柄池,reference中存储的是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的地址信息
      • 优点:reference中存储的是稳定的句柄地址,在对象GC被移动时只需要更改句柄中实例数据指针,reference本身不需要更改
      • 缺点: 需要增加一次指针定位的时间开销
    • 直接指针访问: Java堆对象中放置了类型数据相关信息(在方法区中),而reference中存储的就是对象地址 HotSpot虚拟机采用的
      • 优点:速度快,节省了一次指针定位时间开销
虚拟机栈和本地方法栈溢出
  • HotSpot虚拟机并不区分虚拟机栈和本地方法栈,他们有两种异常
    1. 如果线程请求的栈深度大于虚拟机锁允许的最大深度,抛出栈溢出异常(1000~2000完全没问题)
    2. 如果虚拟机在扩展栈时无法申请到足够的内存空间,抛出OOM异常(在过多线程中内存溢出,如果不能减少线程数或更换62位虚拟机情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程)
即时编译
  • 解释执行: 将编译好的字节码一行一行的翻译为机器码执行 逐条翻译字节码为可运行机器码,不用等待: 用于大部分不常用的代码,无需耗时将其编译为机器码,逐条解释运行
  • 编译执行: 以方法为单位,将字节码一次性的翻译为机器码后执行 多次运行效率更高,小部分的热点代码(反复执行的重要代码),先翻译为符合机器的机器码从而高效运行
  • 初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,即为即时编译
    1. 虚拟机中的字节码是由解释器(Interpreter)完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为热点代码
    2. 为了提高热点代码的执行效率,在运行时 JIT会把这些代码编译成与本地平台相关的机器码,并进行层次的优化,然后保存到内存中
  1. 即时编译器类型
    • HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,他们的过程不同
      • C1编译器是一个简单快速的编译器,主要关注点在局部性的优化,适用于执行时间较短或对启动性能有要求的程序,如GUI应用对界面启动速度有一定要求
      • C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能优要求的程序,这两种编译器也被称为Client Compiler和Server Compiler
    • Java7之前,根据程序特性来选择对应的JIT,虚拟机默认采用解释器和其中一个编译器配合工作
    • Java7引入了分层编译,综合了C1启动性能优势和C2的峰值性能优势,通过设置参数可强制更改
    • C1跟C2编译器主要差别在于编译代码的时机不同(client比server编译器要早,C2编译器能够更好的优化,运行更快): 分层编译: 代码先由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关键字修饰方法,编码方法因为继承,会需要额外的类型检查
  • @HotSpotInstinsicCandidate标注
    • JDK中为了搞笑实现基于CPU指令,运行时HotSpot维护了
逃逸分析
  • 逃逸分析基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,他可能被外部所引用,例如作为参数传递到其他地方中,称为方法逃逸
     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的次数,提升了程序整体的执行效能。所以,逃逸分析的效果只能在特定场景下,满足高频和高数量的容量比较小的变量分配结构,才可以生效!
堆外内存
  • 堆内存的缺点:
    1. GC有成本,堆中对象越多,GC开销越大
    2. 使用堆内存进行文件,网络IO时,JVM会使用堆外内存做一次额外的中转,也就是多一次内存拷贝
  • 而相对应的,堆外内存就是把内存对象分配在Java虚拟机堆外的内存,这些内存直接受操纵系统管理(不是JVM)
    • 一定程度上减少GC对应用程序造成的影响
      ####### 堆外内存实现
  1. 使用ByteBuffer.allocateDirect(分配byte数组大小),得到一个DirectByteBuffer对象,堆内存回收,读写封装
  2. 调用Unsafe.allocateMemory分配,只能在JDK代码中调用,不常用
  • 堆内存回收: 当GC发现DirectByteBuffer对象变成垃圾时,会调用cleaner回收对应的堆外内存,防止内存泄漏,也可以手动调用该方法回收Cleaner.clean回收堆外内存
Cleaner继承PhantomRefrence(虚引用)
  • 当字段refrent(就是DirectByteBuffer对象)被回收时(当虚引用被回收时会收到一个系统通知),会调用到Cleaner.clean方法,进行堆外内存的回收
  • Cleaner是虚引用在JDK中的一个典型应用场景
堆外内存的使用场景
  • 适合长期存在或能复用的场景
  • 适合注重稳定的场景,避免GC导致暂停问题
  • 适合简单对象的存储:只能存储字节数组,需要序列化/反序列化操作
  • 适合注重IO效率的场景:读写文件性能优良
  • 文件IO (BIO, NIO)

你可能感兴趣的:(JVM内存模型及JIT运行优化)