JVM内存模型与垃圾回收

245 阅读7分钟

一、JVM内存模型

JVM的运行时数据区(Runtime Data Area)是JVM在执行Java程序时用于存储程序执行期间数据的内存区域。这些数据区为程序的运行提供了必要的内存支持。

从线程的角度来看,运行时数据区可以分为两大类:

  1. 线程共享区域:

    • 堆(Heap):堆是Java内存管理的核心区域,它用于存储所有创建的对象实例。堆是所有线程共享的内存区域,也是垃圾回收器主要工作的区域。
    • 方法区(Method Area):方法区用于存储类的结构信息,如类的成员变量、方法、构造函数等。它也是所有线程共享的。
  2. 线程独享区域:

    • 程序计数器(Program Counter Register):程序计数器是一个线程私有的内存区域,它用于存储当前线程执行的字节码指令的地址。如果线程正在执行Java方法,程序计数器记录的是正在执行的指令地址;如果执行的是本地方法,程序计数器的值为空。
    • 虚拟机栈(VM Stack):每个线程在执行方法时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。虚拟机栈是线程私有的,其生命周期与线程相同。
    • 本地方法栈(Native Method Stack):本地方法栈用于支持本地方法的执行。它与虚拟机栈类似,也是线程私有的。

二、java垃圾回收机制

Java的垃圾回收机制是一种自动内存管理功能,它允许Java虚拟机(JVM)自动回收不再被程序中的任何部分引用的对象所占用的内存。以下是垃圾回收机制的一些关键点:

  1. 自动性:Java提供了一个系统级的线程,它跟踪每一块分配出去的内存空间。当JVM处于空闲循环时,垃圾收集器线程会自动检查并回收每一块不再被引用的内存。

  2. 不可预期性:垃圾回收的具体时间和行为是不可预测的。一旦对象没有被引用,它是否立即被回收是不确定的,可能直到程序结束该对象还留在内存中。

  3. 回收条件:对象在没有任何引用指向它时,被认为是垃圾,可以被垃圾回收器回收。

  4. 回收策略:垃圾回收的策略因业务场景而异。例如,在内存要求高的场景下,需要提高对象的回收效率;在CPU使用率高的场景下,需要降低垃圾回收的频率。

  5. 调优:垃圾回收的调优是一项重要技能,涉及选择合适的垃圾回收算法和策略,以及根据业务场景进行优化。

  6. GC算法:垃圾回收算法有多种,如标记-清除(Mark-Sweep)、复制(Copying)、标记-整理(Mark-Compact)等。

  7. GC指标:评价GC算法好坏的指标包括吞吐量、延迟、内存占用等。

  8. 手动干预:虽然垃圾回收是自动的,但开发人员可以通过调用System.gc()方法来建议JVM执行垃圾回收,但这不是强制的,JVM可能会忽略这个请求。

对于代码示例,通常我们不需要编写代码来触发垃圾回收,因为这是JVM自动管理的。但是,如果我们需要手动建议垃圾回收,可以这样做:

System.gc(); // 建议JVM进行垃圾回收,但并不保证执行

需要注意的是,过度依赖System.gc()可能导致性能问题,因为它可能会打断正常的程序执行流程。通常,只有在资源非常紧张的情况下,才考虑使用它。

三、讲讲你知道的垃圾回收算法

垃圾回收算法是Java虚拟机(JVM)中用于自动管理内存的关键技术。我了解的垃圾回收算法主要包括以下几种:

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

    • 这是最早的垃圾回收算法之一。
    • 它分为两个阶段:标记和清除。
    • 标记阶段会遍历所有可达对象,并标记它们为存活状态。
    • 清除阶段则会遍历堆内存,回收未被标记的对象所占用的空间。
    • 缺点包括:效率不高、会产生内存碎片。
  2. 复制算法(Copy)

    • 将可用内存划分为两块,每次只使用其中一块。
    • 当进行垃圾回收时,将存活的对象复制到另一块内存区域,并清空原内存区域。
    • 优点是解决了内存碎片问题,缺点是可用内存减少了一半。
  3. 标记-整理算法(Mark-Compact)

    • 类似于标记-清除算法,但在清除阶段,存活对象会被移动到内存的一端,然后清理掉边界以外的内存。
    • 这样既避免了内存碎片,也提高了内存利用率。
  4. 分代收集算法

    • JVM将堆内存分为几个不同的代,通常是新生代和老年代。
    • 新生代使用复制算法,因为新生代对象生命周期短,死亡率高。
    • 老年代使用标记-清除或标记-整理算法,因为老年代对象生命周期长,死亡率低。
  5. 增量收集算法和并发收集算法

    • 增量收集算法将垃圾回收分解为多个小步骤,交错进行,以减少应用程序停顿时间。
    • 并发收集算法则是在应用程序运行时同时进行垃圾回收,以减少对程序运行的影响。

这些算法在现代JVM垃圾回收器中都有应用,例如Serial GC、Parallel GC、CMS GC、G1 GC和ZGC等。每种垃圾回收器根据其设计目标和性能需求,选择了不同的算法或者算法组合。

四、堆栈溢出和内存溢出的区别

堆栈溢出(Stack Overflow)和内存溢出(Memory Overflow,通常指堆内存溢出)是两种不同的内存管理错误,它们的区别主要体现在以下几个方面:

  1. 定义和发生位置

    • 堆栈溢出:发生在栈内存中,当程序递归调用过深或局部变量过大,超出了操作系统为该进程分配的栈空间限制时,就会发生栈溢出。
    • 内存溢出:通常指堆内存溢出,发生在堆内存中,当程序动态分配的堆内存过多,超出了Java虚拟机(JVM)或操作系统的限制时,会发生内存溢出。
  2. 原因

    • 堆栈溢出:递归调用没有正确退出条件、函数调用层次过深、局部变量过大等。
    • 内存溢出:内存泄漏(分配后未释放)、程序逻辑错误导致大量不必要的对象创建、不当的内存分配策略等。
  3. 影响

    • 堆栈溢出:通常导致程序崩溃,因为栈空间被破坏。
    • 内存溢出:可能导致程序性能下降,最终因为内存耗尽而崩溃。
  4. 解决方法

    • 堆栈溢出:优化递归调用,增加栈大小,避免过大的局部变量。
    • 内存溢出:检查并修复内存泄漏,优化对象生命周期管理,调整JVM或操作系统的内存设置。
  5. 安全性问题

    • 堆栈溢出:可能导致栈溢出攻击,因为栈上的数据可以被覆盖,这可能被利用来执行恶意代码。
    • 内存溢出:可能导致使用已释放的内存(use after free),这是微软安全反应中心(MSRC)指出的第二大内存安全问题。

在编程实践中,理解这两种溢出的区别对于编写稳定、安全的代码至关重要。以下是一个简单的Java代码示例,展示了可能导致堆栈溢出的递归调用:

public class StackOverflowExample {
    public static void main(String[] args) {
        recursivePrint(1); // 这里可能导致堆栈溢出
    }

    public static void recursivePrint(int i) {
        if (i <= 10) {
            System.out.println(i);
            recursivePrint(i + 1); // 缺乏退出条件
        }
    }
}

为了防止堆栈溢出,应该确保递归调用有明确的退出条件。对于内存溢出,需要通过内存分析工具(如Java的VisualVM或Eclipse Memory Analyzer Tool)来检测和修复内存泄漏。

五、内存溢出和内存泄露的区别

内存溢出(Memory Overflow)和内存泄露(Memory Leak)是两个常见的内存管理问题,它们之间的区别如下:

  1. 内存溢出

    • 内存溢出指的是程序在运行时,由于某种原因(如算法错误、内存管理不当)试图分配超过其被分配的内存空间,导致可用内存不足。
    • 这种情况通常会导致程序崩溃或产生不可预测的行为。
    • 内存溢出的特点是内存使用在短时间内快速增加,并达到峰值,可能导致程序无法继续运行。
  2. 内存泄露

    • 内存泄露是指程序在运行过程中,由于疏忽或错误,未能释放不再使用的内存,导致这部分内存一直处于占用状态。
    • 内存泄露随着时间的推移会逐渐积累,可能导致程序占用越来越多的内存,最终可能导致内存不足,影响系统性能或导致程序崩溃。
    • 内存泄露的特点是内存使用会随着时间的推移而缓慢增长,不会自动减少。

以下是对前面提供内容的总结:

  • 内存膨胀(Memory Bloat):与内存泄露相似,但内存膨胀是由于程序员对内存管理的不科学,导致程序使用了超出实际需要的内存。内存膨胀可能会因为缓存使用不当或加载了不必要的资源。
  • 敏感数据归零:这是一种保护敏感数据不被泄露的策略,但并不能完全保证数据安全,因为存在多种可能导致数据泄露的情况,如内存溢出前数据被泄露、内存块拷贝后的数据未清除等。
  • 内核内存泄漏:指的是在内核态申请的内存未被正确释放,导致内存占用不断增加。内核内存的分配与用户态内存分配(如malloc/free)不同,它使用kmalloc/kfree和vmalloc/vfree等函数。

六、java中的四种引用类型

Java中的四种引用类型分别是:

  1. 强引用(Strong Reference):这是最常见的引用类型,如果一个对象具有强引用,那么它在可达性分析中是可达的,因此不会被垃圾回收器回收,直到没有任何强引用指向该对象。

    使用场景:绝大多数情况下,我们使用强引用来确保对象在需要的时候不会被回收。

    Object obj = new Object(); // obj就是一个强引用
    
  2. 软引用(Soft Reference):软引用指向的对象在内存不足时会被垃圾回收器回收。软引用非常适合缓存场景,当内存不足时,缓存可以被清理掉,但一旦内存充足,又可以重新获得。

    使用场景:缓存。

    SoftReference<Object> softRef = new SoftReference<>(new Object());
    
  3. 弱引用(Weak Reference):弱引用比软引用更弱,它指向的对象在垃圾回收时总是会被回收。弱引用通常用于实现弱缓存,或者维护非必须的对象。

    使用场景:弱缓存,或者实现某些监听器功能的场景。

    WeakReference<Object> weakRef = new WeakReference<>(new Object());
    
  4. 虚引用(Phantom Reference)或者称为幻象引用:这是最弱的引用类型,它不会影响对象的生命周期,即它指向的对象可能已经被回收了。虚引用主要用于在对象被回收时收到一个系统通知。

    使用场景:非常特殊,通常用于跟踪对象被垃圾回收器回收的活动。

    PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), new ReferenceQueue<>());
    

每种引用类型都有其特定的用途,选择合适的引用类型可以帮助我们更有效地管理内存,提高程序性能。在使用软引用和弱引用时,需要特别注意空指针异常的问题,因为对象可能会在任何时候被回收。