深入学习JVM: (2) Jvm的内存模型

·  阅读 239

一. 前言

深入学习Jvm的第二篇文章, 依然作为总结, 可能会有点儿乏味, 但却是面试及虚拟机调优的必备知识. 话不多说, 进入正题.

二. Jvm内存模型大致划分

先直接来张图:

Jvm内存区域划分.png

相信这张图很多人都不陌生, 也可能有部分同学只听说过堆和栈的. 不过没关系, 听在下娓娓道来即可, 先简单给大伙儿描述一下上图中每个区域大概是用来干什么的(会刻意省略掉部分复杂的东西, 目的是先有个印象, 便于后面知识的理解):

  1. : 通常用来存放我们的局部变量表, 如果是基础数据类型的变量, 放在栈中. 如果是对象类型的变量, 则实际保存在堆中, 栈中只存放该对象的引用地址.
  1. : 存放对象
  1. 方法栈: 给本地方法用的栈, 也就是native方法, 如果你看过Thread的类的start()方法, 就会发现跟踪到底, 调用了一个带有native修饰符的start0()方法, 这意味着它是跨语言调用, 通常是调用C或C++的函数库.
  1. 方法区: 我们的常量, 静态变量, 以及类元信息都在方法区, 这个区也叫元空间. 什么类元信息? 如果看过在下的上一篇文章, 你可能会有所了解. 这个类元信息就是类加载到jvm内存后所产生的、关于该类的所有信息. 需要注意的是, 这块区域使用的是直接内存, 而不是划分给虚拟机的内存.
  1. 程序计数器: 存放Java字节码执行到哪儿了, 可以粗劣的理解成我们debug时的行号. 这个东西可也是至关重要的, 试想一下多线程的情况你就明白了, 这么多的线程, 涉及到挂起和线程的上下文切换, 每个线程如何知道被唤醒后下一行该执行哪行代码呢?
  1. 类装载子系统: 用来将类加载到Jvm的.
  1. 字节码执行引擎: 顾名思义, 执行字节码的嘛.

相信大家注意到那两个色块儿了, 线程独有和线程共享, 什么意思呢?
线程独有: 会给每个线程单独分配一小块内存空间 线程共享: 所有线程共享一块内存 这样说可能不是很理解, 没关系, 请继续看下文, 因为很多知识需要看完之后加到一块儿才能理解.

三. 细说Jvm栈

先来个简单的代码:

/**
 * 简单的java程序, 用于说明栈的关系
 *
 * @Author: deadline
 * @Date: 2021-02-27 18:33
 */
public class JvmTestForStack {

    public int count() {
        int a = 1;
        int b = 2;
        int c = a + b;
        return c;
    }

    public static void main(String[] args) {
        JvmTestForStack jvmTestForStack = new JvmTestForStack();
        jvmTestForStack.count();
    }
}
复制代码

再来张图:

栈内存区域.png

代码和图呢, 它们是一伙儿的, 把它们结合起来仔细看看, 我想你应该已经理解的差不多了, 不过我还是打算再多讲讲:

可能你听说过栈这种数据接口, 先入后出嘛, Jvm内存中的栈也是先入后出的. 之前说过栈是每个线程都单独具备的, 意思是, 每个线程都会分配到一小块栈内存空间, 如上图. 说到这里, 又不得不说栈帧, 什么是栈帧呢? 每个方法被调用时, 就会压入一小块儿内存空间到该线程的栈内存中, 这一小块儿内存空间的名字就叫做栈帧. 这个压入的动作就像往弹夹中压入子弹一样, 先压入的最后击发. 根据上述代码, main()方法是最先入栈的, 所以它的栈帧在栈底, 随后是count()方法, 当count()方法执行完毕, 分配给它的栈帧会立马销毁, 这个动作叫做出栈.

结合上述, 图中的大部分内容我相信你已经看懂了, 不过作为总结...这显然不够, 所以我要再写写操作数栈, 以及动态链接

说到这里, 就得提一个java指令: javap, 这是一个用来反编译class字节码的指令, 使用方式: javap -v xxx.class, 下面是使用该指令反编译上述代码的部分代码:

Constant pool:
   #1 = Methodref          #5.#27         // java/lang/Object."<init>":()V
   #2 = Class              #28            // jvm/JvmTestForStack
   #3 = Methodref          #2.#27         // jvm/JvmTestForStack."<init>":()V
   #4 = Methodref          #2.#29         // jvm/JvmTestForStack.count:()I
   #5 = Class              #30            // java/lang/Object
   #6 = Utf8               <init>
   #7 = Utf8               ()V
   #8 = Utf8               Code
   #9 = Utf8               LineNumberTable
  #10 = Utf8               LocalVariableTable
  #11 = Utf8               this
  #12 = Utf8               Ljvm/JvmTestForStack;
  #13 = Utf8               count
  #14 = Utf8               ()I
  #15 = Utf8               a
  #16 = Utf8               I
  #17 = Utf8               b
  #18 = Utf8               c
  #19 = Utf8               main
  #20 = Utf8               ([Ljava/lang/String;)V
  #21 = Utf8               args
  #22 = Utf8               [Ljava/lang/String;
  #23 = Utf8               jvmTestForStack
  #24 = Utf8               MethodParameters
  #25 = Utf8               SourceFile
  #26 = Utf8               JvmTestForStack.java
  #27 = NameAndType        #6:#7          // "<init>":()V
  #28 = Utf8               jvm/JvmTestForStack
  #29 = NameAndType        #13:#14        // count:()I
  #30 = Utf8               java/lang/Object
复制代码

这个被称之为常量池, 可以看处, 它似乎把我们的java代码分解成了一个一个的符号, 比如#19的main符号, 以及#7的()V符号. 这些符号是存放在方法区中的, 再看下方反编译后的代码:

public int count();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: istore_3
         8: iload_3
         9: ireturn
复制代码

这就是count()方法反编译后的代码, 我们从 0: iconst_1 这行代码开始看, 结合上方的栈内存图, 就可以看出count()方法在底层究竟是怎么做的.

有以上铺垫, 现在可以详细说说了:

动态链接: 认真观察一下上面的常量池反编译代码的 #4 = Methodref #2.#29 这一行, 这个#2.#29是什么意思呢? 这个就是符号链接, #2链接着#28, #28 = jvm/JvmTestForStack; #29链接着#13和#14, #13 = count, #14 = ()I; 那么链接起来, 就变成了 jvm/JvmTestForStack.count()I 这行代码, 之前说过, jvm执行到这行代码仍然不知道count()方法具体有哪些字节码呀, 所以底层执行时, 还需要转换一次链接, 也就是把这些个符号链接, 转换为直接链接, 让jvm知道走到这行代码时, 应该去内存中的哪个位置拿到可执行的jvm字节码.

操作数栈: 经过上面说的动态链接, jvm字节码执行引擎找到了count()方法具体的字节码, 这些字节码jvm可以看懂, 并会根据规则再次转成计算机可以看懂的汇编语言, 然后执行. 而我们的计算机它只认识0和1, 所以就有了上面栈内存图中的...将操作数压栈出栈运算等操作.

再次强调: 对于基本数据类型, 也就是int, double, boolean等类型, 它们的值是直接存放在栈中的. 而对于对象, 大多数时候, 栈中存放的仅仅是一个引用, 真正的值存放在堆中.

四. 细说Jvm堆

仍然是先上图:

堆内存区域及gc过程.png

我们的堆内存区域被划分成了两段: 分别是年轻代和老年代, 年轻代占整个堆内存区域的1/3, 老年代占2/3. 年轻代又别划分为Eden区和幸存区(Survivor区), Eden区占年轻代的8/10, 两个Survivor各占1/10;

一般情况下, 新new的对象会被放到Eden区, Eden区放满则执行minor gc, 进行垃圾回收, 那什么样的对象会被视为垃圾对象呢? 其实就是没有任何引用的对象, 无法再通过任何变量访问的对象. 整个过程呢, 图中已经画的很清晰了, 不过多解释了. 需要注意的是, 除了图中提到的对象动态年龄判断机制, 还有很多种情况会将对象移动到老年代, 会在下一篇文章中细说. 当老年代被放满, 则会触发full gc.

full gc为什么那么慢?

在进行full gc的时候, Jvm会执行一个STW的机制, 全称stop the world. 停止所有用户线程, 进行full gc. 为什么要有stw机制? gc的算法有很多种, 但都需要标记出哪些是垃圾对象或非垃圾对象, 所以我的猜测是, 如果一边进行垃圾回收, 一边又有新的垃圾对象产生...就像遍历一个集合时, 又有其它线程不停的在新增或者修改集合中的值, 那么将会产生很严重的后果, 可能永远都遍历不完, 也可能发生线程安全问题等等等等.

五. Jvm内存参数配置

一一一然是先上图:

Jvm内存参数.png

上图即为Jvm中各个区域的内存配置参数, 上图中的值仅是示例值, 具体大小请根据自身系统的业务情况来定, 不知道该配置多大的同学也可以不配置, java默认的大小已经足够大多数系统使用了, 想要了解或学习如何合理的配置Jvm内存各个区域的大小, 请期待在下的下一篇文章.

完整的参数示例: java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar test.jar
此外还有一个需要注意的地方, 相信大家注意到我反复的再提方法区使用的是直接内存. 方法区的默认大小是21M, 当放满之后, 会执行full gc. 然后根据gc的结果, 动态的水平扩缩容该区域的大小, 如果gc释放的量较大, 则缩小该空间; 如果释放的量较小, 则放大该空间(不会大于MaxMetaspaceSize设置的值); 如果没有设置该值, 则没有限制, 且程序启动时, 就会执行好几次full gc

对了, 根据本文所述, 所以模拟栈溢出, 无限递归调用方法即可, 模拟堆溢出, 弄个集合不停new对象即可, 嘿嘿...^^

今天的总结和分享就到这里, 如果有说的不对的地方, 还请大家不吝赐教.

分类:
后端
标签: