深入理解 JVM,自动内存管理机制

Java 虚拟机运行时内存区域:

  1. 程序计数器:可以看作当前线程执行的字节码的行号指示器。
  2. 虚拟机栈:描述的是 Java 方法执行的内存的模型,每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法的从调用直至执行完成,对应栈帧的入栈到出栈。
  3. 本地方法栈:作用与虚拟机栈类似,虚拟机栈是为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机使用的 Native 方法服务。
  4. 堆(线程共享):用于存放实例数据和数组
  5. 方法区(线程共享):存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码数据等。运行时常量池,属于方法区一部分,Class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。运行时常量池相比 Class 文件的常量池,还有一个动态性特点,运行期间也可能将新的常量放入池中。如 String intern()
HotSpot 虚拟机在 Java 堆中对象分配、布局和访问过程。
  • 对象分配:
    虚拟机遇到 new 指令,查看指令参数是否可以在常量池中定位到一个类符号引用,并且检查该符号引用代表的类是否已经被加载、解析和初始化过。如果没有将执行类加载过程;在类加载检查通过后,给对象分配内存。内存分配完成,虚拟机将内存空间都初始化为零值,保证实例字段在 Java 代码中不赋初始值就可以使用。接下来对对象进行必要设置,如对象所属类元信息、对象哈希码、GC 分代年龄等,存储在对象头中的,虚拟机角度已经产生了一个新对象。Java 程序角度,执行了 new 指令,还要执行 方法,按照程序员意愿进行初始化,对象真正创建完成。
    分配对象内存任务,相当于一个确定大小的内存从 Java 堆划分出来,划分方法分为“指针碰撞”、“空闲列表”,主要由 Java 堆内存是否规整决定,Java 堆是否规则又由所采用的垃圾收集器是否带有压缩整理功能决定。Serial、ParNew 等带 Compact 过程的收集器,系统采用是指针碰撞,而 CMS 基于 Mark-Sweep 算法的收集器是采用空闲列表。
  • 对象布局:
    对象在内存中的布局分为 3 部分:对象头、实例数据、对齐填充。
    对象头分为两部分:一部分存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针。如果对象是 Java 数组,对象头还必须有一块用于记录数组长度的数据。
    实例数据就是对象各种类型字段,无论是从父类继承,还是子类中定义的。
    对齐填充:HotSpot VM 自动内存管理要求对象起始地址为 8 字节的整数倍,就是说对象大小必须为 8 字节整数倍,对象头部分正好为 8 字节的倍数,实例数据部分没有对齐时,通过对齐填充来补齐,因此也不是必然存在的。
  • 对象访问:
    主流访问方式为:使用句柄和直接指针。
    如果使用句柄,Java 堆会划分一块内存作为句柄池,reference 中的存放的是句柄地址,而一个句柄中存放是到对象实例数据的指针和到对象类型数据的指针。
    使用直接指针,那 Java 对象布局中必须考虑如何防止访问类型数据的相关信息,而 reference 中存储的就是对象地址。
    HotSpot 虚拟机中使用直接指针进行对象访问,而对象布局,就是之前写的 对象布局方式。
垃圾收集器与内存分配策略
Java 运行时内存区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域会随着线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而执行入栈和出栈操作。每一个栈帧中分配多少内存基本是在类结构确定下就已知。
回收对象判定 垃圾收集器在对堆进行回收钱,第一件事是要确定这些哪些对象“存活”,而哪些是"死去",进行回收。
引用计数算法
给对象添加一个引用计数器,每当一个地方引用它,计数器值加 1 ;当引用失效,值就减 1;任何时刻计数器值为 0 对象就是判定可以被回收。这个方法的主要问题在于它很难解决对象之间循环引用问题。
可达性分析算法
通过一系列的称为 “GC Roots” 对象作为起始点,从这些节点向下搜索,搜索走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,证明对象时不可用的。
Java 中,作为 GC Roots 对象包括以下几种:
  • 虚拟机栈(栈帧中本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中的 JNI(一般说的 Native 方法)引用的对象
Java 引用的四种类型
强引用:类似 Object obj = new Object() 引用,只要强引用还在,垃圾收集器不会回收被引用的对象。
软引用:描述有用但非必需的对象。在发生 OOM 前,将会把这些对象列入范围中进行第二次回收。如果回收后还没有足够的内存,才抛出 OOM。SoftReference 类来实现软引用。
弱引用:描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集工作时,无论是否有足够内存,都将回收只被弱引用关联的对象。WeakReference 类来实现弱引用。
虚引用:最弱的引用关系。一个对象是否有虚引用存在完全不影响其生存时间,也无法通过虚引用来取得一个对象实例。PhantomReference 类来实现虚引用。
不可达对象的标记过程
第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相关的引用链,那么会对它进行第一次标记且进行一次筛选,条件是此对象是否有必要执行 finalize() 方法,如果没有覆盖 finalize() 或者 finalize() 已经被虚拟机执行过,那么判定为 “没有必要执行”。
第二次标记:接着如果对象判定为有必要执行 finalize() 方法,那么对象将进入 F-Queue 队列,虚拟机自动建立的、低优先级的线程将会执行它,即虚拟机触发这个方法。虚拟机不承诺会等待它执行结束,原因是如果 finalize() 中执行缓慢或发生死循环,那么可能会导致 F-Queue 队列其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,只要在 finalize() 方法中重新与引用链上的任何对象建立关联即可。这样在第二次标记时它将被移除出“即将回收” 的集合。
任何一个对象的 finalize() 方法都只会被系统自动调用一次。
回收方法区
虚拟机在方法区的回收效率较低,HotSpot 使用永久代实现方法区。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
废弃常量的回收与 Java 堆的对象的回收非常类似。
无用的类判定需要同时满足一下条件:
  • 该类的所有实例都已经被回收,也就是 Java 堆不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类的 Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足以上三个条件的无用的类进行回收,是“可以”,而不是和对象一样,不使用了就必然回收。
垃圾收集算法 【深入理解 JVM,自动内存管理机制】标记-清除算法
分为标记、清除两部分,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记对象,标记过程就是对象判定过程。主要有两个不足:一个是效率问题,标记和清除两个过程效率不高;另外一个是标记清除后产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集工作。
复制算法
将可用的内存分为大小相等的两块,每次只使用其中一块。当这一块内存用完了,就将还存活的对象复制到另外一块上,然后再把已使用过的内存空间一次清理掉。这样每次都是对整个半区进行内存回收,内存分配就不用考虑内存碎片等复杂情况。这种算法代价是内存缩小为原来的一半。
现在的商业虚拟机都采用这种算法来回收新生代。根据新生代对象特点,98% 的对象都是朝生夕死,因此不需要是 1:1 划分内存。可以将内存分为一块较大的 Eden 区和两块较小 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象,复制到另一块 Survivor 中,然后清理掉 Eden 和刚使用的 Survivor 空间。HotSpot 默认 Eden 和 Survivor 比例为 8:1,就是每次新生代可用的内存空间为整个新生代容量的 90%,只有 10% 内存会被 “浪费”。
当另外一个 Survivor 没有足够空间存放上一次新生代收集的存活对象,那么这些对象将直接通过 分配担保 进入老年代。
标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 空间,就需要额外的分配担保空间,以应对被使用的内存中的对象 100% 存活的极端情况,所以老年代一般不能直接选用这种算法。
标记-整理算法,标记过程与标记-清除算法一样,但后续步骤不是直接对可收回对象进行清理,而是所有存活的对象向一端移动,然后直接清理端边界以外的内存。
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法,根据对象存活周期的不同将内存分为几块。一般是分为新生代和老年代,根据各个年代特点采用适当的收集算法。
新生代对象大部分朝生夕死,只有少量存活,选用复制算法。
老年代对象存活率较高、没有额外空间进行分配担保,使用“标记-清理”或者"标记-整理"算法来回收。
内存分配与回收策略 Java 技术体系中所提倡的自动内存管理归结为自动化解决两个问题:给对象分配内存以及回收分配给对象的内存。
对象的内存分配,大方向是在堆上分配,主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,按线程优先在 TLAB 上分配,少数情况也会直接分配在老年代。
新生代 GC(Minor GC),Java 对象大部分朝生夕死,所以 Minor GC 非常频繁,回收速度也比较快。
老年代 GC(Major GC/Full GC),出现了老年代 Major GC ,经常伴随一次 Minor GC , Major GC 速度一般会比 Minor GC 慢 10 倍以上。
对象优先在 Eden 分配
当 Eden 区没有足够的空间,虚拟机将发起一次 Minor GC。
大对象直接进入老年代
需要大量连续内存空间的 Java 对象,需要的内存大小大于 -XX:PretenureSizeThreshold参数设置值,将直接在老年代进行分配。目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。
长期存活的对象将进入老年代
既然虚拟机采用分代收集思想来管理内存,那么内存回收时就必须能识别哪些对象放在新生代,哪些对象应放在老年代。为了做到这个,虚拟机在对象定义了个对象年龄计数器。(对象布局中的对象头中部分)
如果对象在 Eden 区出生并且经过第一次 Minor GC 后仍然存活,并且能够被 Survivor 容纳,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它年龄增加到一定程度(默认 15 )就晋升到老年代中。可以通过 -XX:MaxTenuringThrehold设置阈值。
动态对象年龄判定
为了更好适应不同程序的内存状况,虚拟机并不是永远要求对象年龄要达到阈值才能晋升老年代,如果 Survior 区中相同年龄的所有对象大小总和大于 Survivor 空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无需等待达到阈值年龄。
空间分配担保
新生代 Minor GC 后,如果仍然有大量对象存活,就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。
了解虚拟机自动内存分配和回收规则,可以根据具体应用场景,实现选择最优的收集方式和参数配置,达到调优效果。

    推荐阅读