Java-深入理解JVM

384 阅读39分钟

什么是JVM?

VM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

JRE/JDK/JVM是什么关系?

JRE(JavaRuntimeEnvironment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。 JDK(Java Development Kit)是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JVM(JavaVirtualMachine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。

JVM的生命周期

  • 启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。

  • 运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。

  • 消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。

当在电脑上运行一个程序时,就会运行一个java虚拟机,java虚拟机总是开始于main方法,main方法是程序的起点。

java的线程一般分为两种:守护线程和普通线程。守护线程是java虚拟机自己使用的线程,比如GC线程就是一个守护线程,当然你可以把自己的线程设置为守护线程,注意:main方法启动的初始线程不是守护线程。

只要java虚拟机中还有普通线程在执行,java虚拟机就不会停止,如果有足够的权限,你可以调用exit()方法终止线程。

JVM体系结构

结构图

Java-JVM详解.png 链接:Java-JVM详解(总结ProcessOn思维导图)

image.png

  1. JVM分为五个区域:虚拟机栈、本地方法栈、方法区、堆、程序计数器。PS:大家不要排斥英语,此处用英文记忆反而更容易理解。
  2. JVM五个区中虚拟机栈、本地方法栈、程序计数器为线程私有,方法区和堆为线程共享区。图中已经用颜色区分,绿色表示“通行”,橘黄色表示停一停(需等待)。
  3. JVM不同区域的占用内存大小不同,一般情况下堆最大,程序计数器较小。那么最大的区域会放什么?当然就是Java中最多的“对象”了

堆(Heap:线程共享)

上面已经得出结论,堆内存最大,堆是被线程共享,堆的目的就是存放对象。几乎所有的对象实例都在此分配。当然,随着优化技术的更新,某些数据也会被放在栈上等。

枪打出头鸟,树大招风。因为堆占用内存空间最大,堆也是Java垃圾回收的主要区域(重点对象),因此也称作“GC堆”(Garbage Collected Heap)

关于GC的操作,我们后面章节会详细讲,但正因为GC的存在,而现代收集器基本都采用分代收集算法,堆又被细化了。 image.png image.png 同样,对上图呈现内容汇总分析。

  1. 堆的GC操作采用分代收集算法。

  2. 堆区分了新生代和老年代;

  3. 新生代又分为:Eden空间、From Survivor(S0)空间、To Survivor(S1)空间。

Java虚拟机规范规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。也就是说堆的内存是一块块拼凑起来的。要增加堆空间时,往上“拼凑”(可扩展性)即可,但当堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

Java 堆所使用的内存不需要保证是连续的。而由于堆是被所有线程共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性。

方法区(Method Area:线程共享)

方法区与堆有很多共性:线程共享、内存不连续、可扩展、可垃圾回收,同样当无法再扩展时会抛出OutOfMemoryError异常。

正因为如此相像,Java虚拟机规范把方法区描述为堆的一个逻辑部分,但目前实际上是与Java堆分开的(Non-Heap)。

方法区个性化的是,它存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。

image.png

虚拟机栈(JVM Stacks:线程隔离)

Java 虚拟机栈是描述 Java 方法运行过程的内存模型。

Java 虚拟机栈会为每一个即将运行的 Java 方法创建一块叫做“栈帧”的区域,用于存放该方法运行过程中的一些信息,如:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息
  • ......

局部变量表(Local Variable Table) 是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。

其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈动态扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

操作数栈(Operand Stack)也称作操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。

动态链接 Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

方法返回 无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

image.png

压栈出栈过程

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。

由于 Java 虚拟机栈是与线程对应的,数据不是线程共享的,因此不用关心数据一致性问题,也不会存在同步锁的问题。

特点

  • 局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变。

  • Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

    • StackOverFlowError 若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。
    • OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
  • Java 虚拟机栈也是线程私有,随着线程创建而创建,随着线程的结束而销毁。

出现 StackOverFlowError 时,内存空间可能还有很多。

本地方法栈(Native Method Stacks:线程隔离)

本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。

栈帧变化过程

本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。

方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。

ps:与 Java 虚拟机栈实现的功能类似,只不过本地方法栈是描述本地方法运行过程的内存模型。本地方法栈(Native Method Stacks)与虚拟机栈作用相似,也会抛出StackOverflowError和OutOfMemoryError异常。 区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈是为虚拟机使用到的Native方法服务。

如果 Java 虚拟机本身不支持 Native 方法,或是本身不依赖于传统栈,那么可以不提供本地方法栈。如果支持本地方法栈,那么这个栈一般会在线程创建的时候按线程分配。

程序计数器(Program Counter Register(PC 寄存器):线程隔离)

定义:程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地(Natvie)方法,那么此时程序计数器为Undefined。它是唯一没有OutOfMemoryError异常的区域。

作用:

  • 程序计数器的作用可以看做是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变计数器的值来选取下一条字节码指令。其中,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器来完成。(换一种说法:字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制)
  • 在多线程情况下,程序计数器记录的是当前线程执行的位置,从而当线程切换回来时,就知道上次线程执行到哪了。

JVM之内存结构图文详解4

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

类加载器

类与类加载器

判断类是否“相等”

任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。

因此,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

这里的“相等”,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

加载器种类

系统提供了 3 种类加载器:

  • 启动类加载器(Bootstrap ClassLoader): 负责将存放在 <JAVA_HOME>\lib 目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。
  • 扩展类加载器(Extension ClassLoader): 负责加载 <JAVA_HOME>\lib\ext 目录中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader): 由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

ClassLoader

image.png

当然,如果有必要,还可以加入自己定义的类加载器。

双亲委派模型

什么是双亲委派模型

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)

工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(找不到所需的类)时,子加载器才会尝试自己去加载。

在 java.lang.ClassLoader 中的 loadClass 方法中实现该过程。

为什么使用双亲委派模型

像 java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的 Object 类都是同一个。

相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证。

垃圾收集策略与算法

程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。这几个区域的内存分配和回收都具有确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

而对于 Java 堆和方法区,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的正是这部分内存。

判定对象是否存活

若一个对象不被任何对象或变量引用,那么它就是无效对象,需要被回收。

引用计数法

在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题。

举个栗子 👉 对象 objA 和 objB 都有字段 instance,令 objA.instance = objB 并且 objB.instance = objA,由于它们互相引用着对方,导致它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。

可达性分析法

所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。 GC Roots 是指:

  • Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中常量引用的对象
  • 方法区中类静态属性引用的对象 GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。

引用的种类

判定对象是否存活与“引用”有关。在JDK1.2以前,Java中的引用定义很传统,一个对象只有被引用或者没有被引用两种状态,我们希望能描述这一类对象:当内存空间还足够时,则保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为了以下四种。不同的引用类型,主要体现的是对象不同的可达性状态reachable和垃圾收集的影响。

强引用(Strong Reference)

类似 "Object obj = new Object()" 这类的引用,就是强引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。但是,如果我们错误地保持了强引用,比如:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏。

软引用(Soft Reference)

软引用是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用(Weak Reference)

弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。

虚引用(Phantom Reference)

虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。它仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制,比如,通常用来做所谓的 Post-Mortem 清理机制。

回收堆中无效对象

对于可达性分析中不可达的对象,也并不是没有存活的可能。

判定 finalize() 是否有必要执行

JVM 会判断此对象是否有必要执行 finalize() 方法,如果对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么视为“没有必要执行”。那么对象基本上就真的被回收了。

如果对象被判定为有必要执行 finalize() 方法,那么对象会被放入一个 F-Queue 队列中,虚拟机会以较低的优先级执行这些 finalize()方法,但不会确保所有的 finalize() 方法都会执行结束。如果 finalize() 方法出现耗时操作,虚拟机就直接停止指向该方法,将对象清除。

对象重生或死亡

如果在执行 finalize() 方法时,将 this 赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。

任何一个对象的 finalize() 方法只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,想继续在 finalize() 中自救就失效了。

回收方法区内存

方法区中存放生命周期较长的类信息、常量、静态变量,每次垃圾收集只有少量的垃圾被清除。方法区中主要清除两种垃圾:

  • 废弃常量
  • 无用的类

判定废弃常量

只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。比如,一个字符串 "bingo" 进入了常量池,但是当前系统没有任何一个 String 对象引用常量池中的 "bingo" 常量,也没有其它地方引用这个字面量,必要的话,"bingo"常量会被清理出常量池。

判定无用的类

判定一个类是否是“无用的类”,条件较为苛刻。

  • 该类的所有对象都已经被清除
  • 加载该类的 ClassLoader 已经被回收
  • 该类的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区时创建,在方法区该类被删除时清除。

垃圾收集算法

学会了如何判定无效对象、无用类、废弃常量之后,剩余工作就是回收这些垃圾。常见的垃圾收集算法有以下几个:

标记-清除算法

标记的过程是:遍历所有的 GC Roots,然后将所有 GC Roots 可达的对象标记为存活的对象

清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。与此同时,清除那些被标记过的对象的标记,以便下次的垃圾回收。

这种方法有两个不足

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

复制算法(新生代)

为了解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾收集时,就将存活者的对象复制到另一块上面,然后将第一块内存全部清除。这种算法有优有劣:

  • 优点:不会有内存碎片的问题。
  • 缺点:内存缩小为原来的一半,浪费空间。

为了解决空间利用率问题,可以将内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。

但是我们无法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够,需要依赖其他内存(指老年代)进行分配担保。

分配担保

为对象分配内存空间时,如果 Eden+Survivor 中空闲区域无法装下该对象,会触发 MinorGC 进行垃圾收集。但如果 Minor GC 过后依然有超过 10% 的对象存活,这样存活的对象直接通过分配担保机制进入老年代,然后再将新对象存入 Eden 区。

标记-整理算法(老年代)

标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历 GC Roots,然后将存活的对象标记。

整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。

这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低。

分代收集算法

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

  • 新生代:复制算法
  • 老年代:标记-清除算法、标记-整理算法

内存分配与回收策略

对象的内存分配,就是在堆上分配(也可能经过 JIT 编译后被拆散为标量类型并间接在栈上分配),对象主要分配在新生代的 Eden 区上,少数情况下可能直接分配在老年代,分配规则不固定,取决于当前使用的垃圾收集器组合以及相关的参数配置。

以下列举几条最普遍的内存分配规则,供大家学习。

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

👇Minor GC vs Major GC/Full GC

  • Minor GC:回收新生代(包括 Eden 和 Survivor 区域),因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  • Major GC / Full GC: 回收老年代,出现了 Major GC,经常会伴随至少一次的 Minor GC,但这并非绝对。Major GC 的速度一般会比 Minor GC 慢 10 倍 以上。

在 JVM 规范中,Major GC 和 Full GC 都没有一个正式的定义,所以有人也简单地认为 Major GC 清理老年代,而 Full GC 清理整个内存堆。

大对象直接进入老年代

大对象是指需要大量连续内存空间的 Java 对象,如很长的字符串或数据。

一个大对象能够存入 Eden 区的概率比较小,发生分配担保的概率比较大,而分配担保需要涉及大量的复制,就会造成效率低下。

虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。(还记得吗,新生代采用复制算法回收垃圾)

长期存活的对象将进入老年代

JVM 给每个对象定义了一个对象年龄计数器。当新生代发生一次 Minor GC 后,存活下来的对象年龄 +1,当年龄超过一定值时,就将超过该值的所有对象转移到老年代中去。

使用 -XXMaxTenuringThreshold 设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。

动态对象年龄判定

如果当前新生代的 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

JDK 6 Update 24 之前的规则是这样的:
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立,Minor GC 可以确保是安全的; 如果不成立,则虚拟机会查看 HandlePromotionFailure 值是否设置为允许担保失败, 如果是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那此时也要改为进行一次 Full GC。

JDK 6 Update 24 之后的规则变为:
只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。

这个过程就是分配担保。

总结

👇 总结一下有哪些情况可能会触发 JVM 进行 Full GC。

  1. System.gc() 方法的调用 此方法的调用是建议 JVM 进行 Full GC,注意这只是建议而非一定,但在很多情况下它会触发 Full GC,从而增加 Full GC 的频率。通常情况下我们只需要让虚拟机自己去管理内存即可,我们可以通过 -XX:+ DisableExplicitGC 来禁止调用 System.gc()
  2. 老年代空间不足 老年代空间不足会触发 Full GC 操作,若进行该操作后空间依然不足,则会抛出如下错误:java.lang.OutOfMemoryError: Java heap space
  3. 永久代(1.8jdk后替代成MetaSpace)空间不足 JVM 规范中运行时数据区域中的方法区,在 HotSpot 虚拟机中也称为永久代(Permanet Generation),存放一些类信息、常量、静态变量等数据,当系统要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,会触发 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space
  4. CMS GC 时出现 promotion failed 和 concurrent mode failure promotion failed,就是上文所说的担保失败,而 concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。
  5. 统计得到的 Minor GC 晋升到旧生代的平均大小大于老年代的剩余空间。

垃圾收集器

新生代垃圾收集器

Serial 垃圾收集器(单线程)

只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程(Stop The World)。

一般客户端应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。因此 Serial 垃圾收集器适合客户端使用。

由于 Serial 收集器只使用一条 GC 线程,避免了线程切换的开销,从而简单高效。

Serial

Parallel New 垃圾收集器(多线程)

Parallel New 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。

Parallel New 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。

ParNew

Parallel Scavenge 垃圾收集器(多线程)

Parallel Scavenge 和 ParNew 一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:

  • Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
  • ParNew:追求降低用户停顿时间,适合交互式应用。

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

追求高吞吐量,可以通过减少 GC 执行实际工作的时间,然而,仅仅偶尔运行 GC 意味着每当 GC 运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。单个 GC 需要花更多的时间来完成,从而导致更高的暂停时间。而考虑到低暂停时间,最好频繁运行 GC 以便更快速完成,反过来又导致吞吐量下降。

  • 通过参数 -XX:GCTimeRadio 设置垃圾回收时间占总 CPU 时间的百分比。
  • 通过参数 -XX:MaxGCPauseMillis 设置垃圾处理过程最久停顿时间。
  • 通过命令 -XX:+UseAdaptiveSizePolicy 开启自适应策略。我们只要设置好堆的大小和 MaxGCPauseMillis 或 GCTimeRadio,收集器会自动调整新生代的大小、Eden 和 Survivor 的比例、对象进入老年代的年龄,以最大程度上接近我们设置的 MaxGCPauseMillis 或 GCTimeRadio。

老年代垃圾收集器

Serial Old 垃圾收集器(单线程)

Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程,都适合客户端应用。它们唯一的区别就是:Serial Old 工作在老年代,使用“标记-整理”算法;Serial 工作在新生代,使用“复制”算法。

Parallel Old 垃圾收集器(多线程)

Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。

CMS 垃圾收集器

CMS(Concurrent Mark Sweep,并发标记清除)收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

  • 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
  • 并发标记:使用多条标记线程,与用户线程并发执行。此过程进行可达性分析,标记出所有废弃对象。速度很慢。
  • 重新标记:Stop The World,使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来。
  • 并发清除:只使用一条 GC 线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。

并发标记与并发清除过程耗时最长,且可以与用户线程一起工作,因此,总体上说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS

CMS 的缺点:

  • 吞吐量低
  • 无法处理浮动垃圾,导致频繁 Full GC
  • 使用“标记-清除”算法产生碎片空间

对于产生碎片空间的问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。设置参数 -XX:CMSFullGCsBeforeCompaction 告诉 CMS,经过了 N 次 Full GC 之后再进行一次内存整理。

通用垃圾收集器

G1 垃圾收集器

G1 是一款面向服务端应用的垃圾收集器,它没有新生代和老年代的概念,而是将堆划分为一块块独立的 Region。当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。

从整体上看, G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。

这里抛个问题 👇
一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

并不!每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。

如果不计算维护 Remembered Set 的操作,G1 收集器的工作过程分为以下几个步骤:

  • 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
  • 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
  • 最终标记:Stop The World,使用多条标记线程并发执行。
  • 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行。

新世代垃圾回收器

Epsilon GC

Epsilon GC在官方被标为No-Op(无操作)的GC。在JDK11引入。 其原因是因为Epsilon GC只做内存的分配,不做垃圾的回收,适合于一些运行完后直接退出程序的情况。 官方文档:Epsilon: A No-Op Garbage Collector (Experimental)

Shenandonah GC

Shenandoah是第一款不由Oracle公司团队领导开发的Hotspot垃圾收集器,不可避免的受到官方的排挤。比如号称openJDK和OracleJDK没有区别的Oracle公司仍拒绝在OracleJDK12中支持Shenandoah。

Shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研究项目Pauseless GC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDK。

Red Hat研发Shenandoah团队对外宣称,Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。

停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。 总结:

Shenandoah GC的弱项:高运行负担下的吞吐量下降。 Shenandoah GC的强项:低延迟时间。

ZGC

官方文档:ZGC: A Scalable Low-Latency Garbage Collector (Experimental)
官方文档:HotSpot Virtual Machine Garbage Collection Tuning Guide ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在10毫秒以内的低延迟。

《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC的工作过程可以分为4个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射 等。

ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

AliGC

AliGC是阿里巴巴JVM团队基于G1算法,面向大堆(LargeHeap)应用场景。AliGC被应用于阿里巴巴自己研发的JVM(TaoBaoVM)中。 指定场景下的对比:

Zing

Zing目前主打的亮点是低延迟。

调优

在高性能硬件上部署程序,目前主要有两种方式:

  • 通过 64 位 JDK 来使用大内存;
  • 使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。

用 64 位 JDK 管理大内存

堆内存变大后,虽然垃圾收集的频率减少了,但每次垃圾回收的时间变长。 如果堆内存为 14 G,那么每次 Full GC 将长达数十秒。如果 Full GC 频繁发生,那么对于一个网站来说是无法忍受的。

对于用户交互性强、对停顿时间敏感的系统,可以给 Java 虚拟机分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,至少要低到不会影响用户使用。

可能面临的问题:

  • 内存回收导致的长时间停顿;
  • 现阶段,64 位 JDK 的性能普遍比 32 位 JDK 低;
  • 需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生超过 10GB 的 Dump 文件),哪怕产生了快照也几乎无法进行分析;
  • 相同程序在 64 位 JDK 消耗的内存一般比 32 位 JDK 大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。

使用 32 位 JVM 建立逻辑集群

在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口, 然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。

考虑到在一台物理机器上建立逻辑集群的目的仅仅是为了尽可能利用硬件资源,并不需要关心状态保留、热转移之类的高可用性能需求, 也不需要保证每个虚拟机进程有绝对的均衡负载,因此使用无 Session 复制的亲合式集群是一个不错的选择。 我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据 SessionID 分配) 将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可。

可能遇到的问题:

  • 尽量避免节点竞争全局资源,如磁盘竞争,各个节点如果同时访问某个磁盘文件的话,很可能导致 IO 异常;
  • 很难高效利用资源池,如连接池,一般都是在节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余;
  • 各个节点受到 32 位的内存限制;
  • 大量使用本地缓存的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点都有一份缓存,这时候可以考虑把本地缓存改成集中式缓存。

线上调优工具

  1. Jconsole:用于堆JVM中的内存、线程、类的监控
  2. Jstack:用于查看线程信息,例如查看死锁问题;
  3. Jmap:用来查看堆内存使用情况,导出Java进程的内存快照文件,MAT工具。 image.png 达到以下效果:
  4. 停顿时间
  5. 增加服务吞吐量
  6. 监控统计、gc统计
  7. 调整配置

声明

ps:文章如有错误麻烦请告知,另外此文章仅用于学习,参考了以下文章,如冒犯了其中利益请告知我删除:
JVM之内存结构图文详解-华为云
JVM之内存结构图文详解-GitHub
Java虚拟机(JVM)
新世代垃圾回收器-CSDN