我们会在这里介绍我所涉及到的JVM相关的面试点内容,本篇内容持续更新
我们会介绍下述JVM的相关面试点:
- JVM内存结构
- 内存溢出问题
- 方法区与永久代和元空间
- JVM内存参数
- JVM垃圾回收算法
- GC和分代回收算法
- 类加载过程
- 双亲委派
- 对象调用类型
JVM内存结构
我们将会介绍JVM的整体内存结构的运行流程
JVM内存结构图
我们首先给出JVM的内存结构图:
JVM内存结构功能
我们针对上述图分别讲解功能部件:
/*Java Source*/
源代码(就是我们书写的代码)
/*Java Class*/
字节码(由源代码转变过来的,跨平台的关键)
/*类加载子系统*/
在正式执行代码前,我们需要先将代码使用的类和方法加载,类加载子系统就是做这个的
它会将类和方法加载到方法区
/*JVM Stacks 虚拟机栈 和 Native Method Stacks 本地方法栈*/
JVM Stacks 虚拟机栈:用来存放代码中所使用的我们所定义的方法,属性等局部变量
Native Method Stacks 本地方法栈:用于存放本地方法
但是上述栈只是定义,具体实现不一定需要分为两个栈,可以统一存放
/*PC Register 程序计数器*/
我们的程序线程并非一直分配CPU,当我们的线程CPU被剥夺后,我们需要记录继续运行时的继续位置
程序计数器用于记录当重新分配CPU后我们需要执行的下一行代码位置
/*堆*/
存储我们new出来的对象
/*方法区*/
方法区用于存放我们所使用的类和类方法
方法区只是定义,具体实现在不同JDK版本不同:7之前为永久代,8之后为元空间
/*GC垃圾回收*/
当我们的内存不足或处于一个需要清理的状态,我们调用GC垃圾回收清除掉目前不使用的数据
/*解释器*/
我们的字节码是不能直接执行的,我们需要先进行解释编译才能执行
/*即时编译器*/
正常情况下我们的字节码由解释器解释后执行,但会耗费一定时间
若使用次数较多,系统会使用即时编译器来编译字节码,并将其储存,每次直接调用,不经过解释器处理
内存溢出问题
我们将会介绍JVM的各部件的内存溢出问题
内存溢出问题
我们分别给出内存溢出的不同情况:
/*内存溢出区域*/
除了程序计数器其他区域均会出现内存溢出
/*OutMemoryError问题*/
问题产生原因:
1.堆内存耗尽:对象越来越多,且均在使用,无法进行GC
2.方法区内存耗尽:加载类越来越多,很多框架都会在执行时动态生成类
3.虚拟机栈累积:每个线程都会占用1M内存,当线程个数越来越多,长时间不销毁导致错误
/**Error问题*/
问题产生原因:
1.虚拟机内部:方法不断执行,执行次数过多
方法区与永久代和元空间
下面我们来介绍方法区与永久代和元空间问题
方法区与永久代和元空间
我们来介绍方法区与永久代和元空间的概念以及注意事项:
/*方法区*/
方法区只是JVM规范中定义的一块内存区域,用来存储类元数据,方法字节码,即时编译器需要的信息等
/*永久代*/
永久代是JDK1.7之前的方法区存放位置,最开始存放在常量池
/*元空间*/
元空间是JDK1.8之后的方法区存放位置,存放于堆中
/*注意点*/
元空间GC条件非常苛刻:
- 仅当堆中所有的类的对象全部清理之后,才能清理元空间数据
JVM内存参数
下面我们介绍一下面试中常考的JVM内存参数
JVM参数展示
我们根据分区展示JVM常用参数:
/*内存区域*/
-Xmx:最大内存
-Xms:最小内存
-Xmn:新生代内存(伊甸园+from+to)
-XX:Survivor:伊甸园和from 的比率(注意:实际划分应该是伊甸园-from-to:n:1:1)
/*元空间*/
元空间分为classspace(类基本信息)和non-classspace(类的注释,字节码等信息)
-XX:CompressedClassSpaceSize classspace最大内存空间
-xx:MaxMetaspaceSize 元空间最大内存空间
/*代码缓存*/
当代码缓存空间小于240,全部都保存在code cache中
当代码缓存空间大于240,non-nmthods:JVM代码基本信息;profiled-nmthods:部分优化信息;non-profiled-nmthods:完整信息
-XX:ReservedCodeCacheSize 设置代码缓存区内存大小
/*线程*/
-Xss:线程占用内存大小,默认1M
JVM垃圾回收算法
下面我们介绍面试中常问的三种垃圾回收算法
标记操作
在开始前我们先回顾标记操作:
/*标记操作*/
1. 找到Root根对象(Root根对象就是一定不会被垃圾回收的对象,包括但不限于正在使用的对象,静态对象等)
2. 根据Root根对象向下蔓延,标记延申的对象,该类对象将不被回收
标记清除
我们简述标记清除操作:
/*标记清除操作*/
1. 先进行标记处理
2. 直接在内存中将未标记的数据清除(实际上就是标记为空白数据)
/*优缺点*/
1. 执行速度极快
2. 但会产生内存碎片,当内存碎片逐渐增多会导致问题
注意:基本不再使用该GC处理
标记整理
我们简述标记整理操作:
/*标记整理操作*/
1. 先进行标记处理
2. 将未标记的数据清除,同时将标记的数据重新排序,紧密排列
/*优缺点*/
1. 不会产生内存碎片
2. 耗时,需要重新复制粘贴数据更换位置
注意:该算法经常用于老年代的GC处理
标记复制
我们简述标记复制操作:
/*标记复制操作*/
1. 准备两块相同大小的区域,分为from和to区域,我们的信息都会存放在from区域
2. 首先对from区域进行标记处理
3. 直接将from区域的标记数据移动到to区域,按顺序排列,然后对调from和to区域
/*优缺点*/
1. 速度快,不会产生内存碎片
2. 占用双倍内存
注意:该算法经常用于新生代的GC处理
GC和分代回收算法
下面我们介绍GC和分代回收算法
GC概述
首先我们对GC做一个简单的总结:
/*GC目的*/
GC的目的在于实现无用对象内存自动释放,减少内存碎片,加快分配速度
/*GC要点*/
1. 回收区域是堆内存,不包括虚拟机栈,在方法调用结束会自动释放方法占用内存
2. 判断无用对象,使用可达性分析算法和三色标记法标记存活对象,回收未标记对象
3. GC的具体实现称为垃圾回收器
4. GC大多数都采用了分代回收思想,分为新生代和老年代,新生代又分为伊甸园,幸存区;不同区域有不同的回收策略
5. 根据GC规模可以分为Minor GC,Mixed GC,Full GC
/*GC不同规模*/
Minor GC:发生在新生代的垃圾回收,暂停时间短
Mixed GC:G1垃圾回收器特有,新生代整体垃圾回收,老年代部分垃圾回收,可以设置垃圾回收时间,当时间不足,系统会优先回收回收收益大的
Full GC:新生代和老年代的完整垃圾回收,暂停时间长,应极力避免
分代回收思想
我们首先来介绍分代回收思想:
/*分代回收区域*/
新生代:
伊甸园
幸存区
from
to
老年代
/*具体介绍*/
新生代和老年代属于两块大区域
新生代:用于存储较新的数据,经过层层筛选进入老年代
伊甸园:新生代的一块区域,用于存放所有新进入的数据
幸存区:用于存放经过GC的数据,采用from,to也就是标记复制的方法保存
老年代:用于存储经过多次GC还未回收的数据,GC条件苛刻
/*跳转介绍*/
新生代GC:
新生代的伊甸园内存塞满后,进行一次新生代的GC,进行筛选,将保存的数据放入幸存区的from
新生代第二次GC,同样筛选,注意伊甸园和幸存区都需要筛选,然后将保存的数据放入幸存区的to,然后调换from和to
新生代->老年代:
当新生代经过多次GC,数据经历了多次GC仍未被处理,且次数超过一个阈值,就放入老年代中
当新插入的数据过大,新生代无法存储,就直接放入老年代存储
三色标记和并发漏标问题
我们在标记过程中经常会采用三色标记法来标记:
/*三色标记法*/
黑色-已标记
灰色-标记中
白色-未标记
系统会统计Root,然后从Root往下延申,标记过的部分标记为黑色,正在标记的部分未灰色,直到所有Root走完
这时我们需要保留的数据为黑色,我们不需要的数据为白色,标记完成
/*处理并发漏标问题*/
如果我们线程并发处理,我们在GC过程中,另一个线程调用了新的类,这时该类未被标记为黑色,就会导致将我们需要的数据删除
存在两种处理方式:
1.Incremental Update:
只要赋值发生,被赋值的对象就会被记录(类的Root),在三色标记结束后重新遍历记录的对象
2.Snapshot At The Beginning:
新加对象会被记录(类)
被删除引用关系的对象也会被记录
最后同样在三色标记结束后,也会全部遍历处理
垃圾回收器
我们介绍三种垃圾回收器:
/*Parallel GC*/
eden 内存不足时发生Minor GC,标记复制STW(STW:Stop The World,停止其他线程的操作)
old 内存不足时发生Full GC,标记整理STW
该垃圾回收器注意吞吐量
/*ConcurrentMarkSweep GC*/
old 并发标记,重新标记时需要STW,并发清除(并发操作时,其他线程不需要停止操作)
Failback Full GC:当垃圾回收失败时,存在保底策略Failback Full GC
该垃圾回收器注意响应时间
/*G1 GC*/
响应时间与吞吐量兼顾
划分为多个区域,每个区域都可以充当eden,survivor,old,humongous(存放大型数据的位置,减少复制操作,减少时间损耗)
新生代回收:eden内存不足,标记复制STW
并发标记:old 并发标记,重新标记时需要STW
混合收集:并发标记完成,开始混合收集,参与复制的有eden,survivor,old,其中old会根据暂停时间目标,选择部分回收价值高的区域,复制期间STW
Failback Full GC
类加载过程
我们下面来介绍一下类加载过程
类加载流程
类加载主要分为三个步骤:
/*加载*/
1. 将该类的字节码载入方法区,先创建类.class对象
2. 如果此类的父类没有加载,先加载父类
3. 加载是懒惰执行
/*链接*/
1. 验证-验证类是否符合Class规范,合法性,安全性检查
2. 准备-为static变量分配空间,设置默认值
3. 解析-将常量池的符号引用解析为直接引用
/*初始化*/
1. 执行静态代码块与非final静态变量的赋值
2. 初始化是懒惰执行
类加载解释
我们对上述流程的部分内容进行解释:
/*final值处理*/
针对final的基本类型的处理在类的声明阶段就已经进行赋值了(默认为常量)
我们如果直接在main方法中调用类的final的基本类型,既不会触发类初始化也不会触发类加载(直接从常量池取数据或者提前保存到底层)
/*静态变量处理*/
针对静态变量static的声明和分配空间都是在链接阶段进行,但只会赋默认值
针对final引用类型,静态变量static和static静态代码块的信息都是在初始化阶段才会赋值
(将其按从上到下的顺序保存到一个static方法中统一执行)
针对final引用类型和静态变量在main中引用的,都会触发类的加载和初始化
/*链接的符号引用变为直接引用*/
在未进行链接前,我们的常量池和底层代码中都会保存类的符号引用(仅仅是一个占位符)
在链接之后,该占位符就会变成类的引用地址
双亲委派
下面我们来简单介绍一下双亲委派及相关面试点
双亲委派概述
我们首先介绍双亲委派:
/*双亲委派*/
针对类,优先委派上级类加载器进行加载:
1.当上级类加载器能找到这个类,由上级加载,加载后该类对下级加载器也可见
2.当上级类加载器找不到这个类,下级加载器才有资料加载该类
四种类加载器
我们来介绍四种类加载器:
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
Application ClassLoader | classpath | 上级为 Extension |
自定义类加载器 | 自定义 | 上级为 Application |
我们简单介绍一下运行机制:
- 首先我们需要知道Bootstrap ClassLoader 是不可访问的,当我们查找到该层级时会显示null
- 我们如果需要加载一个类,会先检测是否有上级,如果有上级就到上级中去,如果没有就在本层查找是否有该类
- 意思就是以*的加载器中的类为最高标准,如果同时存在多个类,我们会选择*的类加载器中的类来运行
双亲委派逻辑问题
我们提出一个简单的双亲委派相关的逻辑问题:
/*问题*/
我们能够自己编写类加载器来加载一个假冒的java.lang.System吗?
/*解题*/
不能
1.假设你编写的类加载器走双亲委派的流程,那么就会优先启动真正的Java.lang.System,不会加载自己书写的类加载器
2.假设你编写的类加载器不走双亲委派流程,那么你的类加载器加载到假冒的System时,需要先加载父类Java.lang.Object,但没有委派流程,所以你是找不到Objet类的
3.此外,在JDK9之后,针对特殊的包名Java.,Java.lang等都已经进行了警告提示,编写是不会被通过的
/*双亲委派目的*/
1.为了让上级类加载器的类作用于下级类加载器,即瓤你的类能够依赖到JDK提供的核心类
2.让类的加载有优先次序,保证核心类优先加载
对象引用类型
下面我们来介绍对象引用类型
四种常见对象引用类型
我们首先介绍四种常见的对象引用类型:
/*强引用*/
普通变量赋值即为强引用:A a = new A();
通过GC Root的引用链,如果强引用不到对象,该对象就可以被回收
/*软引用SoftReference*/
需要采用软引用对象连接真正的对象:SoftReference a = new SoftReference(new A());
如果仅有软引用引用该对象,则首次GC不会回收该对象,但GC过后若内存仍不足,该对象就会被回收
软引用对象本身需要利用引用队列ReferenceQueue来进行回收
典型例子是反射数据
/*弱引用WeakReference*/
需要采用弱引用对象连接真正的对象:WeakReference a = new WeakReferenec(new A());
如果仅有弱引用引用该对象,则只要发生GC,就直接回收该对象
弱引用对象本身需要利用引用队列ReferenceQueue来进行回收
典型例子是ThreadLocalMap中的Entry对象(key)
/*虚引用PhantomReference*/
虚引用也需要虚引用对象来连接真正的对象:PhantomReference a = new PhantomReference(new A());
该虚引用对象必须联合引用队列ReferenceQueue来执行,系统会一直检测是否存在虚引用对象,当引用的真正对象被回收后,虚引用对象就会被放置到ReferenceQueue中,由ReferenceHandler线程释放其关联的资源
典型例子是Cleaner释放DirectByteBuffer占用的直接内存
/*Cleaner的使用*/
1.创建一个Cleaner:
Cleaner cleaner = Cleaner.create();
2.Cleaner使用:
Cleaner.register(对象,对象回收后方法);
其中,对象就是我们需要回收的对象;后面的方法我们可以采用Lambda表达式实现() -> {}
Finalize
Finalize也被称为终结器引用,其实是一种已经过时的引用类型:
/*Finalize认知*/
属于Object的方法,子类重写后,在该子类被垃圾回收后就会调用,可以执行一些资源释放和清理工作
/*深层认知*/
Finalize不适合完成资源释放和清理工作,因为十分影响性能,甚至严重时引起OOM
/*具体原因*/
// 方法不佳
1.FinalizerThread是守护线程,代码有可能还未执行就结束了,导致资源没有释放
2.Finalize会吞掉异常,我们无法判断资源释放过程中是否出现异常
// 影响性能
1.重写了Finalize的对象在第一次GC时不能被回收,会被FinalizerThread调用finalize方法,将他从unfinalized队列去除后才能释放
2.GC本身就是因为内存不足调用,但是Finalize由于调用过慢(串行执行,锁)导致不能及时释放内存,导致资源放入老年代,导致Full GC
// 质疑
1.Finalizer线程的优先级其实为8,相对而言比较高的,但是由于finalize执行过慢导致跟不上主线程的步伐
结束语
目前关于JVM的面试点就总结到这里,该篇文章会持续更新~
附录
参考资料:
- 黑马Java八股文面试题视频教程:虚拟机-01-jvm内存结构_代码执行流程_哔哩哔哩_bilibili