JVM资料

本文详细介绍了Java虚拟机(JVM)的类加载子系统,包括加载、连接(验证、准备、解析)、初始化等阶段,以及类加载器的工作原理和双亲委派模型。接着,文章阐述了JVM的运行时数据区,如程序计数器、堆、方法区、虚拟机栈和本地方法栈,强调了对象创建的步骤和内存分配策略。此外,讨论了垃圾收集器和内存分配策略,如可达性分析算法、引用类型以及各种垃圾收集算法的优缺点。最后,文章提到了常见的内存溢出问题和JVM调优的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

JVM

2、类加载子系统

类加载就是 Java 虚拟机把描述类的数据从 Class 文件加载到内存的一个过程,并且在这个过程中对数据进行校验、转换解析和初始化,最终形成可以被 Java 虚拟机直接使用的 Java 类

类加载、连接和初始化都是在程序运行期间完成的,这个设计造成了一些性能上的开销,但同时增强了 Java 的跨平台性,同时具备了动态加载和动态连接这两个特性

image-20221005220739671

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定;

按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段

非数组类加载具体流程:

  1. 通过一个类的全限定名称来获取定义这个类的 Class 文件字节流 (加载)

  2. 连接阶段对文件格式进行验证(魔数、主次版本号)(连接-验证-文件格式验证)

  3. 将这部分字节流所代表的静态存储结构转化为方法区的运行时数据结构;因为 Class 文件是一个静态的二进制文件,里面存放的是我们类中所有的一些常量、结构、字段、方法、属性,这些都是静态存储在 Class 文件中的,我们需要把它们转化为方法区所需要的一种运行时的数据结构**(加载)**

  4. 连接-验证-元数据验证

  5. 连接-验证-字节码验证(它所消耗的验证时间在 类加载过程中,JDK6 之后 JVM 和 Java编译器进行了联合优化,尽可能把更多校验辅助措施挪到 Java编译器里进行)

  6. 连接-准备-初始化0值

  7. 在堆内存中生成一个代表这个类的 Class 对象,这个堆中的对象就相当于是方法区的入口,这个类所有的字段和方法的访问入口都在这个对象里面,我们要通过这个对象去方法区访问这个类的数据**(加载)**

数组类本身不通过类加载器来创建,由 Java 虚拟机直接在内存中动态构造出来,但是数组中的元素,还是要靠类加载器来完成加载

数组类创建过程:

  • 如果数组的元素是引用类型,就递归采用非数组类的加载过程去加载这个元素,这个数组将会被标识在加载该类型的类加载的类名空间上
  • 如果数组的组件类型不是引用类型,Java 虚拟机把这个数组标记为与引导类加载器关联
  • 数组类的可访问性和它的元素类型可访问性一致;如果元素类型不是引用类型,它的数组类的可访问性默认是 public

2.1、加载

非数组类加载具体流程:

  1. 通过一个类的全限定名称来获取定义这个类的 Class 文件字节流 (加载)

  2. 连接阶段对文件格式进行验证(魔数、主次版本号)(连接-验证-文件格式验证)

  3. 将这部分字节流所代表的静态存储结构转化为方法区的运行时数据结构;因为 Class 文件是一个静态的二进制文件,里面存放的是我们类中所有的一些常量、结构、字段、方法、属性,这些都是静态存储在 Class 文件中的,我们需要把它们转化为方法区所需要的一种运行时的数据结构**(加载)**

  4. 连接-验证-元数据验证

  5. 连接-验证-字节码验证(它所消耗的验证时间在 类加载过程中,JDK6 之后 JVM 和 Java编译器进行了联合优化,尽可能把更多校验辅助措施挪到 Java编译器里进行)

  6. 连接-准备-初始化0值

  7. 在堆内存中生成一个代表这个类的 Class 对象,这个堆中的对象就相当于是方法区的入口,这个类所有的字段和方法的访问入口都在这个对象里面,我们要通过这个对象去方法区访问这个类的数据**(加载)**

数组类本身不通过类加载器来创建,由 Java 虚拟机直接在内存中动态构造出来,但是数组中的元素,还是要靠类加载器来完成加载

数组类创建过程:

  • 如果数组的元素是引用类型,就递归采用非数组类的加载过程去加载这个元素,这个数组将会被标识在加载该类型的类加载的类名空间上
  • 如果数组的组件类型不是引用类型,Java 虚拟机把这个数组标记为与引导类加载器关联
  • 数组类的可访问性和它的元素类型可访问性一致;如果元素类型不是引用类型,它的数组类的可访问性默认是 public

2.2、连接

加载阶段与连接阶段的部分动作是交叉进行的,加载阶段尚未完成,但是连接阶段可能已经开始了;

验证

验证是连接的第一步,这个阶段的目的就是确保 Class 文件字节流中包含的信息符合 《Java 虚拟机规范》,保证不会出现危害虚拟机的情况

验证阶段主要进行四个检验:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

文件格式验证:

主要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,主要是为了保证输入的字节流能正确地解析并存储在方法区内,只有通过了这个验证,这段字节流才会被允许存储进方法区;后面三个阶段的验证都是基于方法区内存储的字节流去验证的

元数据验证:

对类的字节码描述的信息进行语义分析和校验

  • 这个类是否有父类
  • 这个类的父类是否继承了不被允许被继承的类
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要实现的方法

字节码验证:

通过数据流分析和控制流分析,确定语义是合法的、符合逻辑的;主要就是对类的方法体进行校验,保证被校验类的方法在运行时不会做出危害虚拟机的行为

符号引用验证:

符号引用验证发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作在连接的第三阶段解析的时候发生;

符号引用主要是验证跟据全限定名称是否能找到对应的类、是否存在合法的字段描述和方法描述、以及类的访问权限的校验;符号引用验证的目的是为了确保解析行为能正常执行

准备

准备阶段是为类中静态变量分配内存并设置类变量初始值的阶段;这个时候进行内存分配的仅包括类变量,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中

静态变量所使用的内存都应在方法区中进行分配,但方法区本身就是一个逻辑上的区域

  • JDK7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的
  • JDK8 之后,类变量随着 Class 对象一起存放在堆中,方法区完成成为了一种逻辑上的概念

解析

解析阶段就是 Java 虚拟机将常量池内的符号引用替换为直接引用

2.5、初始化

初始化阶段就是根据程序员通过编码指定的计划去初始化类变量和其他资源

类的初始化是类加载的最后一个步骤,之前的几个步骤,基本都是由 Java 虚拟机来执行的,到了初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码

初始化阶段本质上就是执行类构造器 ()方法的过程

()方法是 Javac编译器自动收集类中的所有类变量的赋值动作的和静态语句块中的语句合并产生的;它不需要显式的调用父类构造器,Java 虚拟机会保证子类的 clinit()方法执行前,父类的 clinit()方法已经执行完毕,所以父类中定义的静态语句块要优于子类先执行;

()方法对于类或者接口来说并不是必须的,如果一个类中没有静态语句块,也没有对静态变量的赋值操作,编译器就可以不生成这个方法;

接口和类对于执行 ()方法的顺寻是不太一样的,执行接口的 clinit()方法不需要先执行父接口的 clinit()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化

Java虚拟机必须保证一个类的 clinit()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 clinit()方法

双重检查锁 – 通过实例化我们的类进行的双重检查锁实现。static变量,clinit 方法,同步

2.6、类加载器

image-20221009021516942

2.7、双亲委派(向上委托、向下委派)

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

双亲委派机制的优点:

2.8、自定义类加载器

3、运行时数据区

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为不同的数据区域,每个区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机的启动而一直存在,有的区域则是依赖用户线程的启动和结束而建立和销毁

image-20221009155530381

3.1、程序计数器

程序计数器可以看成是当前线程所执行的字节码的行号指示器,程序的分支、循环、跳转、异常处理、线程恢复都需要依赖计数器来完成

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

程序计数器不会发生OOM

3.2、堆

堆是虚拟机管理的内存中的最大的一块,堆是被所有线程共享的一块内存区域,在虚拟机启动的时候创建,唯一的目的就是存放对象实例;

Java中所有的对象是里都在堆中分配内存

如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出 OutOfMemoryError 异常

image-20221009160440752
  • 堆大小 = 新生代 + 老年代。堆的大小可通过参数–Xms(堆的初始容量)、-Xmx(堆的最大容量) 来指定
  • 新生代 (Young) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Edem : from : to = 8 : 1 : 1 。(可以通过参数 –XX:SurvivorRatio 来设定 )
  • 即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小
  • JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。
  • 新生代实际可用的内存空间为 9/10 (即 90%) 的新生代空间

3.3、对象创建步骤

1、当 Java 虚拟机遇到一条字节码new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用(要new一个String类,就要先去找String类的符号引用),并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程;

2、在类加载检查通过后,会为新生对象分配内存,为对象分配空间其实就是把一块确定大小的内存块从 Java 堆中划分出来,对象所需内存的大小在类加载完成后就可以完全确定

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

选择分配方式是由 Java 堆是否规整决定的,Java堆是否规整由采用的垃圾收集器是否带有空间压缩能力决定

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况

  • 一种是对分配内存空间的动作进行同步处理,虚拟机采用CAS+失败重试机制来保证更新操作的原子性
  • 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,叫做本地线程分配缓冲,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定

3、内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值(连接 - 准备)

4、Java 虚拟机将类的元数据信息、对象的哈希码,存放在对象的对象头之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式

5、new指令之后会接着执行 Class 文件中的 ()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来

3.4、对象内存布局

3.5、对象访问方式

3.6、方法区

方法区和堆一样,都是线程共享的内存区域,它用来存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据

JDK 在逐渐放弃永久代,JDK7 的 HotSpot,已经逐渐把放在永久代中的字符串常量池、静态变量等移出;JDK8,完全废弃了永久代的概念,用元空间来存储 JDK7 中永久代剩余的信息,主要是类型信息

运行时常量池

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

3.7、虚拟机栈和本地方法栈

虚拟机栈

虚拟机栈是线程私有的,它的生命周期和线程相同,每个方法被执行的时候,Java 虚拟机栈都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息;每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型、对象引用;

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务

3.8、内存溢出

4、垃圾回收器和内存分配策略

4.1、引用计数法(虚拟机里不用)

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的

引用计数法存在一个致命的缺陷,那就是无法解决循环引用的问题

4.2、可达性分析算法

当前主流的商用语言,都是使用的可达性分析算法来判断对象是否存活;

可达性分析算法的基本思路就是:以根对象也就是GCRoot作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到GCRoots间没有任何引用链相连,则证明这个对象不可能再被引用

GCRoots

在 Java 中,可以作为 GC Roots 的对象包括以下几种:

  1. 所有被同步锁(synchronized关键字)持有的对象
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
  3. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
  4. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器,(支撑 JVM 的类)
  5. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
  6. 如果只对 Java堆中某一块区域发起垃圾收集,这个区域里的对象完全有可能被位于堆中的其他区域的对象所引用,这个时候就要把这些关联区域的对象一并加入到 GCRoots 集合中去(跨代引用)

引用

  • **强引用:**是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • **软引用:**是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用
  • **弱引用:**也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用
  • **虚引用:**它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

如果对象不可达,一定会被回收吗

即使在可达性分析算法中判定为不可达的对象,也不是非死不可的,这个时候它们暂时处于等死阶段,要真正宣告一个对象的死亡,至少要经过两次标记:

  1. 如果对象在经过可达性分析后发现没有与GCRoot相连接的引用链,将会被第一次标记;随后进行一次筛选,筛选的条件是是否有必要执行 finalize()方法

    假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”

  2. 如果这个对象有必要执行 finalize()方法,会将这个对象放进一个F-Queue队列中;虚拟机会尝试触发这个方法,但并不一定会等待它运行结束,因为如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃;如果执行finalize()方法后,这个对象还没有与引用链上的任何一个对象建立关联,那么会被进行第二次标记然后进行回收

finazil 方法可以用来做“关闭外部资源”之类的清理性工作,但是运行代价高昂、不确定性大,无法保证各个对象的调用顺序,不推荐使用

4.5、垃圾收集算法

分代收集理论

标记清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程

缺点是:内存空间碎片化问题

标记复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

缺点是:

  • 复制开销
  • 空间利用率只有50%

标记整理算法

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

标记整理算法是一种移动式的算法,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,有 stop the world

基于复制算法和整理算法衍生出来的算法

把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,空间利用率达到了90%

4.6、根节点枚举(安全点、安全区域、OopMap)

根节点枚举

根节点枚其就是选举 GC Roots 的过程

GC Roots的节点主要在全局性的引用(例如常量或类静态属性)或者执行上下文(例如栈帧中的本地变量表)中;

根节点枚举存在两个问题:

  1. 所有的收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因为根节点枚举必须在一个能保证一致性的快照中才能进行,就是说在枚举的过程中,根节点集合跟对象的引用关系不能不断变化,否则会影响分析结果的准确性,所以垃圾回收过程中,用户线程必须要停顿一下(也就是安全点)
  2. Java 应用越做越大,方法区的大小很大,里面的类更是很多很多,逐个检查要浪费很多时间

HotSpot 使用 OopMap 来解决这两个问题,HotSpot 会把对象的偏移量和数据类型计算出来,然后在安全点时,会使用 OopMap 这种数据结构来记录那些地方存对象引用(记录栈和寄存器哪些位置是引用),这样收集器在扫描的时候就可以直接得到这些信息了,并不需要从方法区的 GCRoots 开始查找

安全点

但是对象的引用是不断在变化的,如果每一条指令都生成一个对应的 OopMap,需要额外的大量存储空间,空间成本会很高昂

所以 HotSpot 不会对每一条指令都生成 OopMap,只会在特定的时候生成 OopMap,这个特定的时候就是安全点

因为到达安全点的时候会强制停顿用户线程生成OopMap开始垃圾收集,所以安全点的选取也非常重要,安全点的选取标准就是**“能够让程序长时间的执行”**,所以方法的调用,循环这些指令都可以产生安全点

但是安全点存在一个问题就是,如何在垃圾收集时让所有线程都跑到最近的安全点,有两种方案区解决:

  • 抢先式中断:抢先式中断不需要线程的执行代码主动配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上
  • 主动式中断:主动式中断是当垃圾收集需要中断线程的时候,不直接操作线程,而是设置一个标记位,线程会去不断的轮询这个标志,轮询的地方和安全点是重合的,一旦发现标记位为真,就在最近的安全点上主动挂起;HotSpot 对轮询操作做了优化,精简到了只有一条汇编指令的程度

安全区域

安全点只保证程序运行时的垃圾回收,如果用户线程此时处于 Sleep 或者 Block 装态,用户线程没有办法运行到安全点,这个时候就需要使用安全区来解决

安全区:是指能够确保在某一段代码片段之中,引用关系不会发生变化,在这个区域中任意地方开始垃圾收集都是安全的

当用户线程进入安全区的时候,就会标识自己进入了安全区域,那么这段时间内的垃圾回收就不需要管安全区内的线程了;当线程离开安全区的时候,虚拟机需要检查这个线程是否完成了根节点枚举,如果完成了就继续执行,没完成就需要等待

4.7、记忆集与卡表(跨代引用)

如果只对 Java堆中某一块区域发起垃圾收集,这个区域里的对象完全有可能被位于堆中的其他区域的对象所引用,这个时候就要把这些关联区域的对象一并加入到 GCRoots 集合中去(跨代引用)

所有部分区域垃圾收集的垃圾收集器都存在跨代引用的问题,比如 G1

垃圾收集器在新生代中建立记忆集来避免把整个老年代加到 GCRoots 扫描范围,来解决跨代引用的问题

如果我们记录所有的跨代引用,成本太大了,所以我们在记忆的粒度上并不能做到太细,我们只需要记住一块非收集区域是否有指向收集区域的指针就行了;那么这个区域就是记忆的最小粒度,就叫卡表

卡表在 HotSpot 中的表现形式就是一个 card数组,数组的每一个元素都标识着一块内存区域,叫卡页,只要卡页里面的对象有一个存在跨代指针,就进行元素变脏,垃圾胡死后的时候,只用筛选出卡表中变脏的元素就行了,这样就能很快找出跨代引用,然后合进GC Roots 中,进行根节点枚举

4.8、三色标记法(增量更新+原始快照)

可达性分析算法在经过根节点枚举之后,需要从 GCRoots 在继续往下遍历对象图,这个步骤的停顿时间会与 Java 堆容量成正比,堆越大存储的对象越多,需要标记而产生的时间就会更长

三色标记法:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过(大概率将来也是存活对象)

三色标记法存在并发标记问题

可达性分析的扫描过程,初始的时候,只有 GCRoots 是黑色的,在扫描的过程中,以灰色节点为边界,从黑向白推进,然后扫描顺利完成,黑色对象是存活的对象,白色对象就是可回收的对象

但是,用户线程和收集器可能是并发工作的,可能会出现一些非常严重的错误:

  • 原本要被回收的对象被标记为存活,逃过一次垃圾收集,但是下次清理掉就好
  • 原本存活的对象标记为死亡,这是致命性的错误

存活的对象被标记为死亡的两个条件:

  1. 插入了一条或多条从黑色对象到白色对象的新引用
  2. 删除了全部从灰色对象到该白色对象的直接或间接引用

引用计数法针对这个问题存在两种解决方案:

  • 增量更新:破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
  • 原始快照:破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

CMS 使用增量更新,G1 使用原始快照

4.9、垃圾收集器

新生代垃圾收集器

老年代垃圾回收器

CMS

CMS 是一种以获取最短停顿时间的垃圾收集器

CMS 是基于标记-清除算法实现的垃圾收集器,不可避免的会遇到空间碎片这个问题

CMS 收集的过程:

  1. 初始标记:初始标记是垃圾回收线程单线程的,需要 stop the world,但是初始标记仅仅只是标记一下 GCRoots 能关联到的对象,速度很快
  2. 并发标记:并发标记就是从 GCRoots 直接关联对象开始遍历整个对象图的过程,整个阶段用户线程和垃圾收集线程是并发的
  3. 重新标记:重新标记阶段是垃圾回收线程并发执行的,需要 stop the world,主要作用是为了修正并发标记期间,因用户线程运行而导致标记产生变动的那一部分对象的标记记录
  4. 并发清除:清理删除掉标记阶段已经死亡的对象,由于不需要移动存活对象,所以整个阶段也是和用户线程并发的

CMS 的缺点:

  • 空间碎片,CMS 基于标记-清除算法实现,会产生大量内存碎片,会给分配大对象带来很多困难,往往老年代还有很多空间,但找不到连续空间,不得不提前触发 full GC
  • 浮动垃圾,CMS 在并发标记和并发清理阶段,用户线程和垃圾收集线程是并发的,用户线程在运行自然就会产生新的垃圾,但这一部分垃圾时出现在标记过程之后的,CMS 只能再下一次垃圾收集中对这些垃圾进行处理,这些新产生的垃圾就叫浮动垃圾;一旦浮动垃圾过多,导致老年代在用户线程运行期间,无法满足用户线程分配新对象的需求,就会出现并发失败,一但出现并发失败,就需要冻结用户线程,启用 Serial Old 收集器来重新进行老年代垃圾回收,会出现长时间的停顿;所以 CMS 在老年代使用 92% 的时候就会立刻启动
G1

G1 是基于 Region 的内存布局形式,面向局部收集的垃圾收集器;G1 之前的其他收集器,垃圾收集的目标范围要么是整个新生代,妖魔是整个老年代,要么就是整个堆,而 G1 可以面向堆内存的任何部分来组成回收集进行回收;G1 的衡量标准不再是它属于哪个分代,而是哪块内存存放的垃圾数量最多,回收收益最大,就是混合GC模式

G1 把 Java 堆划分为很多个大小相等的独立区域叫 Region,每一个 Region 都可以根据需要去扮演Eden空间、Syrvivor空间或者老年代空间,收集器根据每个 Region 的角色不同采用不同的策略去处理,Region 是 G1 垃圾回收的最小单元

如果一个对象的大小超过了 Region 容量的一半,就用 Humongous 区域去存储,Humongous 是专门用来存储大对象的区域,可以作为老年代的一部分来看待

G1 会根据每个 Region 回收所获得的空间以及回收所需要的时间,然后在后台维护一个优先级列表,优先回收那些收益最大的 Region

Region 的跨 Region 引用怎么解决

G1 还是使用记忆集来解决跨 Region 引用,每个 Region 都维护了一个自己的记忆集,而且这些记忆集都是双向的,会记录我指向谁,谁指向我,并且 Region 数量比传统垃圾收集器的分代数量对很多,所以导致 G1 内存占用负担就更高

G1 怎么解决并发标记阶段收集线程跟用户线程互不干扰的问题

CMS 使用增量更新来解决,G1 则是用原始快照来解决;

G1 如何解决回收过程中创建新对象的内存分配问题

在垃圾回回收的过程中,用户线程继续运行就会产生新对象,G1 给每一个 Region 设计了两个TAMS指针,把 Region 中一部分空间用于回收过程中的空间分配,并发回收期间所有的新对象的内存地址都在这两个指针位置以上;

如果内存回收速度赶不上内存分配速度,冻结用户线程,进行 Full GC

G1 收集器的回收步骤:

  1. 初始标记:标记 GC Roots 能直接关联到的对象,修改TAMS指针值,让垃圾回收时,能够正确的在Region中分配对象;这个阶段需要暂停用户线程,但是这个过程是跟 Minor GC 同步运行的,所以没有额外停顿(STW)
  2. 并发标记:从 GC Root 开始进行可达性分析,扫描整个堆的对象图,找出要回收的对象,可以跟用户线程并发执行,扫描完成后,需要使用原始快照来解决并发标记问题
  3. 最终标记:对用户线程做一个短暂的暂停,用于处理并发标记问题(STW)
  4. 筛选回收:负责更新 Region 统计数据,对各个 Region 的回收价值何成本进行排序,优先收集价值高的,任意选择多个 Region 构成回收集,然后把需要回收的 Region 中的存活对象复制到空的 Region 中,再清理掉整个旧的 Region,这步操作涉及存活对象的移动,必须暂停用户线程(STW),由垃圾收集线程并发完成

4.10、内存分配策略

对象优先在 Eden 分配,大对象直接进入老年代

大多数情况,对象在新生代 Eden 区中分配,当 Eden 区中没有足够空间进行分配时,虚拟机就会发起一次 Minor GC

对于大对象,比如很长的字符串、或者元素数量很庞大的数组,HotSpot提供了一个配置,如果过了这个阈值,直接在老年代中分配,避免在 Eden 和 Survivor 中来回复制

虚拟机会给每个对象顶一个年龄计数器,存储在对象头中,对象通常在 Eden 区中诞生,经过第一次 Minor GC 后依旧存活,并且能被 Survivor 容纳,就会被移动到 Survivor 中,并将其年龄设置为1,每在 Survivor 中熬过一次 Minor GC,年龄+1,当年龄达到15之后,就进入老年代;

如果 Survivor 空间中,相同年龄的所有对象大小总和占空间的一半,年龄大于等于这个年龄的对象就直接进入老年代

空间分配担保

在发生 Minor GC 之前,虚拟机为了确保万无一失,会先检查一下老年代的最大可用连续空间大于新生代所有对象的总空间,如果大于,那么这一次 Minor GC 就是绝对安全的;

如果小于,虚拟机会根据自身配置去确认是否可以允许空间担保失败,如果允许,就会检查历次晋升到老年代对象的平均大小,如果能够容纳,就进行一次有风险的 Minor GC,如果不能容纳,或者虚拟机不允许空间担保失败就直接进行Full GC

5、OOM异常与JVM调优

在 JVM 进行垃圾收集的时候,不能很好的进行垃圾收集,或者有一些对象被频繁的创建,没有来得及回收,导致堆内存溢出,就会引发OOM异常,OOM异常相当于是堆内存不够使用的最后一种状态

JVM 调优其实是想让垃圾收集和对象创建这两个动作更加和谐流畅

OOM绝大部分情况下,采取代码的排查和修改,有一些情况需要进行 JVM 参数调优,比如对象量很大,会增加一下 JVM 堆的参数设置,但是绝大部分情况都是代码写的有问题导致的;

有些情况比如说,老年代的有用对象越来越多,造成了 OOM,生产问题必立马解决,首当其冲就是 JVM 调优,立刻增老年代存储空间,然后去排查代码,判断为什么会有这么多对象一直存在

OOM 排查步骤

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值