前言
面试中要考到有关JVM的话题,主要也就是三个方面:1.JVM内存划分,2.JVM类加载,3.JVM的垃圾回收;弄清楚这三个方面的内容,带你体会面试就如聊天?本篇会用通俗简洁的话,高效的带你理解JVM这三个模块!
一、JVM内存划分
java程序,是一个名字为java的进程,这个进程就是所说的“JVM”;
JVM 会从操作系统中申请一块大的内存空间,在此基础上分成几个小的区域;
区域划分如下图:
注意:上图中每一个虚线框都对应一个线程;程序计数器是每个线程都有一个;
这些区域分别存放什么?(面试如果问到,如下回答即可)
1.堆:存放new出来的对象;(成员变量)
2.方法区:存放的是类对象;(静态变量)
3.栈(虚拟机栈, 本地方法栈):存放方法之间的调用关系;(局部变量)
4. 程序计数器:存放的是下一个要执行的指令;
注意:变量存放在哪一个区域,和变量类型无关!和变量的形态(局部,成员,静态)有关!
测试:以下变量存放在什么位置?(笔试题中会出现)
解释:
变量a是成员变量,所以存放在堆里
变量b是成员变量,所以存放在堆里
变量c是静态变量,在类对象里,所以存放在方法区中;
testHead这个引用是静态的,在方法区中,他new出的对象是在堆里的;
test这个引用是局部变量,所以就在栈上,他new出的对象在堆中;
二、类加载
2.1、类加载是在干什么?
java程序在运行之前,会先编译,也就是由 .java 编译成 .class文件(二进制字节码文件),运行的时候,java进程(JVM)就会读取到对应的 .class 文件,并解析内容,在内存中构造出类对象进行初始化;
简而言之就是: 类 从 文件 加载到内存中;
2.2、类加载的过程
下图为类的生命周期:
解释:
前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来 说总共分为以下五个步骤:
- 加载;
- 连接 =>(1.验证、2.准备、3.解析);
- 初始化;
对类加载步骤的解释:
1. 加载:找到 .class 文件,读取文件内容,按照 .class 规范的格式来解析;
2. 验证:检查当前的 .class 里的内容格式是否符合要求;
3. 准备:给类里的静态变量分配内存空间;
例如,static int a = 6; 这段代码在准备阶段就会给a分配4个字节的内存空间,同时这些空间的初始值都是0;
4. 解析:初始化字符串常量,把符号引用(占位符)替换成直接引用(内存地址);
例如,String str = "hello!"; 类加载之前,"hello!"这个字符串常量并没有分配内存空间,因此 str 里就无法保存字符串常量的真实地址,只能使用一个占位符标记一下(标记了这里是"hello!"这个字符串常量的内存地址),等到真正给"hello!"分配了内存后(类加载完后),就可以用真正的地址代替之前的占位符;
5.初始化:针对类进行初始化,初始化顺序如下
父类(静态变量、静态代码块)–>子类(静态变量、静态代码块)–>父类(变量、代码块)–> 父类构造器–>子类(变量、初始化块)–>子类构造器。
注意:静态代码和静态变量同级,变量和代码块同级。谁在前先执行谁。类只会初始化一次。
2.3、何时触发类加载?
这里并不是程序一启动就加载了,而是类似于[ 懒汉模式 ] ,使用到这个类的时候,才会触发加载;
怎么算才是使用到这个类?
1. 创建了这个类的实例;
2. 使用了类的静态方法/静态属性;
3.实用类的子类(加载子类会触发加载父类);
2.4、双亲委派模型(重点考察)
2.4.1、什么是双亲委派模型?
一个类加载器收到类加载请求,首先自己不会加载这个类,而是把这个请求委派给父类加载器完成,每一层都是如此,因此所有加载请求最终都会送到最顶层的加载器中,只有父加载器反馈无法加载这个请求,子类才会尝试去加载;
2.4.2、涉及到的类加载器
1. Bootstrap ClassLoader :负责加载标准库中的类;
2. Extension ClassLoader:负责加载JVM扩展的库的类(标准库中没有,但JVM自己实现出了);
3. Application ClassLoader :负责加载我们自己的项目中的自定义类;
2.4.3、详细过程图解
有意思的是,上述过程并未涉及到 “双亲”,只是 “单亲”,这里的 “双亲” 实际上是机翻出来的,更直白其实可以叫 “单亲委派模型”...
三、GC(垃圾回收机制)
在学习C语言的过程中,需要通过 malloc 申请内存,最后通过 free 进行释放,这里就容易存在一个问题——忘记free,造成内存泄漏;而GC(垃圾回收)就是一个主流处理方案;
GC是干什么的呢?
就是一个自动释放内存的机制;我们只需要负责申请内存,释放内存的工作交给JVM完成,JVM会自动判定当前内存是否不再使用,若不再使用,就自动释放;(类似开车 => 手动挡 升级 自动档)
3.1、STW问题(Stop The World)
C++为何不引入GC?因为GC存在一个最大的问题就是会引入额外的 “空间+时间” 开销;
空间上:消耗额外的CPU / 内存资源;
时间上:最大的问题——STW问题(Stop The World);
什么是STW问题?
当程序运行到需要GC释放内存的时候,就有需要消耗一定的时间,反应到用户这里,就有可能存在明显的卡顿;
那那那那...为什么我们还要用他?因为在实际的开发中,开发效率是大于运行效率的~
3.2、GC回收哪部分内存?
方法区?类对象加载一次之后便不会卸载;栈?释放时机确定,不用回收;程序计数器?固定内存空间,不必回收;GC主要就是针对堆来回收的;
如下图:
3.3、垃圾对象的判定算法
如何判断一个某个对象是否是垃圾?
如果一个对象没有任何引用能够指向他,这个对象就是视为垃圾了;
3.3.1、引用计数法(非JVM采取的办法)
注意:此方法不是JVM采取的方法,Python、PHP使用这个方法;
具体的,给每个对象加上一个计数器,这个计数器就表示 “当前的对象有几个引用”;
如下图:
解释:
每多一个引用指向该对象,计数器就+1;
每少一个引用指向该对象,计数器就-1;
当计数器的数值为0时,就说明这个对象已经没有人能够再使用了,此时就可以进行释放;
存在缺点:
1. 空间利用率低,尤其是小对象(例如:计数器本身大小为int,对象里也只有一个int大小的成员,相当于空间增加了一倍);
2. 可能出现循环引用的情况,如下:
解释:
当前这两个对象引用计数器为1,因为即使t1, t2两个引用被置为空,但实际上时两个对象在相互引用,此时外界代码是无法访问的,但由于引用计数器不是0,所以无法进行释放;
3.3.2、可达性分析(JVM采取的办法)
约定一些特定的变量,成为 “GC roots”, 每隔一段时间,从GCroots出发,进行遍历,查询哪些变量是能够被访问到的,能被访问到的变量就称为 “可达”,否则就是 “不可达”;
GC roots对象可以是以下几种:
-- 栈上的变量;
-- 常量池引用的对象;
-- 方法区中静态属性引用对象;
-- 方法区中常量引用的对象;
具体的如下图:
解释:
上图中,通过 GC roots就可以找到object1、object2、object3、object4;而object5、6、7则不可以被访问到,所以5、6、7就是垃圾了;
3.4、垃圾回收算法
3.4.1、标记-清除算法
简单来说,就是标记出垃圾后,直接把对象对应的内存空间进行释放;
如下图:
解释:
上图中,黑色部分就是被标记要清除的部分,经过回收后,就剩下了存活对象;
缺点:
1.效率:标记和清除这两个过程的效率都不高;
2.空间(内存碎片):如上图,标记就清除后会产生大量不连续的内存碎片,碎片太多可能会导致之后程序运行中需要分配较大对象时,可能无法找到连续且足够大的空间而无法申请空间;
3.4.2、复制算法
这是针对内存碎片问题,引入的办法;
具体的,将内存分成两块大小相等的空间,但只使用其中的一块,当需要进行垃圾回收时,就把正在使用的那块空间上还存活的对象复制到另一块上,再将使用过的那块内存全部清空;这样做的好处就是不用在考虑内存碎片问题;
如下图:
缺点:
1. 空间利用率相比标记清除法更低了;
2. 若一轮GC下来,大部分需要保留,只有极少数要回收,这时候复制的开销就很大了;
3.4.3、标记整理算法
标记过程与 “标记-清除算法”一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端连续性的覆盖,然后直接清除掉边界以外的空间;(类似于顺序表的删除元素)
如下图:
评价:
相对于复制算法而言,空间利用率提升了,同时也解决了内存碎片化问题,但是搬运操作比较耗时;
3.4.4、分代算法
分代算法总和了上面所说的三种算法,通过区域划分,实现不用区域用不同的垃圾回收策略,从而实现更好的垃圾回收;也就是我们常说的“因地制宜”~
如下图:
解释:
1. 刚创建出来的对象,进入伊甸区;
2. 若新对象熬过一轮GC,没挂,就通过复制算法,复制到生存区;
3. 生存区的对象也要经历GC,每熬过一次GC,就会通过复制算法拷贝到另一个生存区(只要这个对象不死亡,就会在两个生存区来回拷贝);
4. 如果一个对象在生存区中,反复坚持了很多轮还没去世,就会被放到老年代(老年代GC的频率会降低);
5. 若对象来到了老年代,也会进行定期的GC,只是频率更低了;老年代采取标记整理的方式来处理垃圾;
特殊处理:若新创建的对象非常大,则直接进入老年代;因为一个大的对象进行复制算法,开销太大;另一个角度考虑,既然是一个很大的对象,费这么大开销创建出来,肯定不是立即就销毁的;