JVM-内存分配和回收

Scroll Down

JVM内存分配和回收

在了解了JVM运行时数据区之后,我们发现JVM中对象的存储在堆上,而且java是面向对象的编程语言,我们一般都是通过new一个对象来生成相应的对象。那么我们在程序中调用new的时候,JVM是做了什么去帮我们生成对象的呢?

对象的创建

虚拟机在遇到一条new指令,首先将去检查这个指令的参数是否能在常量池中定位到类的符号引用,并且检查这个类的符号引用代表的类是否已经被加载,解析和初始化过。如果没有那就要先执行相应的类加载过程。

内存分配方式:在类加载检查通过后,接下来虚拟机将为对象分配内存,对象所需的内存大小在类加载完之后便可以完全确定。一般通过指针碰撞或者空闲列表这两种方式来分配。

指针碰撞

如果虚拟机堆中的内存是绝对完整的,所有用过的在一边,没用过的都在另一边,中间放一个指针作为分界点的指示器,当需要内存分配的时候,就简单把指针向空闲区域移动一段和对象大小相等的距离,这种分配方式就是指针碰撞。

空闲列表

如果堆中的内存并不是规整的,已经使用的内存和未使用的内存相互交错,那就无法使用指针碰撞的方式来分配内存,这时候虚拟机会维护一个列表来记录哪些内存是可以使用的,然后在分配的时候,找到一个足够大的空间划分给对象实例,并更新列表的记录,这种方式就叫做空闲列表。

在了解了内存分配方式之后,那么选择那种分配方式是由jvm堆是否规整决定的,但是jvm堆是否规整是根据垃圾收集器是否带有压缩整理功能来决定的。因此在使用Serial, Parnew等收集器的时候,采用的是指针碰撞;而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用的时空闲列表。

对象的访问定位

再上一步我们说到了创建对象,那么创建对象是为了使用对象,Java程序需要通过找到栈上的对象的reference数据来操作对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义引用应该用什么方式去定位,访问堆中的对象的具体位置。所以对象访问方式也是取决于虚拟机的具体实现,目前常见的有使用句柄和直接指针两种。

句柄访问:

Java堆中将会划分出来一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象的类型数据和实例数据的具体地址信息。如下图
Jvm_04

直接指针:

如果使用直接指针访问,Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。
Jvm_05
这两种方式访问个有优势,使用句柄的好处是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而reference不需要改动。
使用直接指针访问方式最大的好处就是速度更快,因为节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这些开销积少成多之后也是非常客观的的执行成本。我们常见的HotSpot虚拟机而言,它是使用第二种方式进行对象访问的。这并不能说明句柄访问就不好,只是说HotSpot采用的是直接指针这种方式。

运行时数据区对应的异常

JVM堆内存溢出

通常我们都比较熟悉通过-Xms和-Xmx来设置堆的内存大小,还有一个很有用的参数是-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现OOM的时候Dump出堆内存的快照以便事后进行分析。 

import java.util.ArrayList;
import java.util.List;

/**
 * -Xms 设置堆最小值
 * -Xmx 设置堆最大值
 * -XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现OutOfMemory的时候Dump出来当前内存堆转储快照,方便后续分析
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class OutOfMemoryInHeap {

    static class OOMObject{

    }

    public static void main(String[] args){
        List<OOMObject> list = new ArrayList<>();
        while(true){
            list.add(new OOMObject());
        }
    }
}

JVM栈和本地方法栈溢出

通过-Xss参数可以设置JVM栈的大小,虽然通常-Xoss参数可以设置本地方法栈的大小(实际是无效的),通常一般也不要去设置本地方法栈的大小。而且通常栈出现的异常是以下两种情况。
StackOverflowError:如果线程申请的栈深度大于虚拟机所支持的最大深度,会抛出StackOverflowError,通常这种情况都因为存在递归或者循环中的嵌套调用容易导致这种情况,也就是说一般出现StackOverflow的时候,可以看看代码中是否可以优化。

OutOfMemoryError:是的没有看错,出现OOM error,是因为虚拟机在扩展栈的时候无法申请到足够内存的时候会抛出OOM error,这种情况比较少见,但是存在这种可能。

方法区和运行时常量池溢出

因为运行时常量池包含在方法区中,我们一般通过限制方法区的大小就可以限制到常量池的大小。
在JDK1.7之前方法区主要就是永久代部分,因此我们通过-XX:PermSize和-XX:MaxPermSize来限制方法区的大小,从而间接限制其中的常量池的容量。
在JDK1.8之后,我们可以通过设置元数据区的大小来限制方法区–XX:MetaspaceSize。

本机直接内存溢出

直接内存容量可以通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆内存一样,当直接内存溢出的时候有一个明显特征的时候是在Heap Dump文件中不会看见明显异常,如果发现OOM之后dump文件很小,然而程序中使用了NIO可以考虑是不是MaxDirectMemorySize。

对象存活判断

我们了解了对象可以创建,使用还有当对象过多的时候,虚拟机会因为内存不够出现的各种异常。然后JVM有自己的对象回收机制,那么当JVM判定对象是不是还"存活",然后决定是不是要去回收这个对象。以下是常见的判断对象是否存活的方法。

引用计数算法

引用计数算法是这样的,给对象添加一个引用计数器,每当有一个地方调用他,就给计数器加1,每当引用失效,计数器就减1;任何时刻计数器为0的对象就不可以再被使用。引用计数算法简单高效但是Java虚拟机没有采用引用计数算法来管理内存,主要是因为很难解决对象之间互相循环引用的问题。但是Python语言有使用。

可达性分析算法

Java和C#采用可达性分析算法,基本思路就是通过一系列称为'GC Roots'的对象作为起点,从这个点开始向下搜索,搜索所走过的路径就是引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在明确了对象是否存活的算法后,那么Java程序中是怎么判断这个对象是不是‘非死不可’呢。其实即使在可达性分析算法中不可达的对象,也并非是‘非死不可’的,这时候他们只是暂时处于‘缓行’阶段,而要真正宣告一个对象死亡要至少经历两次标记过程。如果对象在经历可达性分析后发现没有与GC Roots相连接的引用,那么它将被第一次标记并且进行筛选,筛选的条件是此对象是否有必要执行finalize()方法,或者finalize()已经被虚拟机调用过,虚拟机将这两种情况都看作是‘没必要执行筛选’。

如果对象被判定是有必要执行筛选的,那么这个对象将被放在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自己创建的低优先级的线程去执行它。如果一个对象想要逃离死亡的命运,那么这时候是最后的一次机会,只要对象可以和引用链上的任何一个对象建立关系,那么在第二次标记的时候它将被移出“即将回收集合”;如果对象此时还没有逃脱,那么稍后GC将对F-Queue中的对象进行第二次标记,被第二次标记上的对象基本上就被真的回收了。

垃圾收集算法

查看默认垃圾收集算法

java -XX:+PrintCommandLineFlags -version

标记-清除算法

“标记-清除”(Mark-Sweep)算法,如同名字一样,算法分为两个阶段,标记和清除阶段。首先标记出所有需要回收的对象(可达性分析),在标记完成后统一回收所有被标记的对象。这是最基础的算法,但是有两个问题,效率问题和空间问题,标记和清除两个过程效率都不高;另外回收完成之后会产生大量不连续的内存空间,会导致在后面如果有大对象的时候,有可能找不到足够的连续内存从而不得不进行一次垃圾收集动作。
回收前
Mark-Sweep1
回收后
Mark-Sweep2

复制算法

为了解决“标记-清除”算法的效率问题,出现了复制算法,它将内存分为大小相等的两块,每次只使用一块。当这一块内存使用完了,就将还存活的的对象复制到另一块上面,然后再把另一块内存完全清理掉。这样就不用考虑内存碎片的问题,但是每次只能使用一般内存,代价太高。这种算法用来回收新生代,新生代中的对象大多都是‘朝生夕死’,所以并不用按照1比1来划分,而是将内存分为一块大的Eden空间和两块较小的Survivor空间,当回回收时,将Eden和1个Survior中存活的对象一次copy到另一个Survior中,也就是说最多就10%的浪费。HotSpon默认的Eden和Survior的比例是8:1。加入回收时1个Survior中的空间不够,那么需要依赖老年代来进行分配担保。
copy1

标记-整理算法

“标记-整理”(Mark-Compact),标记过程和标记-清除一样,但是接下来不是直接对可回收对象进行清理,而是让所有存活对象都想一端移动,然后清理掉端边界以外的部分。
Mark-Compact

分代收集算法

当前商业虚拟机的的垃圾收集都采用"分代收集"(Generational Collection)算法。就是根据对象存活周期将内存分为几块,一般Java堆是分为新生代和老年代,这样各个代就可以根据自己的特点选择对应的算法。在新生代对象只有少量存活就选择复制算法;而老年代对象存活率高,而且没有而外空间来担保,就必须采用“标记-清除”或者“标记-整理”算法来进行回收。

垃圾收集器

如果说算法是垃圾回收的方法论,那么垃圾收集器就是内存回收的具体实现了。虚拟机中并没有明确规定,因此不同的厂商,不同版本都有自己的实现。

Serial收集器

Serial是一个历史悠久的收集器,这个收集器是一个单线程的收集器。这里的单线程不是说只能有一个CPU或者1条收集线程来完成垃圾收集工作,而是说它在进行垃圾回收的时候,必须暂停其他工作线程,直到它收集结束。“Stop the world”听着很酷,但是实际用起来我们并不能接受,假设你的程序正在跑,但是隔一会就要你的程序停下来等待垃圾收集,例如等待5分钟,那么这种体验我们肯定不能接受,但是也不是说Serial就一无是处,在一些client的程序中Serial是一个不错的选择。

ParNew收集器

ParNew其实就是Serial的多线程版本,其他和Serial比起来没太大差别,但是ParNew在单CPU的环境下不会比Serial效果好,在两个CPU的环境中ParNew也不能说会稳超Serial,但是随着目前的技术,多核变得很常见,ParNew在一些CPU比较多的环境下,效果还是比较好的。我们可以通过参数-XX:ParallelGCThreads来限制参与垃圾收集的线程数。默认情况下开启的线程数和CPU数量相同。很多时候我们并不期待使用全部CPU资源。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,采用复制算法也是多线程的收集器。它和ParNew的区别是,其他收集器关注的是收集是尽可能缩短用户线程的停顿时间,而Parallel Scavenge是为了达到一个可控的吞吐量。所谓吞吐量=运行用户代码时间/(垃圾收集时间+用户代码运行时间)

/**
*设置最大垃圾收集停顿时间,但是必须是一个大于0的毫秒数,但是并不是说这个值设置到非常小,就能使垃圾收集速度快。
*/
-XX:MaxGCPauseMillis
/**
*设置吞吐量大小是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的占比
*/
-XX:GCTimeRatio:
//手工指定新生代大小
-Xmn

//Eden和Survivor的占比
-XX:SurvivorRatio

//GC自适应调节策略,设置了该参数就不需要手动指定新生代,也不需要指定Eden和Survivor占比等参数
-XX:+UseAdaptiveSizePolicy

Serial Old收集器

是Serial的老年代版本,也是单线程收集器,使用“标记-整理”算法。主要意义也是可以用

Parallel Old收集器

Parallel Scavenge的老年代版本,使用多线程和“标记-整理”算法。在一些注重吞吐量和CPU敏感的场合可以使用Parallel Scavenge+Parallel Old(Java8默认垃圾收集器)。

CMS收集器

CMS(Concurrent Mark Sweep)是一种以获取最短停顿时间的垃圾收集器,目前很大一部分Java互联网站或者B/S系统的服务端都采用这种收集器。是“标记-清除”算法实现的。他的运行过程复杂点,有四个过程:
初始标记:需要“Stop the world”,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
并发标记:是进行GC Roots追踪(GC Roots Tracing)的过程.这个过程和用户线程并发执行。
重新标记:需要“Stop the world”,是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分的对象的标记记录,这个停顿时间大于初始标记,但是远小于并发标记过程。
并发清除:和用户程序并发执行。
CMS的优点是,并发收集,低停顿。有一个明显缺点就是因为采用了“标记-清除”算法,最后会出现大量碎片,有可能会出现在某一个时刻,当有大对象生成,不得不进行一次Full GC来解决这个问题。为了解决该问题CMS有一个参数-XX:UseCmsCompactAtFullCollection来解决因为空间不足进行Full GC。这个参数默认开启,用于在CMS收集器顶不住要进行Full GC是开启内存碎片合并整理的过程,内存整理过程是无法并发的,因此就会耗时。同时还有一个参数是-XX:CMSFullGCsBeforeCompaction,这个参数是用于执行多次不压缩的GC后,跟着来一次压缩的(默认是0,表示每次进入Full GC都进行碎片整理)。

G1收集器

G1(Garbage-First)收集器是收集器比较前沿的成果之一,被视为HotSpon虚拟机的一个重要进化特征。也是一款面向服务端编程的收集器。
有以下有点:并行和并发,分代收集,空间整合,可预测的挺顿。
G1和其他收集器很大的不同是,其他收集器收集范围都是整个新生代和老年代,而G1不再是这样。使用G1收集器的时候,Java堆分为多个大小相等的区域(Region),虽然也保留了新生代和老年代的概念,但是不再是物理隔离了,他们都是一部分Region的集合(这些Region不一定是连续的)。

内存分配与回收策略

理解Minor GC和Major/Full GC
Minor GC:发生在新生代的垃圾收集动作,比较频繁,一般回收速度也快;
Major/Full GC:发生在老年代的垃圾收集动作,出现Major GC一般至少有一次Minor GC但是并非绝对如此。Major GC比Minor GC慢10倍以上。

对象有限分配在Eden

大多数时候对象优先在新生代的Eden区中分配。当Eden区没有足够空间时,虚拟机将发起一次Minor GC。虚拟机通过-XX:+PrintGCDetails参数告诉虚拟机在发生垃圾回收行为的时候打印垃圾回收日志。

大对象直接进入老年代

大对象也就是需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串以及数组。大对象对内存分配来说是坏消息,尤其出现很多频繁出现的短命大对象,很容易导致内存还有不少空间但是不得不触发垃圾回收来安置这些对象。虚拟机提供了-XX:PretenureSizeThreshold参数,令大于这个值的对象直接在老年代分配,这样做的目的就是为了避免在Eden区和Survivor之间发生大量的内存复制(新生代复制算法)。

XX:PretenureSizeThreshold只对Serial和ParNew两款收集器有效,如果在Parallel Scavenge遇到,可以考虑ParNew+CMS的收集器组合。

长期存活对象直接进入老年代

虚拟机采用了分代收集的思想来管理内存,那么怎么判断哪些内存应该在新生代,哪些应该在老年代呢。为了做到这点,虚拟机给每个对象定义了一个Age年龄计数器。具体算法是,如果在新生代发生一次Minor GC后仍然存活,并且可以被Survivor容纳的话,将被移动到Survivor,并且年龄设为1,对象每在Survivor熬过一次Minor GC,年龄就+1,当他的年龄加到15(默认15)加会晋升到老年代。这个值可以通过-XX:MaxTenuringThreshold设置。

动态对象年龄判断

虚拟机也不是永远要求年龄达到了MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

空间分配担保

在发生Minor GC前,虚拟机回先检查老年大最大可用连续内存空间是否大于新生代所有对象空间,如果条件成立,那么Minor GC是安全的。如果不成立,那么虚拟机会通过查看HandlePromotionFailure设置是否允许担保失败。如果允许,那么会继续检查老年代剩余最大连续空间是否大于晋升到老年代的对象的平均大小,如果大于将尝试进行一次Minor GC,尽管是有风险的;如果小于或者不允许冒险,那么这次改为一次Full GC。

附录

常用JVM调试命令

jstack:查看栈信息,可用于多线程时分析和查看线程运行状况。

jstack pid
#查看pid=443进程的栈信息
jstack 443

jmap:查看堆内存信息和生成dump文件

//查看进程的内存映像信息
1.jmap pid

//显示Java堆详细信息,打印一个堆的摘要信息,包括使用的GC算法、堆配置信息和各内存区域内存使用信息
2.jmap heap pid

//显示堆中对象的统计信息,其中包括每个Java类、对象数量、内存大小(单位:字节)、完全限定的类名。打印的虚拟机内部的类名称将会带有一个’*’前缀。如果指定了live子选项,则只计算活动的对象
3.jmap -histo:live pid

//打印类加载器信息,-clstats是-permstat的替代方案,在JDK8之前,-permstat用来打印类加载器的数据
打印Java堆内存的永久保存区域的类加载器的智能统计信息。对于每个类加载器而言,它的名称、活跃度、地址、父类加载器、它所加载的类的数量和大小都会被打印。此外,包含的字符串数量和大小也会被打印
4.jmap -clstats pid

//打印等待终结的对象信息,Number of objects pending for finalization: 0 说明当前F-QUEUE队列中并没有等待Fializer线程执行final
5.jmap -finalizerinfo pid

//生成堆转储快照dump文件,以hprof二进制格式转储Java堆到指定filename的文件中。live子选项是可选的。如果指定了live子选项,堆中只有活动的对象会被转储。想要浏览heap dump,你可以使用jhat(Java堆分析工具)读取生成的文件
6.jmap -dump:format=b,file=heapdump.phrof pid

jmap -dump:format=b,file=heapdump.phrof pid,这个命令执行,JVM会将整个heap的信息dump写入到一个文件,heap如果比较大的话,就会导致这个过程比较耗时,并且执行的过程中为了保证dump的信息是可靠的,所以会暂停应用, 线上系统慎用

jhat:可以在浏览器解析和查看dump文件

//查看dumpfile的内存详细
jhat dumpfile
qujianfei@troyMac oom % jhat java_pid3874.hprof
Reading from java_pid3874.hprof...
Dump file created Sat May 09 11:45:32 CST 2020
Snapshot read, resolving...
Resolving 814571 objects...
Chasing references, expect 162 dots..................................................................................................................................................................
Eliminating duplicate references..................................................................................................................................................................
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.