likes
comments
collection
share

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

作者站长头像
站长
· 阅读数 54

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

花了一周时间,吐血整理。

本文以 JDK1.8 的 Hotsopt 虚拟机为基础,打造了一篇覆盖整个 JVM,一共 30 个知识点的文章。

是真正的:“学 JVM,收藏等于会了!”的文章。

“别挤了真的一滴都没有了!!!”

看什么看,还不三连?

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

学习之前,我们首先来梳理一下整个 JVM 的结构与知识体系,做到心中有 BTree,甚至是 B+Tree。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

1 字节码

众所周知,.java 文件编译后会生成 .class 文件,这个 class 文件就是我们常说的字节码文件。

计算机硬件认识它吗?并不认识。

字节码文件实际上运行在 JVM(Java 虚拟机) 上,由 JVM 屏蔽了底层硬件的差异,让我们能喊出“一次编写,到处运行(报错)”的口号!

当然,现在 JVM 不止能运行 Java 了,近年来衍生出一系列基于 JVM 的编程语言:

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

Class 文件本质上是一个以 8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在 Class 文件中。

JVM 根据其特定的规则解析该二进制数据,从而得到相关信息。

2 类加载机制

2.1 类的生命周期

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(动态绑定)。

另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

2.1.1 加载

加载过程是类生命周期的第一步,主要完成以下几件事情:

  1. 通过类的全限定名获取类的文件,将它转换成二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

由于《Java 虚拟机规范》中定义的加载阶段很宽泛,相较于类加载过程的其他阶段,加载阶段是可控性最强的阶段。

开发人员可以自定义获取类的方式,比如:

  • 从本地文件系统中加载
  • 通过网络下载
  • 从压缩包里面加载(蛋糕小时候玩的页游,长大后拉到服务端的源文件发现 Class 文件全在压缩包里)
  • 从数据库获取
  • 等等……

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

2.1.2 连接

1)验证

确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的要求,保证这些代码运行后不会危害虚拟机自身的安全。

验证阶段大致完成四个检验动作:

  1. 文件格式验证:Class 文件是否符合规范,且能被当前版本的 JVM 处理
  2. 元数据验证:对字节码描述的信息进行语义分析(有没有父类等)
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
  4. 符号引用验证:确保解析动作能正确执行

2)准备

为类的静态变量分配内存,并将其初始化为默认值。

类变量有两种:

public static final int a = 1
public static int b = 2

对于类变量 a 来说,由于被 final 修饰,在准备阶段会被直接赋值;而对于类变量 b 来说,在准备阶段是赋予了零值。

什么是零值?

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

3)解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

符号引用和直接引用:

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。我们这里可以简单理解符号引用就是一个字符串就好了。
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

2.1.3 初始化

初始化阶段为类的静态变量赋予正确的初始值。

JVM 执行初始化的步骤:

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

类的初始化顺序:

  1. 父类的静态变量和static代码块
  2. 子类的静态变量和static代码块
  3. 父类的普通成员变量和非static代码块
  4. 父类构造函数
  5. 子类的普通成员变量和非static代码块
  6. 子类构造函数

类的初始化时机:

  • new 的时候
  • 访问这个类的静态变量,或对静态变量赋值
  • 调用类的静态方法
  • 反射
  • 初始化某个类的子类,则父类也会被初始化
  • 该类时启动类(main 方法,Test 方法)

2.1.4 使用

当类完成了加载、链接、初始化三个阶段后,类就可以被使用。

可以访问它的静态成员,或者使用 new 关键字为其创造对象实例。

2.1.5 卸载

意味着,整个 JVM 生命周期结束:

  • 执行了 Syetem.exit() 方法
  • 程序正常执行结束
  • 程序执行过程中遇到异常
  • 操作系统错误导致进程终止

2.2 类加载器

如果我们想要知道一个类是被哪个类加载器所加载的,我们可以通过调用 Thread.currentThread().getContextClassLoader() API 来获取类加载器。

我们可以尝试一下:

public static void main(String[] args) {
    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
    System.out.println(contextClassLoader);
    System.out.println(contextClassLoader.getParent());
    System.out.println(contextClassLoader.getParent().getParent());
}

输出:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@5fcfe4b2
null

我们可以看到,这个类被 AppClassLoader 所加载,而 AppClassLoader 又被 ExtClassLoader 所加载,可 ExtClassLoader 上层是没有父类加载器的,真的是这样吗?

其实并不是,其实 ExtClassLoader 上层还有一个类加载器,叫 BootstrapLoader,它是用 C 语言实现的,因此无法获取到,便返回了一个 null。

这里咱们就牵扯到类加载器的一个层级关系:

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

类加载器主要分为三种:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分。这个类加载器负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的类
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库
  • 应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。它负责加载用户类路径(ClassPath)上所有的类库

当然,如有必要,我们还可以自定义类加载器,通过自定义加载器,可以自定义加载类的流程。

2.3 双亲委派机制

听起来好像很牛,但其实双亲委派指的就是类加载器之间的层级关系。

先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为 null,并不是说它没有父类加载器,而是 BootStrapClassLoader。假如父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用自己的 findClass() 方法尝试进行加载。

双亲委派模型可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

3 运行时数据区域

JVM 在运行时,将其管理的内存空间划分成不同的区域,这些区域各自有各自的用途,有各自的创建和销毁时间。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

3.1 程序计数器

【线程私有】【记录线程状态】

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

为何是线程私有的?

当多个线程同时在执行时,会有频繁的上下文切换,为了线程能切换回来后能够恢复到正确的位置执行,每条线程都需要一个独立的程序计数器,所以它是线程私有的。

程序计数器中存储着什么?

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。

3.2 虚拟机栈

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

每个方法在执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法的调用和结束,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在《Java 虚拟机规范》中,对这个内存区域规定了两类异常状况:

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

每一个栈帧里都存储着:

  • 局部变量表(存放基本数据类型和对象引用)
  • 操作数栈,或称为表达式栈
  • 动态链接:指向运行时常量池的方法引用
  • 方法返回地址:方法正常退出或者异常退出的地址
  • 其他的附加信息

3.3 本地方法栈

本地方法栈和虚拟机栈发挥的作用非常相似,但本地方法栈服务的对象是虚拟机使用到的方法。

3.4 堆

Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块,同时也是被所有线程共享的一块内存区域,随着虚拟机的启动而创建。

此区域的唯一目的就是存放对象,几乎所有的对象实例都在之类分配内存。

堆空间容易出现 OOM 异常:

  • OutOfMemoryError: GC Overhead Limit Exceeded:JVM 在垃圾回收时用了太多时间并且回收的空间很少的时候会报出的错误
  • OutOfMemoryError: Java heap space:创建对象时,发现堆中的内存不足以存放新的对象就会出现这个报错

3.4.1 堆内存区域概览

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

3.4.2 新生代

存放新对象的地方,新生代又被划分为三个区域:伊甸园(Eden)、Survivor0 和 Survivor1,它们之间的空间与新生代总空间的比例默认是 8:1:1

  • 大多数新创建的对象都位于 Eden 内存空间中,像特别大的对象可能直接进入了老年代
  • Eden 空间满后会触发 Minjor GC,并将幸存下来的对象存入 Survivor 区域,同时 Minjor GC 会检查 Suivivor 区域,幸存的对象会被移入 S0 或 S1,所以 Survivor 中总有一个是空的
  • 经历多次 GC 的对象会进入老年代

3.4.3 老年代

老年代存放一些经历了多次 GC 后的对象或者是新生代无法存放的大对象,老年代执行的垃圾收集称为 Full GC,需要更长的时间,消耗的资源更大。

3.4.4 对象在堆中的生命周期

对于一个 NEW 出来的对象,首先在 Eden 区分配内存。

那么便会引申出一个问题,假如 Eden 区内存空间不够怎么办?

事实上,假如 Eden 区无法容纳新的对象,JVM 会对 Eden 区发起发起 Minor GC;假如 Eden 区经过垃圾回收后任然无法容纳新的对象,就会往 Survivor 区移,假如 Survivor 任然无法存放新对象,便会通过 分配担保机制 将新生代的对象提前转移到老年代中。

假如 JVM 判断,老年代任然没有足够的空间来存放新的对象,便会出发 Full GC,Full GC 频繁会严重影响 Java 程序的性能。

大对象会直接进入老年代:主要是为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

在 Surviror 区存活了很久的对象,也会进入老年代。

每个对象都有一个“年龄”,当对象经历了一次 Minor GC,并进入了 Survivor 区后,对象的初始年龄就变为了 1。

该对象每经历一个 Minor GC,其年龄都会加 1,知道超过一个可配置的阈值(默认 15 岁),就会进入老年代。

3.5 元空间(方法区)

方法区是 JVM 规范定义的一个概念,可以理解为一个接口,JDK1.8 的实现是元空间,可以看作是接口的具体实现。

方法区和堆空间一样,是线程共享的区域,用于存放已被虚拟机加载的类型的信息、常量、静态变量、即时编译器编译后的代码缓存等。

4 垃圾回收机制

Java 虽然对于开发者友好,不需要你关注内存的使用细节,但不意味着你可以「滥用」。

当出现内存溢出问题、当垃圾收集过慢成为系统瓶颈的时候,我们就要对垃圾回收方面的知识有一定的储备,能根据垃圾回收的现象进行相应的调整,优化代码也好,使用中间件也好,调大内存也好,都是解决频繁垃圾回收甚至是内存溢出的重要手段。

4.1 判断对象是否死亡

在进行垃圾回收之前,JVM 需要先判断这个对象是不是已经“死亡”。

在 Java 中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。比如说,有地方引用了这个对象,我就给它加 1,引用失效,就给它减 1,引用数量为 0,则证明这个对象现在是“垃圾”

这种实现简单而高效,但 Java 并没有采用此算法,因为这个算法无法解决循环引用的问题。

可达性分析算法,又称根搜索算法。

可达性分析算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

GC Roots 一般包含以下内容:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

4.2 垃圾回收算法

4.2.1 标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。

最基础的收集算法,后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。

它的主要缺点有两个:

  • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
  • 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

4.2.2 标记-复制算法

为了解决((20220118224514-j7olyx2 "执行效率不稳定"))的问题,创造了标记-复制算法。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

4.2.3 标记-整理算法

针对老年代对象的死亡特征,创造了标记-整理算法。

其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

4.2.4 分代收集算法

堆空间划分了不同的区域,根据不同区域的特性,采用不同的回收算法,以达到更好的利用内存空间的目的。

因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

4.3 垃圾收集器

垃圾收集器多种多样,我们了解一下 Java8 所使用的默认垃圾收集器即可。

找一台带有 Java 8 的机器,终端输入以下命令:

java -XX:+PrintCommandLineFlags -version

「⭐学JVM必收藏之」短习惯了,很不情愿的挤了一篇长的出来……

可以看到,JDK1.8 使用的垃圾收集器是 ParallelGC

对于 Parallel 垃圾收集器来说,新生代采用标记-复制算法,老年代采用标记-整理算法(Parallel Old)。

Parallel 关注的是如何高效率的运用 CPU,也就是提高程序的吞吐量;Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

5 JVM 常用参数

1)堆空间常用参数

  • -Xms:堆内存空间的最小值
  • -Xmx:堆内存空间的最大值

通常以上两个参数的配置是相等的,避免空间不足时动态扩容带来的影响,可以使得堆相对稳定

  • -Xmn:指定新生代大小
  • -XX:SurvivorRatio:Eden区与Subrvivor区大小的比值,如果设置为8,两个Subrvivor区与一个Eden区的比值为2:8,一个Survivor区占整个新生代的十分之一

Xmn 用于设置新生代的大小,过小会增加 Minor GC 频率,过大会减小老年代的大小,一般设为整个堆空间的 1/4 或 1/3

2)元空间参数

  • -XX:MetaspaceSize:设置 Metaspace 的初始大小,也是最小大小
  • -XX:MaxMetaspaceSize:置 Metaspace 的最大大小,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存

3)垃圾回收相关参数

  • -XX:+PrintGCDetails:开启详细GC日志模式,日志的格式是和所使用的算法有关
  • -XX:+PrintGCDateStamps:将时间和日期也加入到 GC 日志中
  • -XX:+HeapDumpOnOutOfMemoryError:程序发生 OOM 时,转储 Dump 文件
  • -XX:HeapDumpPath:设置 Dump 文件的保存位置

References

博客园:类的初始化

pdai - Java 全栈知识体系

Java Guide

转载自:https://juejin.cn/post/7140647984944906270
评论
请登录