JVM 面试题,安排上了!!!
Posted 程序员cxuan
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM 面试题,安排上了!!!相关的知识,希望对你有一定的参考价值。
肝了一篇非常硬核的 JVM 基础总结,写作不易,小伙伴们赶紧点赞、转发安排起来!
原文链接 据说看完这篇 JVM 要一小时
JVM 的主要作用是什么?
JVM 就是 Java Virtual Machine(Java虚拟机)的缩写,JVM 屏蔽了与具体操作系统平台相关的信息,使 Java 程序只需生成在 Java 虚拟机上运行的目标代码 (字节码),就可以在不同的平台上运行。
请你描述一下 Java 的内存区域?
JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个不同的区域,这些组成部分有些是线程私有的,有些则是线程共享的,Java 内存区域也叫做运行时数据区,它的具体划分如下:
虚拟机栈
: Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈中创建一个栈帧(stack frame)
。每个方法执行的过程就对应了一个入栈和出栈的过程。
-
本地方法栈
: 本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用native
关键字修饰的方法所存储的区域。 -
程序计数器
:程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。 -
方法区
:方法区是各个线程共享的内存区域,它用于存储虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。 -
堆
:堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上。JDK 1.7后,字符串常量池从永久代中剥离出来,存放在堆中。堆空间的内存分配(默认情况下):
- 老年代 : 三分之二的堆空间
- 年轻代 : 三分之一的堆空间
- eden 区: 8/10 的年轻代空间
- survivor 0 : 1/10 的年轻代空间
- survivor 1 : 1/10 的年轻代空间
命令行上执行如下命令,会查看默认的 JVM 参数。
java -XX:+PrintFlagsFinal -version
输出的内容非常多,但是只有两行能够反映出上面的内存分配结果
-
运行时常量池
:运行时常量池又被称为Runtime Constant Pool
,这块区域是方法区的一部分,它的名字非常有意思,通常被称为非堆
。它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。
请你描述一下 Java 中的类加载机制?
Java 虚拟机负责把描述类的数据从 Class 文件加载到系统内存中,并对类的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称之为 Java 的类加载机制
。
一个类从被加载到虚拟机内存开始,到卸载出内存为止,一共会经历下面这些过程。
类加载机制一共有五个步骤,分别是加载、链接、初始化、使用和卸载阶段,这五个阶段的顺序是确定的。
其中链接阶段会细分成三个阶段,分别是验证、准备、解析阶段,这三个阶段的顺序是不确定的,这三个阶段通常交互进行。解析阶段通常会在初始化之后再开始,这是为了支持 Java 语言的运行时绑定特性(也被称为动态绑定
)。
下面我们就来聊一下这几个过程。
加载
关于什么时候开始加载这个过程,《Java 虚拟机规范》并没有强制约束,所以这一点我们可以自由实现。加载是整个类加载过程的第一个阶段,在这个阶段,Java 虚拟机需要完成三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流表示的一种存储结构转换为运行时数据区中方法区的数据结构。
- 在内存中生成一个 Class 对象,这个对象就代表了这个数据结构的访问入口。
《Java 虚拟机规范》并未规定全限定名是如何获取的,所以现在业界有很多获取全限定名的方式:
- 从 ZIP 包中读取,最终会改变为 JAR、EAR、WAR 格式。
- 从网络中获取,最常见的应用就是 Web Applet。
- 运行时动态生成,使用最多的就是动态代理技术。
- 由其他文件生成,比如 JSP 应用场景,由 JSP 文件生成对应的 Class 文件。
- 从数据库中读取,这种场景就比较小了。
- 可以从加密文件中获取,这是典型的防止 Class 文件被反编译的保护措施。
加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。程序员可以通过自己定义类加载器来控制字节流的访问方式。
数组的加载不需要通过类加载器来创建,它是直接在内存中分配,但是数组的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。
验证
加载过后的下一个阶段就是验证,因为我们上一步讲到在内存中生成了一个 Class 对象,这个对象是访问其代表数据结构的入口,所以这一步验证的工作就是确保 Class 文件的字节流中的内容符合《Java 虚拟机规范》中的要求,保证这些信息被当作代码运行后,它不会威胁到虚拟机的安全。
验证阶段主要分为四个阶段的检验:
- 文件格式验证。
- 元数据验证。
- 字节码验证。
- 符号引用验证。
文件格式验证
这一阶段可能会包含下面这些验证点:
- 魔数是否以
0xCAFEBABE
开头。 - 主、次版本号是否在当前 Java 虚拟机接受范围之内。
- 常亮池的常量中是否有不支持的常量类型。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据。
- Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。
实际上验证点远远不止有这些,上面这些只是从 HotSpot 源码中摘抄的一小段内容。
元数据验证
这一阶段主要是对字节码描述的信息进行语义分析,以确保描述的信息符合《Java 语言规范》,验证点包括
- 验证的类是否有父类(除了 Object 类之外,所有的类都应该有父类)。
- 要验证类的父类是否继承了不允许继承的类。
- 如果这个类不是抽象类,那么这个类是否实现了父类或者接口中要求的所有方法。
- 是否覆盖了 final 字段,是否出现了不符合规定的重载等。
需要记住这一阶段只是对《Java 语言规范》的验证。
字节码验证
字节码验证阶段是最复杂的一个阶段,这个阶段主要是确定程序语意是否合法、是否是符合逻辑的。这个阶段主要是对类的方法体(Class 文件中的 Code 属性)进行校验分析。这部分验证包括
- 确保操作数栈的数据类型和实际执行时的数据类型是否一致。
- 保证任何跳转指令不会跳出到方法体外的字节码指令上。
- 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是不能把父类数据类型赋值给子类等诸如此不安全的类型转换。
- 其他验证。
如果没有通过字节码验证,就说明验证出问题。但是不一定通过了字节码验证,就能保证程序是安全的。
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化将在连接的第三个阶段,即解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,这个验证主要包括
- 符号引用中的字符串全限定名是否能找到对应的类。
- 指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
- 符号引用的类、字段方法的可访问性是否可被当前类所访问。
- 其他验证。
这一阶段主要是确保解析行为能否正常执行,如果无法通过符号引用验证,就会出现类似 IllegalAccessError
、NoSuchFieldError
、NoSuchMethodError
等错误。
验证阶段对于虚拟机来说非常重要,如果能通过验证,就说明你的程序在运行时不会产生任何影响。
准备
准备阶段是为类中的变量分配内存并设置其初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,在 JDK 7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的。而在 JDK 8 之后,变量则会随着 Class 对象一起存放在 Java 堆中。
下面通常情况下的基本类型和引用类型的初始值
除了"通常情况"下,还有一些"例外情况",如果类字段属性中存在 ConstantValue
属性,那就这个变量值在初始阶段就会初始化为 ConstantValue 属性所指定的初始值,比如
public static final int value = "666";
编译时就会把 value 的值设置为 666。
解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用
:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。直接引用
:直接引用可以直接指向目标的指针、相对便宜量或者一个能间接定位到目标的句柄。直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。
这样说你可能还有点不明白,我再换一种说法:
在编译的时候一个每个 Java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。
《Java 虚拟机规范》并未规定解析阶段发生的时间,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对所使用的符号引用进行解析。
解析也分为四个步骤
- 类或接口的解析
- 字段解析
- 方法解析
- 接口方法解析
初始化
初始化是类加载过程的最后一个步骤,在之前的阶段中,都是由 Java 虚拟机占主导作用,但是到了这一步,却把主动权移交给应用程序。
对于初始化阶段,《Java 虚拟机规范》严格规定了只有下面这六种情况下才会触发类的初始化。
- 在遇到 new、getstatic、putstatic 或者 invokestatic 这四条字节码指令时,如果没有进行过初始化,那么首先触发初始化。通过这四个字节码的名称可以判断,这四条字节码其实就两个场景,调用 new 关键字的时候进行初始化、读取或者设置一个静态字段的时候、调用静态方法的时候。
- 在初始化类的时候,如果父类还没有初始化,那么就需要先对父类进行初始化。
- 在使用 java.lang.reflect 包的方法进行反射调用的时候。
- 当虚拟机启动时,用户需要指定执行主类的时候,说白了就是虚拟机会先初始化 main 方法这个类。
- 在使用 JDK 7 新加入的动态语言支持时,如果一个 jafva.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,需要先对其进行初始化。
- 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个借口的实现类发生了初始化,那该接口要在其之前被初始化。
其实上面只有前四个大家需要知道就好了,后面两个比较冷门。
如果说要答类加载的话,其实聊到这里已经可以了,但是为了完整性,我们索性把后面两个过程也来聊一聊。
使用
这个阶段没什么可说的,就是初始化之后的代码由 JVM 来动态调用执行。
卸载
当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。
⚠️但是需要注意一点:JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的。
在 JVM 中,对象是如何创建的?
如果要回答对象是怎么创建的,我们一般想到的回答是直接 new
出来就行了,这个回答不仅局限于编程中,也融入在我们生活中的方方面面。
但是遇到面试的时候你只回答一个"new 出来就行了"显然是不行的,因为面试更趋向于让你解释当程序执行到 new 这条指令时,它的背后发生了什么。
所以你需要从 JVM 的角度来解释这件事情。
当虚拟机遇到一个 new 指令时(其实就是字节码),首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化。
因为此时很可能不知道具体的类是什么,所以这里使用的是符号引用。
如果发现这个类没有经过上面类加载的过程,那么就执行相应的类加载过程。
类检查完成后,接下来虚拟机将会为新生对象分配内存,对象所需的大小在类加载完成后便可确定(我会在下面的面试题中介绍)。
分配内存相当于是把一块固定的内存块从堆中划分出来。划分出来之后,虚拟机会将分配到的内存空间都初始化为零值,如果使用了 TLAB
(本地线程分配缓冲),这一项初始化工作可以提前在 TLAB 分配时进行。这一步操作保证了对象实例字段在 Java 代码中可以不赋值就能直接使用。
接下来,Java 虚拟机还会对对象进行必要的设置,比如确定对象是哪个类的实例、对象的 hashcode、对象的 gc 分代年龄信息。这些信息存放在对象的对象头(Object Header)中。
如果上面的工作都做完后,从虚拟机的角度来说,一个新的对象就创建完毕了;但是对于程序员来说,对象创建才刚刚开始,因为构造函数,即 Class 文件中的 <init>()
方法还没有执行,所有字段都为默认的零值。new 指令之后才会执行 <init>()
方法,然后按照程序员的意愿对对象进行初始化,这样一个对象才可能被完整的构造出来。
内存分配方式有哪些呢?
在类加载完成后,虚拟机需要为新生对象分配内存,为对象分配内存相当于是把一块确定的区域从堆中划分出来,这就涉及到一个问题,要划分的堆区是否规整。
假设 Java 堆中内存是规整的,所有使用过的内存放在一边,未使用的内存放在一边,中间放着一个指针,这个指针为分界指示器。那么为新对象分配内存空间就相当于是把指针向空闲的空间挪动对象大小相等的距离,这种内存分配方式叫做指针碰撞(Bump The Pointer)
。
如果 Java 堆中的内存并不是规整的,已经被使用的内存和未被使用的内存相互交错在一起,这种情况下就没有办法使用指针碰撞,这里就要使用另外一种记录内存使用的方式:空闲列表(Free List)
,空闲列表维护了一个列表,这个列表记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
所以,上述两种分配方式选择哪个,取决于 Java 堆是否规整来决定。在一些垃圾收集器的实现中,Serial、ParNew 等带压缩整理过程的收集器,使用的是指针碰撞;而使用 CMS 这种基于清除算法的收集器时,使用的是空闲列表,具体的垃圾收集器我们后面会聊到。
请你说一下对象的内存布局?
在 hotspot
虚拟机中,对象在内存中的布局分为三块区域:
对象头(Header)
实例数据(Instance Data)
对齐填充(Padding)
这三块区域的内存分布如下图所示
我们来详细介绍一下上面对象中的内容。
对象头 Header
对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。
在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。
如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。
在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。
用中文翻译过来就是
- 无状态也就是
无锁
的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01。 偏向锁
中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。轻量级锁
中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00。重量级锁
中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11GC标记
开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的markOop.hpp类中的枚举窥出端倪
来解释一下
- age_bits 就是我们说的分代回收的标识,占用4字节
- lock_bits 是锁的标志位,占用2个字节
- biased_lock_bits 是是否偏向锁的标识,占用1个字节。
- max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte。
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数
- cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2 字节。
在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。
所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor
实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)
这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter
对象。
_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。
那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。
锁的两个列表
当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。
Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。
实例数据 Instance Data
实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。
对齐 Padding
对齐不是必须存在的,它只起到了**占位符(%d, %c 等)**的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。
对象访问定位的方式有哪些?
我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问和 通过直接指针访问。
-
如果使用句柄访问方式的话,Java 堆中可能会划分出一块内存作为句柄池,引用(reference)中存储的是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息。如下图所示。
-
如果使用直接指针访问的话,Java 堆中对象的内存布局就会有所区别,栈区引用指示的是堆中的实例数据的地址,如果只是访问对象本身的话,就不会多一次直接访问的开销,而对象类型数据的指针是存在于方法区中,如果定位的话,需要多一次直接定位开销。如下图所示
这两种对象访问方式各有各的优势,使用句柄最大的好处就是引用中存储的是句柄地址,对象移动时只需改变句柄的地址就可以,而无需改变对象本身。
使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。
上面聊到了对象的两种数据,一种是对象的实例数据,这没什么好说的,就是对象实例字段的数据,一种是对象的类型数据,这个数据说的是对象的类型、父类、实现的接口和方法等。
如何判断对象已经死亡?
我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收♻️,那么 JVM 是如何判断哪些对象已经是"无用对象"的呢?
这里有两种判断方式,首先我们先来说第一种:引用计数法。
引用计数法的判断标准是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,不过,在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题。
循环引用问题简单来讲就是两个对象之间互相依赖着对方,除此之外,再无其他引用,这样虚拟机无法判断引用是否为零从而进行垃圾回收操作。
还有一种判断对象无用的方法就是可达性分析算法。
当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链
(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。
这种引用方式如下
如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可大的,所以被认为是可以回收的对象。
在 Java 技术体系中,可以作为 GC Roots 进行检索的对象主要有
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象。
-
方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量。
-
方法区中常量引用的对象,比如字符串常量池中的引用。
-
在本地方法栈中 JNI 引用的对象。
-
JVM 内部的引用,比如基本数据类型对应的 Class 对象,一些异常对象比如 NullPointerException、OutOfMemoryError 等,还有系统类加载器。
-
所有被 synchronized 持有的对象。
-
还有一些 JVM 内部的比如 JMXBean、JVMTI 中注册的回调,本地代码缓存等。
-
根据用户所选的垃圾收集器以及当前回收的内存区域的不同,还可能会有一些对象临时加入,共同构成 GC Roots 集合。
虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用
这一层关系。
这里涉及到到强引用、软引用、弱引用、虚引用的引用关系,你可以阅读作者的这一篇文章
如何判断一个不再使用的类?
判断一个类型属于"不再使用的类"需要满足下面这三个条件
- 这个类所有的实例已经被回收,也就是 Java 堆中不存在该类及其任何这个类字累的实例
- 加载这个类的类加载器已经被回收,但是类加载器一般很难会被回收,除非这个类加载器是为了这个目的设计的,比如 OSGI、JSP 的重加载等,否则通常很难达成。
- 这个类对应的 Class 对象没有任何地方被引用,无法在任何时刻通过反射访问这个类的属性和方法。
虚拟机允许对满足上面这三个条件的无用类进行回收操作。
JVM 分代收集理论有哪些?
一般商业的虚拟机,大多数都遵循了分代收集的设计思想,分代收集理论主要有两条假说。
第一个是强分代假说,强分代假说指的是 JVM 认为绝大多数对象的生存周期都是朝生夕灭的;
第二个是弱分代假说,弱分代假说指的是只要熬过越多次垃圾收集过程的对象就越难以回收(看来对象也会长心眼)。
就是基于这两个假说理论,JVM 将堆
区划分为不同的区域,再将需要回收的对象根据其熬过垃圾回收的次数分配到不同的区域中存储。
JVM 根据这两条分代收集理论,把堆区划分为**新生代(Young Generation)和老年代(Old Generation)**这两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,剩下没有死去的对象会直接晋升到老年代中。
上面这两个假说没有考虑对象的引用关系,而事实情况是,对象之间会存在引用关系,基于此又诞生了第三个假说,即跨代引用假说(Intergeneration Reference Hypothesis),跨代引用相比较同代引用来说仅占少数。
正常来说存在相互引用的两个对象应该是同生共死的,不过也会存在特例,如果一个新生代对象跨代引用了一个老年代的对象,那么垃圾回收的时候就不会回收这个新生代对象,更不会回收老年代对象,然后这个新生代对象熬过一次垃圾回收进入到老年代中,这时候跨代引用才会消除。
根据跨代引用假说,我们不需要因为老年代中存在少量跨代引用就去直接扫描整个老年代,也不用在老年代中维护一个列表记录有哪些跨代引用,实际上,可以直接在新生代中维护一个记忆集(Remembered Set),由这个记忆集把老年代划分称为若干小块,标识出老年代的哪一块会存在跨代引用。
记忆集的图示如下
从图中我们可以看到,记忆集中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为“脏的”(dirty),否则就是“干净的”(clean)。这样在垃圾回收时,只需要扫描记忆集就可以简单地确定跨代引用的位置,是个典型的空间换时间的思路。
聊一聊 JVM 中的垃圾回收算法?
在聊具体的垃圾回收算法之前,需要明确一点,哪些对象需要被垃圾收集器进行回收?也就是说需要先判断哪些对象是"垃圾"?
判断的标准我在上面如何判断对象已经死亡的问题中描述了,有两种方式,一种是引用计数法,这种判断标准就是给对象添加一个引用计数器,引用这个对象会使计数器的值 + 1,引用失效后,计数器的值就会 -1。但是这种技术无法解决对象之间的循环引用问题。
还有一种方式是 GC Roots,GC Roots 这种方式是以 Root 根节点为核心,逐步向下搜索每个对象的引用,搜索走过的路径被称为引用链,如果搜索过后这个对象不存在引用链,那么这个对象就是无用对象,可以被回收。GC Roots 可以解决循环引用问题,所以一般 JVM 都采用的是这种方式。
解决循环引用代码描述:
public class test{
public static void main(String[]args){
A a = new A();
B b = new B();
a=null;
b=null;
}
}
class A {
public B b;
}
class B {
public A a;
}
基于 GC Roots 的这种思想,发展出了很多垃圾回收算法,下面我们就来聊一聊这些算法。
标记-清除算法
**标记-清除(Mark-Sweep)**这个算法可以说是最早最基础的算法了,标记-清除顾名思义分为两个阶段,即标记和清除阶段:首先标记处所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以标记存活的对象,回收未被标记的对象。这个标记的过程就是垃圾判定的过程。
后续大部分垃圾回收算法都是基于标记-算法思想衍生的,只不过后续的算法弥补了标记-清除算法的缺点,那么它由什么缺点呢?主要有两个
- 执行效率不稳定,因为假如说堆中存在大量无用对象,而且大部分需要回收的情况下,这时必须进行大量的标记和清除,导致标记和清除这两个过程的执行效率随对象的数量增长而降低。
- 内存碎片化,标记-清除算法会在堆区产生大量不连续的内存碎片。碎片太多会导致在分配大对象时没有足够的空间,不得不进行一次垃圾回收操作。
标记算法的示意图如下
标记-复制算法
由于标记-清除算法极易产生内存碎片,研究人员提出了标记-复制算法,标记-复制算法也可以简称为复制算法,复制算法是一种半区复制,它会将内存大小划分为相等的两块,每次只使用其中的一块,用完一块再用另外一块,然后再把用过的一块进行清除。虽然解决了部分内存碎片的问题,但是复制算法也带来了新的问题,即复制开销,不过这种开销是可以降低的,如果内存中大多数对象是无用对象,那么就可以把少数的存活对象进行复制,再回收无用的对象。
不过复制算法的缺陷也是显而易见的,那就是内存空间缩小为原来的一半,空间浪费太明显。标记-复制算法示意图如下
现在 Java 虚拟机大多数都是用了这种算法来回收新生代,因为经过研究表明,新生代对象由 98% 都熬不过第一轮收集,因此不需要按照 1 : 1 的比例来划分新生代的内存空间。
基于此,研究人员提出了一种 Appel 式回收,Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块 Survivor 空间,每次分配内存都只使用 Eden 和其中的一块 Survivor 空间,发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已使用过的 Survivor 空间。
在主流的 HotSpot 虚拟机中,默认的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,所以会浪费掉 10% 的空间。这个 8:1 只是一个理论值,也就是说,不能保证每次都有不超过 10% 的对象存活,所以,当进行垃圾回收后如果 Survivor 容纳不了可存活的对象后,就需要其他内存空间来进行帮助,这种方式就叫做内存担保(Handle Promotion) ,通常情况下,作为担保的是老年代。
标记-整理算法
标记-复制算法虽然解决了内存碎片问题,但是没有解决复制对象存在大量开销的问题。为了解决复制算法的缺陷,充分利用内存空间,提出了标记-整理算法。该算法标记阶段和标记-清除一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:
什么是记忆集,什么是卡表?记忆集和卡表有什么关系?
为了解决跨代引用问题,提出了记忆集这个概念,记忆集是一个在新生代中使用的数据结构,它相当于是记录了一些指针的集合,指向了老年代中哪些对象存在跨代引用。
记忆集的实现有不同的粒度
- 字长精度:每个记录精确到一个字长,机器字长就是处理器的寻址位数,比如常见的 32 位或者 64 位处理器,这个精度决定了机器访问物理内存地址的指针长度,字中包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,区域内含有跨代指针。
其中卡精度是使用了卡表作为记忆集的实现,关于记忆集和卡表的关系,大家可以想象成是 HashMap 和 Map 的关系。
什么是卡页?
卡表其实就是一个字节数组
CARD_TABLE[this address >> 9] = 0;
字节数组 CARD_TABLE 的每一个元素都对应着内存区域中一块特定大小的内存块,这个内存块就是卡页,一般来说,卡页都是 2 的 N 次幂字节数,通过上面的代码我们可以知道,卡页一般是 2 的 9 次幂,这也是 HotSpot 中使用的卡页,即 512 字节。
一个卡页的内存通常包含不止一个对象,只要卡页中有一个对象的字段存在跨代指针,那就将对应卡表的数组元素的值设置为 1,称之为这个元素变脏
了,没有标示则为 0 。在垃圾收集时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,然后把他们加入 GC Roots 进行扫描。
所以,卡页和卡表主要用来解决跨代引用问题的。
什么是写屏障?写屏障带来的问题?
如果有其他分代区域中对象引用了本区域的对象,那么其对应的卡表元素就会变脏,这个引用说的就是对象赋值,也就是说卡表元素会变脏发生在对象赋值的时候,那么如何在对象赋值的时候更新维护卡表呢?
在 HotSpot 虚拟机中使用的是写屏障(Write Barrier) 来维护卡表状态的,这个写屏障和我们内存屏障完全不同,希望读者不要搞混了。
这个写屏障其实就是一个 Aop 切面,在引用对象进行赋值时会产生一个环形通知(Around),环形通知就是切面前后分别产生一个通知,因为这个又是写屏障,所以在赋值前的部分写屏障叫做写前屏障,在赋值后的则叫做写后屏障。
写屏障会带来两个问题
无条件写屏障带来的性能开销
每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作。显然,这会增加一些额外的开销。但是,扫描整个老年代相比较,这个开销就低得多了。
不过,在高并发环境下,写屏障又带来了伪共享(false sharing)问题。
高并发下伪共享带来的性能开销
在高并发情况下,频繁的写屏障很容易发生伪共享(false sharing),从而带来性能开销。
假设 CPU 缓存行大小为 64 字节,由于一个卡表项占 1 个字节,这意味着,64 个卡表项将共享同一个缓存行。
HotSpot 每个卡页为 512 字节,那么一个缓存行将对应 64 个卡页一共 64*512 = 32K B。
如果不同线程对对象引用的更新操作,恰好位于同一个 32 KB 区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序性能。
一个简单的解决方案,就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表项未被标记过才将其标记为脏的。
这就是 JDK 7 中引入的解决方法,引入了一个新的 JVM 参数 -XX:+UseCondCardMark,在执行写屏障之前,先简单的做一下判断。如果卡页已被标识过,则不再进行标识。
简单理解如下:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
与原来的实现相比,只是简单的增加了一个判断操作。
虽然开启 -XX:+UseCondCardMark 之后多了一些判断开销,但是却可以避免在高并发情况下可能发生的并发写卡表问题。通过减少并发写操作,进而避免出现伪共享问题(false sharing)。
什么是三色标记法?三色标记法会造成哪些问题?
根据可达性算法的分析可知,如果要找出存活对象,需要从 GC Roots 开始遍历,然后搜索每个对象是否可达,如果对象可达则为存活对象,在 GC Roots 的搜索过程中,按照对象和其引用是否被访问过这个条件会分成下面三种颜色:
- 白色:白色表示 GC Roots 的遍历过程中没有被访问过的对象,出现白色显然在可达性分析刚刚开始的阶段,这个时候所有对象都是白色的,如果在分析结束的阶段,仍然是白色的对象,那么代表不可达,可以进行回收。
- 灰色:灰色表示对象已经被访问过,但是这个对象的引用还没有访问完毕。
- 黑色:黑色表示此对象已经被访问过了,而且这个对象的引用也已经呗访问了。
注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。
现代的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
三色标记法会造成两种问题,这两种问题所出现的环境都是由于用户环境和收集器并行工作造成的 。当用户线程正在修改引用关系,此时收集器在回收引用关系,此时就会造成把原本已经消亡的对象标记为存活,如果出现这种状况的话,问题不大,下次再让收集器重新收集一波就完了,但是还有一种情况是把存活的对象标记为死亡,这种状况就会造成不可预知的后果。
针对上面这两种对象消失问题,业界有两种处理方式,一种是增量更新(Incremental Update) ,一种是原是快照(Snapshot At The Beginning, SATB)。
请你介绍一波垃圾收集器
垃圾收集器是面试的常考,也是必考点,只要涉及到 JVM 的相关问题,都会围绕着垃圾收集器来做一波展开,所以,有必要了解一下这些垃圾收集器。
垃圾收集器有很多,不同商家、不同版本的J VM 所提供的垃圾收集器可能会有很在差别,我们主要介绍 HotSpot 虚拟机中的垃圾收集器。
垃圾收集器是垃圾回收算法的具体实现,我们上面提到过,垃圾回收算法有标记-清除算法、标记-整理、标记-复制,所以对应的垃圾收集器也有不同的实现方式。
我们知道,HotSpot 虚拟机中的垃圾收集都是分代回收的,所以根据不同的分代,可以把垃圾收集器分为
新生代收集器:Serial、ParNew、Parallel Scavenge;
老年代收集器:Serial Old、Parallel Old、CMS;
整堆收集器:G1;
Serial 收集器
Serial 收集器是一种新生代的垃圾收集器,它是一个单线程工作的收集器,使用复制算法来进行回收,单线程工作不是说这个垃圾收集器只有一个,而是说这个收集器在工作时,必须暂停其他所有工作线程,这种暴力的暂停方式就是 Stop The World,Serial 就好像是寡头垄断一样,只要它一发话,其他所有的小弟(线程)都得给它让路。Serial 收集器的示意图如下:
SefePoint 全局安全点:它就是代码中的一段特殊的位置,在所有用户线程到达 SafePoint 之后,用户线程挂起,GC 线程会进行清理工作。
虽然 Serial 有 STW 这种显而易见的缺点,不过,从其他角度来看,Serial 还是很讨喜的,它还有着优于其他收集器的地方,那就是简单而高效,对于内存资源首先的环境,它是所有收集器中额外内存消耗最小的,对于单核处理器或者处理器核心较少的环境来说,Serial 收集器由于没有线程交互开销,所以 Serial 专心做垃圾回收效率比较高。
ParNew 收集器
ParNew 是 Serial 的多线程版本,除了同时使用多条线程外,其他参数和机制(STW、回收策略、对象分配规则)都和 Serial 完全一致,ParNew 收集器的示意图如下:
虽然 ParNew 使用了多条线程进行垃圾回收,但是在单线程环境下它绝对不会比 Serial 收集效率更高,因为多线程存在线程交互的开销,但是随着可用 CPU 核数的增加,ParNew 的处理效率会比 Serial 更高效。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的,而且它也能够并行收集,这么看来,表面上 Parallel Scavenge 于 ParNew 非常相似,那么它们之间有什么区别呢?
Parallel Scavenge 的关注点主要在达到一个可控制的吞吐量上面。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比。也就是
这里给大家举一个吞吐量的例子,如果执行用户代码的时间 + 运行垃圾收集的时间总共耗费了 100 分钟,其中垃圾收集耗费掉了 1 分钟,那么吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量,良好的响应速度可以提升用户体验,而高吞吐量可以最高效率利用处理器资源。
Serial Old 收集器
前面介绍了一下 Serial,我们知道它是一个新生代的垃圾收集,使用了标记-复制算法。而这个 Serial Old 收集器却是 Serial 的老年版本,它同样也是一个单线程收集器,使用的是标记-整理算法,Serial Old 收集器有两种用途:一种是在 JDK 5 和之前的版本与 Parallel Scavenge 收集器搭配使用,另外一种用法就是作为 CMS
收集器的备选,CMS 垃圾收集器我们下面说,Serial Old 的收集流程如下
Parallel Old 收集器
前面我们介绍了 Parallel Scavenge 收集器,现在来介绍一下 Parallel Old 收集器,它是 Parallel Scavenge 的老年版本,支持多线程并发收集,基于标记 - 整理算法实现,JDK 6 之后出现,吞吐量优先可以考虑 Parallel Scavenge + Parallel Old 的搭配
CMS 收集器
CMS
收集器的主要目标是获取最短的回收停顿时间,它的全称是 Concurrent Mark Sweep,从这个名字就可以知道,这个收集器是基于标记 - 清除算法实现的,而且支持并发收集,它的运行过程要比上面我们提到的收集器复杂一些,它的工作流程如下:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
对于上面这四个步骤,初始标记和并发标记都需要 Stop The World,初始标记只是标记一下和 GC Roots 直接关联到的对象,速度较快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程。这个过程时间比较长但是不需要停顿用户线程,也就是说与垃圾收集线程一起并发运行。并发标记的过程中,可能会有错标或者漏标的情况,此时就需要在重新标记一下,最后是并发清除阶段,清理掉标记阶段中判断已经死亡的对象。
CMS 的收集过程如下
CMS 是一款非常优秀的垃圾收集器,但是没有任何收集器能够做到完美的程度,CMS 也是一样,CMS 至少有三个缺点:
-
CMS 对处理器资源非常敏感,在并发阶段,虽然不会造成用户线程停顿,但是却会因为占用一部分线程而导致应用程序变慢,降低总吞吐量。
-
CMS 无法处理浮动垃圾,有可能出现Concurrent Mode Failure失败进而导致另一次完全 Stop The World的 Full GC 产生。
什么是浮动垃圾呢?由于并发标记和并发清理阶段,用户线程仍在继续运行,所以程序自然而然就会伴随着新的垃圾不断出现,而且这一部分垃圾出现在标记结束之后,CMS 无法处理这些垃圾,所以只能等到下一次垃圾回收时在进行清理。这一部分垃圾就被称为浮动垃圾。
-
CMS 最后一个缺点是并发-清除的通病,也就是会有大量的空间碎片出现,这将会给分配大对象带来困难。
Garbage First 收集器
Garbage First 又被称为 G1 收集器,它的出现意味着垃圾收集器走过了一个里程碑,为什么说它是里程碑呢?因为 G1 这个收集器是一种面向局部的垃圾收集器,HotSpot 团队开发这个垃圾收集器为了让它替换掉 CMS 收集器,所以到后来,JDK 9 发布后,G1 取代了 Parallel Scavenge + Parallel Old 组合,成为服务端默认的垃圾收集器,而 CMS 则不再推荐使用。
之前的垃圾收集器存在回收区域的局限性,因为之前这些垃圾收集器的目标范围要么是整个新生代、要么是整个老年代,要么是整个 Java 堆(Full GC),而 G1 跳出了这个框架,它可以面向堆内存的任何部分来组成回收集(Collection Set,CSet),衡量垃圾收集的不再是哪个分代,这就是 G1 的 Mixed GC 模式。
G1 是基于 Region 来进行回收的,Region 就是堆内存中任意的布局,每一块 Region 都可以根据需要
以上是关于JVM 面试题,安排上了!!!的主要内容,如果未能解决你的问题,请参考以下文章