JVM内存分配

344 阅读5分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

进程内存与JVM内存

JVM底层使用C++编写,所以它也遵循操作系统的内存分配原则,JVM进程执行时,操作系统会为其分配以下几块内存:

  • 代码区: 存放C++代码
  • 数据区: 存放static变量、全局变量
  • 堆: 分配内存
  • 栈: 用于方法调用
  • 字符串常量区: 存放常量字符串

以上5个区域构成了进程占用的内存空间,操作系统分配给进程的的最大内存空间=内存条-1G(OS占用)。 对于正常的进程的进程来说,到这就算完了。但是JVM运行起来只是第一步,它需要将.class虚拟机字节码跑起来,所以JVM内部自己定义了一套运行.class字节码的内存结构,也称作运行时数据区

  • 方法区: 存放.class解析出的Class类对象,代码段,static方法和变量
  • 堆: 存放数组、对象
  • 虚拟机栈: 执行Java方法使用的栈
  • 本地方法栈: 执行native方法使用的栈
  • 程序计数器: 存储执行字节码的位置
  • 堆外内存: JVM垃圾收集器管理范围外的内存

你可能会疑惑,进程的内存结构,与JVM内存结构有什么区别呢?进程的堆和JVM的堆是一样还是不一样?可能有些文章上来就是运行时数据区,什么方法区,堆等,然后又引出堆外,就让大家很疑惑,下图可以让大家清晰看出JVM内存分配:

image.png

所以如果内存条大小4G,真正落到我们所说的Java堆上,一定是小于4G。JVM其实也相当于操作系统的加载进程的作用,只不过操作系统加载进程,JVM加载.class字节码文件。

JVM内存溢出

测试JVM各个内存区域如何溢出。测试环境均为JDK8。

堆溢出

堆中存储对象,数组,通过new数组方式可以造成堆OOM。 image.png

方法区溢出

方法区对于JDK7与JDK8有很大的区别。方法区的定义是存储字符串常量池、代码、Class类信息。从作用看,字符串常亮、代码、Class类信息在JVM运行后应该都不会在进行改动了,都不会回收这部分内存, 所以JDK7将这些信息都存储在了堆外内存,其实现的程序称为永久代,32位机器大小64M,64位机器默认大小85M。由于字符串常量池存储在永久带中,这块空间会经常由于内存不足报错:java.lang.OutOfMemoryError: PermGen space。当然可以通过 -XX:MaxPermSize参数控制永久代空间大小,但治标不治本。根治还是得需要垃圾回收,所以在JDK8中,设计了元空间替代永久代,将字符串常量池存储在堆空间中,内存不足时可以通过垃圾回收清理。而Class信息、代码段等数据结构还是存储在堆外空间,但也提供了类卸载机制,用于回收不再使用的Class类。与永久代不同的是,元空间的大小默认最大可以达到操作系统为进程分配的最大内存,所以通常不会出现元空间内存不足的报错。当然元空间内存大小也可以通过-XX:MaxMetaspaceSize参数进行控制。

可以通过asm库动态生成类的方式使得元空间溢出。 asm依赖:

<dependency>
    <groupId>asm</groupId>
    <artifactId>asm</artifactId>
    <version>3.3.1</version>
</dependency>

源代码:

package com.sanjin.jvm.内存溢出;

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

import java.util.ArrayList;
import java.util.List;

public class MyMetaspace extends ClassLoader {
    
    public static List<Class<?>> createClasses() {
        List<Class<?>> classes = new ArrayList<Class<?>>();
        for (int i = 0; i < 10000000; ++i) {
            ClassWriter cw = new ClassWriter(0);
            cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
            MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
            mw.visitVarInsn(Opcodes.ALOAD, 0);
            mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
            mw.visitInsn(Opcodes.RETURN);
            mw.visitMaxs(1, 1);
            mw.visitEnd();
            MyMetaspace  test = new MyMetaspace ();
            byte[] code = cw.toByteArray();
            Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);
            classes.add(exampleClass);
        }
        return classes;
    }
    
    public static void main(String[] args) {
        try {
            while (true) {
                createClasses();
            }
        } catch (Throwable e) {
            e.printStackTrace();
        }
        System.out.println("finnal");
        
    }
}

image.png

栈溢出

当进行方法调用时,需要有地方存储方法入参变量、局部变量、操作数栈、动态链接、方法返回地址信息,在JVM中,这个地方称作栈帧。变量存储在栈帧局部变量表中,没错,这部分变量是分配在栈中、而不是堆中,比如int i;占用栈的四字节空间而不是堆的四字节空间,但是对象、数组还是分配在堆空间中,而对象的引用是在栈中分配的。

  • 局部变量表: 存储入参变量、局部变量信息。
  • 操作数栈: 的作用是用于执行虚拟机字节码指令的。JVM虚拟机是基于栈执行指令。在执行某个指令前需要先将指令需要的参数压入栈中,在进行执行。
  • 动态链接: 在C语言中,A方法中调用B方法,由于编译时B方法的入口地址已经确定了,所以调用B方法时,CPU直接调到B方法的入口地址读取指令执行。但是在JVM中,由于编译的是.class字节码信息,所以通常只能解析具备语义化的方法的签名,比如com.sanjin.并发.HelloDemo.VolatileDemo#setStop,这个称为符号引用。当JVM运行时,符号引用没办法直接跳转到方法的入口地址处,所以还需要将符号引用转换为直接引用,其中符号引用直接引用的映射关系是存储在运行时常量池中。动态连接存储的就是直接引用。
  • 方法返回地址: 进入方法的入口地址,当方法执行完成后需要返回到执行位置。

栈空间的大小是每个线程执行方法时,栈帧的大小之和,其默认大小是128k,可以通过-Xss128k参数控制。测试溢出可以不断增加栈帧数量,比如死循环。

image.png