Java 内存分配与回收机制

1,541 阅读13分钟
原文链接: www.jianshu.com

这篇文章主要讲Java内存的分配与回收机制,主要包括Java运行时的数据区域对象的创建垃圾收集算法回收策略
参考的书籍是周志明老师的《深入理解Java虚拟机》,笔者只是基于书中的内容对其总结概括并图文化。这部分内容几乎都是理解性的,为了便于理解和记忆所以尽量以图文的或表格的形式来展现。


一.运行时数据区域

下图是Java虚拟机运行时的内存示意图:



从图中我们可以看到Java内存总共分为6个部分:

  1. 程序计数器:每条线程都有一个独立的程序计数器计数器可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条所需执行的字节码指令、分支、循环、跳转、异常处理,线程恢复等基础功能都需要依赖这个计数器完成。
  2. Java虚拟机栈: 虚拟机栈是线程私有的,生命周期与线程相同。虚拟机栈为Java方法执行描述内存模型,每个方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。
  3. 本地方法栈:与虚拟机栈发挥的作用相似。区别是虚拟机栈为执行Java方法服务,本地方法栈为Native方法服务
  4. 堆:所有线程共享的区域。在虚拟机启动时创建,所有的对象实例几乎都在堆上分配。Java堆还可以细分为:新生代和老年代,再细致一点有Eden空间、From Survivor空间、To Survivor空间。不过无论如何划分,存储的都是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
  5. 方法区:方法区是各个线程共享的内存区域,主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。这块区域与Java堆一样不需要连续的内存和可以选择固定大小或可扩展外,还可以选择不实现垃圾收集。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,垃圾收集行为在这个区域较少出现。
  6. 运行时常量池:运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面符和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。
  7. 直接内存:直接内存也称堆外内存,它不是虚拟机运行时数据区的一部分。JDK1.4后引入NIO类,是一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接在堆外分配内存,然后通过存储在Java堆中的DirectByteBuffer对象作为引用对这块内存进行操作。这样能够显著提高性能,避免Java堆和Native堆中来回复制数据。

所以通过表格的形式概括如下:

数据区域 概括 线程共享
程序计数器 当前线程所执行的字节码的行号指示器
虚拟机栈 为Java方法执行创建栈帧存储局部变量、操作数栈、动态链接、方法出口等信息
本地方法栈 与虚拟机栈类似,为Native方法服务
存放对象实例
方法区 存储虚拟机已加载的类信息、常量、静态变量、即时编译后的代码等数据
运行时常量池 方法区的一部分,存放编译期生成的字面量和符号引用
直接内存 被分配在堆外的内存,性能高,不受Java堆的大小限制

二.对象的创建与内存布局

1.对象的创建


上图是对象创建的完整流程图,接下来做详细说明。

  1. 当虚拟机收到new指令后,检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化过。如果没有,必须先执行类加载过程。
  2. 在类加载完成后可以确定对象分配所需要的空间。如果Java堆中内存是绝对规整的,用过的内存放一边,空闲的内存放另一边,中间放着一个指针作为分界点的指示器,那分配内存就只是把指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞"。如果Java堆中内存不是规整的,空闲内存与使用过的内存是相互交错的,虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找出足够的空间分配给对象实例,并更新列表上的记录,这种分配方式称为"空闲列表"。采用哪种分配方式通常由虚拟机的垃圾收集器是否带有压缩整理功能决定。
  3. 划分可用空间时,还需考虑为对象实例分配空间时是否是线程安全的。要保证线程安全,有两种方案。一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。另一种是把内存分配的动作按照线程划分在不同空间中进行,每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer , TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
  4. 内存分配完成后,虚拟机对分配到的内存空间都初始化为零值(不包括对象头),保证对象的实例字段在Java代码中可以不赋初始值就可以直接使用。
  5. 虚拟机将对象的信息放入对象的对象头中。
  6. 执行构造函数

2.对象的内存布局



对象的内存布局总共分为三个部分:

  1. 对象头中主要包括两部分信息:

    • 一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
    • 另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是Java数组,那在对象头中还必须有一块记录数组长的数据。
  2. 实例数据部分是对象真正存储的有效信息,也是程序代码中定义的各种类型的字段内容。从父类继承下来的,在子类中定义的都需要记录下来。

  3. 对齐填充仅仅起到占位符的作用。HotSpot VM的自动内存管理系统要求对象起始地址是8字节的整数倍,所以对象大小必须是8字节的整数倍。当对象实例数据部分没有对齐时,需要通过对齐填充来补全。

三.内存的回收

1.对象存活判定

Java虚拟机通过可达性分析来判定对象是否存活。这个算法的基本思想是通过一系列称为"GC Roots"的对象作为起始点,从这些节点向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有与任何引用链相连时,则该对象是不可用的。
如图,object5,object6,object7虽然互有关联,但是GC Roots是不可达的,所以它们被判定是可回收的对象。

另外值得一提的是引用计数算法,引用计数法是通过给对象一个引用计数器,每当有一个地方引用它时,计数器值就加一;引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的。引用计数器效率高、实现简单。但是很难解决对象间相互循环引用的问题,主流Java虚拟机几乎都不再使用引用计数法来管理内存


可达性分析示意图

即使在可达性分析算法中不可达的对象,也不一定会立即被回收。一个对象被回收,至少要经历两次标记过程。
如果对象在进行可达性分析后没有与GC Roots相连的引用链,那它将会被第一次标记并进行一次筛选。筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或finalize()方法已被虚拟机调用过,虚拟机将这两种情况视为"没有必要执行"。
如果这个对象判定为有必要执行finalize()方法,那么这个对象会放置在F-Queue队列中,稍后由虚拟机自动建立、低优先级的Finalizer线程去执行finalize()方法。GC对F-Queue中的对象进行第二次小规模标记,如果对象重新与引用链上的任何一个对象建立关联,那么第二次标记时它将被移除"即将回收"的集合。否则对象就真的要被回收了。


Finalize方法

2.方法区回收判定

方法区的回收主要包括两部分内容:废弃常量无用的类

  • 废弃常量的回收与回收Java堆中的对象类似。
  • 判断无用的类的条件必须满足三个条件:
    • 该类所有实例已经被回收。
    • 加载该类的ClassLoader已被回收。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,也无法通过反射访问该类。

3.垃圾收集算法

  1. 标记-清除算法(Mark-Sweep)
    算法分为"标记"和"清除"两个阶段:首先标记出需要回收的对象,在标记完成后统一回收被标记的对象。它主要不足有两个:一是效率问题,标记和清除两个过程效率都不高。二是空间问题,标记清除后会产生大量不连续内存碎片,碎片太多可能导致要分配较大对象时,无法找到足够的内存空间不得不提前触发一次垃圾收集动作。


    标记-清除
  2. 复制算法
    复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块内存用完了,将存活的对象复制到另一块上面,然后把已使用的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等情况,只要移动堆顶指针,按顺序分配内存即可,实现简单运行高效。只是这种算法将内存缩小为原来的一半,代价较高。


    复制算法
  3. 标记-整理算法(Mark-Compact)
    标记过程与"标记-清除"算法一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存


    标记-整理算法

4.分代收集算法
商业虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期将内存划分为几块。Java堆分为新生代老年代,这样可以根据年代特点采用适当的收集算法。新生代中每次垃圾收集都有大批对象死去,那就选用复制算法。老年代对象存活率高,没有额外空间进行分配担保,适合使用"标记-清理"或"标记-整理"算法来回收。

4.内存分配与回收策略

  1. 对象优先在Eden分区:
    大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机发起一次Minor GC。GC后对象尝试放入Survivor空间,如果Survivor空间无法放入对象时,只能通过空间分配担保机制提前转移到老年代。

  2. 大对象直接进入老年代:
    大对象指需要大量连续内存空间的Java对象。虚拟机提供-XX:PretenureSizeThreshold参数,如果大于这个设置值对象则直接分配在老年代。这样可以避免新生代中的Eden区及两个Survivor区发生大量内存复制。

  3. 长期存活的对象进入老年代:
    虚拟机会给每个对象定义一个对象年龄计数器。如果对象在Eden出生并且经过一次Minor GC后任然存活,且能够被Survivor容纳,将被移动到Survivor空间中,并且对象年龄设为1.每次Minor GC后对象任然存活在Survivor区中,年龄就加一,当年龄到达-XX:MaxTenuringThreshold参数设定的值时,将会移动到老年代。

  4. 动态年龄判断:
    虚拟机不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold设定的值才会将对象移动到老年代去。如果Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

  5. 空间分配担保:
    在Minor GC前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC是成立的。如果不成立,虚拟机查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次移动到老年代对象的平均大小,如果大于,将尝试一次Minor GC。如果小于,或者HandlePromotionFailure设置值不允许冒险,那将进行一次Full GC。

新生代GC(Minor GC):发生在新生代的垃圾收集动作,因为Java对象大多朝生夕死,所以Minor GC非常频繁,回收速度也较快。

老年代GC(Major GC/Full GC):发生在老年代的垃圾收集动作。出现Major GC,经常会伴随至少一次Minor GC。Major GC的速度一般比Minor GC慢10倍以上。