JVM面试题

163 阅读40分钟

image.png

image.png

一、JVM类加载

从源代码(.java)到代码的执行过程?

4步:编译->加载->解释->执行

  1. 编译:将.java文件变成.class文件的过程,其中会进行语法分析、语义分析、注解处理等操作。
  2. 加载:类加载器将.class字节码文件加载到JVM中的过程
    1. 装载:通过一个类的全限定名来获取其定义的二进制字节流;将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

image.png

  1. 连接:
    1. **验证:**验证类是否符合Java规范和JVM规范
    2. **准备:**为类的静态变量分配内存,初始化为系统的初始值
    3. **解析:**将符号引用转化为直接引用
  2. **初始化:**为类的静态变量赋予正确的初始值
  3. 解释:把字节码转换为操作系统能够识别的指令
  4. 执行:执行操作系统能够识别的指令,调用系统的硬件执行程序。

什么是类加载器?类加载器的作用?

微信图片_20220119230636.jpg
类加载器将.class字节码文件加载到内存中,并将这些静态数据结构转换成方法区的运行时数据结构,然后在堆中生成一个代表这个类的java.lang.Class对象,作为方法区中类数据的访问入口。
类加载器的顺序依次是:

  1. **启动类加载器(Bootstrap ClassLoader):**负责将存放在 <JAVA_HOME>/lib 目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。
  2. **扩展类加载器(Extension ClassLoader):**负责加载 <JAVA_HOME>\lib\ext 目录中的所有类库,开发者可以直接使用扩展类加载器。​
  3. **应用程序类加载器(Application ClassLoader):**它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  4. 自定义加载器(User ClassLoader)

双亲委派模型的工作过程?

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

  1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

如何打破双亲委派模型?

只要加载类的时候,不是从APPClassLoader->Ext ClassLoader->BootStrap ClassLoader 这个顺序找,那就算是打破了;因为加载class核心的方法在LoaderClass类的loadClass方法上(双亲委派机制的核心实现);
那只要自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。

有哪些场景打破了双亲委派模型?

典型的打破双亲委派模型的框架和中间件有tomcat。
在初学时部署项目,我们是把war包放到tomcat的webapp下,这意味着一个tomcat可以运行多个Web应用程序。
那假设我现在有两个Web应用程序,它们都有一个类,叫做User,并且它们的类全限定名都一样,比如都是com.yyy.User。但是他们的具体实现是不一样的。
Tomcat给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找,那这样就做到了Web应用层级的隔离。

使用双亲委托机制的好处?

  1. 能够有效确保一个类的全局唯一性。当程序中出现多个限定名相同的类时,类加载器在执行加载时,始终只会加载其中的某一个类。
  2. 能够保证安全性。**例如:**类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委托给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种加载器环境中都是同一个类。相反,如果没有使用双亲委托模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。如果自己去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行。

二、JVM内存结构

JVM内存是怎样划分的?

image.png

1.程序计数器

  1. 程序计数器用于记录各个线程执行的字节码的地址(分支、循环、跳转、异常、线程恢复等都依赖于计数器)
  2. JVM内存结构中唯一一个不会出现OOM的区域。
  3. 线程私有,生命周期和线程的生命周期一样。

2.虚拟机栈

  1. 每个线程在创建的时候都会创建一个**「虚拟机栈」每次方法调用都会创建一个「栈帧」。每个「栈帧」**会包含几块内容:局部变量表、操作数栈、动态连接和方法返回地址
  2. 线程私有,生命周期和线程的生命周期一样。

image.png
垃圾回收是否涉及栈内存?
不需要。因为虚拟机栈中是由一个个的栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
栈内存的分配越大越好吗?
不是,因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程。
栈中可能会出现的异常?

  1. 栈帧过多(无限递归)导致占**StackOverflowError **。
  2. 栈帧过大或在创建新的线程时没有足够的内存去创建对应的虚拟机栈导致OOM

3.本地方法栈

  1. 本地方法栈跟虚拟机栈的功能类似,虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。这里的「本地方法」指的是「非Java方法」,一般本地方法是使用C语言实现的。
  2. 本地方法栈也是线程私有的。
  3. 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 **StackOverflowError **异常。

4.方法区

image.png

  1. 方法区(Method Area)是所有线程共享的内存区域。
  2. HotSpot虚拟机在「JDK8前」用「永久代」实现了「方法区」;在JDK8中,已经用「元空间」来替代了「永久代」作为「方法区」的实现。
  3. 方法区主要是用来存放已被虚拟机加载的「类相关信息」:包括类信息、常量池。
    1. **类信息包括:**类的版本、字段、方法、接口和父类等信息。
    2. 常量池又可以分「静态常量池」和「运行时常量池」
      1. 静态常量池主要存储的是「字面量」以及「符号引用」等信息,也包括「字符串常量池」
      2. 运行时常量池存储的是「类加载」时生成的「直接引用」等信息。
  4. 从「逻辑分区」的角度而言**「常量池」是属于「方法区」的;对于「物理分区」来说「运行时常量池」和「静态常量池』就属于堆**。

5.堆

Java堆的内存结构?

微信图片_20220120224256.jpg

  1. Java堆中是JVM管理的最大一块内存空间,主要存放对象实例
  2. JVM 的 Heap 堆内存在物理上被划分为两部分:年轻代(1/3),老年代(2/3)
  3. 年轻代又被分为了eden、survivor 0、survivor 1(8:1:1)。所有对象都是在eden区中创建出来。
  4. 年轻代这样划分是为了更好的管理堆内存中的对象,方便GC复制算法来进行垃圾回收。JVM每次只会使用eden和其中一块survivor来为对象服务,所以无论什么时候,都会有一块survivor空间,因此年轻代实际可用空间只有90%。

为什么要分为Eden和Survivor?

如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Full GC。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多,所以需要分为Eden和Survivor。

为什么要设置两个Survivor区?

设置两个Survivor区最大的好处就是解决了碎片化,因为年轻代的垃圾回收算法是复制算法,复制算法就需要两块相同的内存空间来进行回收垃圾。
刚刚新建的对象放在Eden中,等触发Minor GC的时候,每次只使用Eden和from survivor,将Eden区和from区中仍然存活的对象copy到to survivor区中,然后清理掉Eden区和from区,存活的对象年龄加1,然后交换to survivor和from survivor,保持to区为空。这个过程能够避免产生内存碎片,但是会使用两倍的内存空间。

年轻代和老年代设置多大才算合理?

更大的年轻代必然导致更小的老年代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的Full GC。
更小的年轻代必然导致更大老年代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率。
年轻代内存最好设置为:并发量*(请求 - 响应数量)

  • 应该依赖应用程序对象生命周期的分布情况:如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。但很多应用都没有这样明显的特性。
  • 本着Full GC尽量少的原则,让老年代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 。通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给老年代至少预留1/3的增长空间。

Java堆常用参数(JVM常用参数)?

  1. **-Xmx:**最大堆大小,默认物理内存的 1/4。
  2. **-Xms:**初始化堆大小,默认物理内存的 1/64。
  3. **-Xmn:**年轻代大小,默认整个堆的 3/8。
  4. **-XX:NewRatio:**老年代与年轻代的比值,如果 xms=xmx,且设置了 xmn 的情况,该参数不用设置。
  5. **-XX:SurvivorRatio:**Eden区和Survivor区的大小比值,设置为8,则两个 Survivor 区与一个Eden区的比值为 2:8,一个 Survivor 占整个新生的 1/10。
  6. **-XX:MaxTenuringThreshold:**进入老年代阈值设置。
  7. **-XX:+HeapDumpOnOutOfMemoryError:**OOM时导出堆到文件。
  8. **-XX:+HeapDumpPath:**导出堆信息的文件路径。
  9. **-XX:OnOutOfMemoryError:**当系统产生OOM时,执行一个指定的脚本,这个脚本可以是任意功能的。比如生成当前线程的dump文件,或者是发送邮件和重启系统。
  10. -Xss:栈内存大小。
  11. **-XX:+PrintGCDetails:**输出GC日志详细信息。
  12. **-Xloggc:./gclogs:**设置GC日志信息的输出路径。

对象在堆中的生命周期?

  1. 在 JVM 内存模型的堆中,堆被划分为新生代和老年代
    1. 新生代又被进一步划分为 Eden区Survivor区,Survivor 区由 From SurvivorTo Survivor 组成
  2. 当创建一个对象时,对象会被优先分配到新生代的 Eden 区
    1. 此时 JVM 会给对象定义一个对象年轻计数器(-XX:MaxTenuringThreshold)
  3. 当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
    1. JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
    2. 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
  4. 如果分配的对象超过了-XX:PetenureSizeThreshold,对象会直接被分配到老年代

JVM中对象的内存布局?

在 HotSpot 虚拟机中,对象的内存布局分为以下 3 块区域:对象头、实例数据和对齐填充
image.png

  1. 对象头:存放运行时数据和类型指针,大小固定。

  1162587-20200918154125385-1537793659.png

  • 运行时数据:用于存储对象自身的运行时数据,如哈希码(必须在程序中使用后才会存储)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等信息
  • **类型指针:**通过该指针能确定对象属于哪个类。如果对象是一个数组,那么对象头还会包括数组长度。
  1. **实例数据:**实例数据部分就是成员变量的值,其中包括父类成员变量和本类成员变量。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节,例如boolean类型占1个字节,int类型占4个字节等等。
  2. **对齐填充:**这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为 HotSpot 要求对象起始地址都是8字节的整数倍,如果不是,就对齐填充。

Java对象在JVM中的创建过程?

  1. 类加载检查

遇到一条 new 指令,首先会根据new的参数在常量池检查是否有这个类的符号引用。
如果没找到这个符号引用,说明类还没有被加载,则进行类的加载、解析和初始化操作。

  1. 为对象分配内存

在类加载完成后,从Java堆中划分出来一部分内存给对象,内存分配有两种方式:指针碰撞和空闲列表

  • **指针碰撞:**如果 Java 堆中内存绝对规整(说明采用的是“复制算法”或“标记整理法”),空闲内存和已使用内存中间放着一个指针作为分界点指示器,那么分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离,这种分配方式称为“指针碰撞”。

image.png

  • 空闲列表:如果 Java 堆中内存并不规整,已使用的内存和空闲内存交错(说明采用的是标记-清除法,有内存碎片),此时没法简单进行指针碰撞, JVM 必须维护一个列表,记录其中哪些内存块空闲可用。分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例。这种方式称为“空闲列表”。

image.png

  1. 将分配的内存初始化为零值(不包含对象头)
  2. 调用对象的方法

JVM创建对象怎么保证线程安全?

虚拟机采用两种方式来保证线程安全:CAS+失败重试和TLAB

  1. CAS+失败重试

CAS是乐观锁的一种实现方式,通过比对原值和旧的预期值来确定是否要将原值更改为新值,如果通过CAS来实现线程安全,那么需要三个因子,首先是在主内存中的一个原值,然后第二个是这个原值在各个线程中的副本,再接下来就是新值。

  • **指针碰撞的方式:**当前指针指向0,线程A比线程B稍微早一点获取到指针的值(都是0),然后线程A需要5个内存,当线程A提交的时候检查现在的值是否是0,发现还是0,那么就将当前指针修改为5。但是当线程B想要提交的时候,发现指针的值已经变为5,不再是0了,那么它提交失败,重新获取最新值,然后一共下次提交的时候进行比较,直到提交成功为止。
  • **空闲列表的方式:**同样的A、B两个线程都需要创建对象分配空间,A需要5,B需要7。首先是A线程,此时的原值是可用的内存空间0-100,A和B的旧的预期值也都是0-100,这个时候A先去创建对象,检查通过后,虚拟机分配了7-12的内存给了A线程的对象,同时将原值以及A线程的旧的预期值更改为0-7,12-100。这个时候B再去提交创建对象的时候,旧的预期值是0-100,与0-7,12-100比对不通过,将预期值更新为0-7,12-100,然后触发失败重试,再次提交的时候比对通过,提交成功,更新原值和线程B的预期值,以此类推。
  1. TLAB

TLAB是本地线程分配缓冲。每个线程都会在Eden空间申请到一个或多个TLAB,大小占Eden空间的1%,当然申请这个空间的过程是线程同步的,这个同步的实现也是依赖于CAS+失败重试的方式。
当这个线程需要创建对象的时候,直接在TLAB里面创建就行了,这样就避免因并发而导致的线程安全问题。有可能现在这个线程需要创建一个对象,但是当前的TLAB的空间不足了,那么它会再向Eden空间去申请一个TLAB,申请的过程是线程同步的,它会把这个对象放到新的TLAB中,也就是说一个线程并不是只有一个TLAB。
那如果这个对象特别大,哪怕是一个新的TLAB也放不下呢?直到这个时候,线程才会去把对象直接创建在Eden空间,再次采用CAS+失败重试的方法去保证线程同步。
**总结:**采用这种方式,线程会向Eden空间申请线程私有的TLAB来创建对象,确保线程安全,除非说现在的TLAB不够用了,再去申请新的TLAB的时候才会同步锁定,或者说是对象特别大,一个全新的TLAB空间都装不下了,必须去Eden空间创建,才会同步锁定。

对象的访问方式?

所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的。也就是说在建立一个对象时两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。 那么根据引用存放的地址类型的不同,对象有不同的访问方式。

句柄

堆中需要有一块叫做“句柄池”的内存空间,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
引用类型的变量存放的是该对象的句柄地址(reference)。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址找到对象。
image.png

直接指针

引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。但对象所在的内存空间需要额外的策略存储对象所属的类信息的地址。
image.png
需要说明的是,HotSpot 采用第二种方式,即直接指针方式来访问对象,只需要一次寻址操作,所以在性能上比句柄访问方式快一倍。但像上面所说,它需要额外的策略来存储对象在方法区中类信息的地址。

内存溢出(OOM)和内存泄漏的区别?

  1. 内存溢出:指程序申请内存时,没有足够的内存供申请者使用。给了你一块存储int类型数据的存储空间,但是你却存储long类型的数据,那么结果就是内存不够用,此时就会报错OOM。
    1. 内存中加载的数据量过于庞大,如一次从数据库取出过多数据。
    2. 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收。
    3. 代码中存在死循环或循环产生过多重复的对象实体。
    4. 使用的第三方软件中的BUG。
    5. 启动参数内存值设定的过小。
  2. **内存泄漏:**指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。

三、垃圾回收机制

如何判断对象是否可以回收?

  1. **引用计数器法:**​在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。

引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题。

  1. **可达性分析法(Java中是使用可达性分析算法判断对象是否存活的):**​所有和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。

GC Roots节点的选取(肯定不能当做垃圾回收的对象)?

  1. 虚拟机栈(栈桢中的本地变量表)中的引用的对象。
  2. 本地方法栈中本地方法的引用的对象。
  3. 方法区中的类的静态变量引用的对象。
  4. 方法区中的常量引用的对象。

如何判断一个对象真正死亡?

对于用可达性分析法搜索不到的对象,GC并不一定会回收该对象。

  1. 判定finalize() 是否有必要执行

JVM 会判断此对象是否有必要执行 finalize() 方法,如果对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么视为“没有必要执行”。那么对象基本上就真的被回收了。
如果对象被判定为有必要执行 finalize() 方法,那么对象会被放入一个 F-Queue 队列中,虚拟机会以较低的优先级执行这些 finalize()方法,但不会确保所有的 finalize() 方法都会执行结束。如果 finalize() 方法出现耗时操作,虚拟机就直接停止指向该方法,将对象清除。

  1. 对象重生或死亡

如果在执行 finalize() 方法时,将 this 赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。

四种引用类型是什么?

无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

  1. 强引用(Strong Reference)

被强引用关联的对象永远不会被回收。
使用 new 一个新对象的方式来创建强引用。
Object obj = new Object()

  1. 软引用(Soft Reference)

被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。
Object obj = new Object();
SoftReference sf = new SoftReference(obj);
obj = null; // 使对象只被软引用关联

  1. 弱引用(Weak Reference)

被弱引用关联的对象一定会被回收(不论内存是否充足),也就是说它只能存活到下一次垃圾回收发生之前。
使用 WeakReference 类来实现弱引用。
Object obj = new Object();
WeakReference wf = new WeakReference(obj);
obj = null;

  1. 虚引用(Phantom Reference)

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。
使用 PhantomReference 来实现虚引用。
Object obj = new Object();
PhantomReference pf = new PhantomReference(obj);
obj = null;

垃圾收集算法有哪些?

标记-清除算法

image.png
分为**“标记”和“清除”**两个阶段:

  1. 标记的过程是:遍历所有的 GC Roots,然后将所有 GC Roots 可达的对象标记为存活的对象
  2. 清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。与此同时,清除那些被标记过的对象的标记,以便下次的垃圾回收。

**优点:**不需要额外的空间。
缺点:

  1. **效率问题:**两次扫描浪费时间。
  2. **空间问题:**标记清除之后会产生大量不连续的内存碎片,碎片太多可能导致以后需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-整理算法(用于老年代)

image.png
分为“**标记”和“整理”**两个过程:

  1. **标记过程:**遍历 GC Roots,然后将存活的对象标记。
  2. **整理过程:**让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

**优点:**没有内存碎片。
**缺点:**增加存活对象的移动成本。

复制算法(用于年轻代)

image.png
为了解决空间利用率问题,可以将内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。
但是我们无法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够,需要依赖其他内存(指老年代)进行分配担保。
**优点:**没有内存碎片。
**缺点:**需要占用双倍的内存空间,比较浪费空间。
**复制算法最佳使用场景:**对象存活度较低。

为什么要进行分代?

大部分对象都死得早,只有少部分对象会存活很长时间。在堆内存上都会在物理或逻辑上进行分代,为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率。

内存分配策略?

  1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

  1. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

  1. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值。

  1. 动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

  1. 空间分配担保

新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。

Minor GC和Full GC的区别?

** **Minor GCFull GC
定义发生在年轻代的垃圾回收过程指发生在老年代的垃圾回收动作
垃圾回收算法复制算法标记-整理算法
触发条件当Eden区空间不足时,触发Minor GC。
1. new出来的大对象的内存大于老年代的可用内存。
1. 空间分配担保失败。
1. 年轻代中长期存活的对象当到达一定的年龄阈值,且老年代的可用内存小于该对象大小。
1. 执行System.gc():只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
垃圾收集过程将Eden区和from区中仍然存活的对象copy到to区中,然后清理掉Eden区和from区,存活的对象年龄加1,然后交换to survivor和from survivor。首先标记老年代中可以清除的对象,然后让存活的对象移到一端,直接清理掉端边界以外的内存。
特点
1. Minor GC非常频繁,一般回收速度也比较快。
1. Minor GC会引发STW:暂停除了垃圾回收线程之外的其他用户线程,当垃圾回收线程执行完后,用户线程才会运行。
1. 当年轻代的寿命超过阈值(默认是15)时,会晋升至老年代。

1. Full GC的速度一般要比Minor GC慢10倍以上。
1. Full GC的STW时间比Minor GC的时间更长。

在Minor GC的时候,从GC Roots出发,那不也会扫描到「老年代」的对象吗?不就相当于全堆扫描吗?

HotSpot 虚拟机「老的GC」(G1以下)是要求整个GC堆在连续的地址空间上。
所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上。
当做Minor GC的时候,从GC Roots出发,如果发现「老年代」的对象,那就不往下走了(Monor GC对老年代的区域毫无兴趣)

如果「年轻代」的对象被「老年代」引用了呢?那肯定是不能回收掉「年轻代」的对象的

HotSpot虚拟机下 有「card table」(卡表)来避免全局扫描「老年代」对象。
「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」
那知道了「卡表」之后,就很好办了。每次Monor GC 的时候只需要去「卡表」找到「脏页」,找到后加入至GC Root,而不用去遍历整个「老年代」的对象了。

对象什么时候会出现在老年代?

  1. **大对象直接进入老年代。**参数-XX:PretenureSizeThreshold设置,超过这个参数设置的值就直接进入老年代。
  2. **长期存活的对象进入老年代。**如果对象在 eden 区出生,那么它的 GC 分代年龄会初始值为 1,每熬过一次 Minor GC 而不被回收,这个值就会增加 1 岁。当它的年龄到达一定的数值时,就会晋升到老年代中,可以通过参数-XX:MaxTenuringThreshold设置年龄阀值(默认是 15 岁)。
  3. Minor GC后大量对象仍然存活的情况(最极端的情况就是内存回收后年轻代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。

如果经常出现Full GC,怎么定位代码哪里出了问题?

Full GC会对整个堆进行整理,包括年轻代和老年代。所以比较慢,大约是Minor GC时间的10倍多,因此应该尽可能减少Full GC的次数。
原因:

  • 老年代大小参数设置过小(堆内存空间分配过少)。
  • 内存泄露,有大量内存垃圾不断在老年代产生;
  • 大对象过多;

解决方法:

  • 猜测是老年代内存分配过小,可以增加老年代内存。
  • 给JVM加上输出具体日志信息的参数:XX:+PrintGCDetails -Xloggc:dcc_gc.log。查看具体Full GC的信息。
  • 利用jmap工具分析整个JVM中的内存信息:看是不是大对象过多,可以用弱引用或者虚引用。

什么是STW?

Java中Stop-The-World机制简称STW。在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互。
STW多半是由于垃圾回收引起。目前所有的年轻代GC都是需要STW的。

垃圾收集器

并行:多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
并发:用户线程和垃圾收集线程同时执行,用户程序在继续运行,而垃圾收程序运行在另一个CPU上。
**安全点:**让其他用户线程停下来的点。因为垃圾回收时对象的地址会改动,避免其他线程找不到对象。
image.png

Serial收集器和Serial Old收集器(早期JDK默认收集器)

Serial特点:

  1. 针对年轻代;
  2. 采用复制算法;
  3. 单线程收集;
  4. 进行垃圾收集时Java应用程序中的线程需要STW。

Serial Old特点:

  1. 针对老年代;
  2. 采用"标记-整理"算法;
  3. 单线程收集;
  4. STW时间比Serial收集器长。

image.png
Serial和Serial Old组合收集器运行示意图

Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的垃圾收集器)

Parallel Scavenge特点

  1. 用于年轻代;
  2. 采用复制算法;
  3. 多线程收集;
  4. 目标是可控制的吞吐量、支持GC自适应调节策略。

Parallel Old特点:

  1. 用于老年代;
  2. 采用**"标记-整理"**算法;
  3. 多线程收集。

image.png
Parallel Scavenge和Parallel Old收集器运行示意图

ParNew收集器

ParNew特点:

  1. 是Serial收集器的多线程版本;
  2. 用于年轻代;
  3. 使用复制算法;
  4. 垃圾回收时, 应用程序仍会暂停, 只不过由于是多线程回收, 在多核CPU上,回收效率会高于串行回收器, 反之在单核CPU, 效率会不如串行回收器。

image.png
ParNew收集器运行示意图

CMS收集器

**CMS收集器(Concurrent Mark Sweep并发标记清除)特点:针对老年代;**使用“标记-清除”算法;

CMS收集器的运作步骤?

image.png

  1. **初始标记(STW):**会标记GCRoots「直接关联」的对象以及「年轻代」指向「老年代」的对象【因为年轻代可能会指向老年代的对象】
  2. **并发标记(和用户线程并发执行):**进行GC Roots Tracing的过程。和用户线程并发执行。
  3. 并发预处理:并发标记阶段有些对象可能从新生代晋升到了老年代,有些大对象可能直接分配到了老年代,可能老年代或者新生代的对象引用发生了变化。
    1. 针对老年代的对象,可以借助类card table的存储(将老年代对象发生变化所对应的卡页标记为dirty),所以「并发预处理」这个阶段会扫描可能由于「并发标记」时导致老年代发生变化的对象,会再扫描一遍标记为dirty的卡页。
    2. 对于新生代的对象,我们还是得遍历新生代来看看在「并发标记」过程中有没有对象引用了老年代。
  4. **重新标记(STW):**用户线程暂停,重新标记存活的对象。
  5. **并发清除(和用户线程并发执行):**和用户线程一起并发回收垃圾对象。这个过程,还是有可能用户线程在不断产生垃圾,但只能留到下一次GC 进行处理了,产生的这些垃圾被叫做“浮动垃圾”

CMS的优点:

  1. 在并发标记和并发清除阶段都是采用和用户线程一起并发工作的,能够保证并发性。
  2. 能够获得最短回收停顿时间。

CMS的缺点:

  1. 空间需要预留:CMS垃圾收集器可以一边回收垃圾,一边处理用户线程,那需要在这个过程中保证有充足的内存空间供用户使用。
  2. 内存碎片问题:CMS本质上是实现了「标记清除算法」的收集器(从过程就可以看得出),这会意味着会产生内存碎片。由于碎片太多,又可能会导致内存空间不足所触发full GC,CMS一般会在触发full GC这个过程对碎片进行整理。

G1收集器(JDK9默认的垃圾收集器)

G1收集器的特点?

  1. G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。回收的时候可以直接对新生代和老年代一起回收。
  2. 从整体看是基于标记-整理算法,从局部(两个Region间)看是基于复制算法。这两种算法都不会产生空间碎片。
  3. G1可以建立可预测的停顿时间模型,可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒。
  4. 使用CSet来存储可回收Region的集合
  5. 使用RSet来处理跨代引用的问题(注意:RSet不保留 年轻代相关的引用关系)
  6. G1可简单分为:Minor GC 和Mixed GC以及Full GC


每个Region被标记了E、S、O和H,说明每个Region在运行时都充当了一种角色。
当新建对象大小超过Region大小一半时,直接在新的一个或多个连续Region中分配,并标记为H(Humongous),表示巨型对象。
堆内存中一个Region的大小可以通过-XX:G1HeapRegionSize参数指定,大小区间只能是1M、2M、4M、8M、16M和32M,总之是2的幂次方。默认把堆内存按照2048份均分,最后得到一个合理的大小。
G1中提供垃圾回收三种模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。

G1收集器Young GC过程?

image.png
image.png
触发条件:当Eden区满了后,会触发Minor GC

  1. 根扫描:标记GC Roots能直接关联到的对象
  2. 更新&处理RSet:扫描Region中的RSet区域,将老年代引用当前Region的年轻代对象都加入到GC Roots下,避免被回收掉。
    1. 因为Minor GC 是回收年轻代的对象,但如果老年代有对象引用着年轻代,那这些被老年代引用的对象也不能回收掉。
    2. RSet这种存储在每个Region都会有,它记录着「其他Region引用了当前Region的对象关系」
  3. 复制对象:把扫描之后存活的对象往「空的Survivor区」或者「老年代」存放,其他的Eden区进行清除。

G1收集器(Mixed GC)的收集过程?

image.png
触发条件:当堆空间的占用率达到一定阈值后会触发Mixed GC(默认45%,由参数决定)
Mixed GC它一定会回收年轻代,并会采集部分老年代的Region进行回收的,所以它是一个“混合”GC。

  1. 初始标记(STW):仅标记一下GC Roots能直接关联到的对象和Root Region,年轻代和老年代都会扫描。
  2. 并发标记:GC线程与用户线程一起执行,GC线程负责收集各个 Region 的存活对象信息,从GC Roots往下追溯,查找整个堆存活的对象。
  3. 重新标记(STW):用SATB算法修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录(修正浮动垃圾)。
    1. 在「并发阶段」时,把每一次发生引用关系变化时旧的引用值给记下来
    2. 然后在「重新标记」阶段只扫描着块「发生过变化」的引用,看有没有对象还是存活的,加入到「GC Roots」上
    3. 不过SATB算法有个小的问题,就是:如果在开始时,G1就认为它是活的,那就在此次GC中不会对它回收,即便可能在「并发阶段」上对象已经变为了垃圾(浮动垃圾)。
  4. 清理垃圾(STW):首先排序各个Region的回收价值和成本,然后根据用户期望的GC停顿时间来制定回收计划,最后按计划回收一些价值高的Region中垃圾对象。回收时采用复制算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存。

G1收集器Full GC的过程?

如果在Mixed GC中无法跟上用户线程分配内存的速度,导致老年代填满无法继续进行Mixed GC,就又会降级到serial old GC来收集整个GC heap。

一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

不是,每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。

为什么G1收集器可以实现可预测的停顿?

通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。这就保证了在有限的时间内可以获取尽可能高的收集效率。

CMS和G1收集器的区别?

 CMS收集器G1收集器
使用范围针对分代模型,是老年代的收集器,可以配合年轻代的Serial和ParNew收集器一起使用针对分区模型,收集范围是老年代和年轻代,不需要结合其他收集器使用。
STW时间以**最小的停顿时间(STW)**为目标可预测垃圾回收的停顿时间为目标
收集算法和垃圾碎片使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片。Region化的内存结构,从整体看是基于“标记-整理”,从局部(两个Region间)看,是基于复制算法,这两种算法都不会产生空间碎片。
垃圾回收过程分别详细介绍

四、JVM具体怎么调优?

一般系统的优化思路?

  1. 一般是关系数据库先到达瓶颈,首先排查数据库的索引是否存在,索引是否失效,是否需要分库分表等
  2. 然后需要考虑是否需要扩容(横向和纵向)
  3. 接着,从应用代码层面上排查并优化,代码是否可以并行,是否存在资源浪费的地方。
  4. 然后,JVM层面进行排查和优化
  5. 最后,从网络和操作系统的底层进行排查

微信图片_20220122180746.jpg

JVM如何排查问题?

微信图片_20220122180902.jpg
JVM评价指标:吞吐量、停顿时间(STW)和垃圾回收频率。基于这些指标需要对内存区域大小以及相关策略(堆的大小、新生代大小、老年代大小、Survivor站大小、晋升老年代的条件)

  1. 按经验来说:IO密集型可以把年轻代空间加大,因为大多数对象都是在年轻代就会灭亡;内存计算密集型可以将老年代空间加大,因为对象存活时间可能比较长。
  2. 垃圾回收器以及各个垃圾回收器的参数调优(-XX:+UseG1GC:使用G1垃圾收集器;-XX:MaxGCPauseMillis:设置目标停顿时间;)
  3. 遇到问题后利用工具进行排查:
    1. 通过jps命令查看java进程[基础]信息(进程号、主类)
    2. 通过jstat命令查看java进程[统计类]相关的信息(类加载、编译相关信息,各个内存区域GC概况和统计)
    3. 通过jinfo命令来查看和调整java进程的[运行参数]
    4. 通过jmap命令来查看java进程的[内存信息],这个命令常用来把JVM的内存信息dump到文件中,然后在用MAT把文件进行分析
    5. 通过jstack命令来查看JVM[线程信息],主要排查死锁相关的问题。