[JVM] JVM内存结构

详解Java虚拟机运行时内存结构

Posted by Hyuga on August 22, 2018

之前JVM简介文章中介绍到JVM内存划分有三部分,分别是:

  • 类装载:在JVM启动时或者在类运行时将需要的class加载到JVM
  • 运行时数据区:JVM中数据交互内存区域
  • 执行引擎:负责执行class文件中包含的字节码指令

本篇主要详解JVM运行时数据区的内存结构

Java程序执行过程

  • Java 源代码文件(.Java文件)
  • Java Compiler(Java编译器)
  • Java 字节码文件(.class文件)
  • 类加载器(Class Loader)
  • Runtime Data Area(运行时数据)
  • Execution Engine(执行引擎)

JVM运行时内存结构

线程私有:程序计数器、java栈、本地方法栈

线程共享:堆、方法区

程序计数器 线程私有

  • 记录当前线程执行的字节码的行号。
  • 线程私有的,每条线程都对应一个独立的程序计数器。
  • 一个很小的内存空间,是内存结构中唯一一个不会抛出OOM的内存区域。

Java虚拟机栈 线程私有

  • 虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表操作数栈动态链接返回地址等。一个栈帧就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个Java方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。
  • 在Java中的方法开始运行时创建,随着方法执行结束而结束,生命周期等同于方法执行周期
    • 内存不足时会抛出OOM(内存溢出)
    • 当产生的栈深度超过了允许的栈深度时抛出StackOverFlowError(堆栈溢出),比如无限递归。

本地方法栈 线程私有

  • 和虚拟机栈类似,只不过调用的方法是Native方法。
  • 线程私有,生命周期与线程相同。

Java堆 线程共享

  • 内存结构中占据空间最大的一部分内存结构。主要用于存储new出来的实例对象和数组。也是垃圾收集器的主要执行目标区域,当Java堆内存不够时会首先启动垃圾收集器清理无用对象。当GC过后内存还是不够就会抛出OOM。

方法区 线程共享

  • 主要用于存储虚拟机加载的类信息、常量、静态变量、类的字段和方法、类的访问权限、类名以及编译器编译后的代码等数据。
  • 方法区的大小决定了系统可以保存多少个类。
  • 方法区的大小设置
    • JDK1.8前:
      • 方法区是堆的一个“逻辑部分”(一片连续的堆空间),但为了与堆做区分,方法区还有个名字叫“非堆”,也有人用“永久代”(HotSpot对方法区的实现方法)来表示方法区。
      • 可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。【这是jdk1.8以前的永久代设置方式】
      • 方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。【这是jdk1.8以前的永久代可能抛出的异常】
      • 存在两种异常:StackOverFlowError和OutOfMemoryError。
    • JDK1.8开始:
      • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
      • -XX:MetaspaceSize:设置元空间初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
      • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
      • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
      • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集
  • 方法区存储的大致内容如下:
    • 每一个类的结构信息
    • 运行时常量池(Runtime Constant Pool)
    • 字段和方法数据
    • 构造函数和普通方法的字节码内容
    • 类、实例、接口初始化时用到的特殊方法

方法区中有个运行时常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中。

Java中的几种常量池后续篇章再总结

栈和堆的区别

功能不同

  • 从软件设计的角度看,JVM栈代表了处理逻辑,而JVM堆代表了数据。
  • 栈内存用来存储局部变量和方法调用。
  • JVM栈中,一个对象只对应了一个4byte的引用。
  • 栈是运行时的单位
  • 堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
  • JVM堆是存储的单位,存的是对象。
  • JVM堆内容可以被多个JVM栈共享(多线程访问同个对象)。共享内存,共享常量和缓存。

共享性不同

  • 栈内存是线程私有的,每个Java线程拥有自己的独立的JVM栈,也就是Java方法的调用栈。每个Java线程拥有自己的独立的native方法栈。
  • 堆内存是所有线程共有的,所有线程共享堆内存。

异常错误不同

  • 如果栈内存或者堆内存不足都会抛出异常。
  • 栈空间不足:java.lang.StackOverFlowError。
  • 堆空间不足:java.lang.OutOfMemoryError。

空间大小

  • 栈的空间大小远远小于堆的。

详解ava虚拟机栈

什么是Java虚拟机栈?

栈我们可以想象成一本一本书叠起来,后进先出。

栈和栈帧的概念

  • 栈是JVM分配的一块内存,一条线程对应一个栈
  • 一个函数方法对应一个栈帧
  • 栈由栈帧组成,每个java函数调用会在对应栈中的压入一个栈帧

栈帧的结构

  • 局部变量表
    • 一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了方法所需要分配的最大局部变量表的容量。
    • java编译期间就确定了局部变量表的内存大小
  • 操作数栈
    • 一个数据结构为后进先出的数组
    • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
    • jvm指令对栈帧变量进行计算时的操作区域,局部变量后进先出计算后赋值回变量
  • 帧数据区
    • 除了局部变量表和操作数栈,java栈还需要一些数据来支持常量池解析,正常方法返回和异常处理等,大部分java字节码指令需要进行常量池访问,在帧数据区中保存着访问常量池的指针,方便程序访问常量池。
    • 当JVM执行到需要常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它。
    • 当函数返回或者出现异常时,虚拟机要恢复调用者函数的栈帧,并让调用者函数继续执行下去,对异常处理,虚拟机必须有一个异常处理表,方便在发生异常的时候找到处理异常的代码,因此异常处理表也是帧数据区中重要的一部分。
    • 当栈针函数操作发生异常,从帧数据区的异常处理表获取对应字节码偏移量信息,调到对应要往下执行的代码行,可能catch后继续执行,也可能抛出异常。

栈上分配和对象逃逸

  • 常规操作是对象都是分配在堆中的,但是对于那种只用到一次后便不再引用的对象,放到堆中岂不是浪费,如果丢了100w个这样的对象到堆中,给gc造成多大没必要的压力?
  • 栈上分配是java虚拟机提供的一项优化技术,对于那些线程私有的对象(指不可能被其他线程访问的对象),可以将它们打散分配在栈上,而不是分配在堆上。【先对对象进行逃逸分析,再做栈上分配】
  • 分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统的性能。

方法的返回地址、动态引用

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池有存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

附加信息 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

Java虚拟机栈的例子

栈是由栈帧组成,每当线程调用一个java方法时,JVM就会在该线程对应的栈中压入一个帧,而帧是由局部变量区、操作数栈和帧数据区组成。 那在一个代码块中,栈到底是什么形式呢?下面是我从《深入JVM》中摘抄的一个例子,大家可以看看:

public class Main{
    public static void addAndPrint(){
        double result = addTwoTypes(1,88.88);
        System.out.println(result);
    }
    public static double addTwoTypes(int i,double d){
        return i + d;
    }
}

执行过程中的三个快照:

  1. 只有在调用一个方法时,才为当前栈分配一个帧,然后将该帧压入栈

  2. 帧中存储了对应方法的局部数据,方法执行完,对应的帧则从栈中弹出,并把返回结果存储在调用 方法的帧的操作数栈中

堆内内存与堆外内存

  • 堆在Java虚拟机启动时候被创建,它存储着Java中的对象,包含:成员变量局部变量类变量
  • 堆被同一个JVM实例中的所有Java线程共享,堆空间不足:java.lang.OutOfMemoryError。
  • 堆我们可以想象成一个储藏室,里面的物品随意摆放,或者想象成垃圾场。
  • 堆分为堆内内存堆外内存:
    • 堆内内存
      • 在使用堆内内存时候完全遵守JVM虚拟机的内存管理机制,也就是GC统一管理;
      • 堆外内存就是把内存分配在Java虚拟机之外,这些内存OS直接管理,我们经常用java.nio.DirectByteBuffer对象进行堆外内存的管理和使用,堆外内存优点:减少回收,加快复制,堆外内存的缺点就是内存难以控制。
    • 堆外内存
      • 存储的全部是对象实例,每个对象都包含一个与之对应的class的信息(class信息存放在方法区)。
      • jvm只有一个堆区(heap)被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身,几乎所有的对象实例和数组都在堆中分配。

堆内内存

堆内内存是我们平常工作中接触比较多的,在jvm参数中只要使用-Xms,-Xmx等参数就可以设置堆的大小和最大值,理解jvm的堆还需要知道下面这个公式: 堆内内存 = 新生代+老年代+持久代

堆内内存(on-heap memory)也就是常说的java堆,完全遵守JVM虚拟机的内存管理机制,采用垃圾回收器(GC)统一进行内存管理。 常见的垃圾回收算法主要有:

  • 引用计数器法(Reference Counting)
  • 标记清除法(Mark-Sweep)
  • 复制算法(Coping)
  • 标记压缩法(Mark-Compact)
  • 分代算法(Generational Collecting)
  • 分区算法(Region)

堆外内存

堆外内存(Direct Memory)又叫直接内存,就是把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理而不是虚拟机。它是利用本地方法库直接在Java堆之外申请的内存区域。

堆外内存,,这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

堆外内存有什么特性:

  • 内存的分配不会受到java堆大小的限制
  • 堆外内存被频繁的调用也可能会导致OutOfMemoryError异常出现

如何使用堆外内存?

在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/0方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

DirectByteBuffer类是在Java Heap外分配内存,对堆外内存的申请主要是通过成员变量unsafe来操作。

使用堆外内存的优点

  • 减少了垃圾回收
    • 因为垃圾回收会暂停其他的工作。
  • 加快了复制的速度
    • 堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。

堆外内存的缺点

  • 内存难以控制,使用了堆外内存就间接失去了JVM管理内存的可行性,改由自己来管理,当发生内存溢出时排查起来非常困难。

详解方法区

  • 全局共享的一块内存区域,又叫静态区,跟堆一样,被所有的线程共享。
  • 用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 方法区在HotSpot中又被称为永久代(JDK8后改用元空间代替永久代)。
  • 该区域的回收目标主要是对常量池的回收以及类的卸载。
  • 当内存空间不足时,无法为方法区开辟新空间时,将抛出OutOfMemoryError。
  • 运行时常量池,存储类加载后的常量池信息。JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。

常见误区

1.Java中的基本数据类型一定存储在栈内存中吗? 一般看到这问题大多都会回答是,因为我们想到的优先是八种基本数据类型,这个是存在于栈中的。如下int a = 1;a存于栈内存。

但如果是基础数据类型数组结构呢?

  int[] array=new int[]{1,2};

那么将引用存在栈中而对象({1,2})存储在堆内。

这也是实际运行时JVM提供的性能优化。

2.栈的速度比堆快吗? 一定情况下栈的速度是比堆快的,但是快的并不明显。毕竟都是RAM。所以这算不上堆和栈的一大区别。

3.对象一定分配在堆内存中吗? 不一定,上面说了,jvm会进行逃逸分析来决定对象时栈上分配还是堆中分配。

什么是OOM(OutOfMemoryError)

  • 内存不足错误
  • 为什么会OOM?
    • 分配的少了:比如虚拟机本身可使用的内存(一般通过启动时的VM参数指定)太少。
    • 应用内存占用太多,并且用完没释放,浪费了(如IO未释放等)。此时就会造成内存泄露或者内存溢出。
  • 内存泄漏和内存溢出
    • 内存泄露:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了,因为申请者不用了,而又不能被虚拟机分配给别人用。
    • 内存溢出:申请的内存超出了JVM能提供的内存大小,此时称之为溢出。

什么是StackOverFlowError

  • 栈溢出错误
  • 当产生的栈深度超过了允许的栈深度时抛出StackOverFlowError(栈溢出错误),比如无限递归。

参考资料

《深入理解Java虚拟机》

链接:Java虚拟机的内存组成以及堆内存介绍-HollisChuang’s Blog

链接:Java堆和栈看这篇就够 - Johnny-Zhuang’s Technology Blog

链接:Java虚拟机的堆、栈、堆栈如何去理解? - 知乎

链接:Java 内存之方法区和运行时常量池 - 漠然的博客 mritd Blog

链接:从0到1起步-跟我进入堆外内存的奇妙世界 - 简书