JVM详解


1、JVM定义

JVM(Java Virtual Machine)是一个虚拟出来的机器,是运行所有Java程序的抽象计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。

  • JVM是一种用于计算机设备的规范,可在不同的软硬件系统上加以实现。其包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。
  • JVM这种虚拟的计算机中运行的是Java字节码,负责装载字节码到其内部,被解释器解释执行,或者是被即时代码发生器有选择的转换成机器码执行。
  • JVM的这种仿真计算机运行字节码的机制实现了Java语言的平台无关性,使得Java语言编译成字节码之后,就可以在不同平台上运行。

Java运行框架

2、JDK、JRE与JVM

  • JRE(Java Runtime Environment),Java 运行时环境,内部包含了Java虚拟机(JVM)以及Java核心类库等运行Java程序的必要组件,计算机中只要安装了JRE就可以运行编译好的java程序。
  • JDK(Java Development Kit),Java 开发工具包,内部包含了JRE,以及编译工具javac,打包工具jar,Java基础类库(Java API)等。

JDK、JRE、JVM

3、JVM架构

JVM 中主要有三个子系统

  • 类加载器子系统(Class Loader Sub System)
  • 运行时数据区(Runtime Data Area)
  • 执行引擎(Execution Engine)
    在这里插入图片描述

3.1 类加载器子系统

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

3.1.1 类加载流程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载,验证,准备,解析,初始化,使用,卸载这7个阶段,其中其中验证、准备、解析3个部分统称为连接。JVM没有规定类加载的时机,但却严格规定了五种情况下必须立即对类进行初始化,加载自然要在此之前。

  • 运行JVM必须指定一个含有main方法的主类,虚拟机会先初始化这个类。
  • 遇到new、getstatic、putstatic、invokestatic这四条指令时,如果类没有被初始化,则首先对类进行初始化。
  • 使用java.lang.reflect包的方法对类进行反射调用时,若类没有进行初始化,则触发其初始化。
  • 当初始化一个类时假如该类的父类没有进行初始化,首先触发其父类的初始化。
  • 当使用Jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic、REF_putstatic、REF_inokestatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化时,触发该类初始化。
1、加载

在加载的过程中,虚拟机会完成以下三件事情:

  • 通过一个类的全限定名加载该类对应的二进制字节流。
  • 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区各个类访问该类的入口。
2、验证

这一步的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。具体验证的东西如下:

  • 文件格式验证:这里验证的时字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据的验证:就是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,例如:这个类是否有父类,除了 java.lang.Object之外。
  • 字节码校验:字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:① 在字节码的执行过程中,是否会跳转到一条不存在的指令。② 函数的调用是否传递了正确类型的参数。③ 变量的赋值是不是给了正确的数据类型等。
  • 符号引用验证:虚拟机在将符号引用转化为直接引用,验证符号引用全限定名代表的类是否能够找到,对应的域和方法是否能找到,访问权限是否合法,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个 方法无法被找到,则会抛出NoSuchMethodError;这个转化动作将在连接的第三个阶段-解析阶段中发生。
3、准备
  • 为类变量(static修饰的变量)分配内存并且设置该类变量的默认初始值,即零值,初始化阶段才会设置代码中的初始值
  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显示初始化
  • 这里不会为实例变量分配初始化,类变量会分配在方法区,而实例变量是会随着对象一起分配给Java堆中。
4、解析

解析阶段是虚拟机将常量池内的符号引用(类、变量、方法等的描述符 [名称])替换为直接引用(直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄 [地址])的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

5、初始化

初始化阶段编译器会将类文件声明的静态赋值变量和静态代码块合并生成<clinit>方法并进行调用。

  • 初始化阶段就是执行类构造器方法<clinit>的过程,这个方法不需要定义,只需要类中有静态的属性或者代码块即可,javac编译器自动收集所有类变量的赋值动作和静态代码块中的语句合并而来
  • 构造器方法中指令按照源文件出现的顺序执行
  • 如果该类有父类,jvm会保证子类的<clinit>在执行前,执行父类的<clinit>
  • 虚拟机必须保证一个类的<clinit>方法在多线程情况下被加锁,类只需要被加载一次
3.1.2 类加载器分类

JVM层面支持两种类加载器:启动类加载器和自定义类加载器,启动类加载器由C++编写,属于虚拟机自身的一部分;继承自java.lang.ClassLoader的类加载器都属于自定义类加载器,由Java编写。逻辑上我们可以根据各加载器的不同功能继续划分为:扩展类加载器、应用程序类加载器和自定义类加载器。

在这里插入图片描述

1、启动类加载器
  • 由C/C++语言实现,嵌套在JVM内部
  • 负责加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类
  • 没有父加载器,加载扩展类和应用程序类加载器,并作为他们的父类加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
2、扩展类加载器
  • 由Java语言实现,派生于ClassLoader类
  • 负责加载java.ext.dirs系统属性所指定目录中的类库,或JAVA_HOME/jre/lib/ext目录(扩展目录)下的类库,如果用户创建的jar放在此目录下,也会自动由扩展类加载器加载
  • 作为类(在rt.jar中)被启动类加载器加载,父类加载器为启动类加载器
3、应用程序类加载器
  • 由Java语言实现,派生于ClassLoader类
  • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 作为类被扩展类加载器加载,父类加载器为扩展类加载器
  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过ClassLoader.getSystemClassLoader()方法可以获取到该类加载器,所以有些场合中也称它为“系统类加载器”
4、自定义类加载器

在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。自定义类加载器作用:

  • 隔离加载类(相同包名和类名的两个类会冲突,引入自己定义类加载器可以规避冲突问题)
  • 修改类加载的方式
  • 扩展加载源(默认从jar包、war包等源加载,可以自定义自己的源)
  • 防止源码泄漏(对编译后的class字节码进行加密,加载时用自定义的类加载器进行解密后使用)
3.1.3 类加载器写协作方式

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成Class对象,当触发类加载时,JVM并不知道当前类具体由哪个加载器加载,都是先给到默认类加载器(应用程序类加载器),默认类加载器怎么分配到具体的加载器呢,这边使用了一种叫双亲委派模型的加载机制。

1、双亲委派模型

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。举例如下:

  • 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  • 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  • 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
    若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
2、全盘负责

当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

3、缓存机制

缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

3.2 运行时数据区

Java虚拟机在执行Java程序时,会把它管理的内存划分为若干不同的数据区域。这区域各有各的用途以及生命周期。

3.2.1 方法区

在这里插入图片描述
方法区,也称非堆(Non-Heap),是一个被线程共享的内存区域。其中主要存储类的类型信息,方法信息,域信息,JIT代码缓存,运行时常量池等。
(1)方法区是各个线程共享的内存区域,在虚拟机启动时创建
(2)虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来
(3)用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据。
(4)当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
在JDK7之前,习惯把方法区称为永久代,而在JDK8之后,又取消了永久代,改用元空间代替。元空间的本质与方法区类似,都是对JVM规范中方法区这一内存区域的一种实现。不过元空间与永久代的最大区别就是:元空间不在虚拟机设置的内存中,而是直接使用的本地内存。所以元空间的大小并不受虚拟机本身的内存限制,而是受制于计算机的直接内存。

版本区别
JDK6及以前有永久代,字符串常量池(String Table),静态变量都存放在永久代上
JDK7有永久代,但字符串常量池,静态变量被从永久代中移出,放到了堆中
JDK8及以后无永久代,改用元空间代替,但字符串常量池,静态变量依然放在堆中

注:永久代大小启动的时候指定,启动后不能更改;元空间大小如果不指定,最大就是物理内存。

1、类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  • 这个类型的完整有效名称(全名=包名.类名)
  • 这个类型直接父类的完整有效名称(interface、Object 都没有父类)
  • 这个类型的修饰符(public,abstract,final的某个子类)
  • 这个类型直接接口的一个有序列表
2、域信息
  • JVM在方法区必须保存类型的所有域信息以及域的声明顺序
  • 域的类型包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
3、方法信息
  • 方法名称
  • 方法返回类型(或void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码、操作数栈、局部变量表及大小
  • 方法的字节码(bytecodes,就是方法的实体编译的字节码)、操作数栈、局部变量表及大小(abstract和native方法除外,因为没有方法体)
  • 异常表(abstract和native方法除外,因为没有方法体)
    每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常的常量池索引
4、运行时常量池

一个有效的字节码文件除了包含各个类的版本信息、字段、方法以及接口等描述信息外,还包含了一项信息就是常量池表(Constant Pool Table),里面包含有各种字面量和对类型、域和方法的符号引用。

字面量和符号引用:举个简单例子,String str = new String(“你好”);其中"你好"就是字面量,而str则为符号引用。str符号引用需要在类加载的解析阶段转换为直接引用,因为 str的值是可以变化的,我们不能在第一时间确定其真正的值,需要在动态运行中进行解析。符号引用包含以下三类:

  • 类和接口的全限定名称
  • 字段的名称和描述符
  • 方法的名称和描述符

一个字节码文件被加载到虚拟机后,字节码文件中的一些数据,如之前提到过的类型信息,域信息,方法信息等,就会被放置到方法区中,而字节码文件中的常量池则会进入方法区中的运行时常量池(注:字符串常量/字面量会放到堆中),JVM为每个已加载的类或者接口都维护一个常量池,即运行时常量池是每个类都有一个。常量池中数据像数组一样,是通过索引访问的。

Java并不要求常量必须在编译时才能产生,即并不是class文件常量池中的内容才能进入运行时常量池,在运行期间也同样可以将新的常量放入池中。比如,可以使用String.intern()动态生成字符串常量,String.intern()方法的作用就是当池中没有相应的字符串常量时,在运行时动态生成。

3.2.2 堆内存(Java堆)

在这里插入图片描述
Java堆是Java虚拟机所管理的内存最大的一块区域,Java堆是线程共享的,在虚拟机启动时创建。

  • 几乎所有的对象实例都在这里分配内存。
  • 字符串常量池(String Table),静态变量也在这里分配内存。
  • Java堆是垃圾收集器管理的内存区域,有些资料称为GC堆,当对象不再使用了,被当做垃圾回收掉后,这些为对象分配的内存又重新回到堆内存中。
  • Java堆在逻辑上应该认为是连续的,但是在具体的物理实现上,可以是不连续的。
  • Java堆可以是固定大小的,也可以是可扩展的。现在主流Java虚拟机都是可扩展的。
    -Xmx 最大堆内存
    -Xms 最小堆内存
  • 如果Java堆没有足够的内存给分配实例,并且也无法继续扩展,则抛出 OutOfMemoryError 异常。
1、堆内存结构
  • 堆内存从结构上来说分为年轻代(YoungGen)和老年代(OldGen)两部分;
  • 年轻代(YoungGen)又可以分为生成区(Eden)和幸存者区(Survivor)两部分;
  • 幸存者区(Survivor)又可细分为 S0区(from space)和 S1区 (to space)两部分;
  • Eden 区占大容量,Survivor 两个区占小容量,默认比例是 8:1:1;
  • 静态变量和字符串常量池在年轻代与老年代之外单独分配空间。
2、分代的意义

年轻代和老年代的划分是为了更好的内存分派及回收,提高效率。

  • 堆是垃圾回收机制的重点区域。我们知道垃圾回收机制有三种,minor gc,major gc 和full
    gc,针对于堆的就是前两种。年轻代的叫 minor gc,老年代的叫major gc。
  • 分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
  • 分代垃圾回收采用分治的思想,进行代际的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。
3、对象在堆中的初始分配位置

(1)对象优先在Eden 区分配
(2)大对象直接进入老年代
  上面提到,新创建的对象是优先存放入 Eden 区的,但是新创建的大对象会直接进入老年代。
  什么是大对象: 10M 的对象算大吗?100M 的对象呢?什么是大对象?大对象的标准是可以由开发者定义的,我们的 JVM 参数中,能够通过 -XX:PretenureSizeThreshold 这个参数设置大对象的标准,可惜的是这个参数只对 Serial 和 ParNew 两款新生代收集器有效。
  对于不能够设置 -XX:PretenureSizeThreshold 参数的JVM来说, Eden 区容量不够存放的对象就是所谓的大对象。

3、对象在堆中的转移

新生成的非大对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到Survivor0 区,Survivor0 区满后触发执行Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个Survivor 区为空。经过多次Minor GC 仍然存活的对象移动到老年代。
  如果新生成的是大对象,会直接将该对象存放入老年代。
  老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。
作用:JVM 通过判断对象的具体年龄来判别是否该对象应存入老年代,JVM通过对年龄的判断来完成对象从年轻代到老年代的转移。

4、对象年龄判断

对象年龄(Age)计数器:HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。
  年龄增加:对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor区中每熬过一次 Minor GC,年龄就增加 1 岁。
  年龄默认阈值:当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

5、Full GC触发机制

触发Full GC执行的情况有如下五种:

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
3.2.3 Java虚拟机栈

在这里插入图片描述

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的,因此也是线程安全的。

  • Java虚拟机栈是线程私有的,其生命周期和线程相同。
  • 虚拟机栈描述的是Java方法执行的线程内存模型,每个方法被执行,都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、参与方法的调用与返回等。
  • 每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中出入栈到出栈的过程
  • JVM 允许指定 Java 栈的初始大小以及最大、最小容量。
1、栈与堆
  • 栈是运行时的单位,而堆是存储的单位
  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据
  • 堆解决的是数据存储的问题,即数据怎么放,放哪里
2、栈帧

在这里插入图片描述
  定义:栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的 Java 虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。
  栈帧初始化大小:在编译程序代码的时候,栈帧中需要多大的局部变量表内存,多深的操作数栈都已经完全确定了。 因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
  栈帧结构:在一个线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
  
(1)局部变量表
在栈帧中,局部变量表占用了大部分的空间,那么接下来我们看下局部变量表的基本概念与特点。
  基本概念:每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。
  特点:
  a.局部变量表的容量以变量槽(Variable Slot)为最小单位;
  b.局部变量表中的 Slot 是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超过了某个变量的作用域,那么这个变量相应的 Slot 就可以交给其他变量去使用,节省栈空间。

(2)操作数栈
  方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程;
  操作数栈的每一个元素可以是任意的 Java 数据类型,32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2;
  简单的理解,操作数栈存放的是当前正在操作的变量,可以是局部变量或者对象实例字段中的常量和变量。比如执行到代码a=b+c时就会把a,b,c都存入到操作数栈来。
  
(3)动态链接
  动态链接保存的是一个引用或者说指针,它指向该栈帧所属方法在运行时常量池(方法区)中的地址,它支持着Java的多态特性。
  在 class 文件的常量池(存储字面量和符号引用)中存有大量的符号引用(1. 类的全限定名,2. 字段名和属性,3. 方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。
  这些符号引用一部分会在类加载过程的解析阶段转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),称为静态解析。另外一部分将在运行期期间转化为直接引用,称为动态链接(父类方法链接到子类,会引用子类重写的方法)。
  
(4)返回地址
  方法返回地址是方法在PC寄存器中的值,也即是该方法的指令地址,方便执行引擎在执行完该方法后,回到该方法对应的指令行号,这样才能继续执行下去。
  返回地址代表的是方法执行结束,当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:return),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 throw 字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
  方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。

3.2.4 程序计数器

程序计数器的英文全称是Program Counter Register,又叫程序计数寄存器。Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。JVM中的PC寄存器是对 物理PC寄存器的一种抽象模拟。

  • 程序计数器其实就是一个指针,它指向了我们程序中下一句需要执行的指令,可以看做当前线程执行的字节码的行数指示器。
  • 不管是分支、循环、跳转等代码逻辑,字节码解释器在工作时就是改变程序计数器的值来决定下一条要执行的字节码。
  • 每个线程都有一个独立的程序计数器,在任意一个确定的时刻,一个CPU内核都只会执行一条线程中的指令,CPU切换线程后是通过程序计数器来确定该执行哪条指令。
  • 程序计数器占用内存空间小到基本可以忽略不计,是唯一一个在虚拟机中没有规定任何OutOfMemoryError 情况的区域。
  • 如果正在执行的是Native方法,则这个计数器为空。
3.2.5 本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的。
只不过虚拟机栈为虚拟机执行的Java方法(即字节码)服务,本地方法栈为虚拟机执行的本地方法(Native方法、C/C++ 实现)服务。

与虚拟机栈一样,当栈深度溢出时,抛出 StackOverFlowError 异常。
当栈扩展内存不足时,抛出 OutOfMemoryError 异常。

3.3 执行引擎

在这里插入图片描述
JVM的主要任务之一是负责装载字节码到其内部(运行时数据区),但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只 是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。

那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine) 的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

3.3.1 解释器

当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令然后执行。

JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式由高级语言直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行,执行效率低。

在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低效。而模板解释器将每一条字节码和一个模板函数性关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

在Hotspot VM中,解释器主要由Interpreter模块和Code模块构成。

  • Interpreter模块:实现了解释器的核心功能。
  • Code模块:用于管理Hotspot VM在与运行时生成的本地机器指令。

由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如:Python、Perl、Ruby等。但就是因为多了中间这一“翻译”过程,导致代码执行效率低下。

为了解决这个问题,JVM平台支持一种叫做即时编译的的技术。即时编译的目的是为了避免函数被解释执行,而是将整个函数编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

3.3.2 即时(JIT)编译器

就是虚拟机将Java字节码一次性整体编译成和本地机器平台相关的机器语言,但并不是马上执行。JIT 编译器将字节码翻译成本地机器指令后,就可以做一个缓存操作,存储在方法区 的 JIT 代码缓存中。JVM真正执行程序时将直接从缓存中获取本地指令去执行,省去了解释器的工作,提高了执行效率高。

HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与及时编辑器并行的结构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

JIT 编译器执行效率高为什么还需要解释器?

  • 当程序启动后,解释器可以马上发挥作用,响应速度快,省去编译的时间,立即执行。
  • 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后,执行效率高。就需要采用解释器与即时编译器并存的架构来换取 一个平衡点。

是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用的执行频率而定。关于那些需要被编译成本地代码的字节码,也被称为热点代码,JIT编译器在运行时会对那些频繁被调用的热点代码做出深度优化,将其直接编译成对应平台的本地机器指令,以此提升Java程序的执行性能。

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体,都可以被称为热点代码。因此都可以通过JIT编译器编译成本地机器指令。由于这种编译方式发生在方法执行的过程中,因此也被称为栈上替换,或者简称为OSR编译。

一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准,必然需要一个明确的阈值。JIT编译器才会将这些热点代码编译成本地机器码执行。

3.3.3 垃圾收集器
1、JVM中的垃圾

简单的说垃圾就是内存中不再使用的对象,所谓使用中的对象(已引用对象),指的是程序中有指针指向的对象;而不再使用的对象(未引用对象),则没有被任何指针指向。如果这些不再使用的对象不被清除掉,我们内存里面的对象会越来越多,而可使用的内存空间会越来越少,最后导致无空间可用。

垃圾回收的基本步骤分两步:

  • 查找内存中不再使用的对象(GC判断策略)
  • 释放这些对象占用的内存(GC收集算法)
2、对象存活判断

即内存中不再使用的对象,判断对象存活一般有两种方式:引用计数算法可达性分析法

(1)引用计数算法

给对象添加一个引用计数器,每当有一个地方引用该对象时,计数器+1,当引用失效时,计数器-1,任何时候当计数器为0的时候,该对象不再被引用。

优点:引用计数器这个方法实现简单,判定效率也高,回收没有延迟性。
缺点:无法检测出循环引用。 如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0,Java的垃圾收集器没有使用这类算法。

(2)可达性分析算法
可达性分析算法是目前主流的虚拟机都采用的算法,程序把所有的引用关系看作一张图,从所有的GC Roots节点开始,寻找该节点所引用的节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

在这里插入图片描述
在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈中引用的对象(局部变量);
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(Native方法)引用的对象
  • 所有被同步锁持有的对象;
  • 虚拟机的内部引用如类加载器、异常管理对象;
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。

3、对象的finalization机制

Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

垃圾(内存中不再使用的对象)回收对象之前,会调用该对象的 finalize()方法(主要在对象被回收时进行资源释放,通常是一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等)

4、垃圾回收算法

(1)标记-清除算法

标记-清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象

  • 标记阶段
    标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的 GC Roots 对象,对从 GCRoots 对象可达的对象都打上一个标识,一般是在对象的 header 中,将其记录为可达对象;

  • 清除阶段
    清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header 信息),则将其回收。
    在这里插入图片描述
    标记-清除算法缺点

  • 效率问题
    标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是非常庞大的,这无疑很耗费时间,而且 GC 时需要停止应用程序,这会导致非常差的用户体验。

  • 空间问题
    标记清除之后会产生大量不连续的内存碎片(从上图可以看出),内存空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。

(2)复制算法

复制算法是将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块内存上,然后把这一块内存所有的对象一次性清理掉。
在这里插入图片描述

复制算法每次都是对整个半区进行内存回收,这样就减少了标记对象遍历的时间,在清除使用区域对象时,不用进行遍历,直接清空整个区域内存,而且在将存活对象复制到保留区域时也是按地址顺序存储的,这样就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。

复制算法优点

  • 复制算法简单高效,优化了标记清除算法的效率低、内存碎片多问题

复制算法缺点

  • 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;
  • 如果对象的存活率很高,极端一点的情况假设对象存活率为 100%,那么我们需要将所有存活的对象复制一遍,耗费的时间代价也是不可忽视的。

(3)标记-整理算法

标记-整理算法算法与标记-清除算法很像,事实上,标记-整理算法的标记过程任然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。
在这里插入图片描述

可以看到,回收后可回收对象被清理掉了,存活的对象按规则排列存放在内存中。这样一来,当我们给新对象分配内存时,JVM只需要持有内存的起始地址即可。标记/整理算法弥补了标记/清除算法存在内存碎片的问题消除了复制算法内存减半的高额代价,可谓一举两得。

标记-整理缺点

  • 效率不高:不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。

(4)分代收集算法

前文介绍JVM堆内存时已经说过了分代概念和对象在分代中的转移,垃圾回收伴随了对象的转移,其中新生代的回收算法以复制算法为主,老年代的回收算法以标记-清除以及标记-整理为主。

5、方法区的垃圾回收

方法区主要回收的内容有:废弃常量和无用的类。Full GC(Major GC)的时候会触发方法区的垃圾回收。

  • 废弃常量
    通过可达性分析算法确定的可回收常量
  • 无用类
    对于无用的类的判断则需要同时满足下面3个条件:
    (1)该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
    (2)加载该类的ClassLoader已经被回收;
    (3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.4 本地方法接口

从Java1.1开始,JNI标准成为Java平台的一部分,它允许(或者说使得)Java代码和其他语言写的代码进行交互。JNI一开始是为了本地已编译语言,尤其是C和C++而设计的,但是它并不妨碍你使用其他编程语言,只要调用约定受支持就可以了。使用Java与本地已编译的代码交互,通常会丧失平台可移植性。但是,有些情况下这样做是可以接受的,甚至是必须的。例如,使用一些旧的库,与硬件、操作系统进行交互,或者为了提高程序的性能。JNI标准至少要保证本地代码能工作在任何Java 虚拟机环境。

  • 10
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值