深入理解Java虚拟机(三)-JVM内存分配和回收

601 阅读15分钟

深入理解Java虚拟机(三)-JVM内存分配和回收

这里主要讨论关于JVM内存分配和回收的部分,我们都知道java是面向对象的编程语言,我们一般都是通过new一个对象来生成相应的对象。那么我们在程序中调用new的时候,JVM是做了什么去帮我们生成对象的呢?

对象的创建

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

对象分配方式

在类加载检查通过后,接下来虚拟机将为对象分配内存,对象所需的内存大小在类加载完之后便可以完全确定。

指针碰撞 & 空闲列表。

  • 指针碰撞: 创建对象时,通过在内存空闲区域移动指针位置的方式分配内存 如果虚拟机堆中的内存是绝对完整的,所有用过的在一边,没用过的都在另一边,中间放一个指针作为分界点的指示器,当需要内存分配的时候,就简单把指针向空闲区域移动一段和对象大小相等的距离,这种分配方式就是指针碰撞
  • 空闲列表: 通过维护一个可用内存列表,然后在分配的时候在列表中找到合适的空间进行分配 如果堆中的内存并不是规整的,已经使用的内存和未使用的内存相互交错,那就无法使用指针碰撞的方式来分配内存,这时候虚拟机会维护一个列表来记录哪些内存是可以使用的,然后在分配的时候,找到一个足够大的空间划分给对象实例,并更新列表的记录,这种方式就叫做空闲列表

在了解了内存分配方式之后,那么选择那种分配方式是由jvm堆是否规整决定的,但是jvm堆是否规整是根据垃圾收集器是否带有压缩整理功能来决定的。

  • 标记整理(Mark-Compact)算法:指针碰撞,例如Serial Parnew等收集器
  • 标记清除(Mark-Sweep)算法:空闲列表,例如CMS收集器

对象的访问

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

目前常见的有使用句柄访问和直接指针两种。 image.png

  • 句柄访问: Java堆中将会划分出来一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象的类型数据和实例数据的具体地址信息。
  • 直接指针:(HotSpot虚拟机采用直接指针) 如果使用直接指针访问,Java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

优缺点对比

这两种方式访问各有优势

  • 使用句柄的好处是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而reference不需要改动。
  • 使用直接指针访问方式最大的好处就是速度更快,因为节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这些开销积少成多之后也是非常客观的的执行成本。

对象存活判断

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

  • 引用计数算法 引用计数算法是这样的,给对象添加一个引用计数器,每当有一个地方调用他,就给计数器加1,每当引用失效,计数器就减1;任何时刻计数器为0的对象就不可以再被使用。引用计数算法简单高效但是Java虚拟机没有采用引用计数算法来管理内存,主要是因为很难解决对象之间互相循环引用的问题但是Python语言有使用。
  • 可达性分析算法(Java采用可达性分析算法) Java和C#采用可达性分析算法,基本思路就是通过一系列称为"GC Roots"的对象作为起点,从这个点开始向下搜索,搜索所走过的路径就是引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的

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

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

堆的内存分配

内存分配与回收策略

在上面了解了对象的创建/访问/存活的方式判断,而且我们也知道堆是JVM中最大的一块内存区域,那么堆里面的内存区域是什么样子的呢?

堆被分为两个大块,一个叫做Old区,一个叫做Young区,其中Young区又分为一个Eden区和两个大小一样的Survivor区(S0,S1),这里面Old区是比较大的一块区域相比于Young区比较完整,Young区里面被分成的Eden和S0,S1的比例是8:1:1。

理解代新生代(Young),老年代(Old),幸存区(Survivor),Eden(新生区)

  • 老年代:(Old)
  • 新生代:(Young)
    • Eden: 伊甸园可以理解为新生区
    • Survivor区: 幸存区分为两部分Survivor0和Survivor1两个大小相同的区域; Survivor0和Survivor1: 也称之为from区和to区,from和to两个区是不断的互换身份并且S0和S1相等,同时保证其中有一块区域是空的,为什么要保证有一个是空的呢?这是因为Young区采用的是复制算法(关于垃圾收集算法和垃圾收集器参考深入理解Java虚拟机(四)-垃圾回收算法和垃圾收集器所以必须要确保有空间可以用来复制; image.png 对象的分配基本上都是在新生代,但是也有些特殊对象会直接分配在老年代。例如:大对象。 Minor GC和Major/Full GC
  • Minor GC:发生在Young区新生代的垃圾收集动作,比较频繁,一般回收速度也快;
  • Major GC:发生在Old区老年代的垃圾收集动作,出现Major GC一般至少有一次Minor GC但是并非绝对如此,Major GC比Minor GC慢10倍以上
  • Full GC: 新生代+老年代同时发生GC(一般发生MajorGC也会触发MinorGC,所以当MajorGC发生的时候也就发生了Full GC);

对象有限分配在Eden

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

大对象直接进入老年代

最典型的大对象就是很长的字符串以及数组。大对象对内存分配来说是坏消息,尤其出现很多频繁出现的短命大对象,很容易导致内存还有不少空间但是不得不触发垃圾回收来安置这些对象。虚拟机提供了-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。

对象申请内存空间判断流程图(关键点) image.png 到这里我们已经聊到了堆中内存区域的划分,以及堆中是怎么判断要不要进行一次垃圾回收的,而且不同区域进行垃圾回收所带来的影响(所需时间)。那么到这里一切就都结束了吗?

我们思考以下几个问题?

1.为什么Young要划分Survivor区域,只有Eden区域不行吗?

假设如果只有eden区,那么每进行一次MinorGC存活的对象就会被送到老年代,这样的话老年代会更快速被填满,出发MajorGC并且也有可能会触发FullGC,这样会消耗很多时间。所以Survivor区域的意义就是减少被送到老年代的对象次数,尽可能的保证不必要的对象在新生代中就被回收掉,让真的需要进去老年代的对象进去老年代。

2.为什么要划分两个Survivor区域?

假如只有一个S区的话,当新对象创建的时候,Eden区域满了,触发一次MinorGC,Eden区域存活的对象要被移动到S区,这样下去等到下次Eden满了的时候,此时MinorGC的时候,Eden区和S区都有部分存活对象,并且这两部分区域的内存很明显是不连续的,如果此时再把两部分存活的对象都放到S区,那么会导致S区出现大量不连续的内存空间可用率也很差。划分两个S区再结合Young本身采用的是复制算法,这样只要确保永远有一个空的S区可以用来复制,就可以避免掉内存不连续的问题

3.Young区域中为什么采用Eden和S0,S1大小8:1:1的方式,Eden区域空间那么大只保留一个S区域作为置换空间,空间够用吗?

因为在新生代使用复制算法,“(新生代中可用的内存):(复制算法用来担保的内存)比例为9:1”,再结合新生代为Eden和两个一样的S区,那么比例就是8:1:1. 另外如果Eden和S0打算和S1置换空间的时候,会进行MinorGC,一般回收后空间都不会超过S1的空间,如果回收后还不够那么就继续回收,直到S1够所需空间。

4.堆内存都是线程共享的区域吗?

其实并不完完全全是线程共享的,JVM默认为每个线程在Eden区域上开辟一个Buffer区域,当需要内存分配的时候,就在自己的Buffer区域来分配,这样可以提高分配效率,但是这个Buffer区域空间一般来说比较小,用来加速对象分配称之为TLAB(Thread Local Allocation Buffer)。这块区域其他线程还是可以读取的,只不过无法在这个区域在分配内存而已。简单来说就是TLAB中读取,移动操作是线程共享的,但在内存分配上是线程独享的,TLAB并不影响垃圾回收。因此针对“堆内存都是线程共享的区域吗”这句话并不完全正确,因为TLAB是堆内存的一部分,他在读取上是线程共享的,但在内存分配上是线程独享的。另外说一点TLAB和ThreadLocal是两个东西,没有什么必然联系。

附录-运行时数据区对应的异常

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。