Java内存溢出OOM


JVM 中常见的两个错误:StackoverFlowError (栈溢出)和OutofMemoryError: java heap space(堆溢出),这两种是属于 Error,而不是 Exception

除此之外,OutOfMemoryError 还有以下的错误

  • java.lang.OutOfMemoryError:GC overhead limit exceeeded
  • java.lang.OutOfMemoryError:Direct buffer memory
  • java.lang.OutOfMemoryError:unable to create new native thread
  • java.lang.OutOfMemoryError:Metaspace

StackoverFlowError

这个错误是栈溢出,栈一般是512k,过度深度的方法调用就会导致栈溢出

下面是一个最简单的例子,一个方法不断的的调用,终会导致栈被撑破,代码就会报这个错误

public class StackOverflowErrorDemo {
    public static void main(String[] args) {
        stackOverflowError();
    }

    private static void stackOverflowError() {
        stackOverflowError();
    }
}

运行结果

Exception in thread "main" java.lang.StackOverflowError
	at cn.edu.hust.day7_oom.StackOverflowErrorDemo.stackOverflowError(StackOverflowErrorDemo.java:9)

OutOfMemoryError

java heap space

创建了很多对象,导致堆空间内存不够

下面我们直接依次点击Run、Edit Configurations,然后在VM Options中加入下面的代码,现在+号表示开启

-Xms10m -Xmx10m -XX:+PrintGCDetails

上述参数表示设置堆的初始化内存和最大内存都为10M,并开启打印基本 GC 信息

最后我们创建一个 50M 的字节数组,模拟堆空间内存不够用,相关代码如下

/**
 * 进入JVM参数配置设置如下的参数
 * -Xms10m -Xmx10m -XX:+PrintGCDetails
 */
public class JavaHeapSpaceDemo {
    public static void main(String[] args) {
        byte[] bytes = new byte[50 * 1024 * 1024];
    }
}

运行结果

GC overhead limit exceeded

GC回收时间过长时会抛出这个错误,过长的定义是,超过了98%的时间用来做GC,但回收了不到2%的堆内存

假设不抛出GC overhead limit 错误会造成什么情况呢?

会造成 GC 清理的这点内存很快会再次被填满,迫使 GC 再次执行,这样就形成了恶性循环,CPU 的使用率一直都是100%,而 GC 却没有任何成果

代码演示

为了更快的达到效果,我们首先需要设置JVM启动参数

-Xms10m -Xmx10m -XX:+PrintGCDetails

下面可以通过不断地向 list 插入 String 对象来模拟这个错误

/**
 * JVM参数配置: -Xms10m -Xmx10m -XX:+PrintGCDetails
 */
public class GCOverheadLimitDemo {
    public static void main(String[] args) {
        int i = 0;
        List<String> list = new ArrayList<>();
        try {
            while(true){
                list.add(String.valueOf(++i).intern());
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
        }
    }
}

运行结果

[Full GC (Ergonomics) [PSYoungGen: 2047K->2047K(2560K)] [ParOldGen: 7059K->7057K(7168K)] 9107K->9105K(9728K), [Metaspace: 3234K->3234K(1056768K)], 0.0432971 secs] [Times: user=0.61 sys=0.00, real=0.04 secs] 
......
[Full GC (Ergonomics) [PSYoungGen: 2047K->0K(2560K)] [ParOldGen: 7081K->611K(7168K)] 9129K->611K(9728K), [Metaspace: 3264K->3264K(1056768K)], 0.0040656 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 2560K, used 157K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 7% used [0x00000000ffd00000,0x00000000ffd27760,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 611K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 8% used [0x00000000ff600000,0x00000000ff698c58,0x00000000ffd00000)
 Metaspace       used 3337K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 360K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.Integer.toString(Integer.java:403)
	at java.lang.String.valueOf(String.java:3099)
	at cn.edu.hust.day7_oom.GCOverheadLimitDemo.main(GCOverheadLimitDemo.java:15)

其中省略号表示进行了很多次跟上一次类似的Full GC,可以看到多次Full GC,并没有清理出空间,最终就抛出 GC overhead limit

Direct buffer memory

这是由于NIO引起的,写NIO程序的时候经常会使用 ByteBuffer 来读取或写入数据,这是一种基于通道(Channel) 与缓冲区(Buffer)的I/O方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

ByteBuffer有以下两种方式分配内存:

  • allocate(capability):分配 JVM 堆内存,属于 GC 管辖范围,由于需要拷贝所以速度相对较慢

  • allocteDirect(capability):分配 OS 本地内存,不属于 GC 管辖范围,由于不需要内存的拷贝,所以速度相对较快

但如果不断分配本地内存,堆内存很少使用,那么 JVM 就不需要执行 GC,DirectByteBuffer 对象就不会被回收,这时候堆内存充足,但本地内存可能用完了,这是再次分配本地内存就会出现OutOfMemoryError,程序就奔溃了

代码演示

为了更快的达到效果,我们首先需要设置JVM启动参数

-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m

我们使用 -XX:MaxDirectMemorySize=5m 配置能使用的堆外物理内存为5M

JVM堆内存大小可以通过-Xmx来设置,同样的direct ByteBuffer可以通过-XX:MaxDirectMemorySize来设置,此参数的含义是当Direct ByteBuffer分配的堆外内存到达指定大小后,即触发Full GC。注意该值是有上限的,默认是64M,最大为sun.misc.VM.maxDirectMemory(),具体值可以自己在程序中获得

  • 没有配置MaxDirectMemorySize的,MaxDirectMemorySize的大小即等于-Xmx
  • Direct Memory具有回收机制,是受GC控制的
  • 对于使用Direct Memory较多的场景,需要注意下MaxDirectMemorySize的设置,避免-Xmx + Direct Memory超出物理内存大小的现象

然后我们申请一个6M的空间

/**
 * JVM参数配置: -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
 */
public class DirectBufferMemoryDemo {
    public static void main(String[] args) {
        ByteBuffer bb = ByteBuffer.allocateDirect(6 * 1024 * 1024);
    }
}

运行结果

[GC (System.gc()) [PSYoungGen: 1477K->496K(2560K)] 1477K->672K(9728K), 0.0007389 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 496K->0K(2560K)] [ParOldGen: 176K->587K(7168K)] 672K->587K(9728K), [Metaspace: 3042K->3042K(1056768K)], 0.0041620 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:694)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at cn.edu.hust.day7_oom.DirectBufferMemoryDemo.main(DirectBufferMemoryDemo.java:7)

unable to create new native thread

不能够创建更多的新的线程了,也就是说创建线程的上限达到了

在高并发场景的时候,会应用到

导致原因:

  • 应用创建了太多线程,一个应用进程创建多个线程,超过系统承载极限
  • 服务器并不允许你的应用程序创建这么多线程,linux系统默认运行单个进程可以创建的线程为1024个,如果应用创建超过这个数量,就会报 java.lang.OutOfMemoryError:unable to create new native thread

解决方法:

  • 想办法降低你应用程序创建线程的数量,分析应用是否真的需要创建这么多线程,如果不是,改代码将线程数降到最低

  • 对于有的应用,确实需要创建很多线程,远超过linux系统默认1024个线程限制,可以通过修改linux服务器配置,扩大linux默认限制

Metaspace

元空间内存不足,Metaspace元空间应用的是本地内存

我们都知道JDK 1.8 之前元空间相当于方法区,当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

3、在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

处理 OOM

对于大型应用程序来说,面对内存不足错误是非常常见的,这反过来会导致应用程序崩溃。这是一个非常关键的场景,很难通过复制来解决这个问题。

这就是为什么 JVM 提供了一些参数,这些参数将堆内存转储到一个物理文件中,以后可以用来查找泄漏:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=./java_pid<pid>.hprof
-XX:OnOutOfMemoryError="< cmd args >;< cmd args >"
-XX:+UseGCOverheadLimit

这里有几点需要注意:

  • HeapDumpOnOutOfMemoryError 指示 JVM 在遇到 OutOfMemoryError 错误时将 heap 转储到物理文件中。
  • HeapDumpPath 表示要写入文件的路径; 可以给出任何文件名; 但是,如果 JVM 在名称中找到一个 <pid> 标记,则当前进程的进程 id 将附加到文件名中,并使用.hprof格式
  • OnOutOfMemoryError 用于发出紧急命令,以便在内存不足的情况下执行; 应该在 cmd args 空间中使用适当的命令。例如,如果我们想在内存不足时重启服务器,我们可以设置参数: -XX:OnOutOfMemoryError="shutdown -r"
  • UseGCOverheadLimit 是一种策略,它限制在抛出 OutOfMemory 错误之前在 GC 中花费的 VM 时间的比例

参考

  • 尚硅谷 Java 大厂面试题第 2 季
  • JavaGuide(Java面试+学习指南)

文章作者: 不才叶某
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 不才叶某 !
评论
  目录