Java面试题集-JVM(二)
1、对象的内存布局?
对象在堆内存的存储布局可分为对象头、实例数据和对⻬填充。
对象头占 12B,包括对象标记和类型指针。对象标记存储对象⾃身的运⾏时数据,如哈希码、GC 分代年龄、锁标志、偏向线程 ID 等,这部分占 8B,称为 Mark Word。Mark Word 被设计为动态数据结构,以便在极⼩的空间存储更多数据,根据对象状态复⽤存储空间。
类型指针是对象指向它的类型元数据的指针,占 4B。JVM 通过该指针来确定对象是哪个类的实例。
实例数据是对象真正存储的有效信息,即本类对象的实例成员变量和所有可⻅的⽗类成员变量。存储顺序会受到虚拟机分配策略参数和字段在源码中定义顺序的影响。相同宽度的字段总是被分配到⼀起存放,在满⾜该前提条件的情况下⽗类中定义的变量会出现在⼦类之前。
对⻬填充不是必然存在的,仅起占位符作⽤。虚拟机的⾃动内存管理系统要求任何对象的⼤⼩必须是8B 的倍数,对象头已被设为 8B 的 1 或 2 倍,如果对象实例数据部分没有对⻬,需要对⻬填充补全。
2、对象的访问方式有哪些?
Java 程序会通过栈上的 reference 引⽤操作堆对象,访问⽅式由虚拟机决定,主流访问⽅式主要有句柄和直接指针。
句柄: 堆会划分出⼀块内存作为句柄池,reference 中存储对象的句柄地址,句柄包含对象实例数据与类型数据的地址信息。优点是 reference 中存储的是稳定句柄地址,在 GC 过程中对象被移动时只会改变句柄的实例数据指针,⽽ reference 本身不需要修改。
直接指针: 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 存储对象地址,如果只是访问对象本身就不需要多⼀次间接访问的开销。优点是速度更快,节省了⼀次指针定位的时间开销,HotSpot 主要使⽤直接指针进⾏对象访问。
3、如何判断对象是否是垃圾?
引⽤计数:在对象中添加⼀个引⽤计数器,如果被引⽤计数器加 1,引⽤失效时计数器减 1,如果计数器为 0 则被标记为垃圾。原理简单,效率⾼,但是在 Java 中很少使⽤,因为存在对象间循环引⽤的问题,导致计数器⽆法清零。
可达性分析:主流语⾔的内存管理都使⽤可达性分析判断对象是否存活。基本思路是通过⼀系列称为GC Roots 的根对象作为起始节点集,从这些节点开始,根据引⽤关系向下搜索,搜索过程⾛过的路径称为引⽤链,如果某个对象到 GC Roots 没有任何引⽤链相连,则会被标记为垃圾。 可作为 GC Roots的对象包括虚拟机栈和本地⽅法栈中引⽤的对象、类静态属性引⽤的对象、常量引⽤的对象。
4、有哪些GC算法?
标记-清除算法
分为标记和清除阶段,⾸先从每个 GC Roots 出发依次标记有引⽤关系的对象,最后清除没有标记的对象。
执⾏效率不稳定,如果堆包含⼤量对象且⼤部分需要回收,必须进⾏⼤量标记清除,导致效率随对象数量增⻓⽽降低。
存在内存空间碎⽚化问题,会产⽣⼤量不连续的内存碎⽚,导致以后需要分配⼤对象时容易触发 FullGC。
标记-复制算法
为了解决内存碎⽚问题,将可⽤内存按容量划分为⼤⼩相等的两块,每次只使⽤其中⼀块。当使⽤的这块空间⽤完了,就将存活对象复制到另⼀块,再把已使⽤过的内存空间⼀次清理掉。主要⽤于进⾏新⽣代。
实现简单、运⾏⾼效,解决了内存碎⽚问题。 代价是可⽤内存缩⼩为原来的⼀半,浪费空间。
HotSpot 把新⽣代划分为⼀块较⼤的 Eden 和两块较⼩的 Survivor,每次分配内存只使⽤ Eden 和其中⼀块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象⼀次性复制到另⼀块 Survivor 上,然后直接清理掉 Eden 和已⽤过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的⼤⼩⽐例是 8:1,即每次新⽣代中可⽤空间为整个新⽣代的 90%。
标记-整理算法
标记-复制算法在对象存活率⾼时要进⾏较多复制操作,效率低。如果不想浪费空间,就需要有额外空间分配担保,应对被使⽤内存中所有对象都存活的极端情况, 所以⽼年代⼀般不使⽤此算法。
⽼年代使⽤标记-整理算法,标记过程与标记-清除算法⼀样,但不直接清理可回收对象,⽽是让所有存活对象都向内存空间⼀端移动,然后清理掉边界以外的内存。
标记-清除与标记-整理的**差异在于前者是⼀种⾮移动式算法⽽后者是移动式的。**如果移动存活对象,尤其是在⽼年代这种每次回收都有⼤量对象存活的区域,是⼀种极为负重的操作,⽽且移动必须全程暂停⽤户线程。如果不移动对象就会导致空间碎⽚问题,只能依赖更复杂的内存分配器和访问器解决。
5、你知道哪些垃圾收集器?
Serial
最基础的收集器,使⽤复制算法、单线程⼯作,只⽤⼀个处理器或⼀条线程完成垃圾收集,进⾏垃圾收集时必须暂停其他所有⼯作线程。
Serial 是虚拟机在客户端模式的默认新⽣代收集器,简单⾼效**,对于内存受限的环境它是所有收集器中额外内存消耗最⼩的,**对于处理器核⼼较少的环境,Serial 由于没有线程交互开销,可获得最⾼的单线程收集效率。
ParNew
Serial 的多线程版本,除了使⽤多线程进⾏垃圾收集外其余⾏为完全⼀致。
ParNew 是虚拟机在服务端模式的默认新⽣代收集器,⼀个重要原因是除了 Serial 外只有它能与 CMS配合。 ⾃从 JDK 9 开始,ParNew 加 CMS 不再是官⽅推荐的解决⽅案,官⽅希望它被 G1 取代。
Parallel Scavenge
新⽣代收集器,基于复制算法,是可并⾏的多线程收集器, 与 ParNew 类似。
特点是它的关注点与其他收集器不同,Parallel Scavenge 的 ⽬标是达到⼀个可控制的吞吐量,吞吐量就是处理器⽤于运⾏⽤户代码的时间与处理器消耗总时间的⽐值。
Serial Old
Serial 的⽼年代版本,单线程⼯作,使⽤标记-整理算法。
Serial Old 是虚拟机在客户端模式的默认⽼年代收集器,⽤于服务端有两种⽤途:① JDK5 及之前与Parallel Scavenge 搭配。② 作为CMS 失败预案。
Parellel Old
Parallel Scavenge 的⽼年代版本,⽀持多线程,基于标记-整理算法。JDK6 提供,注重吞吐量可考虑Parallel Scavenge 加 Parallel Old。
CMS
以获取最短回收停顿时间为⽬标,基于标记-清除算法,过程相对复杂,分为四个步骤:初始标记、并发标记、重新标记、并发清除。
初始标记和重新标记需要 STW(Stop The World,系统停顿),
初始标记仅是标记 GC Roots 能直接关联的对象,速度很快。
并发标记从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较⻓但不需要停顿⽤户线程。
重新标记则是为了修正并发标记期间因⽤户程序运作⽽导致标记产⽣变动的那部分记录。
并发清除清理标记阶段判断的已死亡对象,不需要移动存活对象,该阶段也可与⽤户线程并发。
缺点:① 对处理器资源敏感,并发阶段虽然不会导致⽤户线程暂停,但会降低吞吐量。② ⽆法处理浮动垃圾,有可能出现并发失败⽽导致 Full GC。③ 基于标记-清除算法,产⽣空间碎⽚。
G1
开创了收集器⾯向局部收集的设计思路和基于 Region 的内存布局,主要⾯向服务端,最初设计⽬标是替换 CMS。
G1 之前的收集器,垃圾收集⽬标要么是整个新⽣代,要么是整个⽼年代或整个堆。⽽ G1 可⾯向堆任何部分来组成回收集进⾏回收,衡量标准不再是分代,⽽是哪块内存中存放的垃圾数量最多,回收受益最⼤。
跟踪各 Region ⾥垃圾的价值,价值即回收所获空间⼤⼩以及回收所需时间的经验值,在后台维护⼀个优先级列表,每次根据⽤户设定允许的收集停顿时间优先处理回收价值最⼤的 Region。这种⽅式保证了 G1 在有限时间内获取尽可能⾼的收集效率。
G1 运作过程:
初始标记:标记 GC Roots 能直接关联到的对象,让下⼀阶段⽤户线程并发运⾏时能正确地在可⽤Region 中分配新对象。需要 STW 但耗时很短,在 Minor GC 时同步完成。
并发标记:从 GC Roots 开始对堆中对象进⾏可达性分析,递归扫描整个堆的对象图。耗时⻓但可与⽤户线程并发,扫描完成后要重新处理 SATB 记录的在并发时有变动的对象。
最终标记:对⽤户线程做短暂暂停,处理并发阶段结束后仍遗留下来的少量 SATB 记录。
筛选回收:对各 Region 的回收价值排序,根据⽤户期望停顿时间制定回收计划。必须暂停⽤户线程,由多条收集线程并⾏完成。
可由⽤户指定期望停顿时间是 G1 的⼀个强⼤功能,但该值不能设得太低,⼀般设置为100~300 ms。
6、了解ZGC?
JDK11 中加⼊的具有实验性质的低延迟垃圾收集器,⽬标是尽可能在不影响吞吐量的前提下,实现在任意堆内存⼤⼩都可以把停顿时间限制在 10ms 以内的低延迟。
基于 Region 内存布局,不设分代,使⽤了读屏障、染⾊指针和内存多重映射等技术实现可并发的标记整理,以低延迟为⾸要⽬标。
ZGC 的 Region 具有动态性,是动态创建和销毁的,并且容量⼤⼩也是动态变化的。
7、你知道哪些内存分配与回收策略?
对象优先在 Eden 区分配
⼤多数情况下对象在新⽣代 Eden 区分配,当 Eden 没有⾜够空间时将发起⼀次 Minor GC。
⼤对象直接进⼊⽼年代
⼤对象指需要⼤量连续内存空间的对象,典型是很⻓的字符串或数量庞⼤的数组。⼤对象容易导致内存还有不少空间就提前触发垃圾收集以获得⾜够的连续空间。
HotSpot 提供了 -XX:PretenureSizeThreshold 参数,⼤于该值的对象直接在⽼年代分配,避免在Eden 和 Survivor 间来回复制。
⻓期存活对象进⼊⽼年代
虚拟机给每个对象定义了⼀个对象年龄计数器,存储在对象头。如果经历过第⼀次 Minor GC 仍然存活且能被 Survivor 容纳,该对象就会被移动到 Survivor 中并将年龄设置为 1。对象在 Survivor 中每熬过⼀次 Minor GC 年龄就加 1 ,当增加到⼀定程度(默认15)就会被晋升到⽼年代。对象晋升⽼年代的阈值可通过 -XX:MaxTenuringThreshold 设置。
动态对象年龄判定
为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升⽼年代,如果在 Survivor 中相同年龄所有对象⼤⼩的总和⼤于 Survivor 的⼀半,年龄不⼩于该年龄的对象就可以直接进⼊⽼年代。
空间分配担保
MinorGC 前虚拟机必须检查⽼年代最⼤可⽤连续空间是否⼤于新⽣代对象总空间,如果满⾜则说明这次 Minor GC 确定安全。
如果不满⾜,虚拟机会查看 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许会继续检查⽼年代最⼤可⽤连续空间是否⼤于历次晋升⽼年代对象的平均⼤⼩,如果满⾜将冒险尝试⼀次Minor GC,否则改成⼀次 FullGC。
冒险是因为新⽣代使⽤复制算法,为了内存利⽤率只使⽤⼀个 Survivor,⼤量对象在 Minor GC 后仍然存活时,需要⽼年代进⾏分配担保,接收 Survivor ⽆法容纳的对象。
8、你知道哪些故障处理工具?
jps:虚拟机进程状况⼯具
功能和 ps 命令类似:可以列出正在运⾏的虚拟机进程,显示虚拟机执⾏主类名称以及这些进程的本地虚拟机唯⼀ ID(LVMID)。LVMID 与操作系统的进程 ID(PID)⼀致,使⽤ Windows 的任务管理器或UNIX 的 ps 命令也可以查询到虚拟机进程的 LVMID,但如果同时启动了多个虚拟机进程,必须依赖 jps命令。
jstat:虚拟机统计信息监视⼯具
⽤于监视虚拟机各种运⾏状态信息。可以显示本地或远程虚拟机进程中的类加载、内存、垃圾收集、即时编译器等运⾏时数据,在没有 GUI 界⾯的服务器上是运⾏期定位虚拟机性能问题的常⽤⼯具。参数含义:S0 和 S1 表示两个 Survivor,E 表示新⽣代,O 表示⽼年代,YGC 表示 Young GC 次数,YGCT 表示 Young GC 耗时,FGC 表示 Full GC 次数,FGCT 表示 Full GC 耗时,GCT 表示 GC 总耗时。
jinfo:Java 配置信息⼯具
实时查看和调整虚拟机各项参数,使⽤ jps 的 -v 参数可以查看虚拟机启动时显式指定的参数,但如果想知道未显式指定的参数值只能使⽤ jinfo 的 -flag 查询。
jmap:Java 内存映像⼯具
⽤于⽣成堆转储快照,还可以查询 finalize 执⾏队列、Java 堆和⽅法区的详细信息,如空间使⽤率,当前使⽤的是哪种收集器等。和 jinfo ⼀样,部分功能在 Windows 受限,除了⽣成堆转储快照的 -dump和查看每个类实例的 -histo 外,其余选项只能在 Linux 使⽤。
jhat:虚拟机堆转储快照分析⼯具
JDK 提供 jhat 与 jmap 搭配使⽤分析 jmap ⽣成的堆转储快照。jhat 内置了⼀个微型的 HTTP/Web 服务器,⽣成堆转储快照的分析结果后可以在浏览器查看。
jstack:Java 堆栈跟踪⼯具
**⽤于⽣成虚拟机当前时刻的线程快照。**线程快照就是当前虚拟机内每⼀条线程正在执⾏的⽅法堆栈的集合,⽣成线程快照的⽬的通常是定位线程出现⻓时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的⻓时间挂起等。线程出现停顿时通过 jstack 查看各个线程的调⽤堆栈,可以获知没有响应的线程在后台做什么或等什么资源。
9、Java程序是怎样运行的?
⾸先通过 Javac 编译器将 .java 转为 JVM 可加载的 .class 字节码⽂件。
Javac 是由 Java 编写的程序,编译过程可以分为: ① 词法解析,通过空格分割出单词、操作符、控制符等信息,形成 token 信息流,传递给语法解析器。
② 语法解析,把 token 信息流按照 Java语法规则组装成语法树。
③ 语义分析,检查关键字使⽤是否合理、类型是否匹配、作⽤域是否正确等。
④ 字节码⽣成,将前⾯各个步骤的信息转换为字节码。
字节码必须通过类加载过程加载到 JVM 后才可以执⾏,执⾏有三种模式,解释执⾏、JIT 编译执⾏、JIT 编译与解释器混合执⾏(主流 JVM 默认执⾏的⽅式)。混合模式的优势在于解释器在启动时先解释执⾏,省去编译时间。
之后通过即时编译器 JIT 把字节码⽂件编译成本地机器码。
Java 程序最初都是通过解释器进⾏解释执⾏的,当虚拟机发现某个⽅法或代码块的运⾏特别频繁,就会认定其为"热点代码",热点代码的检测主要有基于采样和基于计数器两种⽅式,为了提⾼热点代码的执⾏效率,虚拟机会把它们编译成本地机器码,尽可能对代码优化,在运⾏时完成这个任务的后端编译器被称为即时编译器。
还可以通过静态的提前编译器 AOT 直接把程序编译成与⽬标机器指令集相关的⼆进制代码。
10、类初始化的情况有哪些?
① 遇到 new、getstatic、putstatic 或 invokestatic 字节码指令时,还未初始化。典型场景包括 new 实例化对象、读取或设置静态字段、调⽤静态⽅法。
② 对类反射调⽤时,还未初始化。
③ 初始化类时,⽗类还未初始化。
④ 虚拟机启动时,会先初始化包含 main ⽅法的主类。
⑤ 使⽤ JDK7 的动态语⾔⽀持时,如果 MethodHandle 实例的解析结果为指定类型的⽅法句柄且句柄对应的类还未初始化。
⑥ 接⼝定义了默认⽅法,如果接⼝的实现类初始化,接⼝要在其之前初始化。
其余所有引⽤类型的⽅式都不会触发初始化,称为被动引⽤。被动引⽤实例:① ⼦类使⽤⽗类的静态字段时,只有⽗类被初始化。② 通过数组定义使⽤类。③ 常量在编译期会存⼊调⽤类的常量池,不会初始化定义常量的类。
接⼝和类加载过程的区别:初始化类时如果⽗类没有初始化需要初始化⽗类,但接⼝初始化时不要求⽗接⼝初始化,只有在真正使⽤⽗接⼝时(如引⽤接⼝中定义的常量)才会初始化。
11、有哪些类加载器?
⾃ JDK1.2 起 Java ⼀直保持三层类加载器:
启动类加载器
在 JVM 启动时创建,负责加载最核⼼的类,例如 Object、System 等。 ⽆法被程序直接引⽤,如果需要把加载委派给启动类加载器,直接使⽤ null 代替即可,因为启动类加载器通常由操作系统实现,并不存在于 JVM 体系。
平台类加载器
从 JDK9 开始从扩展类加载器更换为平台类加载器,负载加载⼀些扩展的系统类, ⽐如 XML、加密、压缩相关的功能类等。
应⽤类加载器
也称系统类加载器,负责加载⽤户类路径上的类库,可以直接在代码中使⽤。 如果没有⾃定义类加载器,⼀般情况下应⽤类加载器就是默认的类加载器。⾃定义类加载器通过继承 ClassLoader 并重写 findClass ⽅法实现。
12、双亲委派模型是什么?
类加载器具有等级制度但⾮继承关系,以组合的⽅式复⽤⽗加载器的功能。双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有⾃⼰的⽗加载器。
⼀个类加载器收到了类加载请求,它不会⾃⼰去尝试加载,⽽将该请求委派给⽗加载器,每层的类加载器都是如此,因此所有加载请求最终都应该传送到启动类加载器,只有当⽗加载器反馈⽆法完成请求时,⼦加载器才会尝试。
类跟随它的加载器⼀起具备了有优先级的层次关系,确保某个类在各个类加载器环境中都是同⼀个,保证程序的稳定性。
13、如何判断两个类是否相等?
任意⼀个类都必须由类加载器和这个类本身共同确⽴其在虚拟机中的唯⼀性。
两个类只有由同⼀类加载器加载才有⽐较意义,否则即使两个类来源于同⼀个 Class ⽂件,被同⼀个JVM 加载,只要类加载器不同,这两个类就必定不相等。