JVM 学习(一)反射、垃圾回收、异常处理--- 2019年4月

时间:2023-12-16 10:59:14

1、JVM 基础知识点

  JVM 虚拟机包含了:自动内存管理器、垃圾回收(垃圾回收调优)。

  执行顺序:Java 代码 --- .class 字节码文件(加载到虚拟机中) --- Java 类放在方法区中。

  当执行一个 Java 方法时,Java 方法 --- 栈帧(Java 方法栈,存放局部变量/字节码的操作数栈、动态链接、方法出口等) --- 退出方法,弹出栈帧(无论方法是执行完还是遇到问题退出)

  JVM 虚拟机区域:共享区域 --- 堆、方法区,线程独享:Java 方法栈、本地方法栈、PC寄存器。

  .class 字节码文件无法直接运行,所以有了:

  解释编译器:边翻译字节码为机器码,边执行(存放的是冷门字节码,占80%左右)

  即时编译器:将热点代码,以方法为单位先编译成机器码,然后可以快速执行。遵循二八定律(20%热点代码占据80%的资源)

  即时编译器: C1、C2、Graal 三种。引入多个即时编译器,是为了在 编译时间 和 生成代码 的执行效率之间进行取舍。

  C1 又叫 Client 编译器,面向的是对启动性能有要求的客户端GUI程序;

  C2 又叫 Server 编译器,面向对峰值性能有要求的服务端程序,优化手段相对复杂,编译时间较长,但同时生成代码的执行效率较高。

  Java7 开始,热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。基本按1:2的比例配置线程给C1及C2编译器。

  JVM 虚拟机会统计每个方法被调用了多少次,超过多少次就认为是热点方法。默认分层编译达到2000调C1,达到15000调C2。

  PC寄存器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

  Java 堆是垃圾回收器管理的主要区域(GC堆)。

  运行时常量池:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。

  JVM 虚拟机中,boolean 被映射为 int 类型,true为1,false为0。

  char 的取值范围为正数,所以一般可以做数组索引。

  栈帧:局部变量区 --- 包括了局部变量和 " this 指针"以及方法接收的参数。

  字节码操作数栈 --- 算数运算依赖

  将 boolean、byte、char、short 加载到操作数栈里面,然后将栈上的值当做 int 类型来计算。

  对象在内存中存储的布局可以分为3块区域:对象头、实例数据、对齐填充。

  对象头:存储对象自身运行时的数据例如 GC 分代年龄、锁状态标志、线程持有的锁等。

  创建对象是为了使用对象,在方法区存储了对象的地址,指向对象实例在堆中的位置。

2、JVM 虚拟机执行方法调用

  JVM 虚拟机识别方法:类名、方法名、方法描述符(入参和返回类型的结合),字节码调用这些方法,如果有重复的,就会在类的验证阶段报错。(编译期就完成重载,所以可以认为 JVM 没有重载)

  JVM 静态绑定:在解析时能够直接识别目标方法的情况。

  JVM 动态绑定:在运行过程中,根据调用者的动态类型来识别目标方法的情况。

  Java 字节码中与调用相关的指令有五种:(编译的时候,将对应的方法调用编译为对应的 invoke 指令)

  invokestatic:调用静态方法;

  invokespecial:调用私有实例方法、构造器,使用 super 关键字调用父类的实例方法或构造器,和所实现的接口的默认方法;

  invokeinterface:调用接口方法;

  invokevirtual:调用非私有方法;

  invokedynameic:调用动态方法。

  对于前两种指令,JVM 虚拟机可以直接识别具体的目标方法

  后两种指令,绝大部分情况下,JVM 虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法,除非方法标记为 final (使用静态绑定)。

  调用指令的符号引用

  编译过程,我们不知道目标方法的具体内存地址,JVM 虚拟机用符号引用来表示该目标方法。

  符号引用包括:目标方法所在的类/接口,目标方法的方法名和方法描述符。

  符号引用存储在 class 文件的常量池中,接口符号引用和非接口符号引用(解析步骤不同,接口引用主要查接口和 Object 类中的实例,非接口引用主要查类所实现的接口中搜索)。

  对于静态绑定的方法调用,实际引用是一个指向方向的指针。

  对于动态绑定的方法调用,实际引用是一个方法表的索引。该做法是用空间换时间。

  类加载机制

  加载 --- 链接 --- 初始化

  类加载的准备阶段,除了为静态字段分配内存之外,还会构造与该类相关联的方法表。

  方法表的本质是一个数组。每一个子类重写方法在方法表的索引值,与父类方法表中被重写的方法的索引值一致。每个数组元指向一个方法实例。

  根据符号引用,JVM 虚拟机在解析虚方法调用时,Java 虚拟机会记录下所生命的目标方法的索引值,并且在运行过程中根据这个索引值去找具体的目标方法,然后执行方法。

  动态绑定的优化技术 --- 内联缓存

  可以缓存方法调用中,调用者的动态类型。下一次访问,直接访问缓存。如果缓存没有,内联缓存就退化为基于方法表的动态绑定。

  为了节省内存空间,Java 虚拟机只采用单态内联缓存。(只缓存一种动态类型以及它所对应的目标方法)

3、JVM 的异常处理

  所有异常都是 Thtowable 类或者其子类。

  子类 Error 被触发时,执行状态已经无法恢复,需要终止线程或者终止虚拟机。

  子类 Exception,涵盖程序可能需要捕获并且处理的异常。另 runtime Exception 属于运行时期才会发现的异常。

  异常实例的构造异常昂贵,因为 JVM 在构造异常实例时需要生成该异常的栈轨迹。这个操作会逐一访问当前线程的栈帧,并且记录下各种调试信息,例如类名、方法名等等。

  JVM 虚拟机捕获异常

  在编译生成的字节码中,每个方法都附带了一个异常表,异常表中的每一个条目代表一个异常处理器,有 from、to、target 指针以及所捕获的异常类型构成(catch 了多少个异常,就有多少个异常条目)。 这些指针的值是字节码索引,用来定位字节码。

  当程序触发异常,就会从上到下遍历异常条目。匹配到了一次,JVM 虚拟机就会将控制流转译至该条目 target 指针指向的字节码。 如果遍历完都没有匹配,那就会弹出方法对应的栈帧,终止方法。

  在 Java 7 之前,对于打开的资源,我们需要定义一个 finally 代码块,来确保该资源在正常或者异常执行情况下都能关闭,例如 IO 资源。

  在 Java 7 中构造了一个 try-with-resources 的语法糖,自节目层使用 Supressed 异常。在try 关键字后声明并实例化实现了 AutoCloseable 接口的类。 try (句柄对象) catch {},这样就不用手动关闭,多个句柄对象用分号 ; 分割。

  Java 7 还支持在同一 catch 代码块中捕获多种异常,实现就是生成多个异常表条目就可以。

  finally 实现无论异常与否都被执行:有编译器来实现,编译器在编译 Java 代码时,会复制 finally 代码块的内容,然后分别放在 try-catch 代码块所有的正常执行路径已经异常执行路径的出口中。

4、JVM实现反射

  允许正在运行的 Java 程序观测、甚至修改程序的动态行为。举例:可以通过 Class 对象获取该类中的所有方法,还可以通过 Method.setAccessible (位于 java.lang.reflect 包,该方法集成于 AccessibleObject )来绕过方法访问权限,私有方法其他类也可以访问。

  IOC 就是依赖于反射机制。(但是反射机制比较慢,性能开销大)

  性能开销大主要原因:变长参数方法导致的 Object 数组、基本类型的自动拆装箱、方法内联。

  方法的反射调用,也就是 Method.invoke。具体的实现:委派给 MethodAccessor 来处理,是一个接口,具体有两个实现 --- 委派实现、本地实现。

  每个 Method 实例的第一次反射调用都会生成一个委派实现,所委派的具体实现便是一个本地实现(进入 Java 虚拟机内部之后,便拥有了 Method 实例所指向方法的具体地址)。此时的反射调用就是将传入的参数准备好,然后调用进入目标方法。

  之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换(某个反射调用的次数在15次之下,采用本地实现。达到16次,便开始动态实现,即动态生成字节码)。

  Class.getMethod 会遍历该类的公有方法,Class.forName 会调用本地方法。所以操作都很费时。其实实践中我们会在应用中缓存 Class.forName 和 Class.getMethod 的结果。

  调优方法:方法内联、逃逸分析、不自动拆装箱。

  三个方法得到 Class 对象(Class.forName、getClass()、.class),然后 newInstance() 生成一个该类的实例。然后使用 isInstance() 来判断一个对象是否有该实例。 使用 Array.newInstance() 来构造该类型的数组。 使用 getFields/getConstructors/getMethods 来访问该类的成员。带 Declared 的方法,不会返回父类的成员,但是会返回私有成员。

  Inflation 机制:反射被频繁调用时,动态生成一个类来做直接调用的机制,加速反射调用。

5、Java 对象的内存布局

  新建对象方式:Object.clone 方法、反序列化、Unsafe.allocateInstance 方法和 new 语句、反射机制(通过调用构造器来初始化实例变量)。

  调用构造器:优先调用父类的构造器,直至 Object 类。这些构造器的调用皆为同一对象,也就是通过 new 指令新建而来的对象。

  通过 new 出来的对象,内存涵盖了所有父类中的实例变量,就算父类的私有实例变量,子类无法访问,但是子类的实例还是会为父类的实例变量分配内存。(普通变量在实例化的时候才分配内存空间,静态变量在程序加载字节码的时候分配内存空间)

  压缩指针

  Java 虚拟机中每个 Java 对象都有一个对象头,由标记字段和类型指针(类型指针指向该对象的类)所构成。

  64位的虚拟机中,对象头的标记字段占64位,类型指针占64位。通过压缩指针,将堆中原本的64为的 Java 对象指针压缩为32位。这样一来,对象头的类型指针也会被压缩为32位。标记字段还是64位。这样对象头的大小就从16字节变为12字节。

  压缩指针不止可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。

  压缩指针原理:内存对齐 --- 每个对象的地址对齐到 8 的倍数。但是如果一个对象用不到8N个字节,空白的那部分空间就浪费了。还有个原因是让字段只出现在同一 CPU 的缓存行中,如果字段不对齐,可能出现跨缓存行的字段。虽然浪费了一定的空间,但是可以减少内存行的读取次数,总的来说对于 JVM 的性能得到了一定的提高。

  字段重排列

  Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。在代码中可能字段A、B这样排序,但是在字节码中可能是B、A排序,B字段去填充A字段对齐造成的字节缺口。

  扩展知识点

  动态语言:程序在运行时可以改变其结构。在 Java 中的主要方式 - 反射

  为什么引入基本类型:Integer 类为例子,仅有一个 int 类型的私有字段,占4个字节。但是类型指针就占64位(16字节),每一个 Integer 对象的额外内存开销至少是400%。

6、垃圾回收

  1)、引用计数法和可达性分析

  计数法就是有一个引用 + 1个数,没有引用 -1 个数。当为0的时候就说明该对象死亡。该做法有很多弊病,最常见就是互相调用的对象如果没有其他引用,也不能回收(应该被回收)。

  JVM 虚拟机垃圾回收,主流算法还是可达性分析

  一系列 GC Roots (堆外指向堆内的引用)做为初始的存活对象合集,从该合集出发,探索所有能够被该集合引用到的对象,并将其加入该集合中,这个过程称之为 标记。 最后,未被探索到的对象便是死亡的。

  可达性分析在多线程情况下也可能漏报和误报。

  GC Roots :Java 方法栈帧中的局部变量、已加载的静态变量、JNI handles、已启动未停止的 Java 线程。

  2)、Stop-the-world 以及安全点

  传统的垃圾回收简单粗暴,直接停止非垃圾回收线程的工作,知道完成垃圾回收。垃圾回收就会有暂停时间。

  安全点的目的不是让其他线程停下来,而且找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化,这样垃圾回收器就能够安全的执行可达性分析。

  安全情况:例如通过 JNI 执行本地代码,如果该段代码不访问 Java 对象,调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,这段代码就可以作为一个安全点。只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

  安全点:JNI 本地代码、解释执行字节码、执行即时编译器生成的机器码、线程阻塞。在除了 JNI 的其他几种状态进行插入安全点检测。

  在安全点的时候进行垃圾回收,间接的减少垃圾回收的暂停时间。

  3)、垃圾回收的三种方式

  第一种:清除,把死亡对象所占据的内存标记为空闲内存,记录再一个空闲列表中。当需要新建对象,内存管理模块就会从该空闲列表寻找空闲对象,划分给新建的对象。

  缺点:造成内存碎片、分配效率低。

  第二种:压缩,把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。能解决内存碎片化的问题。

  缺点:性能开销大。

  第三种:复制,把内存区域分为两等分,指针 form 和 to 来维护。发生垃圾回收后,将 to 区域的对象复制到 from 区域。

  缺点:空间的使用效率极其低下。

  4)、Java 虚拟机的堆划分

  新生代:Eden 区和两个大小相同的 Survivor 区。一般采用的是动态分配策略,根据生成对象的速率,以及 Survivor 区的使用情况,动态调整 Eden 区和 Survivor 区的比例。其中一个 Survivor 区会一直为空。因为Eden 区越大,浪费的堆空间越少。

  当我们调用 new 指令时,会在 Eden 区中划出一块作为存储对象的内存。因为堆是线程共享的,所以划空间是需要进行同步操作的。

  每个线程可以向 JVM 连续申请一段连续的内存,用来放该线程的多个对象。该操作需要加锁,并且维护两个指针,一个指针指向空余内存的起始位置,一个指针指向该段内存的末尾。接下来的 new 指令,直接通过指针加法来实现,把指向空余内存位置的指针加所请求的字节数。当该段线程使用完后,就再申请一段内存。

  Eden 区域耗尽了,JVM 就触发一次 Minor GC,收集新生代的垃圾。存活下来的对象,就会送到 Survivor 区。

  发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区,然后交换 from 和 to 指针,对象又放到了 from 指向的 Survivor 区,to 指向的区又为空。

  JVM 会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制了15次,该对象就被晋升到老年代。如果单个 Survivor 区被占用了50%,那么较高复制次数的对象也会被晋升至老年代。

  发生 Minor GC,垃圾回收器应用了 标记 - 复制算法。Minro GC 的另外一个好处是不用对整个堆进行垃圾回收。但是老年代的对象引用新生代的对象,也被称作为 GC Roots,就可能做了一次全堆扫描。所以就有了下面的解决方案。

  卡表

  大致的标出可能存在老年代到新生代引用的内存区域。减少老年代的全堆内存扫描。

  该技术将整个堆划分为512字节的卡,维护一个卡表用来存储每张卡的标识位。如果某个标识位存在有指向新生代对象的引用,就认为这张卡是脏的。进行 Minor GC 的时候,就不用扫描整个老年代,在卡表中寻找脏卡,将脏卡中的对象加入 Minor GC 的 GC Roots 中。完成所有脏卡的扫描后,JVM 会讲所有的脏卡标识位清零。

  扩展知识

  CMS是针对老年代的垃圾回收,在 Java 9 中已经被废弃,采用 G1 (横跨新生代和老年代的垃圾回收器)。

  新生代到老年代为什么是赋值15次,是因为对象头中的标记字段记录年龄,分配到的空间只有4位,所谓为2的4次方减1,只能记录到15次。