跳到主要内容

JVM

1、什么是 JVM 内存结构?

JVM 将虚拟机分为5大区域,程序计数器、虚拟机栈、本地方法栈、java堆、方法区;

  • 程序计数器:线程私有的,是一块很小的内存空间,作为当前线程执行的字节码的行号指示器,用来存储下一条将要执行的字节码指令的地址;
  • 虚拟机栈:线程私有的,每个线程在创建时都会创建一个虚拟机栈,虚拟机栈由许多栈帧组成,每个方法被执行的时候,JVM 都会同步创建一个栈帧,用于存储局部变量表、操作数、动态链接和方法返回等信息。方法执行时,栈帧入栈,执行完成后,栈帧出栈。当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出StackOverFlowError;当创建线程时没有足够的栈空间,会抛出 OOM 异常。
  • 本地方法栈:线程私有的,保存的是native方法的信息,当一个 JVM 创建的线程调用native方法后, JVM 不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法;
  • 堆:java 堆是所有线程共享的一块内存,几乎所有对象的实例和数组都要在堆上分配内存,因此该区域经常发生垃圾回收的操作;
  • 方法区:是各个线程共享的内存区域,用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据。在jdk1.8中不存在方法区了,被元数据区替代了,原方法区被分成两部分;1:加载的类信息,2:运行时常量池;加载的类信息被保存在元数据区中,运行时常量池保存在堆中;元数据区使用的是本地内存。
  • 为什么需要程序计数器呢?
    • 通过修改程序计数器的值来确定下一条应该执行什么样子的指令。
  • 为什么程序计数器是线程私有的?
    • 为了准确记录各个线程正在执行的当前字节码指令的地址,采用线程独立的设计可以独立计算,不会出现相互干扰的情况。

2、什么是 Java 内存模型?

Java 内存模型规定了变量的访问规则,保证了操作的原子性、可见性、有序性。

3、heap 和 stack 有什么区别?

先介绍下堆和栈分别是什么,有什么作用。
虚拟机栈:线程私有的,每个线程在创建时都会创建一个虚拟机栈,虚拟机栈由许多栈帧组成,每个方法被执行的时候,JVM 都会同步创建一个栈帧,用于存储局部变量表、操作数、动态链接和方法返回等信息。方法执行时,栈帧入栈,执行完成后,栈帧出栈。当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出StackOverFlowError;当创建线程时没有足够的栈空间,会抛出 OOM 异常。
:java 堆是所有线程共享的一块内存,几乎所有对象的实例和数组都要在堆上分配内存,因此该区域经常发生垃圾回收的操作;

  • 栈线程私有,堆线程共享
  • 存储的内容不同,栈存储的是栈帧,堆存储的是对象。
  • 对象的申请方式不同,栈是 jvm 自动生成,堆需要程序主动申请
  • 申请的效率不一样,栈是由系统自动创建,效率较高,堆由 new 分配的内存,速度一般较慢。
  • 申请后系统的响应不同,栈是有空间就分配,没空间就提示栈溢出。堆分配内存有两种方式,指针碰撞和空闲列表。
    • 指针碰撞:Java 堆的内存是规整的,使用的内存放在一边,未使用的内存放在另一边,中间使用一个指针进行分隔,当分配内存时,指针向空闲内存方向移动一个等于对象内存大小的距离。
    • 空闲列表:Java 堆的内存不是规整的,JVM 采用一个列表来记住内存空闲节点,在分配内存时找到一个足够大内存节点,将其分配给程序,并更新空闲列表。
  • 栈内存比较小,堆内存比较大
  • 栈不存在垃圾回收,堆需要进行垃圾回收。 栈和堆都会出现内存溢出的问题。

4、什么情况下会发生栈内存溢出?

栈是线程私有的,它的生命周期和线程一样,随着线程的启动而创建,在线程终止时销毁。每个方法在执行时会创建一个栈帧,用于存放局部变量表、操作数、动态链接、方法返回等信息。

  1. 当线程请求的栈深度超过 JVM 允许的最大深度时会抛出 StackOverFlowError 异常。 一般是递归的时候出现该问题。可以使用 -Xss 来设置 JVM 栈的大小。
  2. 当创建线程时,如果栈的内存不足,会抛出 OOM 异常。

5、谈谈对 OOM 的认识?如何排查 OOM 的问题?

StackOverFlowError:递归调用、方法内部定义了大量的局部变量导致栈空间不足。 可以通过 -Xss 来设置 JVM 栈的大小。
Java heap space:创建一个超大的对象、大量对象引用没有释放,JVM 无法对其自动回收,通过 -Xmx 来设置 JVM 堆内存空间
GC overhead limit exceeded:JVM 花费了 98% 以上的时间执行 GC ,但只恢复了不到 2% 的内存就会报错。
Direct buffer memory:通过 native 方法直接分配对堆外内存,直接内存溢出。
Unable to create new native thread:不能创建更多的线程了
Metaspace: 元空间不足
Requested array size exceeds VM limit
Out of swap space
kill process or sacrifice child

9 种 OOM 实例

排查 OOM 的方法:

  • 增加两个参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录;
  • 同时 jstat 查看监控 JVM 的内存和 GC 情况,先观察问题大概出在什么区域;
  • 使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以把改为弱引用 。

6、谈谈 JVM 中的常量池? JVM 常量池主要分为Class文件常量池、运行时常量池,全局字符串常量池,以及基本类型包装类对象常量池

  • class 文件常量池:主要存放字面量和符号引用。字面量包括字符串和用 final 修饰的成员变量(包括静态变量、实例变量和局部变量)。符号引用包括类和接口的全限定名称、字段的名称和描述符、方法的名称和描述符。在类加载是会被加载到运行时常量池中。
  • 运行时常量池:运行时常量池是基于class 文件常量池创建的,运行时常量池时可以动态扩展的,在运行时可以通过代码生成常量并将其放入运行时常量池,这种特性被用的最多的是 String.intern(). 运行时常量池的数据有类和接口的数量决定,每个类或者接口被 JVM 加载时都会创建一个运行时常量池。
  • 全局字符串常量池:字符串常量池是 JVM 所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。 
  • 基本类型包装类对象常量池:java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

常量池

  • constant_pool_count 常量池的数量,从 1 开始。0 表示不引用任何一个常量池。
  • constant_pool[constant_pool_count-1] 常量池

常量池主要存放:字面量符号引用,这些内容在类加载阶段被加载到运行时常量池中。

  1. 字面量
  • 文本字符串 public String s = "abc"; 中的 abc
  • final 修饰的成员变量,包括静态变量、实例变量和局部变量。
  1. 符号引用
  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

运行时常量池

运行时常量池是方法区的一部分。不同的类公用一个运行时常量池。

Class 文件中除了包含类的版本、字段、方法、接口等描述信息外还有一个 用于存放编译期生成的各种字面量和符号引用 的常量池,这部分内容将在类加载后放到方法区的**运行时常量池**中。

运行时常量池的作用?

  1. 存储字面量和符号引用
  2. 支持动态链接,在类加载的链接-解析阶段,会将符号引用转成直接引用。
  3. 支持 String.intern() 方法,可以确保字符串常量池只有一个字符串的唯一实例。
  4. 促进代码的优化,JIT 可以根据运行时常量池中的信息进行代码优化。(通过静态链接代替动态链接)

字符串常量池

为了减少在 jvm 中创建的字符串的数量,虚拟机维护了一个 字符串常量池。 当创建 String 对象时,jvm 会先检查字符串常量池,如果这个字符串的常量值已经存在字符串常量池中,就直接返回池中的对象,如果不在池中,就会实例化一个字符串并放入池中。 字符串常量池中是不会存储相同的字符串 字符串常量池是一个固定大小的 HashTable

String s = new String("wangzhy");

创建了两个对象(先在字符串常量池中创建对象,再去堆上创建对象):使用 new 关键字创建一个字符串对象时,JVM 会现在字符串常量池中查找有没有这个字符串,如果有就不会在字符串常量池中创建这个对象,直接在堆中创建这个字符串对象,然后将堆中这个对象的地址返回赋值给 s。

String s = "wangzhy";

创建一个对象(找字符串常量池中查找,没有就创建):JVM 先去字符串常量池中查找 "wangzhy" 字符串是否已存在,如果不存在,就在字符串常量池中创建一个 wangzhy 字符串对象。然后直接将字符串常量池中 wangzhy 这个对象的地址返回并赋值给 s。

7、如何判断一个对象是否存活?

  1. 引用计数法:给对象设置一个引用计数器,当有一个地方引用该对象的时候,计数器加 1 ,引用失效时,计数器 -1 ,当计数器为 0 的时,就说明这个对象没有被引用,即垃圾对象,需等待回收。无法解决循环引用的问题
  2. 可达性分析算法:以一系列 GC roots 的对象为起始节点,根据引用关系向下搜索,如果某个对象到 GC roots 间没有任何引用链相连时,说明此对象不可用的,即垃圾对象。
  • 虚拟机栈中引用的对象(栈帧中的局部变量表中的对象)
  • 方法区在中静态属性引用的变量
  • 方法区中常量池引用的对象
  • 本地方法栈 JNI 引用的对象即 native 方法引用的对象

对象回收,要经历两次标记过程:

  1. 第一次标记,判断是否重写了 finalize 方法,未重写,判断为垃圾对象,收集器对其回收。有重写 finalize 方法,进入第二步
  2. 将标记的对象添加到 F-Queue ,由 Finalizer 线程执行对象的 finalize 方法。
  3. 收集器会对 F-Queue 中的对象进行第二次标记,如果此时还是未直接或间接引用 GC roots 对象,则认为是垃圾对象,对其回收。

如果对象在进行可达性分析后发现没有与 GC roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。 加入对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。 如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里的执行是指触发这个方法开始运行,并不保证会等待它们运行结束。原因是:有的 finalize() 方法会很耗时,会阻塞其他对象的 finalize() 方法的执行。 收集器会对 F-Queue 中的对象进行第二次标记,如果此时该对象与 GC roots 没有直接引用或间接引用,该对象会被回收。

8、强引用、软引用、弱引用、虚引用是什么,有什么区别?

  • 强引用,就是普通的对象引用关系,如 String s = new String("ConstXiong")
  • 软引用,用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。SoftReference 实现
  • 弱引用,相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。WeakReference 实现
  • 虚引用是一种形同虚设的引用,在现实场景中用的不是很多,它主要用来跟踪对象被垃圾回收的活动。PhantomReference 实现

9、被引用的对象就一定能存活吗?

不一定,看 Reference 类型,弱引用在 GC 时会被回收,软引用在内存不足的时候,即 OOM 前会被回收, 但如果没有在 Reference Chain 中的对象就一定会被回收。

10. Java中的垃圾回收算法有哪些?

  1. 标记清除算法(内存碎片)
    • 标记
    • 清除被标记的对象
  2. 标记复制算法(不会产生内存碎片,内存利用率不高,只能使用一半的内存)
    • 标记
    • 向前另一半内存复制
  3. 标记整理算法(适用于存活对象少的情况)
    • 标记
    • 将对象向前复制
  4. 分代收集算法 根据内存对象的存活周期不同,将内存划分为几块,jvm 一般将内存分为新生代和老年代。 在新生代中,大量的对象死去和少量对象存活,所以采用复制算法。 老年代因为对象存活率极高,没有额外的空间对他进行分配担保,所以采用标记清理或标记整理算法进行回收。

12. 详细说一下CMS的回收过程?CMS的问题是什么?

CMS,Concurrent Mark Sweep,并发标记清除收集器是一种以获取最短回收停顿时间为目标的收集器。

  1. 初始标记 CMS initial mark:初始标记仅仅只是标记一下 GC roots 能直接关联到的对象,速度很快
  2. 并发标记 CMS concurrent mark:从GC roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  3. 重新标记 CMS remark :为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  4. 并发清除 CMS concurrent sweep:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

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

优点:并发收集、低停顿; 缺点:

  1. CMS 收集器对处理器资源非常敏感。在并发标记阶段,虽然不会导致用户线程停顿,但却会占用一部分线程,导致应用程序变慢。
  2. CMS 收集器无法处理“浮动垃圾”。在并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随着新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”
  3. CMS 收集器是基于标记-清除算法实现的收集器,再收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但 就是无法找到足够大的连续空间来分配对象,而不得不提前触发一次 Full GC 的情况。

CMS(Concurrent Mark Sweep,并发标记清除) 收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

从名字就可以知道,CMS是基于“标记-清除”算法实现的。CMS 回收过程分为以下四步:

  1. 初始标记 (CMS initial mark):主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。

  2. 并发标记 (CMS concurrent mark):根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。

  3. 重新标记(CMS remark):顾名思义,就是要再标记一次。为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。

  4. 并发清除(CMS concurrent sweep):清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。

CMS 的问题:

1. 并发回收导致CPU资源紧张:

在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程数是:(CPU核数 + 3)/ 4,当CPU核数不足四个时,CMS对用户程序的影响就可能变得很大。

2. 无法清理浮动垃圾:

在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。

3. 并发失败(Concurrent Mode Failure):

由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX**:** CMSInitiatingOccupancyFraction 参数来设置。

这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。

4.内存碎片问题:

CMS是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。

为了解决这个问题,CMS收集器提供了一个 -XX**:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX:**CMSFullGCsBeforeCompaction,这个参数的作用是要求CMS在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。

13. 详细说一下G1的回收过程?

Garbage First,G1 收集器采用面向局部收集的设计思路和基于 Region 的内存布局形式。G1 可以面向堆内存任何部分来组成回收集进行回收。

  1. 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且时借用进行 Minor GC的时候同步完成的,所以G1 收集器在这个阶段实际没有额外的停顿。
  2. 并发标记,从 GC roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这个阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。
  3. 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量 SATB 记录。
  4. 筛选回收:负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成会收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 空间。这里的操作设计存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1 收集器除并发标记外,其余阶段也是要完全暂停用户线程的。

G1(Garbage First)回收器采用面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾回收器。G1设计初衷就是替换 CMS,成为一种全功能收集器。G1 在JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个Region之间)上看又是基于 标记-复制 算法实现的。

G1 回收过程,G1 回收器的运作过程大致可分为四个步骤:

  1. 初始标记(会STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

  2. 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。

  3. 最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。

  4. 清理阶段(会STW):更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

14. JVM 中一次完整的GC是什么样子的?

先描述一下Java堆内存划分。

在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old ),新生代默认占总空间的 1/3,老年代默认占 2/3。 新生代有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。

新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。

老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。

再描述它们之间转化流程:

  • 对象优先在Eden分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

    • 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区;

    • Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
    • 移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。GC年龄的阀值可以通过参数 -XX:MaxTenuringThreshold 设置,默认为 15;
    • 动态对象年龄判定:Survivor 区相同年龄所有对象大小的总和 > (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。其中这个使用率通过 -XX:TargetSurvivorRatio 指定,默认为 50%;
    • Survivor 区内存不足会发生担保分配,超过指定大小的对象可以直接进入老年代。
  • 大对象直接进入老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

  • 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和老年代

15. Minor GC 和 Full GC 有什么不同呢?

Minor GC:只收集新生代的GC。

Full GC: 收集整个堆,包括 新生代,老年代,永久代(在 JDK 1.8及以后,永久代被移除,换为metaspace 元空间)等所有部分的模式。

**Minor GC触发条件:**当Eden区满时,触发Minor GC。

Full GC触发条件

  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存。如果发现统计数据说之前Minor GC的平均晋升大小比目前old gen剩余的空间大,则不会触发Minor GC而是转为触发full GC。
  • 老年代空间不够分配新的内存(或永久代空间不足,但只是JDK1.7有的,这也是用元空间来取代永久代的原因,可以减少Full GC的频率,减少GC负担,提升其效率)。
  • 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
  • 调用System.gc时,系统建议执行Full GC,但是不必然执行。

16. 介绍下空间分配担保原则?

如果YougGC时新生代有大量对象存活下来,而 survivor 区放不下了,这时必须转移到老年代中,但这时发现老年代也放不下这些对象了,那怎么处理呢?其实 JVM 有一个老年代空间分配担保机制来保证对象能够进入老年代。

在执行每次 YoungGC 之前, JVM 会先检查老年代最大可用连续空间是否大于新生代所有对象的总大小。因为在极端情况下,可能新生代 YoungGC 后, 所有对象都存活下来了,而 survivor 区又放不下,那可能所有对象都要进入老年代了。这个时候如果老年代的可用连续空间是大于新生代所有对象的总大小的, 那就可以放心进行 YoungGC。但如果老年代的内存大小是小于新生代对象总大小的,那就有可能老年代空间不够放入新生代所有存活对象,这个时候 JVM 就 会先检查 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许,就会判断老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于,将尝试进行一次YoungGC,尽快这次YoungGC是有风险的。如果小于,或者 -XX:HandlePromotionFailure 参数不允许担保失败,这时就会进行 一次 Full GC。

在允许担保失败并尝试进行YoungGC后,可能会出现三种情况:

  • ① YoungGC后,存活对象小于survivor大小,此时存活对象进入survivor区中
  • ② YoungGC后,存活对象大于survivor大小,但是小于老年大可用空间大小,此时直接进入老年代。
  • ③ YoungGC后,存活对象大于survivor大小,也大于老年大可用空间大小,老年代也放不下这些对象了,此时就会发生“Handle Promotion Failure”,就触发了 Full GC。如果 Full GC后,老年代还是没有足够的空间,此时就会发生OOM内存溢出了。

通过下图来了解空间分配担保原则:

17. 类加载的过程?

  • 加载 Loading
    1. 根据类的全限定名称读取字节码文件到内存中
    2. 将字节码流所代表的静态存储结构转换为方法区的运行时数据结构。
    3. 在内存中生成一个代表该类的 Class 对象,作为方法区这个类的各种数据的访问入口。
  • 连接 Linking
    • 确认 Verification(确认字节码文件符合要求)
      • 确保 Class 文件的字节流中包含的信息符合 JVM 规范,保证这些信息被当作代码运行后不会危害到虚拟机自身的安全。
        • 文件格式验证
        • 元数据验证
        • 字节码验证
        • 符号引用验证
    • 准备 Preparation(分配空间和初始值)
      • 为类中定义的静态变量分配内存并设置类变量初始值。
      • public static int a = 123; 分配内存并设置初始值为 0; 设置 a 的值为 123 是在初始化阶段。
      • public static final int b = 234; 分配内存并设置初始值为 234;
    • 解析 Resolution(将符号引用改为直接引用,直接引用(直接执行实例对象的指针,内存地址))
  • 初始化 Initialization 真正开始执行类中编写的 Java 程序代码。
    • 静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
    • 父类中定义的静态语句块要优先于子类的变量赋值操作。
    • 接口中不能使用静态语句块,但仍有变量初始化的赋值操作。只有当父接口中定义的变量被使用时,父接口才会被初始化

18. 什么是类加载器,常见的类加载器有哪些?

类加载器的种类

  • 启动类加载器:负责加载 lib、-Xbootclasspath 指定的路径的类库到 JVM 中。
  • 扩展类加载器:lib/ext 、java.ext.dir 指定的类库。
  • 应用程序类加载器:加载用户类路径上的所有类库
  • 自定义类加载器

19. 什么是双亲委派模型?为什么需要双亲委派模型?

什么是双亲委派模型? 如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此 所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。

双亲委派机制的好处? Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。

20. 列举一些你知道的打破双亲委派机制的例子,为什么要打破?

  • JNDI 通过引入线程上下文类加载器,可以在 Thread.setContextClassLoader 方法设置,默认是应用程序类加载器,来加载 SPI 的代码。有了线程上下文类加载器,就可以完成父类加载器请求子类加载器完成类加载的行为。打破的原因,是为了 JNDI 服务的类加载器是启动器类加载,为了完成高级类加载器请求子类加载器(即上文中的线程上下文加载器)加载类。

  • Tomcat,应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。

    tomcat之所以造了一堆自己的classloader,大致是出于下面三类目的:

    • 对于各个 webapp中的 classlib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况,而对于许多应用,需要有共享的lib以便不浪费资源。
    • JVM 一样的安全性问题。使用单独的 classloader去装载 tomcat自身的类库,以免其他恶意或无意的破坏;
    • 热部署。

    tomcat类加载器如下图:

  • OSGi,实现模块化热部署,为每个模块都自定义了类加载器,需要更换模块时,模块与类加载器一起更换。其类加载的过程中,有平级的类加载器加载行为。打破的原因是为了实现模块热替换。

  • JDK 9,Extension ClassLoader 被 Platform ClassLoader 取代,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。打破的原因,是为了添加模块化的特性。

22. Java对象创建过程

  1. 检查类是否已被加载、连接、初始化,如果没有就执行对应的类加载过程。
  2. 给对象分配内存空间
  • 指针碰撞
  • 空闲列表
  1. 并发情况下保证修改的原子性
  • 对分配内存空间的动作进行同步处理
  • 使用本地线程缓冲区
  1. 内存分配完成后,将除对象头外的对象内存空间初始化为0
  2. 对对象头进行必要设置

当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。 在类加载检查通过后,虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来。

  • Java 堆是规整的,所有被使用过的内存都放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”
  • Java 堆不是规整的,已经被使用的内存和空闲的内存相互交错在一起,那就没办法简单地进行指针碰撞了,虚拟机必须维护一个列表,记录上哪些内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给实例对象,并更新列表上的记录,这种分配方式称为“空闲列表”

除了如何划分空间之外,还有另一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发的情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。

  1. 对分配内存空间的动作进行同步处理----采用 CAS 配上失败重试的方式保证更新操作的原子性
  2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆上预先分配一小块内存,称为本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的本地缓存区中分配。只有本地缓存分配区用完了,分配新的缓存区时才需要同步锁定。

内存分配完成之后,虚拟机必须将分配到的内存空间都初始化为零值,如果使用了本地线程分配缓冲的话,这一项工作也可以提前到 TLAB 分配时顺便进行。

Java 虚拟机还要对对象进行必要的设置。

内存泄露与内存溢出

内存溢出:程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
内存泄露:程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占用着内存,既不能被使用也不能分配给其他程序,于是就发生了内存泄漏。