返回文章列表
JVM虚拟机
JVM运行时数据区内存栈堆

05运行时数据区

JVM 在运行 Java 程序时,会把它管理的内存划分成若干个区域,称为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用,但允许不同虚拟机在实现上各不相同。从大类上看,JVM 由类加载器、运行时数据区域(JVM 管理的内存)、执行引擎(即时编译器、解释器、垃圾回收器等)和本地接口几部分组成,类加载器把 class 字节码文件中的内容加载到内存中,执行引擎负责运行,运行时数据区则负责管理 JVM 使用到的内存(比如创建对象和销毁对象)。

![](/images/jvm/03-基础篇-Java内存区域 -p2-i1.png)

从内存是否共享的角度,可以将运行时数据区分成两类:线程独占的(程序计数器、Java虚拟机栈、本地方法栈)和线程共享的(堆、方法区)。

![](/images/jvm/03-基础篇-Java内存区域 -p4-i1.png)

下面分别展开讲解。

1.程序计数器

程序计数器(Program Counter Register)也叫 PC 寄存器,每个线程会通过程序计数器记录当前要执行的字节码指令的地址。

![](/images/jvm/03-基础篇-Java内存区域 -p10-i1.png)

在加载阶段,虚拟机把字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址,每一条字节码指令都会拥有一个内存地址。代码执行过程中,程序计数器会记录下一行字节码指令的地址,执行完当前指令后,虚拟机的执行引擎根据程序计数器执行下一行指令。除了顺序执行,程序计数器还可以控制程序指令的进行,实现分支、跳转、异常等逻辑。

在多线程执行情况下,Java 虚拟机需要通过程序计数器记录 CPU 切换前解释执行到哪一句指令,等线程再次被调度时继续解释运行。

![](/images/jvm/03-基础篇-Java内存区域 -p21-i1.png)

程序计数器在运行中会出现内存溢出吗?内存溢出指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。因为每个线程只存储一个固定长度的内存地址,程序计数器是不会发生内存溢出的,程序员无需对程序计数器做任何处理。

2.Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。

public class MethodDemo {
    public static void main(String[] args) {
        study();
    }
 
    public static void study(){
        eat();
        sleep();
    }
    public static void eat(){
        System.out.println("吃饭");
    }
    public static void sleep(){
        System.out.println("睡觉");
    }
}

执行 main 方法时把 main 的栈帧压入栈中,调用 study 时再把 study 的栈帧压入栈顶;study 中又调用 eat,eat 栈帧入栈;eat 执行完出栈,再调用 sleep 入栈……整个调用过程符合"先进后出",控制台依次打印"吃饭"、"睡觉"。

![](/images/jvm/03-基础篇-Java内存区域 -p25-i1.png)

Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。

栈帧内部由三部分组成:局部变量表、操作数栈、帧数据。

![](/images/jvm/03-基础篇-Java内存区域 -p28-i1.png)

  • 局部变量表:作用是在运行过程中存放所有局部变量。
  • 操作数栈:栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域。
  • 帧数据:主要包含动态链接、方法出口、异常表的引用。

这三部分的细节(slot 槽、this、参数顺序、槽的复用、操作数栈的执行过程、动态链接、方法出口、异常表等)会在第14篇中详细展开。本文先看栈内存溢出的边界。

Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出,Java虚拟机栈内存溢出时会抛出 StackOverflowError 错误。

![](/images/jvm/03-基础篇-Java内存区域 -p50-i1.png)

如果不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构(Linux x86 64 位默认 1MB,ppc 2MB;BSD、Solaris 64 位均为 1MB;Windows 基于操作系统默认值)。

要修改 Java 虚拟机栈的大小,可以使用虚拟机参数 -Xss:

  • 语法:-Xss栈大小
  • 单位:字节(默认,必须是 1024 的倍数)、k 或者 K(KB)、m 或者 M(MB)、g 或者 G(GB)
  • 例如:-Xss1048576、-Xss1024K、-Xss1m、-Xss1g

一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为 -Xss256k 节省内存。

3.本地方法栈

Java虚拟机栈存储了 Java 方法调用时的栈帧,而本地方法栈存储的是 native 本地方法的栈帧。在 Hotspot 虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数,同时方便出现异常时也把本地方法的栈信息打印出来。

![](/images/jvm/03-基础篇-Java内存区域 -p56-i1.png)

4.堆

一般 Java 程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。

![](/images/jvm/03-基础篇-Java内存区域 -p58-i1.png)

堆空间有三个需要关注的值:used、total、max。

  • used:当前已使用的堆内存
  • total:java虚拟机已经分配的可用堆内存
  • max:java虚拟机可以分配的最大堆内存

![](/images/jvm/03-基础篇-Java内存区域 -p60-i1.png)

堆内存 used、total、max 三个值可以在 arthas 中通过 dashboard 命令看到。手动指定刷新频率(不指定默认 5 秒一次):dashboard -i 刷新频率(毫秒)。

随着堆中对象增多,当 total 即将不足时,虚拟机会继续分配内存给堆,total 值会变大,但 total 最多只能与 max 相等。如果不设置任何的虚拟机参数,max 默认是系统内存的 1/4,total 默认是系统内存的 1/64。在实际应用中一般都需要设置 total 和 max 的值(Oracle 官方文档:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html )。

是不是当 used = max = total 的时候,堆内存就溢出了呢?不是,堆内存溢出的判断条件比较复杂,在下一章《垃圾回收器》中会详细介绍。

要修改堆的大小,可以使用虚拟机参数 -Xmx(max 最大值)和 -Xms(初始的 total):

  • 语法:-Xmx值 -Xms值
  • 单位:字节(默认,必须是 1024 的倍数)、k 或者 K(KB)、m 或者 M(MB)、g 或者 G(GB)
  • 限制:Xmx 必须大于 2 MB,Xms 必须大于 1MB
  • 例如:-Xms6m -Xmx80m

为什么 arthas 中显示的 heap 堆大小与设置的值不一样呢?arthas 中的 heap 堆内存使用了 JMX 技术中内存获取方式,这种方式与垃圾回收器有关,计算的是可以分配对象的内存,而不是整个内存。

Java 服务端程序开发时,建议将 -Xmx 和 -Xms 设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况。

5.方法区

方法区是存放基础信息的位置,线程共享,主要包含三部分内容:

  • 类的元信息:保存了所有类的基本信息
  • 运行时常量池:保存了字节码文件中的常量池内容
  • 字符串常量池:保存了字符串常量

方法区是用来存储每个类的基本信息(元信息),一般称之为 InstanceKlass 对象,在类的加载阶段完成。

![](/images/jvm/03-基础篇-Java内存区域 -p73-i1.png)

方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池;当常量池加载到内存中之后,可以通过内存地址快速定位到常量池中的内容,这种常量池称为运行时常量池。

方法区是《Java虚拟机规范》中设计的虚拟概念,每款 Java 虚拟机在实现上各不相同。Hotspot 设计如下:

  • JDK7 及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数 -XX:MaxPermSize=值 来控制。
  • JDK8 及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。可以使用 -XX:MaxMetaspaceSize=值 将元空间最大大小进行限制。

![](/images/jvm/03-基础篇-Java内存区域 -p76-i1.png)

arthas 可使用 memory 打印出内存情况,JDK7 及之前的版本查看 ps_perm_gen 属性,JDK8 及之后的版本查看 metaspace 属性。

5.1字符串常量池

方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。字符串常量池存储在代码中定义的常量字符串内容,比如 "123" 中的 123 就会被放入字符串常量池。

public class Test {
    public static void main(String[] args) {
        String s1 = new String("abc");
        String s2 = "abc";
        System.out.println(s1 == s2);
    }
}

![](/images/jvm/03-基础篇-Java内存区域 -p82-i1.png)

字符串常量池和运行时常量池有什么关系?早期设计时,字符串常量池属于运行时常量池的一部分,存储位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分:

  • JDK7 之前:运行时常量池逻辑包含字符串常量池,Hotspot 虚拟机对方法区的实现为永久代。
  • JDK7:字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在永久代。
  • JDK8 之后:Hotspot 移除了永久代,用元空间(Metaspace)取而代之,字符串常量池还在堆。

String.intern() 方法可以手动将字符串放入字符串常量池中。分别在 JDK6、JDK8 下执行同一段代码,JDK6 中结果是 false false,JDK8 中是 true false:

  • JDK6 版本中,intern() 方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中,返回的也是永久代里面这个字符串实例的引用。JVM 启动时就会把 java 加入到常量池中。

![](/images/jvm/03-基础篇-Java内存区域 -p89-i1.png)

  • JDK7 及之后版本中,由于字符串常量池在堆上,所以 intern() 方法会把第一次遇到的字符串的引用放入字符串常量池。

![](/images/jvm/03-基础篇-Java内存区域 -p90-i1.png)

5.2静态变量的存储

运行时数据区都学完了,静态变量存储在哪里呢?

JDK6 及之前的版本中,静态变量是存放在方法区中的,也就是永久代。

![](/images/jvm/03-基础篇-Java内存区域 -p91-i1.png)

JDK7 及之后的版本中,静态变量是存放在堆中的 Class 对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter 针对 putstatic 指令的处理。

![](/images/jvm/03-基础篇-Java内存区域 -p92-i1.png)

6.直接内存

直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于 Java 运行时的内存区域。在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:

  1. Java 堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
  2. IO 操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到 Java 堆中。

现在直接放入直接内存即可,同时 Java 堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。

![](/images/jvm/03-基础篇-Java内存区域 -p94-i1.png)

要创建直接内存上的数据,可以使用 ByteBuffer:

  • 语法:ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
  • 注意事项:arthas 的 memory 命令可以查看直接内存大小,属性名 direct。

如果需要手动调整直接内存的大小,可以使用 -XX:MaxDirectMemorySize=大小。单位 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。默认不设置该参数情况下,JVM 自动选择最大分配的大小。以下示例以不同的单位说明如何将直接内存大小设置为 1024 KB:

-XX:MaxDirectMemorySize=1m
-XX:MaxDirectMemorySize=1024k
-XX:MaxDirectMemorySize=1048576