JVM面试通关指南:内存区域、类加载器、双亲委派与GC算法全解析


🔍 开发者资源导航 🔍
🏷️ 博客主页个人主页
📚 专栏订阅JavaEE全栈专栏

JVM虚拟机的机制是面试常见的题目,属于八股文的范畴,JAVA的设计初衷是为了不用你理解底层,该问题的产生源于《深入理解JVM虚拟机》一书,该书的产生原本是为了给C++程序员看的,但是不知为什么后来成为了JAVA程序员的常见面试题。

本文将从八股文的角度来讲解常见的四个问题:内存区域划分类加载双亲委派模型垃圾回收机制(GC)

一、内存区域划分

1.1 为什么要划分区域呢?

JVM虚拟机是仿照真实的机器,真实的操作系统进行设计的,因此JVM在设计的时候参考了这一机制。

1.2 区域划分

JVM在运行之初会向操作系统申请空间 ,然后再对这片空间的不同区域进行功能性划分。

JVM对于区域划分为了四个部分:程序计数器元数据区

  • 堆:程序中创建的所有对象都在保存在堆中。
  • 栈:用于保存方法的调用关系。
  • 程序计数器:用于保存当前的指令执行到了哪里,因为CPU在运行时是并发执行的,因此为了切换线程后能恢复到正确的执⾏位置,每条线程都需要独⽴的程序计数器。
  • 元数据区:保存已经加载好的类,以及一些常量。

对于堆和元数据区,整个JAVA进程共用同一份,而程序计数器和栈每个线程都有一份。

局部变量保存在栈上,全局变量保存在堆上,静态变量保存在元数据区。

二、类加载

2.1 加载步骤

从JAVA的官方文档中,类加载可以分为三个阶段,而第二个阶段又可以分为三个步骤,因此总共是五个步骤。

1.加载

找到.class文件,根据类的全限定名(例如java.lang.String)打开文件,读取文件的内容到内存里。

2.验证

解析,验证.class读到的内容是否合法,并把这个数据转化为结构化的数据。

3.准备

给类对象申请一块内存空间。

4.解析

针对字符串常量进行初始化,将从类里面解析出来的变量放到元数据区的常量池里面。

5.初始化

针对类的各种属性进行填充(包括静态成员),如果这个类的父类还没有加载,也会触发其父类的加载。

以上的顺序是按照官方文档的顺序,但是JVM具体实现的顺序是并不一定的,如果面试出现了这个问题以上述文档顺序为主。

2.2 加载时机

在一个进程中,一个类的加载只会出现一次,而它的加载时机采用的是懒加载模式。

JAVA的代码用到哪个类,就触发哪个类的加载,触发方式包括以下方式:

  • 构造该方法的实例
  • 调用类的静态属性/静态方法
  • 使用某个类时,其父类没有加载,也会触发父类的加载

三、双亲委派模型

双亲委派模型是一个高频的面试问题,这个模型好就好在它起了一个好名字,实际上这个问题并不复杂。

在JVM中默认提供了三种类加载器,这些类加载器的作用范围不同,彼此之间存在一种"父子"的关系。

启动类加载器(Bootstrap ClassLoader):​ ​负责加载JAVA标准库的核心类,是所有类加载器的父加载器,但没有父加载器(可以认为是 null)。

扩展类加载器(Extension ClassLoader)​ ​负责加载JAVA扩展库目录的类,父加载器是 Bootstrap ClassLoader。​

应用程序类加载器(Application ClassLoader)​ ​负责加载JAVA的第三方库,父加载器是 Extension ClassLoader

父子关系图:

复制代码
Bootstrap ClassLoader(标准库)
               ↑
Extension ClassLoader(扩展库)
               ↑
Application ClassLoader(第三方库)

在运行的时候,会从Application ClassLoader(第三方库)开始进入,但是他并不会立即尝试加载该类,而是委托给其"父亲"Extension ClassLoader(扩展库),而其"父亲"也不会立即加载,也是委托给其"父亲"Bootstrap ClassLoader(标准库),如果其"父亲"没有找到再交给它"孩子"进行加载。

也就是说类加载的顺序其实是:

复制代码
(标准库)→(扩展库)→(第三方库)

而上述的过程就称之为"双亲委派模型",那么为什么要这么写呢?我换种方式不也是可以实现吗?因为其源码的过程就是大致这么写的:

复制代码
循环(类加载器 != null) {
    类加载器 = 类加载器.父亲
}

而这段代码就被提取了出来当做一个模型,但凡当时换了一种方式实现,也不会叫做这个名字。

四、垃圾回收机制(GC)

GC是JAVA释放内存的机制,在C语言中申请的空间需要free掉,否则就会产生内存泄漏 的问题,但是手动释放内存太麻烦了,而且还容易忘记导致出错,因此JAVA引入GC机制自动识别不使用的内存,自动对其释放。

4.1 找垃圾

释放垃圾的前要先找到垃圾,目前存在两种常见的找垃圾机制。

1. 引用计数(Python,PHP使用该方式)

该机制在每个对象new的时候,都搭配一个小的内存空间用来保存一个整数,这个整数表示当前对象有多少个引用指向它,如果引用数量为0就代表该对象不再使用,可以当做垃圾进行处理。

缺点1:内存消耗多

因为每个对象都需要携带一个整数,这个对象越小引用计数占内存的比例就越大,例如引用计数是4字节,而对象是8字节,此时你的内存就膨胀了50%。

缺点2:可能出现循环引用的问题

假设存在以下代码:

java 复制代码
class test {
    test t = null;
}

test a = new test();
test b = new test();

a.t = b;
b.t = a;

此时对象a的引用计数和b的引用计数均为2。

如果我们将a和b都设置为null,引用计数就会变成1而非0。

java 复制代码
a = null;
b = null;

而此时的a和b我们无法获取到,也就是所谓的"垃圾",但是因为循环引用问题,引用计数为1,并无法释放这个内存,依旧会产生内存泄漏问题。

2. 可达性分析(JAVA采用了此方法)

如果说引用计数是存在空间开销,那么可达性分析的方法是在用时间换空间。

该机制先以某些特定的对象作为遍历的起点,然后对这个对象尽可能的遍历,每次访问到一个对象都会将其标记为"可达 ",当将这些对象都遍历完后就知道那些是"不可达"的,也就是将要回收的垃圾。

例如以下代码:

java 复制代码
class test1 {
    int a;
}

class test2 {
    int b;
}

class test3 {
    test1 t1 = new test1();
    test2 t2 = new test2();
}

test3 t3 = new test3();

可达性分析在开始的时候从t3开始遍历,因为t3存在t1和t2这两个已经开辟空间的对象,GC会将t1和t2都标记为"可达 ",并且遍历t1和t2,但是因为t1和t2的属性中并没有可以遍历的对象,因此不会继续遍历,而如果t1和t2内部的属性中也是存在开辟空间了的对象,同样也会遍历和标记为"可达"。

如果让t3.t1 = null,下一次可达性分析的时候t1就会因为无法遍历到无法标记为"可达"。

可以当做遍历起点的特定对象包括哪些呢?

  • 栈上的局部变量(引用类型)
  • 常量池引用指向的对象
  • 静态成员(引用类型)

可达性分析是周期性的,每隔一段时间就会触发一次这样的可达性分析遍历,如果你的对象非常多的话,这个过程就会非常的耗费时间和资源。

4.2 释放垃圾

已经知道了那些是垃圾,那么该如何释放呢?下面我们将讨论几种常见的方式以及Java给出的解决方法。

1. 标记-清除

把垃圾对象的内存直接释放掉,但是这样做会产生内存碎片问题。

此时t2的内存虽然已经被释放掉了,但是因为空间的申请必须是连续的,不能多个空间拼在一起,因此总的空闲空间虽然很大,但是一旦申请稍大一些的空间就会失败。

2. 复制算法

复制算法将空间分为了两半,每次只使用一边,释放垃圾的时候将不是垃圾的部分复制到另一边,最后再整体释放垃圾部分,这样的算法可以保证空间的连续性。

此算法的缺点也很明显,空间利用率很低一次只能使用一半的空间,除此之外一旦垃圾对象不是很多,复制的成本会很高。

3. 标记-整理

将不是垃圾的对象整理到一起,并且将垃圾统一释放。

该方法类似于顺序表的搬运,虽然解决了内存问题,但是复制成本依旧很大。

4.分代回收(Java使用的方法)

Java使用的方法将上述的方法结合了起来,扬长避短。

Java将对象按照"年龄"(GC轮次),分为三个区域:伊甸区幸存区老年代,针对三个不同的区域采用不同的策略来执行。

针对不同区域,分代回收的思想是这样的:如果一个对象很"年轻",这个对象就很有可能挂掉,如果一个对象比较"老"了,这个对象很有可能继续存在。

这个思想是一个经验规律,绝大多数的对象都活不过第一轮GC,因此对于新生代GC频率就比较高,而针对老年代的频率就降低一些。

  • 刚创建的对象会先放到伊甸区,如果活过了第一轮GC就通过复制算法复制到幸存区。
  • 在幸存区中也是通过复制算法来释放垃圾,因为对象规模小,所以复制的成本是可控的,当在幸存区存活次数达到一定值时,就会被复制到老年代区域。
  • 在老年代区域中会使用标记-整理的方式来释放垃圾。
  • 除此之外如果是一个很大的对象那么他就会直接进入老年代里面。
java 复制代码
伊甸区→幸存区→...→幸存区→老年代
|        复制算法      |  标记整理  | 
相关推荐
自由鬼32 分钟前
如何处理Y2K38问题
java·运维·服务器·程序人生·安全·操作系统
_oP_i4 小时前
RabbitMQ 队列配置设置 RabbitMQ 消息监听器的并发消费者数量java
java·rabbitmq·java-rabbitmq
Monkey-旭4 小时前
Android Bitmap 完全指南:从基础到高级优化
android·java·人工智能·计算机视觉·kotlin·位图·bitmap
我爱996!4 小时前
SpringMVC——响应
java·服务器·前端
小宋10214 小时前
多线程向设备发送数据
java·spring·多线程
天若有情6735 小时前
【python】Python爬虫入门教程:使用requests库
开发语言·爬虫·python·网络爬虫·request
大佐不会说日语~6 小时前
Redis高频问题全解析
java·数据库·redis
寒水馨6 小时前
Java 17 新特性解析与代码示例
java·开发语言·jdk17·新特性·java17
启山智软6 小时前
选用Java开发商城的优势
java·开发语言