原创

JVM实战(2)——理论基础篇:垃圾回收(Garbage Collection)

温馨提示:
本文最后更新于 2023年07月10日,已超过 292 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我

一、什么是垃圾(What is Garbage?)

在Java中,垃圾是指那些不再被程序使用的对象。当对象被创建时,Java会为其分配内存空间。然而,当该对象不再被引用时,它就成为了垃圾。垃圾对象占据内存空间,但对程序不再具有任何意义。

那么如何判断一个对象是否为垃圾,最简单的办法就是判断其是否被其他对象所引用,但仅仅是这样的判断会存在一个问题,就是循环引用。所以,为了解决这个问题,JVM引入了“可达性”。即当一个对象不能通过任何活跃的引用链(从根对象开始)到达时,它就被认为是不可达的。这意味着程序不再使用该对象,并且它可以被视为垃圾。

这里又涉及到一个问题,哪些实力是根(which instances are roots)?

在Java中,根对象的实例并不是指具体的对象类型,而是根据不同的根对象类型,有一些特定的实例。

根对象的类型可以包括以下几种

JVM Stack(Java虚拟机栈):每个线程在运行时都会创建一个对应的Java虚拟机栈,用于存储方法调用和局部变量。根对象的实例可以是存储在Java虚拟机栈中的局部变量引用的对象。

Native Method Stack(本地方法栈):本地方法栈是用于执行本地(非Java)方法的栈,它与Java虚拟机栈类似。根对象的实例可以是存储在本地方法栈中的局部变量引用的对象。

Runtime Constant Pool(运行时常量池):运行时常量池是方法区的一部分,它存储着编译时生成的字面量、符号引用和一些常量。根对象的实例可以是运行时常量池中的常量引用的对象。

Static References in Method Area(方法区中的静态引用):方法区用于存储类的结构信息、常量、静态变量和方法代码等。根对象的实例可以是方法区中的静态变量引用的对象。

Clazz (Classes)(类(类的元信息)):类的元信息存储在方法区中,包括类的名称、方法、字段、访问修饰符等。类本身也可以被视为根对象的实例。

这些根对象的实例是垃圾回收的起点,垃圾回收器通过从这些实例开始遍历,追踪引用链,标记可达的对象,并清理不可达的对象。只有被根对象的实例引用的对象,才被认为是存活的对象,而其他不可达的对象将被判定为垃圾,并在垃圾回收过程中进行清理。

二、如何找到垃圾(How to find a garbage?)

在上面说了什么是垃圾,也提到了提到了根的概念,这样,我们顺理成章的就得到了如何找到垃圾的方法:

  1. 引用计数法(Reference Counting):该方法通过在每个对象上维护一个引用计数器,记录引用该对象的数量。当引用计数器为0时,表示该对象不再被引用,即为垃圾对象。然而,Java的垃圾回收器并不使用引用计数法,因为该方法无法解决循环引用的问题。
  2. 可达性分析(Reachability Analysis):Java的垃圾回收器主要使用可达性分析来确定垃圾对象。可达性分析从一组称为"根对象"的起始点开始,例如JVM栈、本地方法栈、运行时常量池和静态引用等。然后,垃圾回收器通过跟踪对象之间的引用链,标记所有从根对象可达的对象。剩余未标记的对象即为垃圾对象。

三、算法(Algorithms)

在Java中,垃圾回收器使用不同的算法来执行垃圾回收操作。以下是Java中常见的三种垃圾回收算法的简要说明:

1. Mark-Sweep(标记-清除)算法:

Mark-Sweep算法是最基本的垃圾回收算法之一。它分为两个阶段:标记阶段和清除阶段。

在标记阶段,垃圾回收器从根对象开始遍历,标记所有可达的对象,将它们标记为存活对象。

在清除阶段,垃圾回收器清除所有未被标记的对象,即垃圾对象。清除的对象会释放其占据的内存空间。

该算法的缺点是:会产生碎片化。如下图所示:

2. Copying(复制)算法:

Copying算法是为了解决标记-清除算法中的内存碎片问题而提出的一种算法。它将堆内存分为两个区域,通常称为"From"和"To"空间。开始时,所有存活的对象位于"From"空间。

在垃圾回收过程中,垃圾回收器会从根对象开始遍历,将存活的对象复制到"To"空间,并按照顺序排列。复制完成后,"From"空间中的所有对象都被认为是垃圾,可以直接清除。然后,"From"空间和"To"空间的角色交换,使得"From"空间成为下一次垃圾回收的目标。

复制算法的优点是可以避免内存碎片问题,并且分配对象时只需简单地移动指针。但是,它会浪费一部分空间,因为"To"空间的大小必须大于等于"From"空间中存活对象的大小。如下图所示:

3. Mark-Compact(标记-整理)算法:

Mark-Compact算法也是为了解决标记-清除算法中的内存碎片问题而提出的一种算法。它包含三个阶段:标记阶段、压缩阶段和清除阶段。

在标记阶段,垃圾回收器从根对象开始遍历,标记所有可达的对象。

在压缩阶段,垃圾回收器将所有存活的对象向一端移动,以便在堆内存的另一端创建一个连续的空闲空间。这样可以消除内存碎片,使得堆内存的布局更加紧凑。

在清除阶段,垃圾回收器清除未被标记的对象,即垃圾对象,并释放它们占据的内存空间。

Mark-Compact算法可以保持内存的连续性,但需要在压缩阶段进行

对象的移动操作,可能会导致一定的性能开销。

这些垃圾回收算法在实际应用中根据不同的场景和需求选择使用。Java的垃圾回收器通常会根据当前的内存使用情况和垃圾回收的目标选择合适的算法进行回收操作。

四、堆内存逻辑分区

Java堆内存是Java虚拟机(JVM)中最大的内存区域之一,用于存储动态创建的对象和数组。包括新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation)(在Java 8之前),以及它们在垃圾回收中的作用。

4.1. 新生代(Young Generation):

新生代是Java堆内存中的一个较小的区域,用于存储新创建的对象。它进一步分为Eden空间、Survivor空间From和Survivor空间To。当对象第一次被创建时,它们会被分配到Eden空间。在新生代的垃圾回收中,一些存活的对象会被移动到Survivor空间,而其他未被回收的对象则会被移到老年代。垃圾回收器通常使用复制(Copying)算法来回收新生代,即将存活的对象复制到另一个Survivor空间,并清除未使用的对象。

4.1.1. Eden空间:

Eden空间是新生代中最大的分区,用于存储刚刚创建的对象。当对象第一次被创建时,它们会被分配到Eden空间。通常情况下,大部分对象在很短的时间内就会变成垃圾。在垃圾回收时,所有未被引用的对象将被清除,而仍然存活的对象将会被移动到Survivor空间。Eden空间的大小可以通过JVM参数进行调整。

4.1.2. Survivor空间:

Survivor空间是新生代中的一个较小的分区,用于存储在Eden空间和另一个Survivor空间之间存活的对象。在垃圾回收过程中,存活的对象将从Eden空间复制到一个Survivor空间,而未被引用的对象将被清除。在下一次垃圾回收时,存活的对象将从一个Survivor空间复制到另一个Survivor空间,以实现对象的存活状态切换。这种切换通常采用复制算法来完成。

4.1.3. 垃圾回收算法:

在新生代的垃圾回收中,常用的算法是复制算法(Copying Algorithm)。复制算法将存活的对象从一个存储区复制到另一个存储区,同时清除未使用的对象。这种算法具有简单高效的特点,能够快速回收短生命周期的对象。然而,由于Survivor空间较小,复制算法可能导致存活对象的移动频繁。

Eden空间是对象最初分配的地方,而Survivor空间用于存储在Eden空间和另一个Survivor空间之间存活的对象。通过复制算法来回收新生代内存,可以快速清除短生命周期的对象,并且由于存活对象较少,复制算法在新生代的垃圾回收中表现出良好的性能。了解新生代的内部结构和垃圾回收算法有助于优化内存使用和提高应用程序的性能。

4.2. 老年代(Old Generation):

老年代是Java堆内存中的一个较大的区域,用于存储生命周期较长的对象。当对象在新生代经过多次垃圾回收仍然存活时,它们会被移动到老年代。与新生代不同,老年代的垃圾回收频率较低,一般使用标记-清除(Mark-Sweep)或标记-整理(Mark-Compact)算法来回收。这些算法标记并清除或整理未使用的对象,以确保内存的连续性。

4.3. 永久代(Permanent Generation):

在Java 8之前的版本中,永久代用于存储类的元信息、静态变量和常量。然而,在Java 8及以后的版本中,永久代被元空间(Metaspace)取代。元空间不再作为堆内存的一部分,而是使用本地内存来存储类的元信息。这个改变是为了解决永久代容易出现内存溢出的问题。

Java堆内存的逻辑分区在垃圾回收中起着重要的作用。通过将内存分为不同的分区,可以更有效地管理对象的创建和回收。

新生代使用复制算法来频繁地回收短生命周期的对象,而老年代则更注重长生命周期对象的回收。这种分代的垃圾回收策略可以提高垃圾回收的效率和性能。理解堆内存的逻辑分区可以帮助开发人员更好地优化内存使用,避免内存泄漏和性能问题,并确保Java应用程序的稳定性和可靠性。

 

正文到此结束
本文目录