JVM垃圾回收与内存分配

2023-02-20  本文已影响0人  夜流星_9775

在介绍垃圾回收之前,需要先了解一些JVM的内存区域的知识点。
我们都知道不同于C语言,java的内存,都由虚拟机来管理,不需要程序员手动释放内存。但是这种机制有利的时候也有弊,在代码不当的时候,容易出现内存泄漏和溢出方面的问题,所以我们就需要了解内存的构成。

JVM运行时数据区域介绍

JVM运行时数据区域

1、程序计数器:由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。为了线程切换后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这类内存区域线程私有。此区域没有OutOfMemoryError。

2、Java虚拟机栈:线程私有的,生命周期与线程相同。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。此区域有OutOfMemoryError。

3、本地方法栈:同Java虚拟机栈。只不过前者执行的是java方法,本地方法栈执行的是虚拟机的本地方法。此区域有OutOfMemoryError。

4、Java堆:堆是被所有线程共享的一块内存区域,同时是内存中最大的一块。此内存区域的唯一目的就是存放对象实例。Java世界里几乎所有的对象实例都在这里分配内存。堆是垃圾收集器管理的内存区域。此区域有OutOfMemoryError。

5、方法区:各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。此区域有OutOfMemoryError。
简单来说,方法区存储的是:静态变量+常量+类信息(版本、方法、字段等)+运行时常量池

6、运行时常量池:方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
常量不一定只有编译期才能产生,运行期间也可以将新的常量放入池中,例如String类的intern()方法(如果字符串s在字符串常量池中存在对应字面量,则intern()方法返回该字面量的地址;如果不存在,则在常量池中创建一个对应的字面量,并返回该字面量的地址)。

7、直接内存: NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。

JVM垃圾回收

为什么要了解垃圾回收呢?
今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那为什么我们还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

而Java堆和方法区这两个区域的内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理

垃圾回收主要分为几个步骤
1、回收哪些内存
2、怎么有效回收

回收哪些内存?

也就是怎么判断哪些对象还存活 哪些对象已“死”可以回收
1、引用计数法 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的
缺点:单纯的引用计数很难解决对象之间相互循环引用的问题。如果两个对象相互引用,除此之外,这两个对象再无任何引用,这两个对象都应该被回收,但是引用计数法的话无法回收它们。
2、可达性算法: 通过GC Roots根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链,如果某个对象到GC Roots间没有任何引用链相连,从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,可以回收。

可达性分析算法

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种
1、在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
2、在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。例如:private static Object tail;
3、在方法区中常量引用的对象,譬如字符串常量池里的引用。例如:private final Object tail;
4、在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
5、所有被同步锁(synchronized关键字)持有的对象

这里介绍一下引用类型

1、强引用
Object obj = new Object() 只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
2、软引用
Object o1 = new Object(); SoftReference<Object> s1 = new SoftReference<Object>(o1); 当内存不足时,垃圾收集器在系统将要发生内存溢出异常之前,会进行第二次回收
3、弱引用
Object o1 = new Object(); WeakReference<Object> w1 = new WeakReference<Object>(o1); 垃圾收集器工作时会直接回收
4、虚引用
Object o1 = new Object(); PhantomReference<Object> w1 = new PhantomReference<Object>(o1); 垃圾收集器工作时会直接回收,目的是跟踪对象的垃圾回收

怎么有效回收?

垃圾收集算法

1、标记-清除算法:标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象

“标记-清除”算法示意图

缺点:
第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次GC

2、标记-复制算法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉、

标记-复制算法示意图

缺点:
可用内存缩小为了原来的一半,空间资源浪费

3、标记-整理算法:让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存 标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

“标记-整理”算法示意图

对象移动操作必须全程暂停用户应用程序才能进行。移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,但是从整个程序的吞吐量来看,移动对象会更划算。

算法细节实现

1、根节点枚举:根节点枚举这一步骤时是必须暂停用户线程的。虚拟机是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来
2、安全点:用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
3、安全区域:用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了
4、记忆集:记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围

经典垃圾收集器

在讲垃圾收集器之前,先介绍一下回收的专有名词
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
1、新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
2、老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
3、混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

1、Serial收集器:单线程工作的收集器,进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。比喻:你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完吗。

Serial/Serial Old收集器运行示意图

2、ParNew收集器:Serial收集器的多线程并行版本

ParNew/Serial Old收集器运行示意图

3、Serial Old收集器:Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

Serial/Serial Old收集器运行示意图

4、Parallel Scavenge收集器 : 吞吐量优先收集器,可以并行收集
5、Parallel Old收集器 :Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

Parallel Scavenge/Parallel Old收集器运行示意图

6、CMS收集器:以获取最短回收停顿时间为目标的收集器 并发收集、低停顿

CMS收集器运行示意图
1)初始标记(CMS initial mark)
初始标记、重新标记这两个步骤需要“Stop The World”--全程暂停用户应用程序。初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;
2)并发标记(CMS concurrent mark)
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
3)重新标记(CMS remark)
而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;
4)并发清除(CMS concurrent sweep)
最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
CMS的缺点:
1、在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
2、无法处理浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
3、CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生

7、Garbage First收集器(G1):支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标

G1收集器运行示意图

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。

而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集进行回收,这就是G1收集器的Mixed GC模式。G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够采用不同的策略去处理。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。

1)初始标记(Initial Marking)
仅仅只是标记一下GC Roots能直接关联到的对象,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的
2)并发标记(Concurrent Marking)
从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
3)最终标记(Final Marking)
对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
4)筛选回收(Live Data Counting and Evacuation)
负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
G1的缺点:
用户程序运行过程中,G1为了垃圾收集产生的内存占用要比CMS要高。在小内存应用上 CMS 的表现大概率会优于G1,而 G1 在大内存应用上则发挥其优势。平衡点在 6-8GB 之间

8、ZGC收集器 : 在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟

内存分配与回收

1、对象优先在Eden新生代分配
当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

2、大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java对象,比如很长的字符串、元素数量多的数据

3、长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在新生代Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置

4、动态对象年龄判定
并不是永远要求对象的年龄必须达到XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

5、空间分配担保
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看XX:HandlePromotionFailure参数的设置值是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

上一篇下一篇

猜你喜欢

热点阅读