文章目录
-
- 1 JVM 内存结构
- 2 简述Java类加载机制(Java类加载过程)
-
- 2.1 什么是类的加载、链接、初始化
- 2.2 forName(“Java.lang.String”)和loadClass(“Java.lang.String”)有什么区别?
- 2.3 谈谈类的加载器分类?
- 2.4 谈谈你对双亲委派机制的理解?
- 2.5 双亲委派机制优化和劣势?
- 2.6 沙箱机制听过吗?
- 3 谈谈你对程序计数器的理解
-
- 3.1 为什么使用PC寄存器记录当前线程的执行地址呢?
- 4 说说你对本地方法栈的理解
- 5 什么虚拟机栈?
-
- 5.1 栈里面存在GC吗?
- 5.2 静态变量和局部变量的对比?
- 5.3 调整栈大小,就能保证不出现溢出吗?
- 5.4 分配的栈内存越大越好吗?
- 5.5 垃圾回收是否会涉及到虚拟机栈?
- 5.6 方法中定义的局部变量是否线程安全?
- 5.7 什么情况下会发生栈内存溢出?
- 5.8 说说堆和栈的区别?
- 6 方法区中都存放什么东西?
-
- 6.1 方法区是否也会溢出?
- 6.2 谈谈你对方法区中字符串常量池、静态变量的变化?
- 6.3 为什么要用元空间取代永久代
- 6.4 StringTable为什么要调整?
- 6.5 对象内部结构都有什么?
- 7 谈谈你对执行引擎的理解?
-
- 8 简单谈谈你对堆的理解?(共享|垃圾回收)
-
- 8.1 堆里面的分区
- 8.2 堆大小通过什么参数设置?
- 8.3 初始堆大小和最大堆大小一样,问这样有什么好处?
- 8.4 Minor GC | Major GC | Full GC
- 8.5 什么是垃圾?
- 8.6 什么是GC?为什么要有GC?
- 8.7 JVM垃圾回收常用算法?
- 8.8 你对枚举根节点做可达性分析了解吗?
- 8.9 什么是内存溢出?
- 8.10 JVM内存结构中,那些部分会出现内存溢出?
- 8.11 什么是内存泄漏?
- 8.12 强引用、软引用、弱引用、虚引用的区别?
- 8.13 吞吐量优先选择什么垃圾回收器?响应时间优先呢?
- 8.14 谈谈你对JVM中垃圾收集器的理解?
- 8.15 讲一下CMS垃圾收集器垃圾回收的流程
- 8.16 CMS优缺点
- 8.17 CMS会出现漏标,怎么解决的?
- 8.18 三色标志谈谈你的理解是什么样的?
- 8.19 谈谈你对G1垃圾收集器的理解?
- 8.20 G1垃圾收集器的特点、缺点?
- 8.21 谈谈你对G1中的Region的理解?
- 8.22 大致说说G1的回收过程?
- 8.23 G1的年轻代GC?
- 8.24 G1并发标记过程?
- 8.25 G1混合回收 Mixed GC?
- 8.26 G1和CMS相比有哪些优势?
- 8.27 我们怎么去选择垃圾收集器?
- 8.28 说说GC和分带回收算法?
- 总结
-
- 你是如何理解java是一门跨平台的语言的?也就是【一次编译,到处运行】?
- 聊聊从源码文件(.java)到代码执行的过程呗。
- 你知道双亲委派机制吗?
- 你对JVM的内存结构了解吗?你来讲一下吧?
- Java是垃圾回收中,你认为那些是垃圾?
- 那你是怎么判断对象不在被使用的呢?
- 那你说一下什么是【GC Roots】呢?
- 那么为什么垃圾要进行分代回收呢?
- 那JDK8及一下的垃圾收集器有哪些?
- 那么新创建的对象在是放在【新生代】,那什么时候会到【老年代】呢?
- 既然你又提到了Minor GC,那Minor GC 什么时候会触发呢?
- 那在「年轻代」GC的时候,从GC Roots出发,那不也会扫描到「老年代」的对象吗?那不就相当于全堆扫描吗?
- 但又有个问题,那如果「年轻代」的对象被「老年代」引用了呢?(老年代对象持有年轻代对象的引用),那时候肯定是不能回收掉「年轻代」的对象的。
- 简单聊聊CMS垃圾收集器?
- 那你清楚CMS的工作流程吗?
- 我看现在很多企业都在用G1了,那你觉得CMS有什么缺点呢?
- 你对G1垃圾收集器了解吗?我们来简单聊一下呗?
- 那你讲讲G1的GC过程呗?
1 JVM 内存结构
- ①. 类加载器子系统
- ②. 运行时数据区[
堆、方法区、java虚拟机栈、本地方法栈、程序计数器
]
- ③. 执行引擎(解释器和JIT编译器共存)
2 简述Java类加载机制(Java类加载过程)
- 加载机制是指类的加载、链接、初始化的过程
2.1 什么是类的加载、链接、初始化
-
加载: 将字节码文件中的.class文件,通过类加载器,加载进运行时数据区的方法区内,并创建一个大的Class对象
-
链接:(验证、准备、解析)
① 验证(比如说验证字节码文件开头是CAFFBABA,版本号等)
② 准备(为类变量赋予默认的初始化值, 使用static+final修饰, 且显示赋值不涉及到方法或者构造器调用的基本数据类型或者String类型的显示赋值都在准备阶段)
③ 解析:将类中的符号引用变成直接引用(符号引号在字节码文件的常量池中)
-
初始化: 为类变量赋予正确的初始化值, 执行Clinit方法(静态代码块或使用static修饰的变量)
注意: 一个类中声明类变量, 但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时
2.2 forName(“Java.lang.String”)和loadClass(“Java.lang.String”)有什么区别?
-
①. forName()会导致类的主动加载,而getClassLoader()不会导致类的主动加载,Class.forName():是一个静态方法,最常用的是Class.forName(String className);根据传入的类的全限定名返回一个Class对象。该方法在将Class文件加载到内存的同时,会执行类的初始化
-
②. ClassLoader.loadClass():这是一个实例方法,需要一个 ClassLoader 对象来调用该方法。该方法将Class文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化。(该方法因为需要得到一个ClassLoader 对象,所以可以根据需要指定使用哪个类加载器)
2.3 谈谈类的加载器分类?
类的加载器一共分为4类,启动类加载器(Bootstrap)、扩展类加载器(Extension)、应用程序类加载器(Application ClassLoader)、用户自定义加载器
。
- 这个类加载使用
C/C++
语言实现的,嵌套在JVM内部
- 它用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sum.boot.class.path路径下的内容),用于提供JVM自身需要的类(String类就是使用的这个类加载器)
- 由于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
- 不继承自java.lang.ClassLoader,没有父加载器
- Java语言编写,由sum.music.Launcher$ExtClassLoader实现
- 派生于ClassLoader类,父类加载器为启动类加载器
- 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载
- Java语言编写,由sum.music.Launcher$AppClassLoader实现
- 派生于ClassLoader类,父类加载器为扩展类加载器
- 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 调用System.getSystemClassLoader() | Thread.currentThread().getContextClassLoader()获取到的是系统类加载器
2.4 谈谈你对双亲委派机制的理解?
-
①. 如果一个类加载收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行
-
②. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器
-
③. 如果父类的加载器可以完成类的加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式
2.5 双亲委派机制优化和劣势?
优势:
- ①. 避免类的重复加载,确保一个类的全局唯一性(当父ClassLoader已经加载了该类的时候,就没有必要子ClassLoader再加载一次)
比如:我们如果是引导类加载器加载了,就没必要再一次使用扩展类加载器进行加载
- ②. 保护程序安全,防止核心API被随意篡改
劣势:
- ① 检查类是否加载的委托过程是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类)
2.6 沙箱机制听过吗?
比如自定义String类,但是在加载String类的时候会使用引导类加载器进行加载,而引导类加载器在加载过程中会先加载jdk自带的文件(rt.jar包中的java\lang\String.class),报错信息说没有main方法就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制在一定程度上可以保护程序安全,保护原生的JDK代码
3 谈谈你对程序计数器的理解
-
①. 作用:是用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令
-
②. 是线程私有的 、不会存在内存溢出(唯一一个运行时数据区没有OOM的区域)
-
③. 如果执行的是一个Native方法,那这个计数器是undefined
3.1 为什么使用PC寄存器记录当前线程的执行地址呢?
- ①. 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
- ②. JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
4 说说你对本地方法栈的理解
-
①.本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java诞生的时候是C/C++横行的时候,要想立足,必须由调用C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载native libraies
-
②. native方法的举例:Object类中的clone wait notify hashCode等Unsafe类都是native方法
5 什么虚拟机栈?
①.虚拟机栈(Java Virtual Machine Stacks)线程是紧密联系的,每创建一个线程时就会对应创建一个Java栈, 所以Java栈也是"线程私有"的内存区域,这个栈中又会对应包含多个栈帧,每调用一个方法时就会往栈中创建并压入一个栈帧,栈帧是用来存储方法数据和部分过程结果的数据结构,每一个方法从调用到最终返回结果的过程,就对应一个栈帧从入栈到出栈的过程 [先进后出]
虚拟机栈包括:局部变量表、操作数栈、动态链接、方法的返回地址、附件信息
5.1 栈里面存在GC吗?
- 栈中是不存在GC的,存在OOM和StackOverflowError
5.2 静态变量和局部变量的对比?
-
①. 我们知道类变量表有两次初始化的机会,第一次是在"准备阶段",执行系统初始化,对类变量设置为零值,另一次则是在"初始化"阶段,赋予程序员在代码中定义的初始值
-
②. 和类变量初始化不同的是,局部变量表不存在初始化的过程,这意味着一旦定义了局部变量则必须认为初始化
5.3 调整栈大小,就能保证不出现溢出吗?
- 不能。因为调整栈大小,只会减少出现溢出的可能,栈大小不是可以无限扩大的,所以不能保证不出现溢出
5.4 分配的栈内存越大越好吗?
- 不是,因为增加栈大小,会造成每个线程的栈都变的很大,使得一定的栈空间下,能创建的线程数量会变小
5.5 垃圾回收是否会涉及到虚拟机栈?
-
①. 不会;垃圾回收只会涉及到方法区和堆中,方法区和堆也会存在溢出的可能
-
②. 程序计数器,只记录运行下一行的地址,不存在溢出和垃圾回收
-
③. 虚拟机栈和本地方法栈,都是只涉及压栈和出栈,可能存在栈溢出,不存在垃圾回收
5.6 方法中定义的局部变量是否线程安全?
- 如果局部变量在内部产生并在内部消亡的,那就是线程安全的
5.7 什么情况下会发生栈内存溢出?
-
①. 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出
-
②. 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出
5.8 说说堆和栈的区别?
-
①.从GC、OOM、StackOverflowError的角度
[栈中不存在GC,当固定大小的栈会发生StackOverflowError,动态的会发生OOM。堆中GC、OOM、StackOverflowError都存在]
-
②. 从堆栈的执行效率[栈的效率高于堆]
-
③. 内存大小,数据结构
[堆的空间比栈的大一般,栈是一种FIFO先进后出的模型。堆中结构复杂,可以有链表、数组等]
-
④. 栈管运行,堆管存储
6 方法区中都存放什么东西?
- 类型信息、常量、静态变量、即时编译器编译后的代码缓存
6.1 方法区是否也会溢出?
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误
- 比如:
①. 加载大量的第三方的jar包
②. tomcat部署的工程过多(30-50个)
③. 大量动态的生成反射类
6.2 谈谈你对方法区中字符串常量池、静态变量的变化?
- ①. Jdk 1.6 及之前: 有
永久代,静态变量、字符串常量池1.6在方法区
- ②. Jdk 1.7 : 有永久代,但已经逐步 " 去永久代 ",
字符串常量池、静态变量移除,保存在堆中
- ③. jdk 1.8 及之后:
无永久代变为元空间
。但静态变量、字符串常量池仍在堆中
6.3 为什么要用元空间取代永久代
- ①.永久代参数设置过小,在某些场景下,如果动态加载的类过多,容易产生Perm区的OOM,比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误
- ②.永久代参数设置过大,导致空间浪费
- ③. 默认情况下,元空间的大小受本地内存限制)
- ④. 对永久代进行调优是很困难的
[方法区的垃圾收集主要回收两部分:常量池中废弃的常量和不再使用的类型,而不再使用的类或类的加载器回收比较复杂,full gc 的时间长]
6.4 StringTable为什么要调整?
- ①.jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才能触发。而full gc是老年代的空间不足、永久代不足才会触发
- ②. 这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆里,能及时回收内存
6.5 对象内部结构都有什么?
对象头、实例数据、对齐填充
(保证8个字节的倍数)
对象头里面有什么?
- ①. 对象标记Mark Word(哈希值(HashCode )、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳)
- ②. 类元信息
7 谈谈你对执行引擎的理解?
- ①.
执行引擎的任务就是将字节码指令解释/编译,生成对应平台上可执行的机器码
- ②. 解释器(负责响应时间): 当Java虚拟机启动时会根据预定义的规范对字节码采用
逐行解释的方式执行
,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行
- ③. JIT(负责性能) (Just In Time Compiler)编译器(即时编译器): 就是虚拟机将源代码
直接编译成和本地机器平台相关的机器语言
JIT是基于计数器的热点探测技术将热点代码进行缓存, 主要分为基于: 方法调用计数器用于统计方法的调用次数; 回边计数器则用于统计循环体执行的循环次数
7.1 HotSpotVM中 JIT 分类?
-
JIT的编译器还分为了两种,分别是C1和C2,在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler
,但大多数情况下我们简称为C1编译器 和 C2编译器
。
-
C2编译器启动时长比C1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器。
8 简单谈谈你对堆的理解?(共享|垃圾回收)
-
①. Java堆区在JVM启动的时候即被创建,其空间大小也是确定的。是JVM管理最大的一块内存空间
-
②. 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
-
③. 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才有被移除
(注意:一个进程就是一个JVM实例,一个进程中包含多个线程)
-
④. 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)
8.1 堆里面的分区
-
①. 在JDK1.7中分为: 新生代+老年代+永久代
| 在JDK1.8中分为: 新生代+老年代+元空间
-
②. 新生代:伊甸园区、幸存者S0、S1(8:1:1)
,几乎所有的Java对象都是在Eden区被new出来的,绝大部分的Java对象的销毁都在新生代进行了;IBM公司的专门研究表明,新生代中 80%
的对象都是"朝生夕死"的
老年代:另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
(新生代:老年代=1:2
)
8.2 堆大小通过什么参数设置?
-
①. -Xms
:初始内存(默认为物理内存的1/64
)
-
②. -Xmx
:最大内存(默认为物理内存的1/4
)
-
③. -XX:NewRatio=2
-XX:SurvivorRatio
-XX:HandlePromotionFailure:空间分配担保
-Xmn:设置新生代最大内存大小,一般使用默认值就可以了
8.3 初始堆大小和最大堆大小一样,问这样有什么好处?
- 通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后
不需要重新分隔计算堆区的大小
,从而提升性能
8.4 Minor GC | Major GC | Full GC
-
①. Minor GC 在Eden伊甸园区满的时候会触发,发生在新生代中
-
②. Major GC 在老年代中满了会进行触发,发生在老年代
,major gc的时间比minor gc时间长
-
③. Full GC 发生在整个堆中
8.5 什么是垃圾?
- 只要对象不再被使用了,那我们就认为该对象就是垃圾,对象所占用的空间就可以被回收
8.6 什么是GC?为什么要有GC?
-
①. 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
-
②. 如果不进行垃圾回收,垃圾会占据内存,可能会导致OOM现象
8.7 JVM垃圾回收常用算法?
- 标记阶段:
引用计数法
- ①. 原理: 假设有一个对象A, 任何一个对象对A的引用, 那么对象A的引用计数器+1, 当引用失败时, 对象A的引用计数器就-1, 如果对象A的计数器的值为0, 就说明对象A没有引用了, 可以被回收
- ②. 最大的缺陷:
无法解决循环引用
的问题, gc永远都清除不了(这也是引用计数法被淘汰的原因)
- ③. Java使用的不是引用计数法(Java之所以没有使用引用计数法, 是由于不能解决循环引用问题) | (Python使用了是引用计数法)
2. 标记阶段:可达性分析算法
● 可达性分析算法是以根对象集合
(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
● 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
● 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
● 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
3. 清除阶段:复制算法
- 优点:
①.没有标记和清除过程,实现简单,运行高效
②. 不会产生内存碎片,且对象完整不丢
- 缺点:
①. 浪费了10%的空间
②. 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小
。
注意:复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。因为新生代中的对象一般都是朝生夕死的,在新生代中使用复制算法是非常好的
注意:是当伊甸园区满后,会触发minjor gc,进行垃圾的回收
-
清除阶段:标记清除
-
清除阶段:标记整理
8.8 你对枚举根节点做可达性分析了解吗?
- ①. 基本思路是通过一系列名为"GC Roots"的对象(集合)作为起点,从这个被称为GC ROOTs 的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象是不可达对象(被回收),否则就是可达对象
8.9 什么是内存溢出?
-
①. javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存
-
②. 说明Java虚拟机的堆内存不够
。原因有二
Java虚拟机的堆内存设置不够
(比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数一Xms、一Xmx来调整)
代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
-
③. 这里面隐含着一层意思是,在抛出0utOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
导致内存溢出的列子:
- 误用线程池导致的内存溢出
- 查询数据量太大导致的内存溢出
- 动态生成类导致的内存溢出
8.10 JVM内存结构中,那些部分会出现内存溢出?
不会出现内存溢出的区域 是程序计数器
- 出现OutOfMemoryError 的情况:
① 堆内存耗尽
,对象越来越多,又一直在使用,不能被垃圾回收
② 方法区内存耗尽
, 加载的类越来越多,很多框架都会在运行期间动态产生新的类
③ 虚拟机栈积累
,每个线程最多会占用1M内存,线程个数越来越多,而又长时间运行不销毁时
- 出现StackOverflowError的区域:
① 虚拟机内部
,方法调用次数过多
8.11 什么是内存泄漏?
- ①. 也称作“存储渗漏”。严格来说,
只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏
- ②. 但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏
- ③. 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现0utOfMemory异常,导致程序崩溃。
比如:
- 单例模式
单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
- 一些提供close的资源未关闭导致内存泄漏
数据库连接
(dataSourse.getConnection() ),网络连接
(socket)和io
连接必须手动close,否则是不能被回收的。
8.12 强引用、软引用、弱引用、虚引用的区别?
-
①. 强引用:不回收
-
②. 软引用: 内存不足即回收
-
③. 弱引用: 发现即回收
-
④. 虚引用: 对象回收跟踪
8.13 吞吐量优先选择什么垃圾回收器?响应时间优先呢?
8.14 谈谈你对JVM中垃圾收集器的理解?
-
①. 不同的厂商会考虑使用不同的JVM,不同的JVM会使用不同的垃圾收集器,下面我介绍下主流的垃圾收集器有哪些(主流的7种),下面你就可以展开去说明七种垃圾收集器的每一个细节
-
②. 截止JDK 1.8,一共有7款不同的垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器
-
③. 同厂商、不同版本的虚拟机实现差别很大。HotSpot 虚拟机在JDK7/8后所有收集器及组合(连线),如下图:
8.15 讲一下CMS垃圾收集器垃圾回收的流程
-
①. 初始标记
(Initial一Mark)仅仅只是标记出和GCRoots能直接关联到的对象,有STW现象、暂时时间非常短
-
②. 并发标记
(Concurrent一Mark)阶段: 从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行(并发标记阶段有三色标记
,下文有记录)
-
③. 重新标记
(Remark) 阶段:有些对象可能开始是垃圾,在并发标记阶段,由于用户线程的影响,导致不是垃圾了,这里需要重新标记的是这部分对象,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
-
④. 并发清除
:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
-
⑤. 补充说明:
- 在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial 0ld收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
CMS收集器的垃圾收集算法采用的是标记一清除算法
,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。 那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配。
(在并发标记阶段一开始不是垃圾,最后变成了垃圾)
8.16 CMS优缺点
- ①. 优点:并发收集、低延迟
- ②. CMS的弊端:
- 会产生内存碎片
- CMS收集器对CPU资源非常敏感
(在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低)
- CMS收集器无法处理浮动垃圾。可能出现"Concurrent Mode Failure" 失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间
- 并发标记阶段,在遍历GCRoots,用户线程也在执行,若此时遍历过一个对象发现没有引用,但由于用户线程并发执行,这期间可能导致遍历过的这个对象又被其他对象引用,所以才需要重新标记阶段再遍历一次看又没有漏标记的,否则就会导致被重新引用的对象被清理掉
- 浮动垃圾:在并发标记阶段一开始不是垃圾,最后变成了垃圾(属于多标的情况)
8.17 CMS会出现漏标,怎么解决的?
- ①. 通过
增量更新和写屏障
的方式去解决
- ②. 在把我们新增的引用放到集合的时候,会实现一种写屏障的方式。在对象前后通过一个dirty card queue将引用信息, 存在card中,这个dirty card queue会放在cardtable中,而cardtable是记忆集的具体实现,最终这个引用就会放在记忆集中的
(写屏障我们可以理解为在赋值操作的前面加一个方法,赋值的后面做一些操作,也可以理解为AOP。具体的C++实现代码如下图:)
8.18 三色标志谈谈你的理解是什么样的?
- ①. 在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。这里我们引入“三色标记”来给大家解释下,把Gcroots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
- 黑色(black): 节点被遍历完成,而且子节点都遍历完成
- 灰色(gray): 当前正在遍历的节点,而且子节点还没有遍历
- 白色(white): 还没有遍历到的节点,即灰色节点的子节点
- ②. 根据三色扫描算法,如果有下面两种情况发生,则会出现漏扫描的场景:
- 把一个白对象的引用存到黑对象的字段里,如果这个情况发生,因为标记为黑色的对象认为是扫描完成的,不会再对他进行扫描。只能通过灰色的对象(CMS垃圾收集器)
(如上图中的D如果是白色对象没有引用,某一个时刻由于用户线程的影响,将A黑色对象 引用了D的情况,解决办法:使用写屏障和增量更新解决)
- 某个白对象失去了所有能从灰对象到达它的引用路径(直接或间接)(G1垃圾收集器)
(如上图中的B灰色对象某一个时刻由于用户线程的影响将B到D的引用置为null,解决办法:使用写屏障和原始快照)
- ③. 三色过程: 如下图所示,假如说A引入了B,B引用了C,D没有被任何引用。那么首先我们的CMS首先扫描到了A,发现A有引用B,那么我们的CMS会将A标记为黑色,B标记为灰色,然后这时候,通过B又找到了C那么这个时候发现C已经没有任何引用了就会将C标记为黑色。但是我们的D到目前为止没有被任何引用,记住我这里说的条件!那么D从始至终都没有被扫描,此时就会一直是白色,对于白色的对象来说CMS在执行并发清理的时候就会将此类对象干掉。
但是这里有了一个问题:如果我们的扫描过程已经结束这一段了,但是此时此刻我的A突然引用了D类型怎么办,这样一来我们的D只要被GC干掉是不是就会出现问题?也就是说我这里产生了一个漏标的问题。当然,我们的JVM开发人员可不是傻子,这里他们用了一个操作叫做增量更新和写屏障来解决这种问题的。
8.19 谈谈你对G1垃圾收集器的理解?
-
①. G1是一个并行回收器,它把堆内存分割为很多不相关的区域(region物理上不连续)
,把堆分为2048个区域,每一个region的大小是1 - 32M不等,必须是2的整数次幂。使用不同的region可以来表示Eden、幸存者0区、幸存者1区、老年代等
-
②. 每次根据允许的收集时间,优先回收价值最大的Region
(每次回收完以后都有一个空闲的region,在后台维护一个优先列表)
-
③. 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)
-
④. 下面说一个问题:既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First(G1)GC?
官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起"全功能收集器"的重任与期望
8.20 G1垃圾收集器的特点、缺点?
- 并行性: G1在回收期间,可以有多个Gc线程同时工作,有效利用多核计算能力。此时用户线程STW
- 并发性: G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
- 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
- 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
- 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代
-
③. 空间整合
(G1将内存划分为一个个的region。 内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记一压缩(Mark一Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显)
-
④. 可预测的停顿时间模型
(即:软实时soft real一time)
(这是 G1 相对于 CMS 的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒、可以通过参数-XX:MaxGCPauseMillis进行设置)
- 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制
- G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
- 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。
(CMS的最好的情况G1不一定比的上,但是CMS最差的部分,G1可以比上)
- 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
- 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间
8.21 谈谈你对G1中的Region的理解?
-
①. 使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB, 2MB, 4MB, 8MB, 1 6MB, 32MB。可以通过-XX:G1Hea pRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变
-
②. 一个region 有可能属于Eden, Survivor 或者0ld/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于Survivor内存区域,0表示属于0ld内存区域。图中空白的表示未使用的内存空间
-
③. 垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1. 5个region,就放到H
(对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待)
8.22 大致说说G1的回收过程?
- ①. G1 GC的垃圾回收过程主要包括如下三个环节:
年轻代GC (Young GC)
老年代并发标记过程
(Concurrent Marking)
混合回收
(Mixed GC)
- 顺时针,young gc -> young gc + concurrent mark-> Mixed GC顺序,进行垃圾回收。
-
②. 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程
;G1的年轻代收集阶段是一个并行(多个垃圾线程)的独占式收集器。在年轻代回收器,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及
-
③. 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程
-
④. 标记完成马上开始混合回收过程
。对于一个混合回收期, G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
-
⑤. 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收
8.23 G1的年轻代GC?
回收时机
(1). 当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程
(2). 年轻代垃圾回收只会回收Eden区和Survivor区
(3). 回收前:
(4). 回收后:
-
①. 根扫描: 一定要考虑remembered Set,看是否有老年代中的对象引用了新生代对象
(根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口)
-
②.更新RSet: 处理dirty card queue(见备注)中的card,更新RSet。 此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用
(dirty card queue: 对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty CardQueue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。那为什么不在引用赋值语句处直接更新RSet呢?这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多)
-
③. 处理RSet:识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象
-
④. 复制对象:复制算法
(此阶段,对象树被遍历,Eden区 内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到01d区中空的内存分段。如果Survivor空间不够,Eden空间的 部分数据会直接晋升到老年代空间)
-
⑤. 处理引用: 处理Soft,Weak, Phantom, Final, JNI Weak等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片
8.24 G1并发标记过程?
-
①. 初始标记阶段
: 标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC
-
②. 根区域扫描
(Root Region Scanning): G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在young GC之前完成(YoungGC时,会动Survivor区,所以这一过程必须在young GC之前完成)
-
③. 并发标记
(Concurrent Marking): 在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
-
④. 再次标记
(Remark): 由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot一at一the一beginning (SATB)
(在CMS中有详细讲解)
-
⑤. 独占清理
(cleanup,STW): 计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。(这个阶段并不会实际上去做垃圾的收集)
-
⑥. 并发清理阶段
: 识别并清理完全空闲的区域
8.25 G1混合回收 Mixed GC?
- ①. Mixed GC并不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
8.26 G1和CMS相比有哪些优势?
-
①. G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片
-
②. G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间
8.27 我们怎么去选择垃圾收集器?
-
①. 单CPU或者小内存,单机程序 -XX:+UseSerialGC
-
②. 多CPU,需要最大吞吐量,如后台计算型应用
-XX:+UseParallelGC 或者 -XX:+UseParallelOldGC
-
③. 多CPU,追求低停顿时间,需快速响应如互联网应用
-XX:+UseConcMarkSweepGC 或者 -XX:+ParNewGC
8.28 说说GC和分带回收算法?
- GC的目的在于实现无用对象内存自动释放,减少内存碎片、加快分配速度
- GC要点
① 回收区域是堆内存
,不包括虚拟机栈,在方法调用结束会自动释放方法占用内存
② 判断无用对象,使用可达性分析算法
,三色标记法
标记存活对象,回收未标记对象
③ GC具体的实现称为垃圾回收器
④ GC大都采用了分代回收思想
,理论依据是大部分对象朝生夕灭,用完立即就可以回收,另有少部分对象会长时间存活,每次很难回收,根据这两类对象的特性将回收区域分为新生代
和老年代
,不用区域应用不同的回收策略
⑤ 根据GC的规模可以分为Minor GC, Mixed GC, Full GC
总结
你是如何理解java是一门跨平台的语言的?也就是【一次编译,到处运行】?
聊聊从源码文件(.java)到代码执行的过程呗。
简单总结的话,我认为分为4步骤:编译->加载->解释->执行
编译:
-
将源码文件编译成JVM可以解释的class文件,编译过程中会对源码程序做【语法解析】、【语义分析】、【注解处理】等,最后才生成字节码文件。
-
比如对泛型的擦除和我们经常用Lombok就是在编译阶段干的。
加载:
-
就是将编译好的class文件加载到JVM中,其中加载阶段又可以细化为几个步骤:装载->连接->初始化
-
【装载时机】为了节省内存的开销,并不会一次性把所有的类都装载进JVM中,而是等有需要的时候才进行装载(比如new和反射等等)
-
【装载发生】class文件是通过【类加载器】装载到JVM中的,为了防止内存中出现同样多份的字节码,使用双亲委派机制(它不会自己去尝试加载这个类,而是把请求委托给父类加载器去完成,依次向上)
-
【装载规则】JDK中的本地方法类一般由根加载器(Bootstrp loader)装载,JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现装载,而程序中的类文件则由系统加载器(AppClassLoader )实现装载。
-
装载这个阶段它做的事情主要总结为:查找并加载类的二进制数据,在JVM「堆」中创建一个java.lang.Class类的对象,并将类相关的信息存储在JVM「方法区」中
-
通过【装载】这个步骤后,现在已经把class文件装载到JVM中,并创建出对应的Class对象以及类信息存储至方法区。
-
【连接】这个阶段它主要做的事情可以总结为:对class的信息进行验证、为【类变量】分配内存空间,并为其赋默认值。
-
【连接】有可以细化为几个步骤:验证->准备->解析
-
① 验证:验证类是否符合Java规范和JVM规范
-
② 准备:为类的静态变量分配内存,并初始化为系统的初始值
-
③ 解析:讲符号引用转为直接引用的过程
-
通过【连接】这个步骤之后,现在已经对class信息做了校验并分配了内存空间和默认值了。
-
接下来就是【初始化】阶段了,这个阶段可以总结为:为类的变量和静态变量赋予正确的初始值。
-
过程大概就是收集class的静态变量、静态代码块、静态方法至,随后从上往下开始执行。
-
如果【实例化对象】则会调用构造方法对实例变量进行初始化,并执行构造方法内的代码。
解释:
-
初始化完成之后,当我们尝试执行一个类的方法时,会找到对应方法的字节码的信息,然后解释器会把字节码的信息解释成系统能够识别的指令码。
-
【解释】这个阶段它做的事可以理解为:把字节码转换为操作系统能够识别的指令。
-
在解释阶段会有两种方式把字节码解释成机器指令码,一个是字节码解释器、一个是即时编译器(JIT)
-
JVM会对【热点代码】做编译,非热点代码直接进行解释。当JVM发现某个方法或代码块的运行特别频繁的时候,就有可能把这部分代码认定为「热点代码」
-
使用「热点探测」来检测是否为热点代码。「热点探测」一般有两种方式,计数器和抽样。HotSpot使用的是「计数器」的方式进行探测,为每个方法准备了两类计数器:方法调用计数器和回边计数器
-
这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
-
即时编译器把热点方法的指令码保存起来,下次执行的时候就无需重复的进行解释,直接执行缓存的机器语言
执行:
- 【执行】这个阶段它做的事情可以总结为:操作系统把解释器解析出来的指令码,调用系统的硬件执行最终的程序指令。
你知道双亲委派机制吗?
- 为了防止内存中存在多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载类,而是把请求委托给父加载器去完成,依次向上)
- JDK 中的本地方法类一般由根加载器(Bootstrp loader)装载,JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现装载,而程序中的类文件则由系统加载器(AppClassLoader )实现装载。
你对JVM的内存结构了解吗?你来讲一下吧?
JVM的内存结构,主要指的是JVM中的运行时数据区,它主要分为五个部分:方法区、堆、程序计数器、虚拟机栈、本地方法栈。
我就先从程序计数器
开始讲起吧:
- Java是有多线程的,假设目前线程数大于CPU数,就很可能有【线程切换】的现象,切换意味着【中断】和【恢复】,那自然就需要有一块区域来保存【当前的线程的执行信息】
- 所以,程序计数器就是用于记录各个线程执行的字节码的地址(分支、循环、跳转、异常、线程恢复等都依赖于计数器)
那接下来讲讲虚拟机栈
吧:
到方法区
了:
- 在JDK8中,已经用【元空间】替代了【永久代】作为【方法区】的实现了。
- 方法区主要用来存放已被虚拟机加载的【类相关信息】:包括类信息、常量池。
- 类信息又包含了类的版本、字段、方法、接口和父类等信息。
- 常量池有可以分为【静态常量池】和【运行时常量池】
- 静态常量池主要存储的是【字面量】以及【符号引用】等信息,静态常量池也包括了我们说的【字符串常量池】
- 【运行时常量池】存储的是【类加载】时生成的【直接引用】等信息
- 又值得注意的是:从【逻辑分区】的角度而言【常量池】是属于【方法区】的
- 但自从JDK7以后,就已经把【静态常量池】和【运行时常量池】转移到了【堆】内存进行存储。
那来看最后一个堆
吧:
- 【堆】是线程共享区域,几乎类的实例和数组分配的内存都来自于它。
- 【堆】被划分为【新生代】和【老年代】,【新生代】又被进一步划分为Eden和Survivor ,最后 Survivor 由 From Survivor 和 To Survivor 组成。
我想问下,你说从「JDK8」已经把「方法区」的实现从「永久代」变成「元空间」,有什么区别?
- 主要的区别就是:【元空间】存储不在虚拟机中,而是使用【本地内存】,JVM不会在出现方法区的内存溢出,以往【永久代】经常因为内存不够而抛出OOM异常。
- 按照JDK8版本,总结起来就相当于:【类信息】是存储在【元空间】的,而【常量池】从JDK7开始就一直在【堆】中,这是没有变化的。
Java是垃圾回收中,你认为那些是垃圾?
只要对象不在被使用,那我就认为该对象是垃圾,空间所占用的空间就会被回收。
那你是怎么判断对象不在被使用的呢?
常用的算法有【引用计数法】和【可达性分析法】
引用计数法:
可达性分析法:
- 它从【GC Roots】开始向下搜索,当【对象】到【GC Roots】都没有任何引用相连时,说明对象是不可用的,可以被回收。
- 它从【GC Roots】开始向下搜索,当【对象】到【GC Roots】都没有任何引用相连时,说明对象是不可用的,可以被回收。
- 【GC Roots】是一组必须【活跃】的引用。从【GC Roots】出发,程序使用直接引用,或者间接引用,程序能够找到正在被使用的对象,只要跟「GC Roots」没有直接或者间接引用相连,那就是垃圾。
- JVM用的就是【可达性分析算法】来判断对象是否是垃圾
- 垃圾回收的第一步就是【标记】,标记那些没有被【GC Roots】引用的对象。
- 标记完之后,我们就可以选择直接「清除」,只要不被「GC Roots」关联的,都可以干掉
- 但是也很明显会存在问题,直接清除会有【内存碎片】的问题:可能我有10M的空余内存,但程序申请9M内存空间却申请不下来(10M的内存空间是垃圾清除后的,不连续的)【其实就是标记清除】
- 那如何解决这个内存碎片问题呢?那就是把【标记】存活的对象【复制】到另一块空间,复制完之后,直接把原有的块干掉,这样就没有内存碎片的问题了。【其实就是标记复制】
- 这种做法缺点又很明显:内存利用率低,得有一块新的区域给我复制(移动)过去
- 还有一种「折中」的办法,我未必要有一块「大的完整空间」才能解决内存碎片的问题,我只要能在「当前区域」内进行移动
- 把存活的对象移到一边,把垃圾移到一边,那再将垃圾一起删除掉,不就没有内存碎片了嘛
- 这种专业的术语就叫做「整理」【其实就是标记整理】
那你说一下什么是【GC Roots】呢?
- 在我们的JVM中不是有虚拟机栈吗?那虚拟机栈是不是有栈帧?栈帧是不是有局部变量?局部变量不就存在着引用嘛。
- 那么如果栈帧位于虚拟机的栈顶,是不是就可以说明这个栈帧是活跃的(换言之,是线程正在被调用)
- 既然是线程正在被调用,那栈帧里指向【堆】的对象引用,是不是一定是【活跃】的引用?所以,当前活跃的栈帧指向堆里的对象引用就可以是【GC Roots】
- 当然了,能作为「GC Roots」也不单单只有上面那一小块
- 比如类的静态变量引用是「GC Roots」,被「Java本地方法」所引用的对象也是「GC Roots」等等…
那么为什么垃圾要进行分代回收呢?
- 因为大部分对象的生命周期都很短,只有小部分对象会存活很长时间。
- 并且【垃圾回收】是会导致「stop the world」(应用停止访问),就是回收垃圾的时候,程序是不能正常运作的。
- 为了使「stop the world」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率,对会把对象进行区分,死得快的对象所占的区域叫做「年轻代」,活得久的对象所占的区域叫做「老年代」
- 但也不是所有的「垃圾收集器」都会有,只不过我们现在线上用的可能都是JDK8,JDK8及以下所使用到的垃圾收集器都是有「分代」概念的。
- 值得注意的是,高版本所使用的垃圾收集器的ZGC是没有分代的概念的
那JDK8及一下的垃圾收集器有哪些?
- 「年轻代」的垃圾收集器有:Serial、Parallel Scavenge、ParNew
- 「老年代」的垃圾收集器有:Serial Old、Parallel Old、CMS
- 「年轻代」「老年代」垃圾收集器:G1
- 看着垃圾收集器有很多,其实还是非常好理解的。Serial是单线程的,Parallel是多线程
- 这些垃圾收集器实际上就是「实现了」垃圾回收算法(标记复制、标记整理以及标记清除算法)
- 这些垃圾收集器实际上就是「实现了」垃圾回收算法(标记复制、标记整理以及标记清除算法)
- 又可以发现的是,「年轻代」的垃圾收集器使用的都是「标记复制算法」
- 所以在「堆内存」划分中,将年轻代划分出Survivor区(Survivor From 和Survivor To),目的就是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动)
- 而新的对象则放入Eden区
年轻代占堆内存1/3,老年代占堆内存2/3。Eden区占年轻代8/10,Survivor区占年轻代2/10(其中From 和To 各站1/10)
那么新创建的对象在是放在【新生代】,那什么时候会到【老年代】呢?
我认为可以分为两种情况:
- 如果对象太大了,就会直接进入老年代(对象创建时就很大 || Survivor区没办法存下该对象)
- 如果对象太老了,那就会晋升至老年代(每发生一次Minor GC ,存活的对象年龄+1,达到默认值15则晋升老年代 || 动态对象年龄判定 可以进入老年代)
既然你又提到了Minor GC,那Minor GC 什么时候会触发呢?
当Eden区空间不足时,就会触发Minor GC
那在「年轻代」GC的时候,从GC Roots出发,那不也会扫描到「老年代」的对象吗?那不就相当于全堆扫描吗?
- HotSpot 虚拟机「老的GC」(G1以下)是要求整个GC堆在连续的地址空间上。
- 所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上
- 当做Minor GC的时候,从GC Roots出发,如果发现「老年代」的对象,那就不往下走了(Minor GC对老年代的区域毫无兴趣)
但又有个问题,那如果「年轻代」的对象被「老年代」引用了呢?(老年代对象持有年轻代对象的引用),那时候肯定是不能回收掉「年轻代」的对象的。
-
HotSpot虚拟机下 有「card table」(卡表)来避免全局扫描「老年代」对象
-
「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」
-
那知道了「卡表」之后,就很好办了。每次Minor GC 的时候只需要去「卡表」找到「脏页」,找到后加入至GC Root,而不用去遍历整个「老年代」的对象了。
简单聊聊CMS垃圾收集器?
-
如果用Seria和Parallel系列的垃圾收集器:在垃圾回收的时,用户线程都会完全停止,直至垃圾回收结束
-
CMS的全称:Concurrent Mark Sweep,翻译过来是「并发标记清除」
-
用CMS对比上面的垃圾收集器(Seria和Parallel和parNew):它最大的不同点就是「并发」:在GC线程工作的时候,用户线程「不会完全停止」,用户线程在「部分场景下」与GC线程一起并发执行。
-
但是,要理解的是,无论是什么垃圾收集器,Stop The World是一定无法避免的!
-
CMS只是在「部分」的GC场景下可以让GC线程与用户线程并发执行
-
CMS的设计目标是为了避免「老年代 GC」出现「长时间」的卡顿(Stop The World)
那你清楚CMS的工作流程吗?
CMS可以简单分为五个步骤:初始标记、并发标记、并发预处理、重新标记以及并发清除
。
从步骤就不难看出,CMS主要是实现了「标记清除」垃圾回收算法
我就从「初始标记」来开始吧:
- 「初始标记」会标记GCRoots「直接关联」的对象以及「年轻代」指向「老年代」的对象
- 「初始标记」这个过程是会发生Stop The World的。但这个阶段的速度算是很快的,因为没有「向下追溯」(只标记一层)
在「初始标记」完了之后,就进入了「并发标记」阶段啦:
- 「并发标记」这个过程是不会停止用户线程的(不会发生 Stop The World)。这一阶段主要是从GC Roots向下「追溯」,标记所有可达的对象。
- 「并发标记」在GC的角度而言,是比较耗费时间的(需要追溯)
「并发标记」这个阶段完成之后,就到了「并发预处理」阶段啦:
- 「并发预处理」这个阶段主要想干的事情:希望能减少下一个阶段「重新标记」所消耗的时间,因为下一个阶段「重新标记」是需要Stop The World的
- 「并发标记」这个阶段由于用户线程是没有被挂起的,所以对象是有可能发生变化的
- 可能有些对象,从新生代晋升到了老年代。可能有些对象,直接分配到了老年代(大对象)。可能老年代或者新生代的对象引用发生了变化
那这个问题如何解决呢?
- 针对老年代的对象,其实可以借助类card table的存储(将老年代对象发生变化所对应的卡页标记为dirty)
- 所以【并发预处理】这个阶段会扫描可能由于【并发标记】时导致老年代发生变化的对象,会再扫描一遍标记为dirty的卡页
- 对于新生代的对象,我们还是得遍历新生代来看看在「并发标记」过程中有没有对象引用了老年代
- 不过JVM里给我们提供了很多「参数」,有可能在这个过程中会触发一次 minor GC(触发了minor GC 是意味着就可以更少地遍历新生代的对象)
「并发预处理」这个阶段阶段结束后,就到了「重新标记」阶段:
- 「重新标记」阶段会Stop The World,这个过程的停顿时间其实很大程度上取决于上面「并发预处理」阶段(可以发现,这是一个追赶的过程:一边在标记存活对象,一边用户线程在执行产生垃圾)
最后就是**「并发清除」**阶段,不会Stop The World:
- 一边用户线程在执行,一边GC线程在回收不可达的对象
- 这个过程,还是有可能用户线程在不断产生垃圾,但只能留到下一次GC 进行处理了,产生的这些垃圾被叫做“浮动垃圾”
- 完了以后会重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备
我看现在很多企业都在用G1了,那你觉得CMS有什么缺点呢?
1.空间需要预留
2.内存碎片问题
- CMS本质上是实现了「标记清除算法」的收集器(从过程就可以看得出),这会意味着会产生内存碎片
- 由于碎片太多,又可能会导致内存空间不足所触发full GC,CMS一般会在触发full GC这个过程对碎片进行整理
- 整理涉及到「移动」/「标记」,那这个过程肯定会Stop The World的,如果内存足够大(意味着可能装载的对象足够多),那这个过程卡顿也是需要一定的时间的。
你对G1垃圾收集器了解吗?我们来简单聊一下呗?
- G1 垃圾收集器可以给你设定一个你希望Stop The Word 停顿时间,G1垃圾收集器会根据这个时间尽量满足你
- 在G1垃圾收集器的世界上,堆的划分不再是「物理」形式,而是以「逻辑」的形式进行划分,不过,像之前说过的「分代」概念在G1垃圾收集器的世界还是一样奏效的,比如说:新对象一般会分配到Eden区、经过默认15次的Minor GC新生代的对象如果还存活,会移交到老年代等等
- 堆被划分了多个同等份的区域,在G1里每个区域叫做Region。老年代、新生代、Survivor这些应该就不用我多说了吧?规则是跟CMS一样的
- G1中,还有一种叫 Humongous(大对象)区域,其实就是用来存储特别大的对象(大于Region内存的一半)
- 一旦发现没有引用指向大对象,就可直接在年轻代的Minor GC中被回收掉
- 其实稍微想一下,也能理解为什么要将「堆空间」进行「细分」多个小的区域,像以前的垃圾收集器都是对堆进行「物理」划分
- 如果堆空间(内存)大的时候,每次进行「垃圾回收」都需要对一整块大的区域进行回收,那收集的时间是不好控制的
- 而划分多个小区域之后,那对这些「小区域」回收就容易控制它的「收集时间」了
那你讲讲G1的GC过程呗?
在G1收集器中,可以主要分为有Minor GC(Young GC)和Mixed GC,也有些特殊场景可能会发生Full GC
那我就直接说Minor GC先咯:
- 当Eden区满了之后,会触发Minor GC,Minor GC同样也是会发生Stop The World
- 要补充说明的是:在G1的世界里,新生代和老年代所占堆的空间是没那么固定的(会动态根据「最大停顿时间」进行调整)
- Minor GC我认为可以简单分为为三个步骤:
根扫描、更新&&处理 RSet、复制对象
- 第一步应该很好理解,因为这跟之前CMS是类似的,可以理解为初始标记的过程
那么在这第二步之前先讲一个RSet的概念?那什么是RSet呢?
- 那比如我年轻代需要垃圾回收,那么如果年轻代的对象被老年代所引用呢?CMS是通过卡表来解决这个问题,而G1解决「跨代引用」的问题的存储一般叫做RSet
- 只要记住,RSet这种存储在每个Region都会有,它记录着「其他Region引用了当前Region的对象关系」
- 对于年轻代的Region,它的RSet 只保存了来自老年代的引用(因为年轻代的没必要存储啊,自己都要做Minor GC了)
- 而对于老年代的 Region 来说,它的 RSet 也只会保存老年代对它的引用(在G1垃圾收集器,老年代回收之前,都会先对年轻代进行回收,所以没必要保存年轻代的引用)
- 在Minor GC 的最后,会处理下软引用、弱引用、JNI Weak等引用,结束收集
那我们来说一下Mixed GC 过程呗:
- 当堆空间的占用率达到一定阈值后会触发Mixed GC(默认45%,由参数决定)
- Mixed GC 依赖「全局并发标记」统计后的Region数据
- 「全局并发标记」它的过程跟CMS非常类型,步骤大概是:
初始标记(STW)、并发标记、最终标记(STW)以及清理(STW)
- Mixed GC它一定会回收年轻代,并会采集部分老年代的Region进行回收的,所以它是一个“混合”GC。
- 初始标记:
- 首先是「初始标记」,这个过程是「共用」了Minor GC的 Stop The World(Mixed GC 一定会发生 Minor GC),复用了「扫描GC Roots」的操作。
- 在这个过程中,老年代和新生代都会扫。总的来说,「初始标记」这个过程还是比较快的,毕竟没有追溯遍历嘛
- 并发标记
- 接下来就到了「并发标记」,这个阶段不会Stop The World
- GC线程与用户线程一起执行,GC线程负责收集各个 Region 的存活对象信息
- 从GC Roots往下追溯,查找整个堆存活的对象,比较耗时