Java内存模型和GC机制

最近学习Python的GC机制时,想到了java的GC,忘得差不多了,(⊙﹏⊙)b!!这里便做一下回顾总结。推荐周志明译本的《深入理解Java虚拟机》。

1. Java内存模型

1.1 程序计数器

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

这部分的内存区域是线程私有的。JVM中的多线程是通过线程轮流切换,每个线程在CPU分配的时间片执行的方式来实现的。任何一时刻,每个CPU内核都只会执行一个线程,线程切换的时候会保存上一个任务的状态,以便下次切换会这个任务时再加载这个任务。程序计数器的作用就是在做上下文切换的时候,可以让程序恢复到正确的位置。

1.2 Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期和线程相同。虚拟机描述的是Java方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、返回值等信息。每一个方法从调用直至执行完成的过程,就雪莹这一个栈帧在虚拟机栈中入栈到出栈的过程。

通常会粗粒度的把Java内存划分为堆内存(Heap)和栈内存(Stack),这里的栈内存讲的就是虚拟机栈(局部变量表部分)。

局部变量存放了编译器可知的各种基本数据类型、引用类型。64位的long和double类型数据会占用2个局部变量空间,其余数据类型只占用1个。局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法在栈中分配多大的空间是确定的,在方法运行期间不会改变局部变量表的大小。

1.3 本地方法栈

本地方法栈和虚拟机栈的作用是非常类似的,在HotSpot虚拟机中把这两部分合到了一起。本地方法栈和虚拟机栈的区别是:虚拟机栈为虚拟机执行Java方法(即字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。

1.4 Java堆

Java堆是JVM所管理的内存中最大的一块,它是被所有线程所共享的一块内存区域。在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是GC管理的主要区域。从内存回收的角度来看,由于现在基本上都采用分代回收算法,Java堆还可以分为新生代和老年代,后面小节会详细介绍。

Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可。在实现中,既可以实现成固定大小的,也可以是可扩展的。可以通过-Xmx(最大值)和-Xms(最小值)配置。

1.5 方法区

方法区也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然JVM规范把方法区描述为堆的一部分,它却有一个别名(Non-Heap)非堆。

有人称方法区为永久代,但其实永久代只是HotSpot虚拟机对方法去这个概念的实现。在JDK1.8,永久代已被移除,用元空间代替,元空间不再使用虚拟机内存,而直接使用本地的系统内存。

方法区中有一部分叫做常量池,用来存放编译期生成的各种字面量和符号引用,这部分内容在类加载后放入方法区的常量池中。

1.6 直接内存

主要用在NIO中,它提供了一个DirectByteBuffer对象,可以直接直接访问系统内存,可以避免在Java堆和Native堆中来回切换数据。

2. 对象创建过程

  1. 首先虚拟机会检查常量池中类的信息,如果没有,需要先加载类信息。检查通过后,JVM将为新生对象分配内存,对象所需的内存大小在类加载完之后就可以确定,为对象分配空间的任务其实就是将一块确定大小的内存从Java堆中划分出来。

    分配内存有两种方式:

    • 指针碰撞:假设堆中的内存是绝对规整的,所有使用过的内存都放在一遍,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那么为新对象分配内存时只需要将指针向空闲空间的那边挪动一段与对象大小相等的距离即可
    • 空闲列表:假设堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录哪些内存时可用的,在分配的时候从列表中找到一块足够大的空间划分给新的对象,并更新列表上的记录
  2. 内存分配完成后,JVM将分配到的内存空间都初始化零值

undefined接下来JVM要对对象进行必要的设置,例如对象是哪个类的实例,如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄,这些信息将保存在对象的对象头中

undefined下面将是执行init,根据编写的代码对对象进行初始化,对象创建完成

3. Java引用类型

Java中将引用分为了四种类型:强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)。

  • 强引用:指的是类似Object obj=new Object()这样显示声明的对象引用,是最普遍存在的引用,只要强引用还在,GC永远不会回收掉被引用的对象,即使抛出OutOfMemmoryError,使程序终止。
  • 软引用:用来描述一些还有用但非必需的对象。对于软引用关联的对象,在系统即将发生OOM错误之前,将会对这些对象进行回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。可以使用SoftReference类来实现软引用。可以使用软引用来构建缓存。
  • 弱引用:用来描述非必须对象,优先级比软引用要低,在垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
  • 虚引用:最弱的一种引用关系,一个对象是否有虚引用的存在,不会对其生存时间构成影响,也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
引用类型 被垃圾回收时间 用途 生存时间
强引用 从来不会 对象的一般状态 JVM停止运行时终止
软引用 在内存不足时 对象缓存 内存不足时终止
弱引用 在垃圾回收时 对象缓存 gc运行后终止
虚引用 Unknown Unknown Unknown

4. 垃圾检测

垃圾回收(Garbage Collection)是JVM垃圾回收器提供的一种在空闲时间,不定时回收无任何引用对象占用的内存空间的一种机制。那么如何判定一个对象已经没有任何引用了呢?

4.1 引用计数法

每个对象都有一个引用计数器,当一个对象被创建初始化后,该数字就为1。每当别的地方引用它时,计数器就会加1。当引用失效(如超出作用域,引用指向新的对象等),计数器就会减1。如果对象的引用计数为0,则就会被GC回收。

引用计数的优点是执行简单、判定效率高。缺点是无法解决对象之间的循环引用问题。

# python简单演示循环引用
class C():
    def __init__(self):
        print('内存地址是:%s' % str(hex(id(self))))

def test():
        c1 = C()
        c2 = C()
        c1.t = c2 # &c2的引用计数加1,变为2
        c2.t = c1 # &c1的引用计数加1,变为2
        del c1 # c1指向的对象引用计数减1,变为1
        del c2 # c2指向的对象引用数减1,变为1
test()

# 即使是将当前对象的引用删除,由于原来c1和c2对象中还引用着彼此,所以引用计数都不为0,无法被GC回收

4.2 可达性分析算法

Java通常采用可达性分析(Reachability Analysis)来判定对象是否存活的。它是从离散数学中的图论引入的。

基本思路是:先找到一组对象作为GC Roots(根节点),然后从根节点开始遍历,遍历结束后,如果发现某个对象与GC Roots没有任何引用链相连(即该对象不可达),就证明该对象就是不可用的垃圾对象,GC会在接下来清除它们。

即使是循环引用的对象,如果与根节点没有引用链,依然会被GC回收。

以下对象可以作为GC Roots:

  • 虚拟机栈(栈帧中的本地变量表)中的引用的对象
  • 方法区中的类静态属性以及常量引用的对象
  • 本地方法栈中Native方法引用的对象
  • 存活的线程

在使用可达性分析遍历对象图的时候,有几个关键点需要注意:

  • GC停顿:在整个分析期间不能出现对象引用关系还在不断变化的情况,所以在GC进行的时候必须要停顿所有的线程(Stop The World),停顿的位置称为安全点(Safepoint),一般在循环的末尾、方法返回前、抛出异常的位置等。如果发生GC的时候,线程还没有执行到一个安全点,线程继续执行,到达下一个安全点的的时候暂停,然后等待GC;
  • finialize():在可达性分析中不可达的对象,真正宣判它的死亡,需要两次标记过程:
    • 如果对象在进行可达性分析过后没有与GC Roots相连,那么它会被第一次标记并且进行第一次筛选,筛选的条件是此对象有没有必要执行finalize()方法。当对象没有覆盖finalize方法或者已经被JVM调用过,该对象会被视作“没有必要执行”。
    • 如果对象被判定为有必要finalize()方法,那么这个对象将会被放置在一个F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。由于finalize()只会被系统调用一次,这是对象完成“自我救赎”的最后一次机会。稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
    • 建议尽量不要去使用finalize()方法。

5. 垃圾回收

5.1 标记-清除(Mark-Sweep)

标记-清除算法是最基础的收集算法。它分为两个阶段:

  • 标记:标记阶段的任务就是标记出所有需要被回收的对象
  • 清除:回收被标记对象所占用的内存空间

优点:不需要移动对象,仅需要对不存活的对象进行处理,在对象存活率较高的场景下极为高效

缺点:

  • 效率问题:标记和清除的效率都不高,需要维护一张空闲列表
  • 空间问题:标记清除后会产生大量不连续的内存碎片,当分配大对象时,因为找不到足够的连续内存空间而不得不提前触发另一次GC

5.2 标记-整理(Mark-Compact)

与标记-清除法类似,但标记过后不是对可回收对象进行清理, 而是将所有存活的对象都向一段,然后直接清理掉边界以外的内存。

优点:经过整理过后,新对象的分配只需要指针碰撞即可完成,而且不会再有碎片问题

缺点:需要将所有的对象都拷贝到一个新的地址,并且更新引用地址,GC停顿较长

5.3 复制(Copying)

该算法的提出是为了解决句柄开销和内存碎片问题。它将可用内存分为大小相等的两块区域,每次只使用其中一块。当一块中的内存用完了,就将还存活的对象复制到另外一块区域上面,然后将使用过的内存空间一次性清理掉。

优点:

  • 标记和复制阶可以同时执行
  • 每次是对整块半区进行回收,在对象存活率较低的场景下效率较高
  • 分配对象时不用考虑碎片问题

缺点:实际可用内存缩小为原来的一半

5.4 分代回收(Generational Collection)

JVM中采用的是分代回收,它根据对象的存活周期将内存区域分为新生代和老年代。

新生代中:对象生命周期短,每次GC时都有大批对象死去,只有少量存活,比较适用于复制方法。

老年代:对象存活时间极长,比较实用于标记-清理或标记-整理方法。

Python中也采用分代回收方法,将对象分为0、1、2代,可参照文章了解。

6. Java中的分代回收

JVM中的堆内存按照GC的角度可分为新生代和和老年代,新生代又可以分为三个部分:Eden和两个Survivor区(Survivor0、Survivor1)。

新生代GC:Minor GC,非常频繁,回收速度比较快

老年代GC:Major GC,一般会伴随着Minor GC,对整个堆内存做一次GC,所以也称Full GC,频次较低,速度较慢

6.1 新生代

几乎所有新创建的对象都是放在了年轻代。新生代在GC时,采用的是复制算法,由于新生代中的对象生命周期大都很短,所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为较大的Eden区,和两块较小的Survivor区,三者的比例一般是8:1:1,可以通过-XX:SurvivorRatio设置。

大部分对象是在Eden中生成。GC时大致的过程如下:

  • 当创建新的对象时,如果Eden空间不足时,会触发一次Minor GC。回收时,先将Eden区的存活对象复制到S0区
  • 当再次触发Minor GC时,会将Eden和S0区存活的对象复制到S1区,清空Eden和S0区
  • 每次Minor GC时,都会对Eden和其中一个Survivor区域操作,将存活的对象放入到另外一个Survivor区中,如此反复。
  • 如果另外一块Survior区没有足够空间存放上一次Mionr GC下存活的对象,这些对象将存放到老年代(这种称之为分配担保)
  • 每当对象在Survivor区经历一次GC存活下来,它的年龄将加1,如果年龄达到N(一般是15)岁,就会移动到老年代中

6.2 老年代

老年代中存在的一般都是生命周期比较长的对象,它的空间也比新生代大很多(一般是2:1),一般采用的标记-整理方法。需要注意的有以下几点:

  • 如上一小节所说,在新生代长期存活的对象将会被放入到老年代中

  • 大对象(很长的字符串、长数组等)直接进入老年代,大对象可能导致内存还有不少空间时就提前触发Minor GC以获取足够的连续空间,也可以避免在Eden和Survivor区之间发生大量的内存复制。大对象的阈值可以通过参数设置

  • Survivor空间中,如果相同年龄的对象大小总和,大于Survivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代

  • 当老年代中的空间不足不足以存放即将升入老年代的对象时,会触发一次Full GC。

  • 发生Minor GC之前,由于可能存在大量对象存活的情况(假如100%存活),虚拟机会检查老年代中剩余空间是否大于新生代所有对象总空间,如果这个条件成立,Minor GC可以确保是安全的。如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代中最大的可用连续空间,是否大于历次晋升到老年代对象的平均大小。如果大于,将尝试Minor GC。如果小于、或者设置中不允许担保失败,或者在Minor GC时担保失败,则会发生一次Full GC。

    总结

    以上粗浅的介绍了JAVA中的GC机制。由于每次GC都会造成GC停顿,所以在开发过程中,尽可能减少GC的开销。比如尽可能不要显式调用System.gc()、字符串拼接时尽量使用StringBuffer、能使用基本类型的地方不要使用包装类等。

推荐阅读更多精彩内容