JVM虚拟机内存模型结构以及垃圾回收机制解析

前言

大家都知道一个java文件要想能够运行,必须通过javac命令将.java文件编译成.class字节码文件,然后通过指令将字节码文件放入java虚拟机中运行。那么这里就有个疑问了!java虚拟机到底是什么?它的内部构造是怎样的呢?下面就来解开它神秘的面纱。

java虚拟机内存模型

我先来张图,记得牛顿曾经说过“图片是最能说明问题的东西,没有之一”(我知道你会说他没说过,好了继续吧)

                                     

当字节码文件(.class文件)被加载进java虚拟机后,虚拟机会为其分配内存空间,为了方便管理内存空间进一步进分为了五大块:堆、方法区、本地方法栈、虚拟机栈、程序计数器。

java堆在java虚拟机中所占用内存最大,在虚拟机启动时就被创建,被所有线程共享的一块内存区域,其中存放的对象实例,如通过new关键字创建的对象,如果堆中没有足够的内存空间来存放对象,切其无法扩展扩展时,会抛出OutOfMemoryError异常(即内存溢出)。

  • 方法区

该内存区域主要存放被虚拟机加载的类的类元信息、常量、静态变量、实时编译器编译后的代码(如通过发射生成的类)等数据,方法区是被所有线程共享的内存区域,其中运行时常量池就是方法区中的一部分空间,如果方法区的内存空间不满足内存分配需求时,Java虚拟机会抛出OutOfMemoryError异常。

  • 本地方法栈

该内存区域主要是为java中使用的native方法服务的,为其提供内存空间,native方法是通过c/c++实现,该区域是线程独私有。

  • 虚拟机栈

该内存区域是线程私有的,与线程同时被创建。其中主要存放着局部变量、参数、返回值等数据,虚拟机栈也就是平时大家常说的栈(stack),在占中内存不足时则会出现则会抛出OutOfMemoryError异常,当栈中深度超过虚拟机允许的最大深度时就会抛出StackOverflowError异常(方法的递归到达一定深度时则会出现该异常)。

  • 程序计数器

程序计数器是一块较小的内存区域,它也是线程私有。顾名思义其主要功能是当前线程字节码文件执行时所指向字节码指令地址,简单点说就是指向当前执行字节码指令的下一条指令地址。

在解释了一大推理论后,可能大家还是不怎么明白,这些东西到底是做什么的啊!别急,我们慢慢来看。这里给提供一个小例子来说明java虚拟机内存模型以及内部各模块之间关系。

public class Test {

    public static void main(String[] args) {
        Test test = new Test();
        int sum = test.sum(1, 2);
        System.out.println(sum);
    }


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

要运行上述代码首先需要将Test.java文件编译为Test.class字节码文件,通过类装载子系统将字节码文件加载进java虚拟机,然后将具体字节码文件信息存储在虚拟机内不同功能内存区域中,真正执行程序是通过执行引擎运行。

虚拟机栈

虚拟机栈是线程私有的,其中包含多个栈帧。栈帧包含有局部变量表、操作数栈、动态链接、方法出口等信息,当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java栈中,当该方法执行完成,这个栈帧就从Java栈中弹出,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

程序在编译时栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

上面demo程序入口是main函数,所以执行引擎执行程序时首先会将main压入栈,main函数中调用了sum函数,故会将sum函数压入栈,通俗点说一个函数就对应着一个栈帧,当函数执行完毕后该函数会弹出站,故当sum函数执行后会被弹出栈,进而回到main函数,继续执行。

方法区

方法区主要包含已被虚拟机加载的类信息,如类的架构信息、类中常量、静态变量、以及即时编译器编译后的代码。

                                         

  • 类型信息

主要存储当前类的名称、父类、实现接口、类的修饰符(public、private、default、protect、abstract、final、static)之类的信息。

  • 类型常量池

每一个Class文件中,都维护着一个常量池(不是运行时常量池),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 。

字面值:相当于基本类型的数据值,在编译时就能确定的值。

符号引用:不同于我们常说的引用,它们是对类型,域和方法的引用,类似于面向过程语言使用的前期绑定,对方法调用产生的引用。

  • 字段信息

包含字段的修饰符、名称、类型等

  • 方法信息

有方法的修饰符、返回值类型、方法名、方法字节码、操作数栈以及局部变量表大小、方法参数列表、异常表

  • 类变量

指该类所有对象共享的变量,即使没有任何实例对象时,也可以访问的类变量。它们与类进行绑定。

  • 指向类加载器的引用

jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。jvm在动态链接的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,jvm需要保证这两个类型的类加载器是相同的。这对jvm区分名字空间的方式是至关重要的。

  • 指向class实例的引用

jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用。

  • 方法表

为了提高访问效率,JVM会对每个装载的非抽象类,都创建一个数组,数组的每个元素是实例能调用的方法的直接引用,包括父类中继承过来的方法。

运行时常量池具有动态性,并非只有编译期生成的内容才能进入运行时常量池,在运行期间产生的常量也能被放入池中,也是由于这种特性,当常量池无法申请新的内存时会抛出OutOfMemoryError异常。

这里需要重点讲解,堆是java虚拟机所管理的内存最大的一块,其中主要存放着对象实例,同时java堆也是垃圾回收器管理的重点区域.。在java虚拟机中堆被分为了新生代(Young)与老年代(Old)两个区域,而新生代有被分为了Eden、From Survivor、To Survivor三个区域。其中老年代所占内存大小为堆的2/3,新生代占1/3。新生代中伊甸园区(Eden)所占内存空间为新生代内存区域的8/10,From和To两区域各占1/10。这里盗用一张图片来说明下。

 

例如上面例子main函数中创建了Test对象,当执行完sum函数后,实际Test对象已经没有使用了,但是该对象还存在堆中占用内存空间,这样如果后面程序继续创建对象,那么肯定会出现堆中内存分配紧张,继而会出现内存溢出。为了解决这种现象,java虚拟机制定了垃圾回收机制,也就是内存中无用的对象清除,释放内存空间。

要回收对象就得判断该对象是否能够被回收,也就是判断该对象是否还有引用或者关联。下面主要介绍两种判断对象是否存活的方式。

引用计数法

给每个对象加一个引用计数器,每当有一个地方引用它时,计数器就加 1,引用失效后减 1,当对象的计数器为 0,则说明这个对象可以被回收了。这个算法非常简单,但存在一个非常大的弊端:一旦两个对象相互引用,那么就无法判断了。

对象可达性分析算法(根搜索算法)

通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链(从GC Roots到这个对象不可达)时,则证明此对象是不可用的。它们到GC Roots是不可达的,将会判断为是可回收的对象。

可作为GC Roots的对象有:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JNI(即一般说的Native方法)引用的对象

GC Roots即指对象的引用,对对象的操作是通过引用来实现的,引用是指向堆内存对象的指针,如果当前对象没有引用指向,那该对象无法被操作,被视为垃圾。可达性分析算法主要从对象的引用出发,寻找对象是否存在引用,若不存在进行标识处理,为GC做准备。

在介绍垃圾回收机制之前我先来介绍下垃圾回收算法。

标记 - 清除算法

标记 - 清除算法应该是最简单基础的收集算法了,只需要标记需要回收的对象,标记完成后统一回收即可。但其有两个非常明显的弊端。

  • 标记清除效率都不高
  • 标记清除后会产生大量不连续的内存碎片,导致程序以后需要较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

复制收集算法

复制算法主要是将可用内存划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完,就将存活着的对象复制到另一块内存上去,然后把已使用过的内存空间一次性清理掉。复制回收算法能有效地避免内存碎片,但是算法需要把内存一分为二,导致内存使用率。

标记 - 整理算法​​​​​​​

复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率非很低。 效率会很低。标记-整理算法就解决了这样的问题,同样采用的是根搜索算法进行存活对象标记,但后续是将所有存活的对象都移动到内存的一端,然后清理掉端外界的对象。

分代收集算法​​​​​​​

这种算法其实就是根据对象的存活周期不同将内存划分为几块。一般把 Java 堆分为新生代和老年代,然后根据各个年代的特点采用最适合的收集算法。

 

JAVA虚拟机垃圾回收策略

java中新生代内存区域几乎是所有 Java 对象出生的地方,java中使用的大部分对象存活时间都不需要很长,当一个对象不存在任何引用时,为了内存空间的合理利用,java虚拟机就会调动GC来回收处理这些无用对象,其中新生代是GC垃圾回收的的主要部分。

GC分为两种:

  • Minor GC

Minor GC主要发生在新生代区域,java虚拟机会单独创建一个线程来执行垃圾回收,此阶段采用的垃圾回收算法为复制回收算法,新创建的对象普片都存在于Eden区,当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳(上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。

  • Full GC

Full GC 将会对整个堆进行整理,包括新生代、老年代和持久代。Full GC 因为需要对整个堆进行回收,所以比 Minor GC 要慢,因此应该尽量减少 Full GC 的次数。在对 JVM 调优的过程中,很大一部分工作就是对 Full GC 的调节,有如下原因可能导致 Full GC:

  1. 老年代被写满
  2. System.gc() 被显式调用

其中Java虚拟机还有个持久代,该持久代也就是方法区,上述已经介绍方法区了,这里不多做解释。Java8后持久代也不在内存中,而是指本地内存。

至此,本篇文章就结束了。 由于佩琪水平所限,文章难免出现错误,欢迎指正!

 

参考

https://mp.weixin.qq.com/s/hOcxN_s1vRhnR40kgFXsBg

https://blog.youkuaiyun.com/Yano_nankai/article/details/50957578

https://blog.youkuaiyun.com/hupoling/article/details/62887251

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值