跟我一起分析学习 JVM 内存模型

  • 跟我一起分析学习 JVM 内存模型
  • 学习分析 JVM 中的对象与垃圾回收机制(上)
  • 学习分析 JVM 中的对象与垃圾回收机制(下)
  • JVM 方面总结

建议按照顺序阅读

当执行 main 方法的时候, 其实内部过程是这样的.


而常说的JVM 内存模型, 即是 JVM 中的运行时数据区. 下面会详细对齐进行分析, 大部分都是理论的, 可能会有点枯燥.

运行时数据区的构成

运行时数据区的构成

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域. 这些数据区域被统称为运行时数据区. 看上图得知会分为两个区域, 线程隔离/私有与共享的.那么接下来对它们逐个进行分析学习. (其实还会有另外一个区域叫做直接内存区域. 这里先不说这个. )

在 JDK1.4 中心加入了 NIO 类, 它可以使用 Native 函数库直接分配堆外内存 (Native) 堆, 然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作. 这样能在一些场景中显著的提高性能, 因为避免了在 Java 堆与 Native 堆中来回复制数据.

先从线程隔离区域开始:

一. 线程隔离区


 

1. 虚拟机栈

也可称为 Java 栈, 是一种先进后出的数据结构.
每个 java 线程都对应一个虚拟机栈, 栈内是一个个线程需要调用的方法. 每个方法又对应一个叫栈帧的数据结构. 方法调用到执行的过程, 就对应一个栈帧在虚拟机栈中入栈和出栈的过程. 虚拟机栈的生命周期是和线程也是相同的, 是在JVM运行时创建的. 如果将虚拟机栈看过是弹夹, 那么栈帧就是弹夹内的子弹.

1.1 栈帧的构成
  • 局部变量表
    • 用来存储方法内定义的的局部变量与方法参数.(但是只能存储基本数据类型的变量与引用类型的)
    • 局部变量表的容量计量单位为 slot, 一个 slot 可以存放一个 32 位以内的数据.
    • 虚拟机通过索引的方式使用局部变量表, 索引值从 0 开始. 方法执行时, 索引为 0 的 slot 默认用于传递方法所属对象的引用, 方法中可以使用this 关键字来访问这个隐含的参数.
    • 为了节省空间, 表中的 slot 是可以重用的.
    • 随着方法调用结束后, 会随着栈帧的销毁也随之销毁, 释放空间.
  • 操作数栈

    • 操作数栈也常被称为操作栈, 是一个也是一个先进后出的栈. 方法刚开始执行的时候, 操作数栈是空的, 在方法执行的过程中, 会有各种字节码指令往操作数栈中存取数据.
    • 操作数栈的每一个元素可以是任意的 java 数据类型.
    • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配.
  • 动态连接

    • Class 文件的常量池中存在有大量的符号引用, 字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数.
    • 这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用, 这种转化成为静态解析.
    • 另外一部分将在每一次运行期间转化为直接引用, 这部分成为动态连接.

简单理解: 符号引用和直接引用在 运行时 进行解析和链接的过程, 就叫动态链接.
一个方法调用另外一个方法, 或者一个类使用另一个类的成员变量时, 需要知道它的名字, 而符号引用就相当于名字, 这些被调用者的名字就存放在 Java 字节码文件中(,class 文件).

名字是知道了, 但是Java真正运行起来的时候, 还需要靠符号引用解析成直接引用, 利用直接引用来准确的找到.

当一个.class 文件被装载到JVM内部时, 如果目标方法在编译期已确定, 并且运行时期保持不变, 这种情况下的符号引用解析成直接引用的过程叫静态链接.

如果被调用的方法在编译期无法确定, 只能在程序运行的时候才能将方法的符号引用解析未直接引用, 那么这个过程就成为动态链接, 例如多态, 只有在运行时才能确定到底调用的是哪个子类的方法.

  • 方法返回地址
    • 当一个方法被执行后有两种方式退出这个方法, 正常完成与异常完成.
    • 正常完成: 执行引擎遇到任意一个方法返回的字节码指令.
    • 异常完成: 遇到异常, 并且这个异常没有在方法体内得到处理.
    • 无论哪种方式退出, 在方法退出后, 都需要返回方法被调用的位置, 程序才能继续执行. 方法返回时可能需要在栈帧中保存一些信息, 用于恢复它的上层方法的执行状态.
    • 方法退出的过程实际上等同于将当前栈帧出栈. 因此退出时的操作可能有: 恢复上层方法的局部变量表与操作数栈, 如果有返回值则把它压入调用者栈帧的操作数栈中. 调用 PC 计数器的值以指向方法调用指令的最后一条指令.

不同操作系统的虚拟机栈的大小是受到限制的, 默认大小为 1M.

这些理论看起来很弯弯绕绕, 别着急, 后面会有具体的分析. 现在只是先了解一下它们大概功能, 存什么, 做什么.

栈帧示意图

 

2. 本地方法栈

这个与虚拟机栈相似, 它们之间的区别只不过是本地方法栈为本地方法服务.

当一个创建的线程调用 Native 方法后, JVM 不再为其在虚拟机栈中创建栈帧, JVM 只是简单的动态链接并直接调用 Native 方法. (虚拟机规范无强制规定, 各版本的虚拟机自由实现), 但是 HotSpot 版本直接将本地方法栈与虚拟机栈合二为一了.


 

3. 程序计数器

  • 程序计数器负责记录当前线程正在执行的字节码指令的地址,
  • 线程私有, 在多线程情况下, 为了让线程切换后依然能恢复到原位, 每条线程都需要有各自独立的程序计数器.
  • 不会 OOM, 程序计数器存储的是字节码文件的行号, 而这个范围是可以知道的, 在一开始分配内容时就可以分配一个绝对不会溢出的内存.
  • 如果正在执行的是 Native 方法. 这个计数器值则为空. 因为 Native 方法大多是通过 C 实现并未编译成需要执行的字节码指令, 也就不需要去存储字节码文件的行号.

 
现在说完了运行时数据区中线程隔离类型的. 下面根据一个例子来看一下他们的执行过程对内存区域的影响.
现有代码如下

public class Person {
    public int work() {
        int x = 1;
        int y = 2;
        int z = (x + y) * 10;
        return z;
    }
    public static void main(String[] args) {
        Person person = new Person();
        person.work();
    }
}

将编译的 class 文件 通过 javap -c 反编译后如下所示

public class org.study.Person {
  public org.study.Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return

  public int work();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class org/study/Person
       3: dup
       4: invokespecial #3                  // Method "":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method work:()I
      12: pop
      13: return
}

看到 Code 下面每行前面都有一个数字, 0,1,2,3,4,5 这样的数字, 这个数字是针对方法体的偏移量, 大体上可以理解这个为程序计数器, 记录字节码的地址,

  1. 根据上面说的那些, 首先是 main 方法执行, 那么将 main 方法的栈帧压入虚拟机栈.
  2. 然后将调用的 work 方法栈帧也也压入虚拟机栈.

上面两步执行完后 , 虚拟机栈结构如下


栈帧结构图
  1. 开始分析反编译出来的 work() 方法
  public int work();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

0: iconst_1 : 将 int 类型的 1入操作数栈. 为什么将下标为 0 的指向 this, 看上面 1.1 中局部变量表的说明


1: istore_1: 将操作数栈栈顶的 int 型数值存入局部变量表下标为 1 的位置.

这两个指令执行完后, 就相当于执行完了 java 代码中的 int x = 1. 那么后面的 int y = 2 也是同理.

2: iconst_2 : 将 int 型 2 入操作数栈.
3: istore_2 : 将操作数栈中栈顶 int 型数值存入局部变量表. 下标为 2 的位置.


好了,现在 int y = 2 也执行完了. 接着执行 int z = (x + y ) * 10

4: iload_1: 将局部变量表中下标为 1 的 int 型数据入操作数栈
5: iload_2: 将局部变量表中下标为 2 的 int 型数据入操作数栈

把这两个数据放入操作数栈的目的就是为了对它们进行操作..


6: iadd: 执行相加的指令. 这个指令分为 3 个步骤.

  • 先将操作数栈中栈顶的两个数据出栈
  • 执行相加
  • 将相加的结果再压入操作数栈

出栈操作



相加后入栈操作


那么执行到这里的时候, 是不是发现每次操作方法内的变量的时候, 都会先将其由局部变量表放入到操作数栈, 操作完后再将其压入到操作数栈. 那么接着向下看

7: bipush 10: 将 10 的值拓展成 int 值后压入操作数栈

为什么不是使用上面的 iconst_10 来让它入操作数栈呢?
因为在底层操作的时候, 针对值的大小, 使用的指令不同

9: imul: 相乘的指令, 这个指令与相加的指令相同也是分 3 个步骤

  • 出栈
  • 相乘
  • 入栈

10: istore_3: 将操作数栈栈顶的 int 数值存入到局部变量表中下标为 3 的位置.

那么现在 int z = (x + y) * 10 也执行完了 , 还剩下最后一个返回, 那么返回显然也属于是一个操作, 既然是一个操作, 那么就需要放到操作数栈中进行, 所以还需要将返回的值, 从局部变量表加载到操作数栈中来.

11: iload_3: 将局部变量表中下标为 3 的数值压入到操作数栈

12: ireturn: 这个就是方法返回的字节码指令. 将 work() 操作数栈中栈顶的值压入到调用者也就是main栈帧中的操作数栈中. 同时 work 栈帧出栈. 这一步就不再用图来描述了.

那么通过这个这个例子, 估计对线程隔离区中的, 虚拟机栈, 栈帧, 操作数栈, 局部变量表, 都有一定的认识了, 那么下面一起看线程共享区的方法区与堆.

二. 线程共享区


 

4. 方法区

  • 《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下: 它用于存储已被虚拟机加载的类型信息, 常量, 静态变量, 即时编译器编译后的代码缓存等.
  • 运行时常量池也是方法区的一部分.
  • 可以动态扩展, 扩展失败会抛出 OOM 异常.

Java 中的常量池, 实际上分为两种形态: 运行时常量池与静态常量池.

静态常量池: 即 *.class 文件中的常量池, class 文件中的常量池不仅仅包含字符串(数字), 字面量, 还包含类, 方法的信息, 占用了 class 文件的大部分空间.

运行时常量池: 是 JVM 虚拟机在完成类装载操作后, 将 class 文件中的常量池载入到内存中, 并保存在方法区中, 我们常说的常量池, 就是指方法区中的运行时常量池.

运行时常量池在 JDK1.8 后, 将字符串的常量放入了堆中. 常量池只保持引用.
关于常量池可参照这篇文章: JAVA常量池,一篇文章就足够入门了。(含图解)

其实在 <> 中只是规定了有 方法区这么一个概念跟它的作用. HotSpot 在 JDK 1.8 之前使用一个永久代将这个概念实现了.
但是因为存储上述多种数据很难确定大小, 导致也无法确定永久代的大小. 并且每次 Full GC 后永久代的大小都会改变, 经常抛出 OOM 异常. 所以为了更容易的管理方法区, 在 JDK1.8 后就将永久代移除, 将方法区的实现变成了元空间 Metaspace, 它位于本地内存中, 而不是虚拟机的内存中.

元空间与永久代的本质类似, 都是 JVM 规范中方法区的实现. 不过元空间与永久代之间最大的区别在于: 元空间不在虚拟机中, 而是使用本地内存. 因此, 默认情况下, 元空间的大小仅受本地内存限制, 但是可以通过参数来指定元空间的大小.

但是元空间也有可能去挤压堆空间, 假如 机器有 20G 内存, 堆设置最大分配上线为 10G, 初始 为 5G. 而设置的元空间大小为 15G, 那么堆就无法扩展到 10G, 最大也就是扩展到 5G. 日常肯定不会这样操作了, 只是说会有这样的问题.


 

5. 堆

堆区 Heap 是 JVM 中最大的一块内存区域, 几乎所有的对象实例以及数组都是在堆上分配空间(为什么说是几乎所有的对象实例? 也就是说还是有一些不是在堆中分配的, 下一章会说到), 是垃圾收集的主要区域.

为了垃圾收集器进行对象的回收管理, JVM 把堆区进行了分代管理, 细分为年轻代与老年代, 其中年轻代又分为 Eden, from ,to 三个部分. 默认比例是 8:1:1 的大小. 其中年轻代占堆区的三分之一, 剩下的都为老年代部分.

堆区大小的设置

  • Xms 堆区内存初始内存分配的大小.
  • Xmx 堆区内存可被分配的最大上限.
堆区结构

既然方法区与堆区都是线程共享的, 为什么不使用一份呢? 还需要用两个区来进行区分?
堆中存放的都是对象实例, 数组等, 这些都是需要频繁进行回收的. 而方法区内存放的内容回收难度大, 属于一种动静分离的思想. 将偏静态的放入到方法区, 将经常需要动态创建和回收的放入到堆区, 以便垃圾回收.


现在运行时数据区的内容基本分析完了, 再通过一个例子来深入的的理解运行时数据区.
代码如下.

package org.study;

class Student {
    String name;
    String sex;
    int age;
    public void setName(String name) {
        this.name = name;
    }
    public void setSex(String sex) {
        this.sex = sex;
    }
    public void setAge(int age) {
        this.age = age;
    }
}
public class JVMObject {
    public final static String SEX_MAN = "man";
    public static String SEX_WOMAN = "woman";

    public static void main(String[] args) throws InterruptedException { //栈帧
        //new Student 存入堆中
        //student1 引用, 存入栈帧中的局部变量表
        Student student1 =  new Student();
        student1.setName("李雷");
        student1.setAge(12);
        student1.setSex(SEX_MAN);
        for (int i = 0; i < 100; i++) {
            System.gc();
        }
        //new Student 存入堆中
        //student2 引用, 存入栈帧中的局部变量表
        Student student2 =  new Student();
        student2.setName("韩梅梅");
        student2.setAge(13);
        student2.setSex(SEX_WOMAN);

        Thread.sleep(Integer.MAX_VALUE); //让主线程陷入休眠
    }
}

代码中有一个静态变量 与常量, 同时在 main 方法中创建了两个 Student 对象, 在 student1 创建后, GC了 100 次. 接着创建 student2. 通过这个例子, 现在一起来更深入的理解一下整体的运行时数据区.

在运行之前为开发工具的 Run/Debug Configurations 中的 VM options设置几个参数
-XX : -UseCompressedOops 不压缩是为了方便我们更容易的看到内容中的一些内容.
-Xms30m -Xmx30m 设置堆区初始值与最大值
-XX : +UseConcMarkSweepGC 设置垃圾回收器.

image.png

  • 首先申请内存, 然后分配各个区域的内存大小.

  • 接着是类价值, 将 Student.classJVMObject.class 还有静态变量与常量放入到方法区. 如下图所示

  • 虚拟机栈开始入栈

  • Student student1 = new Student();student1 的引用放入局部变量表, 将 student1 实例放入到堆.

  • 执行 Student 内的方法, 就是不断出栈入栈的过程(操作数栈), 可以参考上面的那个例子, 这里就直接略过.

  • 执行 100 次 GC 后, 会将 student1 实例移动到老年代内.

  • 执行 student2 的逻辑, 与 student1 类似. 最后内存区域内容如下图


    接下来, 就需要来验证上面说的那些了, 这里使用到了一个工具, HSDB 是一个内存可视化工具.
    使用这个工具可以来查看我们上面代码执行完后内存区域的内容.

HSDB: 在 JDK 1.8 中支持直接调用. cd 值 jdk 的 lib 包下执行命令
sudo java -cp ./sa-jdi.jar sun.jvm.hotspot.HSDB. (我是 MAC 所以用了 sudo)

接着再使用 JPS 命令来查看我们当前 class 文件的进程 ID, (因为上面代码中逻辑是执行完后一直在休眠, 所以进程还存在).

yzhangs-MacBook-Pro:study yzhang$ jps
59169 
29523 Jps
29507 Launcher
29508 JVMObject
29335 HSDB

29508 就是进程的 ID 了. 接着在 HSDB 中选择 File -> Attach to HotSpot prcess, 在弹出的窗口中输入 29508



附加成功后出现如下界面

查看主线程的虚拟机栈, 选中 main 后点击上面的 stack memory ... 按钮

出现如下界面

  • 最左侧是栈的内存地址
  • 中间一列是该地址上存的值(大多是别的对象的地址).
  • 最右侧是 HotSpot 的说明

通过右侧的说明, 我们是不是看到了 JVMObject.main 方法的栈帧


其实说白了, 虚拟机栈帧应该就是对内存中物理地址的一种虚拟化.
在 main 方法栈帧上面还看到了另外一个 Thread.sleep 方法的栈帧, 这是一个本地方法, 同时也验证了上面在本地方法栈中说的 Hotspot 直接将本地方法栈与虚拟机栈合二为一.

接下来去看方法区中的 class. 在 HSDB 顶部菜单中选择 Object Histogram

出现下面界面, 在搜索栏中输入包名. 就看到了方法区内的 Student.class


image.png

双击这个 Student 进入到下一个界面.



这就是创建的 韩梅梅与李雷, 那么怎么证明这两个对象实例是在堆中而不是方法区呢?
继续在顶部菜单 Tools 中找到 Heap Parameters 显示堆的信息

image.png

看出新生代中的内存地址如下

eden : 0x000000011a400000, 0x000000011a53ceb0, 0x000000011ac00000

from : 0x000000011ac00000, 0x000000011ac00000, 0x000000011ad00000

to : 0x000000011ad00000, 0x000000011ad00000, 0x000000011ae00000

老年代的内存地址: 0x000000011ae00000 到 0x000000011c200000

新生代都是有三个地址组成. 分别表示 内存起始地址使用空间结束地址整体空间结束地址

不难看出,在新生代中只有 Eden 区的起始地址和使用空间结束地址不相同(分配有对象), 而 from 区和 to 区的使用空间地址和起始地址相同(空使用区域).

我们将这些起止地址与结束地址放到第二个例子的堆区的图上, 然后再根据两个 Student 对象的地址的, 来找一下他们对应的位置.

韩梅梅的地址为: 0x000000011a400000 刚好对应到新生代中 Eden 中的地址.
李雷的地址为 : 0x000000011ae6bf98 也在老年代的 0x000000011ae00000 到 0x000000011c200000之间.
证明我们的图是正确的, 也证明了, 创建的这两个对象实例确实是在堆内.

最后再来确定一下栈帧中存放的到底是不是两个堆中对象实例的引用.



这里也证实了, 在栈帧中的局部变量中如果存放的是对象的话, 存放的是引用. 指向堆中的地址.


 

三. 虚拟机的优化技术

  1. 编译优化 - 方法内联

    通过一段代码来演示

    public class Test{
          public static boolean max(int a, int b){
              return a > b;
          }
          public static void main(string[] args){
                max(1,2);
          }
    }
    

    这段代码在运行的时候, 会为 max 方法创建一个栈帧, 然后执行入栈出栈等操作. 其实如果是上面这种在 max 方法内已经是确定了的表达式, 那么在编译的时候虚拟机会直接将目标方法原封不动的放到调用方法中来, 那么编译后类似下面的伪代码

    public class Test{
        public static boolean max(int a, int b){
            return a > b;
        }
        public static void main(string[] args){
                //max(1,2);
                boolean result = 1 > 2
        }
    }
    

    避免了方法的调用, 也就避免了创建栈帧, 入栈, 出栈, 等操作. 带来了性能上的一些提升.
    在开发中避免一个方法中写大量的代码, 习惯使用小方法体..
     

  2. 栈的优化 - 栈帧之间的数据共享
    两个栈帧之间数据共享, 主要体现在方法调用中有参数传递的情况, 上一个栈帧的部分局部变量表与下一个栈帧的操作数栈共用一部分空间, 这样既节约了空间. 也避免了参数的复制传递
    还是以一段代码为例

    public class TestStackFrame {
      public static void main(String[] args) throws InterruptedException {
          TestStackFrame testStackFrame = new TestStackFrame();
          testStackFrame.add(1);
    
      }
    
      private void add(int a) throws InterruptedException {
          int result = a + 1;
          Thread.sleep(Integer.MAX_VALUE);
      }
    }
    

    这个又需要用到 HSDB 工具来查看栈帧信息了.



    发现其中栈帧 main 的操作数栈与 add 栈帧的局部变量表的区域中有一部分是重复的. 这就表示了重复的那部分内存区域是共用的.

  3. 逃逸分析/指令重排序/锁消除/锁优化
    这个在从 Synchronized 到锁的优化 一文中已经说过. 不再复述.

4.栈上分配 (下一章会分析到)


 

四. 常见的内存溢出

常见的内存溢出分为以下几种

  • 栈溢出: 栈溢出分为两种, 一种是 StackOverFlowError 一种是 OutOfMemory.
    • StackOverFlowError: 当有一个方法递归调用自己, 不断的虚拟机栈中创建栈帧, 由于栈的深度有有限制的, 这样就会引发 StackOverFlowError
    • OutOfMemory: 假如有 1000 个线程同时执行, 但是机器内存只有 500M了, 而在 hotspot 上每个栈帧都是固定大小, 假如每个栈帧占用 1M, 那么就会出现 OOM.
  • 堆溢出: 这是最常见的情况, 比如我们设置了堆的大小为 30M,初始也为 30M, 假如我们在程序中声明了一个 35M 的数组, 那么也会抛出 OOM.
  • 方法区溢出: 使用 cglib 不断的编译代码, 然后限制方法区的大小, 设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m , 就会导致方法区的内存溢出. java.long.outofmemoryError.Metaspace
  • 本质接内存(堆外内存)溢出: 使用 NIO 的时候, 限制直接内存的大小为 100m, -XX:MaxDirectMemorySize=100m ,然后使用 NIO 中的 ByteBuffer.allocateDirect 分配一个 128M 的数组. 则也会抛出 java.long.outofmemoryError: Direct buffer memory

 

五. 总结

从功能上来对比堆和栈

  • 栈是以栈帧的方式存储方法调用的过程, 并存储方法调用过程中基本数据类型的变量以及对象的引用变量, 其内存分配在栈上, 变量出了作用域就会自动释放
  • 堆内存用来存储 java 中的对象实例, 无论是成员变量, 局部变量, 还是类变量, 它们指向的对象都存储在堆中.

从线程独享还是共享上来对比

  • 栈内存归属于单个线程, 每个线程都会有一个栈内存, 其存储的变量只能其所属的线程中可见, 即栈内存可以理解成线程的是有内存.
  • 堆内存中的对象对所有线程可见, 同时可以被所有线程访问.

你可能感兴趣的:(跟我一起分析学习 JVM 内存模型)