JVM内存管理及垃圾回收机制

JVM笔记

JDK:Java、JVM、Java API类库,是支持java程序开发的最小环境。
JRE:Java API类库中Java SE API子集和JVM,是支持java程序运行的标准环境。
Fork/Join模式:处理多核并行编程的一种经典方法,能够轻松利用多个CPU核心提供的计算资源来协作完成一个复杂的计算任务。

JVM内存管理

JVM内存管理即是管理运行时数据区

  • 运行时数据区分类:
    • 所有线程共享:
      • 方法区
    • 线程隔离:
      • 虚拟机栈
      • 本地方法栈
      • 程序计数器

线程共享

堆是Java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域。在虚拟机启动时创建,唯一目的是用于存放对象实例。

  1. 所有的对象实例以及数组都分配在堆上。
  2. 堆是GC管理的主要区域,因此从垃圾回收的角度考虑,目前多采用分代收集算法,Java堆还可以细分为新生代和老生代。
  3. 在JVM规范中,堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。在JVM配置中使用Xmx和Xms配置。
    1. Xmx:设置JVM最大堆内存空间。
    2. Xms:设置JVM初始堆内存空间。(可与Xmx相同,这样堆不可扩展,避免GC回收后进行调整)
  4. 堆无法继续扩展时,会抛出OutOfMemmoryError异常。

方法区

方法区是各个线程共享的内存区域,它们用于存储已被虚拟机加载的Class的相关信息:类信息(类名、访问修饰符、常量池等)、常量、静态变量、即时编译器编译后的代码等数据。

  1. GC在方法区的回收目标主要是针对常量池的回收和针对类型的卸载。
  2. 当方法区无法满足内存分配需求时,将抛出OutOfMemmoryErrori 异常。
  3. 在JVM中使用MaxPermSize设置方法区大小。
运行时常量池

运行时常量池是方法区的一部分,常量池主要存放Class文件中编译期生成的各种字面量和符号引用。这部分内容在类加载后存放到方法区的运行时常量池中。

  1. 运行时常量池具有动态性,Java语言不要求常量一定只能在编译期产生,运行期间也可能将新的常量放入池中,例如String的intern()方法。
  2. 常量池无法申请到内存时抛出OutOfMemmoryError异常。

线程隔离

程序计数器

是一块较小的内存空间。作用是用于标识当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来获取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复都是依赖这个计数器完成。

  1. JVM中多线程通过时间片轮转的方式轮流切换线程。因此在任意确定时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器。各条线程之间的计数器互不影响,独立存储。
  2. 如果线程正在执行一个Java方法,则这个计数器的值是正在执行的虚拟机字节码指令的地址;如果是一个Native方法,则这个计数器值为空
  3. 程序计数器是JVM中唯一一个没有规定任何OutOfMemmoryError情况的区域。

Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。描述了Java方法执行的内存模型:每一个方法执行的时候都会同时创建一个栈帧,栈帧用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用到执行完成对应着虚拟机栈中栈帧入栈出栈的过程。

image.png
  1. 局部变量表存放了编译期可知的各种基本数据类型、对象引用和返回地址类型。其中64位的long和double会占用2个局部变量空间,其余的数据类型占用1个空间。局部变量表所需的内存空间大小在编译期完成确定和分配,在方法运行期间,不会改变局部变量表的大小。
  2. 方法存储在运行时常量池中,每一个栈帧都有一个动态链接指向该栈帧所属方法在运行时常量池中的地址。
  3. 在JVM中,虚拟机栈有两个异常:
    1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
    2. 如果虚拟机栈在动态扩展时无法申请到足够的内从会抛出OutOfMemmoryError异常。
  4. 在JVM中使用Xss配置虚拟机栈大小。

本地方法栈

本地方法栈存储Native方法的内存模型,基本与Java虚拟机栈类似,也会抛出StackOverflowError异常和OutOfMemmoryError异常。

总结

  1. 除了程序计数器以外,堆、方法区、栈、本地方法区都会产生OutOfMemmory异常(OOM)。
  2. 堆的OOM:堆用于存储对象实例,产生OOM,只需要不断创建对象,并保证GC Root到对象之间具有可达路径。

内存中对象访问

最简单的对象访问,也涉及栈、堆、方法区这三个最重要内存区域之间的关联。

Object obj =new Object();

分析:

  1. Object obj这部分的语义会反映到Java栈的本地变量表中,作为一个reference类型数据出现。
  2. new Object()这部分语义会反映在Java堆中,形成一块存储了Object类型所有实例数据值(对象中各个实例字段的数据)的结构化内存。此外在Java堆中还包含能查找到此对象类型数据(对象类型、父类、实现的接口)的地址信息,这些都存储在方法区中。

reference类型:一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问Java堆中的对象的具体位置。目前主流的访问方式有两种:句柄和直接指针。

  1. 句柄:Java堆中会划分一块内存作为句柄池,reference中存储的就是对象在句柄池中的句柄地址,而该句柄地址对应的句柄中包含了对象实例数据和类型数据各自的具体地址信息。
  2. 直接指针:reference中直接存储的就是对象在Java堆中的地址。但采用这种方式,必须要考虑如何放置访问类型数据的相关信息,因此在对象的结构化内存中,还需要包含一个指向该实例对象类型数据的指针引用。
  3. 两种方式比较:句柄访问方式的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC回收)时只会改变句柄中的实例数据指针,而reference本身不需要改变;使用直接访问的最大好处是速度快,节省了一次指针定位的时间开销。

GC和内存分配策略

程序计数器、虚拟机栈、本地方法栈三个区与线程的生命周期相同。这几个区域的内存分配都是编译期可知的, 因此具有确定性,故不需要在这几个区域考虑回收的问题。因为在方法结束或者线程结束时,内存自然也就跟着回收了。

Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。因此这两个区域是GC的主要回收区域。

GC需要完成的三件事情:

  1. 哪些内存需要回收
  2. 什么时候回收
  3. 如何回收

确定回收对象

  1. 引用计数算法
  2. 根搜索算法

引用计数算法

描述:给对象中添加一个引用计数器,每当有一个地方引用它时,该对象的引用计数器就加1;当引用失效时,计数器值减1.任何时候,计数器都为0的对象表明不可能再被使用。

特点:实现简单,判定高效,但很难解决对象之间相互循环引用的问题。

public class Father{
    public Object instance = null;
    
    public static void testGC(){
        Father objA=new Father();
        Father objB=new Father();
        objA.instance=objB;
        objB.instance=objA;

        objA=null;
        objB=null;

        System.gc();
    }
}

在该例子中,objA的引用指向一个Fahter实例A,objB的引用指向一个Father实例B(这两个实例完全不一样)。之后,令objA的实例A中的instance引用指向objB,objB实例B中的instance引用指向objA。这样,objA和objB对象实例的引用计数器的值分别为1.此时,令objA和objB这两个引用变量指向null,表示原先的实例A和实例B都没有被任何引用变量指向,实例A和B已经是可回收,但由于实例A和B互相循环引用,引用计数器都为1,因此这种情况下,GC无法对实例A、B进行回收。

根搜索算法

描述:通过一系列的GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,可以被GC回收。

GC Roots对象包括以下几种:

  1. 虚拟机栈中的引用的对象。
  2. 方法区中的类静态属性引用的对象。
  3. 方法区中的常量引用的对象。
  4. 本地方法栈中的引用的对象。

引用

判定对象是否存活都与“引用”相关。
引用原始定义:如果reference类型的数据中存储的数值代表的是另外一块内从的起始地址,就称这块内存代表着一个引用。
引用的扩充定义:强引用、软引用、弱引用、虚引用。

  1. 强引用:在程序中普遍存在的,类似于Object obj=new Object()这类的引用。
  2. 软引用:关联非必需的对象。对于软引用对象,系统会在内存溢出异常之前,会将此类引用对象列入回收范围进行回收,如果回收后还是内存不足,才会抛出内存溢出异常。
  3. 弱引用:关联非必需对象,强度比软引用更弱。这类引用只能生存到下一次垃圾回收之前。在下一次GC工作时,无论内存是否足够,都会回收掉弱引用对象。
  4. 虚引用:设置虚引用仅仅是希望在这个对象被回收时收到一个系统通知。

回收标记过程

在GC回收过程中,并不一定会回收对象。在这个过程中,会有两次标记,对象可以有机会离开回收队列。

在根搜索算法中,回收一个对象,至少需要两次标记过程。(在一次根搜索之后,对于回收对象)

  1. 回收对象第一次标记,并进行筛选。筛选条件是此对象是否有必要执行finalize()方法。对于以下两种方式,JVM认为没有必要执行finalize()方法。
    1. 对象没有覆盖finalize()方法。
    2. finalize()方法已经被执行过。
  2. 若标记为有必要执行finalize()方法。
    1. 对象放置在F-Queue队列中,并在稍后由JVM自动建立、低优先级的Finalizer线程区执行。(JVM会触发这个方法,但不保证会等待它运行结束)
  3. 稍后,在执行finalize()方法期间,GC对F-Queue队列中的对象进行第二次标记。如果对象在finalize()方法中成功拯救自己—--只要重新与引用链上的任何一个对象建立关联即可。此时在第二次标记中,它会被移出F-Queue队列,不会回收。
  4. 之后,还在F-Queue中的对象将被回收。
    任何一个对象的finalize()方法都会被系统自动调用一次。

执行回收算法

  1. 标记-清除
  2. 标记-复制
  3. 标记-整理
  4. 分代收集

标记-清除

算法分为标记和清除两个阶段。

  1. 首先标记出所有需要回收的对象。
  2. 标记完成后同意回收掉所有被标记的对象。

主要的缺点:

  1. 效率问题,标记和清除过程的效率不高。
  2. 空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。


    标记-清除

标记-复制(新生代)

为了解决效率问题,改进标记-清除算法为标记-复制算法。

  1. 将可用内存按容量大小划分成相等的两块,每次只使用其中的一块。
  2. 当一块内存用完了,就将还存活着的对象复制到另一块上面,然后清除已使用过的这块。
  3. 这样使得每次都是对其中的一块进行内存回收,内存分配时,也不用考虑内存碎片的问题。只要移动堆顶指针,按顺序分配内存即可。

主要的缺点:
将原先可用内存容量缩小为原来的一半,每次都是使用一半作为当前可用的内存进行分配。空间代价较高。


标记-复制

在分代回收中,多采用该算法回收新生代。
IBM研究表明:新生代中98%的对象都是朝生夕死的,所以不需要按照1:1比例划分新生代内存空间。而是将内存分为一块较大的Eden空间和两块较小的Survivor空间。每次使用Eden和其中一个Survivor空间。当回收时:

  1. 将Eden和Survivor中还存活的对象一次性的拷贝到另外一个Survivor空间中。
  2. 最后清理掉Eden和刚才用过的Survivor空间。

HotSpot虚拟机默认Eden和Survivor比例是8:1,也就是每次新生代可用内存空间是90%(80%+10%),只有10%的会被浪费掉。
但也存在Survivor不够用的情况,这样的时候需要老年代进行内存担保。

标记-整理(老年代)

复制算法在对象存活率较高时,需要执行较多的复制操作,效率会变低。更需要考虑存活过多,Survivor空间不够,需要额外空间担保的问题。对于内存中存活100%的对象,复制算法并不合适。因此在对象存活率较高的老年代,一般选择标记-整理算法。

  1. 与标记-清除算法中的标记过程一样,先进行标记。
  2. 让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。


    标记-整理

分代收集

这种算法没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,这样就可以根据每一个年代的特点采用最适当的收集算法。

  1. 在新生代,每次垃圾收集都发现有大批对象死去,只有少量存活,那就选用标记-复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  2. 在老年代,因为对象存活率较高,没有额外空间对它进行分配担保,就必须使用标记-清除或者标记-整理算法进行回收。

内存分配与回收策略

Java体系中的自动内存管理解决两个问题:

  1. 给对象分配内存(本节内容)
  2. 回收分配给对象的内存(上一节以详解)

对象内存分配:堆上分配,对象主要分配在新生代的Eden区

优先Eden分配

大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,JVM触发一次Minor GC。

  • Minor GC和Full GC区别:
    • 新生代GC(Minor GC):发生在新生代上的垃圾收集动作。因为大多数对象朝生夕灭,所以Minor GC非常频繁,回收速度也很快。
    • 老年代GC(Full/Major GC):发生在老年代的GC,出现了Full/Major GC。经常会伴随一次Minor GC,但非绝对的。Full GC的速度一般比Minor GC慢10倍以上。

大对象直接进入老年代

所谓大对象:需要大量连续内存空间的Java对象。比如:很长的字符串及数组。经常出现大对象容易导致内存还有不少空间就会提前触发垃圾收集以获取足够的连续空间分配下一个大对象。

长期存活的对象进入老年代

分代回收:JVM需要能够识别哪些对象应当放在新生代,哪些对象可以放在老年代。
JVM给每个对象定义一个对象年龄计数器(Age)。

  • 如果一个对象在Eden出生,并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor区,并将Age=1;
  • 对象在Survivor区每熬过一次Minor GC,Age+1,当年龄增加到一定程度(默认是15岁),就会被晋升到老年代中。
  • 对象晋升老年代的Age阈值设置:-XX:MaxTenuringThreshold设置。

动态对象年龄判定

为了适应不同程序的内存状况,JVM不能总是要求年龄必须到达阈值才能晋升老年代。如果Survivor空间中相同年龄所有对象大小的综合大于Survivor空间大小的一半,则年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到阈值要求的年龄。

及时释放Survivor空间,保证标记-复制回收效率?

空间分配担保

  • 发生Minor GC时,JVM会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小:
    • 如果大于,则改为直接进行一次Full GC。
    • 如果小于,则查看HandlePromotionFailure设置是否允许担保失败:
      • 如果允许,只会进行Minor GC。
      • 如果不允许,则改为一次Full GC。

推荐阅读更多精彩内容