深入JVM-内存模型

本文讨论以 JDK8 版本展开

Java虚拟机栈

栈帧

栈帧:栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和附件信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

栈对应线程,栈帧对应方法

在活动线程中, 只有位于栈顶的帧才是有效的。称为当前栈帧,正在执行的方法称为当前方法,定义当前方法的类是当前类。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。而StackOverflowError 表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。
虚拟机栈通过pop和push的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另一个栈帧上。在执行的过程中,如果出现了异常,会进行异常回溯,返回地址通过异常处理表确定。
如果当前方法调用另一个方法完成,则该方法将不再是当前方法。调用方法时,将创建新栈帧,并在控制权转移到新方法时对应的栈帧。在方法返回时,当前栈帧将其方法调用的结果(如果有的话)传递回前一帧。当前一帧变为当前帧时,当前帧将被丢弃。
在这里插入图片描述

局部变量

每个栈帧都包含一个称为局部变量的变量数组,存放方法参数和方法内部定义的局部变量的区域。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。
Java虚拟机使用局部变量在方法调用时传递参数。在类方法调用时,所有参数都将从连续的局部变量(从局部变量0开始)传递。在调用实例方法时,始终使用局部变量0将引用传递给在其上调用实例方法的对象。随后将任何参数传递到从局部变量1开始的连续局部变量中。

public int test(int a, int b) {
    Object obj = new Object();
    return a + b;
}

如果局部变量是Java的8种基本基本数据类型,则存在局部变量表中,如果是引用类型。如new出来的String,局部变量表中存的是引用,而实例在堆中。
在这里插入图片描述

操作数栈

每个栈帧均包含一个后进先出(LIFO)堆栈,称为其操作数堆栈。框架的最大操作数堆栈深度是在编译时确定的。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。当JVM为方法创建栈帧的时候,在栈帧中为方法创建一个操作数栈,保证方法内指令可以完成工作。
创建包含操作数堆栈的栈帧时,该操作数堆栈为空。Java虚拟机提供了将局部变量或字段中的常量或值加载到操作数堆栈上的指令。其他Java虚拟机指令从操作数堆栈中获取操作数,对其进行操作,然后将结果压回操作数堆栈。操作数堆栈还用于准备要传递给方法的参数并接收方法结果。

public class OperandStackTest {

    public int sum(int a, int b) {
        return a + b;
    }
}

编译生成.class文件之后,再反汇编查看汇编指令

> javac OperandStackTest.java
> javap -v OperandStackTest.class > 1.txt

例如,IADD 指令将两个int值加在一起。它要求int将要相加的值是操作数堆栈的前两个值,并由前面的指令压入该值。这两个int值都从操作数堆栈中弹出。将它们相加,然后将它们的总和推回操作数堆栈。子计算可嵌套在操作数堆栈上,从而产生可被包含计算使用的值。

  public int sum(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3 // 最大栈深度为2 局部变量个数为3
         0: iload_0 // 从[局部变量0]中装载int类型值入栈
         1: iload_1 // 从[局部变量1]中装载int类型值入栈
         2: iadd    // 将栈顶元素弹出栈,执行int类型的加法,结果入栈
         3: ireturn //从方法中返回int类型的数据
      LineNumberTable:
        line 10: 0
动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

动态链接将这些符号引用转换为具体的方法引用,根据需要加载类以解析尚未定义的符号,并将变量访问转换为与这些变量的运行时位置关联的存储结构中的适当偏移量。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且 这个异常没有在方法体内得到处理。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

  1. 返回值压入上层调用栈帧
  2. 异常信息抛给能够处理的栈帧
  3. PC 计数器指向方法调用后的下一条指令

Java对象内存布局

Java虚拟机栈的局部变量中引用类型是指向堆中的对象,元数据区中的静态变量如果是引用类型也会指向堆中的对象。
那堆中的对象会指向元数据区嘛?元数据区中会包含类的信息,堆中会有对象,那怎么知道对象是哪个类创建的呢?

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充。
在这里插入图片描述

对象头

Mark Word:对象自身的运行时数据(Mark Word)

  • 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
  • 该部分数据被设计成1个非固定的数据结构 以便在极小的空间存储尽量多的信息(会根据对象状态复用存储空间)

Class Pointer:对象类型指针

  • 即对象指向它的类元数据的指针
  • 虚拟机通过这个指针来确定这个对象是哪个类的实例

Length:保存数组长度

  • 如果对象是数组,那么在对象头中还必须有一块用于记录数组长度的数据。
  • 因为虚拟机可以通过普通Java对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。

实例数据

存储的信息:对象真正有效的信息

  • 包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节(64位系统中是8个字节)。

这部分数据的存储顺序会受到虚拟机分配参数(FieldAllocationStyle)和字段在Java源码中定义顺序的影响。

// HotSpot虚拟机默认的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
// 从分配策略中可以看出,相同宽度的字段总是被分配到一起
// 在满足这个前提的条件下,父类中定义的变量会出现在子类之前

CompactFields = true;
// 如果 CompactFields 参数值为true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

对齐填充

存储的信息:占位符

  • 占位作用
  • Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。

总结

在这里插入图片描述

内存模型

在这里插入图片描述

认识

  • 一块是非堆区,一块是堆区。
  • 堆区分为两大块,一个是Old区,一个是Young区。
  • Young区分为两大块,一个是Survivor区(S0+S1),一块是Eden区。Eden:S0:S1=8:1:1 S0和S1一样大,也可以叫From和To。

Eden区

一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。

比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了 100M或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect), 这样的GC我们称之为Minor GC,Minor GC指得是Young区的GC。
经过GC之后,有些对象就会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor 区,然后再清空Eden区中的这些对象。

Survivor区

Survivor区分为两块S0和S1,也可以叫做From和To。 在同一个时间点上,S0和S1只能有一个区有数据,另外一个是空的。

接着上面的GC来说,比如一开始只有Eden区和From中有对象,To中是空的。 此时进行一次GC操作,From区中对象的年龄就会+1,我们知道Eden区中所有存活的对象会被复制到To区,From区中还能存活的对象会有两个去处。
若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区,此时Eden区和From区没有达到阈值的 对象会被复制到To区。 此时Eden区和From区已经被清空(被GC的对象肯定没了,没有被GC的对象都有了各自的去处)。
这时候From和To交换角色,之前的From变成了To,之前的To变成了From。也就是说无论如何都要保证名为To的Survivor区域是空的。
Minor GC会一直重复这样的过程,知道To区被填满,然后会将所有对象复制到老年代中。

Old区

一般Old区都是年龄比较大的对象,或者相对超过了某个阈值的对象。如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代。

在Old区也会有GC的操作,Old区的GC我们称作为Major GC。

默认阈值是15,我们可以通过JVM参数设置这个阈值。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代中最大可用连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试进行一次Minor GC。如果小于或者HandlePromotionFailure设置为不允许,那这时就改为一次Full GC。
分配担保解释:
新生代使用复制算法完成垃圾收集,为了节约内存Survivor的设置的比较小,当Minor GC后如果还有大量对象存活,超过了一个Survivor的内存空间,这时就需要老年代进行分配担保,把Survivor中无法容纳的对象直接进入老年代。若虚拟机检查老年代中最大可用连续空间大于新生代所有对象总空间那么就能保证不需要发生Full GC,因为老年代的内存空间够用。反之,如果老年代中最大可用连续空间小于新生代所有对象总空间就需要在尝试Minor GC失败后进行Full Gc或者直接Full GC。

调优

命令解释
-XX:NewSize和-XX:MaxNewSize用于设置年轻代的大小,建议设为整个堆大小的1/3或者1/4,两个值设为一样大。
-XX:InitialSurvivorRatio用于设置新生代Eden/Survivor空间的初始比例
-XX:+PrintTenuringDistribution这个参数用于显示每次Minor GC时Survivor区中各个年龄段的对象的大小。
-XX:NewRatioOld区/Young区的内存比例
-XX:MaxTenuringThreshold配置一个对象从新生代晋升到老年代的阈值(默认值是15)
-XX:+HeapDumpOnOutOfMemoryError可以让JVM在遇到OOM异常时,输出堆内信息,特别是对相隔数月才出现的OOM异常尤为重要。

问题

  1. 如何理解Minor/Major/Full GC?

    • Minor GC:新生代
    • Major GC:老年代
    • Full GC:新生代+老年代
  2. 为什么需要Survivor区?只有Eden不行吗?
    如果没有Survivor,Eden区每进行一次Minor GC,并且没有年龄限制的话,存活的对象就会被送到老年代。 这样一来,老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。
    执行时间长有什么坏处?频发的Full GC消耗的时间很长,会影响大型程序的执行和响应速度。
    可能你会说,那就对老年代的空间进行增加或者较少咯。 假如增加老年代空间,更多存活对象才能填满老年代。虽然降低Full GC频率,但是随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长。
    假如减少老年代空间,虽然Full GC所需时间减少,但是老年代很快被存活对象填满,Full GC频率增加。
    所以Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16 次Minor GC还能在新生代中存活的对象,才会被送到老年代。

  3. 为什么需要两个Survivor区?
    最大的好处就是解决了碎片化。也就是说为什么一个Survivor区不行?第一部分中,我们知道了必须设置Survivor区。假设 现在只有一个Survivor区,我们来模拟一下流程:
    刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。永远有一个Survivor space是空的,另一个非空的Survivor space无碎片。

  4. 新生代中Eden:S1:S2为什么是8:1:1?

    • 新生代中的可用内存:复制算法用来担保的内存为9:1
    • 可用内存中Eden:S1区为8:1
    • 即新生代中Eden:S1:S2 = 8:1:1

总结

在这里插入图片描述
上图可以看出一个对象从出生到最后被回收的整个流程。

参考链接

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值