JVM(补档)


说一下 Jvm 的主要组成部分及其作用

  1. 类加载器(ClassLoader)
  2. 运行时数据区(Runtime Data Area)
  3. 执行引擎(Execution Engine)
  4. 本地库接口(Native Interface)
  • 首先通过类加载器会把 Java 代码转换成字节码
  • 运行时数据区再把字节码加载到内存中
  • 特定的命令解析器执行引擎,将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。

内存区域

说说JVM运行时数据区域

JDK 1.8 之前

JDK 1.8 之后

线程私有的:

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

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

Java 虚拟机栈

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

栈是 JVM 运行时数据区域的一个核心,除了一些 Native 方法,其他所有的 Java 方法调用都是通过栈来实现的。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址

局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用

操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。

动态链接 主要服务一个方法需要调用其他方法的场景。将方法的符号引用转换为调用方法的直接引用。

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

为什么说“几乎”所有的对象都在堆中分配?

从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Eden、Survivor)
  2. 老生代
  3. 永久代(方法区)

JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存

堆这里最容易出现的就是 OutOfMemoryError 错误

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

方法区和永久代以及元空间是什么关系呢?

永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  • OOM概率降低:整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整。而元空间使用的是直接内存,受本机可用内存的限制,一般元空间比永久代大

  • 加载更多的类:元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

运行时常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量,

符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。

常量池表会在类加载后存放到方法区的运行时常量池中。

字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

StringTable 本质上就是一个HashSet<String>

StringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。

JDK1.7 之前,字符串常量池存放在永久代。
JDK1.7 字符串常量池和静态变量从永久代移动了中。

JDK 1.7 为什么要将字符串常量池移动到堆中

主要是因为方法区的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。

Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,直接内存受到本机总内存大小以及处理器寻址空间的限制。

堆和栈的区别

  1. 数据结构:堆是动态分配的内存区域,用于存储程序运行时创建的对象。而栈是一种具有后进先出(LIFO)结构的内存区域,用于管理函数调用和局部变量。
  2. 内存管理:堆的内存分配和释放是手动控制的,需要显式地申请和释放内存。而栈的内存管理是自动的,由编译器根据函数调用和返回自动分配和释放。
  3. 分配方式:堆的内存分配是动态的,通过函数如malloc()或new来进行分配。而栈的内存分配是静态的,编译器在编译阶段就确定了函数调用栈的大小。
  4. 内存空间:堆的空间较大,通常取决于操作系统和硬件。栈的空间较小,通常有限制,一般几MB到几十MB。
  5. 生命周期:堆上的对象的生命周期可以是任意长的,直到被显式地释放。而栈上的对象的生命周期与函数调用有关,只存在于函数执行期间。
  6. 访问速度:由于栈上的数据是连续分配的,所以访问速度较快。而堆上的数据是动态分配的,访问速度较慢。
  7. 分配效率:堆的内存分配效率较低,需要在运行时进行内存管理,容易产生内存碎片。而栈的内存分配效率较高,只需要简单地移动栈指针。

HotSpot 虚拟机对象

说说对象的创建过程

  1. 类加载检查:JVM检查这个类否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

  2. 分配内存:对象所需的内存大小在类加载完成后便可确定,把一块确定大小的内存从 Java 堆中划分出来。

  3. 初始化零值:将分配到的内存空间都初始化为零值,保证对象的实例字段在 Java 代码中可以不赋初始值就直接使用。

  4. 设置对象头:例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 **这些信息存放在对象头中。

  5. 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。执行构造方法。

对象内存分配方式了解吗

  • 指针碰撞 :
    • 适用场合 :堆内存规整(即没有内存碎片)的情况下。
    • 原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表 :
    • 适用场合 : 堆内存不规整的情况下。
    • 原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
    • 使用该分配方式的 GC 收集器:CMS

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”,复制算法内存也是规整的。

如何解决内存分配并发问题

创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的

  • CAS+失败重试: 采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

对象的内存布局了解吗

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

  • 对象头

    • 运行时数据:哈希码、GC 分代年龄、锁状态标志等等
    • 类型指针:指向它的类元数据的指针,通过这个指针来确定这个对象是哪个类的实例。
  • 实例数据:对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

  • 对齐填充部分:占位作用。因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,也就是对象的大小必须是 8 字节的整数倍。

对象的访问定位方式有哪些

目前主流的访问方式有:句柄直接指针

句柄

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

直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。

各自优点:

使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用的就是这种方式来进行对象访问。

垃圾回收

内存分配和回收原则

对象优先在 Eden 区分配

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

当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 Survivor 空间也不够,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放,就不会出现 Full GC。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

大对象直接进入老年代主要是为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

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

虚拟机给每个对象一个对象年龄(Age)计数器。

对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1

对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。

主要进行 gc 的区域,gc分类

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

了解空间分配担保机制吗

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。就会进行 Minor GC,否则将进行 Full GC。

新生代为什么要分为Eden和Survivor,它们的比例是多少

实际上,新生代中的对象有98%熬不过第一轮收集,因此并不需要按照1∶1的比例来划分新生代的内存空间。

每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。

这样可以提高垃圾的回收效率,合理利用内存空间。

为什么要设置两个Survivor区域

设置两个 Survivor 区最大的好处就是解决内存碎片化。

因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。

这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。

说一下JVM中一次完整的GC流程

我们创建的对象将 Eden 区全部挤满,此时,Minor GC 就触发了。

在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。为什么要做这样的检查呢?原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:

  1. 老年代剩余空间大于新生代中的对象大小,那就直接Minor GC,GC完survivor不够放,老年代也绝对够放;
  2. 老年代剩余空间小于新生代中的对象大小,这时候根据老年代空间分配担保规则
    • 老年代中剩余空间大小,大于历次Minor GC之后剩余对象的大小,进行 Minor GC;
    • 老年代中剩余空间大小,小于历次Minor GC之后剩余对象的大小,进行Full GC,把老年代空出来再检查。

开启老年代空间分配担保规则,Minor GC 剩余后的对象够放到老年代,Minor GC 后会有这样三种情况:

  1. Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束;
  2. Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC 结束;
  3. Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC。Full GC 之后,老年代任然放不下剩余对象,就只能 OOM;

未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,只能 OOM;

StackOverFlow与OOM的区别是什么

  • StackOverFlow是空间不足出现的,主要是单个线程运行过程中调用方法过多或是方法递归操作时申请的栈帧使用存储空间超出了单个栈申请的存储空间。

  • OOM主要是区申请的内存空间不够用时出现,比如单次申请大对象超出了堆中连续的可用空间。

垃圾回收器为什么要STW

因为在进行垃圾回收过程中,需要确保没有对象正在被修改或引用关系正在改变。

如果在清理过程中中,应用程序仍然在运行,可能会导致以下问题:

  1. 一致性:垃圾回收器必须保证回收过程中堆内存的一致性,即回收前后,对象的引用关系和状态保持一致。
  2. 安全性:如果应用程序在对象处于不一致状态时访问这些对象,可能会导致错误的结果。例如,如果一个对象已经被回收但应用程序仍然试图使用它,就可能引发空指针异常或其他运行时错误。

虽然STW机制会导致应用程序的暂停和延迟,但可以提供较高的垃圾回收效率和保证正确性。

死亡对象判断方法

引用计数法

给对象中添加一个引用计数器:

  • 每当有一个地方引用它,计数器就加 1;
  • 当引用失效,计数器就减 1;
  • 任何时候计数器为 0 的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

例如对象 objAobjB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,引用计数算法无法通知 GC 回收器回收他们。

可达性分析算法

通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

哪些对象可以作为 GC Roots

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

对象可以被回收就会马上被回收吗

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收

对象引用类型有哪些

引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

1.强引用

  • 使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,垃圾回收器绝不会回收它。
  • 当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止。

2.软引用

  • 一个对象只具有软引用,如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。
  • 软引用可用来实现内存敏感的高速缓存。加速 JVM 对垃圾内存的回收速度,减少OOM
  • 软引用可以和一个引用队列联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

3.弱引用

  • 在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

  • 弱引用可以和一个引用队列联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用

  • “虚引用”,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

  • 虚引用主要用来跟踪对象被垃圾回收的活动

  • 虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。

如何判断一个字符串常量是废弃常量

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断一个类是无用的类

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

标记-清除法

该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。

  1. 效率问题
  2. 空间问题(标记清除后会产生大量不连续的碎片)

标记-复制法

为了解决效率问题,“标记-复制”收集算法出现了。

它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。

标记-整理法

根据老年代的特点提出的一种标记算法,标记过程与“标记-清除”算法一样,然后让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

新生代:每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。

老年代:对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

垃圾收集器

Serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。单线程

它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Scavenge 收集器

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量

新生代采用标记-复制算法,老年代采用标记-整理算法。

这是 JDK1.8 默认收集器

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS 收集器是一种 “标记-清除”算法实现的,整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。GC 线程无法保证可达性分析的实时性,所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 为了修正并发标记期间因为用户程序继续运行,而导致标记产生变动的那一部分对象的标记记录
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

主要优点:并发收集、低停顿

但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感:需要占用额外的CPU资源,CPU资源紧张的情况下,CMS可能会导致更长的停顿时间
  • 无法处理浮动垃圾:标记阶段完成后,应用程序可能会继续产生新的垃圾对象(浮动垃圾),CMS收集器无法处理它们,只能等待下一次垃圾回收才能清理
  • 内存碎片问题:CMS收集器使用”标记-清除”算法

G1 收集器

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

具备以下特点:

  1. 区域化堆内存结构:G1收集器将堆内存划分为多个大小相等的区域(Region),每个区域既可以是Eden空间,也可以是Survivor空间或Old空间。有助于减少垃圾回收的范围,提高回收效率。
  2. 并发标记和并发清理:在标记阶段,G1会与应用程序并发执行,以避免长时间的停顿。在清理阶段,G1会根据垃圾回收策略,优先清理垃圾最多的区域(Garbage-First),以达到最大收益。
  3. 可预测的停顿时间:通过设定目标的吞吐量和可容忍的停顿时间,G1会根据实时的垃圾回收情况来动态调整回收策略。
  4. 空间整理:在回收过程中对堆内存进行压缩和整理。这有助于减少内存碎片,并提高堆内存的利用率。

G1 收集器的运作大致分为以下几个步骤:

  1. 初始标记:暂停所有的其他线程,并记录下直接与 root 相连的对象
  2. 并发标记:在初始标记完成后,并发标记阶段开始。此时,应用程序可以继续运行,而G1收集器并发地标记所有可到达的对象。
  3. 最终标记:最终标记会处理在并发标记过程中产生的新垃圾,并更新引用关系。
  4. 筛选回收:根据实时的垃圾回收数据和停顿时间目标,进行区域评估和优先级排序。然后选择垃圾最多的区域作为回收目标,并将存活对象转移到其他未满的区域。(G1收集器的核心操作)

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region。(G1收集器将堆内存划分为多个大小相等的区域(Region))

从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。

ZGC 收集器

ZGC 也标记-复制算法,不过 ZGC 对该算法做了重大改进。

在 ZGC 中出现 Stop The World 的情况会更少

内存泄漏和内存溢出

内存泄露是什么

在 Java 中,内存泄漏就是存在一些不会再被使用却没有被回收的对象:

  1. 这些对象是可达的,即在有向图中,存在通路可以与其相连;
  2. 这些对象是无用的,即程序以后不会再使用这些对象。

如果对象满足这两个条件,这些对象就可以判定为 Java 中的内存泄漏,这些对象不会被 GC 所回收,然而它却占用内存。

内存泄露的根本原因是什么

长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是 Java 中内存泄漏的发生场景。

举几个可能发生内存泄漏的情况

  1. 静态集合类引起的内存泄漏;

  2. 各种连接:比如数据库连接(dataSourse.getConnection()),网络连接(socket) 和 IO 连接,除非其显式的调用了其 close() 方法将其连接关闭,否则是不会自动被 GC 回收的;

  3. 内部类:内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放;

  4. 单例模式:单例对象在初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被 JVM 正常回收,导致内存泄漏。

  5. ThreadLocal类的key 为 ThreadLocal 的弱引用,而 value 是强引用。如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

尽量避免内存泄漏的方法

  1. 尽量不要使用 static 成员变量,减少生命周期
  2. 及时关闭资源
  3. 使用字符串处理时避免使用String,应使用StringBuild
  4. 避免在循环中创建对象

内存溢出是什么

内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。

内存溢出的原因是什么

常见的有:

  1. 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
  2. 代码中存在死循环或循环产生过多重复的对象实体;
  3. 启动参数内存值设定的过小。

内存溢出的解决办法有哪些(如何避免)

  • 第一步,修改JVM启动参数,直接增加内存。
  • 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
  • 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
  • 第四步,使用内存查看工具动态查看内存使用情况。

类加载

谈谈你对类加载机制的了解(类生命周期)

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

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载 7 个阶段。其中验证、准备、解析 3 个部分统称为连接

说说类加载过程

JVM并不是一开始就会将所有的类加载到内存,而是用到某个类,才会去加载,只加载一次。

JVM加载 Class 类型的文件主要三步:加载->连接->初始化
连接过程又可分为三步:验证->准备->解析

加载

类加载过程的第一步,主要完成下面 3 件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将二进制字节流读入内存(JDK1.7及之前为JVM内存,JDK1.8及之后为本地内存)
  3. 在堆内存中生成一个代表该类的 Class 对象,作为(方法区/元空间)数据的访问入口

验证

主要是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

1、文件格式校验:验证字节流是否符合 class 文件的规范,并且能被当前版本的虚拟机处理。

2、 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。

3、 字节码验证:该阶段主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为;

4、 符号引用验证:将符号引用转化为直接引用

准备

准备阶段的主要任务是为类的类变量开辟空间并赋默认值

  • 1、静态变量是基本类型(int、long、short、char、byte、boolean、float、double)的默认值为0
  • 2、静态变量是引用类型的,默认值为null
  • 3、静态常量默认值为声明时设定的值
    例如:public static final int i = 3; 在准备阶段,i的值即为3

解析

该阶段的主要职责为将Class在常量池中的符号引用转变为直接引用,针对的是静态方法及属性和私有方法与属性

符号引用就是一组符号来描述目标,可以是任何字面量。
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

例如,一个类的方法为test(),则符号引用即为test,这个方法存在于内存中的地址假设为0x123456,则这个地址则为直接引用。

初始化

根据程序制定的主观计划去初始化类变量和其他资源,初始化阶段是执行类构造器 () 方法的过程。

什么时候会去加载一个类

  • 1.创建该类的实例
  • 2.调用该类的类方法
  • 3.访问类或接口的类变量,或为类变量赋值
  • 4.利用反射Class.forName();
  • 5.初始化该类的子类
  • 6.运行main方法,main方法所在类会被加载

类加载器有哪些

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

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

类加载器的加载机制有哪些

类加载器负责将.class文件加载到内存,系统为所有被载入到内存的类生成Class对象,类一但被加载,便不会加载第二次

每个类,都拥有一个独立的类名称空间,类全限定名+类加载器,确立其在 JVM中的唯一性

全盘负责

当一个类加载器负责加载某个类时,那这个类所引用的所有类都用这个加载器去加载,除非显示调用其他类加载器,这样可以避免一个类被重复加载。

双亲委派

工作流程:

在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。

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

自底向上检查类是否被加载,自顶向下尝试加载类

缓存机制

当jvm加载完成一个类是会将类放入jvm缓存中,加载流程为先去缓存区查看当前类是否被加载,如果没有则读.class文件并加载,如果加载则直接返回。

双亲委派模型的优点有哪些

  • 避免类的重复加载:JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类

  • 保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

怎么打破双亲委派模型

  1. 自己写一个类加载器,继承ClassLoader类;
  2. 重写 loadClass() 方法
  3. 重写 findClass() 方法

这里最主要的是重写 loadClass 方法,因为双亲委派机制的实现都是通过这个方法实现的

有哪些实际场景是需要打破双亲委派模型的

以JDBC为例,它的代码在rt.jar中,由启动类加载器去加载,但它需要调用厂商实现的SPI代码,这些代码部署在ClassPath下面。

根据双亲委派模型,启动类加载器无法直接委派应用程序类加载器(Application ClassLoader)来加载SPI的实现代码。那么启动类加载器如何找到这些代码呢?

JDK引入了线程上下文类加载器(TCCL: Thread Context ClassLoader),线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链,利用线程上下文类加载器去加载所需要的SPI代码。

如何自定义类加载器

实现一个用户自定义类加载器需要去继承ClassLoader类并重写findClass方法

应用场景

  1. 加密保护
            公司的有些核心类库的字节码是经过加密的,这样的话,就需要实现自己的加载器,在加载这些类库的时候进行解密,然后再载入到内存
  2. 其他来源
            字节码是放在数据库,硬盘其他路径,甚至有可能放在云上。需要自定义加载器去加载。

Class文件结构

谈谈你对类文件结构的理解?有哪些部分组成?

  1. 魔数(magic):每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class 文件,即判断这个文件是否符合 Class 文件规范。

  2. 文件的版本:minor_version 和 major_version。

  3. 常量池:constant_pool_count 和 constant_pool:常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

  4. 访问标志:access_flags:用于识别一些类或者接口层次的访问信息。包括:这个 Class 是类还是接口、是否定义了 Public 类型、是否定义为 abstract 类型、如果是类,是否被声明为了 final 等等。

  5. 类索引、父类索引与接口索引集合:this_class、super_class和interfaces。

  6. 字段表集合:field_info、fields_count:字段表(field_info)用于描述接口或者类中声明的变量;fields_count 字段数目:表示Class文件的类和实例变量总数。

  7. 方法表集合:methods、methods_count

  8. 属性表集合:attributes、attributes_count

JVM调优

说下你用过的 JVM 监控工具?

指令:

  1. jps :查看当前 Java 进程信息
  2. jmap:内存监控
  3. jhat:分析 heapdump 文件
  4. jstack:线程快照,查看各个线程的调用堆栈,定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。

如何利用监控工具调优

1、堆信息查看

  1. 可查看堆空间大小分配(年轻代、年老代、持久代分配)
  2. 提供即时的垃圾回收功能
  3. 垃圾监控(长时间监控回收情况)
  4. 查看堆内类、对象信息查看:数量、类型等
  5. 对象引用情况查看

2、线程监控

  1. 线程信息监控:系统线程数量
  2. 线程状态监控:各个线程都处在什么样的状态下
  3. Dump 线程详细信息:查看线程内部运行情况
  4. 死锁检查

3、 热点分析

  1. CPU 热点:检查系统哪些方法占用的大量 CPU 时间;
  2. 内存热点:检查哪些对象在系统中数量最大

4、快照

快照是系统运行到某一时刻的一个定格。

在我们进行调优的时候,依赖快照功能,就可以进行系统两个不同运行时刻,对象的不同,以便快速找到问题。

5、内存泄露检查

JVM 的一些参数

  • 1. 堆设置

-Xms:初始堆大小
-Xmx:最大堆大小

-XX:NewSize=n:设置年轻代大小

-XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为 1:3,年轻代占整个年轻代年老代和的 1/4

-XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。

-XX:MaxPermSize=n:设置永久代大小

  • 2. 收集器设置

-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC:设置并发收集器

  • 3. 垃圾回收统计信息

-XX:+PrintGC:开启打印 gc 信息
-XX:+PrintGCDetails:打印 gc 详细信息


文章作者: Aiaa
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Aiaa !
  目录