JVM 教程

导读:本篇文章讲解 JVM 教程,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

jvm概述

前言

本节内容主要是介绍 JVM 的定义、意义以及虚拟机的发展历程,是本套课程的基础知识部分,也是我们初次握手JVM 的章节。本节主要知识点如下:

  • 了解 JVM 的定义,这是我们了解 JVM 概念的基础,为本节的重点之一;
  • 了解 JVM 存在的价值及意义,从使用层面了解 JVM 存在的意义,也是本节课程的重点内容;
  • 了解 JVM 整体结构,该结构图是从宏观层面,介绍的虚拟机的整体结构模块,后续会对每个模块进行细致的介绍与讲解,此处可视作了解内容,为后续内容的学习奠定基础;
  • 了解如何查看自己所使用的 JVM 版本,安装完成 JDK 的学习者,都可以进行查看;
  • 了解 JVM ,JRE 和 JDK 三者直接的区别,这是学习 JVM 前需要掌握的基础知识。

JVM 定义

定义: JVM (Java Virtual Machine 简称 JVM),亦可称之为 Java 虚拟机。它是运行所有 Java 程序的抽象计算机,是 Java 语言的运行环境,它是 Java 最具吸引力的特性之一。
在这里插入图片描述

虚拟机:从字面意义上来理解,虚拟机是一个虚拟化出来的计算机。

举个例子:我们经常在 Windows 操作系统上安装 Linux 的虚拟机,然后在 Linux 虚拟机上进行 Shell 脚本的编写练习,那么从这个角度上来说, Linux 虚拟机就类似于 JVM ,不同的是 Linux 虚拟机支撑了 Shell 脚本的运行环境,而 JVM 支撑了 Java 语言的运行。

JVM 是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java 虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java 虚拟机屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 Java 虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

JVM 的作用

JVM 是 Java 语言的一大关键亮点,对于 JVM 的作用,我们这里介绍两个主要的作用,来体现 JVM 的价值所在。

  • 跨平台性:Java 语言之所以有跨平台的优点,完全是 JVM 的功劳,跨平台性是 JVM 存在的最大的亮点。

以上一个知识点部分所举出例子来说,Windows 操作系统安装上 JVM 之后,可以支持 Java 程序的运行; Linux 操作系统安装上 JVM 之后,可以支持 Java 程序的运行;同理,Unix 操作系统等等所有我们熟悉的操作系统,安装上 JVM 之后,都可以支持 Java 程序的运行。

这大大提升了 Java 语言的平台灵活性,能够在众多语言争鸣的时代,脱颖而出。

  • 优秀的垃圾回收机制: Java 语言的诞生,极大的降低了软件开发人员的学习难度,除了 Java 面向对象编程的特性能够降低学习难度以外,还有一个比较重要的点,就是在进行 Java 编程的时候,可以更少的去考虑垃圾回收机制。

学习过 C 语言的技术人员都能够体会这一点,因为 C 语言编程过程中,要通过代码手动实现内存垃圾的回收与空间释放,这提升了编程的难度,因为考虑内存空间释放,更多的会涉及到底层的知识,这是非常高的一个门槛。从 JVM 的角度来说,JVM 拥有自己的垃圾回收机制,为开发人员分担了部分工作。

Tips:JVM 在 Java 语言中占据了非常重要的地位,学习 JVM 是 Java 技术人员必须要做的事情,目前企业对于 Java从业者对 JVM 的掌握程度要求非常高,是重点学习内容。

查看自己的 JVM

我们知道,如果需要运行 Java 程序,必须要安装 JDK,这说明 JDK 中就包含了支持 Java 语言运行的JVM ,我们来看下如何查看本机的 JVM 信息。

无论是 Windows 操作系统还是 Linux 操作系统,正确安装 JDK 并且配置好环境变量后,在命令行输入如下命令进行查看:

java -version

以本人的机器为例,可以看到如下的执行结果:

C:\Users\Wayne.WangTJ>java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

我们仔细看下最后一句执行结果,Java HotSpot™ 64-Bit Server VM (build 25.191-b12, mixed mode),这就是我的电脑中 Jvm 虚拟机操作系统的版本。 当然了,安装不同的版本,结果是有所区别的。

JVM,JRE 和 JDK 联系

三者的定义:我们先来明确下三者的定义,然后说明三者的联系与区别。

  • JDK:全称 java development kit ,开发工具包,面向我们的开发者,为开发者提供开发类库,他是 java 的核心。JDK 包含了JRE,一堆工具类(javac、java)以及 Java 的基础类库(Object,string);
  • JRE:全称 java runtime environment。包含了JVM 实现和需要的类库。JRE 是一个运行环境,并非开发工具;
  • JVM:它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM 有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java 语言最重要的特点就是跨平台运行。使用 JVM 就是为了实现跨平台。
    如下图所示,我们可以看到 JDK 包含 JRE,JRE 包含 JVM,我们继续来看下边的讲解,彻底了解三者之间的联系与区别。
    在这里插入图片描述

三者的区别:

  • JDK 和 JRE 的区别: JDK 是开发工具包,包含了JRE;JRE 是运行环境,不提供开发工具包。
  • JRE 和 JVM 的区别: JRE 包含了JVM,JRE=JVM+lib。JRE 为 class 文件提供了运行的环境,但是需要 JVM 进行 class 文件的翻译,JVM 将翻译好的文件传给 os 系统或者是 CPU 映射指令集,才能够最终完成运行。

三者的联系:三者互相配合不可分割。

JVM 不能够单独的搞定 class 文件,解释 class 的时候,JVM (安装路径\Java\jre1.8.0_144\bin)需要调用我们所需要的类库(安装路径\Java\jre1.8.0_144\lib)。笼统的来说,JVM + lib = JRE。而 JDK 是基于 JRE 基础之上进行的。

总体来说,我们利用 JDK 开发了属于我们自己的程序,通过 JDK 的 javac 工具包进行了编译,将 Java 文件编译成为了 class 文件(字节码文件),在 JRE 上运行这些文件的时候,JVM 进行了这些文件(字节码文件)的翻译,翻译给操作系统,映射到 CPU 指令集或者是操作系统调用,最终完成了我们的代码程序的顺利运行。

小结

本节课程主要是对 JVM 的定义以及作用进行一个简单的介绍,通过对 JVM 的整体结构进行描述,使我们了解了 JVM 的基本情况,为后续的学习奠定良好的基础。

JVM 整体架构

目标

  • 认识 JVM 整体结构,对 JVM 的各个模块作用有一个初步的了解,为本节基础内容;
  • 对「类加载子系统模块」,进行更加细粒度的模块划分介绍,重点从概念层面掌握类加载的步骤,为本节重点内容之一;
  • 对「运行时数据区」进行更加细粒度的模块划分介绍,重点先从概念层面,了解运行时数据区的五大模块定义及作用,为本节重点内容之一;
  • 对「执行引擎」进行更加细粒度的模块划分介绍,重点先从概念层面,了解执行引擎的三大模块定义及作用,其中包括垃圾回收器的初步介绍,为本节重点内容之一;
  • 了解 JVM 的生命周期,可视作本节课程的次重点内容。
    本节内容是我们初次了解 JVM 整体架构,以及各模块的定义及作用,从概念的角度去了解 JVM 的各个模块,为我们后续对各模块的深入学习打下了良好的基础。

JVM 整体架构

我们首先来看看,JVM 的整体结构图,然后对每一个结构模块进行简单的介绍。
在这里插入图片描述

从结构中可以看出,JVM 结构主要分为以上几个模块,其中部分重点模块内部还会细分责任更加明细的模块,此处先来简单了解下每个模块的作用。

  • Class 文件:主要指编译成字节码的 Java 文件,Class 文件才是 JVM 可以识别的文件,所以 Java 文件需要先进行编译才可进入 JVM 执行;
  • 类加载子系统:类的加载,主要负责从文件系统,或者网络中加载 Class 信息,并与运行时数据区进行交互;
  • 运行时数据区:主要包括五个小模块,Java 堆, Java 栈,本地方法栈,方法区,寄存器。后文对细节模块会有概念的介绍;
  • 执行引擎:分配给运行时数据区的字节码将由执行引擎执行,执行引擎读取字节码并逐个执行。垃圾回收器就是执行引擎的一部分;
  • 本地方法接口:本机方法库进行交互,并提供执行引擎所需的本机库;
  • 本地方法库:它是执行引擎所需的本机库的集合。

通过上文对JVM 整体结构的介绍,我们对JVM有了一定的认识。但是对于上图所示的JVM 整体架构图,还是感觉很抽象,感觉并不直观。那么我们再继续看下图,通过 6 个步骤,来简单的描述下,一个 Java 文件在 JVM 中的流转过程。
在这里插入图片描述

我们对上图中的 6 个步骤,逐一进行介绍:

  • 步骤 1 : 我们的 Demo.java 文件,通过 JDK 的 javac 命令,成功的被编译成为额 Demo.class 文件;
  • 步骤 2 :JVM 有自己的类加载器,将编译好的 Demo.class文件进行了加载;
  • 步骤 3 :类加载器将加载的 Demo.class文件投放到了运行时数据区,供程序执行使用;
  • 步骤 4 :运行时数据区将字节码文件,交给执行引擎执行;
  • 步骤 5 :执行引擎执行完毕,会对运行时数据区的数据进行操作,比如说垃圾回收机制是执行引擎的一部分,垃圾回收机制,针对的是运行时数据区的堆空间,后续我们会详细讲解;
  • 步骤 R :我们发现图中有很多步骤 R ,此处 R 代表 Random,即随机发生的步骤。其实就是我们在执行过程中的一个本地方法的调用,只要我们的程序在运行过程中需要调用本地方法,那么步骤R就会发生。

Tips:此处仅仅是一个简要的介绍,后文会对重点部分进行更加细粒度的模块介绍。此处需要同学记住整体 JVM 结构框架,方便后续知识的学习。

类加载子系统

Java 的动态类加载功能由类加载器子系统处理,处理过程包括加载、链接和初始化。如下图所示,展现了类加载子系统的处理过程。
在这里插入图片描述

我们来介绍下上图中类加载子系统的三个步骤:

加载:通过三种不同的类加载器对 Class 文件进行加载,后续章节会对三种类加载器单独进行讲解。我们也可以自定义类加载器,通过复写 classLoader 方法可以实现自定义的类加载器。

链接:链接阶段会对加载好的 Class 文件进行字节码、静态变量、方法引用等进行验证和解析,为初始化做准备。

初始化:类加载的最后阶段,对类进行初始化。

Tips:类加载子系统是非常复杂的,其实加载(Loading)和链接(Linking)部分还能够进行更加细致的过程划分。鉴于我们刚刚接触
JVM,此处点到即止。不过不用担心,后续的章节会对加载(Loading)和链接(Linking)这两个部分进行更加细粒度的划分以及更加细致的讲解,我们循序渐进,步步为营。

运行时数据区

如下图运行时数据区共包含如下 5 个模块,方法区,Java 栈,本地方法栈,堆和程序计数器。

在这里插入图片描述

Tips:方法区和堆为共享内存区域,多线程环境下共享这两块内存区域。 Java
栈,本地方法栈和程序计数器为线程私有部分,私有数据对其他线程不可见。

我们下边来介绍下运行时数据区的五个模块:

  • 方法区(Method Area):所有的类级数据将存储在这里,包括静态变量。每个 JVM 只有一个方法区,它是一个共享资源;

  • 堆区域(Heap Area):所有对象及其对应的实例变量和数组将存储在这里。每个 JVM 也只有一个堆区域。由于方法和堆区域共享多个线程的内存,所存储的数据不是线程安全的;

  • 栈区(Stack Area):对于每个线程,将创建单独的运行时栈。对于每个方法调用,将在栈存储器中产生一个条目,称为栈帧。所有局部变量将在栈内存中创建。栈区域是线程安全的,因为它不共享资源;

  • PC寄存器(PC Registers):也称作程序计数器。每个线程都有单独的 PC 寄存器,用于保存当前执行指令的地址。一旦执行指令,PC 寄存器将被下一条指令更新;

  • 本地方法栈(Native Method stacks):本地方法栈保存本地方法信息。对于每个线程,将创建一个单独的本地方法栈。

执行引擎

如下图执行引擎共包含如下三个模块,解释器,JIT 编译器和垃圾回收器。
在这里插入图片描述

Tips:垃圾回收器是本模块的重中之重,也是 JVM 的重中之重。后续会有专门的小节内容对垃圾回收器进行细致的讲解。

我们下边来介绍下执行引擎的三个模块:

  • 解释器:解释器是作用于字节码的解释。解释器的缺点是当一个方法被调用多次时,每次都需要一个新的解释;

  • JIT 编译器:JIT 编译器消除了解释器的缺点。执行引擎将在转换字节码时使用解释器的帮助,但是当它发现重复的代码时,将使用 JIT 编译器,这提高了系统的性能;

  • 垃圾回收器(Garbage Collector):收集和删除未引用的对象。可以通过调用 System.gc() 触发垃圾收集。

小结

本节主要是对 JVM 的整体框架进行介绍,了解整体架构是学习 JVM 的基本前提。后续我们会对每一个模块进行展开讲解,所有的知识点都是围绕 JVM 的架构展开的,本节内容非常重要,一定要认真的学习。

JVM 常用参数配置

IntelliJ IDEA 添加运行参数

S

JVM 参数:跟踪垃圾回收

  1. 通过开发工具 IntelliJ IDEA 配置 JVM参数,需要打开 “Run->Edit Configurations” 菜单,然后在 VM Options 中添加相应的 JVM 参数。
    JVM 教程

如上图为添加 JVM 参数 -XX:+PrintGC 的图片说明示例,掌握添加参数的方式,是学习具体参数配置的前提。

  1. 示例代码准备
    为了能够更好的体会,垃圾回收参数的执行效果,我们需要准备一段简易的代码,供我们执行使用,并在代码中手动执行垃圾回收操作,触发垃圾回收机制,使我们的参数能够追踪垃圾回收的动作,并打印相应的日志。

实例:准备测试代码,创建一个 String 类型的 ArrayList,并在 list 中添加三个元素,分别是 “Hello”,“World”,“!!!”。然后直接手动执行垃圾回收操作。

public class PrintGCParamsDemo {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        list.add("Hello");
        list.add("World");
        list.add("!!!");
        System.gc(); //手动执行 gc 垃圾回收
    }
}

Tips: 之所以要手动执行 gc 垃圾回收,是因为 JVM 自动的执行垃圾回收是需要一定的条件的,简单的 main
函数是不能够达到触发垃圾回收的临界值的。所以这里手动进行 gc 方法的调用,是为了展示我们的参数锁带来的作用。

  1. -XX:+PrintGC 参数
    参数作用:-XX:+PrintGC 参数是垃圾回收跟踪中十分常用的参数。使用这个参数启动 Java 虚拟机后,只要遇到 GC,就会打印日志。

为了更好的理解并掌握 -XX:+PrintGC 参数,我们通过如下步骤进行操作。

  • 步骤 1:在 VM Options 中配置参数 -XX:+PrintGC 并保存;
  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:
[GC (System.gc())  3933K->792K(251392K), 0.0054898 secs]
[Full GC (System.gc())  792K->730K(251392K), 0.0290579 secs]

结果分析:由于这是第一次接触 JVM 打印日志,我们按照字段逐一进行分析。

  • GC 与 Full GC:代表了垃圾回收的类型,后续会有详细的讲解,这里了解日志意义即可;
  • System.gc():代表了引发方式,是通过调用 gc 方法进行的垃圾回收;
  • 3933K->792K(251392K):代表了之前使用了 3933k 的空间,回收之后使用 792k 空间,言外之意这次垃圾回收节省了 3933k – 792k = 3141k 的容量。251392K 代表总容量;
  • 792K->730K(251392K):分析同上;
  • 0.0054898 secs:代表了垃圾回收的执行时间,以秒为单位。
    那么我们通过上边的结果分析,可以非常顺利的解读这两行日志。
  1. -XX:+PrintGCDetails 参数
    参数作用:-XX:+PrintGCDetails 参数是垃圾回收跟踪中十分常用的参数,而且日志更加详细。获取比 -XX:+PrintGC 参数更详细的 GC 信息。

为了更好的理解并掌握 -XX:+PrintGCDetails 参数,我们通过如下步骤进行操作。

  • 步骤 1:在 VM Options 中配置参数 -XX:+PrintGCDetails 并保存;
    -步骤 2:运行示例代码,观察执行结果。
    结果验证:

结果分析: 这里只做新增部分的解释。

PSYoungGen:代表了「年轻代」的回收,在这里只需要了解即可,因为后续讲解 JVM 堆内存以及垃圾回收原理时才会涉及到年轻代;
ParOldGen:「老年代」这里只需要了解即可,因为后续讲解 JVM 堆内存以及垃圾回收原理时才会涉及到老年代;
Metaspace:「元空间」,JDK 的低版本也称之为永久代,依然,此处了解即可。
我们看到 -XX:+PrintGCDetails 参数打印了更加详细的日志内容,把空间清理的部分也表达的更加详细了。

  1. -XX:+PrintHeapAtGC 参数
    参数作用:-XX:+PrintHeapAtGC 参数是垃圾回收跟踪中,对堆空间进行跟踪时十分常用的参数,可以在每次 GC 前后分别打印堆的信息。注意,是 GC 前后均打印,打印两次。

为了更好的理解并掌握 -XX:+PrintHeapAtGC 参数,我们通过如下步骤进行操作。

步骤 1:在 VM Options 中配置参数 -XX:+PrintHeapAtGC 并保存;
步骤 2:运行示例代码,观察执行结果。
结果验证:

{Heap before GC invocations=1 (full 0):
 PSYoungGen      total 76288K, used 3933K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 6% used [0x000000076b400000,0x000000076b7d7480,0x000000076f400000)
  from space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
  to   space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)
 ParOldGen       total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)
 Metaspace       used 3420K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 371K, capacity 388K, committed 512K, reserved 1048576K
Heap after GC invocations=1 (full 0):
 PSYoungGen      total 76288K, used 792K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 0% used [0x000000076b400000,0x000000076b400000,0x000000076f400000)
  from space 10752K, 7% used [0x000000076f400000,0x000000076f4c6030,0x000000076fe80000)
  to   space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
 ParOldGen       total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)
 Metaspace       used 3420K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 371K, capacity 388K, committed 512K, reserved 1048576K
}
{Heap before GC invocations=2 (full 1):
 PSYoungGen      total 76288K, used 792K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 0% used [0x000000076b400000,0x000000076b400000,0x000000076f400000)
  from space 10752K, 7% used [0x000000076f400000,0x000000076f4c6030,0x000000076fe80000)
  to   space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
 ParOldGen       total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)
 Metaspace       used 3420K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 371K, capacity 388K, committed 512K, reserved 1048576K
Heap after GC invocations=2 (full 1):
 PSYoungGen      total 76288K, used 0K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 0% used [0x000000076b400000,0x000000076b400000,0x000000076f400000)
  from space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)
  to   space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
 ParOldGen       total 175104K, used 705K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1cb07a0,0x00000006cc700000)
 Metaspace       used 3420K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 371K, capacity 388K, committed 512K, reserved 1048576K
}


结果分析: 由于这是对「堆空间」的日志打印,在学习完 JVM 堆空间之前,了解即可。对于堆空间的参数跟踪,这里进行了更加细致的打印。从结果来看,在 GC 前后,打印了两次堆空间信息,并且将 – PSYoungGen 以及 ParOldGen 进行了更加详细的日志打印。

后续学习完 JVM堆空间之后,回望这部分知识,会非常的简单,此处也必须要先了解,为后续对堆的学习打下良好的基础。

  1. -XX:+PrintGCTimeStamps 参数
    参数作用:会在每次 GC 发生时,额外输出 GC 发生的时间,该输出时间为虚拟机启动后的时间偏移量。需要与 -XX:+PrintGC 或 -XX:+PrintGCDetails 配合使用,单独使用 -XX:+PrintGCTimeStamps 参数是没有效果的。

为了更好的理解并掌握 -XX:+PrintGCTimeStamps 参数,我们通过如下步骤进行操作。

  • 步骤 1:在 VM Options 中配置参数 -XX:+PrintGC -XX:+PrintGCTimeStamps 并保存;
  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:
0.247: [GC (System.gc())  3933K->760K(251392K), 0.0114098 secs]
0.259: [Full GC (System.gc())  760K->685K(251392K), 0.0079185 secs]

结果分析:我们看到了,与 -XX:+PrintGC 参数打印的结果,唯一的区别就是日志开头的 0.247 与 0.259。此处 0.247 与 0.259 表示, JVM开始运行 0.247 秒后发生了 GC,开始运行 0.259 秒后,发生了 Full GC。

  1. 小结
    在这里插入图片描述

本小节的重点内容,即我们所讲述的四个常见的跟踪垃圾回收的参数,学习者,需要对这四个常用参数的意义,以及使用方式进行掌握。由于日志输出中部分内容,涉及到的知识点还没有讲到,但是本节对于这些内容的初步接触,能够有利于学习者,在后续的学习中提升学习的效率,以及理解效率。

JVM 参数:跟踪类的加载与卸载

  1. 前言
    本节内容主要是学习, JVM 跟踪类的加载与卸载的常用参数配置,这是工作中跟踪类的加载与卸载情况时 JVM 中最常用的参数配置。本节主要知识点如下:
  • 理解并掌握跟踪类的加载与卸载的参数 -XX:+TraceClassLoading,为本节重点内容;
  • 理解并掌握跟踪类的加载与卸载的参数 -XX:+TraceClassUnloading,为本节了解内容,非重点知识;
  • 理解并掌握跟踪类的加载与卸载的参数 -XX:+PrintClassHistogram,为本节重点内容;
    JVM 跟踪类的加载与卸载的常用参数是使用 JVM 所必须的知识点,通篇皆为重点掌握内容,需要在理解的基础上并掌握参数的使用方法。
  1. 示例代码准备
    此处的示例代码,与上一节的示例代码相似,但是有重要的区别。详细区别请看 Tips 的内容。

实例:准备测试代码,创建一个 String 类型的 ArrayList,并在 list 中添加三个元素,分别是 “Hello”,“World”,“!!!”。

Tips:注意,此处的示例代码,并没有执行 gc 操作。上一节的内容是为了跟踪垃圾回收,所以需要手动调用 gc
方法而达到垃圾回收的效果。而此处我们讨论的是类的加载与卸载,此处无需进行手动垃圾回收。

public class TracingClassParamsDemo {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        list.add("Hello");
        list.add("World");
        list.add("!!!");
    }
}
  1. -XX:+TraceClassLoading 参数
    参数作用:-XX:+TraceClassLoading 参数是为了跟踪类的加载。

为了更好的理解并掌握 -XX:+TraceClassLoading 参数,我们通过如下步骤进行操作。

  • 步骤 1:在 VM Options 中配置参数 -XX:+TraceClassLoading 并保存;
  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:由于追踪的结果日志非常庞大,此处仅展示具有代表性的类的加载。全部的类加载日志,请学习者自行执行代码进行验证。
[Opened C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.lang.Object from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.util.ArrayList$SubList from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.util.ListIterator from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.util.ArrayList$SubList$1 from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded DemoMain.TracingClassParamsDemo from file:/D:/GIT-Repositories/GitLab/Demo/out/production/Demo/]
[Loaded java.lang.Class$MethodArray from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.lang.Void from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.lang.Shutdown from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]
[Loaded java.lang.Shutdown$Lock from C:\Program Files\Java\jdk1.8.0_152\jre\lib\rt.jar]

结果分析:我们来对类的加载日志进行分析。

  • 第一行:Opened rt.jar。打开 rt.jar,rt.jar 全称是 Runtime,该 jar 包含了所有支持 Java 运行的核心类库,是类加载的第一步;
  • 第二行:加载 java.lang.Object。Object 是所有对象的父类,是首要加载的类;
  • 第三、四、五行:加载了 ArrayList 的相关类,我们的示例代码中使用到了 ArrayList,因此需要对该类进行加载;
  • 第六行:加载我们的测试类 TracingClassParamsDemo ;
  • 第七行:加载 java.lang.Class 类,并加载类方法 MethodArray;
  • 第八行:加载 java.lang.Void 类,因为我们的 main 函数是 void 的返回值类型,所以需要加载此类;
  • 第九、十行:加载 java.lang.Shutdown 类, JVM 结束运行后,关闭 JVM 虚拟机。
    从以上对日志的分析来看,JVM 对类的加载,不仅仅加载我们代码中使用的类,还需要加载各种支持
    Java 运行的核心类。类加载的日志量非常庞大,此处仅仅对重点类的加载进行日志的解读,全部的类加载日志,请学习者自行执行代码进行验证。
  1. -XX:+TraceClassUnloading 参数
    参数作用:-XX:+TraceClassUnloading 参数是为了跟踪类的卸载。由于系统类加载器加载的类不会被卸载,并且只加载一次,所以普通项目很难获取到类卸载的日志。

此处我们先来看看,通过系统类加载器加载的类是否会被卸载。

为了更好的理解并掌握 -XX:+TraceClassUnloading 参数,我们通过如下步骤进行操作。

  • 步骤 1:在 VM Options 中配置参数 -XX:+TraceClassUnloading 并保存;
  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:未打印日志,未发生类的卸载。

引出问题:为什么看不到跟踪类卸载的日志呢?

上文提到了,由系统类加载器加载的类不能够被卸载。所以想要看到跟踪类卸载的日志,我们需要使用自定义的类加载器。通过自定义的类加载器加载的类,在类不可达的时候,会发生垃圾回收,并卸载该类。

一般情况下,开发过程中很少实现自定义的类加载器,除非有特殊的需求场景需要通过自定义的类加载器进行类的加载,因此此处对 -XX:+TraceClassUnloading 稍作了解即可。

  1. -XX:+PrintClassHistogram 参数
    参数作用:-XX:+PrintClassHistogram 参数是打印、查看系统中类的分布情况。

为了更好的理解并掌握 -XX:+PrintClassHistogram 参数,我们通过如下步骤进行操作。

  • 步骤 1:在 VM Options 中配置参数 -XX:+PrintClassHistogram 并保存;
  • 步骤 2:修改示例代码,在代码最后添加代码 Thread.sleep(99999999999L),确保 main 函数长时间不能结束执行(当然了,也可以使用 while (true) 语句,进行无限长时间循环来创造这种场景,可自行选择),以便于观察类的分布情况;
public class TracingClassParamsDemo {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();
        list.add("Hello");
        list.add("World");
        list.add("!!!");
        try {
            Thread.sleep(99999999999L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 步骤 3:运行程序,观察日志输出;
  • 步骤 4:不中断 main 函数的运行,将鼠标指针移动到日志输出的 console 界面并单击鼠标左键,确保鼠标的实时位置在 console 界面。按下键盘 Ctrl+Break 键,观察日志输出。
    结果验证:我们执行步骤 3 时,没有观察到日志的输出。当我们尝试步骤 4 时,获取到了日志输出如下图所示。
    在这里插入图片描述

结果分析: 这是系统中类的分布情况,那我们来看下日志中每列的表头部分代表的意思:

  • num:自增的序列号,只是为了标注行数,没有特殊的意义;
  • instances:实例数量,即类的数量;
  • bytes:实例所占子节数,即占据的内存空间大小;
  • class name:具体的实例。
    我们取出第 3 条日志来进行下翻译:系统当前状态下,java.lang.String 类型的实例共有 2700 个,共占用空间大小为 64800 bytes。
  1. 小结
    在这里插入图片描述

本小节的重点内容即,我们所讲述的三个常用的跟踪类的加载与卸载参数,学习者需要对这三个常用参数的意义,以及使用方式进行掌握。

需要特别注意第二个参数 -XX:+TraceClassUnloading,在后续讲解类加载器的时候,会实现自定义的类加载器,并使用该参数演示类的卸载。通篇皆为重点内容,需要认真对待,掌握本节要点内容。

JVM 参数:配置堆空间与栈空间

  1. 前言
    本节内容主要是学习 JVM 配置堆空间与栈空间的常用参数配置,堆空间和栈空间这两块内存区域是非常重要的运行时数据存放区,掌握堆空间与栈空间的参数配置,在实际工作中非常重要。本节主要知识点如下:
  • 理解并掌握配置堆空间的参数 -Xms 和 -Xmx,并配和跟踪垃圾回收参数 -XX:+PrintGCDetails 验证堆空间是否配置成功,为本节重点内容;
  • 理解并掌握配置年轻代的参数 -Xmn,并配和跟踪垃圾回收参数 -XX:+PrintGCDetails 验证堆空间是否配置成功,为本节重点内容;
  • 理解并掌握配置元空间的参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize,并配和跟踪垃圾回收参数 -XX:+PrintGCDetails 验证堆空间是否配置成功,为本节重点内容;
  • 理解并掌握配置栈空间的参数 -Xss,为本节次重点内容;
    JVM 配置堆空间与栈空间的常用参数,是非常重要的知识点,需要在理解的基础上,并掌握参数的使用方法。堆空间更加详细的内部结构会在后续的课程中做专门讲解,此处先掌握配置方法即可。
  1. 示例代码准备
    本节主要是为了学习配置堆空间和栈空间的参数,因此不需要像跟踪垃圾回收那样手动调用 gc 方法,也不需要像跟踪类的加载与卸载那样建立一个 ArrayList 来观察 ArrayList 类的加载。所以此处的实例代码非常简单,随意打印一行字符串即可,我们主要的关注点在配置完堆空间和栈空间之后是否生效。
    示例:
public class HeapAndStackParamsDemo {  
	 public static void main(String[] args) {  
		 System.out.println("Heap and Stack!");  
	 }  
}
  1. -Xms 和 -Xmx 参数
    参数作用:
  • -Xms:设置堆的初始空间大小;
  • -Xmx:设置堆的最大空间大小。

Tips:多数情况下,这两个参数是配合使用的,设置完初始空间大小后,为了对堆空间的最大值有一个把控,还需要设置下堆空间的最大值。

场景设置:设置堆的初始空间大小为 10 M,设置堆的最大空间大小为 20 M。(此处设置的空间大小为实验数据,具体值的设置,需要根据不同项目的实际情况而定。)

  • 步骤 1:在 VM Options 中配置参数 -Xms10m -Xmx20m -XX:+PrintGCDetails 并保存;

Tips:为了验证参数设置是否成功,我们需要配合使用 – XX:+PrintGCDetails
来获取堆空间的空间大小,因此此处的参数需要添加上 –
XX:+PrintGCDetails,此处仅为验证,正常情况下,堆空间的设置是单独使用的,如: -Xms10m -Xmx20m。

  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:
Heap
 PSYoungGen      total 2560K, used 2012K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 98% used [0x00000000ffd00000,0x00000000ffef7388,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  
 ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
  
 Metaspace       used 3354K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 367K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们实验场景的设计中,设置了堆空间的大小初始化为 10 M,那么换算成 Kb 为 10 M = 10240 Kb。

Tips:我们从打印的结果中看到了三部分内存,PSYoungGen (年轻代),ParOldGen(老年代),Metaspace
(元空间)。从 JDK1.8 开始,Metaspace (元空间)不属于堆空间,目前我使用的 JDK 大版本号为 1.8
,因此对于堆空间的初始化大小 10 M,应该只分配给了 PSYoungGen (年轻代)和 ParOldGen(老年代)。

提出问题:我们来进行下计算,(PSYoungGen total)2560K + (ParOldGen total)7168K = 9728 K。为什么不等于 10240 K? 是什么原因呢?

问题解决:其实是因为这里的 total 指的是可用内存,我们来看下 PSYoungGen (年轻代)的全部日志:

PSYoungGen      total 2560K, used 2012K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 98% used [0x00000000ffd00000,0x00000000ffef7388,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)

我们可以看到,PSYoungGen(年轻代)包含了 eden 区以及 from space 和 to space 两个区域,同一时间,from space 和 to space 只有一个区域是可以用的。所以分配给 PSYoungGen(年轻代)的总内存是 2560 K+ 512 K= 3072 K。

我们再从新做下计算,(PSYoungGen total)3072 K+ (ParOldGen total)7168K = 10240 K。到此,证明了参数设置的有效性。

  1. -Xmn 参数
    参数作用:专门的设置年轻代 PSYoungGen 大小的参数。

场景设置:为了更好的理解并掌握 -Xmn 参数,我们沿用上一知识点的 -Xms10m -Xmx20m -XX:+PrintGCDetails 参数,在此参数的基础上,添加 -Xmn5m ,单独设置年轻代 PSYoungGen 的大小为 5m。

Tips:前文讲解过,堆空间大小 = 年轻代空间大小 + 老年代空间大小,此处设置堆空间初始大小为 10m,年轻代大小为 5m,
那么通过简单的计算,老年代的空间大小为 10m – 5m = 5m。我们继续来看实验步骤和结果验证。

  • 步骤 1:在 VM Options 中配置参数 -Xms10m -Xmx20m -Xmn5m -XX:+PrintGCDetails 并保存;
  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:

结果分析:我们主要来关注下 PSYoungGen(年轻代)的大小,看是否为 5m,换算成 Kb 为 5120 Kb。前文提到过,total 仅代表可用内存,而同一时间 from space 和 to space 只有一个是可用的,所以 PSYoungGen(年轻代)总内存的大小为 4608K + 512K = 5120K = 5m。

  1. -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 参数
    Tips:在 JDK 1.8 之前,所有加载的类信息都放在永久代中。但在 JDK1.8 之时,永久代被移除,取而代之的是元空间(Metaspace)。一些版本比较低的教程或者论坛,经常会在忽略 JDK 版本的前提下谈永久代或者元空间,不要被此类教程迷惑,此处同学要特别注意。

参数作用:

  • -XX:MetaspaceSize :元空间发生 GC 的初始阈值;
    Tips:-XX:MetaspaceSize 这个参数并非设置元空间初始大小,而是设置的发生 GC 的初始阈值。举例来说,如果设置 -XX:MetaspaceSize 为 10m,那么当元空间的数据存储量到达 10m 时,就会发生 GC。

  • -XX:MaxMetaspaceSize :设置元空间的最大空间大小。
    场景设置:设置元空间发生 GC 的初始阈值的大小为 10 M,设置元空间的最大空间大小为 20 M。(此处设置的空间大小为实验数据,具体值的设置,需要根据不同项目的实际情况而定。)
    我们通过两步来进行验证:

  • 步骤 1:在 VM Options 中配置参数 -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=20m -XX:+PrintGCDetails 并保存;

  • 步骤 2:运行示例代码,观察执行结果。
    结果验证:

Heap
 PSYoungGen      total 76288K, used 5244K [0x000000076b400000, 0x0000000770900000, 0x00000007c0000000)
  eden space 65536K, 8% used [0x000000076b400000,0x000000076b91f0d8,0x000000076f400000)
  from space 10752K, 0% used [0x000000076fe80000,0x000000076fe80000,0x0000000770900000)
  to   space 10752K, 0% used [0x000000076f400000,0x000000076f400000,0x000000076fe80000)
  
 ParOldGen       total 175104K, used 0K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
  object space 175104K, 0% used [0x00000006c1c00000,0x00000006c1c00000,0x00000006cc700000)
  
 Metaspace       used 3392K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 368K, capacity 388K, committed 512K, reserved 1048576K

结果分析:从上面的执行结果可以看到,Metaspace 空间的初始大小为 3392K ,并不是我们设置的 10M。那是因为 -XX:MetaspaceSize 设置的是元空间发生 GC 的初始阈值。当达到这个值时,元空间发生 GC 操作。如果不进行设置,这个值默认是 20.8M。

而 -XX:MaxMetaspaceSize 则是设置元空间的最大值,如果不手动设置,默认基本是机器的物理内存大小。虽然可以不设置,但还是建议设置一下,因为如果一直不断膨胀,那么 JVM 进程可能会被 OS kill 掉。

  1. -Xss 参数
    参数作用:设置单个线程栈大小,一般默认 512 – 1024kb。

Tips:由于单个线程栈大小跟操作系统和 JDK 版本都有关系,因此其默认大小是一个范围值, 512 – 1024kb。在平时工作中,-Xss 参数使用到的场景是非常少的,因为单个线程的栈空间大小使用默认的 512 – 1024kb 就能够满足需求。

如果在某些个别场景下,单个线程的栈空间发生内存溢出,多数情况是由于迭代的深度达到了栈的最大深度,导致内存溢出。这种异常情况,多数会选择优化方法,并不是立刻提升栈空间大小,因为盲目提升栈空间大小,是一种资源浪费。

-Xss 参数的使用为本节课程的次重点,学习者只要了解并掌握该参数的作用即可,万一工作中碰到设置栈空间大小的场景,也不至于束手无措。

  1. 小结
    在这里插入图片描述

本小节的重点内容即我们所讲述的几种设置堆空间的参数,学习者需要对这些常用参数的意义以及使用方式进行掌握,在实际工作中,使用频次非常高。对于栈空间的参数设置,为本节次重点,因为使用场景比较少,学习者只要有此参数的印象即可,做到用时可用。

Class 文件

Class 文件中的魔数、主次版本号与常量池

  1. 前言
    本节内容主要是介绍 Class 文件结构中的魔数、主次版本号与常量池。本节主要知识点如下:

Class 文件的数据类型,概念性的知识,为本节基础知识点;
Class 文件结构介绍,为本节次重点知识;
魔数的定义及所占字节空间,为本节重点内容之一;
次版本号与主版本号的定义及对照表,次版本号与主版本号为本节重点内容之一,版本号对照表为了解内容;
常量池计数器与常量池的定义及意义,为本节重点内容之一。
2. Class文件数据类型
根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

  • 无符号数:无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节;无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成的字符串值;

  • 表:表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表。

  • Tips:无符号数和表这两种类型的数据,初次来看非常的抽象,从概念层面来看似乎很难理解。我们无需着急, 本节所讲述的魔数,次版本号,主版本号以及常量池计数器皆为无符号数类型,而常量池为表类型,讲解这些结构时,我会为大家提供示意图,使学习者从感官上看到这两种数据类型,从而彻底理解这两种数据类型。

  1. Class 文件结构
    Class 文件是一组以(8位bit的)byte 字节为基础单位的二进制流。如下图所示 Class 文件的字节码示意图:
    在这里插入图片描述

上图中被绿色框圈起来的则为标准的 Class 文件的样子。左侧为软件本身提供的辅助信息,记录当前行前面总共有多少个 byte (或者说多少个 u1 ),用于快速定位数据(通过数据偏移量的方式。右侧为直接以编辑器打开 Class 文件的样子,显示为乱码。

Tips:使用普通的编辑器打开 Class 文件我们会看到乱码,如果想要像上图一样观察 Class 文件的话,需要下载专门的编辑器。WinHex 就具备这个功能,有兴趣的同学可以安装 WinHex 并使用。

  1. 魔数(Magic Number)
    定义:每个 Class 文件的头 4 个字节(u4)称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。所有 Class 文件,魔数均为 0xCAFEBABE。

Tips:从Class文件结构图中,我们可以看到,Class文件的开头确实是CAFEBABE。下载并安装 WinHex
的学习者,如果打开任意的一个Class文件,开头也必然是CAFEBABE。

无符号数结构示意图:前文提到,魔数是无符号数类型的数据,对于无符号数,我们通常以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节。我们先来看下魔数的示意图:
在这里插入图片描述

Tips:上文提到过,魔数开头为 CAFEBABE,占用 4 个字节,无符号数表示为 u4,那么其中 CA,FE,BA,BE分别占用 1
个字节,无符号数表示为 u1。至此我们了解了魔数的定义及作用,并明白了何为无符号数类型,掌握了魔数的无符号数表示为 u4。

  1. 次版本号与主版本号

学前疑问:学习者可能会有疑问,为什么标题说的是次版本号与主版本号,次版本号在前,而主版本号在后呢?先次后主,是不是读起来感觉有点别扭呢?

疑问解答:特别强调下,对于Class 文件结构,第一部分为 u4 的魔数,魔数后边紧跟的就是 u2 的次版本号,次版本号后边才是 u2
的主版本号,此处需要特别注意,从结构上来说,次版本号在前,主版本号在后。

定义:次版本号与主版本号共同标识了我们所使用的的 JDK 版本,如 JDK 1.8.0 版本的次版本号为 u2 大小,用字节码表示为 00 00,主版本号也是 u2 大小,用字节码表示为 00 34。

  • 次版本号:JDK 版本的小版本号;
  • 主版本号:JDK 版本的大版本号。

Tips:如果 Class 文件的开头 8 个字节分别为 CA FE BA BE 00 00 00 34,那么我们可以确定,这是一个 JVM
可识别的 Class 文件,且使用的 JDK 1.8.0的版本进行的编译,因为前4个字节魔数为 CA FE BA BE 符合标准,后4
个字节 00 00 00 34 为 JDK 1.8.0的版本。

无符号数结构示意图:前文提到,次版本号与主版本号也是无符号数类型的数据。我们接下来看下魔数的示意图:
在这里插入图片描述

Tips:至此我们了解了先次后主,了解了次版本号与主版本号分别占用 2 个字节,无符号数表示为
u2。同时我们从整体上了解了,魔数后边为次版本号,次版本号后边为主版本号。主版本号后边紧跟的是什么?不要着急,我们继续学习。

版本号对照表:开篇的前言部分已经说过,对照表部分为了解内容,这里简单举出几个版本的对照表,了解一下即可。

JDK 版本 16进制字节码
1.8.0 00 00 00 34
1.7.0 00 00 00 33
1.6.0 00 00 00 32
1.5.0 00 00 00 31
  1. 常量池计数器与常量池
    Tips:前文提出过,主版本号后边紧跟的是什么,现在我们揭开答案,主版本号后边紧跟的是常量池计数器,常量池计数器后边紧跟的是常量池。那么常量池后边紧跟的是什么?此处又提出问题,我们后续讲解会有解答。

定义:我们先来看下两者的定义。

  • 常量池计数器:记录常量池中的常量的数量。由于常量池中的常数的数量是不固定的,所以在常量池的入口放置了一个 u2 类型的数据,来代表常量池容器记数值(constant_pool_count)。常量池计数器也是无符号数类型数据。
  • 常量池:Class 文件中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最多的数据项目之一,同时它还是 Class 文件中第一个出现的表类型数据项目。

Tips:相信学习者对常量池的兴趣会比较大,为什么这么说呢?从常量池定义中,我们看到了一句话:“它还是Class文件中第一个出现的表类型数据项目”。表类型数据,终于等到了
Class 文件的表类型数据结构,我们本节会为学习者提供表类型的机构示意图。

常量池计数器无符号数结构示意图:我们还是要按照 Class 文件的结构顺序一步一步来说,先要搞明白常量池计数器,然后再去学习表类型的常量池。
在这里插入图片描述

常量池计数器,我们对于这种无符号数结构其实已经非常的了解了,所以此处我们点到即止,了解常量池计数器的定义及作用,了解了常量池计数器占用 u2 大小即可。

常量池表结构示意图:我们终于接触到了 Class 文件中的表结构,那么我们先睹为快,然后再讲解常量池的重要知识点。
在这里插入图片描述

常量池中存储的数据:常量池中主要存放着两种常量,字面量(Literal)和符号引用(Synbolic References)。

  • 字面量包括:文本字符、声明为 final 的常量值、基础数据类型的值等;
  • 符号引用包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
    cp_info类型:cp_info 又可细分为 14 种结构类型。下表中第二列所说的标志,是指每一种数据类型的标记值,此处做简单了解即可。
    在这里插入图片描述
  1. 小结
    本节讲解了 Class 文件结构开头的 3 种结构,魔数,次版本号与主版本号,常量池计数器与常量池。我们了解了它们的定义及意义,也了解了什么是无符号类型数据,什么是表类型数据,我们需要进行重点掌握。

本节我们也抛出了问题,常量池后边紧跟的结构是什么?我们会在下篇课程中进行讲解。本节所了解到的无符号数与表类型数据,有助于我们后续对其他 Class 文件结构的学习。

Class 文件的访问标志与索引

  1. 前言
    本节内容主要是介绍 Class 文件结构中的访问标志与索引,其中索引又细分为类索引、父类索引、接口索引计数器、接口索引集合四个知识点。本节主要知识点如下:
  • 访问标志的定义及意义,以及结构示意图,为本节重点内容之一;
  • 访问标志的标记类型及标记值对应表,为本节的次重点内容;
  • 类索引、父类索引、接口索引计数器、接口索引集合的定义及示意图,为本节内容之一。
    本节的索引过程讲解对于初学者来说会有一定的难度,讲解过程也会涉及到之前所学习的常量池知识,学习者需要结合之前所学知识去理解索引,并进一步的加深对常量池的认识。
  1. 访问标志(access_flags)

Tips:前文在讲解常量池部分的最后提出过问题,Class文件结构的常量池后边紧跟的是什么结构呢?访问标志,就是我们寻求的答案。

定义:在常量池结束之后,紧接着的 2 个字节代表访问标志(access_flags),访问标志用于识别一些类或接口层次的访问信息。

从定义的描述中我们可以了解到,访问标志也是无符号数类型的数据,既然访问标志占用了 2 个字节,那么访问标志的占用空间也可用 u2 来表示。

无符号数结构示意图:

在这里插入图片描述

从上图中,我们能够清晰的看到访问标志占用了 2 个字节,是类似于我们之前所学习的常量池计数器的,因为常量池计数器也是占用了 2 个字节,均为 u2 大小。

  1. 访问标志类型对应表
    这部分内容为次重点内容,通过对这部分的学习,学习者需要了解访问标志的不同类型,以及不同类型的访问标志的意义。我们先来看下对应表,后续我会举例说明。
标志类型 对应标志值 标志意义
ACC_PUBLIC 0x0001 是否为 public 类型
ACC_FINAL 0x0010 是否被声明为 final 类型
ACC_SUPER 0x0020 是否允许使用 invokespcial 字节码指令的新语义
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为抽象类型
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码生成
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举
我们举个简单的例子来理解下访问标志以及这张对应表。假设需要访问一个接口,那么此时访问标志 ACC_INTERFACE 的值为 true,标志对应的值为 0x0200。这样 JVM 虚拟机在处理访问的时候,就能够做到有据可依。
  1. 类索引与父类索引
    定义:类索引(this_class)和父类索引(super_class)都是一个 u2 大小的数据。
  • 类索引:确定当前类的全限定名。
  • 父类索引:确定当前类的父类的全限定名。
    Tips:由于 Java 单继承的原则,父类只可能有一个;由于 Object 是所有其他类的基类,所以除了 Object 类没有父类以外,其余所有类的 super_class 都不为空。

无符号数结构示意图:类索引是紧跟在访问标志之后的结构,类索引后边紧跟的结构是父类索引。由于类索引与父类索引关系非常紧密,都是描述的当前类以及当前类的父类的全限定名,所以此处我们将二者放在一起进行讲解。
在这里插入图片描述

  1. 接口索引计数器与接口索引集合
    父类索引后边紧跟的是接口索引计数器,接口索引计数器后边紧跟的是接口索引集合。类似于常量池计数器和常量池的关系,接口索引计数器记录的是接口索引集合中接口索引的数量。

Tips:对于常量池计数器和常量池,一个是无符号数类型,一个是表类型。相比而言,接口索引计数器和接口索引集合皆为无符号数类型,这里学习者可以进行对比记忆。我们继续来看下两者的定义以及无符号数类型的结构示意图。

定义:

  • 接口索引计数器:代表了接口索引集合中接口的数量;
  • 接口索引集合:按照当前类 implements(或当前接口extends)的接口的顺序,从左到右依次排列在接口索引集合中,此部分集合称为接口索引集合。
    无符号数结构示意图:接口索引计数器和接口索引集合均为无符号数类型结构,结构示意图如下图所示。
    在这里插入图片描述

从图中可以看出,接口索引计数器占用了 2 个字节,为 u2 大小,接口索引集合中的每一个接口元素占用了 2 个字节大小,也为 u2 大小。

Tips:接口索引集合后边紧跟的数据结构是什么?我们继续抛出问题,后续章节会有问题的解答,让我们带着问题继续探究 Class 文件结构。

  1. 小结
    本节讲解了 Class 文件结构中的访问标志与索引,其中索引我们又细分了四部分结构进行了讲解,分别是类索引,父类索引,接口索引计数器以及接口索引集合。通过本节的学习,我们了解了它们的定义及意义,也了解了它们的数据类型及示意图。

本节我们也抛出了问题,接口索引集合后边紧跟的结构是什么?我们会在下篇课程中进行讲解。本节所了解到的访问标志与索引相关知识均为重点内容,学习者需要用心掌握。

Class 文件中的字段表、方法表与属性表

  1. 前言
    本节内容主要是介绍 Class 文件结构中的字段表、方法表与属性表。从本节标题的描述中,我们只看到了这 3 种表结构,但实际上本节会讲解 6 种结构,因为每一种表结构,都需要有计数器进行计数。本节主要知识点如下:
  • 字段表计数器和字段表的定义及意义,以及结构示意图,为本节重点内容之一;
  • 方法表计数器和方法表的定义及意义,以及结构示意图,为本节重点内容之一;
  • 属性表计数器和属性表的定义及意义,以及结构示意图,为本节次重点内容,由于JVM 对属性表没有特别规范的限制,此处了解其定义及结构即可。
    本节所讲解的内容,是Class 文件结构的最后部分,学习完本节课程后,会对 Class 文件结构有一个整体的认识,并且每一部分的结构顺序与课程中讲解的顺序完全一样,把所有的示意图进行拼接,就能够形成一个完成的 Class 文件结构示意图。
  1. 字段表计数器和字段表
    上节课程抛出了问题,接口索引集合后边紧跟的结构是什么?这里我们来进行解答,后边紧跟的是字段表计数器,字段表计数器后边紧跟的是字段表。

定义:字段表计数器(fields_count)与字段表不可分割,这里我们对两部分结构一起讲解。

  • 字段表计数器(fields_count):记录字段表中字段的数量,为无符号数类型。
  • 字段表(fields):字段表(fields)用于描述接口或者类中声明的变量。字段(field)包括类级变量(即静态变量)以及实例变量(即:非静态变量),但不包括在方法内部声明的局部变量。字段表为表类型结构。
    Tips:这里请学习者特别关注下字段表定义介绍中的最后两句话:“字段(field)包括类级变量(即静态变量)以及实例变量(即:非静态变量),但不包括在方法内部声明的局部变量。“ 简单的总结这句话的意思是字段表中存储的是全局标量,不存储局部变量。

字段表计数器无符号数结构示意图:与其他计数器一样,字段表计数器(fields_count)是一个无符号数结构类型的数据,u2 大小。
在这里插入图片描述

字段表-表结构类型示意图:字段表是一个表结构的类型数据,回忆下我们接触到的第一个 Class 文件的表结构类型数据为常量池。这里我们来看下字段表的表结构示意图。

在这里插入图片描述

前文提到过,字段(field)包括类级变量(即静态变量)以及实例变量(即:非静态变量),上图所示的一个 field_info 就代表了一个变量。为了表示一个变量,需要知道这个变量的修饰符,如 public,还需要知道这个变量的变量名称,因此一个 field_info 中存储了很多特征值,所有的特征值综合起来就完整的描述了一个变量。

  1. 方法表计数器与方法表
    字段表后边紧跟的是方法表计数器,方法表计数器后边紧跟的是方法表。

定义:方法表计数器(methods_count)与方法表不可分割,这里我们对两部分结构一起讲解。

  • 方法表计数器(methods_count):记录方法表中字段的数量,为无符号数类型。
  • 方法表(methods):存储了当前类或者当前接口中的 public 方法,protected 方法,default 方法,private 方法等。方法表为表结构类型。
    Tips:Class文件是通过Java文件编译而来的,如果文件中有方法,就会将方法的信息存储到方法表,并通过方法表计数器进行方法的计数。

方法表计数器无符号数结构示意图:与其他计数器一样,方法表计数器(methods_count)是一个无符号数结构类型的数据,u2 大小。

在这里插入图片描述

方法表-表结构类型示意图:方法表是一个表结构的类型数据,与前文所讲解的字段表结构一样。

在这里插入图片描述

一个method_info中会存储很多方法的特征值,我们通过如下示例方法进行举例:

对于get 方法,method_info 中会存储如下信息:

public String get(String name) {
	return "";
}
  • 方法的修饰符 public:还记的我们上节所讲述的 access_flag 吗?access_flag 对应表中有一个 ACC_PUBLIC 就代表了 public 修饰符,他对应的值为 0x0001,此处的标记也需要使用到这个表。都是通用的哦;
  • 方法名称:get 方法;
  • 方法的参数:String name;
  • 方法的返回值类型: String 类型的返回值。
    通过如上方法特征值的共同修饰,完成了一个 method_info 的存储,也就完成了一个方法的存储。

4.属性表计数器与属性表
方法表后边紧跟的是属性表计数器,属性表计数器后边紧跟的结构为属性表。至此,Class 文件的全部结构就讲解完了。回顾之前的知识,Class 文件结构是以魔数开头,以属性表结尾的。

定义:属性表计数器(attributes_count)与属性表不可分割,这里我们对两部分结构一起讲解。

  • 属性表计数器(attributes_count):记录属性表中属性的数量,为无符号数类型。
  • 属性表(attributes):属性表(attributes)与 Class 文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不能识别的属性。

Tips:学习者对属性这一概念并不陌生了,这里不多加赘述,但是我们重点要关注下属性表的两大特点:一个是限制宽松,无顺序长度要求;一个是开发者可以自己向属性表中添加不重复的属性。言外之意是属性表作为
Class 文件的一个结构,是非常灵活的,且没有明确的长度大小规定。此部分知识我们稍作了解即可。

属性表计数器无符号数结构示意图:与其他计数器一样,属性表计数器(attributes_count)是一个无符号数结构类型的数据,u2 大小。

在这里插入图片描述

属性表-表结构类型示意图:属性表也是一个表结构类型,与字段表、方法表结构类似,但是属性表没有固定的长度和顺序限制,此处我们了解下其结构即可。

在这里插入图片描述

  1. 小结
    本节讲解了 Class 文件结构中最后的几种结构,分别是字段表计数器,字段表,方法表计数器,方法表,属性表计数器,属性表。其中字段表与方法表为本节重点内容,属性表部分视为次重点,仅做了解即可。

至此,我们的 Class 文件结构就讲解完了,我们回顾下 Class 文件的整体结构为:魔数->次版本号->主版本号->常量池计数器->常量池->类索引->父类索引->接口索引计数器->接口索引集合->字段表计数器->字段表->方法表计数器->方法表->属性表计数器->属性表。

学习者需要认真学习 Class 文件的数据结构,掌握整体结构以及细节知识点。

类加载子系统

JVM 类加载器分类

  1. 前言
    我们之前对类加载子系统进行过简要的介绍,此处我们将会进行更加细致的讲解。本节主要知识点如下:
  • 启动(Bootstrap)类加载器的作用及代码验证,为本节重点内容之一;
  • 扩展(Extension)类加载器的作用及代码验证,为本节重点内容之一;
  • 系统(System Application)类加载器的作用及代码验证,为本节重点内容之一。
    通篇皆为重点内容,都是学习者需要重点掌握的。并且此节的内容也是后续内容的知识基础,为了更顺利的进行学习,次节内容需要重点掌握。
  1. 类加载子系统知识回顾
    我们在JVM 总体架构的讲解过程中,提到过类加载子系统的工作流程分为三步:加载->链接->初始化。如下图所示:
    在这里插入图片描述

本节我们所讨论的内容都是围绕第一步“加载(Loading)” 进行的。对于链接和初始化,我们会在后边的章节进行讲解。

我们将加载(Loading)这一步,再进行下细致的模块划分,如下图所示:

在这里插入图片描述

从上图中我们可看到,加载(loading)这一步,里边包含了三个更加细粒度的模块,分别为 BootStrap Class Loader,Extention Class Loader 和 Application Class Loader,这三个 Class Loader 就是我们加载过程中必须要使用到的三大类加载器。

  1. 启动(Bootstrap)类加载器
    定义:启动(Bootstrap)类加载器也称为引导类加载器,该加载器是用本地代码实现的类加载器,它所加载的类库绝大多数都是出自 %JAVA_HOME%/lib 下面的核心类库,当然还有其他少部分所需类库。

由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

Tips:从上述定义的描述中,我们可以看到一个特别需要关注的点:启动类加载器加载的绝对大多数是 %JAVA_HOME%/lib
下边的核心类库。这句话完完全全的体现出了启动(Bootstrap)类加载器存在的意义。对于其他少部分核心类的加载,我们在代码验证过程中来讲解。接下来,让我们通过示例代码进行下验证。

示例:通过编写一个 main 函数,打印出通过启动(Bootstrap)类加载器加载的所有的类库信息,以证实启动(Bootstrap)类加载器加载的是 %JAVA_HOME%/lib 下边的核心类库。

Tips:注意下 main 函数代码的第二行代码 URL[] urls =
sun.misc.Launcher.getBootstrapClassPath().getURLs(); 这是通过 sun 公司提供的
Launcher 包获取 Bootstrap 类加载器下 ClassPath 下的所有的 URL。

import java.net.URL;

public class LoaderDemo {
    public static void main(String[] args) {
        System.out.println("BootstrapClassLoader 的加载路径: ");
        URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for(URL url : urls)
            System.out.println(url);
     }
}

结果验证:运行 main 函数。

Tips:此处运行结果所打印的类库的绝对路径为本人本机的安装路径,学习者应按照自己真实的JDK安装路径以及版本对号入座,此处仅为示例。

BootstrapClassLoader 的加载路径: 
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/resources.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/rt.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/sunrsasign.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jsse.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jce.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/charsets.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jfr.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/classes

结果解析:我们可以看到,运行结果中的前 7 个类库(不同的JDK版本会有差异,此处我们讨论的是JDK 1.8版本),都是出自lib下的核心类库。但是对于最后一条加载信息却不是 lib 下的类库。我们仔细看下最后这条信息的加载 file:/D:/Programs/Java/jdk1.8.0_111/jre/classes。

这就是前文我们所提到的其他少部分的核心类库加载,学习者可以根据自己真实的安装位置打开 /jre 文件夹,看看是否存在 /classes 路径。结果是 /classes 文件夹路径并不存在,除非我们进行特殊的参数创建才可以出现 /classes 路径。此处并非我们主要讨论的问题,我们关注的是lib文件夹下的核心类库加载,这里仅做了解即可。

  1. 扩展(Extension)类加载器
    定义:扩展类加载器是由 Sun 公司提供的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的,它负责将 %JAVA_HOME%/lib/ext 或者少数由系统变量 -Djava.ext.dir 指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。

Tips:此处我们依旧对大多数的核心类库加载位置进行讨论,即 %JAVA_HOME%/lib/ext
文件夹下的扩展核心类库。对于系统变量指定的类库,稍作了解即可。下边进行示例代码验证

示例:

import java.net.URL;
import java.net.URLClassLoader;
public class LoaderDemo {
    public static void main(String[] args) {
        //取得扩展类加载器
        URLClassLoader extClassLoader = (URLClassLoader)ClassLoader.getSystemClassLoader().getParent();
        System.out.println(extClassLoader);
        System.out.println("扩展类加载器 的加载路径: ");
        URL[] urls = extClassLoader.getURLs();
        for(URL url : urls)
            System.out.println(url);
     }
}

结果验证:运行 main 函数。
扩展类加载器 的加载路径:

file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/access-bridge-64.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/cldrdata.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/dnsns.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/jaccess.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/jfxrt.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/localedata.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/nashorn.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunec.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunjce_provider.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunmscapi.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunpkcs11.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/zipfs.jar

结果解析:我们可以看到,运行结果中所有的核心类库均来自 %JAVA_HOME%/lib/ext 的文件夹。

  1. 系统(System Application)类加载器
    定义:系统类加载器是由 Sun 公司提供的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的,它负责将 用户类路径(java -classpath或-Djava.class.path变量所指的目录,即当前类所在路径及其引用的第三方类库的路径)下的类库加载到内存中。开发者可以直接使用系统类加载器。

Tips:系统(System Application)类加载器加载的核心类库类型比较多,也会加载 lib 下的未被 BootStrap 类加载器加载的类库,还会加载 ext 文件夹下的未被 Extension 类加载器加载的类库,以及其他类库。总而言之一句话,加载除了 BootStrap 类加载器和 Extension 类加载器所加载的其余的所有的核心类库。

示例:

import java.net.URL;
import java.net.URLClassLoader;
public class LoaderDemo {
    public static void main(String[] args) {
        //取得应用(系统)类加载器
        URLClassLoader appClassLoader = (URLClassLoader)ClassLoader.getSystemClassLoader();
        System.out.println(appClassLoader);
        System.out.println("应用(系统)类加载器 的加载路径: ");
        URL[] urls = appClassLoader.getURLs();
        for(URL url : urls)
            System.out.println(url);
     }
}

结果验证:运行 main 函数。
应用(系统)类加载器 的加载路径:

file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/charsets.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/deploy.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/access-bridge-64.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/cldrdata.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/dnsns.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/jaccess.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/jfxrt.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/localedata.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/nashorn.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunec.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunjce_provider.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunmscapi.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/sunpkcs11.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/ext/zipfs.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/javaws.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jce.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jfr.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jfxswt.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/jsse.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/management-agent.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/plugin.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/resources.jar
file:/D:/Programs/Java/jdk1.8.0_111/jre/lib/rt.jar
file:/E:/IdeaWorkspace/LeeCode/target/classes/
file:/D:/Programs/IntelliJ%20IDEA%20Educational%20Edition%202019.3.1/lib/idea_rt.jar

结果解析:我们可以看到, 系统(System Application)类加载器加载的类库种类很多,除了之前两种类加载器加载的类库,其余必须的核心类库,都由系统类加载器加载。

  1. 小结
    对于类加载器中的第一步加载(Loading),我们主要讲解了 3 种类加载器。并且对不同的类加载器所加载的类库进行了讲解以及代码验证。通篇皆为重点知识,需要学习者用心学习。

对于加载(Loading)这一步,我们还未讲解完,下节课程会讲解加载(Loading)这一步所遵循的双亲委派模型,本节作为下一节的知识基础,更需要着重理解、掌握。

JVM 双亲委派模型

  1. 前言
    上节课程的小结部分提到,双亲委派模型是加载(Loading)步骤中所使用的模型,上节内容为本节内容的基础知识,学习本节内容之前,要确保已经掌握了上节所讲解的内容。本节主要知识点如下:
  • 了解双亲委派模型的示意图,是本节课程的基础知识;
  • 掌握双亲委派模型的工作原理,并结合示意图进行理解,为本节内容的核心知识点;
  • 举出三个不同类型的案例供学习者参考,更深入的了解双亲委派模型的工作原理,为本节内容的重点内容;
  1. 双亲委派模型
    在讲解双亲委派模型之前,我们先来看看双亲委派模型的示意图,相信看到如下示意图中的模块信息,学习者会感到莫名的亲切感。示意图如下:

在这里插入图片描述

上图中的亲切感来自哪里呢?我们可以看到在双亲委派模型中,有三种类加载器是我们上节课程中所讲解的,有了上节课程的知识积淀,再理解双亲委派模型会非常的容易。

双亲委派模型原理:

  • 向上委托:如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。如果父类加载器可以完成类加载任务,就成功返回;
  • 向下委派:倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
    如果对于原理性的描述还是比较模糊的话,不要着急,我们继续学习下边的内容,通过案例体会双亲委派模型的原理。
  1. 案例 1:加载 /jre/lib/resources.jar
    通过上节课程的学习,我们能够知道 /jre/lib/resources.jar 是需要被启动(BootStrap)类加载器加载的核心类库,那么我们来看看它的加载流程图。

加载流程图:根据双亲委派模型,我们来看下 resources.jar 的完整加载过程。

在这里插入图片描述

从上图中我们可以看到,对于核心类库 resources.jar 的加载,分为以下 4 步:

  • 步骤 1:resources.jar 会先通过自定义类加载器(前提是我们实现了自定义类加载器),自定义类加载器不会做处理,直接向上委托给系统(System Application)类加载器;
  • 步骤 2:系统(System Application)类加载器接到委托后,也不做任何处理,直接向上委托给扩展(Extension)类加载器;
  • 步骤 3:扩展(Extension)类加载器接到委托后,也不做任何处理,直接向上委托给启动(Bootstrap)类加载器;
  • 步骤 4:启动(Bootstrap)类加载器接到委托后,发现 resources.jar 是自己负责加载的核心类库,于是进行加载,最后成功加载了 resources.jar。
  1. 案例 2:加载 /jre/lib/ext/cldrdata.jar
    通过上节课程的学习,我们能够知道 /jre/lib/ext/cldrdata.jar 是需要被扩展(Extension)类加载器加载的核心类库,那么我们来看看它的加载流程图。

加载流程图:根据双亲委派模型,我们来看下 cldrdata.jar 的完整加载过程。
在这里插入图片描述

从上图中我们可以看到,对于核心类库 cldrdata.jar 的加载,分为以下 5 步:

  • 步骤 1:cldrdata.jar 会先通过自定义类加载器(前提是我们实现了自定义类加载器),自定义类加载器不会做处理,直接向上委托给系统(System Application)类加载器;
  • 步骤 2:系统(System Application)类加载器接到委托后,也不做任何处理,直接向上委托给扩展(Extension)类加载器;
  • 步骤 3:扩展(Extension)类加载器接到委托后,也不做任何处理,直接向上委托给启动(Bootstrap)类加载器;
  • 步骤 4:启动(Bootstrap)类加载器接到委托后,发现 cldrdata.jar 不是自己负责加载的核心类库,于是进行向下委派,委派给扩展(Extension)类加载器;
  • 步骤 5:扩展(Extension)类加载器接到委派后,发现 cldrdata.jar 是自己负责加载的核心类库,于是进行加载,最后成功加载了 cldrdata.jar。

Tips:我们可以看到,在向上委托的过程中,cldrdata.jar 虽然在步骤 3
已经达到了扩展(Extension)类加载器,但是由于扩展(Extension)类加载器需要遵循向上委托的原则,必须要将
cldrdata.jar 向上委托给启动(Bootstrap)类加载器,直到启动(Bootstrap)类加载器向下委派
cldrdata.jar 到扩展(Extension)类加载器才进行类库的加载。

  1. 案例 3:加载 /jre/lib/plugin.jar
    通过上节课程的学习,我们能够知道 /jre/lib/plugin.jar 是需要被系统(System Application)类加载器加载的核心类库,那么我们来看看它的加载流程图。

加载流程图:根据双亲委派模型,我们来看下 plugin.jar 的完整加载过程。
在这里插入图片描述

从上图中我们可以看到,对于核心类库 plugin.jar 的加载,分为以下 6 步:

  • 步骤 1:plugin.jar 会先通过自定义类加载器(前提是我们实现了自定义类加载器),自定义类加载器不会做处理,直接向上委托给系统(System Application)类加载器;
  • 步骤 2:系统(System Application)类加载器接到委托后,也不做任何处理,直接向上委托给扩展(Extension)类加载器;
  • 步骤 3:扩展(Extension)类加载器接到委托后,也不做任何处理,直接向上委托给启动(Bootstrap)类加载器;
  • 步骤 4:启动(Bootstrap)类加载器接到委托后,发现 plugin.jar 不是自己负责加载的核心类库,于是进行向下委派,委派给扩展(Extension)类加载器;
  • 步骤 5:扩展(Extension)类加载器接到委派后,发现 plugin.jar 也不是自己负责加载的核心类库,于是进行向下委派,委派给系统(System Application)类加载器;
  • 步骤 6:系统(System Application)类加载器接到委派后,发现 plugin.jar 是自己负责加载的核心类库,于是进行加载,最后成功加载了 plugin.jar。

Tips:类似于案例 2 的讲解,虽然 plugin.jar 是系统(System
Application)类加载器负责加载的,但是要遵循向上委托的原则,因此在步骤 2 不能够实时加载,只能等待父加载器向下委派时加载。

  1. 小结
    通过对双亲委派模型的讲解,我们了解到了双亲委派模型的定义以及原理,并通过 3 个案例详细的讲述了在双亲委派模型下是如何进行的类库的加载。

通篇皆为重点内容,所有的内容都是围绕双亲委派模型的原理展开的,需要学习者仔细品味案例,掌握双亲委派模型的原理。

JVM 中类加载的链接与初始化

  1. 前言
    对于类加载子系统,前边的课程已经对加载(Loading)这一步做了详细的讲解,本节主要对类加载子系统加载步骤中的链接与初始化进行讲解。本节主要知识点如下:
  • 链接(Linking)步骤更加详细的模块划分:验证,准备和解析,为本节基础知识点;
  • 掌握在链接(Linking)步骤中的第一步验证的详细验证内容,为本节重点内容之一;
  • 掌握在链接(Linking)步骤中的第二步准备的准备内容,为本节重点内容之一;
  • 掌握在链接(Linking)步骤中的第三步解析的具体解析内容,为本节重点内容之一;
  • 掌握初始化(Init)步骤中的规则以及实例初始化顺序,为本节重点内容之一。
    通篇皆为重点内容,本节知识也会为类加载子系统部分画上一个完美的句号,一定要认真对待。
  1. 类加载子系统知识回顾
    我们在JVM 总体架构的讲解过程中,提到过类加载子系统的工作流程分为三步:加载->链接->初始化。如下图所示:
    在这里插入图片描述

本节我们所讨论的内容都是围绕第二步“链接(Linking)” 和第三步“初始化(Init)”进行的。
我们将链接(Linking)这一步,再进行下细致的模块划分,如下图所示:

在这里插入图片描述

从上图中我们可看到,链接(Linking)这一步,里边包含了三个更加细致的步骤,分别为验证(verify),准备(prepare)和解析(resolve)。后文我们会对这三个步骤进行讲解。

  1. 链接-验证(verify)
    定义:验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机的自身安全。

验证过程的主要验证信息:验证过程中,主要对三种类型的数据进行验证,分别是“元数据验证,字节码验证和符号引用验证”。具体内容请看下边的讲解。

元数据验证:

  • 验证这个类是否有父类(除了 java.lang.Object 之外,所有类都应当有父类);

  • 验证这个类是否继承了不允许被继承的类(被 final 修饰的类);

  • 如果这个类不是抽象类,验证该类是否实现了其父类或接口之中所要求实现的所有方法;

  • 验证类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等等)。
    字节码验证:字节码验证主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会产生危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中;

  • 保证跳转指令不会跳转到方法体以外的字节码指令上;

  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险不合法的。
    符号引用验证:符号引用验证可以看作是类对自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验以下内容:

  • 符号引用中通过字符串描述的全限定名是否能够找到对应的类;

  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;

  • 符号引用中的类、字段、方法的访问性(private、default、protected、public)是否可被当前类访问。

  1. 链接-准备(prepare)
    定义:准备阶段是正式为类变量分配内存并设置类变量默认值(通常情况下是数据类型的零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中。

Tips:准备阶段是设置类变量的默认值,不同类型的类变量的默认值是不同的。变量默认值的对照表请参看下表:

变量类型 默认值
int 0
long 0L
short 0
char ‘\u0000’
byte 0
boolean false
folat 0.0f
double 0.0d
reference null
  1. 链接-解析(resolve)
    定义:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

Tips:定义中又引出了两个新的概念:符号引用和直接引用。想要理解解析,必须要先搞明白什么是符号引用和直接引用。

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那么引用的目标一定是已经存在于内存中。

解析过程具体的解析内容:解析过程中,主要对如下4种类型的数据进行验证:

  • 类或接口的解析;
  • 字段解析;
  • 类方法解析;
  • 接口方法解析。
  1. 初始化
    定义:进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

实例的初始化顺序:在进行初始化时,实例变量的初始化顺序如下图所示:
在这里插入图片描述

实例的初始化顺序是非常重要的知识点,在面试过程中也经常涉及到这个知识点,上图的加载顺序需要重点掌握。

  1. 小结
    到目前为止,类加载器子系统就全部讲解完成了。我们学习了类的加载,三种类加载器,双亲委派模型以及本节所讲述的链接与初始化,其中对链接有细分了三个步骤进行了讲解。

类加载器子系统是非常重要的 JVM 模块,需要用心学习,对于一些概念性知识要增强理解,原理性知识要深入思索。后续我们会继续讲解 JVM 的其他重要模块。

运行时数据区

JVM 的栈与寄存器

  1. 前言
    从本节开始,我们对运行时数据区进行讲解,运行时数据区又可以细分为五个模块:栈,堆,寄存器,方法区和本地方法栈,本节我们主要针对讲解栈(包括 Java 栈与本地方法栈)与寄存器。本节主要知识点如下:
  • 了解栈的基本概念及特点,为本节的基础知识;
  • 理解并掌握栈帧的概念以及栈帧的数据结构,并对栈帧结构中的局部变量表,操作数栈,动态链接以及返回地址做详细的讲解,为本节核心内容,需要重点学习;
  • 理解并掌握寄存器的概念及作用,为本节重点内容。
  1. 运行时数据区知识回顾
    之前我们在讲解 JVM 整体架构的过程中,对运行时数据区进行了总体的概括,运行时数据区又可以细分为五个模块:栈,堆,寄存器,方法区和本地方法栈,如下图所示。

在这里插入图片描述

本节我们主要针对讲解栈(Java 栈与和地方法栈)与寄存器(程序计数器),其他 2 个模块,方法区和堆会在后续的课程中进行讲解。

  1. 栈的基本介绍
    基本概念:Java 栈有两个,分别是虚拟机栈和本地方法栈。这里以虚拟机栈为例,本地方法栈和虚拟机栈基本相同。

栈的特点:对于每个线程,将创建单独的运行时栈。对于每个方法调用,将在栈存储器中产生一个条目,称为栈帧。所有局部变量将在栈内存中创建。栈区域是线程安全的,因为它不共享资源。

Java 虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭);
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常;
Java 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法执行的同时会创建一个栈帧。对于我们来说,主要关注的栈内存,就是虚拟机栈中局部变量表部分。

Tips:从栈的特点的最后一点可以看到,开发者主要关注的是栈内存,而栈内存的消耗是因为每个方法执行的同时会创建一个栈帧,而占用空间最大的部分就是栈帧的局部变量表部分。后续我们会展开讲解。

  1. 栈帧
    定义:栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的 java 虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。

栈帧初始化大小:在编译程序代码的时候,栈帧中需要多大的局部变量表内存,多深的操作数栈都已经完全确定了。 因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

栈帧结构:如下图所示,在一个线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
在这里插入图片描述

从上图中我们能够看到,栈帧的组成结构,下文我们将对局部变量表,操作数栈,动态链表以及返回地址进行讲解。

  1. 栈帧 – 局部变量表
    在栈帧中,局部变量表占用了大部分的空间,那么接下来我们看下局部变量表的基本概念与特点。

基本概念:每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。

特点:

  • 局部变量表的容量以变量槽(Variable Slot)为最小单位;
  • 在方法执行过程中,Java 虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程;
  • 局部变量表中的 Slot 是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超过了某个变量的作用域,那么这个变量相应的 Slot 就可以交给其他变量去使用,节省栈空间。
  1. 栈帧 – 操作数栈
    操作数栈也是栈帧中非常重要的结构,操作数栈不需要占用很大的空间,那么我们一起来看下操作数栈的作用及特点。
  • 操作数栈是一个后入先出(Last In First Out)栈,方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程;
  • 操作数栈的每一个元素可以是任意的 Java 数据类型,32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2;
  • 当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,通过一些字节码指令从局部变量表或者对象实例字段中复制常量或者变量值到操作数栈中。
  1. 栈帧 – 动态链接与返回地址
    动态链接的基本概念及作用如下:
  • 每个栈帧都包含一个指向运行时常量池(JVM 运行时数据区域)中该栈帧所属方法属性的引用,持有这个引用是为了支持方法调用过程中的动态链接。

  • 在 Class 文件格式的常量池(存储字面量和符号引用)中存有大量的符号引用(1. 类的全限定名,2. 字段名和属性,3. 方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。
    这些符号引用一部分会在类加载过程的解析阶段的时候转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态链接。
    返回地址:返回地址代表的是方法执行结束,方法执行结束有两种方式,我们来具体看下栈帧中返回地址的作用:

  • 当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:return),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 throw 字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。

  1. 寄存器简介
    寄存器( PC register )基本概念:每个线程启动的时候,都会创建一个 PC(Program Counter,程序计数器)寄存器。PC 寄存器里保存有当前正在执行的 JVM 指令的地址。

寄存器简介:

  • 每一个线程都有它自己的 PC 寄存器,也是该线程启动时创建的。保存下一条将要执行的指令地址的寄存器是:PC 寄存器。PC 寄存器的内容总是指向下一条将被执行指令的地址,这里的地址可以是一个本地指针,也可以是在方法区中相对应于该方法起始指令的偏移量;
  • 每个线程都有一个寄存器,是线程私有的,其实就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,以及即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记;
  • 这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 如果执行的是一个 Native 方法,那这个计数器是空的。
  1. 寄存器的特点
    通过对寄存器的介绍,我们知道,寄存器器是用来存储指向下一条指令的地址,以及即将要执行的指令代码。我们来看下寄存器的特点:
  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域; –
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致;
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 java 方法的 JVM 指令地址:或者,如果是在执行 native 方法,则是未指定值(undefined);
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成;
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一个条需要执行的字节码指令;
  • 它是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域。
  1. 小结
    本节主要讲解了运行时数据区的栈与寄存器,其中栈又包括了 Java 栈和本地方法栈,因为对于 Java 栈和本地方法栈,内存结构是十分相似的,因此放到一起讲解。本节内容中的核心知识点 – 栈帧,有非常多的概念问题,需要学习者先做了解,在了解的基础上,慢慢的消化。

JVM 方法区

  1. 前言
    本节主要讲解运行时数据区的方法区。本节主要知识点如下:
  • 了解方法区的作用及意义,为本节的基础知识;
  • 了解方法区存放数据类型,为本节重点内容之一;
  • 了解运行时常量池,我们在学习Class文件结构的时候,也学习过常量池结构,那么运行时常量池本节课程会进行讲解;
  • 了解方法区与堆内存结构的关系,以JDK 1.8 版本为分界线,进行对比讲解,为本节重点内容之一。
  1. 什么是方法区
    定义:方法区,也称非堆(Non-Heap),是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field 等元数据对象、static-final 常量、static 变量、JIT 编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域 “运行时常量池”。

Tips:对于运行时常量池,后文会有讲解。

对于习惯在 HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。

  1. 方法区存放的数据
    在讲解方法区内存放的数据之前,我们先通过示意图来直观的看下,方法区存放的数据与堆内存之间的关系。如下图所示:
    在这里插入图片描述

从图中可以看到,方法区存放了 ClassLoader 对象的引用,也存放了一个到类对象的引用,这两个引用的对象实例会存放到堆内存中。从上图我们就可以简单的了解到方法区存放的数据是什么,接下来,我们对存放的数据类型进行解释。

  • 类型全限定名:全限定名为 package 路径与类名称组合起来的路径;
  • 类型的直接超类的全限定名:父类或超类的全限定名;
  • 类型是类类型还是接口类型:判定当前类是 Class 还是接口 Interface;
  • 类型的访问修饰符:判断修饰符,如 pulic,private 等;
  • 类型的常量池:这部分会在下文进行讲解;
  • 字段信息:类中字段的信息;
  • 方法信息:类中方法的信息;
  • 静态变量:类中的静态变量信息;
  • 一个到类 ClassLoader 的引用:对 ClassLoader 的引用,这个引用指向对内存;
  • 一个到 Class 类的引用:对对象实例的引用,这个引用指向对内存。
  1. 运行时常量池
    我们先来回顾下Class 文件结构中的常量池的相关知识。

Class 文件中的常量池:
在 Class 文件结构中,最头的 4 个字节用于存储 Megic Number,用于确定一个文件是否能被 JVM 接受,再接着 4 个字节用于存储版本号,前 2 个字节存储次版本号,后 2 个存储主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个 u2 类型的数据 (constant_pool_count) 存储常量池容量计数值。

常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References)。更加具体的知识,同学们可以翻看之前相关的小节内容。

**运行时常量池:**我们回到正题,来看下运行时常量池。

Tips:其实 Class 文件中的常量池与运行时常量池的关系非常容易理解,Class
文件中的常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。简单总结来说,编译器使用
Class 文件中的常量池,运行期使用运行时常量池。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是 String 类的 intern() 方法。

  1. 常量池的优势
    常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。

例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。

  • 节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
  • 节省运行时间:比较字符串时,== 比 equals () 快。对于两个引用变量,只用 == 判断引用是否相等,也就可以判断实际值是否相等。
  1. 方法区内存变更
    在这里插入图片描述

方法区的实现,虚拟机规范中并未明确规定,目前有 2 种比较主流的实现方式:

HotSpot 虚拟机 1.8之前:在 JDK1.6 及之前版本,HotSpot 使用 “永久代(permanent generation)” 的概念作为实现,即将 GC 分代收集扩展至方法区。这种实现比较偷懒,可以不必为方法区编写专门的内存管理,但带来的后果是容易碰到内存溢出的问题(因为永久代有 – XX:MaxPermSize 的上限)。

在 JDK1.7,HotSpot 逐渐改变方法区的实现方式,如 1.7 版本移除了方法区中的字符串常量池,但为发生本质的变化。

HotSpot 虚拟机 1.8之后:1.8 版本中移除了方法区并使用 metaspace(元数据空间)作为替代实现。metaspace 占用系统内存,也就是说,只要不碰触到系统内存上限,方法区会有足够的内存空间。但这不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。

  1. 小结
    本节主要讲解了运行时数据区里边的方法区,方法区是一块共享内存区域,在运行时数据区占据着十分重要的位置。我们了解了方法区里边存储的数据类型,也了解到了方法区的作用,同时了解了方法区内存的版本变更,通篇皆为重点知识,学习者需要用心学习。

JVM 堆内存

  1. 前言
    本节主要讲解运行时数据区的堆内存。本节主要知识点如下:
  • 掌握堆内存空间结构图,从总体层面认识堆内存,为本节重点内容之一;
  • 了解 JVM 堆空间的基本概念,为本节的基础知识点;
  • 了解堆内存的分代概念,年轻代,eden区,from/to,幸存者及老年代,为本节核心知识点,后续对垃圾回收讲解时,大部分的回收都是发生在堆内存中,掌握分代概念是学习垃圾回收机制的必要前提。
  1. 堆内存结构
    堆内存是运行时数据区中非常重要的结构,实例对象会存放于堆内存中。在后续小节中,我们讲解 GC 垃圾回收器,绝大多数的垃圾回收都发生在堆内存中,因此对于 JVM 来说,堆内存占据着十分重要的且不可替代的位置。

我们先来看下堆内存的结构图,初步了解堆内存的整体内存划分。
在这里插入图片描述

从上图可以看到如下几个要点:

  • 堆内存从结构上来说分为年轻代(YoungGen)和老年代(OldGen)两部分;
  • 年轻代(YoungGen)又可以分为生成区(Eden)和幸存者区(Survivor)两部分;
  • 幸存者区(Survivor)又可细分为 S0区(from space)和 S1区 (to space)两部分。
    从图中,我们能够大体了解堆内存的结构划分,后文在讲解分代概念时,我们会提供更加直观,更加清晰的内存结构图。
  1. 什么是堆内存
    物理层面:从物理层面(硬件层面)来说,当 Java 程序开始运行时,JVM 会从操作系统获取一些内存。JVM 使用这些内存,这些内存的一部分就是堆内存。

Java层面:从开发层面来说,堆内存通常在存储地址的底层,向上排列。当一个对象通过 new 关键字或通过其他方式创建后,对象从堆中获得内存。当对象不再使用了,被当做垃圾回收掉后,这些内存又重新回到堆内存中。

总结来说,堆内存是JVM启动时,从操作系统获取的一片内存空间,他主要用于存放实例对象本身,创建完成的对象会放置到堆内存中。

  1. 堆内存的分代概念
    从上文堆内存的结构图中,我们看到了比较多的JVM堆内存中的专有名词,比如:年轻代,老年代。那么对于堆内存来说,分代是什么意思呢?为什么要进行分代呢?

分代:将堆内存从概念层面进行模块划分,总体分为两大部分,年轻代和老年代。从物理层面将堆内存进行内存容量划分,一部分分给年轻代,一部分分给老年代。这就是我们所说的分代。

分代的意义:易于堆内存分类管理,易于垃圾回收。类似于我们经常使用的 Windows 操作系统,我们会将物理磁盘划出一部分存储空间作为用户系统安装盘(如 C 盘),我们还极大可能将剩余的磁盘空间划分为 C, D, E 等磁盘,用于存储同一类型的数据。

  • 易于管理:对于堆空间的分代也是如此,比如新创建的对象会进入年轻代(YoungGen)的生成区(Eden),生命周期未结束的且可达的对象,在经历多次垃圾回收之后,会存放入老年代(OldGen),这就是分类管理;

  • 易于垃圾回收:将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及 GC 频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。

Tips:关于上文提到的垃圾回收部分的知识,我们会在后边的章节做专门的、详细的讲解,此处我们先做了解即可。

  1. 堆内存结构详解
    讲解完分代的概念,我们来对堆内存中的不同的代,不同的内存空间的作用进行更加详细的讲解。讲解之前,我们来看下如下示意图,更加直观的了解堆内存结构。

在这里插入图片描述

堆内存每个模块之间的关系及各自的特点概述如下:

JVM 内存划分为堆内存和非堆内存,堆内存分为年轻代(YoungGen)、老年代(OldGen);
年轻代又分为 Eden 和 Survivor 区。Survivor 区由 FromSpace 和 ToSpace 组成。Eden 区占大容量,Survivor 两个区占小容量,默认比例是 8:1:1;
堆内存存放的是对象,垃圾收集器就是收集这些对象,然后根据 GC 算法回收;
新生成的对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到Survivor0 区,Survivor0 区满后触发执行 Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个 survivor 区为空。经过多次 Minor GC 仍然存活的对象移动到老年代;
老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。

Tips:关于上文提到的垃圾回收部分的知识,我们会在后边的章节做专门的、详细的讲解,此处我们主要关注在堆内存的每个模块的概念,特点及作用。对于垃圾回收部分的知识,我们后续再进行学习。

  1. 小结
    本节主要讲解了运行时数据区里边的堆内存,堆内存是一块共享内存区域,在运行时数据区占据着十分重要的位置。我们了解了堆内存里的分代概念,并从示意图中直观的感受了堆内存的结构。我们了解了堆内存中不同内存空间模块的作用、特点及意义。这都是非常重要的知识点。

由于垃圾回收绝大多数都是发生在堆内存中,因此在课程讲解的过程中,多少会涉及到垃圾回收的一些概念,此处如果不能理解的学习者,可以在学习完垃圾回收器后再次理解目前不能够掌握的知识。

JVM 堆的对象转移与年龄判断

  1. 前言
    上节课程我们讲解了堆内存中不同内存空间模块的作用、特点及意义,本节主要讲解堆内存中对象的转移与年龄判断。本节主要知识点如下:
  • 理解并掌握对象优先在 Eden 区分配的实验案例,为本节重点内容之一;
  • 理解并掌握对象直接在老年代分配的触发条件,理解什么是大对象,为本节重点内容之一;
  • 掌握堆内存对象转移的完整流程图及触发机制,为本节核心知识点,其它所有知识点都是围绕这一知识点展开的;
  • 理解并掌握年龄判断的定义,作用及默认年龄值,为本节重点内容之一。
    通篇皆为重点内容,其核心是围绕堆内存对象转移的完整流程图及触发机制,本节课程的内容会涉及到垃圾回收的相关概念,此处我们先做了解即可,后续会对垃圾回收进行专门的讲解。
  1. 对象优先在Eden 区分配

Tips:标题中“优先”一次需要学习者认真品味,“优先”
意味着首先考虑,那么在一些特殊情况下,新创建的对象还是有可能不在Eden区分配的。这种特殊情况我们在讲解老年代(OldGen)的时候再进行说明。

上节课程我们学习了,Eden 区属于年轻代(YoungGen)。在创建新的对象时,大多数情况下,对象先在 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

那我们如何进行证明,新创建的对象优先在Eden 区分配呢?为了对这个结论进行验证,我们来设计如下实验。

实验设计:

  • 创建了一个类,类名称可自定义,并在类中实现一个 main 函数,为后续测试做前提准备;
  • 在运行main函数之前,通过设置 JVM 参数,设置堆内存初始大小为 20M,最大为 20M,其中年轻代大小为 10M,不需要特殊设置 Eden 区的大小;
  • 除了设置堆内存参数之外,还需要设置JVM 参数跟踪详细的垃圾回收日志,以便于观察年轻代(YoungGen)的内存使用情况;
  • 设置完成后,main 函数不写任何代码,运行空的 main 函数观察打印日志;
  • 在main函数中创建一个 2M 大小的对象,运行 main 函数观察打印日志。

Tips:实验中会用到两种JVM的参数配置,一种是配置堆内存的参数,另外一种是配置跟踪垃圾回收的参数。这两部分参数我们在之前的章节都有详细描述过。

实验要点准备:

  • 设置堆内存大小为 20M,最大为 20M,其中年轻代大小为 10M,并设置垃圾跟踪日志打印。需要通过JVM参数 -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails 进行设置;
  • 不需要特殊设置 Eden 区的大小,那么年轻代中 Eden 区、from space 和 to space 将会以默认的 8:1:1进行空间分配;
  • 创建一个 2M 大小的对象,我们可以通过语句 byte[] obj = new byte[210241024] 来实现。
    空运行main函数代码演示:
public class DemoTest {
    public static void main(String[] args) {
    }
}

空运行mian函数日志:

Heap
 PSYoungGen      total 9216K, used 2370K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff850aa0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 3439K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 374K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们主要关注 PSYoungGen(年轻代)下的内存分配。空运行情况下,我们看到 Eden 区的大小为 8192K,已使用 28%。为什么空运行下还会有 28% 的内存使用呢?这 28% 的内存使用,包括了支持main函数运行的对象实例。

新建 2M 对象的代码演示:

public class DemoTest {
    public static void main(String[] args) {
        byte[] obj = new byte[2*1024*1024];
    }
}

新建 2M 对象的运行日志:此处我们只展示年轻代的运行日志。

PSYoungGen      total 9216K, used 4418K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 53% used [0x00000000ff600000,0x00000000ffa50ac8,0x00000000ffe00000)

结果分析:我们看到,新建 2M 的对象之后,Eden 区使用的空间从之前的 28% 增长到了 53%,净增长 25%。那么我们来进行简单的计算 Eden 区的总内存大小 8192K * 25% = 2048K = 2M。

看到这里我们应该明白了,新创建的对象确实是优先存储于年轻代(YoungGen)中的Eden区的。

  1. 大对象直接进入老年代
    我们在进行上一知识点讲解时提到过,新创建的对象是优先存放入 Eden 区的,那么对于新创建的大对象来说,会直接进入老年代码。

什么是大对象:2M 的对象算大吗?10M 的对象算大吗?100M 的对象呢?什么是大对象,大对象的标准是什么?大对象的标准是可以由开发者定义的,我们的 JVM 参数中,能够通过 -XX:PretenureSizeThreshold 这个参数设置大对象的标准,可惜的是这个参数只对 Serial 和 ParNew 两款新生代收集器有效。

那么如果不能够设置 -XX:PretenureSizeThreshold 参数,那什么是大对象呢?Eden 区容量不够存放的对象就是所谓的大对象。

为了验证“大对象直接进入老年代”这一结论,我们依然通过实验进行验证。

实验设计:

  • 沿用上一个实验的 JVM 参数设置,并在此基础上增加参数设置 -XX:PretenureSizeThreshold = 3m;
  • 将新建的 2M 对象修改为新建 6M对象;
  • 运行 main 函数,观察日志结果。
    实验要点准备:本实验所需的 JVM 参数为 -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails。

代码示例:

public class DemoTest {
    public static void main(String[] args) {
        byte[] obj = new byte[6*1024*1024];
    }
}

运行结果:

Heap
 PSYoungGen      total 9216K, used 2370K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff850aa0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 6020K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 58% used [0x00000000fec00000,0x00000000ff1e1010,0x00000000ff600000)
 Metaspace       used 3439K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 374K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们先来看下老年代(OldGen),total 10240K, used 6020K,说明我们新创建的对象是直接进入了老年代。然后我们来看下 Eden区 为什么不能存储 6M 大小的对象,我们进行简单的计算。

Eden 区剩余内存空间 = 总空间 8192K * (1-28%)= 5898 K < 6M。这就是我们所说的,大对象直接进入老年代。

  1. 对象转移流程
    上文我们学习了 Eden 区优先存放新建的独享,新建大对象不会经过Eden区,直接进入老年代,那么还剩两个区域没有进行讲解:幸存者区 from space 和 幸存者区 to space。我们在对流程图进行讲解时,会对这两块内存区域进行说明。

在这里插入图片描述

从上图中可以看出,新生成的非大对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到 Survivor0 区,Survivor0 区满后触发执行 Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个 survivor 区为空。经过多次 Minor GC 仍然存活的对象移动到老年代。

如果新生成的是大对象,会直接将该对象存放入老年代。

老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。

  1. 对象年龄判断
    对象年龄判断的作用:JVM 通过判断对象的具体年龄来判别是否该对象应存入老年代,JVM通过对年龄的判断来完成从对象从年轻代到老年代的转移。

对象年龄(Age)计数器:HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。

年龄增加:对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor区中每熬过一次 Minor GC,年龄就增加 1 岁。

年龄默认阈值:当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

  1. 小结
    本节我们学习了堆内存对象的转移过程以及 JVM 是如何通过判断对象年龄来决定是否将对象从年轻代转移至老年代的。通篇皆为重点内容,学习者需认真对待,本节内容与垃圾回收也息息相关,学好本节课程,也能为后续垃圾回收部分打下良好的基础。

垃圾回收器

JVM 可达性分析法

  1. 前言
    上节课我们结束了运行时数据区的讲解,本节课程开始,我们来对执行引擎进行讲解,在执行引擎模块中,首当其冲的就是垃圾回收器。本节主要知识点如下:
  • 了解垃圾回收器在 JVM 整体架构中的位置,为本节基础知识;
  • 了解并掌握垃圾回收器的定义以及意义,为本节基础知识;
  • 理解并掌握可达性分析法的原理以及意义,为本节课程的核心知识点。
  1. 垃圾回收器的位置
    我们在 JVM 整体架构介绍的小节提到过 JVM 的垃圾回收器位于执行引擎中。而当时我们的执行引擎只是简单的画了下,那么我们先来看下执行引擎的更细致的结构如下图:
    在这里插入图片描述

Tips:在讲解可达性分析之前,我们先来解决一些基本问题:什么是垃圾回收器?为什么进行垃圾回收?哪些内存需要回收?

  1. 垃圾回收器的基本概念
    什么是垃圾回收器:JVM 为 Java 提供了垃圾回收机制,其实是一种偏自动的内存管理机制。简单来说,垃圾回收器会自动追踪所有正在使用的对象,并将其余未被使用的对象标记为垃圾,不需要开发者手动进行垃圾回收,JVM 自动进行垃圾回收,释放内容。

为什么进行垃圾回收:如果不进行垃圾回收,内存迟早都会被消耗空,因为我们在不断的分配内存空间而不进行回收。除非内存无限大,我们可以任性的分配不回收,但是事实并非如此。所以,垃圾回收是必须的。

哪些内存需要回收:哪些内存需要回收是垃圾回收机制第一个要考虑的问题,所谓“要回收的垃圾”无非就是那些不可能再被任何途径所使用的对象。无需再使用的对象,会被标记为垃圾,等待JVM回收此部分内存。

Tips:Java中通过可达性分析法来检测对象是否为垃圾,如果不可达,则将对象标记为垃圾,会被 JVM 回收,接下来我们学习可达性分析法。

  1. 可达性分析法基本原理
    方法原理:通过一系列称为”GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链(即GC Roots到对象不可达时),则证明此对象是不可用的。

那么问题又来了,如何选取 GCRoots 对象呢?在 Java 语言中,可以作为 GCRoots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的局部变量区,也叫局部变量表)中引用的对象;
  • 方法区中的类静态变量属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中 JNI(Native方法)引用的对象。
Tips:看了如上的原理与 GC Roots 选择的描述,感觉概念性问题比较抽象,难于理解,我们继续通过示例来进一步理解可达性分析法。
  1. 可达性分析法示例
    上文中提到了,可达性分析法是通过 GC Roots 为起点的搜索,有四种对象可以作为 GC Roots,那么我们通过如下示意图来理解下,何为不可达对象。

在这里插入图片描述

GC Roots 四种类型解释:从上图中,我们可以看到四种 GC Roots。这里我们对这四种 GC Roots 做一下更为细致的解释。

  • 虚拟机栈中的引用的对象:我们在程序中正常创建一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址作为引用保存到虚拟机栈中,如果对象生命周期结束了,那么引用就会从虚拟机栈中出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的;

  • 全局的静态的对象:也就是使用了 static 关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为 GC Roots 是必须的;

  • 常量引用:就是使用了 static final 关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为 GC Roots;

  • Native 方法引用对象:这一种是在使用 JNI 技术时,有时候单纯的 Java 代码并不能满足我们的需求,我们可能需要在 Java 中调用 C 或 C++ 的代码,因此会使用 native 方法,JVM 内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为 GC Roots。

从上图来理解可达性分析法就会非常简单,四种 GC Roots 无非是 Java 中的引用对象,从GC Roots 出发,类似于我们使用开发工具看代码,发现某部分代码用不到了,我们就会删除这部分代码。其实可达性分析法也是如此,发现某些对象不可达了,就会被垃圾回收器收集。

从上图中来看,对象 A,B,C,D,E,F 为可达对象;而对象 G,H,I,J,K 为不可达对象,会被标记为垃圾对象,最终被垃圾回收器回收。

  1. 小结
    本节讲解了垃圾回收回收器的定义以及垃圾回收器存在的意义,并在此基础上讲解了垃圾回收器是如何判定对象的可达性的。可达性分析法是本节的核心知识点,是必须要掌握的知识点。

在讲解 GC Roots 的知识点时,我们总是会使用 “引用对象” 这四个字,其中引用又分为 4 种引用,下节课程我们会详细的讲解。

JVM 四种引用

  1. 前言
    延续上节可达性分析法的讲解,本节主要讲解可达性分析法所使用的四种引用类型,本节主要内容如下:
  • 强引用的定义以及如何消除强引用,为本节重点内容之一;
  • 软引用的定义及使用场景,为本节重点内容之一;
  • 弱引用的定义及代码示例,验证任何情况下,只要发生 GC 就会回收弱引用对象,为本节重点内容之一;
  • 虚引用的定义以及作用,为本节重点内容之一;
  1. 可达性分析的四种引用类型
    上节课程内容讲解了可达性分析,可达性分析的 GC Roots 均为引用对象,那么引用对象有 4 种引用类型如下:
  • 强引用;
  • 软引用;
  • 弱引用;
  • 虚引用。
    本节课程内容与可达性分析相辅相成,学习者务必在学习完可达性分析内容后再学习本节内容。
  1. 强引用
    定义:强引用就是指在程序代码之中普遍存在的,类似Object obj = new Object()这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

代码示例:

public class DemoTest {
    public static void main(String[] args) {
        Object obj = new Object(); // 强引用
    }
}

在强引用的定义中有这样一句话:“只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。” 那么有没有办法将强引用消除呢?

消除强引用示例代码:

public class DemoTest {
    public static void main(String[] args) {
        Object obj = new Object(); // 强引用
        obj = null; //消除强引用
    }
}

如果不使用强引用时,可以赋值 obj=null,显示的设置 obj 为 null,则 gc 认为该对象不存在引用,这时候就可以回收此对象。

  1. 软引用
    定义:软引用用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,如果内存充足,则垃圾回收器不会回收该对象,如果内存不够了,就会回收这些对象的内存。

在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

软引用使用场景:Android 应用图片
软引用主要应用于内存敏感的高速缓存,在 Android 系统中经常使用到。一般情况下,Android 应用会用到大量的默认图片,这些图片很多地方会用到。如果每次都去读取图片,由于读取文件需要硬件操作,速度较慢,会导致性能较低。所以我们考虑将图片缓存起来,需要的时候直接从内存中读取。

但是,由于图片占用内存空间比较大,缓存很多图片需要很多的内存,就可能比较容易发生 OutOfMemory 异常。这时,我们可以考虑使用软引用技术来避免这个问题发生。

SoftReference 可以解决 OOM 的问题,每一个对象通过软引用进行实例化,这个对象就以cache的形式保存起来,当再次调用这个对象时,那么直接通过软引用中的 get() 方法,就可以得到对象中的资源数据,这样就没必要再次进行读取了,直接从 cache 中就可以读取得到,当内存将要发生 OOM 的时候,GC 会迅速把所有的软引用清除,防止 OOM 发生。

  1. 弱引用
    定义:弱引用描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java 中的类 WeakReference 表示弱引用。

代码示例:

import java.lang.ref.WeakReference;
 
public class Main {
    public static void main(String[] args) {    
        WeakReference<String> sr = new WeakReference<String>(new String("hello"));         
        System.out.println(sr.get());
        System.gc();                //通知JVM的gc进行垃圾回收
        System.out.println(sr.get());
    }
}

结果验证:第二个输出结果是 null,这说明只要 JVM 进行垃圾回收,被弱引用关联的对象必定会被回收掉。

hello
null
  1. 虚引用
    定义:”虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用在 Java 中使用 java.lang.ref.PhantomReference 类表示。

作用:虚引用主要用来跟踪对象被垃圾回收的活动。

虚引用与软引用和弱引用的区别:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

使用示例:虚引用必须和引用队列(ReferenceQueue)联合使用

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class Main {
    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<String>();
        PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);
        System.out.println(pr.get());
    }
}
  1. 小结
    在这里插入图片描述

本节主要讲解可达性分析的四种对象引用类型,通篇皆为重点内容,需要学习者理解并掌握这四种引用类型。

JVM 常见的垃圾回收算法

  1. 前言
    本节主要讲解垃圾回收算法,并对每一种算法进行讲解。垃圾回收算法在垃圾回收器中占据着十分重要的地位,对本节内容一定要格外重视。本节主要内容如下:
  • 标记-清除(Mark-Sweep)算法的原理及缺陷,为本节重点内容之一;
  • 复制(coping)算法的原理及缺陷,为本节重点内容之一;
  • 标记-整理(Mark-Compact)算法的原理及缺陷,为本节重点内容之一;
  • 分代收集理论的原理及思想,为本节重点内容之一;
    通篇皆为重点内容,务必要用心学习。
  1. 垃圾回收算法种类
    我们先来讨论一个问题,垃圾回收算法有几种?

如果单纯从一些博客或者论坛上的内容来说,部分作者会将垃圾回收分为如下 4 种算法:

  • 标记-清除(Mark-Sweep)算法;

  • 复制(coping)算法;

  • 标记-整理(Mark-Compact)算法;

  • 分代收集算法。
    但是这种分类是不准确的,准确来说,垃圾回收只有 3 种算法:

  • 标记-清除(Mark-Sweep)算法;

  • 复制(coping)算法;

  • 标记-整理(Mark-Compact)算法。
    为什么会有所谓的“分代收集算法”呢? 此处我们埋下一个伏笔,后文中我会在适当的地方给予解释。

  1. 标记-清除(Mark-Sweep)算法
    标记 – 清除(Mark-Sweep)算法是最基本的算法。

基本概念:标记-清除算法就如同它的名字一样,分为“标记”和“清除”两个阶段:

  • 首先标记出所有需要回收的对象,这就是标记阶段;

  • 标记完成后统一回收所有被标记的对象,这就是所谓的清除阶段。
    缺点:这种算法的不足主要体现在效率和空间。

  • 从效率的角度讲:标记和清除两个过程的的效率都不高;

  • 从空间的角度讲:标记清楚后会产生大量不连续的内存碎片,内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾回收动作。
    为了更加透彻的理解标记-清除(Mark-Sweep)算法,我们来看下如下示意图,通过直观的图形展示,彻底搞懂标记-清除(Mark-Sweep)算法。

在这里插入图片描述

  1. 复制(coping)算法

Tips:前文提到过,标记-清除(Mark-Sweep)算法从效率的角度讲,”标记”和”清除”两个过程的的效率都不高,为了提升效率,我们引出了复制(coping)算法。

基本概念:复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配的执行过程如下图所示:

在这里插入图片描述

缺点:不过这种算法有个缺点,内存缩小为原来的一半,这样代价太高了。

现在的商用模拟机都采用这种算法来回收新生代,不过研究表明1:1的比例非常不科学,因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。

每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。

HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。

  1. 标记-整理(Mark-Compact)算法
Tips:复制算法在对象存活率较高的场景下要进行大量的复制操作,效率还是很低。并且每次只使用一半的内存空间,资源浪费严重。标记-整理(Mark-Compact)算法解决了内存利用率的问题,并且减少了大量复制的问题。

根据老年代的特点,有人提出了另外标记-整理(Mark-Compact)算法,标记过程与标记-整理(Mark-Compact)算法一样,不过不是直接对可回收对象进行整理,而是让所有存活对象都向一端移动,然后清理掉边界以外的内存。标记-整理算法的工作过程如图:

在这里插入图片描述

  1. 分代清理
    问题:我们上文埋下了伏笔,分代清理到底是不是第四种算法呢?

解答:不是,我们通常称之为分代收集理论,或称之为分代收集思想。目前虚拟机基本都采用分代收集理论来进行垃圾回收。

分代收集理论结合了以上的 3 种算法,根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。准确的说,分代收集理论就是在不同的内存区域使用不同的算法,它是 以上 3 种算法的使用者。

因此说,分代清理并非是一种单独的算法,而是一种收集理论。
在这里插入图片描述

  1. 小结
    本节内容主要讲解了垃圾回收的 3 种算法和分代收集理论。通篇皆为重点掌握内容,是垃圾回收的核心知识点。学习者可以结合给出的示意图进行理解,这样能够更好地掌握本节所讲的内容。

JVM 垃圾回收器分类

  1. 前言
    本节主要讲解 7 种垃圾回收器,其中有 3 种垃圾回收器是作用于年轻代垃圾回收的收集器;另外 3 种圾回收器是作用于老年代垃圾回收的收集器;剩余的 1 种垃圾回收器能够同时作用于年轻代和老年代。

7 种垃圾回收器以及其作用的内存区域如下图所示:
在这里插入图片描述

通篇皆为重点知识,请认真学习并理解每种垃圾回收器的特点。

  1. Serial收集器
    基本概念:Serial收集器是最基本、发展历史最久的收集器,这个收集器是采用复制算法的单线程的收集器。

Tips:从概念上来看,我们需要注意Serial收集器的两个特点:一个是采用复制算法,另外一个是单线程收集。

单线程的收集器:单线程一方面意味着他只会使用一个 CPU 或者一条线程去完成垃圾收集工作,另一方面也意味着他进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。

不过实际上到目前为止,Serial收集器依然是虚拟机运行在 Client 模式下的默认新生代收集器,因为它简单而高效。Serial 收集器运行过程如下图所示:

在这里插入图片描述

  1. Parnew收集器
    基本概念:Parnew 收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为和 Serial 收集器完全一样,但是他却是 Server 模式下的虚拟机首选的新生代收集器。

Tips:从概念上来看,我们需要注意Parnew收集器的两个特点:一个是采用复制算法,另外一个是多线程收集。

特点:

  • 除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。CMS 收集器第一次实现了让垃圾收集器与用户线程基本上同时工作;
  • Parnew 收集器默认开启的收集线程数与 CPU 数量相同,在 CPU 数量非常多的情况下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。
    Parnew 收集器运行过程如图所示:

在这里插入图片描述

  1. Parallel Scavenge收集器
    基本概念:Parallel Scavenge 收集器也是一个新生代收集器,也采用了复制算法,也是并行的多线程收集器。Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量。Parallel Scavenge 收集器是虚拟机运行在 Server 模式下的默认垃圾收集器。被称为“吞吐量优先收集器”。

Tips:从概念上来看,我们需要注意Parallel
Scavenge收集器的三个个特点:一个是采用复制算法,一个是多线程收集,一个是达到控制吞吐量的目标。

Parallel Scavenge 收集器运行过程同 Parnew 收集器一样:

在这里插入图片描述

控制吞吐量:CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量。所谓吞吐量就是 CPU 用于运行用户代码时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。

空间吞吐量参数介绍:虚拟机提供了-XX:MaxGCPauseMills 和 -XX:GCTimeRatio 两个参数来精确控制最大垃圾收集停顿时间和吞吐量大小。不过不要以为前者越小越好,GC 停顿时间的缩短是以牺牲吞吐量和新生代空间换取的。由于与吞吐量关系密切,Parallel Scavenge 收集器也被称为“吞吐量优先收集器”。

Parallel Scavenge 收集器有一个参数 -XX:UseAdaptiveSizePolicy 参数,这是一个开关参数,这个参数打开之后,就不需要手动指定新生代大小、Eden 区和 Survivor 参数等细节参数了,虚拟机会根据当前系统的运行情况以及性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

如果对于垃圾收集器运作原理不太了解,以至于在优化比较困难的时候,可以使用 Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成。

  1. Serial Old收集器
    基本概念: Serial Old 收集器同样是一个单线程收集器,作用于老年代,使用“标记-整理算法”,这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

Serial Old 收集器运行过程如图所示:
在这里插入图片描述

  1. Parallel Old收集器
    基本概念: Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理算法”进行垃圾回收。

这个收集器在 JDK 1.6 之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge收集器+Parallel Old收集器 的组合。

Parallel Scavenge 收集器+Parallel Old 收集器 的组合运行过程如下图所示:

在这里插入图片描述

  1. CMS收集器
    基本概念:CMS(Conrrurent Mark Sweep,连续标记扫描)收集器是以获取最短回收停顿时间为目标的收集器。使用标记-清除算法。

收集步骤:收集过程分为如下四步:

  • 初始标记:标记 GCRoots 能直接关联到的对象,时间很短;
  • 并发标记:进行 GCRoots Tracing(可达性分析)过程,时间很长;
  • 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长;
    并发清除:回收内存空间,时间很长。其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。
    CMS 收集器运行过程如下图所示:

在这里插入图片描述

  1. G1收集器
    基本概念:G1 是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉 JDK1.5 中发布的 CMS 收集器。

与其他GC收集器相比,G1收集器具有以下特点:

  • 并发和并行:使用多个 CPU 来缩短 Stop The World 停顿时间,与用户线程并发执行;
  • 分代收集:独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次 GC 的旧对象,以获取更好的收集效果;
  • 空间整合:基于标记-整理算法,无内存碎片产生;
  • 可预测的停顿:能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒。
    在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region 的集合。
  1. 小结
    本节主要讲解了 7 种垃圾收集器:Serial 收集器,Parnew 收集器,Parallel Scavenge 收集器,Serial Old 收集器,Parallel Old 收集器,CMS 收集器和G1 收集器。

其中专门针对年轻代的收集器有 Serial 收集器,Parnew 收集器和 Parallel Scavenge 收集器;专门作用于老年代的收集器有Serial Old 收集器,Parallel Old 收集器和 CMS 收集器;而 G1 收集器即能够作用于年轻代,也能够作用于老年代。

学习者需要对这 7 种垃圾回收器进行理解,并掌握其作用的内存区域。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/101239.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!