JVM(六)——各个内存划分详细总结

前言

前面一篇小结博客,简单总结了JVM最终的划分结果,并根据官网的描述看到了JVM为何如此描述,这一篇博客就在那上面进一步总结一下各个区域的作用和结构。

Java虚拟机栈和栈帧

上一篇博客中关于方法调用,为何有虚拟机栈,以及虚拟机栈中每一个方法调用的操作是如何完成的,我们附上了这样一张图。

但是,一个方法包含什么,或者说这个虚拟机栈中的栈帧包含什么,这个是我们需要进一步深入学习和探索的。这就是其中栈帧的内容。

官网中有这样一句话:A Java Virtual Machine stack stores frames一个Java虚拟机栈中存储frames,关于frames在官网中有如下两段描述:

A *frame* is used to store data and partial results, as well as to perform dynamic linking, return values for methods, and dispatch exceptions.

A new frame is created each time a method is invoked. A frame is destroyed when its method invocation completes, whether that completion is normal or abrupt (it throws an uncaught exception). Frames are allocated from the Java Virtual Machine stack of the thread creating the frame. Each frame has its own array of local variables, its own operand stack, and a reference to the run-time constant pool of the class of the current method.

可以看出,每个栈帧包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向常量池的引用(a reference to the run-time constant pool)、方法返回地址(Return Address)和其他附加信息。栈帧在方法调用的时候创建,在方法调用结束的时候销毁。

局部变量表(Local Variables)

方法中定义的局部变量以及方法的参数均会存储在这个表中,局部变量表中的变量不可直接使用,如果需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。

操作数栈(Operand Stack)

以压栈和出栈的方式存储操作数的数据结构

动态链接(Dynamic Linking)

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。这里可与多态联想起来。

方法返回地址(Invocation Completion)

当一个方法执行完之后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;另一种时遇到异常,并且这个异常没有在方法体内部得到处理。异常官网中会有正常返回和异常返回两种介绍。

整体结构

总体来看我们看到的栈帧数据结果如下所示:

在这里插入图片描述

进一步的理解

针对普通数据类型的栈帧数据变化,在之前的一篇博客中已经总结过,这里不再赘述——栈帧数据中的变化。针对具体的字节码指令,后续会单独总结。针对引用类型的数据,栈帧中的局部变量会有一个指向。指向何处?如果在栈帧中有一个变量,类型为引用类型,比如Object test = new Object(),这时候obj如果作为一个方法中的局部变量,就是典型的栈中元素指向堆中的对象,如下图所示:

在这里插入图片描述

如果上述的test变量变成静态变量,private static Object test = new Object();我们可以知道静态变量是分配在方法区中的(上篇总结博客中提到过)。但是new Object()生成的对象是在堆上分配的,这就是典型的方法区中元素指向堆
在这里插入图片描述
上面两段我们总结发现虚拟机栈帧中会有可能指向堆,方法区也有可能指向堆,堆中应该不可能指向虚拟机栈,**那么有没有可能堆中会有引用指向方法区呢?**针对这个问题我们先从一个对象在内存中的布局说起。

先聊聊对象的内存布局

这里先抛出一个问题,一个对象怎么知道自己属于那个类?(所以,想到这里可以很明确了,肯定有堆指向方法区的引用)。如果把我们自己放在JVM设计师的角度,我们是不是在需要在对象中维护一个属性,用于标明当前对象是属于那一个Class对象的?但是我们并不需要闲式的申明这个字段。这个就和Java对象的内存布局有关了(即一个Java对象在JVM内存中的具体结构)。如下图所示:
在这里插入图片描述
通常我们打交道最多的,只是实例数据部分,其实一个对象在内存中还有对象头和对齐填充部分。上图比较详细的呈现了各个部分的组成。

对象头

分为Mark Word、Class Pointer和Length,Length只是数组对象的时候才有,前两者所有对象都有。

这里以64位系统为例,Mark Word占8个字节,其中包含这个对象的哈希码。分代年龄,以及锁状态的标志(具体的结构之前在研究并发编程的时候总结过)。其中分代年龄用于标记这个对象经过了几次GC

Class Pointer占8字节,这个就指向对象对应的类元数据的内存地址。这里就可以回答了,对象就是通过这个区域指向了方法区,用于标记本对象属于那个Class。

这里可以联想一下如下代码:new Object().getClass();这个不就是通过操作Class Pointer对象头,然后通过Class Pointer来获取这个对象所属类的原信息么?

走到这一步我们可以来一个总结

一个小结

到目前为止,我们通过虚拟机栈帧中的操作数栈串起了之前的内存模型,发现方法区,堆,虚拟机栈三个存储区域之间的关系。虚拟机栈中栈帧的操作数栈如果存放引用对象,内存地址会指向堆内存。针对静态变量,如果是一个普通的对象类型,则会存在方法区指向堆内存的引用,针对对象头中的Class Pointer就是堆指向方法区的引用

堆和方法区的划分

一步步解构堆的划分

下面正式进入堆和方法区的详细总结,方法区在官网中也有另外一个名字叫做“非堆”。看下面的总结我们尽量将自己放在JVM设计者的角度来思考。
前面我们已经知道,JVM的对象模型是堆和方法区的组合,且这两者的生命周期都是在JVM启动的时候就开始了。可以简单理解为如下的模型
在这里插入图片描述
这是一个简陋的模型,简陋的划分。通过前面的分析我们可以知道,每次分配的对象都会分配在堆上,于是我们的模型可理解为如下所示:
在这里插入图片描述
这样似乎没有毛病,对象正常的在堆上分配了。但是,有一个问题,如果堆的空间非常大,但是总会有被占满的时候,如果新对象无法在堆上进行分配了,就需要JVM进行垃圾回收的时候,是不是就很苦逼了,JVM垃圾回收线程需要遍历一块非常大的堆空间。因此我们需要根据GC年龄来对堆进行分区。于是我们可以将其分为老年代和新生代,如果某些对象扛过了很多次GC回收,则我们就可以将其移动到老年区,针对新生代GC会频繁一点,这样我们每次GC的耗时会整体上缩短。
到这里我们知道了第一种进入老年代的操作:GC年龄达到阈值
我们继续来看,如果新生代中对象很多,这个时候我们需要分配一个更大的对象,但是新生代中已经没有空间承接这个对象,这个时候,这个对象就会直接被分配在老年代
到这里我们知道了第二种进入老年代的操作:对象过大,新生代无法分配
到现在为止我们可以看到我们的JVM内存会变成如下所示:
在这里插入图片描述
这样划分就够了么?就完美了么?似乎并没有。
设想一下:如果新生代中有很多对象,这个时候总的可用空间有10M,但是这些空间都不连续,最大的可用连续空间为6M,如果需要分配一个8M的对象,这个时候必然会触发GC,触发GC之后有些对象会去老年区,新生代能勉强完成这次分配,但是后续如果继续分配肯定会触发频繁的GC,这样业务代码还有多少时间去执行呢,同时还有一个问题,其实应用程序中大部分的对象生命周期都比较短(这个原因在官网说明的是:统计如此)如果频繁的GC,业务线程执行耗时会非常长,这并不是一个好的体验,因此新生代还需进一步划分。

洋洋洒洒说了这么一个复杂的问题,其实问题无非就是所谓的内存碎片空间造成的,也就是说上面的划分方式并不是完美的,我们依旧要进行优化,依旧要进行思考,至少要一定程度上解决内存碎片所带来的问题。我们可以对新生代进一步划分,将其划分为Eden区和survivor区。

有了survivor区之后,如果新生代中有一定GC年龄的对象,可以先移动到survivor区,这样新生代连续的空间就稍微变多了,同时在GC操作之后又可以缓冲一下,一定程度上缓解了老年代频繁GC的问题。但是survivor区为毛是两块相等的内存空间?

其实这个是为了解决内存碎片的问题,试想如果只有一个survivor,eden区满了之后触发GC垃圾回收,扛过GC的对象会被放到survivor区中,多次GC之后,survivor区也会存在内存碎片的问题。因此,如果只有一个survivor区,只是减少了一定的GC次数,但是并没有优化内存碎片的问题,所以我们可以引入两个survivor区,且两个survivor区之间采用复制回收算法,这样就能解决内存碎片的问题。

因此可以如下划分
在这里插入图片描述
总结一下:survivor区的出现是为了缓解eden区的碎片,survivor区出现两个,同时采用复制回收算法,是为了解决survivor区的内存碎片问题。默认情况下eden与s0和s1之间的比例是8:1:1,同时这里也会出现第三种进入到老年代的情况:S0区不够的时候,也需要从老年代借一些空间来进行分配(这就是所谓的担保机制),如果S区中的对象GC age大于了指定值,也会移动到老年代

因此,经典的内存划分如下图所示:
在这里插入图片描述

再次小结一下

走到这里,我们可以尝试从对象分配的角度来看问题了,引入了eden区,survivor区,老年代之后,一个对象的分配到底需要经过多少次判断?到底是怎么分配的。网上有这样一段话,我认为是最通俗的解释。

我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的‘From’区,自从去了Survivor区,我就开始漂了,有时候在Survivor的‘From’区,有时候在Survivor的‘To’区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯 了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。

用图表示如下:
在这里插入图片描述

这是我到目前为止遇到过最通俗的对象分配的介绍,我自己总结似乎也无法总结的更好,这里就直接引用了。

GC操作

其实有各种GC操作,之前在总结垃圾收集器的时候,我们并没有总结这一个点。

Young GC——对新生代进行垃圾回收操作(包含了Eden和S区的回收)通常称为Minor GC

Old GC——通常称为Major GC,Major GC通常会伴随着Minor GC。

Full GC——即Young GC+Old GC。上面已经说过,Major GC 通常会伴随着Minor GC,这句话其实就是说的是如果对老年代进行GC操作,会引发Full GC ,而Full GC是会暂定正常的业务线程的(STW)因此我们要尽量的去减少触发Full GC的操作

一些实例

工具——visual GC

visual GC是visual vm的一个插件,这个插件可以可视化对Java应用程序的堆和方法区进行分析,具体的安装使用可以参考大牛的博客——visual VM & visual GC的介绍和使用

代码实例

上一篇总结过,除了程序计数器无法产生溢出,好像其他的都区域都有产生溢出的可能。这里就通过代码实例来证明,下述代码都是基于springboot准备的。

堆溢出

准备如下代码

package com.learn.springbootjvm.controller;

import com.learn.springbootjvm.domain.Person;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;

/**
 * 堆上分配对象
 */
@RestController
public class HeapController {
    List<Person> list=new ArrayList<Person>();
    @GetMapping("/heap")
    public String heap() throws Exception{
        while(true){
            list.add(new Person());
            Thread.sleep(1);
        }
    }
}

通过如下虚拟机参数运行:

-Xms10M -Xmx10M -Xmn10M

启动对应的项目之后,通过 localhost:9090/heap访问,然后启动visual GC插件,会看到如下情况。
在这里插入图片描述

一段时间之后,后台程序会出现OOM异常。
在这里插入图片描述

方法区溢出

之前探讨过,方法区存放的是类的元数据,因此我们需要有一段代码不断的构造类实例。

如下所示:不断产生类的字节码

package com.learn.springbootjvm.utils;


import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

import java.util.ArrayList;
import java.util.List;

public class MetaspaceUtil extends ClassLoader {

    public static List<Class<?>> createClasses() {
        List<Class<?>> classes = new ArrayList<Class<?>>();
        for (int i = 0; i < 10000000; ++i) {
            ClassWriter cw = new ClassWriter(0);
            cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null,
                    "java/lang/Object", null);
            MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>",
                    "()V", null, null);
            mw.visitVarInsn(Opcodes.ALOAD, 0);
            mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object",
                    "<init>", "()V");
            mw.visitInsn(Opcodes.RETURN);
            mw.visitMaxs(1, 1);
            mw.visitEnd();
            MetaspaceUtil test = new MetaspaceUtil();
            byte[] code = cw.toByteArray();
            Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);
            classes.add(exampleClass);
        }
        return classes;
    }
}

下面的代码就会模拟非堆的溢出

/**
 * 在方法区分配,元空间工具类中会频繁的构建Class对象
 -XX:MetaspaceSize=50M -XX:MaxMetaspaceSize=50M
 */
@RestController
public class NonHeapController {

    List<Class<?>> list=new ArrayList<Class<?>>();
    @GetMapping("/nonheap")
    public String heap() throws Exception{
        while(true){
            list.addAll(MetaspaceUtil.createClasses());
            Thread.sleep(5);
        }
    }
}

虚拟机栈溢出

这里直接上实例代码

package com.learn.springbootjvm.stack;

public class StackOverFlowDemo {

    public static long count=0;

    public static void method(long i){
        System.out.println(count++);
        method(i);
    }

    public static void main(String[] args) {
        method(1);
    }
}

这段代码直接执行即可。这里絮叨一下关于StackOverFlow的问题,Stack 的大小,可以通过-Xss参数来设置,但是这个参数如何设定其实是有讲究的。

线程栈的大小是一个双刃剑,如果设置过小,出现栈溢出的可能性就非常大(特别是在该线程内有递归,大循环的时候)。如果设置过大,就会影响到创建线程的数量(如果是多线程的应用,就会出现内存溢出的错误)。

总结

这篇博客在上一篇博客的基础上,重新总结了对象在内存中的布局,到这个时候,我们可以清晰的理解这张图了
在这里插入图片描述
但是还不够,java虚拟机还有垃圾收集器和字节码指令以及执行引擎等待我们去学习和总结。后面继续总结

发布了129 篇原创文章 · 获赞 37 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/liman65727/article/details/103796649
今日推荐