JVM 运行流程、类加载、垃圾回收

一、JVM 简介

1、JVM

JVM 是 Java Virtual Machine 的简称,意为 Java 虚拟机。

虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。

常见的虚拟机:JVM、VMwave、Virtual Box。

JVM 和其他两个虚拟机的区别:

  1. VMwave 与 VirtualBox 是通过软件模拟物理 CPU 的指令集,物理系统中会有很多的寄存器;
  2. JVM 则是通过软件模拟 Java 字节码的指令集,JVM 中只是主要保留了 PC 寄存器,其他的寄存器都进行了裁剪。

JVM 是一台被定制过的现实当中不存在的计算机

每个 jvm 就是一个 java 进程。如果有两个 java 进程,就是两个 jvm 了!!!

为什么要在程序和操作系统中间添加一个 JVM:Java 是一门抽象程度特别高的语言,提供了自动内存管理等一系列的特性。这些特性直接在操作系统上实现是不太可能的,所以就需要 JVM 进行一番转换。

从图中可以看到,有了 JVM 这个抽象层之后,Java 就可以实现跨平台了。JVM 只需要保证能够正确执行 .class 文件,就可以运行在诸如 Linux、Windows、MacOS等平台上了。

Java如何实现跨平台:windows系统,实现了一个windows 版本的JVM,Linux系统,实现了Linux版本的JVM,Mac系统实现了Mac 版本的JVM

随便某个系统,也实现了对应版本的 JVM,跨平台是靠无数个不同版本的 JVM 支持的!! 这些不同的JVM内部封装了,不同系统的API,对上都是执行同样规则的字节码

在这里插入图片描述

C, C++,Go, Rust 都是把代码编译成 native code,也就是 cpu 能识别的机器指令,不需要虚拟机。——针对不同系统 / cpu 生成的机器指令是不一样 (编译出来的可执行程序不一样)

Java, Python, PHP, 为了跨平台,都是统一翻译成指定的字节码,然后由对应的虚拟机转换成机器指令。——字节码都是一样的,tomcat windows 上的直接拷贝到 Linux 就能运行,一个 windows 上编译的 c++ 程序,拷贝到 Linux 就不行了(也有特殊手段如 wine)


2、JVM 发展史

2.1、Sun Classic VM

早在 1996 年 Java1.0 版本的时候,Sun 公司发不了一款名为 Sun Classic vm 的 java 虚拟机,它同时也是世界上第一款商业 java 虚拟机,jdk1.4 时完全被淘汰。

这款虚拟机内部只提供解释器。

如果使用 JIT 编译器,就需要进行外挂。但是一旦使用了 JIT 编译器,JIT 就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作。

现在 Hotspot 内置了此虚拟机;

2.2、Exact VM

为了解决上一个虚拟机问题,jdk1.2 时,sun 提供了此虚拟机。

Exact 具备现代高性能虚拟机的雏形,包含了一下功能:

  1. 热点探测(将热点代码编译为字节码加速程序执行);

  2. 编译器与解析器混合工作模式。

只在 Solaris 平台短暂使用,其他平台上还是 classic vm 。英雄气短,终被 Hotspot 虚拟机替换。

2.3、HotSpot VM

HotSpot 历史

  1. 最初由一家名为“Longview Technologies”的小公司设计;

  2. 1997年,此公司被Sun收购;2009年,Sun公司被甲骨文收购。

  3. JDK1.3时,HotSpot VM成为默认虚拟机

目前 HotSpot 占用绝对的市场地位,称霸武林。

不管是现在仍在广泛使用 JDK6,还是使用比较多的 JDK8 中,默认的虚拟机都是 HotSpot;
最主流使用的 JVM,Oracle 官方 jdk 和开源的 openjdk 都是使用这个 JVM。从服务器、桌面到移动端、嵌入式都有应用。

名称中的 HotSpot 指的就是它的热点代码探测技术。它能通过计数器找到最具编译价值的代码,触发即 时编译 (JIT) 或栈上替换;通过编译器与解释器协同工作,在最优化的程序响应时间与最佳执行性能中取 得平衡

2.4、JRockit

JRockit 是专注于服务器端应用,目前在HotSpot的基础上,移植JRockit的优秀特性。

它可以不太关注程序的启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后 执行;

大量的行业基准测试显示,JRockit JVM是世界上最快的JVM。

使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70%)和硬件成本的减少(达 50%);

优势:全面的Java运行时解决方案组合。

JRockit面向延迟敏感型应用的解决方案JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财 务、军事指挥、电信网络的需要;

MissionControl服务套件,它是一组以极低的开销来监控、管理和分析生产环境中的应用程序的工具; 2008,BEA被Oracle收购。

Oracle表达了整合两大优秀虚拟机的工作,大致在JDK8中完成。整合的方式是在HotSpot的基础上,移 植JRockit的优秀特性。

2.5、J9 JVM

全称:IBM Technology for Java Virtual Machine,简称IT4J,内部代号:J9。

市场定位于 HotSpot 接近,服务器端、桌面应用、嵌入式等多用途 JVM,广泛用于 IBM 的各种 Java 产品。

目前,有影响力的三大商用虚拟机之一,也号称是世界上最快的Java虚拟机(在IBM自己的产品上稳定);

2017年左右,IBM发布了开源 J9 VM,命名 OpenJ9,交给Eclipse基金会管理,也称为Eclipse OpenJ9。

2.6、Taobao JVM(国产研发)

由 AliJVM 团队发布。阿里,国内使用Java最强大的公司,覆盖云计算、金融、物流、电商等众多领域,
需要解决高并发、高可用、分布式的复合问题。有大量的开源产品。

基于OpenJDK 开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里JAVA体系的基石;

基于OpenJDK HotSpot JVM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机,它
具有以下特点(了解即可):

  1. 创新的GCIH(GC invisible heap)技术实现了off-heap,即将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收评率和提升GC的回收效率的目的。

  2. GCIH中的对象还能够在多个Java虚拟机进程中实现共享。

  3. 使用crc32指令实现JVM intrinsic降低JNI的调用开销;

  4. PMU hardware的Java profiling tool和诊断协助功能;

  5. 针对大数据场景的ZenGC。

taobao JVM应用在阿里产品上性能高,硬件严重依赖intel的cpu,损失了兼容性,但提高了性能,目前已经在淘宝、天猫上线,把Oracle官方JVM版本全部替换了


3、 JVM 和《Java虚拟机规范》

以上的各种 JVM 版本,比如 HotSpot 和 J9 JVM,都可以看做是不同厂商实现 JVM 产品的具体实现,而它们(JVM)产品的实现必须要符合《Java虚拟机规范》,《Java虚拟机规范》是 Oracle 发布 Java 领域最重要和最权威的著作,它完整且详细的描述了 JVM 的各个组成部分

PS: 本文以下部分,默认都是使用 HotSpot,也就是 Oracle Java 默认的虚拟机为前提来进行介绍的


二、JVM 运行流程

JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键,那么 JVM 是如何执行的呢 ?

1、JVM 执行流程

程序在执行之前先要把 java 代码转换成字节码(class文件),JVM 首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中 运行时数据区(Runtime Data Area) ,而字节码文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由 CPU 去执行,而这个过程中需要调用其他语言的接口 本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。

在这里插入图片描述

总结来看, JVM 主要通过分为以下 4 个部分,来执行 Java 程序的,它们分别是:

  1. 类加载器(ClassLoader)

  2. 运行时数据区(Runtime Data Area)

  3. 执行引擎(Execution Engine)

  4. 本地库接口(Native Interface)


2、JVM 运行时数据区

2.1、内存区域划分

毛坯房装修时,需要把房子划分成多个房间,每个房间都有不同的作用,如客厅、卧室,厨房等。

内存区域划分也是一样,JVM 也就是启动的时候,会申请到一整个很大的内存区域
JVM 是一个应用程序,要从操作系统这里申请内存 (相当于租了个写字楼)
JVM 就要根据需要,把整个空间,分成几个部分,每个部分各自有不同的功能作用

JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念,它由以下 5 大部分组成:

在这里插入图片描述


① 本地方法栈

本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的

native 就表示是 JVM 内部的 C++ 代码,就是给调用 native 方法 (JVM 内部的方法) 准备的栈空间


② Java 虚拟机栈(线程私有)

此处所说的栈,和数据结构的栈,不是一个东西 !!

数据结构的栈,是一个通用的,更广泛的概念。此处所谈到的栈,是 JVM 中的一个特定空间。

对于JVM虚拟机栈,这里存储的是方法之间的调用关系

  • 整个栈空间内部,可以认为是包含很多个元素 (每个元素表示一个方法)
    把这里的每个元素,称为是一个 “栈帧”
    这一个栈帧里,会包含这个方法的入口地址,方法的参数是啥,返回地址是啥,局部变量…

对于本地方法栈,存储的是 native 方法之间的调用关系

Java 虚拟机栈的作用: Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的

内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。我们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。

栈帧 包含了以下 4 部分:

在这里插入图片描述

  1. 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。

  2. 操作栈: 每个方法会生成一个先进后出的操作栈。

  3. 动态链接: 指向运行时常量池的方法引用。

  4. 方法返回地址: PC 寄存器的地址。


什么是线程私有?

由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器 (多核处理器则指的是一个内核) 都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为 “线程私有” 的内存。

在这里插入图片描述

这里的这个栈,其实不是只有一个,有很多个!! 每个线程有一个,
jconsole 查看 java 进程内部的情况,就可以看到所有的线程
点击线程就能看到该线程的调用栈的情况(查看了该线程的栈里的信息)

在这里插入图片描述

栈是线程私有的 [非常常见],但是严格的说并不准确
私有,意思是我的你用不了。
实际上,一个线程栈上的内容,可以被另一个线程使用到的


③ 程序计数器(线程私有)

程序计数器的作用: 用来记录当前线程执行的行号的。

**程序计数器是一块比较小的内存空间,和 Java 虚拟机栈一样是每个线程都有一份的,**可以看做是当前线程所执行的字节码的行号指示器。

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

程序计数器内存区域是唯一一个在 JVM 规范中没有规定任何 OOM 情况的区域!


④ 堆(线程共享)

堆的作用: 程序中创建的所有对象都在保存在堆中,类的成员变量也就是在堆上

堆 是整个JVM空间最大的区域,堆是一个进程只有一份的!!
栈 是每个线程有一份,一个进程有 N 个!!
堆——多个线程用的都是同一个堆。栈——每个线程用自己的栈

我们常见的 JVM 参数设置 -Xms10m 最小启动内存是针对堆的,-Xmx10m 最大运行内存也是针对堆的。
ms 是 memory start 简称,mx 是 memory max 的简称

堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC 次数之后还存活的对象会放入老生代。新生代还有 3 个区域:一个 Endn + 两个 Survivor(S0/S1)

在这里插入图片描述

垃圾回收的时候会将 Endn 中存活的对象放到一个未使用的 Survivor 中,并把当前的 Endn 和正在使用
的 Survivor 清除掉


⑤ 方法区/元数据区(线程共享)

在这里插入图片描述

元 Mate ,保存类对象,常量池,静态对象

一个进程有一块,多个线程共用

方法区的作用: 用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。

改名:在《Java虚拟机规范中》把此区域称之为 “方法区”,而在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域叫做永久代(PermGen),从 java 8 开始叫做元空间(Metaspace)。

PS:永久代(PermGen)和元空间(Metaspace)是 HotSpot 中对《Java虚拟机规范》中方法区的实现,它们三者之间的关系就好比,对于一辆汽车来说它定义了一个部分叫做 “动能提供装置”,但对于不同的汽车有不同的实现技术,比如对于燃油车来说,它的“动能提供装置”的实现技术就是汽油发动机(简称发动机),而对于电动汽车来说,它的“动能提供装置”的实现就是电动发动机(简称电机),发动机和电机就相当于永久代和元空间一样,它是对于 “制动器” 也就是方法区定义的实现。

JDK 1.8 元空间的变化:

  1. 对于 HotSpot 来说,JDK 8 元空间的内存属于本地内存,这样元空间的大小就不在受 JVM 最大内存的参数影响了,而是与本地内存的大小有关。
  2. JDK 8 中将字符串常量池移动到了堆中。

运行时常量池:

  • 运行时常量池是方法区的一部分,存放字面量与符号引用。

    • 字面量: 字符串(JDK 8 移动到堆中) 、final 常量、基本数据类型的值。

    • 符号引用: 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。


3、总结

在这里插入图片描述

最主要的考点,给一段代码,问某个变量是在哪个区域上?? 原则:

  1. 局部变量在栈
  2. 普通成员变量在堆
  3. 静态成员变量在方法区 / 元数据区

4、内存布局中的异常问题

① Java 堆溢出

Java 堆用于存储对象实例,只要不断的创建对象,并且保证 GC Roots 到对象之间有可达路径来避免来 GC 清除这些对象,那么在对象数量达到最大堆容量后就会产生内存溢出异常。
可以设置 JVM 参数 -Xms: 设置堆的最小值、-Xmx: 设置堆最大值。

Java 堆内存的 OOM 异常是实际应用中最常见的内存溢出情况。当出现 Java 堆内存溢出时,异常堆信息 “java.lang.OutOfMemoryError” 会进一步提示 “Java heap space”。当出现 “Java heap space” 则很明确的告知我们,OOM发生在堆上。

此时要对 Dump 出来的文件进行分析,以 MAT 为例。分析问题的产生到底是出现了内存泄漏 (Memory Leak) 还是内存溢出 (Memory Overflow)

内存泄漏: 泄漏对象无法被 GC
内存溢出: 内存对象确实还应该存活。此时要根据 JVM 堆参数与物理内存相比较检查是否还应该把 JVM堆内存调大;或者检查对象的生命周期是否过长。


② 虚拟机栈和本地方法栈溢出

由于我们HotSpot虚拟机将虚拟机栈与本地方法栈合二为一,因此对于HotSpot来说,栈容量只需要由-Xss参数来设置。

关于虚拟机栈会产生的两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,会抛出StackOverFlow异常

  • 如果虚拟机在拓展栈时无法申请到足够的内存空间,则会抛出OOM异常

范例:观察StackOverFlow异常(单线程环境下) 、

/**
* JVM参数为:-Xss128k
* @author 38134
*
*/
public class Test {
    
    
	private int stackLength = 1;
	public void stackLeak() {
    
    
		stackLength++;
		stackLeak();
	}

    public static void main(String[] args) {
    
    
		Test test = new Test();
		try {
    
    
			test.stackLeak();
		} catch (Throwable e) {
    
    
			System.out.println("Stack Length: "+test.stackLength);
			throw e;
		}
	}
}

出现StackOverflowError异常时有错误堆栈可以阅读,比较好找到问题所在。如果使用虚拟机默认参数,栈深度在多多数情况下达到1000-2000完全没问题,对于正常的方法调用(包括递归),完全够用。
如果是因为多线程导致的内存溢出问题,在不能减少线程数的情况下,只能减少最大堆和减少栈容量的方式来换取更多线程。
范例:观察多线程下的内存溢出异常

/**
 * JVM参数为:-Xss2M
 * @author 38134
 *
 */
public class Test {
    
    
    private void dontStop() {
    
    
        while(true) {
    
    
        }
    }
    public void stackLeakByThread() {
    
    
        while(true) {
    
    
            Thread thread = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    dontStop();
                }
            });
            thread.start();
        }
    }
    public static void main(String[] args) {
    
    
        Test test = new Test();
        test.stackLeakByThread();
    }
}

以上代码运行需谨慎。先记得保存手头所有工作


三、JVM 类加载

1、类加载过程

从上面的图片我们可以看出整个 JVM 执行的流程中,和程序员关系最密切的就是类加载的过程了,所以接下来我们来看下类加载的执行流程。
对于一个类来说,它的生命周期是这样的:

在这里插入图片描述

其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来
说总共分为以下几个步骤:

  1. 加载

  2. 连接

    1. 验证
    2. 准备
    3. 解析
  3. 初始化


① 加载

加载:把 .class文件找到 (找的过程),打开文件,读文件,把文件内容读到内存中

“加载”(Loading)阶段是整个 “类加载”(Class Loading)过程中的一个阶段,它和类加载 Class Loading 是不同的,一个是加载 Loading,另一个是类加载 Class Loading,所以不要把二者搞混了。

在加载 Loading 阶段,Java虚拟机需要完成以下三件事情:

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

② 验证

验证是连接阶段的第一步,检查 .class 文件格式是否正确,这一阶段的目的是确保 class 文件的字节流中包含的信息符合官方提供的《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

最终加载完成是要得到类对象

Java Language and Virtual Machine Specifications

在这里插入图片描述

java 代码中写的类的所有信息都会包含在这里,只不过是使用二进制的方式重新组织了

在这里插入图片描述

验证选项:
文件格式验证,字节码验证,符号引用验证…


③ 准备

准备阶段是正式给类对象中定义的变量(即静态变量,被 static 修饰的变量)分配内存 (先在元数据区占个位置),并设置类变量初始值的阶段,内存初始化为全0 (会使静态成员被设置成 0 值)。

比如此时有这样一行代码:

public static int value = 123;

它是初始化 value 的 int 值为 0,而非 123。


④ 解析

解析阶段是 Java 虚拟机初始化字符串常量, 将常量池内的 符号引用 替换为 直接引用 的过程

如何理解 符号引用 替换为 直接引用:

  • 字符串常量,得有一块内存空间,存这个字符的实际内容,还得有一个引用,来保存这个内存空间的起始地址
  • 在类加载之前,字符串常量 此时是处在 .class 文件中的。此时这个 “引用” 记录的并非是字符串常量的真正的地址,而是它在文件中的 “偏移量” 这个东西 (或者是个占位符) ——符号引用
  • 类加载之后,才真正把这个字符串常量给放到内存中,此时才有 "内存地址”,这个引用才能被真正赋值成指定内存地址 ——直接引用

例:组织去电影院看电影,我知道前面是 A,后面是 B,只知道自己的相对位置,不知道具体的位置 => 符号引用

等到了电影院,组织坐下了以后,才知道自己的真实为孩子 => 符号引用


⑤ 初始化

调用构造方法,进行成员初始化,执行代码块,执行静态代码块,加载父类…

真正针对类对象里的内容进行初始化,Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程


2、类加载的时机

一个类,啥时候会被加载呢?
不是 java 程序一运行,就把所有的类都加载了,而是真正用到才加载 (懒汉模式)

  1. 构造类的实例

  2. 调用这个类的静态方法 / 使用静态属性,因为需要先有类对象

  3. 加载子类,就要先加载其父类

用到了,才加载。一旦加载过之后,后续的话再使用就不必重复加载了


3、、双亲委派模型

类加载器(Class Loader):负责将 JVM 中的字节码转化为能够被 JVM 执行的类,在 JVM 运行时,会根据需要动态创建类,启动类加载器会首先加载程序中的核心类库,然后通过双亲委派模型逐层向下加载依赖类。类加载器可以分为三种:启动类加载器、扩展类加载器和应用程序类加载器。

在这里插入图片描述

3.1、类加载器类型

站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader

站在 Java 开发人员的角度来看,类加载器就应当划分得更细致一 些。自 JDK 1.2 以来,Java 一直保持着三层类加载器、双亲委派的类加载架构器。

JVM 默认提供的三个类加载器:

  • BootstrapClassLoader 启动类加载器:负责加载标准库中的类 (java 规范,要求提供哪些类,无论是哪种 JVM 的实现,都会提供这些一样的类,加载 JDK 中 lib 目录中 Java 的核心类库,即 $JAVA_HOME/lib 目录。
  • ExtensionClassLoader 扩展类加载器:负责加载 JVM 扩展库中的类 (规范之外,由实现 JVM 的厂商 / 组织,提供的额外的功能),加载 lib/ext 目录下的类。
  • ApplicationClassLoader 应用程序类加载器:负责加载用户提供的第三方库 / 用户项目代码 中的类

上述三个类,存在 “父子关系”
(不是父类子类,相当于每个 class loader 有一个 parent 属性指向自己的父 类加载器)

再另一方面,类加载器,其实是可以用户自定义的,上述三个类加载器是 jvm 自带的
用户自定义的类加载器,也可以加入到上述流程中,就可以和现有的加载配合使用了
User Defined ClassLoader 自定义类加载器:根据自己的需求定制类加载器。

在这里插入图片描述


3. 2、双亲委派模型

"双亲” 委派模型,其实是翻译的很尴尬 (机翻)
叫做 “单亲委派模型” 更合适,或者叫做 “父亲委派模型”
parent 双亲之一

加载:把 .class文件找到 (找的过程),打开文件,读文件,把文件内容读到内存中

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

上述类加载器如何配合工作:

  • 首先加载一个类的时候,是先从 ApplicationClassLoader 开始

  • 但是 ApplicationClassLoader 会把加载任务,交给父亲,让父亲去进行

  • 于是 ExtensionClassLoader 要去加载了… 但是也不是真加载,而是再委托给自己的父亲

  • BootstrapClassLoader 要去加载了,也是想委托给自己的父亲,结果发现自己的父亲是 null。
    没有父亲 / 父亲加载完了,没找着类,才由自己进行加载
    此时 BootstrapClassLoader 就会搜索自己负责的标准库目录的相关的类,如果找到,就加载,如果没找到,就继续由子类加载器进行加载

  • ExtensionClassLoader 真正搜索扩展库相关的目录,如果找到就加载,如果没找到,就由子类加载器进行加载

  • ApplicationClassLoader 真正搜索用户项目相关的目录,如果找到就加载,没找到,由子类加载器进行加载 (由于当前没有子 了,就只能抛出 ClassNotFoundException) 这样的异常)


3. 2、双亲委派模型的优点

为什么要有上述顺序?
上述这套顺序其实是出自于 jvm 实现代码的逻辑,这段代码大概是类似于 “递归" 的方式写的

其实从最上面这里直接开始,也不是不行
但是 JVM 代码当前是按照这种类似于递归的方式来实现的,就导致了从下到上,又从上到下过程

这个顺序,最主要的目的,就是为了保证 Bootstrap 能够先加载,Application 能够后加载,这就可以避免说因为用户创建了一些奇怪的类,引起不必要的 bug

优点:

  1. 避免重复加载类: 比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。

  2. 安全性: 使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话,就会出现一些问题,

    假设用户在自己的代码中,写了个 java.lang.String 这个类,
    按照上述加载流程,此时 JVM 加载的还是标准库的类,不会加载到用户自己写的这个类
    这样就能保证,即使出现上述问题,也不会让 JVM 已有代码混乱,最多是用户自己写的类不生效罢了


4、破坏双亲委派模型

自己写的类加载器,可以去遵守,也可以不遵。是否遵守,主要是看需求
如 Tomcat,去加载 webapp,这里就是单独的类加载器,不遵守双亲委派模型,而是从指定目录加载

双亲委派模型虽然有其优点,但在某些情况下也存在一定的问题,比如 Java 中 SPI(Service ProviderInterface,服务提供接口)机制中的 JDBC 实现。

小知识:SPI 全称 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的接口,它可以用来启用框架扩展和替换组件。 SPI 的作用就是为这些被扩展的 API 寻找服务实现。

JDBC 的 Driver 接口定义在 JDK 中,其实现由各个数据库的服务商来提供,比如 MySQL 驱动包。我们
先来看下 JDBC 的核心使用代码:

public class JdbcTest {
    
    
    public static void main(String[] args){
    
    
        Connection connection = null;
        try {
    
    
            connection =
                    DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test", "root",
                            "awakeyo");
        } catch (SQLException e) {
    
    
            e.printStackTrace();
        }
        System.out.println(connection.getClass().getClassLoader());
        System.out.println(Thread.currentThread().getContextClassLoader());
        System.out.println(Connection.class.getClassLoader());
    }
}

然后我们进入 DriverManager 的源码类就会发现它是存在系统的 rt.jar 中的,如下图所示:

在这里插入图片描述

由双亲委派模型的加载流程可知 rt.jar 是有顶级父类 Bootstrap ClassLoader 加载的,如下图所示:

在这里插入图片描述

而当我们进入它的 getConnection 源码是却发现,它在调用具体的类实现时,使用的是子类加载器(线
程上下文加载器 Thread.currentThread().getContextClassLoader )来加载具体的数据库数据库包
(如 mysql 的 jar 包),源码如下:

@CallerSensitive
public static Connection getConnection(String url, 
                                       java.util.Properties info) throws SQLException {
    
    
    return (getConnection(url, info, Reflection.getCallerClass()));
}
private static Connection getConnection(String url, java.util.Properties info, 
                                        Class<?> caller) throws SQLException {
    
    
    ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
    synchronized(DriverManager.class) {
    
    
        // synchronize loading of the correct classloader.
        if (callerCL == null) {
    
    
            //获取线程上下为类加载器
            callerCL = Thread.currentThread().getContextClassLoader();
        }
    }
    if(url == null) {
    
    
        throw new SQLException("The url cannot be null", "08001");
    }
    println("DriverManager.getConnection(\"" + url + "\")");
    SQLException reason = null;

    for(DriverInfo aDriver : registeredDrivers) {
    
    
        // isDriverAllowed 对于 mysql 连接 jar 进行加载
        if(isDriverAllowed(aDriver.driver, callerCL)) {
    
    
            try {
    
    
                println(" trying " + aDriver.driver.getClass().getName());
                Connection con = aDriver.driver.connect(url, info);
                if (con != null) {
    
    
                    // Success!
                    println("getConnection returning " + aDriver.driver.getClass().getName());
                    return (con);
                }
            } catch (SQLException ex) {
    
    
                if (reason == null) {
    
    
                    reason = ex;
                }
            }
        } else {
    
    
            println(" skipping: " + aDriver.getClass().getName());
        }
    }
    if (reason != null) {
    
    
        println("getConnection failed: " + reason);
        throw reason;
    }

    println("getConnection: no suitable driver found for "+ url);
    throw new SQLException("No suitable driver found for "+ url, "08001");
}

这样一来就破坏了双亲委派模型,因为 DriverManager 位于 rt.jar 包,由 BootStrap 类加载器加载,而其 Driver 接口的实现类是位于服务商提供的 Jar 包中,是由子类加载器(线程上下文加载器Thread.currentThread().getContextClassLoader )来加载的,这样就破坏了双亲委派模型了(双亲委派模型讲的是所有类都应该交给父类来加载,但 JDBC 显然并不能这样实现)。它的交互流程图如下所示:

在这里插入图片描述


四、垃圾回收

1、GC (Garbage Collection)

垃圾,指的就是不再使用的内存
垃圾回收,就是把不用的内存帮我们自动释放了

C 语言中有 malloc,C++ 有 new,这些都是动态内存申请 (在堆上申请一块内存空间)
上述内存空间需要手动方式进行释放 free, delete

C / C++ 的情况
如果不手动释放,这块内存的空间就会持续存在,一直存在到进程结束 (堆上的内存生命周期比较长,不像栈,栈的空间会随着方法执行结束,栈帧销毁而自动释放。堆,则默认不能自动释放)

可能导致一个严重的问题:内存泄露
如果内存一直占着不用,又不释放,就会导致剩余空间越来越少… 进一步导致后续的内存申请操作失败 !!
尤其是服务器,特别害怕这个,因为是 7 * 24 h 运行的。如果是客户端程序,如打开 QQ,使用完就关了,进程退出,所有内存全部释放

GC 是解决内存泄漏其中最最主流的一种方式
Java Go Python PHP JS 大部分的主流语言都是使用 GC 来解决上述问题的

C++ 为什么没有 GC?因为 GC 有好处,也有坏处

  • GC好处:非常省心,让程序猿写代码简单点,不容易出错
    GC坏处:需要消耗额外的系统资源,也有额外的性能开销
    这就不符合 C++ 的初心的,追究性能到极致
  • 另外 GC 这里还有一个比较关键的问题,STW 问题,stop the world
    如果有时候,内存中的垃圾已经很多了,此时触发一次 GC 操作,开销可能非常大,大到可能就把系统资源吃了很多
    另一方面 GC 回收垃圾的时候可能会涉及到一些锁操作,导致业务代码无法正常执行这样的卡顿,极端情况下,可能是出现几十毫秒甚至上百毫秒

新版 Java (Java 13 开始) 引入 zgc 这个垃圾回收器,已经是设计的非常精致,可以使 STW 能控制在 1ms 以下了


JVM 有很多区域:堆,栈,程序计数器,元数据区…

栈上的空间是一块一块的栈帧,随方法的调用申请,方法结束,自动销毁。程序计数器,每个线程都有一份的,线程结束时,内存就自然跟着线程回收了。元数据区放的是类对象,一般只考虑类的加载,不会涉及到类的卸载。

因此 GC 主要针对 进行释放

GC 是以 “对象" 为基本单位,进行回收的 (而不是字节)

在这里插入图片描述

GC 回收的是,整个对象都不再使用的情况
而一部分使用,一部分不使用的对象,暂且先不回收 (一个对象,里面有很多属性,可能其中 10 个属性后面要用,10 个属性后面再也不用了)
要回收,就是回收整个对象,而不会 “回收半个对象”

GC 实际工作过程:

  1. 找到垃圾/判定垃圾。Java 堆 中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还存活,哪些已经"死去"。

    • 找到垃圾 / 判定垃圾
      关键思路,抓住这个对象,看这个对象到底有没有 "引用” 指向它
      Java 中使用对象,只有这一条路,通过 引用 来使用!! 如果一个对象有引用指向它,就可能被使用到
      如果一个对象,没有引用指向了,就不会再被使用了
  2. 再进行对象的释放


2、死亡对象的判断算法

内存 VS 对象

在 Java 中,所有的对象都是要存在内存中的(也可以说内存中存储的是一个个对象),因此我们将内存回收,也可以叫做死亡对象的回收。

如何知道 对象 是否有 引用 指向,两种典型实现:

  1. 引用计数算法 [不是 java 的做法. python / php]
  2. 可达性分析 [ java 的做法]

注意面试审题!
问题是:谈谈垃圾回收中的如何判定对象是垃圾,此时可以两个都说
问题是:谈谈 java 的垃圾回收中如何判定对象是垃圾,这个时候你再说引用计数就不合适了!


2.1、引用计数算法

引用计数描述的算法为:
每个对象分配一个引用计数器 (整数),每当创建一个引用指向该对象,计数器就 +1;当引用被销毁时,计数器就 -1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。

{
    
    
    Test t = new Test(); // Test 对象的引用计数 1
	Test t2 = t; // t2 也指向 t 了,引用计数为 2
	Test t3 = t; // 引用计数为 3
} // 大括号结束,上述三个引用超出作用域,失效,此时引用计数就是 0 了,此时 new Test() 对象就是垃圾了

引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采用引用计数法进行内存管理。

但是,在主流的 JVM 中没有选用引用计数法来管理内存,原因:

  1. 内存空间浪费的多 (利用率低 )
    • 每个对象都要分配一个计数器,如果按 4 个字节算的
      代码中的对象非常少,无所谓,如果对象特别多了,占用的额外空间就会很多,尤其是每个对象都比较小的情况
      一个对象体积 1k,此时,多 4 个自己,无所谓
      一个对象体积是 4 字节,此时多 4 个字节,相当于体积扩大一倍
  2. 无法解决对象的循环引用问题 (最主要的原因)

范例:

class Test {
    
    
	Test t = null;}
}

Test a = new Test(); // 1 号对象, 引用计数是 1
Test b = new Test(); // 2 号对象, 引用计数也是 1

a.t = b // a.t也指向2号对象,2号对象引用计数是2了
b.t = a // b.t也指向1号对象了,1号对象引用计数也是2了.

接下来,如果 a 和 b 引用销毁,此时 1号对象和 2号对象引用计数都 -1,但是结果都还是 1,不是 0,但是虽然不是 0,不能释放内存,但是实际上这俩对象已经没有办法被访问到了!!!

Python / PHP 使用引用计数,需要搭配其他的机制,来避免循环引用


2.2、可达性分析算法

Java 中的对象,都是通过引用来指向并访问的
经常是一个引用指向一个对象,这个对象里的成员,又指向别的对象

class Node {
    
    
    public int val;
    public Node left;
    public Node right;
}

public class TestDemo {
    
    
    public static Node build() {
    
    
        Node a = new Node();
        Node b = new Node();
        Node c = new Node();
        Node d = new Node();
        Node e = new Node();
        Node f = new Node();
        Node g = new Node();
        a.val = 1;
        b.val = 2;
        c.val = 3;
        d.val = 4;
        e.val = 5;
        f.val = 6;
        g.val = 7;
        a.val = 7;
        a.left = b;
        a.right = c;
        b.left = d;
        b.right = e;
        e.left = g;
        c.right = f;
        return a;
    }

    public static void main(String[] args) {
    
    
        Node root = build();
        // 此时这个 root 就相当于树根节点了
        // 当前代码中只有一个引用 root,但是它管理了 N 个对象
    }
}

在这里插入图片描述

虽然此处只有 root 引用,但是上述 7 个对象都是可达的!!!
root => a

root.left => b

root.left.left => d

root.left.right => e

root.left.right.left =>g

此处可达性分析就是从 roo t出发,尽可能进行遍历,所有能够被访问到的对象就都是可达的!!

root.right.right = null;

会导致 f 不可达,f 就是垃圾了

root.right = null;

会导致c不可达~
如果c不可达, f一定不可达

整个 Java 中所有的对象,就通过类似于上述的关系,通过这种 链式 / 树形结构,整体给串起来

可达性分析,就是把所有这些对象被组织的结构视为是树,从数根节点除法,也就是通过一系列称为"GC Roots"的对象作为起始点,遍历树,走过的路径称之为"引用链",所有能被访问到的对象,标记成 “可达”,当一个对象到GC Roots没有任何的引用链相连时 (不能被访问到的,就是不可达) (同样采用此法的还有C#、Lisp-最早的一门采用动态内存分配的语言)

JVM 自己捏着一个所有对象的名单 (每次 new 的一个对象,JVM 都会记录下来,JVM 会知道一共有哪些对象,每个对象的地址),通过上述遍历,把可达的标记出来了,剩下的不可达的就可以作为垃圾进行回收了

可达性分析需要进行类似于 “树遍历”,这个操作相比于引用计数来说肯定要更慢一些的

但是速度慢没关系,上述可达性分析遍历操作,并不需要一直执行,只需要每隔一段时间,分析一遍就可以了

在这里插入图片描述

对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。

在 Java 语言中,可作为 GC Roots 的对象包含下面几种:

  1. 栈上的局部变量
  2. 常量池中的对象
  3. 静态成员变量

一个代码中有很多这样的起点,把每个起点都往下遍历一遍,就完成了一次扫描过程

在这里插入图片描述

从上面我们可以看出“引用”的功能,除了最早我们使用它(引用)来查找对象,现在我们还可以使用“引用”来判断死亡对象了。所以在 JDK1.2 时,Java 对引用的概念做了扩充,将引用分为强引用(StrongReference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。

  1. 强引用:强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。

  2. 软引用:软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在 JDK1.2 之后,提供SoftReference 类来实现软引用。

  3. 弱引用:弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供 WeakReference类来实现弱引用。

  4. 虚引用:虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。


3、垃圾回收算法

学会将死亡对象标记出来后,就可以进行垃圾回收操作了,在正式介绍垃圾收集器之前,先看下垃圾回收机器使用的几种算法(这些算法是垃圾收集器的指导思想)

3.1、标记-清除算法

“标记-清除” 算法是最基础的收集算法。算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象 。后续的收集算法都是基于这种思路并对其不足加以改进而已。

在这里插入图片描述

"标记-清除"算法的不足主要有两个:

  1. 效率问题:标记和清除这两个过程的效率都不高

  2. 空间问题:内存碎片问题,被释放的空闲空间,是零散的,不是连续的,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。

申请内存要求的是连续空间,总的空闲空间可能很大,但是每一个具体的空间都很小,可能导致申请大一点内存的时候就失败了!!! 例如,总的空闲空间是 10K,分成 1K 一个,一共 10 个,此时如果申请 2K 内存,就会申请失败了!!


3.2、复制算法

“复制” 算法是为了解决 “标记-清理” 的效率问题。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,用一半,丢一半复制算法,就是把 “不是垃圾” 的对象复制到另外一半,然后把整个空间删除掉。每次触发复制算法,都是向另外─侧进行复制,将内存的数据拷贝过去

这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。

缺点:

  1. 空间利用率低

  2. 如果要是垃圾少,有效对象多复制成本就比较大了

在这里插入图片描述


3.3 标记-整理算法

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。

针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是顺序表删除中间元素,会有元素搬运的操作,让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

解决了复制算法的第一个缺点 空间利用率低 的问题
但是很明显,这种做法的缺点,效率也不高,如果要搬运的空间比较大,此时开销也很大

在这里插入图片描述


3.4、分代算法

基于上述这些基本策略,搞了一个复合策略 “分代回收”

通过区域划分,实现不同区域和不同的垃圾回收策略,从而实现更好的垃圾回收。对于不同的场景,使用不同的算法,这就时分代算法的设计思想。

基于一个经验规律:如果一个东西,存在的时间比较长了,那么大概率还会继续的长时间持续存在下去

上述规律,对于 Java 的对象也是有效的 (是有一系列的实验和论证过程…)。java 的对象要么就是生命周期特别短,要么就是特别长,根据生命周期的长短,分别使用不同的算法

给对象引入一个概念,年龄,单位不是年,而是熬过 GC 的轮次 (经过一次可达性分析的遍历,分析这个对象不是垃圾)
年龄越大,这个对象存在的时间就越久

当前 JVM 垃圾收集都采用的是 “分代收集(Generational Collection)” 算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆 分为新生代和老年代。

哪些对象会进入新生代?哪些对象会进入老年代?

  • 新生代:一般创建的对象都会进入新生代;
  • 老年代:大对象和经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代移动到老年代。

根据上述经验规律,大部分的 java 中的对象都是 “朝生夕死",生命周期非常短,所以幸存区很小,伊甸区很大,一般够放。

所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为**一块较大的Eden(伊甸园)空间和两块较小的Survivor(幸存者)**空间,每次使用Eden和其中一块Survivor(两个Survivor区域一个称为From区,另一个称为To区域)。

在这里插入图片描述

——过程:

  • 刚 new 出来的,年龄是 0 的对象,放到伊甸区。熬过一轮 GC,对象就要被放到幸存区了

  • 在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法; 将存活的对象移到幸存区,将整个伊甸区释放

  • 幸存区之后,也要周期性的接受 GC 的考验,如果变成垃圾,就要被释放。如果不是垃圾,拷贝到另外一个幸存区 (这俩幸存区同一时刻只用一个),在两个幸存区之间来回拷贝 (复制算法),由于幸存区体积不大,此处的空间浪费也能接受。

    如果这个对象已经再两个幸存区中来回拷贝很多次了,这个时候就要进入老年代了

  • 老年代中对象生命周期普遍更长,也要周期性 GC 扫描,但是频率更低了,采用 “标记-清理” 或者 “标记-整理” 算法。

当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保。

HotSpot 默认 Eden 与 Survivor 的大小比例是8 : 1,也就是说 Eden:Survivor From : Survivor To = 8:1:1。所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。

HotSpot实现的复制算法流程如下:

  1. 当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发Minor gc的时候,会扫描Eden区和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域,并将Eden和From区域清空。

  2. 当后续Eden又发生Minor gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。

  3. 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代

在这里插入图片描述

面试题 : 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗

  1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。

  2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC,经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

在这里插入图片描述


猜你喜欢

转载自blog.csdn.net/qq_56884023/article/details/131755536
今日推荐