JVM Memory Model - tenji/ks GitHub Wiki

JVM内存模型

Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域。

程序计数器(Program Counter Register)

每个JVM线程都有自己的程序计数器。 在任何时候,每个JVM线程正在执行一个方法的代码,即该线程的当前方法。在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了在线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,独立存储,互不影响。所以,程序计数器是线程私有的内存区域。

由于Java应用程序可以包含一些Native代码(例如,使用Native库),因此Native和非Native方法计数器的存储内容不停。如果线程执行的是一个Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果线程执行的是一个Native方法,计数器的值为空。

Java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域。

虚拟机栈(JAVA VM Stack)

Java虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时会创建一个栈帧(Stack Frame),栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

跟程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

我们常说的堆内存(Heap)和栈内存(Stack)中的栈指的就是虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

局部变量表存放了编译器可知的各种基本数据类型对象引用类型(指向对象起始地址的引用指针,或指向代表对象的句柄)和returnAddress类型(指向了一条字节码指令的地址)。

局部变量表所需的内存空间在编译时期完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常,比如方法递归没有终止条件。

如果虚拟机栈可以动态扩展且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常,比如启动了过多的线程。

栈溢出模拟

import java.util.*;
import java.lang.*;
public class OOMTest {
    public void stackOverFlowMethod() {
        stackOverFlowMethod();
    }
    public static void main(String... args) {
        OOMTest oom = new OOMTest();
        oom.stackOverFlowMethod();
    }
}

运行上面的代码,会出现以下的异常:

Exception in thread "main" java.lang.StackOverflowError
        at OOMTest.stackOverFlowMethod(OOMTest.java:6)

以上就是通过构造一个死循环来构造线程请求的栈深度大于虚拟机所允许深度的场景。

本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务(也就是字节码),而本地方法栈为虚拟机使用到的Native方法服务。

Java虚拟机规范对本地方法栈使用的语言、使用方法与数据结构并没有强制规定,因此可以由虚拟机自由实现。例如:HotSpot虚拟机直接将本地方法栈和虚拟机栈合二为一。

跟虚拟机栈相同,Java虚拟机规范对这个区域也规定了两种异常情况StackOverflowError和OutOfMemoryError异常。

堆(Heap)

JVM堆内存结构

  • 年轻代(Young Generation)
    • Eden Memory
    • Survivor Memory
  • 老年代(Old Generation)

注意:永久代(Permanent Generation)是不属于堆内存的,尽管逻辑上它是堆的一部分。

堆溢出模拟

import java.util.*;
import java.lang.*;
public class OOMTest {
    public static void main(String... args) {
            List<byte[]> buffer = new ArrayList<byte[]>();
            buffer.add(new byte[10*1024*1024]);
    }
}

通过如下的命令运行上面的代码(可使用-XX:+PrintGCDetails打出更详细的日志):

$ java -verbose:gc -Xmn10M -Xms20M -Xmx20M -XX:+PrintGC OOMTest

运行后会有OutOfMemoryError的提示:

[GC 1180K->366K(19456K), 0.0037311 secs]
[Full GC 366K->330K(19456K), 0.0098740 secs]
[Full GC 330K->292K(19456K), 0.0090244 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at OOMTest.main(OOMTest.java:7)

从运行结果可以看出,JVM进行了一次Minor gc和两次的Major gc,从Major gc的输出可以看出,gc以后old区使用率为134K,而字节数组为10M,加起来大于了old generation的空间,所以抛出了异常,如果调整-Xms21M,-Xmx21M,那么就不会触发gc操作也不会出现异常了。

传送门 ----> 如何分析GC日志?

通过上面的实验其实也从侧面验证了一个结论:当对象大于新生代剩余内存的时候,将直接放入老年代,当老年代剩余内存还是无法放下的时候,出发垃圾收集,收集后还是不能放下就会抛出内存溢出异常了

方法区(Method Area)和运行时常量池

方法区也是被所有的线程共享的一块内存区域。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

在HotSpot虚拟机上,方法区就是用永久代实现的,但是不是绝对的,部分虚拟机(如BEA JRockit等)不存在永久代的概念。

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样 不需要连续的内存和可以选择固定大小或者可扩展之外,还可以选择不实现垃圾回收。这也是方法区跟堆内存的一个区别,堆内存是必须要实现垃圾回收的。这区域的内存回收目标主要是针对常量池的回收类型的卸载,一般而言,这个区域的内存回收比较难以令人满意,尤其是类型的回收,条件相当苛刻,这也是方法区可以选择不实现垃圾回收的原因,但是这部分区域的内存回收确实是必要的。

Java虚拟机规范规定,当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError异常。

永久代溢出的场景

  • 使用一些应用服务器的热部署的时候,我们就会遇到热部署几次以后发现内存溢出了,这种情况就是因为每次热部署的后,原来的class没有被卸载掉。
  • 如果应用程序本身比较大,涉及的类库比较多,但是我们分配给持久带的内存(通过-XX:PermSize和-XX:MaxPermSize来设置)比较小的时候也可能出现此种问题。
  • 一些第三方框架,比如spring, hibernate都通过字节码生成技术(比如CGLib)来实现一些增强的功能,这种情况可能需要更大的方法区来存储动态生成的Class文件。

常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量(Interned Strings)和符号引用(Symbol Reference),这部分内容将在类加载后进入方法区的运行时常量池中存放。

一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。

常量池溢出模拟

import java.util.*;
import java.lang.*;
public class OOMTest {
    public static void main(String... args) {
        List<String> list = new ArrayList<String>();
        while (true) {
            list.add(UUID.randomUUID().toString().intern());
        }
    }
}

通过如下命令执行上面的代码(JDK 1.8之后的版本MaxPermSize参数已经失效):

$ java -verbose:gc -Xmn5M -Xms10M -Xmx10M -XX:MaxPermSize=1M -XX:+PrintGC OOMTest

运行后会有OutOfMemoryError的提示(JDK 1.8之后的版本提示不一样):

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
        at java.lang.String.intern(Native Method)
        at OOMTest.main(OOMTest.java:8)

元空间(Metaspace)

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

元空间溢出模拟

减少内存占用的编码建议

  • 使用StringBuilder代替字符串相加
  • 避免过深的类层次结构和过深的方法调用

这两者都是非常占用内存的,特别是方法调用更是堆栈空间的消耗大户。

  • 尽量避免使用static变量,类内私有常量可以用final来代替

static变量属于类变量,存放于方法区(Method Area);而final变量仍然属于实例变量,存放于堆(Heap)中。

XSS

XSS越大,每个线程的大小就越大,占用的内存越多,能容纳的线程就越少;XSS越小,则递归的深度越小,容易出现栈溢出 java.lang.StackOverflowError。减少局部变量的声明,可以节省栈帧大小,增加调用深度。

线程数决定因素

JVM 中可以生成的最大数量由 JVM 的堆内存大小、线程的 Stack 内存大小、系统最大可创建的线程数量(JAVA 线程的实现是基于底层系统的线程机制来实现的,Windows下_beginthreadex,Linux下pthread_create)三个方面影响。

具体数量可以根据 JAVA 进程可以访问的最大内存(32位系统上一般2G)、堆内存、线程的 Stack 内存来估算。

(MaxProcessMemory - JVMMemory – ReservedOsMemory) / (ThreadStackSize) = Number of threads
  • MaxProcessMemory : 进程的最大寻址空间
  • JVMMemory : JVM 内存(可以理解为堆内存)
  • ReservedOsMemory : 保留的操作系统内存,如 Native heap,JNI 之类,一般100多M
  • ThreadStackSize : 线程栈的大小,JVM 启动时由Xss指定

参考链接

深入理解JVM(1) : Java内存区域划分

JVM Memory Model / Structure and Components

Java (JVM) Memory Model – Memory Management in Java

Java Virtual Machine Run-Time Data Areas

Java常见内存溢出异常分析

Java8内存模型—永久代(PermGen)和元空间(Metaspace)

⚠️ **GitHub.com Fallback** ⚠️