Java虚拟机总结

Java虚拟机总结

Java 代码为何在虚拟机中运行,以及如何在虚拟机中运行。

java语言的可移植性。一旦 Java 代码被编译为 Java 字节码,便可以在不同平台上的 Java 虚拟机实现上运行。此外,虚拟机还提供了一个代码托管的环境,代替我们处理部分冗长而且容易出错的事务,例如内存管理。

Java 虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC 寄存器(程序计数器)、Java 方法栈和本地方法栈。Java 程序编译而成的 class 文件,需要先加载至方法区中,方能在 Java 虚拟机中运行。

为了提高运行效率,标准 JDK 中的 HotSpot 虚拟机采用的是一种混合执行的策略。

它会解释执行 Java 字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。

HotSpot 装载了多个不同的即时编译器,以便在编译时间和生成代码的执行效率之间做取舍。


虚拟机的boolean类型

boolean类型的值要么是true要么是false 定义别的值编译器就会报错,实际上编译器在把java文件编译成class文件的时候自动把true转换成1,false转换成2,Java 代码中的逻辑运算以及条件跳转,都是用整数相关的字节码来实现的。

验证:

public class TestBoolean {
    public static void main(String[] args) {
        try {
            boolean flag = true;
            if (flag) {
                System.out.println("hello -java");
            }
            if (flag == true) {
                System.out.println("hello jvm");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

使用javap -c查看字节码文件得到

public static void main(java.lang.String[]);
Code:
   0: iconst_1 // 这句话对应 boolean flag = true; 
               // 意思是将int型(1)推送至栈顶 可见编译器把true专成了1 
   1: istore_1
   2: iload_1
   3: ifeq          14
  // 略...

jvm如何加载类,或者说怎么把class文件加载到jvm

javac把Java文件编译成了class文件,但是并没完,编译成的class在电脑硬盘上,并没有加载到jvm。

虚拟机加载class文件大致需要三步:

1、加载

加载,是指查找字节流,并且据此创建类的过程,Java 虚拟机需要借助类加载器来完成查找字节流的过程。

加载器有:

启动类加载器(boot class loader)加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)

拓展加载器(extension class loader)扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

应用类加载器(application class loader)应用类加载器的父类加

载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

自定义加载器 实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。这就是所谓的 双亲委派模型

2、链接

链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。

验证:确保被加载类能够满足 Java 虚拟机的约束条件。

准备:为被加载类的静态字段分配内存。除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

解析: 阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载。

3、初始化

为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。


jvm如何调用方法

在 Java 中,方法存在重载以及重写的概念,重载指的是方法名相同而参数类型不相同的方法之间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。

Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。jvm调用重载或者重写方法是不同的,采用了静态绑定和动态绑定可以理解成重载是静态绑定,重写是动态绑定。

静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于 Java 编译器已经区分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。

在 class 文件中,Java 编译器会用符号引用指代目标方法。在执行调用指令前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息(方法表)。方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。

总结下就是jvm调用方法的时候判断是静态绑定还是动态绑定,静态绑定的话,在编译阶段会存一个符号引用,加载到jvm时转成实际引用,这样就拿到了想要的信息。动态绑定的话,从方法表里取信息。


jvm异常处理

Java 字节码中,每个方法对应一个异常表。当程序触发异常时,Java 虚拟机将查找异常表,并依此决定需要将控制流转移至哪个异常处理器之中。Java 代码中的 catch 代码块和 finally 代码块都会生成异常表条目。

try-catch的处理
public class TestException {
    public static void main(String[] args) {
        try {
            int i = 1 / 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
}
编译后的字节码:
public static void main(java.lang.String[]);
Code:
   0: iconst_1
   1: iconst_0
   2: idiv
   3: istore_1
   4: goto          12
   7: astore_1
   8: aload_1
   9: invokevirtual #3                  // Method java/lang/Exception.printStackTrace:()V
  12: return
Exception table:
   from    to  target type
       0     4     7   Class java/lang/Exception

编译过后,该方法的异常表拥有一个条目。其 from 指针和 to 指针分别为 0 和 4,代表它的监控范围从索引为 0 的字节码开始,到索引为 4 的字节码结束(不包括 4)。该条目的 target 指针是 7,代表这个异常处理器从索引为 7 的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是 Exception。

当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。

如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。

try-catch-finally的处理

finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。也就是是在编译的时候 把finally的代码分别复制到try和catch的代码里面,这样就解释了为什么finally的代码为什么一定会执行!


jvm实现反射

创建反射对象class.forName(XX.XX.XX),这里想当于一个符号引用,类加载的时候转成实际引用,加载完成类的信息存到方法区,这样取类的信息就行了。

java对象的内存布局

  1. 对象头包括两部分:

第一部分:是存储对象自身的运行时数据,如哈希码,GC 分代年龄,锁状态标志,线程持有的锁等等。

第二部分: 是类型指针,即对象指向类元数据的指针。

  1. 实例数据:是对象真正存储的有效信息,也是在程序代码中所定义的各种类
    型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
  2. 对齐填充:不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

java内存模型

Java 程序是需要运行在 Java 虚拟机上面的,Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

JMM 是一种规范,是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

目的是保证并发编程场景中的原子性、可见性和有序性。


jvm如何实现synchronized

当声明 synchronized 代码块时,编译而成的字节码将包含 monitorentermonitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),作为所要加锁解锁的锁对象。

当用 synchronized 标记方法时,你会看到字节码中方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。

monitorenter 和 monitorexit 的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。

synchronized 关键字的实现,按照代价由高至低可分为重量级锁、轻量级锁和偏向锁三种。

重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。Java 虚拟机采取了自适应自旋,来避免线程在面对非常小的 synchronized 代码块时,仍会被阻塞、唤醒的情况。

轻量级锁采用 CAS 操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。

偏向锁只会在第一次请求时采用 CAS 操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。


什么是垃圾回收机制?

  • Java 中对象是采用 new 或者反射的方法创建的,这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由 Java 虚拟机通过垃圾回收机制完成的。GC 为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控。
  • Java 程序员不用担心内存管理,因为垃圾收集器会自动进行管理。
  • 可以调用下面的方法之一:System#gc()Runtime#getRuntime()#gc() ,但 JVM 也可以屏蔽掉显示的垃圾回收调用。

为什么不建议在程序中显式的声明 System.gc() ?

因为显式声明是做堆内存全扫描,也就是 Full GC ,是需要停止所有的活动的(Stop The World Collection),对应用很大可能存在影响。

另外,调用 System.gc() 方法后,不会立即执行 Full GC ,而是虚拟机自己决定的。

如果一个对象的引用被设置为 null , GC 会立即释放该对象的内存么?

不会, 这个对象将会在下一次 GC 循环中被回收。

#finalize() 方法什么时候被调用?它的目的是什么?

#finallize() 方法,是在释放该对象内存前由 GC (垃圾回收器)调用。

  • 通常建议在这个方法中释放该对象持有的资源,例如持有的堆外内存、和远程服务的长连接。
  • 一般情况下,不建议重写该方法。
  • 对于一个对象,该方法有且仅会被调用一次。

如何判断一个对象是否已经死去?

有两种方式:

  1. 引用计数
  2. 可达性分析

1)引用计数

每个对象有一个引用计数属性,新增一个引用时计数加 1 ,引用释放时计数减 1 ,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题。目前在用的有 Python、ActionScript3 等语言。

2)可达性分析(Reachability Analysis)

从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。目前在用的有 Java、C# 等语言。

如果 A 和 B 对象循环引用,是否可以被 GC?

可以,因为 Java 采用可达性分析的判断方式。

在 Java 语言里,可作为 GC Roots 的对象包括以下几种?

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中的类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中 JNI(即一般说的 Native 方法)中引用的对象。

JVM 垃圾回收算法?

有四种算法:

  1. 标记-清除算法
  2. 标记-整理算法
  3. 复制算法
  4. 分代收集算法

1)标记-清除算法

标记-清除(Mark-Sweep)算法,是现代垃圾回收算法的思想基础。

标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。

一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。

  • 缺点:

    • 1、效率问题,标记和清除两个过程的效率都不高。
    • 2、空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2)标记-整理算法

标记整理算法,类似与标记清除算法,不过它标记完对象后,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

  • 优点:

    • 1、相对标记清除算法,解决了内存碎片问题。
    • 2、没有内存碎片后,对象创建内存分配也更快速了(可以使用TLAB进行分配)。
  • 缺点:

    • 1、效率问题,(同标记清除算法)标记和整理两个过程的效率都不高。

3)复制算法

复制算法,可以解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可(还可使用TLAB进行高效分配内存)。

  • 优点:

    • 1、效率高,没有内存碎片。
  • 缺点:

    • 1、浪费一半的内存空间。
    • 2、复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

4)分代收集算法

当前商业虚拟机都是采用分代收集算法,它根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法。

  • 在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就选用复制算法。
  • 而老年代中,因为对象存活率高,没有额外空间对它进行分配担保,就必须使用“标记清理”或者“标记整理”算法来进行回收。

JVM 垃圾收集器有哪些?

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

  • 新生代收集器

    • Serial 收集器
    • ParNew 收集器

    ParNew 收集器,是 Serial 收集器的多线程版。

    • Parallel Scavenge 收集器
  • 老年代收集器

    • Serial Old 收集器

      • Serial Old 收集器,是 Serial 收集器的老年代版本。
    • Parallel Old 收集器

      • Parallel Old 收集器,是 Parallel Scavenge 收集器的老年代版本。
    • CMS 收集器
  • 新生代 + 老年代收集器

    • G1 收集器
    • ZGC 收集器

小结表格如下:

收集器串行、并行or并发新生代/老年代算法目标适用场景
Serial串行新生代复制算法响应速度优先单CPU环境下的Client模式
Serial Old串行老年代标记-整理响应速度优先单CPU环境下的Client模式、CMS的后备预案
ParNew并行新生代复制算法响应速度优先多CPU环境时在Server模式下与CMS配合
Parallel Scavenge并行新生代复制算法吞吐量优先在后台运算而不需要太多交互的任务
Parallel Old并行老年代标记-整理吞吐量优先在后台运算而不需要太多交互的任务
CMS并发老年代标记-清除响应速度优先集中在互联网站或B/S系统服务端上的Java应用
G1并发both标记-整理+复制算法响应速度优先面向服务端应用,将来替换CMS

常见 GC 的优化配置?

配置描述
-Xms初始化堆内存大小
-Xmx堆内存最大值
-Xmn新生代大小
-XX:PermSize初始化永久代大小
-XX:MaxPermSize永久代最大容量
-XX:SurvivorRatio设置年轻代中 Eden 区与 Survivor 区的比值
-XX:Xmn设置年轻代大小

其他可参考 《JVM 调优》 文章。

Last modification:October 9th, 2019 at 02:04 pm

Leave a Comment