JVM——类加载和垃圾回收

目录

前言

JVM简介

JVM内存区域划分

JVM的类加载机制

1.加载

双亲委派模型

2.验证

验证选项

3.准备

4.解析

5.初始化

触发类加载

[JVM的垃圾回收策略 GC](#JVM的垃圾回收策略 GC)

[一:找 谁是垃圾](#一:找 谁是垃圾)

1.引用计数

[2.可达性分析 (这个方案是Java采取的方案)。](#2.可达性分析 (这个方案是Java采取的方案)。)

二:释放垃圾对象

三种典型的策略

JVM实现思路


前言

我们在学习JVM的时候,其实里面的内容是非常之多的,但是里面的大部分内容都是属于八股,想要彻底搞明白,就需要看大量的关于JVM的源代码,JVM的源代码是C++写的。想要深入研究的可以去看看《深入理解Java虚拟机》这本书。

这篇文章主要针对JVM中的常见的面试题来展开。

JVM简介

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

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

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

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

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

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

JVM内存区域划分

JVM其实就是一个Java进程,Java进程也就是JVM会从操作系统这里申请一大块内存空间,给Java代码来使用。

JVM从操作系统申请的这块内存空间中,进行进一步的划分,给出了每块划分后的空间的不同用途。

其中,最核心的就是栈、堆、元数据区(方法区)。

  • 虚拟机栈是给Java代码来使用的,主要存放一些局部变量,还有维护方法之间的调用关系。
  • 本地方法栈则是给JVM内部的本地方法来使用的。
  • 堆上存放的就是new出来的对象、成员变量。
  • 程序计数器中存放的就是一个内存地址,这个内存地址就是下一个要执行字节码所在的地址,作用就是记录当前程序执行到那个指令了。

需要注意的是,堆和元数据区,在一个JVM 中只存在一份,也就是多个线程共享堆区和元数据区。

栈(本地方法栈和虚拟机栈)和程序计数器则是存在多份的,也就是每个线程都会有一份。

JVM的线程操作和操作系统的线程操作是一对一的关系。也就是说每次在Java代码中创建的线程都会在操作系统中有一个线程与之对应。

这里的面试题主要就是判断某个变量或者对象在JVM的那个区域?

例如下面代码:

java 复制代码
void func() {
    Test t1 = new Test();
}

上述代码在一个方法里面我们实例化了一个Test对象。

func方法是在元数据区以一些二进制的指令来存储的。

我们可以看到t1变量是一个在方法里面定义的,所以他是一个局部变量,局部变量就存储在栈上。

而new Test(); 这个对象的本体则是在堆上的。

其实像这里的关于JVM区域的面试题,我们只需要知道JVM的每个区域都是存储什么东西的就好了。

  • 虚拟机栈是给Java代码来使用的,主要存放一些局部变量,还有维护方法之间的调用关系。
  • 本地方法栈则是给JVM内部的本地方法来使用的。
  • 堆上存放的就是new出来的对象、成员变量。
  • 程序计数器中存放的就是一个内存地址,这个内存地址就是下一个要执行字节码所在的地址,作用就是记录当前程序执行到那个指令了。

JVM的类加载机制

对与一个类来说,他的生命周期是这样的:

前面的5步也是类加载的过程和固定的顺序。我们主要研究前面的5步。

类加载具体就是把一个.class文件,也就是类编译后的文件,加载到内存中,得到了类对象这样的过程就称之为类加载。

一个程序想要运行,就需要把指令和数据加载到内存中。类加载就是做的这个事情。

下面是类加载的5个步骤:

1.加载

这里的加载过程其实简单,就是找到.class文件,然后读取文件的内容。

但是在找.class文件的这个过程中,会有一个非常重要的机制:双亲委派模型

双亲委派模型

在JVM中,加载类需要用到一组特殊的模块:类加载器。

在JVM中,内置了三个类加载器。

  • BootStrap ClassLoader 负责加载Java标准库中的类
  • Extension ClassLoader 负责加载一些非标准的但是是Sun/Oracle扩展库的类
  • Application ClassLoader 负责加载项目中自己写的类、以及第三方库中的类

当具体加载一个类的时候,他的过程是这样的:

需要先给定一个类的全限定类名,"java.lang.String" 这个类名是一个字符串的形式。

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

具体可以参考下图:

2.验证

由于.class文件有着明确的数据格式(二进制的),这一阶段的主要目的就是确保Class文件中的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求。

验证选项

文件格式验证

字节码验证

符号引用验证......

3.准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

比如下面这样的代码:

java 复制代码
public static int value = 123;

此时在准备阶段value的值并不是123,而是0。

4.解析

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

  • 符号引用:就是字符串常量在.class文件已经存在,但是他们只知道彼此之间的相对位置,并不知道自己在内存中的具体位置。
  • 直接引用:真正的加载到内存中,就会把字符串常量填充到内存中的特定地址上去。此时字符串引用的就是直接引用,(也就是Java中普通的引用)。

5.初始化

在初始化阶段,JVM才真正的执行类中编写的Java代码,将主导权交给应用程序,初始化阶段就是执行类的构造方法的过程。(类要是有父类,就需要先初始化父类,在初始化子类)。

触发类加载

注意:类加载这个动作不是说JVM一启动就会进行加载,因为JVM整体是一个懒加载的策略,也就是非必要,不加载。

以下三种请况就会加载:

  1. 创建了这个类的实例
  2. 使用了这个类的静态方法/静态属性
  3. 使用子类,会触发父类的加载

JVM的垃圾回收策略 GC

Java中的垃圾回收是为了帮助我们自动释放内存的一种机制。

面试题:为什么需要垃圾回收机制

因为在程序运行过程中,会向操作系统申请大量的内存空间,但是这些空间也有可能会消耗尽,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

上面我们谈到了关于JVM的几个区域,那么垃圾回收释放的是那个区域的空间呢?

需要注意的是,栈和程序计数器是每个线程都会有一份的。他们会随着线程的销毁而一起销毁的。

而元数据区里面的存储的类对象,很少会进行销毁。

所以我们释放的就是堆中的空间。上面我们谈到堆中主要就是存放new 出来的对象的。

GC也就是以对象为单位进行释放的。(释放对象)

GC中主要分为两个阶段:

一:找 谁是垃圾

Java通过引用来判断是否是垃圾对象,如果没有引用指向,就判定这个对象是垃圾。

1.引用计数

给对象安排一个额外的空间,保存了一个整数,表示该对象有几个引用指向它。Java实际上并没有采取这样的方案,(Python、PHP采用了这个方案)。

java 复制代码
Test t1 = new Test();

此时是有一个引用指向的,所以引用计数器为1。

如果代码变成这样:

java 复制代码
Test t1 = new Test();
Test t2 = t1;

也就是说随着引用的增加,计数器就会增加,引用的销毁,计数器就会减少。

当计数器为0时,就会认为该对象没有引用指向了,就是垃圾了。

但是缺点也是很明显:

  1. 浪费内存空间
  2. 存在循坏引用的情况

2.可达性分析 (这个方案是Java采取的方案)。

把对象之间的引用关系理解成为了一个树形结构,从一些特殊的起点出发,进行遍历,只要能访问到,是可达的,不是垃圾,再把不可达的当做垃圾即可。

此时通过root这个引用是可以访问到整个树的任意节点的。

可达性分析的关键要点在于要进行上述的遍历,需要有起点的。

起点可以是:

  1. 栈上的局部变量(每个栈的每个局部变量都是起点)
  2. 常量池中引用的对象
  3. 方法区中静态成员引用的对象

可达性分析,总体就是从所有的起点出发,看看该对象里面又通过哪些引用能访问到那些对象,顺藤摸瓜的把所有可以访问的对象都访问一遍,遍历的同时把对象标记为"可达"。

可达性分析,克服了引用计数的两个缺点

但是也是有自己的问题:

  • 消耗更多的时间 因此即使某个对象成了垃圾,也不能第一时间发现,因为在扫描的过程中,也是需要时间的。
  • 在进行可达性分析的时候,要顺藤摸瓜,一旦这个过程中,当前代码中的对象的引用关系发生了变化,就可以出现bug。

因此为了更好的完成这个顺藤摸瓜的过程,就需要让其他的业务线程都暂停工作!!!(STW)

(STW) stop the world !

但是Java毕竟发展了这么多年,拉进回收这里也是在不断的进行优化,STW这个问题也可以比较好的对付了。

二:释放垃圾对象

三种典型的策略

1:标记清除

如果现在向内存申请了一块下面这样的空间,然后我标出来的就是垃圾对象,需要清除的。

这种策略就是直接把垃圾对象的内存就释放了。

但是这种简单粗暴的方式会产生内存碎片。

内存碎片:申请空间都是连续的整块空间,现在上述图中的空闲空间都是散落在独立的空间里面的。现在空闲总空间可能超过1G,但是我想申请500M,却是申请不了。

2:复制算法

这种方法是把空间分为两部分。一次只使用一半。

复制算法就是把不是垃圾的对象拷贝到一边去,然后在统一释放整个区域。

此时我要释放的是2和4,我就需要把剩下1和3复制到另一边去。然后再把这边全部释放。

复制算法解决了内存碎片的问题,但是也有缺点:

  • 内存利用率比较低
  • 如果大部分对象都是保留的,垃圾很少,此时的复制成本就比较高

3:标记整理

类似于顺序表删除中间元素,有一个搬运的过程

解决了内存碎片问题但是搬运的整体开销也是比较大的。

JVM实现思路

实际上,JVM的实现方式是结合了上述几种思想之后的方法。

分代回收思想

具体细节:

  • 给对象设置年龄这样的概念,用来描述这个对象存在多久了。如果一个对象刚诞生,那么就是0岁。
  • 每次进过一次扫描(可达性分析)如果没有被标记为垃圾对象,这是对象年龄就增加一岁。
  • 通过年龄来区分这个对象的活动时间。

经验规律:年龄越大的对象,也将会持续存在更长的时间。

针对不同的年龄来采取不同的回收策略

JVM针对这几个区域来执行不同的策略。

1:新创建的对象,放在伊甸区

垃圾回收扫描到伊甸区之后,大多数的对象将会在第一轮扫描下被GC给淘汰掉。

2:如果伊甸区的对象,熬过第一轮GC,就会通过复制算法,拷贝到生存区。

生存区分为两半(大小相等),一次只使用其中的一半。

如果GC在扫描生存区的时候,发现垃圾对象也就淘汰,不是垃圾的,就通过复制算法拷贝到生存区的另一边。

3:当对象在生存区熬过了若干次GC的时候,年龄也变大了。此时就会通过复制算法拷贝到老年代。

4:进入老年代之后,由于年龄都比较大了,被标记为垃圾对象的概念也很小,所以针对老年代的GC扫描也会降低频率。

特殊情况:如果对象非常大,直接进入老年代(大对象进行复制算法,成本非常高,而且大对象也不会很多)。

相关推荐
松☆9 分钟前
Dart 核心语法精讲:从空安全到流程控制(3)
android·java·开发语言
编码者卢布23 分钟前
【Azure Storage Account】Azure Table Storage 跨区批量迁移方案
后端·python·flask
编码者卢布30 分钟前
【App Service】Java应用上传文件功能部署在App Service Windows上报错 413 Payload Too Large
java·开发语言·windows
q行1 小时前
Spring概述(含单例设计模式和工厂设计模式)
java·spring
好好研究2 小时前
SpringBoot扩展SpringMVC
java·spring boot·spring·servlet·filter·listener
毕设源码-郭学长2 小时前
【开题答辩全过程】以 高校项目团队管理网站为例,包含答辩的问题和答案
java
玄〤2 小时前
Java 大数据量输入输出优化方案详解:从 Scanner 到手写快读(含漫画解析)
java·开发语言·笔记·算法
tb_first2 小时前
SSM速通3
java·jvm·spring boot·mybatis
一起养小猫2 小时前
Flutter for OpenHarmony 实战:番茄钟应用完整开发指南
开发语言·jvm·数据库·flutter·信息可视化·harmonyos