Android技术知识Android开发Android开发

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

2021-03-26  本文已影响0人  __Y_Q

建议按照顺序阅读

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


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

运行时数据区的构成

运行时数据区的构成

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

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

先从线程隔离区域开始:

一. 线程隔离区


 

1. 虚拟机栈

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

1.1 栈帧的构成

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

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

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

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

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

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

栈帧示意图

 

2. 本地方法栈

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

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


 

3. 程序计数器


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

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."<init>":()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 "<init>":()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 中的常量池, 实际上分为两种形态: 运行时常量池与静态常量池.

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

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

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

其实在 <<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 的大小. 其中年轻代占堆区的三分之一, 剩下的都为老年代部分.

堆区大小的设置

堆区结构

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


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

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

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 ... 按钮

出现如下界面

通过右侧的说明, 我们是不是看到了 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.栈上分配 (下一章会分析到)


 

四. 常见的内存溢出

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


 

五. 总结

从功能上来对比堆和栈

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


上一篇 下一篇

猜你喜欢

热点阅读