《Java 后端面试经》JVM 篇

《Java 后端面试经》专栏文章索引:
《Java 后端面试经》Java 基础篇
《Java 后端面试经》Java EE 篇
《Java 后端面试经》数据库篇
《Java 后端面试经》多线程与并发编程篇
《Java 后端面试经》JVM 篇
《Java 后端面试经》操作系统篇
《Java 后端面试经》Linux 篇
《Java 后端面试经》设计模式篇
《Java 后端面试经》计算机网络篇
《Java 后端面试经》微服务篇

《Java 后端面试经》JVM 篇

🚀JVM 可以运行多种语言吗?

JVM 只识别字节码,所以 JVM 其实跟语言是解耦的,也就是没有直接关联,只要符合字节码规范,都可以由 JVM 运行。像 scala、Groovy、Kotlin 等语言都可以在 JVM 上运行。
在这里插入图片描述

🚀谈谈 JMM

JMM 是 Java 内存模型(Java Memory Model),目的是为了屏蔽各种硬件和操作系统之间的内存访问差异,从而让 Java 程序在各种平台对内存的访问一致

Java 内存模型规定所有的变量都存储在主内存中(包括实例变量,静态变量)。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量(局部变量)和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。

在这里插入图片描述

每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是 Java 内存模型定义的线程基本工作方式。不要把 JMM 与 JVM 的内存结构混淆了(堆、栈、程序计数器等),一般问 JMM 是想问多线程、并发相关的问题。

🚁追问1:JMM 是真实存在的嘛,和 JVM 内存模型(JAVA 虚拟机内存模型)是一样的嘛?

不是真实存在的,JMM 讲的也只是一种模型,真实的实现可能还是和模型会有差异的。JMM 和 JVM 是不一样的,它们并不是同一个层次的划分,基本上没啥关系。

🚀JVM 内存模型

在这里插入图片描述
JVM 管理的内存包括 Java 运行时数据区,运行时数据区分为线程共享区域和线程私有区域。线程共享区域包括:

  • 方法区
  • 直接内存

线程私有区域包括:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享区域是非线程安全的,线程私有区域是线程安全的。

  • Java 虚拟机管理的最大的一块内存区域,所有线程共享的区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例几乎所有的对象实例以及数组都在这里分配内存。
  • 几乎是因为:从 JDK 1.7 开始已经默认开启逃逸分析,如果对象确定只在当前方法内部使用且不逃逸到方法外部或被其他线程访问,编译器可以选择将其直接分配在栈上而非堆上。
  • Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆。
  • 堆最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,例如:1、java.lang.OutOfMemoryError: GC Overhead Limit Exceeded当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。2、java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过 -Xmx 参数配置,若没有特别配置,将会使用默认值。

方法区

  • JDK 1.7 及之前叫作 ”永久代“,JDK 1.8 及以后叫作 ”元空间“
  • 用来存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 在 JDK 1.8 之前,方法区中有一部分称为 ”常量池“,用于存放编译器生成的各种字面量和符号引用。在 JDK 1.8 及之后,原来常量池换成了直接内存(非运行时数据区的一部分)。

运行时常量池

  • 运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)
  • 当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

直接内存

  • 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
  • 在 JDK 1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

程序计数器

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理等。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • 唯一一个不会出现 OOM 的内存区域,生命周期随着线程创建而开始,随着线程结束而消亡。

虚拟机栈

  • 虚拟机栈由一个个栈帧组成,Java 中每个方法执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。其中,局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用。
  • Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。1、如果 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。2、Java 虚拟机栈的内存大小可以动态扩展如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
  • 生命周期与线程相同。

本地方法栈

  • 本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 Native 方法(使用 C/C++ 编写的方法),
  • 也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
  • 生命周期与线程相同。

🚁追问1:程序计数器可以为空吗?

可以为空,原因是因为,当执行本地方法时,程序计数器存放 Java 字节码的地址,而 native 方法的方法体是非 Java 的,所以程序计数器的值才未定义,可以为空。

🚁追问2:堆中又怎么细分的?

堆中可以细分为新生代老年代,其中新生代又分为 Eden 区From SurvivorTo Survivor 区,比例是 8:1:1. 在 JDK 1.8 之前,堆内存通常分为:

  • 新生代内存(Young Generation)
  • 老生代(Old Generation)
  • 永久代(Permanent Generation)

到了 JDK 1.8 版本及之后永久代(Permanent Generation)已被 Metaspace(元空间) 取代,元空间使用的是直接内存。

🚁追问3:JVM 中哪些内存区域会内存溢出?

除了程序计数器不会产生 OOM,其他区域都会产生 OOM.

🚁追问4:为什么要将永久代替换成元空间?

  • 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制。虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  • 在 JDK 1.8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

🚀说一下堆栈的区别?

  1. 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
  2. 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
  3. 堆是线程共享的;栈是线程私有的。

🚁追问1:什么情况下会发生栈溢出?

  1. 当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出 StackOverFlowError 异常,这种情况通常是因为方法递归没终止条件。
  2. 新建线程的时候没有足够的内存去创建对应的虚拟机栈,虚拟机会抛出 OutOfMemoryError 异常,比如线程启动过多就会出现这种情况。

🚀Java 中对象的创建过程是什么样的?

Java 中对象的创建过程为 5 步:
在这里插入图片描述

  1. 类加载检查检查是否被加载过,没被加载过要进行类加载。首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存:对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
  3. 初始化零值:将分配到的内存空间都初始化为零值(不包括对象头),保证了对象不赋初值就可以直接用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头:虚拟机对对象进行必要的设置,例如,对象是哪个类的实例、如何才能找到类的元数据信息、对象的 GC 分代年龄等信息放在对象头中。
  5. 执行 init() 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

🚁追问1:内存分配的策略有哪些?

Java 中的内存分配策略主要有两种,分别是指针碰撞空闲列表

  1. 指针碰撞:如果 Java 堆中的内存都是规整的,所有被使用过的放在一边,未使用过的放另一在边,中间有一个指针作为分界,分配内存仅仅需要把这个指针向空闲空间方向移动一段即可。
  2. 空闲列表:如果 Java 堆中的内存不是规整的,已使用过的和空闲的交错,虚拟机就需要维护一个列表,记录哪些内存是可用的,在分配的时候找到足够大的一块内存进行分配。

🚁追问2:对象头包含哪些?

在这里插入图片描述
对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

虚拟机中对象头包含两类信息

  • 第一类是用于存储对象自身运行时数据、如哈希码、GC 分代年龄、线程持有的锁、偏向线程ID、偏向时间戳。
  • 对象的另外一部分是类型指针,即对象指向它的类型元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充

  • 对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。
  • 例如,Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

🚁追问3:对象的访问定位方法有几种,各有什么优缺点?

Java 虚拟机中对象的访问方式有 ①使用句柄和 ②直接指针两种。

  1. 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

在这里插入图片描述2. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

在这里插入图片描述

总结

  • 使用句柄最大的好处就是 reference 中存储的是稳定句柄地址,在对象移动时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
  • 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

🚀JVM 在创建对象时采用了哪些并发安全机制?

1. CAS + 失败重试
线程 1 在读取一块内存空间的时候还没有分配给对象,然后比较一次以防止预处理过程中有线程(如线程 2)抢占了该块空间,如果读到值不为 null,即不相等,就再读取一次,如果这时候有值说明空间已被抢占了,就寻找下一块空间,否则,分配此块空间给线程创建的对象。
在这里插入图片描述
在这里插入图片描述

2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

每个线程在 Java 堆中预先分配一小块内存,也就是本地线程分配缓冲,这样每个线程都单独拥有一个缓冲,如果需要分配内存,就在自己的缓冲上分配,这样就不存在竞争的情况,可以大大提升分配效率。

🚀GC 如何判断对象可以被回收?

垃圾回收的根本目的是利用一些算法进行内存的管理,从而有效的利用内存空间,在进行垃圾回收前,需要判断对象的存活情况,在 JVM 中有两种判断对象的存活算法,下面分别进行介绍。

1、引用计数法:
在对象中添加一个引用计数器,每当有一个地方引用它时计数器就加 1,当引用失效时计数器减 1。当计数器为 0 的时候,表示当前对象可以被回收。

这种方法的原理很简单,判断起来也很高效,但是存在两个问题:

  • 堆中对象每一次被引用和引用清除时,都需要进行计数器的加减法操作,会带来性能损耗。
  • 当两个对象相互引用时,计数器永远不会 0. 也就是说,即使这两个对象不再被程序使用,仍然没有办法被回收,通过下面的例子看一下循环引用时的计数问题:
public void reference(){
  A a = new A();
  B b = new B();
  a.instance = b;
  b.instance = a;   
}

在方法执行完成后,栈中的引用被释放,但是留下了两个对象在堆内存中循环引用,导致了两个实例最后的引用计数都不为 0,最终这两个对象的内存将一直得不到释放,也正是因为这一缺陷,使引用计数算法并没有被实际应用在 GC 过程中。

2、可达性分析法

  • 可达性分析算法的基本思路就是,以一系列被称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,证明该对象不再存活,可以作为垃圾被回收。
  • 可达性分析算法是 JVM 默认使用的寻找垃圾的算法,需要注意的是,虽然说的是寻找垃圾,但实际上可达性分析算法寻找的是仍然存活的对象。至于这样设计的理由,是因为如果直接寻找没有被引用的垃圾对象,实现起来相对复杂、耗时也会比较长,反过来标记存活的对象会更加省时。

🚁追问1:在 Java 中,可作为 GC Roots 的对象有哪些?

  • 在虚拟机栈(栈帧的本地变量表)中引用的对象。
  • 在方法区中类静态属性引用的对象。
  • 在方法区中常量引用的对象。
  • 本地方法栈(Native 方法)中引用的对象。
  • 被同步锁 synchronized 持有的对象引用。
  • JVM 内部的引用,如基本数据类型对应的 Class 对象、一些常驻异常对象等,及系统类加载器。
  • 此外还有一些临时性的 GC Roots,这是因为垃圾收集大多采用分代收集和局部回收,考虑到跨代或跨区域引用的对象时,就需要将这部分关联的对象也添加到 GC Roots 中以确保准确性。
    在这里插入图片描述

🚁追问2:被标志为 GC 的对象一定会被 GC 掉吗?

不一定,还有逃脱的可能。真正宣告一个对象死亡至少经历两次标记的过程。

  • 可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。
  • 当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
  • 被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

🚁追问3:为什么不要使用 finalize 方法?

  1. finalize 方法执行线程优先级很低。
  2. finalize 方法只能执行一次。

🚀垃圾回收算法有哪些?

1、标记-清除算法
标记-清除算法是一种非常基础的垃圾收集算法,当堆中的有效内存空间耗尽时,会触发 STW(stop the world),然后分标记清除两阶段来进行垃圾收集工作:

  • 标记:从 GC Roots 的节点开始进行扫描,对所有存活的对象进行标记,将其记录为可达对象。
  • 清除:对整个堆内存空间进行扫描,如果发现某个对象未被标记为可达对象,那么将其回收。
    在这里插入图片描述

但是这种算法会带来几个问题:

  • 在进行 GC 时会产生 STW,停止整个应用程序,造成用户体验较差
  • 标记和清除两个阶段的效率都比较低,标记阶段需要从 GC Roots 进行扫描,清除阶段需要对堆内所有的对象进行遍历。
  • 仅对非存活的对象进行处理,清除之后会产生大量不连续的内存碎片。导致之后程序在运行时需要分配较大的对象时,无法找到足够的连续内存,会再触发一次新的垃圾收集动作

此外,JVM 并不是真正的把垃圾对象进行了遍历,把内部的数据都删除了,而是把垃圾对象的首地址和尾地址进行了保存,等到再次分配内存时,直接去地址列表中分配,通过这一措施提高了一些标记-清除算法的效率。

2、标记-复制算法

复制算法主要被应用于新生代,它将内存分为大小相同的两块,每次只使用其中的一块。在任意时间点,所有动态分配的对象都只能分配在其中一个内存空间,而另外一个内存空间则是空闲的。复制算法可以分为两步:

  1. 其中一块内存的有效内存空间耗尽后,JVM 会停止应用程序运行,开启复制算法的 GC 线程,将还存活的对象复制到另一块空闲的内存空间复制后的对象会严格按照内存地址依次排列,同时 GC 线程会更新存活对象的内存引用地址,指向新的内存地址。
  2. 在复制完成后,再把使用过的空间一次性清理掉,这样就完成了使用的内存空间和空闲内存空间的对调,使每次的内存回收都是对内存空间的一半进行回收。
    在这里插入图片描述

复制算法的的优点是弥补了标记-清除算法中,会出现内存碎片的缺点,但是它也同样存在一些问题:

  • 只使用了一半的内存,所以内存的利用率较低,造成了浪费。
  • 如果对象的存活率很高,那么需要将很多对象复制一遍,并且更新它们的应用地址,这一过程花费的时间会非常的长。

从上面的缺点可以看出,如果需要使用标记-复制算法,那么有一个前提就是要求对象的存活率要比较低才可以,因此,复制算法更多的被用于对象“朝生夕死”发生更多的新生代中。

3、标记-整理算法

标记-整理算法和标记-清除算法非常的类似,主要被应用于老年代中,可分为以下两步:

  • 标记:和标记-清除算法一样,先进行对象的标记,通过 GC Roots 节点扫描存活对象进行标记
  • 整理:将所有存活对象往一端空闲空间移动,按照内存地址依次排序,并更新对应引用的指针,然后清理末端内存地址以外的全部内存空间
    在这里插入图片描述
    可以看到,标记整理算法对前面的两种算法进行了改进,一定程度上弥补了它们的缺点:
  • 相对于标记-清除算法,弥补了出现内存空间碎片的缺点。
  • 相对于复制算法,弥补了浪费一半内存空间的缺点。

但是同样,标记-整理算法也有它的缺点,一方面它要标记所有存活对象另一方面还添加了对象的移动操作以及更新引用地址的操作,因此标记-整理算法具有更高的使用成本。

4、分代收集算法
实际上,java 中的垃圾回收器并不是只使用的一种垃圾收集算法,当前大多采用的都是分代收集算法。JVM 一般根据对象存活周期的不同,将内存分为几块,一般是把堆内存分为新生代和老年代,再根据各个年代的特点选择最佳的垃圾收集算法。主要思想如下:

  • 新生代中,每次收集都会有大量对象死去,所以可以选择标记-复制算法,只需要复制少量对象以及更改引用,就可以完成垃圾收集。
  • 老年代中,对象存活率比较高,使用复制算法不能很好的提高性能和效率。另外,没有额外的空间对它进行分配担保,因此选择标记-清除或标记-整理算法进行垃圾收集。
    在这里插入图片描述

至于为什么在某一区域选择某种算法,还是和三种算法的特点息息相关的,再从 3 个维度进行一下对比:

  • 执行效率:从算法的时间复杂度来看,标记-复制算法最优,标记-清除次之,标记-整理最低。
  • 内存利用率:标记-整理算法和标记-清除算法较高,标记-复制算法最差。
  • 内存整齐程度:标记-复制算法和标记-整理算法较整齐,标记-清除算法最差。

尽管具有很多差异,但是除了都需要进行标记外,还有一个相同点,就是在 GC 线程开始工作时,都需要 STW 暂停所有工作线程。

🚀垃圾回收器有哪些?

垃圾回收器可以在新生代和老年代都有,在新生代有 Serial、Parallel New、Parallel Scavenge。老年代有 CMS、Serial Old、Parallel Old 还有不区分年代的 G1 算法。

Serial 收集器

  • Serial 收集器是一个单线程收集器。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作**,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(Stop The World)**,直到它收集结束。
  • 新生代采用标记-复制算法,老年代采用标记-整理算法。
  • 最大的特点就是简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。

Parallel New 收集器:

  • ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
  • 新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Scavenge 收集器

  • Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。
  • Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
  • 新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old 收集器

  • Serial 收集器的老年代版本,它同样是一个单线程收集器。
  • 它主要有两大用途:一种用途是在 JDK 1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old 收集器

  • Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。
  • 在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS 收集器

  • CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
  • 使用标记-清除”算法的垃圾收集器。

G1 收集器

  • G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多处理器及大容量内存的机器,以极高概率满足 GC 停顿时间要求的同时还具备高吞吐量性能特征。

🚁追问1:CMS 垃圾回收器的过程是什么样的?会带来什么问题?

CMS 全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,如果老年代使用 CMS 垃圾回收器,需要添加虚拟机参数 -XX:+UseConcMarkSweepGC.

CMS(Concurrent Mark Sweep) 回收过程可以分为 4 个步骤:

  1. 初始标记:标记出 GC Roots 能直接关联到的对象,速度很快,但需要暂停所有其他的工作线程。
  2. 并发标记: GC 和用户线程一起工作,执行 GC Roots 跟踪标记过程,不需要暂停工作线程。
  3. 重新标记:在并发标记过程中用户线程继续运作,导致在垃圾回收过程中部分对象的状态发生了变化,为了确保这部分对象的状态的正确性,需要对其重新标记并暂停工作线程。
  4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,这个过程用户线程和垃圾回收线程同时发生。

在整个过程中,耗时最长的是并发标记和并发清除阶段,这两个阶段垃圾收集线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

带来的问题:

  1. 产生浮动垃圾。 在并发清理阶段用户线程还在运行,会不断有新的垃圾产生这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中回收它们,只好等到下一次垃圾回收再处理。
  2. CMS 是基于标记-清除算法,会产生大量的空间碎片。
  3. 对 CPU 资源敏感

🚁追问2:谈谈 G1 垃圾收集器?相比于 CMS 突出的地方是什么?

G1 将整个堆分成相同大小的分区(物理上不连续的),有四种不同类型的分区:Eden、Survivor、Old 和 Humongous. 分区的大小取值范围为 1M 到 32M,都是 2 的幂次方。分区大小可以通过 -XX:G1HeapRegionSize 参数指定。Humongous 区域用于存储大对象。G1 规定只要大小超过了一个分区容量一半的对象就认为是大对象。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

G1 收集器的回收过程分为以下几个步骤:

  • 初始标记。暂停所有其他线程,记录直接与 GC Roots 直接相连的对象,耗时较短 。
  • 并发标记。从GC Roots 开始对堆中对象进行可达性分析,找出要回收的对象,耗时较长,不过可以和用户程序并发执行。
  • 最终标记。需对其他线程做短暂的暂停,用于处理并发标记阶段对象引用出现变动的区域。
  • 筛选回收。对各个分区的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划然后把决定回收的分区的存活对象复制到空的分区中,再清理掉整个旧的分区的全部空间。这里的操作涉及存活对象的移动,会暂停用户线程,由多条收集器线程并行完成。

G1 收集器的特点:

  1. 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时减少用户线程 STW.
  2. 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
  3. 分代回收可以同时回收新生代和老年代 。
  4. 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  5. 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

🚁追问3:JDK 默认使用的是哪种垃圾回收器?

jdk 1.7 默认垃圾收集器 Parallel Scavenge(新生代)+ Parallel Old(老年代)。
jdk 1.8 默认垃圾收集器 Parallel Scavenge(新生代)+ Parallel Old(老年代)。
jdk 1.9 默认垃圾收集器 G1.

🚀内存分配策略是什么样的?

  • 对象优先在 Eden 分配:大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC.
  • 大对象直接进入老年代:大对象是指需要连续内存空间的对象,避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。最典型的大对象有长字符串和大数组。可以设置 JVM 参数 -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配。
  • 长期存活的对象进入老年代:对象在 Survivor 区每经过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。
  • 动态对象年龄判定:并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。
  • 空间分配担保:在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC.

🚁追问1:内存溢出与内存泄漏的区别?

内存溢出:实实在在的内存空间不足导致。

内存泄漏该释放的对象没有释放,多见于自己使用容器保存元素的情况下。

🚁追问2:内存泄漏如何解决?

  1. 使用静态内部类,避免线程造成的内存泄漏。
  2. 退出程序前,清除集合容器里的东西,并将集合容器置为 null,避免集合容器中的内存泄露等。

🚀Minor GC 和 Full GC的区别?

Minor GC回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。

Full GC回收老年代和新生代,老年代的对象存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

🚁追问1:Full GC 的触发条件?

对于 Minor GC,其触发条件比较简单,当 Eden 空间满时,就将触发一次 Minor GC. 而 Full GC 触发条件相对复杂,有以下情况会发生 Full GC:

  • 调用 System.gc():只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
  • 老年代空间不足老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。 为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间
  • 空间分配担保失败:使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC.
  • JDK 1.7 及以前的永久代空间不足:在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC. 如果经过 Full GC 仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError.

🚀JVM 中类的加载机制是什么样的?

类的加载指的是将类的 Class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个此类的对象,通过这个对象可以访问到方法区对应的类信息。类的加载分为 5 个阶段

1、加载:

  • 读取一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口。

加载阶段和链接阶段的部分内容是交叉进行的,加载阶段尚未结束,链接阶段可能就已经开始了。

2、验证:

  • 文件格式验证:验证字节流是否符合 Class 文件规范。
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 虚拟机规范的要求。
  • 字节码验证:通过数据流和控制流进行分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证:确保解析动作能正确执行。

3、准备:

  • 在方法区中为类变量分配内存空间并设置类中变量的初始值
  • 进行内存分配的仅包括类变量,而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  • 从概念上讲,类变量所使用的内存都应当在方法区中进行分配。但是在 JDK 1.7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。

4、解析:

  • 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
  • 符号引用就是一组符号来描述目标,可以是任何字面量。
  • 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

5、初始化:

  • 执行初始化方法 <clinit> () 方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

🚁追问1:类加载器有哪些?

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自 java.lang.ClassLoader

  • BootstrapClassLoader(启动类加载器) :最顶层的加载类,由 C++ 实现,负责加载 %JAVA_HOME%/lib 目录下的 jar 包和类或者被 -Xbootclasspath 参数指定的路径中的所有类。
  • ExtensionClassLoader(扩展类加载器) :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  • AppClassLoader(应用程序类加载器) :面向用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

在这里插入图片描述

🚁追问2:什么叫双亲委派机制?

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
  • 加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 当父类加载器无法处理时,才由自己来处理。
  • 当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

双亲委托机制的优点:

  • 通过带有优先级的层级关系可以避免类的重复加载
  • 保证 Java 程序安全稳定运行,Java 核心 API 不会被篡改
    在这里插入图片描述

🚁追问3:如何打破双亲委派机制?

  • 自定义加载器的话,需要继承 ClassLoader. 如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
  • 如果想打破双亲委派模型则需要重写 loadClass() 方法。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ReadThroughLife

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值