在 HotSpot JVM 实现中 Heap 内存被"分代"管理。
JVM 的内存首先被分割成两部分:
-Heap Memory 堆内存
堆内存是我们程序运行时可以申请的内存空间,用于存储程序运行时的数据信息。
-Non Heap Memory 非堆内存
除了堆内存区域用来存放存活(Living)的数据,JVM 还需要尤其是类描述、元数据等更多信息。所以这写信息统一被存放命名为 Permanent generation(永久/常驻代)的区域。
非堆内存其实就是 JVM 留给自己用的,所以方法区、JVM 内部处理或优化所需要的内存(如 JIT 编译后的代码缓存区)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造犯法的代码等都在非堆内存中。
非堆内存由 JVM 管理,我们无法在程序中使用。
Heap Memory 又被分为两大区域:
-Young/New Generation 新生代
新生对象放置在新生代中,新生代由 Eden 与 Survivor Space 组成。
-Old/Tenured Generation 老年代
老年代用存放程序中经过几次垃圾回收还存活的对象。
复制代码
1.1.1 Young/New Generation 新生代:
程序中新建的对象都将分配到新生代中,新生代又由 Eden(伊甸园)与两块 Survivor(幸存者) Space 构成。Eden 与 Survivor Space 的空间大小比例默认为 8:1,即当 Young/New Generation 区域的空间大小总数为 10M 时,Eden 的空间大小为 8M,两块 Survivor Space 则各分配1M,这个比例可以通过 -XX:SurvivorRatio 参数来修改。Young/New Generation 的大小则可以通过 -Xmn 参数来指定。
Eden:刚刚新建的对象将会被放置到 Eden 中,这个名称寓意着对象们可以在其中快乐自由的生活。
Survivor Space:幸存者区域是新生代与老年代的缓冲区域,两块幸存者区域分别为 s0 与 s1,当触发Minor GC 后将仍然存活的对象移动到S0中去(From Eden To s0)。这样 Eden 就被清空可以分配给新的对象。
当再一次触发 Minor GC 后,S0 和 Eden 中存活的对象被移动到 S1 中(From s0To s1),S0 即被清空。在同一时刻, 只有 Eden 和一个 Survivor Space 同时被操作。所以 s0 与 s1 两块 Survivor 区同时会至少有一个为空闲的,这点从下面的图中可以看出。
当每次对象从 Eden 复制到 Survivor Space 或者从 Survivor Space 之间复制,计数器会自动增加其值。 默认情况下如果复制发生超过 16 次,JVM 就会停止复制并把他们移到老年代中去。如果一个对象不能在Eden 中被创建,它会直接被创建在老年代中。
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,通常很多的对象都活不过一次 GC,所以 Minor GC 非常频繁,一般回收速度也比较快。
Minor GC 清理过程(图中红色区域为垃圾):
1、清理之前,看下图
复制代码
2、清理之后,看下图
复制代码
注意:图中的"From"与"To"只是逻辑关系而不是 Survivor Space 的名称,也就是说谁装着对象谁就是"From"。
一个对象在幸存者区被移动/复制的次数决定了它是否会被移动到堆中。
复制代码
1.1.2Old/Tenured Generation 老年代
老年代用于存放程序中经过几次垃圾回收后还存活的对象,例如缓存的对象等,老年代所占用的内存大小即为 -Xmx 与 -Xmn 两个参数之差。
堆是 JVM 中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了 new 对象的开销是比较大的,鉴于这样的原因,Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称为 TLAB(Thread Local Allocation Buffer),其大小由 JVM 根据运行的情况计算而得,在 TLAB 上分配对象时不需要加锁,因此 JVM 在给线程的对象分配内存时会尽量的在 TLAB 上分配,在这种情况下 JVM 中分配对象内存的性能和 C 基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配,TLAB 仅作用于新生代的 Eden,因此在编写 Java 程序时,通常多个小的对象比大的对象分配起来更加高效,但这种方法同时也带来了两个问题,一是空间的浪费,二是对象内存的回收上仍然没法做到像 Stack 那么高效,同时也会增加回收时的资源的消耗,可通过在启动参数上增加 -XX:+PrintTLAB 来查看 TLAB 这块的使用情况。
老年代GC(Major GC/Full GC):
指发生在老年代的 GC,出现了 Major GC,通常会伴随至少一次Minor GC(但也并非绝对,在 ParallelScavenge 收集器的收集策略里则可选择直接进行 Major GC)。Major GC 的速度一般会比 Minor GC 慢10倍以上。
虚拟机给每个对象定义了一个对象年龄(age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
当一个 Object 被创建后,内存申请过程如下:
1.JVM 会试图为相关 Java 对象在 Eden 中初始化一块内存区域。
2.当 Eden 空间足够时,内存申请结束。否则进入第三步。
3.JVM 试图释放在 Eden 中所有不活跃的对象(这属于1或更高级的垃圾回收), 释放后若 Eden 空间仍然不足以放入新对象,则试图将部分 Eden 中活跃对象放入 Survivor 区。
4.Survivor 区被用来作为新生代与老年代的缓冲区域,当老年代空间足够时,Survivor 区的对象会被移到老年代,否则会被保留在 Survivor 区。
5.当老年代空间不够时,JVM 会在老年代进行0级的完全垃圾收集(Major GC/Full GC)。
6.Major GC/Full G 后,若 Survivor 及老年代仍然无法存放从 Eden 复制过来的部分对象,导致JVM 无法在 Eden 区为新对象创建内存区域,JVM 此时就会抛出内存不足的异常。
复制代码
2. GC
2.1 什么是 GC
每个程序员都遇到过内存溢出的情况,程序运行时,内存空间是有限的,那么如何及时的把不再使用的对象清除将内存释放出来,这就是 GC 要做的事。
理解 GC 机制就从:"GC 的区域在哪里","GC 的对象是什么","GC 的时机是什么","GC 做了那些事"几方面来分析。
复制代码
2.2 需要 GC 的内存区域
JVM 中,程序计数器、虚拟机栈、本地方法栈都是随线程的而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存回收主要集中于 Java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的。
复制代码
2.3 GC 的对象
需要进行回收的对象就是已经没有存活的对象,判断一个对象是否存活常用的两种办法:
1、引用计数:
每个对象有一个引用计数属性,新增一个引用时计数加 1,引用释放时计数减 1,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题。
2、可达性分析:
从 GC Roots 开始向下搜索,搜索所走过的路径成为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。
在 Java 语言中,GC Roots 包括:
-虚拟机栈中的引用对象。
-方法区中类静态属性实体引用的对象。
-方法区中常量引用的对象。
-本地方法栈中 JNI 引用的对象。
复制代码
2.4 什么时候触发 GC
1、程序调用 System.gc 时可以触发
2、系统自身来决定 GC 触发的时机(根据 Eden 区和 From Space区的内存大小来决定。当内存大小不足时,则会启动 GC 线程并停止应用线程)。
GC 有分为 Minor GC 和 Full GC(也称为 Major GC)
-Minor GC 触发条件:
当 Eden 区满时,触发 Minor GC
-Full GC 触发条件:
a.调用 System.gc 时,系统建议执行 Full GC,但是不必然执行
b.老年代空间不足
c.方法区空间不足
d.通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
e.由 Eden 区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
复制代码
2.5 GC 做了什么事
主要做了清理对象,整理内存的工作。Java 堆分为新生代和老年代,采用了不同的回收方式。(回收方式即回收算法)
复制代码
3. GC 垃圾回收算法
3.1 GC 常用算法
GC 常用的算法有:
1、标记-清除算法
2、复制算法
3、标记-压缩算法
4、分代收集算法
目前主流的 JVM(HotSpot)采用的是分代收集算法。
复制代码
3.1.1 标记 - 清除算法:
如它的名字一样,算法分为"标记"和"清除"两个阶段。
首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
它的主要缺点有两个:
1、效率问题,标记和清除过程的效率都不高。
2、空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当前程序在以后的运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
复制代码
3.1.2 复制算法:
"复制"(Copying)的收集算法,它将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块就行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低,
复制代码
3.1.3 标记 - 压缩算法:
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保。以应对被使用的内存中所有对象都 100 % 存活的极端情况,所以在老年代一般直接选用这种算法。
根据老年代的特点,有人提出了另外一种"标记-整理"(Mark-Compact)算法,标记过程仍然与"标记-清除"算法一样,但后续的步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉一端边界以外的内存。
复制代码
3.1.4 分代收集算法
GC 分代的基本假设:
绝大部分对象的生命周期都非常短暂,存活时间短。
"分代收集"(Generational Collection)算法,把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都会发现有大批的对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而在老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记-清理"或"标记-整理"算法来进行回收。
复制代码
https://juejin.im/post/5d970ecfe51d45781b63b8eb