🔍 开发者资源导航 🔍 |
---|
🏷️ 博客主页: 个人主页 |
📚 专栏订阅: 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:可能出现循环引用的问题
假设存在以下代码:
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。
a = null;
b = null;
而此时的a和b我们无法获取到,也就是所谓的“垃圾”,但是因为循环引用问题,引用计数为1,并无法释放这个内存,依旧会产生内存泄漏问题。
2. 可达性分析(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就通过复制算法复制到幸存区。
- 在幸存区中也是通过复制算法来释放垃圾,因为对象规模小,所以复制的成本是可控的,当在幸存区存活次数达到一定值时,就会被复制到老年代区域。
- 在老年代区域中会使用标记-整理的方式来释放垃圾。
- 除此之外如果是一个很大的对象那么他就会直接进入老年代里面。
伊甸区→幸存区→...→幸存区→老年代
| 复制算法 | 标记整理 |