关于JVM内存模型与内存回收的那些B事

12 篇文章 0 订阅

本文精要的说明JVM的内存模型和内存回收原理

插入一篇JVM虚拟机版本介绍的题外文章:https://www.zhihu.com/question/29265430?sort=created


大家都知道Java虚拟机在运行程序的过程中会把它所管理的内存划分为若干个不同的数据区。

一、首先我们来鼓捣鼓捣JVM的内存模型:

这里引用简书一篇博文内容图:https://www.jianshu.com/p/046e157d6a7b

JVM内存模型图

JVM内存模型图2

其中蓝色部分为线程共享的Method Area(方法区)和Heap(堆),所有线程均会向此区域读写数据。白色部分为线程私有,每条线程均有自己独立的JVM Stack(虚拟机栈)、Native Method Stack(本地方法栈)、Program Counter Register(程序计数器),各线程之间相互独立。

- 方法区(Method Area)
- 堆(Heap)
- 虚拟机栈(JVM Stack)
- 本地方法栈(Native Method Stack)
- 程序计数器(Program Counter Register)

方法区(Method Area)

所有线程共享,用于存储JVM加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据。它只是JVM规范中定义的一个概念,是堆的一个逻辑部分,为了与普通堆区分开来,有一个别名叫做Non-Heap(非堆)。

  • 永久代
    在Java 8之前的HotSpot虚拟机中,有一个被称为“永久代”的区域,它与堆内存连续,本质上与方法区并不等价,它其实就是方法区的一个实现,是HotSpot特有的,HotSpot的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区,这样垃圾回收器就可以像管理Java堆一样管理这部分内存,省去了专门为方法区编写内存管理的代码。

  • 元空间
    从Java 8开始,永久代被移除,取而代之的是元空间,它直接从操作系统分配内存,独立且可以自由扩展,最大可分配空间就是系统可用空间,因此不会遇到PermGen的内存溢出(out of memory)错误。一旦发生内存泄露,会占用大量本地内存。元空间以ClassLoader为单位独立分配本地内存,不受堆GC管理,需要由元空间虚拟机(Metaspace VM)负责来管理。

Java堆(Heap)

JVM所管理的内存中最大的一块,是垃圾回收器管理的主要区域(因此也称为“GC堆”,Garbage Collected Heap),所有线程共享,在虚拟机启动的时候创建。几乎所有的对象实例以及数组都要在堆上分配,这里没有说那么绝对,是因为通过“逃逸分析”这种技术,JIT编译器在编译阶段就可以进行一些高效的优化:栈上分配(Stack Allocation)、标量替换(Scalar Replacement),从而使得对象(必要时进行一些分解)被分配到栈内存,这样对象不仅访问速度快,所占用的内存也可以随栈帧出栈而自动销毁,减轻了垃圾回收器的压力。 当堆中没有足够空间可以用来分配实例对象,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

Java虚拟机栈(JVM Stack)

它描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出入口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中的入栈和出栈的过程。它的生命周期与线程相同。

这个区域规定了两种异常情况:

  • 如果线程所请求的深度超过虚拟机所允许的深度,将抛出StackOverflowError,常见于递归调用

  • 如果虚拟机动态扩展时无法申请到足够的内存,将会抛出OutOfMemoryError,比如,程序中不断创建新的线程,每新建一条线程都会划分出一块线程栈,当程序占用的内存到达系统所允许的上限时( Linux上可以通过ulimit -v ),就会抛出此异常。

本地方法栈(Native Method Stack)

与Java虚拟机栈的作用非常相似,区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。此区域也会抛出StackOverflowError和OutOfMemoryError异常。 HotSpot虚拟机中,直接把Java虚拟机栈和本地方法栈合二为一。

程序计数器(Program Counter Register)

可看作是当前线程所执行的字节码的行号指示器。由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说就是一个内核)都会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间互不影响,独立存储。

  • 如果线程正在执行的是一个Java方法,则计数器内存储的是虚拟机字节码指令的地址

  • 如果线程正在执行的是一个Native方法,则计数器的值为空(Undefined)

此内存区域是唯一一个在JVM规范中没有明确规定任何OutOfMemoryError情况的区域。


二、闲扯JVM的内存回收

1、首先介绍Java的四种对象引用

  • 强引用
    我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。 如:Object obj = new Object()

  • 软引用
    如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用使用SoftReference来实现。

  • 弱引用
    如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。 弱引用使用WeakReference来实现。
    当要获得weak reference引用的object时, 首先需要判断它是否已经被回收(weakReference.get()是否为null)。如果此方法为空, 那么说明weakReference指向的对象已经被回收了.

  • 虚引用
    “虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
    虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

2、JVM判断一个对象是否可以回收的那点事

判断Java对象是否存活的算法——根搜索算法。

这个算法的思路其实很简单,它把内存中的每一个对象都看作一个节点,并且定义了一些对象作为根节点“GC Roots”。如果一个对象中有另一个对象的引用,那么就认为第一个对象有一条指向第二个对象的边,如下图所示。JVM会起一个线程从所有的GC Roots开始往下遍历,当遍历完之后如果发现有一些对象不可到达,那么就认为这些对象已经没有用了,需要被回收。

GC Roots

看到这里是不是很疑惑JVM是如何知道哪个对象属于 GC Roots?实际上GC Roots定义的对象有四种类型:

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

光看这四种类型是不是觉得还是有点模糊?那么HotSpot虚拟机如何通过GC Roots来判断一个对象的状态的呢?

HotSpot首先需要枚举所有的GC Roots根节点,虚拟机栈的空间不大,遍历一次的时间或许可以接受,但是方法区的空间很可能就有数百兆,遍历一次需要很久。更加关键的是,当我们遍历所有GC Roots根节点时,我们需要暂停所有用户线程,因为我们需要一个此时此刻的”虚拟机快照”,如果我们不暂停用户线程,那么虚拟机仍处于运行状态,我们无法确保能够正确遍历所有的根节点。所以此时的时间开销过大更是我们不能接受的。

基于这种情况,HotSpot实现了一种叫做OopMap的数据结构,这种数据结构在类加载完成时把对象内的偏移量是什么类型计算出,并且存放下位置,当需要遍历根结点时访问所有OopMap即可。

实际上,在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与 GC Roots 相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖 finalize()方法,或 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果该对象被判定为有必要执行 finalize()方法,那么这个对象将会被放置在一个名为 F-Queue 队列中,并在稍后由一条由虚拟机自动建立的、低优先级的 Finalizer 线程去执行 finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的 finalize()方法最多只会被系统自动调用一次),稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果要在 finalize()方法中成功拯救自己,只要在 finalize()方法中让该对象重引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

用安全点Safepoint约束根节点

如果将每个符合GC Roots条件的对象都存放进入OopMap中,那么OopMap也会变得很大,而且其中很多对象很可能会发生一些变化,这些变化使得维护这个映射表很困难。实际上,HotSpot并没有为每一个对象都创建OopMap,只在特定的位置上创建了这些信息,这些位置称为安全点(Safepoints)。

为了保证虚拟机中安全点的个数不算太多也不是太少,主要决定安全点是否被建立的因素是时间。当进行了耗时的操作时,比如方法调用、循环跳转等时会产生安全点。此外,HotSpot虚拟机在安全点的基础上还增加了安全区域的概念,安全区域是安全点的扩展。在一段安全区域中能够实现安全点不能达成的效果。

注意:方法区在HotSpot中被称为永久代,方法区中的废弃常量和无用的类也是需要被GC回收的

  • 废弃常量:如果常量池中的某个常量没有被任何引用所引用,则该常量是废弃常量
  • 无用的类:1、该类的所有实例已被回收(堆中不存在该类的实例对象)。2、加载该类的类加载器已经被回收。3、该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射机制访问该类的方法。

3、你晓得与不晓得的垃圾回收算法打包装箱

  • 引用计数算法

    引用计数法的逻辑是:在堆中存储对象时,在对象头处维护一个counter计数器,如果一个对象增加了一个引用与之相连,则将counter++。如果一个引用关系失效则counter–。如果一个对象的counter变为0,则说明该对象已经被废弃,不处于存活状态。

    这种方法来标记对象的状态会存在很多问题:
    1、无法解决多重类型引用(强软弱虚)。
    2、无法解决对象相互(循环)引用的问题。

    比如:如果一个对象A持有对象B,而对象B也持有一个对象A,那发生了类似操作系统中死锁的循环持有,这种情况下A与B的counter恒大于1,会使得GC永远无法回收这两个对象。

  • 标记-清除算法(Mark-Sweep)

    最基础的垃圾收集算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收掉所有被标记的对象。

    标记-清除算法

    标记-清除算法的缺点有两个:首先,效率问题,标记和清除效率都不高。其次,标记清除之后会产生大量的不连续的内存碎片,空间碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

  • 复制算法(Copying)

    将可用内存按容量分成大小相等的两块,每次只使用其中一块,当这块内存使用完了,就将还存活的对象复制到另一块内存上去,然后把使用过的内存空间一次清理掉。这样使得每次都是对其中一块内存进行回收,内存分配时不用考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。复制算法的缺点显而易见,可使用的内存降为原来一半。

    复制算法

  • 标记-整理算法(Mark-Compact)

    标记-整理算法在标记-清除算法基础上做了改进,标记阶段是相同的标记出所有需要回收的对象,在标记完成之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在移动过程中清理掉可回收的对象,这个过程叫做整理。

    这里写图片描述

    标记-整理算法相比标记-清除算法的优点是内存被整理以后不会产生大量不连续内存碎片问题。

    复制算法在对象存活率高的情况下就要执行较多的复制操作,效率将会变低,而在对象存活率高的情况下使用标记-整理算法效率会大大提高。

  • 分代收集算法(Generational Collection)

    JVM根据对象在内存中存活时间的长短,把堆内存分为新生代(包括一个Eden区、两个Survivor区)和老年代(Tenured或Old)。Perm代(永久代,Java 8开始被“元空间”取代)属于方法区了,而且仅在Full GC时被回收。大致如下图

三、接下来我们专业研究下什么是分代收集算法(Generational Collection)

好吧,我偷懒了,某博主亲自做实验得出的博文记录更专业:简书博主“小鱼爱小虾”的传送门”:https://www.jianshu.com/p/b776070926ad

博文说明:
本文并非完全个人文字所写,只为面试屡次被问及相关知识,然而在面试前马马虎虎的查看并不能达到面试要求,特别收集部分博文的精华已经自己的理解作此文记录。

参考:
1、https://www.jianshu.com/p/b776070926ad
2、https://www.cnblogs.com/cielosun/p/6674431.html
3、https://www.cnblogs.com/huajiezh/p/5769255.html
4、https://www.ibm.com/developerworks/cn/java/j-lo-JVMGarbageCollection/index.html


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值