MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java对象的内存分配与回收

2021-02-215.4k 阅读

Java对象的内存分配

Java堆内存结构概述

在Java中,对象主要分配在堆内存上。Java堆是Java虚拟机所管理的内存中最大的一块,被所有线程共享。它的目的就是存放对象实例以及数组(数组在Java中也是对象)。从垃圾回收的角度,Java堆可以细分为新生代和老年代。新生代又进一步分为Eden区和两个Survivor区(通常称为Survivor0和Survivor1)。

这种结构设计主要是基于大部分对象的生命周期特点。通常,新创建的对象首先会被分配到Eden区,当Eden区空间不足时,会触发一次Minor GC(新生代垃圾回收),存活下来的对象会被移动到Survivor区。经过多次Minor GC后,依然存活的对象会逐步晋升到老年代。

对象在堆中的分配过程

  1. Eden区分配 当Java程序创建一个新对象时,多数情况下会优先在Eden区分配内存。例如以下代码:
public class MemoryAllocationExample {
    public static void main(String[] args) {
        // 创建一个简单的对象并分配在Eden区
        MemoryAllocationExample obj = new MemoryAllocationExample();
    }
}

在这个例子中,MemoryAllocationExample类的对象obj在创建时,其内存会优先尝试在Eden区分配。如果Eden区有足够的空间,对象就会被成功分配。

  1. Survivor区分配与晋升 当Eden区空间不足,触发Minor GC。垃圾回收器会扫描Eden区和Survivor区(其中一个,假设为Survivor0,另一个Survivor1用于存放回收后存活的对象),标记并回收不再被引用的对象。存活的对象会被复制到Survivor1区,同时对象的年龄(age)会加1。年龄表示对象经历过的垃圾回收次数。 如果对象在Survivor区中经过一定次数(默认15次,可以通过-XX:MaxTenuringThreshold参数调整)的Minor GC后依然存活,就会被晋升到老年代。以下代码示例展示了对象的晋升过程(假设存在一个对象不断存活,触发多次GC):
import java.util.ArrayList;
import java.util.List;

public class ObjectPromotionExample {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(new byte[4 * _1MB]);
        }
    }
}

在上述代码中,每次循环创建一个4MB大小的字节数组对象。随着对象不断创建,Eden区很快会空间不足,触发Minor GC。如果这些对象在多次GC后依然存活,就会晋升到老年代。

  1. 老年代分配 老年代主要存放生命周期较长的对象。除了从新生代晋升上来的对象,一些大对象(超过了新生代可分配的最大连续空间)也会直接分配到老年代。例如:
public class LargeObjectAllocation {
    private static final int _10MB = 10 * 1024 * 1024;

    public static void main(String[] args) {
        // 直接在老年代分配一个10MB的大对象
        byte[] largeObject = new byte[10 * _10MB];
    }
}

在这个例子中,largeObject由于大小为10MB,超过了新生代通常可分配的最大连续空间,所以会直接在老年代分配内存。

栈上分配与逃逸分析

除了堆分配,在某些情况下,对象也可能在栈上分配。Java虚拟机使用逃逸分析技术来判断对象的作用域是否会逃逸出方法。如果对象不会逃逸出方法,那么就有可能在栈上分配,这样当方法执行结束,栈帧弹出,对象所占用的内存就会被自动释放,避免了在堆上分配带来的垃圾回收开销。

例如以下代码:

public class StackAllocationExample {
    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) {
            stackAllocatedMethod();
        }
    }

    private static void stackAllocatedMethod() {
        // 这里的obj对象不会逃逸出该方法
        StackAllocationExample obj = new StackAllocationExample();
    }
}

stackAllocatedMethod方法中创建的StackAllocationExample对象obj,其作用域仅在该方法内部,不会被其他方法访问到。如果开启了逃逸分析(在Java 6u23+版本默认开启),并且虚拟机经过分析确定该对象不会逃逸,那么obj对象就有可能在栈上分配。

直接内存与对象分配

Java中除了堆内存,还有直接内存。直接内存并不是Java虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。它是通过Unsafe类或者NIO包下的ByteBuffer等类来分配和管理的。

直接内存的分配不受Java堆大小的限制,但会受到操作系统可用内存大小的限制。直接内存的主要优势在于,对于一些需要频繁与底层系统交互(如I/O操作)的场景,它可以减少数据在Java堆和本地内存之间的拷贝,提高性能。

例如,使用ByteBuffer分配直接内存:

import java.nio.ByteBuffer;

public class DirectMemoryExample {
    public static void main(String[] args) {
        // 分配1MB的直接内存
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024);
    }
}

在上述代码中,通过ByteBuffer.allocateDirect方法分配了1MB的直接内存。虽然直接内存的使用可以提升性能,但也要注意合理管理,因为它的回收不受Java垃圾回收器直接控制,如果使用不当,可能会导致内存泄漏。

Java对象的内存回收

垃圾回收机制概述

Java的垃圾回收机制(Garbage Collection,GC)是Java语言的一大特性,它自动管理对象的内存回收,减轻了开发者手动管理内存的负担。垃圾回收器的主要任务是识别并回收不再被程序使用的对象所占用的内存空间。

垃圾回收的基本过程包括:

  1. 对象可达性分析:垃圾回收器通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到该对象不可达),则证明此对象是不可用的。
  2. 垃圾回收:确定了哪些对象是垃圾(不可达对象)后,垃圾回收器会回收这些对象所占用的内存空间。

垃圾回收算法

  1. 标记 - 清除算法(Mark - Sweep)
    • 原理:该算法分为两个阶段,标记阶段和清除阶段。在标记阶段,垃圾回收器从GC Roots开始遍历,标记所有可达对象。然后在清除阶段,回收所有未被标记的对象,即垃圾对象,释放它们占用的内存空间。
    • 缺点:标记 - 清除算法会产生大量不连续的内存碎片。随着不断的垃圾回收,内存碎片会越来越多,当需要分配大对象时,可能无法找到足够连续的内存空间,导致提前触发垃圾回收甚至OOM(OutOfMemoryError)。
  2. 复制算法(Copying)
    • 原理:复制算法将内存分为大小相等的两块,每次只使用其中一块。当这一块内存用完时,触发垃圾回收,将存活的对象复制到另一块内存,然后将原来使用的那一块内存全部清理掉。
    • 优点:复制算法不会产生内存碎片,并且回收效率高,只需要移动堆顶指针,按顺序分配内存即可。
    • 缺点:内存利用率低,因为始终只有一半的内存可用。为了解决这个问题,在Java的新生代中,采用了一种优化的复制算法,即把新生代分为Eden区和两个Survivor区,比例通常为8:1:1。每次使用Eden区和其中一个Survivor区,当触发垃圾回收时,将Eden区和使用的Survivor区中存活的对象复制到另一个Survivor区,然后清理Eden区和原Survivor区。
  3. 标记 - 整理算法(Mark - Compact)
    • 原理:标记 - 整理算法结合了标记 - 清除算法和复制算法的优点。它首先进行标记阶段,标记出所有存活的对象。然后在整理阶段,将存活的对象向内存的一端移动,最后清理掉边界以外的内存空间。
    • 优点:避免了标记 - 清除算法产生的内存碎片问题,同时也不像复制算法那样牺牲一半的内存空间。
    • 缺点:整理阶段需要移动大量对象,效率相对较低。该算法主要应用于老年代,因为老年代对象存活率高,复制算法代价太大,而标记 - 清除算法产生的碎片问题更为严重。
  4. 分代收集算法(Generational Collection)
    • 原理:分代收集算法是目前Java虚拟机普遍采用的垃圾回收算法,它根据对象的生命周期长短将内存分为不同的代(如新生代和老年代),针对不同代采用不同的垃圾回收算法。
    • 新生代:由于新生代对象存活率低,所以采用复制算法。大部分对象在Eden区创建,当Eden区满时,触发Minor GC,将存活对象复制到Survivor区,经过多次Minor GC后,存活对象晋升到老年代。
    • 老年代:老年代对象存活率高,空间较大,采用标记 - 整理算法或标记 - 清除算法(在某些垃圾回收器中结合使用)。当老年代空间不足时,触发Major GC(也叫Full GC),回收老年代的垃圾对象。

垃圾回收器

  1. Serial垃圾回收器
    • 特点:Serial垃圾回收器是最基本、最古老的垃圾回收器。它是单线程的垃圾回收器,在进行垃圾回收时,会暂停所有应用线程(Stop - The - World,STW)。它的优点是简单高效,在单核CPU环境下有较好的性能表现。
    • 应用场景:适用于客户端应用或者小内存应用场景,例如一些嵌入式设备或者桌面应用程序。可以通过-XX:+UseSerialGC参数启用。
  2. ParNew垃圾回收器
    • 特点:ParNew垃圾回收器是Serial垃圾回收器的多线程版本,它使用多个线程进行垃圾回收,同样会暂停所有应用线程。ParNew垃圾回收器在多核CPU环境下能充分利用多核优势,提高垃圾回收效率。
    • 应用场景:通常与CMS垃圾回收器配合使用,作为新生代的垃圾回收器。可以通过-XX:+UseParNewGC参数启用。
  3. Parallel Scavenge垃圾回收器
    • 特点:Parallel Scavenge垃圾回收器也是多线程的垃圾回收器,它的目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾回收时间))。它可以通过参数调整吞吐量,例如-XX:MaxGCPauseMillis控制最大垃圾回收停顿时间,-XX:GCTimeRatio控制垃圾回收时间占总时间的比例。
    • 应用场景:适用于注重吞吐量的应用场景,如后台批处理任务。可以通过-XX:+UseParallelGC参数启用。
  4. Parallel Old垃圾回收器
    • 特点:Parallel Old垃圾回收器是Parallel Scavenge垃圾回收器的老年代版本,同样是多线程的,采用标记 - 整理算法。它与Parallel Scavenge垃圾回收器配合使用,提供了高吞吐量的垃圾回收方案。
    • 应用场景:适用于注重吞吐量并且应用对象存活周期较长的场景。可以通过-XX:+UseParallelOldGC参数启用。
  5. CMS(Concurrent Mark Sweep)垃圾回收器
    • 特点:CMS垃圾回收器是一种以获取最短回收停顿时间为目标的垃圾回收器。它在垃圾回收过程中尽量减少应用线程的停顿时间,采用的是标记 - 清除算法。CMS垃圾回收过程分为以下几个阶段:
      • 初始标记(Initial Mark):暂停所有应用线程,标记出直接与GC Roots相连的对象,这个阶段速度很快。
      • 并发标记(Concurrent Mark):与应用线程并发执行,从初始标记的对象开始,标记所有可达对象。
      • 重新标记(Remark):暂停所有应用线程,修正并发标记期间因应用线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段比初始标记稍慢。
      • 并发清除(Concurrent Sweep):与应用线程并发执行,清除所有未被标记的对象,即垃圾对象。
    • 优点:在垃圾回收过程中,大部分时间可以与应用线程并发执行,减少了应用线程的停顿时间,提高了响应速度。
    • 缺点:会产生内存碎片,因为采用标记 - 清除算法;并且由于与应用线程并发执行,会占用一部分CPU资源,在CPU资源紧张的情况下可能影响应用性能。同时,CMS垃圾回收器在老年代内存使用率达到一定阈值(默认92%,可通过-XX:CMSInitiatingOccupancyFraction参数调整)时就会触发,可能导致频繁的Full GC。可以通过-XX:+UseConcMarkSweepGC参数启用。
  6. G1(Garbage - First)垃圾回收器
    • 特点:G1垃圾回收器是一种面向服务端应用的垃圾回收器,它将堆内存划分为多个大小相等的Region,这些Region可以动态地被划分成Eden区、Survivor区和老年代。G1垃圾回收器采用了标记 - 整理算法,避免了内存碎片问题。它的一大特点是可以预测停顿时间,通过设定-XX:MaxGCPauseMillis参数,G1垃圾回收器会尽量在这个时间内完成垃圾回收。
    • 垃圾回收过程
      • 初始标记(Initial Mark):暂停所有应用线程,标记出直接与GC Roots相连的对象,并且记录Remembered Set(记录跨Region引用关系的数据结构)。
      • 并发标记(Concurrent Mark):与应用线程并发执行,标记出所有可达对象。
      • 最终标记(Final Mark):暂停所有应用线程,处理并发标记阶段结束后仍遗留的少量对象标记。
      • 筛选回收(Evacuation):根据每个Region的垃圾回收收益(回收的空间大小和所需时间),选择回收价值最大的Region进行回收,采用复制算法将存活对象复制到其他Region,同时整理内存空间。
    • 优点:可以有效避免内存碎片,能更好地控制垃圾回收停顿时间,适用于大内存、多CPU的应用场景。同时,它对堆内存的管理更加灵活,不像传统垃圾回收器那样对新生代和老年代有固定的划分。可以通过-XX:+UseG1GC参数启用。

内存分配与回收的调优

堆内存参数调优

  1. 设置堆内存大小 可以通过-Xms-Xmx参数来设置Java堆的初始大小和最大大小。例如,-Xms2g -Xmx2g表示将Java堆的初始大小和最大大小都设置为2GB。合理设置堆内存大小对于应用性能至关重要。如果堆内存设置过小,可能会频繁触发垃圾回收,导致应用性能下降;如果设置过大,一方面会占用过多系统资源,另一方面可能会增加垃圾回收的时间。
  2. 新生代与老年代比例调整 通过-XX:NewRatio参数可以调整新生代和老年代的比例。例如,-XX:NewRatio=2表示新生代和老年代的比例为1:2,即新生代占堆内存的1/3,老年代占2/3。如果应用中对象生命周期较短,新生代比例可以适当调大;如果对象生命周期较长,老年代比例可以适当调大。

垃圾回收器选择与调优

  1. 根据应用场景选择垃圾回收器
    • 注重响应速度的应用:如Web应用、交互式应用,可选择CMS或G1垃圾回收器。CMS垃圾回收器能在大部分时间与应用线程并发执行,减少停顿时间;G1垃圾回收器可以更好地预测停顿时间,适用于对响应时间要求较高的场景。
    • 注重吞吐量的应用:如后台批处理任务,可选择Parallel Scavenge和Parallel Old垃圾回收器组合,它们能提供较高的吞吐量。
  2. 垃圾回收器参数调优
    • CMS垃圾回收器:可以通过-XX:CMSInitiatingOccupancyFraction参数调整老年代内存使用率达到多少时触发CMS垃圾回收。如果设置过低,可能会导致频繁的Full GC;设置过高,可能会导致在垃圾回收过程中老年代内存不足,从而触发更耗时的Full GC。
    • G1垃圾回收器:通过-XX:MaxGCPauseMillis参数设置最大垃圾回收停顿时间,G1垃圾回收器会尽量在这个时间内完成垃圾回收。同时,还可以通过-XX:G1HeapRegionSize参数设置Region的大小,合理设置Region大小对于G1垃圾回收器的性能也有影响。

分析工具辅助调优

  1. JConsole:JConsole是JDK自带的图形化监控工具,可以监控Java应用的内存使用情况、线程状态、垃圾回收等信息。通过连接到运行中的Java进程,能直观地查看堆内存的使用情况,包括新生代、老年代的空间变化,以及垃圾回收的次数和时间等。
  2. VisualVM:VisualVM是一款功能更强大的JDK自带工具,它不仅能监控Java应用的运行状态,还能进行性能分析。可以通过它进行内存快照分析,查看对象的引用关系,找出可能存在的内存泄漏点。同时,也能对垃圾回收进行详细分析,辅助调优垃圾回收器参数。
  3. MAT(Memory Analyzer Tool):MAT是一款专门用于Java堆内存分析的工具。它可以分析堆内存快照文件(.hprof文件),快速定位内存泄漏问题,展示对象的占用空间、引用链等详细信息。通过MAT,可以深入了解应用的内存使用情况,从而针对性地进行内存分配和回收的优化。

在实际的Java应用开发中,深入理解Java对象的内存分配与回收机制,并合理进行调优,对于提高应用的性能、稳定性和资源利用率至关重要。通过不断实践和分析,选择合适的垃圾回收器和调优参数,能让Java应用在不同的场景下都能发挥出最佳性能。