JVM从入门到地狱,你想要的样子它都有(O(∩_∩)O)~~~

入门

JVM是什么?

  • JVM是Java Virtual Machine的缩写。它是一种基于计算设备的规范,是一台虚拟机,即虚构的计算机。
  • JVM屏蔽了具体操作系统平台的信息(就像是我们在电脑上开了个虚拟机一样),当然,JVM执行字节码时实际上还是要解释成具体操作平台的机器指令的。
  • 通过JVM,Java实现了平台无关性,Java语言在不同平台运行时不需要重新编译,只需要在该平台上部署JVM就可以了。因而能实现一次编译多处运行。(就像是你的虚拟机也可以在任何安了VMWare的系统上运行).

JVM结构及各个模块作用分析

JVM主要包括:程序计数器(Program Counter),Java堆(Heap),Java虚拟机栈(Stack),本地方法栈(Native Stack),方法区(Method Area).

在这里插入图片描述

程序计数器(Program Counter Register)

  • 程序计数器(Program Counter Register)是一块较小的内存空间,它是运行速度最快的存储区域,因为它位于处理器内部,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解析器的工作是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • JVM多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。在一个确定的时刻,一个处理器(多核处理器的一个内核)只会执行一条线程中的命令。因此,为了正常的切换线程,每个线程都会有一个独立的PC,各线程的PC不会互相影响。这个私有的PC所占的这块内存即是线程的“私有内存”。

  • 如果线程在执行的是Java方法,那么PC记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的不是Java方法即Native方法,那么PC的值为undefined。

  • PC的内存区域是唯一的没有规定任何OutOfMemoryError的Java虚拟机规范中的区域。

Native方法:被native关键字修饰的方法叫做本地方法,本地方法和其它方法不一样,本地方法意味着和平台有关,因此使用了native的程序可移植性都不太高。另外native方法在JVM中运行时数据区也和其它方法不一样,它有专门的本地方法栈。native方法主要用于加载文件和动态链接库,由于Java语言无法访问操作系统底层信息(比如:底层硬件设备等),这时候就需要借助C语言来完成了。被native修饰的方法可以被C语言重写。

Java虚拟机栈(Java Virtual Machine Stacks)

  • Java虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息,每个方法从调用直至执行完成的过程,都对应着一个栈帧在虚拟机栈中入栈到出栈的过程。平时说的栈一般指局部变量表部分栈中存储的数据大小和生命周期必须是确定的,所以Java 中只有某些数据,如对象引用是被放在栈中的,而应用程序内部庞大的生命周期不确定的对象却放在的堆中。

    • 栈帧(Stack Frame)的内部结构
      在这里插入图片描述
  • 局部变量数组存储了编译可知的八个基本类型(int, boolean, char, short, byte, long, float, double),对象引用(根据不同的虚拟机实现可能是引用地址的指针或者一个handle),returnAddress类型。64位的long和double会占用两个Slot,其余类型会占用一个Slot。在编译期间,局部变量所需的空间就会完成分配,动态运行期间不会改变所需的空间。

    扫描二维码关注公众号,回复: 10417729 查看本文章
  • 操作数栈在执行字节码指令时会被用到,这种方式类似于原生的CPU寄存器,大部分JVM把时间花费在操作栈的花费上,操作栈和局部变量数组会频繁的交换数据。

  • 动态链接控制着运行时常量池和栈帧的连接。所有方法和类的引用都会被当作符号的引用存在常量池中。符号引用是实际上并不指向物理内存地址的逻辑引用。JVM 可以选择符号引用解析的时机,一种是当类文件加载并校验通过后,这种解析方式被称为饥饿方式。另外一种是符号引用在第一次使用的时候被解析,这种解析方式称为惰性方式。无论如何 ,JVM 必须要在第一次使用符号引用时完成解析并抛出可能发生的解析错误。绑定是将对象域、方法、类的符号引用替换为直接引用的过程。绑定只会发生一次。一旦绑定,符号引用会被完全替换。如果一个类的符号引用还没有被解析,那么就会载入这个类。每个直接引用都被存储为相对于存储结构(与运行时变量或方法的位置相关联的)偏移量。

  • 对Java虚拟机栈这个区域,Java虚拟机规范规定了两种异常:

    • 线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverFlow异常。
    • 对于支持动态扩展的虚拟机,当扩展无法申请到足够的内存时会抛出OutOfMemory异常。

本地方法栈(Native Stack)

  • 本地方法栈(Native Method Stack)与虚拟机栈的作用是一样的,只不过虚拟机栈是服务Java方法的,而本地方法栈是为虚拟机调用Native方法服务的。

Java 堆(Heap, Garbage Collection Heap)

  • Java堆(Java Heap)是Java虚拟机中内存最大的一块,是被所有线程共享的,在虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。
  • Java堆是垃圾收集器管理的主要区域,因而也被称为GC堆。收集器采用分代回收法,GC堆可以分为新生代(Yong Generation)和老生代(Old Generation)。新生代包括Eden Space和Survivor Space。但无论哪个区域,如何划分,存储的都是Java对象实例,进一步的划分是为了更好的回收内存或快速的分配内存。
    • Java堆(Java Heap)的内部结构
      在这里插入图片描述
  • 根据Java虚拟机规范,堆所在的物理内存区间可以是不连续的,只要逻辑连续就可以。实现时既可以是固定大小,也可以是可扩展的。如果堆无法扩展时,就会抛出OutOfMemoryError。

方法区(Method Area)

  • 方法区(Methed Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
  • 方法区和Java堆类似,也属于各线程共享的内存区域。它属于非堆区(Non Heap),和Java堆区分开。不过并非进入方法区的数据就会永久存在了,这块区域的内存回收主要为常量池的回收和类型的卸载。这个区域的回收处理不善也会导致严重的内存泄漏。

方法区不等于永久代:很多人愿意把方法区称作“永久代”(Permanent Generation,Permgen Space),本质上两者并不等价,方法区 是 JVM 虚拟机规范中的内容,而HotSpot虚拟机垃圾回收器团队把GC分代收集扩展到了方法区,或者说是用来永久 代来实现方法区而已,这样能省去专门为方法区编写内存管理的代码,但是在Jdk8也移除了“永久代”,使用Native Memory来实现方法区。并重新命名为Metaspace元空间. 也就是方法区相当于我们的Java中的接口,永久代是实 现类

  • 当方法区无法满足内存分配需求时也会抛出OutOfMemoryError。

类信息(Class Data)

  • 类信息存储在方法区,其主要构成为运行时常量池(Run-Time Constant Pool)和方法(Method Code)。

运行时常量池(Run-Time Constant Pool)

  • 运行时常量池是方法区的一部分。Class文件中有类的版本,字段,方法,接口等描述信息和用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。Java虚拟机规范对Class的细节有着严苛的要求而对运行时常量池的实现不做要求。一般来说除了翻译的Class,翻译出来的直接引用也会存在运行时常量池中。
  • 运行时常量池具备动态性,即运行时也可将新的常量放入池中。比如String类的intern()方法。
  • 常量池无法申请到足够的内存分配时也会抛出OutOfMemoryError。

直接内存(Direct Memory)

  • 直接内存并不在Java虚拟机规范中,不是Java的一部分,但是也被频繁使用并可能导致OutOfMemoryError。Native函数库可以直接分配堆外内存,通过存储在Java堆里的DirectDataBuffer对象作为这块内存的引用进行操作。这样做在一些场景中可以显著提高性能。
  • 直接内存是堆外内存,自然不受Java堆大小的限制,但是可能受实体机内存大小的限制。如果内存各部分总和大于实体机的内存时,也会报出OutOfMemoryError。

类加载机制

类的加载时机

  • 程序编译生成的.class文件都会直接被加载到JVM中吗??

  • 虚拟机规范则是严格规定了只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

    • 创建类的实例,也就是new的方式
    • 访问某个类或接口的静态变量,或者对该静态变量赋值
    • 调用类的静态方法
    • 反射(如Class.forName(“com.shengsiyuan.Test”)) – 初始化某个类的子类,则其父类也会被初始化
    • Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
  • 所以说:

    • Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到JVM中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。

类加载过程

当程序要使用某个类时,如果该类还未被加载到内存中,则系统会通过加载连接初始化三步来实现这个类进行初始化。

1. 加载
  • 加载:指Java虚拟机查找字节流(查找.class文件),并且根据字节流创建java.lang.Class对象的过程。这个过程,将类的.class文件中的二进制数据读入内存,放在运行时区域的方法区内。然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构。

  • 类加载阶段:

    • (1)Java虚拟机将.class文件读入内存,通过一个类的全限定名来获取其定义的二进制字节流, 并为之创建一个Class对象。
    • (2)任何类被使用时系统都会为其创建一个且仅有一个Class对象。
    • (3)这个Class对象描述了这个类创建出来的对象的所有信息,比如有哪些构造方法,都有哪些成员方法,都有哪些成员变量等。
  • 相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

  • 加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据.
    在这里插入图片描述

2.链接
  • 链接包括验证、准备以及解析三个阶段。
    • (1) 验证阶段 。主要的目的是确保被加载的类(.class文件的字节流)满足Java虚拟机规范,不会造成安全错误。

    • (2) 准备阶段 。负责为类的静态成员分配内存,并设置默认初始值。

      准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几 点需要注意:
      1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块 分配在Java堆中
      2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中 被显式地赋予的值

    • (3) 解析阶段 。将类的二进制数据中的符号引用替换为直接引用。

      解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口 方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

3. 初始化
  • 初始化,则是为标记为常量值的字段赋值的过程。换句话说,只对static修饰的变量或语句块进行初始化。
  • 在Java中对类变量进行初始值设定有两种方式:
    • ①声明类变量是指定初始值;
    • ②使用静态代码块为类变量指定初始值;
  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

JVM初始化步骤

  • 1、假如这个类还没有被加载和连接,则程序先加载并链接该类
  • 2、假如该类的直接父类还没有被初始化,则先初始化其直接父类
  • 3、假如类中有初始化语句,则系统依次执行这些初始化语句

类加载器

  • JVM 类加载器是通过ClassLoader 及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
    在这里插入图片描述
  • (1) Bootstrap ClassLoader
    • 负责加载$JAVA_HOME中的jre/lib/rt.jar 里所有的class,由c++实现,不是ClassLoader子类。
  • (2) Extension ClassLoader
    • 负责加载Java 平台中扩展功能的一些jar包,包括$JAVA_HOME中的jre/lib/ext/*.jar-D java.ext.dirs指定目录下的jar包。
  • (3) App ClassLoader
    • 负责加载CLASS_PATH 中指定的jar包及目录中class
  • (4) Custom ClassLoader
    • 应用程序根据自身需要自定义的ClassLoader,如Tomcat,jboss 都会根据J2EE规范自行实现ClassLoader,加载过程中会先检查是否已被加载,检查顺序是自底向上,从Custom ClassLoaderBootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类在所有ClassLoader 只加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类
      在这里插入图片描述

JVM 三种预定义加载器

JVM预定义有三种类加载器,当一个 JVM启动的时候,Java 默认开始使用如下三种类加载器:

  • (1) 引导类加载器(Bootstrap class loader) :它用来加载 Java 的核心库,是用原生代码来实现的,并不继承自 java.lang.ClassLoader。它负责将<Java_Runtime_Home>/lib下面的核心类库或- Xbootclasspath选项指定的jar包加载到内存中。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
  • (2) 扩展类加载器(Extensions class loader) :该类加载器在此目录里面查找并加载 Java 类。扩展类加载器是由Sun的ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它用来加载 Java的扩展库。Java 虚拟机的实现会提供一个扩展库目录。它负责将< Java_Runtime_Home >/lib/ext或者由系统变量-Djava.ext.dirs指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
  • (3) 系统类加载器(System class loader) :系统类加载器是由 Sun的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径java -classpath- Djava.class.path变量所指的目录下的类库加载到内存中。开发者可以直接使用系统类加载器。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。

实例

public class Math {
    public static final Integer CONSTANT = 666;

    public int compute(){
        int a = 1;
        int b = 2;
        int c = (a+b)*10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        Math math2 = new Math();
        math2.compute();
        System.out.println("test");
    }
}

在这里插入图片描述

  • 宏观简述一下代码中的工作流程:
    • 1、通过java.exe运行Math.class,通过类加载器,随后(3)被加载到JVM中,元空间存储着类的信息(包括类的名称、方法信息、字段信息…)。
    • 2、然后JVM找到Math的主函数入口(main),为main函数创建栈帧,开始执行main函数
    • 3、main函数的第一条命令是Math math = new Math();就是让JVM创建一个math 对象,但是这时候方法区中没有Math 类的信息,所以JVM马上加载Math 类,把Math 类的类型信息放到方法区中(元空间)
    • 4、加载完Math 类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Math 实例分配内存, 然后调用构造函数初始化Math 实例,这个Math 实例持有着指向方法区的Math 类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用
    • 5、当使用math.compute();的时候,JVM根据Math 引用找到math对象,然后根据math对象持有的引用定位到方法区中Math 类的类型信息的方法表,获得compute()函数的字节码的地址
    • 6、为compute()函数创建栈帧,开始运行compute()函数

GC 基础

在这里插入图片描述

基础术语

年轻代
  • JVM 堆中一片区域, 用于存放对象用,内部分为 EDEN,SURVIVOR(包含 FROM和 TO 两份)区域,比例是8:2(FROM,TO 各占1)
老年代
  • JVM 堆中一片区域,用于存放对象用,用于存放生命周期较长或者空间较大的对象(G1中有专门的大对象区间)
永久代
  • JDK8已经移除,之前是 JVM 规范中方法区的实现
GC Root
  • 一个指针(引用),它保存了堆里面的对象(指向),而自己又不存储在堆中,那么它就可以是一个 ROOT,可以作为 GC Roots 的节点主要是全局性的引用(如常量或者静态属性引用的对象)与执行上下文(栈帧中的局部变量表)以及 JNI 本地方法栈中引用的对象
对象提升规则
  • 虚拟机给每个对象定义了一个年龄计数器,对象每经过一次年轻代的垃圾回收然后存活下来就会加1,当达到一定年龄后(默认是15)会将对象提升放入到老年代中
Minor GC
  • 年轻代的回收称之为Minor GC,年轻代的回收频率特别频繁,大多数对象都是在年轻代中创建并回收的
MajorGC/Full
  • 年老代(老年代)的内存区域一般大于年轻代,所以年老代发生 GC 的频率会必年轻代少,对象从年老代消失的时候我们称为MajorGC或者Full GC,Full GC 会占用大量时间导致程序一段时间内无响应
FullGC 一般发生在以下几种情况:
  • 老年代空间不足
    • 老年代只有在年轻代对象转入或者创建大对象,大数组的时候才会出现不足的情况,因为 Full GC 的资源消耗问题,所以我们尽量减少创建大对象和大数组,让对象尽量在年轻代就回收
  • 永久代空间满
    • 当加载到类过多或者反射过多的类已经调用过多方法的时候永久代可能会被占满,可以通过设置更大的永久代来解决,JDK8之后这些信息进入元空间,不需要我们再分配
  • 整体空间不足
    • 此情况和1很像,年轻代回收的时候发现对象放不下,转而向老年代放,老年代暂时内存也不够放,这时候就会触发 FullGC 4. 对象提升的平均大小 大于老年代的剩余空间此情况其实还是和前面很像,不过有区别,第一个 Minor GC 后,假设有 10M 内存对象被提升到了老年代,下一次 Minor GC 的时候会先判断老年代空间有没有10M,没有就触发 Full GC

JVM常见专业术语

Mixed GC Event
  • 混合 GC 事件,即所有的年轻代和一部分老年代一起回收,混合 GC 一定是跟随在 Minor GC 后端。
  • 为什么是老年代的部分Region?什么时候触发Mixed GC?
    • 回收部分老年代是参数 - XX:MaxGCPauseMillis ,用来指定一个G1收集过程目标停顿时间,默认值200ms,当然这只是一个期望值。G1的强大之处在于他有一个停顿预测模型(Pause Prediction Model),他会有选择的挑选部分Region,去尽量满足停顿时间,关于G1的这个模型是如何建立的,这里不做深究。Mixed GC的触发也是由一些参数控制。比如 XX:InitiatingHeapOccupancyPercent 表示老年代占整个堆大小的百分比,默认值是45%,达到该阈值就会触发一次Mixed GC
STW
  • STOP THE WORLD ,GC 事件/过程发生的时候 JVM 要停止你所有的程序的线程的执行,类似于你妈妈拖地的时候让你滚到一边站在那别动(哈哈)
  • 这样的设计的原则是因为垃圾回收器的任务就是对垃圾对象进行清理,为了让垃圾回收器可以有效的执行,大部分情况下会要求程序进入一个停顿状态,终止所有线程的执行,只有这样才不会产生新的垃圾,同时也保证了系统在某一时间的一致性,因为垃圾回收的时候会产生程序停顿的感觉,这种感觉叫STW
System.gc()
  • 这个方法的作用主要是触发 FullGC,对老年代和年轻代进行回收,但是注意这个操作仅仅是请求垃圾回收的建议而已,JVM 不一定会立刻执行,而是对垃圾回收算法加权,使得垃圾回收更容易发生,就相当于你家里有垃圾你没丢,你妈妈冲你吼快去把垃圾扔掉,你可能会立刻去扔,你也可能翻翻身继续睡
Region
  • 在G1的垃圾回收算法中,堆内存采用了另外一种完全不同的方式进行组织,被划分为多个(默认2000多个)大小相同的内存块(Region),每个Region是逻辑连续的一段内存,在被使用时都充当一种角色,每次垃圾收集的时候只会处理几个区域,以此来控制垃圾回收产生的停顿时间,Region表示一个区域,每个区域里面的字符代表属于不同的分代内存空间类型(E[Eden],O[Old],S[Survivor],H[Humongous]) ,空白的分区不属于任何一个分代,G1可以在需要的时候将这个区域分给 O 或者 E 之类的 ,其中H是以往算法中没有的,它代表Humongous,表示这些Region存储的是巨型对象(humongous object,H-obj),当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H。 Region的大小只能是1M、2M、 4M、8M、16M或32M,比如 -Xmx16g -Xms16g ,G1就会采用16G / 2048 = 8M 的Region.

提升

判断一个对象是不是垃圾

常见的垃圾标记算法有两种,分别是引用计数器算法和可达性分析算法(根搜索算法)

引用计数器算法
  • 引用计数算法很简单,它实际上是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加一,如果删除对该对象的引用,那么它的引用计数就减一,当该对象的引用计数为0时,那么该对象就会被回收。引用计数垃圾收集机制不一样,它只是在引用计数变化为0时即刻发生,而且只针对某一个对象以及它所依赖的其它对象。所以,我们一般也称呼引用计数垃圾收集为直接的垃圾收集机制但是这种引用计数算法有一个比较大的问题,那就是它不能处理环形数据 - 即如果有两个对象相互引用,那么这两个对象就不能被回收,因为它们的引用计数始终为1。这也就是我们常说的“内存泄漏”问 题
  • 算法特点
    • 需要单独的字段存储计数器,增加了存储空间的开销;
    • 每次赋值都需要更新计数器,增加了时间开销;
    • 垃圾对象便于辨识,只要计数器为0,就可作为垃圾回收;
    • 及时回收垃圾,没有延迟性;
    • 不能解决循环引用的问题;
可达性分析算法
  • 根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
  • 这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。
  • 那么问题又来了,如何选取GCRoots对象呢?
  • 在Java语言中,可以作为GCRoots的对象包括下面几种:
    • (1). 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
    • (2). 方法区中的类静态属性引用的对象。
    • (3). 方法区中常量引用的对象。
    • (4). 本地方法栈中JNI(Native方法)引用的对象。

垃圾收集算法

当我们成功标记出存活和死亡对象后,GC 接下来就开始执行垃圾回收,释放内存,常见的收集算法有三种 标记-清除算法(Mark-Sweep) ,复制算法(Copying),标记-压缩算法(Mark-Compact)也有人称标记-整理算法

在这里插入图片描述

分代收集理论(重要)
  • 收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
  • 基于分代收集理论,HotSpot虚拟机将java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。由于对象可能会产生跨代引用的问题。解决办法是在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
  • 基于分代收集理论,在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。
标记-清除算法
  • 算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
  • 缺点:
    • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
    • 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大* 对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-复制算法(复制算法,不适用老年代)
  • “半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 优点:
    • 算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效。
  • 缺点:
    • 将会产生大量的内存间复制的开销。
    • 将可用内存缩小为了原来的一半,空间浪费比较严重。
优化的半区复制分代策略(Appel式回收)
  • 把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
  • HotSpot对Appel式回收的实现
    • HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
    • 如果每次回收有多于10%的对象存活,因此Appel式回收还有一个安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。这个是此算法不适用老年代的原因。
标记-整理算法
  • 其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
  • 缺点:
    • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

GC 垃圾回收器

什么是GC 垃圾回收器?
  • GC 垃圾回收器是 JVM 中自动内存管理机制的具体实现,在 HotSpot 虚拟机中 GC 的工作主要划分为两大类,分别是内存动态分配和垃圾回收,在内存执行分配之前,GC 首先会对内存空间进行划分,考虑到 JVM中存活对象的生命周期会具有两极化,应该采取不同的垃圾收集策略,分代收集可以实现这个目标,目前几乎所有的GC 都是采用分代收集算法执行垃圾回收一般来说当内存空间中的内存消耗到达一定阈值之后,GC 就会执行垃圾回收,而且回收算法必须非常准确,一定不能造成内存中存活的对象被错误的回收掉,也不能造成已经死亡的对象没有及时回收,而且 GC 执行内存回收的时候应该做到高效,不应该导致程序长时间的暂停,以及要避免产生内存碎片,不过 GC 回收垃圾的时候不可避免的会产生碎片,因为被回收的对象空间不是连续的,这样一来会导致没有足够的空间分配给大内存对象,不过可以通过压缩算法来消除碎片可以通过以下六点来评估一个 GC 的性能 :
    1. 吞吐量:程序的运行时间(程序时间+回收时间)
    2. 垃圾回收开销: 吞吐量的补数,垃圾回收器所占时间与总时间的比例
    3. 暂停时间: 执行垃圾回收的时候,程序的工作线程被暂停的时间
    4. 收集频率: 相对于程序的执行,收集操作发生的频率
    5. 堆空间: Java 堆占用的空间大小
    6. 快速: 一个对象从创建到被回收所经历的时间
垃圾回收器分类
  • 由于 JDK 的高速迭代,Java 到现在已经衍生了很多版本的 GC,比如 Serial/Serial Old 收集器,ParNew 收集器,Parallel/Parallel Old 收集器,CMS(Concurrent-Mark-Sweep) 收集器,以及从 JDK7U4版本开始的出现的G1(Garbage-First)收集器等
  • 按照不同的划分角度,可以将 GC 分为 不同的类型
  • 按照 线程数 划分 可以分为 串行垃圾回收器 和 并行垃圾回收器
    • 串行回收指的是同一段时间内只允许一件事情发生,当有多个 CPU 的时候也只能有一个 CPU 用于执行垃圾回收操作,并且在执行回收的时候,程序中的工作线程会被暂停,回收结束后才会恢复,这就是串行回收,一般情况下串行回收被用在 client 模式下,和串行回收相反,并行回收可以使用多个 CPU 来执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然使用 STW和复制算法
  • 串行回收有两个特点:首先 它仅仅使用单个线程进行垃圾回收, 其次它是 独占式 的垃圾回收方式
  • 年轻代串行回收器使用复制算法,实现相对简单,逻辑处理特别高效,而且没有额外的线程切换开销,在诸如单 CPU 或者较小的应用内存等硬件平台,它的性能可以超过并行回收器并行收集器是工作在新生代 垃圾回收器,它只是简单的将串行回收器多线程话,它的回收策略算法以及参数和串行一致,并行回收器也是独占式的回收器,在收集过程中,也会 STW,不过在并发能力强的 CPU 上面,它产生的停顿时间小于串行收集器,效率更高
  • 按照 工作模式 分可以划分为 并发式回收器 和 独占式回收器 ,并发式(注意不是并行)回收器与应用程序线程交替执行,以尽量减少应用程序的停顿时间,独占式垃圾回收器一旦运行就停止应用中的其他线程,直到回收结束
  • 按照 碎片处理 方式分可以分为 压缩式垃圾回收器 和 非压缩式垃圾回器 ,压缩式垃圾回收器会在回收完成后堆存货对象进行压缩整理,消除回收后的碎片,非压缩式的垃圾回收器不会进行此过程
  • 按照 工作内存区间 划分又可以分为 年轻代垃圾回收器 和 年老代垃圾回收器

面试题

JVM大厂高频面试题,连这些都不知道,还敢说自己学过JVM?

你知道的越多,你不知道的越多。
有道无术,术尚可求,有术无道,止于术。
如有其它问题,欢迎大家留言,我们一起讨论,一起学习,一起进步

发布了207 篇原创文章 · 获赞 243 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_40722827/article/details/105271939
今日推荐