2023-12-14
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://www.skjava.com/mianshi/tiji/1738418218

什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?

Java 虚拟机是 Java 程序运行环境的一部分,负责执行 Java 字节码,它是 Java 实现平台无关性的基石。

Java 程序首先被编译成一种中间形式,即字节码(.class 文件),这些字节码不针对任何特定的硬件或操作系统。当运行 Java 程序时,JVM 在实际的硬件平台上解释执行这些字节码,实现了“一次编写,到处运行”(Write Once, Run Anywhere, WORA)的理念。因此,Java 程序无需为不同平台重新编译,只要对应平台上有相应的 JVM 实现,Java 程序就可以在上面运行。

这种设计有如下几个好处:

  1. 跨平台性:只需编写一次代码,就可以在多个平台上运行,无需针对每个操作系统写特定的代码。

  2. 安全性:JVM 提供了一个隔离的运行环境,可以在执行 Java 程序时对其进行检查和限制,减少安全风险。

  3. 移植性:由于 JVM 屏蔽了底层硬件和操作系统的差异,Java 程序更容易从一个平台迁移到另一个平台。

    内存管理

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

JVM 的主要组成部分如下:

  1. 类加载器(Class Loaders)
    • 负责加载 Java 类到 JVM 中。它在运行时将 Java 的 .class 文件加载到内存中,并为它们创建对应的 Class 对象。
    • 类加载器按照父级委派模型工作,首先会请求父类加载器加载类,只有在父类加载器加载失败时,才会尝试自己加载。
  2. 运行时数据区(Runtime Data Areas)
    • 方法区(Method Area):存储每个类的结构,如运行时常量池、字段和方法数据,方法和构造函数的代码,以及类型、方法和构造函数的特殊方法。
    • 堆(Heap):JVM 中内存最大的一块,用于存储所有类实例和数组。垃圾收集器主要的工作区域也在这里。
    • 栈(Stacks):存储局部变量和部分结果,并参与方法调用和返回。每个线程拥有自己的调用栈。
    • 程序计数器(PC Register):当前线程所执行的字节码的行号指示器。
    • 本地方法栈(Native Method Stacks):为 JVM 使用到的 Native 方法服务。
  3. 执行引擎(Execution Engine)
    • 负责执行类文件中的指令。它包括一个虚拟处理器、解释器、及即时编译器(JIT 编译器)。
    • 解释器:快速解释字节码,但执行同样代码的效率较低。
    • 即时编译器(JIT):编译热点代码(频繁执行的代码)为本地机器码,提高执行效率。
  4. 垃圾回收器(Garbage Collector)
    • 负责回收堆内存中不再被使用的对象。垃圾回收器的存在使得 Java 程序员不需要手动管理内存,减少了内存泄漏和指针失误的问题。
  5. 本地接口(Native Interface)
    • 提供了一个接口,用于交互 Java 代码和本地库,允许 Java 程序调用或被其他语言如 C/C++ 写的程序调用。
  6. 本地库(Native Libraries)
    • 由 JVM 使用的标准库,这些库通常用 C/C++ 编写,被本地接口调用。

32 位和 64 位的 JVM,int 类型变量的长度是多数?

在 Java 中,数据类型的大小不受 JVM 是 32 位还是 64 位的影响。这是 Java 语言的一部分规范,确保了 Java 程序的可移植性。因此,无论在 32 位还是 64 位的 JVM 上,int 类型的变量长度都是 32 位(4 字节)。这同样适用于其他基本数据类型,如 byteshortlongfloatdoublecharboolean,它们的大小在不同的 JVM 架构中都是一致的。

请详细介绍下 JVM 运行时数据区

VM 运行时数据区域是 Java 虚拟机定义的内存区域,用于在 Java 程序运行期间存储数据。这些区域包括堆(Heap)、方法区(Method Area)、虚拟机栈(VM Stacks)、程序计数器(Program Counter Register)和本地方法栈(Native Method Stacks)。

  • 堆(Heap):JVM中最大的一块内存区域,它被所有Java线程共享。在堆中,主要存放了Java应用创建的对象实例和数组。因为这些对象的生命周期不总是确定的,所以堆也是垃圾收集器的主要工作区域,以确保释放那些不再被任何引用的对象所占用的内存。
  • 方法区(Method Area):同样是线程共享的内存区域,它用来存储每个类的结构信息,比如类的名称、直接父类的名称、方法和变量的信息以及编译后的代码等。在一些JVM实现中,方法区可能被称为"永久代"(PermGen),但从Java 8开始,已经被元空间(Metaspace)所取代。
  • **Java栈(Java Stack)**则是线程私有的,它的生命周期与线程相同。每个栈由多个栈帧组成,每个栈帧对应着一次Java方法调用。栈帧中包含了局部变量表、操作数栈、动态链接信息以及方法返回时的操作。
  • **程序计数器(Program Counter Register)**是每个线程私有的内存区域,它包含了当前线程所执行的字节码的行号指示器。如果执行的是Java方法,计数器记录的是字节码指令的地址;如果执行的是Native方法,计数器的值则是undefined。
  • **本地方法栈(Native Method Stack)**与Java栈相似,但它被用来支持Java中的Native方法(即用其他语言如C或C++编写的方法)。当线程调用一个Native方法时,它的调用状态会被压入本地方法栈。

说一下堆栈的区别?

堆(Heap)

  • 堆是由 JVM 运行时管理的内存区域,用于存放 Java 应用程序创建的对象和数组。
  • 堆内存是在所有线程之间共享的,对象无固定的存活周期,存活时间可能从应用程序开始到结束。
  • 内存的分配和回收是动态的,垃圾收集器负责回收那些不再被任何引用的对象,防止内存泄漏。
  • 堆的大小和生命周期是可以调节的,通常有一个固定的开始大小,但它会随着对象的创建和回收动态扩展或收缩,直到达到 JVM 配置的限制。

栈(Stack)

  • 栈是线程私有的内存区域,每个线程都有自己的栈,用于存储局部变量和部分结果,以及用于方法调用和返回。
  • 栈内存中的数据有明确的生命周期,遵循“先进后出”(LIFO)的原则。每个方法调用时会创建一个栈帧,方法结束时,对应的栈帧会被销毁。
  • 对于栈内存的分配和回收,操作更快速而且效率较高,因为它不需要复杂的垃圾收集算法,每个方法结束后,局部变量就会自动释放。
  • 栈的大小通常比堆小,且不可动态扩展,每个线程的栈空间在线程创建时被分配。

总的来说,堆是用于存储对象和数组的内存区域,是线程共享的,其对象的生命周期不固定,由垃圾收集器管理。而栈是线程私有的内存区域,用于基本类型的局部变量和对象引用,其数据随着方法调用而创建,方法结束而销毁。

对象创建的过程了解吗?

详细过程如下:

  1. 类加载检查
    • 当代码试图创建对象时,JVM 首先检查这个类的 Class 对象是否已经被加载、链接和初始化。如果没有,那么JVM会执行类加载过程。
  2. 内存分配
    • 一旦确定类已经被加载,JVM 接下来将在堆上为新对象分配内存。
    • 对象所需的内存大小在类加载完成后即已知,包括对象的所有实例变量和其他开销(如元数据信息、对齐填充等)。
    • 内存分配可以通过「指针碰撞」(如果内存是绝对连续的)或者「空闲列表」(如果内存是非连续的)方式来实现。
    • JVM 实现可能会采用垃圾回收算法中的分代收集算法,在这种情况下,新创建的对象通常会被放在堆的年轻代(Young Generation)。
  3. 初始化零值
    • 内存分配后,JVM 将分配的内存空间初始化为零值。这确保了对象的实例变量不会有未知的值。
  4. 设置对象头
    • JVM 会在对象的内存中设置对象头(包括类的元数据信息、哈希码、垃圾回收信息等)。
    • 对象头标识了对象的运行时类型和锁状态等信息。
  5. 执行 <init> 方法
    • 对象头设置完毕后,JVM 会调用构造函数。构造函数可能会调用父类的构造函数,并执行所有初始化块。
    • 构造函数中的代码将负责设置实例变量的初始值,这些值可能与默认零值不同。
  6. 引用分配
    • 完成对象的初始化后,JVM 将分配的内存地址赋值给引用变量,此时对象才算是完全创建好了。

整个对象创建过程是由 JVM 的类加载器、内存分配子系统、执行引擎等协作完成的。这个过程中,可能会触发垃圾收集活动,特别是在堆内存不足时。同时,虚拟机也可能对对象的内存分配和初始化过程进行优化,如通过逃逸分析判断对象是否可以在栈上分配,或者通过即时编译器优化对象的创建代码。

怎么获取 Java 程序使用的内存?堆使用的百分比?

  1. 使用命令行工具
    • jstat 命令可以用来查看堆内存以及其他各种运行时数据区的使用情况。
    • 使用 jstat -gc <pid> 可以看到年轻代、老年代以及永久代/元空间的当前使用情况。
  2. 使用 JConsole 或 VisualVM
    • 这些是 Java 提供的可视化工具,可以用来监控 Java 应用程序的内存使用、线程使用等运行时数据。
    • 它们可以显示堆内存的使用情况,包括初始大小、已用大小、提交大小和最大大小。
  3. 使用 Runtime 类
    • Runtime 类提供了与 Java 应用程序的运行时环境相关的方法。
    • Runtime.getRuntime().totalMemory() :获取 JVM 的总内存量。
    • Runtime.getRuntime().freeMemory() :获取 JVM 的空闲内存量。
    • Runtime.getRuntime().maxMemory() :获取 JVM 尝试使用的最大内存量。
    • 堆使用百分比可以通过计算 (totalMemory - freeMemory) / maxMemory 得到。
  4. 使用 MemoryMXBean
    • java.lang.management 包提供了管理和监视 Java 虚拟机的类和接口。
    • MemoryMXBean 可以获取 Java 虚拟机的内存状态信息。
    • 通过 ManagementFactory.getMemoryMXBean() 获取 MemoryMXBean 实例。然后可以使用 MemoryMXBean.getHeapMemoryUsage() 获取堆的内存使用情况。

什么是指针碰撞?

指针碰撞是一种简单高效的内存分配策略,常用于管理内存堆中的空闲空间,适合处理连续内存分配。

在使用指针碰撞的内存分配中,维护一个指针(通常称为 “分配指针”),指向堆内存中当前可用于分配的第一个位置。当需要分配内存时,只需要进行以下步骤:

  1. 检查足够的空间:首先检查从分配指针开始的内存块是否足够大以满足内存分配请求。
  2. 分配内存:如果有足够的空间,将内存从分配指针指向的位置开始分配给对象。
  3. 移动指针:分配后,将分配指针“碰撞”向前移动到新分配的内存块之后的位置。

指针碰撞内存分配策略非常快,因为它只是简单地移动一个指针,而不需要遍历和查找足够大小的空闲块。然而,它的一个主要缺点是可能会导致内存碎片,特别是在频繁的对象创建和销毁的情况下。为了解决这个问题,通常会结合使用垃圾收集器,特别是那些采用复制(Copying)或标记-整理(Mark-Compact)算法的收集器,来定期整理内存,减少碎片,从而为指针碰撞策略提供连续的空闲内存区域。

什么是空闲列表?

空闲列表是另一种内存管理方法,用于跟踪堆内存中的空闲空间。与指针碰撞方法不同,空闲列表方法适用于非连续的内存分配。

在使用空闲列表管理内存时,系统会维护一个列表,记录堆内存中所有未被分配的空间。这个列表包含了多个空闲块的大小和位置信息。当应用程序请求分配内存时,内存管理器会遍历空闲列表,寻找一个足够大的空闲块来满足这个请求。

工作流程如下:

  1. 查找合适的空闲块:当请求内存分配时,系统会检查空闲列表,查找一个足够大的空闲块。这个查找过程可能会根据不同的策略进行,如首次适应(First Fit)、最佳适应(Best Fit)或最差适应(Worst Fit)。
  2. 分配内存:一旦找到合适的空闲块,系统会从这个块中分配所需的内存给请求者。如果空闲块的大小刚好等于请求的大小,则整个块被分配。如果空闲块比请求的大小大,它会被分割,一部分分配给请求者,剩余的部分仍然保留在空闲列表中。
  3. 更新空闲列表:分配内存后,空闲列表需要更新以反映内存的新状态。如果一个空闲块被完全使用,它将从空闲列表中移除。如果一个空闲块被分割,列表将更新为只包含剩余部分的信息。

空闲列表方法可以减少内存碎片问题,因为它可以更精细地管理内存分配,但这种精细度带来的代价是更高的管理成本,因为系统必须维护更复杂的数据结构,并且在每次内存分配时进行更多的计算。此外,频繁的分配和回收可能会导致列表变得很长,增加查找合适空闲块的时间。为了优化性能,可以使用合并邻近的空闲块的技术来减少列表的长度和提高内存的利用率。

JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?

会。假设 JVM 虚拟机上,每一次 new 对象时,指针就会向右移动一个对象 size 的距离,一个线程正在给 A 对象分配内存,指针还没有来的及修改,另一个为 B 对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占。

有两种可选方案来解决这个问题:

  • 采用 CAS 分配重试的方式来保证更新操作的原子性。这意味着当一个线程在执行一个原子操作分配内存时,没有其他线程可以同时执行另一个会干扰它的操作。
  • 线程本地分配缓冲(TLAB)
    • JVM 中的每个线程可以拥有自己的小块私有的内存缓冲区,称为线程本地分配缓冲(TLAB)。
    • 每个线程在自己的 TLAB 上分配内存,这样它们就不会与其他线程冲突,从而减少了同步的需要。
    • 当一个线程的 TLAB 用尽时,它需要获取新的 TLAB,这个过程可能需要同步,但这种情况发生得比较少。

内存溢出和内存泄漏是什么意思

内存溢出:

  • 内存溢出发生时,表示 Java 虚拟机(JVM)中的堆内存不足,无法再分配新的对象。
  • 这种情况通常发生在应用程序尝试创建对象,但是堆内存已经满了,而且垃圾收集器无法释放任何更多的内存空间。
  • 内存溢出可能是由于内存泄漏,或者是因为 JVM 的堆内存设置不足以应对应用程序的需求。
  • 当内存溢出发生时,JVM 会抛出 OutOfMemoryError 异常。

内存泄漏:

  • 内存泄漏是指已分配的内存资源未能被释放,而应用程序不再使用这些内存资源。
  • 在 Java 中,内存泄漏通常是由于长时间生存的对象持有对不再需要的对象的引用,阻止垃圾收集器回收它们,导致无法回收的对象逐渐积累。
  • 随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终可能导致内存溢出。
  • 内存泄漏的诊断通常需要借助分析工具(如MAT)来检查哪些对象被错误地保留在内存中。

总结来说,内存溢出是指没有足够的内存空间来分配新的对象,通常是一种严重的错误,会导致程序崩溃。而内存泄漏是一个渐进的过程,未使用的对象逐渐积累,消耗掉所有可用内存,可能最终导致内存溢出。

内存泄漏可能由哪些原因导致呢?

内存泄露主要是由于对象不再需要,但是垃圾收集器无法进行回收,因为仍然存在对这些对象的引用。

长生命周期对象持有短生命周期对象的引用

  • 如果一个具有长生命周期的对象引用了应该是短生命周期的对象,那么这些短生命周期的对象不会被回收。

集合类中的对象没有被及时清理

  • 在使用集合类(如 List、Map、Set 等)时,即使对象已经不再需要,如果没有从集合中移除,它们就会一直占用内存。

静态字段

  • 静态字段的生命周期与类的生命周期相同,如果静态字段引用了某个对象,那么这个对象可能在整个应用程序的生命周期内都不会被回收。

内部类和匿名类持有外部类的引用

  • 非静态内部类和匿名类隐式持有对其外部类实例的引用,如果内部类的实例比外部类活得更久,那么外部类实例也不会被回收。

缓存对象

  • 未正确管理的缓存可能导致已缓存的对象长时间占用内存,尤其是在使用强引用缓存时。

不当的资源管理

  • 如果资源(如数据库连接、网络连接等)没有被正确关闭,可能导致内存泄漏。

ThreadLocal变量滥用

  • ThreadLocal 变量存储在线程的内存中,如果线程一直运行(例如,在线程池中),那么这些变量可能不会被清理。

如何判断对象仍然存活?

  1. 引用计数法:这是最简单的一种方法,但在 Java 的主流 JVM 实现中并未使用。每个对象有一个引用计数器,当有一个地方引用它时,计数器值加一;当引用失效时,计数器值减一。计数器为零时,对象可以被回收。这种方法的主要问题是它无法处理循环引用的情况。
  2. 可达性分析(Reachability Analysis):这是 Java 中最常用的方法。在这种方法中,一系列称为“GC Roots”的对象被认为是活跃的。从这些节点开始,JVM 通过引用关系遍历,能够连续到达的对象被认为是活跃的,否则被认为是可回收的。常见的 GC Roots 包括:
    • 活跃线程
    • 静态字段(类的静态属性)
    • 本地方法栈内的局部变量

怎么判断对象是否可以被回收?

判断一个对象是否可以被回收主要依赖于可达性分析(Reachability Analysis)。下面是判断一个对象是否可以被回收的主要步骤和准则:

  1. GC Roots 连接:在可达性分析中,首先确定一组称为 GC Roots 的对象。这些对象是垃圾收集的起点。常见的 GC Roots 包括:
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
    • 方法区中类静态属性引用的对象。
    • 方法区中常量引用的对象。
    • 本地方法栈中 JNI(即通常说的 Native 方法)引用的对象。
  2. 可达性分析:从 GC Roots 开始,JVM 检查所有通过引用链能够到达的对象。如果一个对象可以从 GC Roots 出发通过引用链到达,它被认为是可达的(活跃的),因此不应该被回收。
  3. 不可达对象:如果对象无法从任何 GC Roots 到达,那么它被认为是不可达的,可以被回收。这些对象包括:
    • 完全没有被引用的对象。
    • 仅由不可达的对象引用的对象(即它们不是由任何活跃对象或 GC Roots 直接或间接引用的)。
  4. 特殊情况 - 弱引用、软引用和虚引用:在 Java 中,除了强引用外,还有弱引用(WeakReference)、软引用(SoftReference)和虚引用(PhantomReference)。这些引用类型在垃圾收集时有特殊的处理规则。例如,软引用在内存不足时会被回收,而弱引用则无论内存状况如何总是会被回收。
  5. 回收判定后的处理:一旦对象被判定为不可达,它就可以被垃圾收集器回收。在这个阶段,如果对象类定义了 finalize 方法,且该方法未被调用过,JVM 会将这些对象放入一个队列(Finalizer队列),等待执行其 finalize 方法,这是对象的最后一次机会改变自己的“死亡”命运。如果在 finalize 方法执行后,对象仍然不可达,则会在下一次垃圾回收中被清理。

Java 中可作为 GC Roots 的对象有哪几种?

  1. 本地方法栈中的引用: 这些是从本地方法(即用非 Java 语言编写的方法,如 C 或 C++)引用的对象。
  2. Java 虚拟机栈中的引用: 这包括 Java 方法的局部变量。每个线程的方法调用都有自己的栈帧,其中包含局部变量表,这些局部变量可能引用其他对象。
  3. 方法区中的类静态属性引用的对象: 方法区(Method Area)存储了每个类的结构,如运行时常量池、字段、方法数据等。类的静态变量也存储在这里,它们可能引用其他对象。
  4. 方法区中常量引用的对象: 这指的是方法区中的常量池中的引用,常量池主要存储编译时期生成的各种字面量和符号引用。
  5. 同步锁持有的对象: 在同步代码块或方法中作为锁定对象的引用。

Java 中都有哪些引用类型?

Java 中的引用类型主要分为四种:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

  1. 强引用(Strong Reference):
    • 最常见的引用类型,当我们通常创建一个对象并赋值给一个引用变量时,这就是强引用。例如:Object obj = new Object();
    • 只要某个对象有强引用,垃圾回收器就不会回收它。即使程序可能面临内存溢出的风险,只要对象还被强引用指向,它就不会被回收。
  2. 软引用(Soft Reference):
    • 软引用是用来描述一些有用但并非必需的对象。在Java中通过SoftReference类实现。
    • 软引用的对象在内存足够的情况下不会被回收,只有在内存不足时,JVM才会回收这些对象,从而有效地避免了内存溢出。
    • 适合用于缓存。
  3. 弱引用(Weak Reference):
    • 比软引用的生存期更短。通过WeakReference类实现。
    • 当垃圾回收器工作时,不管内存是否足够,都会回收只被弱引用指向的对象。
    • 弱引用可以用于实现规范映射(Canonical Maps)等,例如WeakHashMap
  4. 虚引用(Phantom Reference):
    • 最弱的一种引用类型。
    • 通过PhantomReference类实现,并且必须和ReferenceQueue一起使用。
    • 虚引用的对象在被垃圾回收时,会收到一个系统通知,这通常用于执行一些重要的清理工作。

finalize()方法了解吗?有什么作用?

finalize()主要用于在对象被垃圾回收器销毁之前执行清理操作。它是 Object 类的一个方法,因此每个类都可以覆盖finalize() 方法来实现自定义的清理逻辑。其作用有如下几个:

  • 资源释放finalize() 方法通常用于释放对象持有的资源,如关闭文件句柄、网络连接或者释放自定义的内存资源等。这在 Java 早期版本中是一种常见的模式。
  • 最终机会清理:在对象被垃圾回收之前,finalize() 提供了一个最后的机会来执行清理操作。这意味着你可以在对象不再被使用时执行一些重要的清理工作。

但是,由于 finalize() 的调用时机完全取决于垃圾回收器,导致它不可预测性和性能问题,所以已经不再被推荐使用。且在 Java 9 及以后的版本中,finalize() 方法被标记为废弃(deprecated)。

Java 堆的内存分区了解吗?

  • 年轻代(Young Generation):
    • 这是新创建的对象首先分配内存的地方。年轻代内存分为几个部分:
      • Eden 空间:大部分新生成的对象首先在 Eden 空间分配。
      • 两个幸存者空间(Survivor Spaces):分为 S0(Survivor 1)和 S1(Survivor 2)。存活下来的对象从 Eden 空间转移到一个幸存者空间,然后在幸存者空间之间来回移动。
    • 年轻代的特点是对象的分配和回收都相对较快。
  • 老年代(Old Generation 或 Tenured Generation):
    • 存放长时间存活的对象。
    • 当对象在年轻代中存活足够长的时间后,它们就会被转移到老年代。
    • 老年代的垃圾回收(GC)频率比年轻代低,但每次回收的时间更长。
  • 永久代/元空间(PermGen/Metaspace,取决于 JVM 版本):
    • 在 JDK 8 之前,JVM 使用的是永久代(PermGen)。永久代主要用于存储类元数据、方法对象等。
    • 由于永久代经常出现内存溢出的问题,JDK 8 引入了元空间(Metaspace)来替代永久代。元空间不在虚拟机内存中而是使用本地内存,因此其大小受本地内存限制。

请详细介绍下新生代

新生代专门用于存放新创建的对象。由于大多数对象生命周期都很短,JVM 通过在新生代中频繁进行垃圾回收来有效管理内存。

新生代的内存结构

新生代主要分为三个部分:

  • Eden 空间:新创建的对象通常首先在 Eden 空间分配内存。当 Eden 空间填满时,触发一次 Minor GC(也叫 Young GC),即新生代的垃圾回收。
  • 两个幸存者空间:这两个空间分别被称为 S0(Survivor 1)和 S1(Survivor 2)。在进行 Minor GC 时,存活的对象会从 Eden 空间移动到一个幸存者空间(如 S0),而从一个幸存者空间(如 S0)存活下来的对象则会移动到另一个幸存者空间(如 S1)。这种来回移动的过程有助于筛选出那些更有可能长期存活的对象。

Eden 和两个幸存者空间的大小比例默认是 8:1:1,可以通过 -XX:SurvivorRatio 来调整。

新生代的垃圾回收(Minor GC)

  • 当 Eden 区域满了,就会触发一次 Minor GC。
  • 在 Minor GC 过程中,存活的对象会从 Eden 移动到一个幸存者空间,同时,从一个幸存者空间存活下来的对象会移动到另一个幸存者空间。
  • 经过多次 GC 后,仍然存活的对象会被认为是长期存活对象,它们将被移动到老年代(Old Generation)。

请详细介绍下老年代

老年代主要用于存储长期存活的对象。

老年代的特点

  • 长期存活对象
    • 当对象在新生代(Young Generation)的多次垃圾回收(GC)后仍然存活,它们通常会被移动到老年代。
    • 这些对象被视为长期存活对象,例如缓存数据或持久化的状态信息。
  • 大容量
    • 老年代的大小通常比新生代大得多,占据了堆内存的大部分。它的容量决定了可以长期存储的对象数量。
  • 垃圾回收频率
    • 老年代的垃圾回收频率较低,但每次回收的耗时通常比新生代长。

老年代的垃圾回收

Major GC / Full GC

  • 当进行老年代的垃圾回收时,通常称为 Major GC 或 Full GC。这种回收可能会涉及整个堆内存的回收,包括新生代和老年代。
  • Major GC 的执行时间通常比新生代的 Minor GC 长得多,可能导致明显的停顿。

知道内存分配策略吗?

  • 对象优先在 Eden 分配
    • 在大多数情况下,对象首先在新生代的 Eden 区域分配。当 Eden 区域填满时,会触发 Minor GC(即新生代垃圾回收)。
  • 大对象直接进入老年代
    • 大对象(如大数组或大对象)可能直接在老年代分配内存,以避免在 Eden 区和两个 Survivor 区之间来回复制,减少垃圾回收时的开销。
  • 长期存活的对象将进入老年代
    • 对象在新生代中每经历一次 Minor GC,其年龄就会增加1。当对象的年龄增加到一定程度(默认为 15,可通过 -XX:MaxTenuringThreshold 设置),它们就会被移动到老年代。
    • 这个机制减少了在新生代和老年代之间不必要的对象复制。
  • 动态对象年龄判定
    • 为了更有效地利用内存,JVM 会动态地调整对象晋升到老年代的年龄阈值。
    • 如果在 Survivor 空间中相同年龄所有对象大小的总和超过 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 -XX:MaxTenuringThreshold 设置的年龄。
  • 空间分配担保
    • 在发生 Minor GC 之前,JVM会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果这个条件满足,那么 Minor GC 是安全的。如果不满足,VM 会查看 -XX:HandlePromotionFailure 设置,决定是否进行一次 Full GC。

为什么 Java 8 使用元空间替代永久代作为方法区的实现?

有如下几个原因:

  • 内存限制问题
    • 永久代内存限制:在旧版本的 JVM 中,永久代的大小是固定的,且在 JVM 启动时就被确定。这意味着存储在永久代中的类元数据的总量受到限制,容易出现 OutOfMemoryError
    • 元空间更灵活:元空间使用本地内存,而不是虚拟机内存,因此不受 Java 堆大小的限制。这使得元空间可以动态调整大小,减少了内存溢出的风险。
  • 垃圾回收的简化
    • 永久代的回收复杂性:永久代的垃圾回收相对复杂,特别是类和类加载器的卸载机制。
    • 元空间的垃圾回收:将类元数据移到本地内存中的元空间简化了垃圾回收过程,因为元空间主要涉及类元数据的回收,而这通常是在类加载器的生命周期结束时发生。

对象什么时候会进入老年代?

年龄阈值

  • 每个对象在新生代中有一个年龄计数器。当对象在新生代的 Eden 区或 Survivor 区存活下来后,每经历一次 Minor GC,它的年龄就会增加。
  • 当对象的年龄达到一定阈值(默认值通常为 15,但可以通过 JVM 参数 -XX:MaxTenuringThreshold 调整)时,它会被晋升到老年代。
  • 这个机制基于这样一个假设:如果一个对象已经存活了很多次垃圾回收,那么它可能会继续存活更长的时间。

动态年龄判定

  • JVM 采用动态年龄判定机制来决定对象晋升到老年代的时机。
  • 如果在 Survivor 空间中相同年龄的所有对象的大小总和超过 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到最大年龄阈值。

大对象直接进入老年代

  • 大对象(即所需内存空间较大的对象)可能会直接在老年代分配,以避免在新生代中占用大量空间并在 Minor GC 时导致大量的数据复制。
  • 这个行为可以通过 JVM 参数(如 -XX:PretenureSizeThreshold)控制。当对象的大小超过这个设置值时,会直接在老年代分配。

空间分配担保

  • 在进行 Minor GC 前,JVM 会检查老年代的最大可用连续空间是否大于新生代所有对象总空间。
  • 如果不满足条件,JVM 会调整对象的晋升年龄,让一些对象提前进入老年代,以确保 Minor GC 能顺利进行。

什么是 Stop The World ? 什么是 OopMap ?什么是安全点?

Stop-The-World(STW)

  • 定义:Stop-The-World 指的是在垃圾回收过程中,JVM 会暂停应用程序的所有线程。在这段时间里,除了垃圾回收线程之外,所有线程都会被暂停,不进行任何工作。
  • 目的:STW 事件发生是为了防止应用程序在垃圾回收过程中继续修改对象,这样可以保证垃圾回收的准确性和安全性。
  • 影响:STW 会影响应用程序的响应时间和吞吐量。因此,选择垃圾回收器时,需要考虑其 STW 的时间和频率,以适应不同的应用需求。

OopMap(Ordinary Object Pointer Map)

  • 定义:OopMap 是一种数据结构,它用于记录对象内部哪些部分是指向其他对象的引用(即 Ordinary Object Pointers)。
  • 用途:在垃圾回收过程中,JVM 需要知道堆栈和寄存器中哪些位置存储了指向堆上对象的引用。OopMap 提供了这些信息,使得垃圾回收器可以正确识别这些引用,以更新它们(如果对象被移动了)或者判断对象是否可达。
  • 效率:OopMap 提高了垃圾回收的效率,因为垃圾回收器不需要扫描整个对象来查找引用。

安全点(Safepoint)

  • 定义:安全点是程序执行中的特定位置,在这些位置上,JVM 可以安全地进行垃圾回收。
  • 特征:在安全点,所有引用的位置都是已知的(例如,在方法调用、循环跳转、异常抛出等位置)。这意味着 JVM 可以确保在这些点上的对象引用信息是准确和一致的。
  • STW 和安全点:Stop-The-World 事件通常在安全点发生,因为这是执行垃圾回收操作的安全时机。
  • 线程同步:为了达到安全点,JVM 会通知所有线程,在它们达到最近的安全点时暂停执行。

对象一定分配在堆中吗?有没有了解逃逸分析技术?

在 Java 中,传统上对象是分配在堆(Heap)内存中的。然而,并非所有对象都必须分配在堆上。Java 虚拟机(JVM)的一个优化技术,称为逃逸分析(Escape Analysis),可以改变这种分配方式。

逃逸分析

逃逸分析是一种编译器优化技术,用于分析对象的作用域和生命周期:

  1. 定义:逃逸分析确定对象的使用范围和生命周期是否超出了其定义的作用域。
  2. 不逃逸的对象:如果一个对象在方法内创建,并且其引用不会逃逸到方法之外,那么这个对象被认为是“不逃逸”的。
  3. 栈分配:对于不逃逸的对象,JVM 可以选择在栈上分配这些对象,而不是在堆上。栈上分配的好处是,一旦方法执行完毕,这些对象的内存就可以立即被回收,从而减少垃圾回收的负担。
  4. 同步省略:逃逸分析还可以用于同步省略(也称为锁消除)。如果确定某个对象只能被一个线程访问,那么对这个对象的同步操作可以被省略。
  5. 标量替换:逃逸分析还可以进行标量替换,将一个聚合对象拆分成几个独立的局部变量,进一步优化性能。

逃逸分析通常是默认启用的。但是,你也可以通过 JVM 参数显式地开启或关闭它:

  • 启用逃逸分析:-XX:+DoEscapeAnalysis
  • 禁用逃逸分析:-XX:-DoEscapeAnalysis

垃圾回收

GC是什么?为什么要GC

GC,即Garbage Collection,垃圾回收,用于自动管理内存。它的主要目的是识别和回收程序不再使用的内存,即“垃圾”,以避免内存泄漏和优化内存使用效率。

为什么需要垃圾回收

  1. 自动内存管理:在 Java 这样的高级语言中,程序员不需要显式地分配和释放内存,这是由 JVM 的垃圾回收器自动完成的。这大大简化了编程,减少了内存泄漏和其他内存相关错误的风险。
  2. 提高效率:GC 自动回收不再使用的对象,释放内存空间供新对象使用,从而提高了内存的使用效率。
  3. 程序稳定性:通过及时清理无用对象,GC 有助于维护应用程序的性能稳定性,避免因内存耗尽导致的程序崩溃。
  4. 提高安全性:自动垃圾回收减少了手动内存管理可能带来的安全问题,如悬空指针和重复释放等。

有什么办法主动通知虚拟机进行垃圾回收?

有两种方式可以通知 JVM 进行垃圾回收。

  1. System.gc()** 方法**: 这个方法是最常用的请求垃圾回收的方式。当你调用 System.gc() 时,你实际上是在建议虚拟机执行垃圾回收。虚拟机可能会考虑这个建议,并在合适的时候执行垃圾回收。但是,这种方法并不保证虚拟机一定会立即执行垃圾回收,也不保证回收操作的效果。
  2. Runtime.getRuntime().gc()** 方法**: 这个方法的作用与 System.gc() 类似,也是向虚拟机建议执行垃圾回收。它通过获取当前运行时环境的实例来请求垃圾回收。

一般来说我们要谨慎使用这两种方式,以避免不必要的性能损耗。

在Java中,对象什么时候可以被垃圾回收

在 Java 中,对象变成垃圾回收的候选对象,即可被垃圾回收器回收的时机,主要取决于它们的可达性(Reachability)。以下几种情况可以判定为垃圾回收的候选对象:

  • 无引用: 当对象不再有任何活动引用指向它时,它就可以被垃圾回收。这意味着对象在程序中不再可达,比如所有引用该对象的变量都已经超出了其作用域或被设置为 null
  • 仅有循环引用: 如果一组对象之间相互引用,但这组对象整体上不再被其他活动部分的程序所引用,那么这组对象也可以被垃圾回收。Java 的垃圾回收器能够识别并处理循环引用。
  • 对象所属的类已经被卸载: 如果一个对象的类的 ClassLoader 被垃圾回收,那么这个类的所有实例也可以被回收。这种情况在一些复杂的应用中可能会发生,尤其是在使用了自定义 ClassLoader 的情况下。
  • 终结器(Finalizer)的作用: 如果一个对象覆盖了 finalize() 方法,并且垃圾回收器发现了它,那么在对象被回收前,finalize() 方法会被调用。但这不是判断对象可以被回收的条件,而是在对象回收前的一种机制。因为 finalize() 方法的不确定性和性能问题,一般不推荐使用。

但是,即使对象变成了垃圾回收的候选对象,具体何时被回收仍然是不确定的。垃圾回收的具体时机取决于垃圾回收器的算法和内存使用情况。

JVM中的永久代中会发生垃圾回收吗

从 Java 8 开始,永久代已被元空间(Metaspace)所替代。但是,在 Java 8 之前的版本中,永久代是存在的,并且在永久代中也会发生垃圾回收。

Java 8 之前的版本(使用永久代)

在永久代中,确实会发生垃圾回收,但这种回收发生的频率和情况与堆内存中的垃圾回收有所不同。当类被卸载(这通常发生在使用了自定义类加载器并且该类加载器被回收的情况下)时,该类及其相关的元数据就会从永久代中移除。

在 Java 8 及以后的版本(使用元空间)

在元空间中,类的元数据仍然可以被回收。当类不再被使用时(例如,当类的加载器不再存在时),这些类及其元数据会被垃圾回收器回收。

说一下 JVM 有哪些垃圾回收算法?

标记-清除(Mark-Sweep)算法

此算法分为两个阶段:标记和清除。在标记阶段,算法标记出所有从根集合可达的对象。在清除阶段,未被标记的对象被认为是垃圾,并被清除。

但是,它可能产生大量内存碎片,导致后续可能无法为大对象找到足够的连续内存空间。

复制(Copying)算法

它将可用内存分为两块。每次只使用其中一块。当这一块内存用完了,就将存活的对象复制到另一块上,然后清理掉当前使用的这一块内存。

没有内存碎片。但它会使内存使用率低,因为任何时候只有一半的内存是被使用的。

标记-整理(Mark-Compact)算法

此算法在标记阶段和标记-清除算法相似。在整理阶段,它将所有存活的对象压缩到内存的一端,然后清理掉边界以外的内存。

它解决了内存碎片问题。但是整理过程中需要移动对象,可能会有较高的开销。

分代(Generational)收集算法

不同年龄的对象的生命周期不同。通常将内存分为年轻代(Young Generation)和老年代(Old Generation),分别应用不同的回收策略。

通过优化针对不同年龄段的对象的回收策略,可以在提高效率的同时减少回收引起的停顿时间。

说一下 JVM 有哪些垃圾回收器?

  1. Serial GC:
    • 类型: 单线程回收器。
    • 适用场景: 适用于小型应用和单核处理器环境。
    • 特点: 它在进行垃圾回收时会暂停所有应用线程(Stop-The-World)。
  2. Parallel GC (也称为 Throughput Collector):
    • 类型: 多线程回收器。
    • 适用场景: 适用于多核处理器环境,注重吞吐量。
    • 特点: 在年轻代使用并行方式执行,但老年代的垃圾回收仍然会触发全局暂停。
  3. Concurrent Mark Sweep (CMS) GC:
    • 类型: 并发回收器。
    • 适用场景: 适用于需要更短垃圾回收停顿时间的应用。
    • 特点: 它旨在减少应用停顿时间,通过并发标记和清除实现。
  4. G1 GC (Garbage-First Collector):
    • 类型: 并发和并行相结合的回收器。
    • 适用场景: 适用于大型堆内存和需要更精细控制垃圾回收停顿时间的应用。
    • 特点: 将堆划分为多个区域(Region),并按区域进行回收,目标是提供高吞吐量同时控制停顿时间。
  5. ZGC (Z Garbage Collector):
    • 类型: 可伸缩的低延迟垃圾回收器。
    • 适用场景: 适用于多核处理器和大堆内存的场景。
    • 特点: 旨在实现低停顿时间,同时支持大到几个 TB 的堆内存。
  6. Shenandoah GC:
    • 类型: 并发的低停顿时间回收器。
    • 适用场景: 适用于需要低延迟且堆内存较大的应用。
    • 特点: 类似于 ZGC,它的目标是减少垃圾回收导致的停顿时间,即使在处理大堆内存时也能保持低延迟。

详细介绍一下 CMS 垃圾回收器?

CMS 是专门用于回收老年代(Old Generation)的垃圾回收器。它的主要目标是获取最小的垃圾收集停顿时间,因此非常适合那些对响应时间有严格要求的应用。

工作流程

CMS 的工作过程可以分为几个主要阶段:

  1. 初始标记(Initial Mark):这个阶段会标记所有直接与 GC Root 相连的对象。这个阶段需要停止所有的应用线程(STW,Stop-The-World),但通常这个阶段很快。
  2. 并发标记(Concurrent Mark):在这个阶段,JVM 标记所有从 GC Root 直接或间接可达的对象。这个过程是并发进行的,即在应用程序运行的同时进行,不需要停止应用线程。
  3. 重新标记(Remark):因为在并发标记阶段,应用程序还在运行,可能会有一些对象的引用关系发生变化。重新标记阶段用来修正这些变化。这个阶段通常需要短暂的 STW。
  4. 并发清除(Concurrent Sweep):清除那些不再被引用的对象,回收内存空间。这个过程也是并发进行的。

CMS 的特点

  • 低停顿:CMS 设计的主要目标是减少垃圾收集造成的停顿时间。通过并发标记和清除,它能在应用程序运行时执行大部分工作,从而减少STW的时间。
  • 并发处理:能够与应用线程同时运行,减少了垃圾回收对应用响应时间的影响。
  • 老年代专用:CMS 只处理 JVM 中的老年代,新生代仍然使用其他垃圾回收器(如:ParNew或Serial)处理。

CMS 的缺点

  • 并发执行的代价:虽然减少了停顿时间,但并发执行会占用一部分CPU资源,可能导致应用程序的吞吐量有所下降。
  • 空间碎片化:由于 CMS 是基于标记-清除算法,它清除垃圾后可能会留下许多小的空闲内存块,导致空间碎片化。这可能需要定期的全面垃圾收集来整理这些碎片。
  • 对 CPU 资源敏感:由于其并发特性,CMS 对 CPU 资源比较敏感。在 CPU 资源受限的环境下,CMS 的性能可能会受到影响。

新生代垃圾回收器和老年代垃圾回收器都有哪些?有什么区别?

新生代垃圾回收器

新生代垃圾回收器主要负责管理 JVM 堆内存中的新生代区域。由于新生代中的对象生命周期短,这些回收器通常采用复制算法。常见的新生代垃圾回收器有:

  1. Serial GC:这是最基本的垃圾回收器,使用单线程进行垃圾回收。在执行垃圾回收时,会触发停顿(Stop-The-World),停止所有应用线程。适用于小型应用或者单核处理器环境。
  2. ParNew GC:是 Serial GC 的多线程版本。它在多核处理器上表现更好,但在进行垃圾回收时同样会触发停顿。
  3. Parallel GC(也称为 Throughput Collector):使用多线程进行垃圾回收,旨在提高吞吐量。在垃圾回收时也会发生停顿。

老年代垃圾回收器

老年代垃圾回收器负责管理 JVM 堆内存中的老年代区域。老年代中的对象通常有更长的生命周期,因此这些回收器通常采用标记-清除或标记-整理算法。常见的老年代垃圾回收器包括:

  1. Serial Old GC:是 Serial GC 的老年代版本,同样是单线程且在垃圾回收时会触发停顿。
  2. Parallel Old GC:是 Parallel GC 的老年代版本,使用多线程进行垃圾回收,目标是提高吞吐量。
  3. CMS (Concurrent Mark-Sweep):以最小化停顿时间为目标,通过并发标记和清除阶段减少应用暂停时间,但可能会产生较多的空间碎片。
  4. G1 (Garbage-First):是一种跨越新生代和老年代的垃圾回收器,它将堆分割成多个区域,并优先回收垃圾最多的区域。G1 GC 旨在提供更可预测的停顿时间,适用于大型堆内存。

什么时候会触发FullGC

Full GC 通常比新生代的垃圾回收更耗时,因为它会涉及到整个堆内存的回收活动,包括新生代、老年代以及永久代(或者在 Java 8 及以后版本的元空间 Metaspace)。以下是触发 Full GC 的一些常见情况:

  1. 老年代空间不足:当老年代空间不足以为新晋升的对象提供空间时,JVM 会触发 Full GC 来清理老年代并回收更多空间。
  2. 新生代晋升到老年代的空间不足:如果在新生代的垃圾回收后,存活的对象空间超过了老年代能提供的空间,JVM 也会触发 Full GC。
  3. 永久代(PermGen)或元空间(Metaspace)满:当类的元数据占用的空间超过了永久代或元空间的限制时,JVM 会触发 Full GC。在 Java 8 及以后版本中,元空间取代了永久代。
  4. System.gc() 调用:当应用程序显式调用 System.gc() 时,JVM 通常会执行 Full GC。然而,这个行为并不是完全保证的,因为垃圾回收是由 JVM 控制的。
  5. JVM 内部的垃圾回收算法决定:JVM 的垃圾回收算法可能决定执行 Full GC,例如在某些情况下进行内存压缩或在低内存情况下的资源回收。
  6. RMI 的垃圾回收:在使用 RMI 时,JVM 默认每分钟进行一次 Full GC,以回收 RMI 收集的对象。

JVM 的永久代中会发生垃圾回收么

JVM中的永久代是一个专门用于存储类的元数据、静态变量等数据的内存区域。在 Java 8 之前的版本中,永久代是非堆内存的一部分。永久代会发生垃圾回收,但是它回收的频率和方式与堆内存中的回收有所不同:

  1. 垃圾回收的触发时机:在旧版本的 JVM 中,当永久代接近其最大容量时,垃圾回收器会被触发以清理不再使用的类定义和元数据。这通常发生在大量类被加载和卸载的场景中,例如在某些应用服务器上或者使用动态代理生成大量类的情况下。
  2. 垃圾回收的方式:永久代的垃圾回收通常与堆内存的全局回收(Full GC)一起发生。当执行 Full GC 时,垃圾回收器不仅清理堆内存中的对象,也会检查并清理永久代中不再需要的类定义。
  3. Java 8 的变化:值得注意的是,在 Java 8 中,永久代被移除,取而代之的是元空间(Metaspace)。元空间不在虚拟机内存中而是使用本地内存,它主要用于存储类的元数据。元空间的引入改变了类元数据的存储方式和垃圾回收策略,使其更加灵活和高效。
  4. 垃圾回收的挑战:在永久代中进行垃圾回收的一个挑战是确定哪些类不再被使用。一个类不再被使用的条件包括:该类的所有实例都已被回收,对应的类加载器已经被回收,以及该类没有在任何地方被引用。

能详细说一下 CMS 收集器的垃圾收集过程吗?

CMS 收集器的垃圾收集过程可以分为几个主要阶段:

  1. 初始标记(Initial Mark)
    • 这是一个需要“Stop-The-World”(STW)的阶段,意味着在这个阶段中,所有的应用线程都会被暂停。
    • 在这个阶段,标记所有直接与 GC Roots 相连的对象。GC Roots 包括本地变量、活跃线程等。
  2. 并发标记(Concurrent Mark)
    • 这个阶段是并发执行的,即在执行垃圾收集的同时,应用程序的线程也在运行。
    • 在这个阶段中,JVM 遍历堆中的对象图,标记所有从 GC Roots 可达的对象。
  3. 预清理(Precleaning)
    • 这也是一个并发阶段,目的是为了减少最终标记的工作量。
    • 它主要是用来更新由于应用程序继续运行而发生变化的那部分对象的标记记录。
  4. 最终标记(Final Mark)
    • 这是第二个需要 STW 的阶段,但通常比初始标记阶段更短。
    • 在这个阶段,处理在并发标记和预清理阶段遗漏的那些在应用程序运行期间被修改的对象。
  5. 并发清除(Concurrent Sweep)
    • 在标记完成之后,开始并发地清理垃圾。
    • 在这个阶段,不再使用的对象被清理,但应用线程仍然在运行。
  6. 并发重置(Concurrent Reset)
    • 在垃圾收集完成后,重置 CMS 收集器的内部数据结构,为下一次垃圾回收做准备。

垃圾收集器应该如何选择?

  • 应用的性能需求
    • 响应时间优先:如果应用需要低延迟,例如在线交易处理系统,应选择如 G1 GC 或 ZGC 这样的低停顿时间收集器。
    • 吞吐量优先:对于批处理和后台处理等吞吐量更重要的场景,可以考虑 Parallel GC 或 CMS。
  • 堆的大小
    • 对于较小的堆(几百 MB 到几 GB),可以使用 Serial GC 或 Parallel GC。
    • 对于大堆(数十 GB 以上),G1 GC 或 ZGC 会是更好的选择。
  • 调优的复杂性和管理开销
    • 一些高级的收集器(如 CMS、G1 GC)提供了更多的调优选项,但也增加了管理和调优的复杂性。
    • 对于需要简单配置和维护的场景,简单的收集器(如 Serial GC)可能更合适。
  • 硬件资源
    • 如果资源(如 CPU 核心数)有限,可能需要避免使用那些对资源需求较高的收集器,例如 Parallel GC 或 G1 GC。
    • 在资源充足的情况下,可以选择更复杂、更高效的收集器。

能详细说一说G1 收集器吗?

G1(Garbage-First)是被设计用来替代旧的如 CMS(Concurrent Mark-Sweep)收集器。G1 收集器的主要目标是提供高吞吐量同时保持尽可能低的延迟。

工作原理

  • 堆分区
    • G1 收集器将堆内存分割成多个大小相等的区域(Region),这些区域可以是 Eden、Survivor 或 Old Generation。
    • 这种划分使得 G1 能够更灵活地管理堆,只回收价值最大的区域,从而控制回收周期的长度。
  • 回收过程
    • G1 收集器同时处理 Young 和 Old Generation,用来替代传统的 Young Generation(如 Parallel GC)和 Old Generation(如 CMS)收集器。
    • G1 通过跟踪每个区域中的垃圾量,优先回收包含最多垃圾的区域,以此提高回收效率。
  • 并发和停顿
    • G1 收集器的许多操作是并发进行的。例如,在处理 Old Generation 时,它会并发地标记活动对象。
    • G1 致力于保持较短的停顿时间,它允许用户指定期望的停顿时间目标(例如,不超过 100ms),并尽量在这个范围内完成回收工作。

特点

  • 可预测的停顿时间
    • 通过设定期望的停顿时间,G1 能够更好地满足需要低延迟的应用的需求。
  • 高效的垃圾回收
    • 回收最有价值区域的策略提高了回收效率,尤其是在堆内存较大的情况下。
  • 并发标记
    • 并发地标记活动对象,减少了全面垃圾回收的需要,从而降低了停顿时间。
  • 碎片整理
    • 在进行垃圾回收时,G1 还会进行碎片整理,这有助于避免内存碎片化,提高内存分配的效率。

适用场景

  • G1 特别适合于堆内存较大(超过 4GB)的应用程序。
  • 对于要求较短停顿时间的应用,G1 也是一个很好的选择。

有了 CMS,为什么还要引入 G1?

由于 CMS 对 CPU 资源比较敏感,在垃圾回收的过程还会产生内存碎片。而 G1 旨在填补 CMS 的不足:

  1. 更好的性能:G1 提供了更好的性能,尤其是在大内存应用中。它通过划分区域和优先级回收减少了全堆扫描的需要。
  2. 内存碎片处理:G1 通过整理来减少内存碎片,这是 CMS 面临的主要问题之一。
  3. 可预测的停顿:G1 允许用户指定停顿时间,这对于需要高响应性的应用来说非常重要。
  4. 更广泛的适用性:G1 的设计旨在适应更广泛的应用场景,包括那些拥有大量内存和多核处理器的服务器环境。

类加载机制

能说一下类的生命周期吗?

在 Java 中,类的生命周期是指类从加载到 JVM 中,直到卸载出 JVM 的整个过程。这个过程大致可以分为以下几个主要阶段:

  • 加载(Loading):
    • 在这个阶段,JVM 会通过类加载器读取类的二进制数据,并将其转换成一个 java.lang.Class 类的实例。这个过程包括检查类的格式、验证其正确性、分配内存,并初始化静态字段。
  • 链接(Linking):
    • 链接阶段可以进一步细分为验证、准备和解析三个子阶段:
      • 验证(Verification): 确保被加载的类的正确性,检查类是否符合 Java 虚拟机规范。
      • 准备(Preparation): 为类的静态变量分配内存,并初始化为默认值。
      • 解析(Resolution): 解析这个类中用到的其他类、接口、字段和方法的引用。
  • 初始化(Initialization):
    • 在初始化阶段,JVM 会执行类构造器 <clinit>() 方法的代码。这包括静态变量的赋值操作和静态代码块的执行。
  • 使用(Using):
    • 类一旦被加载并初始化后,就可以被程序使用了。这包括创建类的实例、调用类的方法、使用类的变量等。
  • 卸载(Unloading):
    • 类在不再被使用时,可以被 JVM 卸载。这通常在类的类加载器被垃圾回收时发生。类的卸载意味着该类的 java.lang.Class 实例被回收,同时该类的所有对象实例也都被回收。

描述一下JVM 加载class 文件的原理机制

JVM 加载 class 文件的原理机制大致分为如下几个关键阶段:

  • 类加载器(Class Loaders):
    • 类加载是由类加载器(ClassLoader)完成的。JVM 提供了几种内置的类加载器(比如启动类加载器、扩展类加载器和应用类加载器),同时也允许创建自定义的类加载器。
    • 类加载器首先会检查请求的类是否已经被加载。如果已加载,直接返回该类的 Class 对象;如果未加载,进入加载过程。
  • 查找 Class 文件:
    • 类加载器会根据类的全限定名(如 java.lang.String)在文件系统或者其他地方(如网络、ZIP 文件等)查找对应的 .class 文件。
  • 加载 Class 文件:
    • 一旦找到了对应的 Class 文件,类加载器会将该文件的内容(二进制数据)加载到内存中。这些数据包含了类的结构、方法、变量等信息。
  • 解析:
    • 加载到内存中的类数据会被解析成为一个 java.lang.Class 类的实例。这个实例代表了被加载的类,包含了该类的所有信息。
  • 验证:
    • 验证是确保加载的类满足 JVM 规范且不会危害到 JVM 自身安全的一个重要步骤。这包括对类的格式、方法和字段的访问性、以及其他一些基本的结构检查。
  • 准备:
    • 在准备阶段,JVM 会为类中的所有静态变量分配内存,并设置默认初始值。这些默认值通常是数据类型的零值(如 0、false 等)。
  • 解析引用:
    • 这一步涉及将类文件中的符号引用转换为直接引用。符号引用是一种抽象的引用,如类和接口的全限定名,而直接引用通常是指向内存中对象或方法的指针。
  • 初始化:
    • 最后阶段是类的初始化,这时会执行静态初始化器(静态代码块)和静态变量的赋值操作。

类加载器有哪些?

  • 启动类加载器(Bootstrap ClassLoader):
    • 这是最顶层的类加载器,用于加载 JVM 的核心类库,如 rt.jar 和其他核心库文件。它不是 java.lang.ClassLoader 的子类,因为 JVM 是用原生代码实现的。
  • 扩展类加载器(Extension ClassLoader):
    • 负责加载 Java 的扩展库,即 JAVA_HOME/jre/lib/ext 目录下的 JAR 包和类文件,或者由系统属性 java.ext.dirs 指定的路径下的类库。
  • 应用类加载器(Application ClassLoader):
    • 这是用于应用程序级别的类加载器,负责加载环境变量 CLASSPATH 或系统属性 java.class.path 指定路径下的类库。它是 java.lang.ClassLoader 的一个实例,是我们通常使用的默认类加载器。
  • 用户自定义类加载器:
    • Java 允许开发者通过继承 java.lang.ClassLoader 类来创建自己的类加载器。这使得开发者可以自定义类的加载方式,例如从加密文件中加载类,或者从网络等非标准来源加载。

类加载器之间存在父子关系,但并非通过继承实现,而是通过组合。当一个类加载器收到类加载的请求时,它首先会委托给其父加载器去尝试加载这个类,只有在父加载器加载失败时,它才会尝试自己去加载。这种委托机制确保了 Java 类的唯一性,例如,无论一个应用中有多少个类加载器,java.lang.Object 这个类都只会被加载一次。

什么是双亲委派模型?

双亲委派模型是 Java 中类加载器使用的一种特定机制,用于加载类和接口。

  • 当一个类加载器收到类加载的请求时,它首先不会尝试自己加载这个类,而是把这个请求委派给父类加载器去完成。
  • 这个过程会一直向上递归,直到达到最顶层的启动类加载器(Bootstrap ClassLoader)。
  • 只有当父类加载器无法完成这个请求(即它不支持加载请求的类)时,子类加载器才会尝试自己去加载这个类。

为什么要用双亲委派机制?

  • 保障安全性:
    • 双亲委派机制可以防止基础类库被随意替换。由于系统类由启动类加载器加载,因此即使用户编写了与系统类同名的类,也不会导致系统类被替换,这保护了 Java 核心类库的安全性。
  • 避免类的重复加载:
    • 在双亲委派模型下,类只会被加载一次。当一个类加载器收到类加载的请求时,它会首先尝试让其父类加载器去加载这个类,以此来保证不会加载已经加载过的类。这避免了同一个类被多次加载的情况,节省了资源,同时保持了类的单一性。
  • 保持命名空间的一致性:
    • 双亲委派模型帮助维护了 Java 类的命名空间。不同的类加载器可以定义不同的命名空间,相同名称的类可以存在于不同的类加载器及其命名空间中,而不会发生冲突。
  • 提升性能:
    • 由于类只加载一次,所以减少了类加载的次数,这在一定程度上提高了性能。特别是对于核心类库这种被频繁使用的类,这种优势更加明显。

如何破坏双亲委派机制?

破坏双亲委派机制通常意味着自定义类加载器以改变类加载的顺序或方式。在特定的应用场景中,比如热部署(hot deployment)或插件式架构,可能需要定制类加载过程以满足特殊需求。要破坏双亲委派机制,可以按照以下步骤进行:

  1. 自定义类加载器:
    • 继承 java.lang.ClassLoader 类来创建自己的类加载器。
  2. 重写 ****loadClass():
    • 在自定义类加载器中重写 loadClass()。通常,loadClass() 方法首先会调用父类加载器尝试加载类,如果失败,再尝试自己加载类。要破坏双亲委派模型,可以改变这个顺序。
  3. 直接加载类:
    • loadClass() 方法中,可以先尝试自己加载类,而不是委派给父类加载器。如果自己无法加载,再考虑将请求委派给父类加载器。
  4. 使用 ****findClass():
    • 在自定义类加载器中实现 findClass() 方法。当 loadClass() 方法在父类加载器中找不到类时,将会调用 findClass()方法尝试加载类。在 findClass() 方法中,可以实现特定的类加载逻辑。

你知道 Java 中有哪些破坏了双亲委派机制?

在 Java 中,有几个典型的例子破坏了双亲委派机制:

JDBC 4.0 驱动加载

  • 在 JDBC 4.0 中,引入了一种服务提供者机制(SPI),允许 JDBC 驱动程序在 META-INF/services 目录下注册自己,而不需要通过代码显式加载驱动。这种机制实际上破坏了传统的双亲委派模型,因为它允许驱动程序类被应用程序类加载器加载,而不是始终由系统类加载器加载。

Tomcat 容器

  • Tomcat为每个部署的 Web 应用使用一个单独的类加载器,允许加载与其他应用隔离的类。这样,相同的类(例如,一个常见的库的不同版本)可以存在于不同的 Web 应用中,而不会相互干扰。这种机制破坏了标准的双亲委派模型,因为容器首先尝试使用自己的类加载器加载类,而不是委派给父类加载器。

你觉得应该怎么实现一个热部署功能?

热部署功能是可以在修改完代码后,不重启应用,实现类信息更新,以节省开发时等待启动时间。

在 Java 中,要实现热部署功能通常涉及动态地加载、更新和卸载类或资源。下面实现热部署的基本步骤:

自定义类加载器:

  • 实现一个自定义的类加载器,这个类加载器可以加载或重新加载改变了的类文件。热部署的关键是能够替换旧的类定义,这通常需要破坏标准的双亲委派模型。

类定义的隔离:

  • 保证每个版本的类都被隔离开来,以避免不同版本之间的冲突。这可以通过为每个版本的类创建不同的类加载器实例来实现。

监控类文件的改变:

  • 实现一个机制来监控类文件的改变。这可以通过文件系统监控,或者在开发环境中监听构建系统的输出。

重新加载改变的类:

  • 一旦检测到类文件有改变,使用新的或者专门的类加载器加载新版本的类。旧版本的类需要被垃圾回收,这通常意味着需要切断所有到旧类实例和类加载器的引用。

状态管理:

  • 在类重新加载时,可能需要保持应用的状态。这通常涉及在重新加载前保存状态,并在加载后恢复。

JVM 调优

JVM 的常见参数配置知道哪些?

  1. 堆内存大小设置:
    • -Xms<size>: 设置JVM启动时的初始堆内存大小。例如,-Xms256m 设置初始堆大小为 256 MB。
    • -Xmx<size>: 设置JVM可以使用的最大堆内存大小。例如,-Xmx1024m 设置最大堆大小为 1024 MB。
  2. 栈大小设置:
    • -Xss<size>: 设置每个线程的堆栈大小。例如,-Xss1m 设置线程堆栈大小为 1 MB。
  3. 年轻代与老年代大小设置:
    • -Xmn<size>: 设置年轻代的大小。
    • -XX:NewRatio=<ratio>: 设置年轻代(包括Eden和两个Survivor区)与老年代的比例。
  4. 垃圾收集器设置:
    • -XX:+UseG1GC: 使用G1垃圾收集器。
    • -XX:+UseParallelGC: 使用并行垃圾收集器。
    • -XX:+UseConcMarkSweepGC: 使用CMS垃圾收集器。
    • -XX:+UseSerialGC: 使用串行垃圾收集器。
  5. GC日志设置:
    • -Xloggc:<file-path>: 将GC日志记录到文件。
    • -XX:+PrintGCDetails: 输出更详细的GC日志信息。
    • -XX:+PrintGCDateStamps: 在GC日志中添加时间戳。
  6. 堆转储设置:
    • -XX:+HeapDumpOnOutOfMemoryError: 在发生内存溢出时自动进行堆转储。
    • -XX:HeapDumpPath=<file-path>: 指定堆转储的文件路径。
  7. 性能优化相关:
    • -XX:CompileThreshold=<count>: 设置方法调用次数阈值,超过这个阈值就会被JIT编译器编译成本地代码。
    • -XX:SurvivorRatio=<ratio>: 设置Eden区与一个Survivor区的大小比例。
    • -XX:MaxTenuringThreshold=<threshold>: 设置对象在年轻代中的最大晋升年龄。
  8. 类加载相关:
    • -XX:+TraceClassLoading: 跟踪类的加载信息。
    • -XX:+TraceClassUnloading: 跟踪类的卸载信息。

线上服务 CPU 占用过高怎么排查?

参考文章:

内存飙高问题怎么排查?

参考文章:

频繁 minor gc 怎么办?

参考文章:

频繁 Full GC 怎么办?

参考文章:

阅读全文