JVM 内存结构与内存溢出 / 泄漏问题全解析

jvm 内存结构有哪几种内存溢出的情况?

  • 堆内存溢出:当出现Java.lang.OutOfMemoryError:Java heap space异常时,就是堆内存溢出了。原因是代码中可能存在大对象分配,或者发生了内存泄露,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。
  • 栈溢出:如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError。
  • 元空间溢出:元空间的溢出,系统会抛出Java.lang.OutOfMemoryError: Metaspace。出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
  • 直接内存内存溢出:在使用ByteBuffer中的allocateDirect()的时候会用到,很多JavaNIO(像netty)的框架中被封装为其他的方法,出现该问题时会抛出Java.lang.OutOfMemoryError: Direct buffer memory异常。

遇到过堆溢出的情况吗?如何解决?

堆溢出(java.lang.OutOfMemoryError: Java heap space)通常发生在程序持续创建对象且无法被 GC 及时回收的场景下。

遇到堆溢出时,首先需要定位原因,一般分两步:

  1. 捕获内存快照 :通过 JVM 参数-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof,让程序在发生 OOM 时自动生成堆快照文件。
  2. 分析快照文件:使用 MAT(Memory Analyzer Tool)或 JProfiler 等工具分析快照,重点看哪些对象占用了大量内存、是否存在内存泄漏(如对象长期被无用引用持有,无法回收)。

常见的解决思路根据原因不同而不同:

  • 如果是内存泄漏:比如静态集合无意识地缓存了大量对象、长生命周期对象持有短生命周期对象的引用(如单例类持有业务对象)等。这时候需要梳理对象引用链,找到未释放的根源,比如清理静态集合中不再使用的元素、解除不必要的对象关联。
  • 如果是内存不足 :即程序确实需要大量内存(如处理大文件、加载大量数据到内存),但当前堆配置太小。这种情况下可以通过调整 JVM 参数扩大堆内存,比如-Xms2g -Xmx4g(初始堆 2G,最大堆 4G),但需注意不能超过物理内存限制,避免频繁 swap。

另外,从代码层面优化也很重要:比如避免一次性加载全部数据(改用分批处理)、使用缓存时设置合理的过期策略、及时释放资源(如 IO 流、数据库连接)等,从源头减少内存占用。

举个例子:曾遇到过一个批量处理任务,代码中把所有处理结果都存到了一个静态 List 里,导致对象越积越多,最终堆溢出。通过分析快照发现这个 List 占用了 80% 的堆内存,修改为处理完一批就写入数据库并清空 List 后,问题解决。

栈溢出的情况呢?

栈溢出(java.lang.StackOverflowError)是 JVM 中另一种常见的内存错误,和堆溢出的原理与场景截然不同。栈溢出主要发生在 Java 虚拟机栈(或本地方法栈)的内存空间被耗尽时,通常与方法调用的深度直接相关。

从触发原因来看,最常见的场景是无限递归调用。因为 Java 方法调用时会在栈中创建栈帧(存储局部变量、操作数栈、方法返回地址等),每递归一次就会新增一个栈帧。如果递归没有正确的终止条件,栈帧会不断累积,最终超过虚拟机栈的最大容量,导致栈溢出。比如一个简单的无终止条件的递归方法:

复制代码
public void recursiveMethod() {
    recursiveMethod(); // 无限递归,没有终止条件
}

调用这个方法很快就会抛出StackOverflowError

另一种情况是单个方法的栈帧过大。如果一个方法定义了大量局部变量,或者局部变量占用内存过大(比如大数组),单个栈帧就会占用较多栈空间,可能在调用层级不深时就耗尽栈内存。

解决栈溢出的思路主要有:

  1. 排查递归逻辑:检查是否存在无限递归或递归层级过深的问题,添加正确的终止条件,或减少递归深度。必要时可将递归改写为迭代(如用循环替代),因为迭代不会持续创建新栈帧。
  2. 调整栈内存大小 :通过 JVM 参数-Xss(如-Xss256k)增大栈内存容量。但这种方式要谨慎,栈内存过大会导致线程可创建数量减少(总内存固定时,单个线程栈越大,能创建的线程数越少)。
  3. 优化方法栈帧:减少方法内局部变量的数量,避免在方法中创建过大的对象或数组,将大对象的创建移到堆中(通过 new 关键字),降低单个栈帧的内存占用。

举个实际例子:曾遇到一个树形结构遍历的方法,因节点层级极深(超过 10 万层)且用递归实现,导致栈溢出。解决方式是将递归遍历改为基于栈的迭代遍历,手动维护节点访问顺序,避免了栈帧的无限累积,问题得以解决。

总的来说,栈溢出的核心原因是方法调用栈深度超过了栈内存限制,解决时应优先从代码逻辑(尤其是递归)入手,而非单纯调大栈内存。

有具体的内存泄漏和内存溢出的例子么请举例及解决方案?

1、静态属性导致内存泄露

会导致内存泄露的一种情况就是大量使用static静态变量。在Java中,静态属性的生命周期通常伴随着应用整个生命周期(除非ClassLoader符合垃圾回收的条件)。下面来看一个具体的会导致内存泄露的实例:

复制代码
public class StaticTest {
    public static List<Double> list = new ArrayList<>();
    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }
    public static void main(String[] args) {
        Log.info("Debug Point 1");
        new StaticTest().populateList();
        Log.info("Debug Point 3");
    }
}

如果监控内存堆内存的变化,会发现在打印Point1和Point2之间,堆内存会有一个明显的增长趋势图。但当执行完populateList方法之后,对堆内存并没有被垃圾回收器进行回收。

但针对上述程序,如果将定义list的变量前的static关键字去掉,再次执行程序,会发现内存发生了具体的变化。VisualVM监控信息如下图:

对比两个图可以看出,程序执行的前半部分内存使用情况都一样,但当执行完populateList方法之后,后者不再有引用指向对应的数据,垃圾回收器便进行了回收操作。因此,我们要十分留意static的变量,如果集合或大量的对象定义为static的,它们会停留在整个应用程序的生命周期当中。而它们所占用的内存空间,本可以用于其他地方。

那么如何优化呢?第一,进来减少静态变量;第二,如果使用单例,尽量采用懒加载。

2、 未关闭的资源

无论什么时候当我们创建一个连接或打开一个流,JVM都会分配内存给这些资源。比如,数据库链接、输入流和session对象。

忘记关闭这些资源,会阻塞内存,从而导致GC无法进行清理。特别是当程序发生异常时,没有在finally中进行资源关闭的情况。这些未正常关闭的连接,如果不进行处理,轻则影响程序性能,重则导致OutOfMemoryError异常发生。

如果进行处理呢?第一,始终记得在finally中进行资源的关闭;第二,关闭连接的自身代码不能发生异常;第三,Java7以上版本可使用try-with-resources代码方式进行资源关闭。

3、 使用ThreadLocal

ThreadLocal提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal相当于提供了一种线程隔离,将变量与线程相绑定,从而实现线程安全的特性。

ThreadLocal的实现中,每个Thread维护一个ThreadLocalMap映射表,key是ThreadLocal实例本身,value是真正需要存储的Object。

ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统GC时,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value。

如果当前线程迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref → Thread → ThreadLocalMap → Entry → value,永远无法回收,造成内存泄漏。

如何解决此问题?

  • 第一,使用ThreadLocal提供的remove方法,可对当前线程中的value值进行移除;

  • 第二,不要使用ThreadLocal.set(null) 的方式清除value,它实际上并没有清除值,而是查找与当前线程关联的Map并将键值对分别设置为当前线程和null。

  • 第三,最好将ThreadLocal视为需要在finally块中关闭的资源,以确保即使在发生异常的情况下也始终关闭该资源。

    try {
    threadLocal.set(System.nanoTime());
    //... further processing
    } finally {
    threadLocal.remove();
    }

相关推荐
城俊BLOG1 小时前
C++的注册机制和插件系统
java·服务器·c++
HoneyMoose1 小时前
Discourse 删除版本历史
开发语言
兩尛1 小时前
c++知识点4
开发语言·c++
云qq1 小时前
C++ 原子操作
开发语言·c++·算法
Aurorar0rua1 小时前
CS50 x 2024 Notes C - 08
c语言·开发语言·学习方法
froginwe111 小时前
SQL GROUP BY 详解
开发语言
wangl_922 小时前
C#性能优化完全指南 - 从原理到实践
开发语言·性能优化·c#·.net·.netcore·visual studio
Try,多训练2 小时前
软件设计师备考第一性原理分析
java·经验分享·学习方法
xyq20242 小时前
Redis 哈希(Hash)
开发语言