JVM 内存深度介绍

关于 JVM 内存管理或者说垃圾收集,大家可能看过很多的文章了,笔者准备给大家总结下。我在这一块也学习了很多次,也是断断续续学习。在以后的文章中也会出现知识点的补充之类的事情,我没有对自己的文章有一个规划,主要是学习了一些什么知识之后,对它有部分理解之后我就会写部分,主要是为了记录自己学习和当时理解思路并做一个简单的分享而已。

GC Roots 有哪些:

  • 当前各线程执行方法中的局部变量(包括形参)引用的对象
  • 已被加载的类的 static 域引用的对象
  • 方法区中常量引用的对象
  • JNI 引用

以上不完全,但是已经够用了。

可达性分析:通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。 

引用计数法就不详细介绍了。引用计数法缺点?

思考为什么可达性分析需要经过两次以上标记?

   如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行**finalize()**方法,当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过了,虚拟机将这两种情况都视为"没有必要执行",此时的对象才是真正’死’的对象。  

 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它(即就是虚拟机触发finalize()方法),finalize()方法是对象逃脱死亡的最后一次机会,如果对象在finalize()中成功拯救自己 (只需要与引用链上的任何一个对象建立起关联关系即可),那在第二次标记时它将会被收回(finalize()方法只能自救一次哦);如果对象在finalize()中没有成功拯救自己,就会被立刻被收回。 

任何一个对象的finalize()方法都只会被系统调用一次,如果相同的对象在逃脱一次后又面临一次回收,它的fianlize()方法不会被再次执行。

为什么finalize()方法都只会被系统调用一次,并且判断条件??有什么优点??

样例代码:

public class thread_test {        public static  thread_test test;        public void isAlive(){            System.out.println("线程活着");        }        @Override        public void finalize() throws Throwable {            super.finalize();            System.out.println("finalize方法执行");            //与引用链上的任何一个对象建立起关联关系            test = this;        }        public static void main(String[] args) throws InterruptedException {            //1.在堆上创建对象            test = new thread_test();            //2.test置空 堆上的对象没有任何栈内存指向            test = null;            //3.调用垃圾回收机制 但是由于此对象覆写了finalize方法 可以缓刑            System.gc();            //4.垃圾回收需要时间            Thread.sleep(500);            if(test != null){                test.isAlive();            }else{                System.out.println("线程死亡");            }            //------------------------------------------            //下面的代码与上面完全一致,但是此时自救失败            test = test;            System.gc();            Thread.sleep(500);            if(test != null){                test.isAlive();            }else{                System.out.println("线程死亡");            }            test = null;            System.gc();            Thread.sleep(500);            if(test != null){                test.isAlive();            }else{                System.out.println("线程死亡");            }        }}
复制代码

垃圾回收:

minjorGC:当年轻代被填满后,会进行一次年轻代垃圾收集

full GC/major GC:full GC 会收集所有区域,先进行年轻代的收集,使用年轻代专用的垃圾回收算法,然后使用老年代的垃圾回收算法回收老年代和永久代。如果算法带有压缩,每个代分别独立地进行压缩。

基于统计,计算出每次年轻代晋升到老年代的平均大小,if (老年代剩余空间 < 平均大小) 触发 full gc。

快速分配:

对于多线程应用,对象分配必须要保证线程安全性,如果使用全局锁,那么分配空间将成为瓶颈并降低程序性能。HotSpot 使用了称之为 Thread-Local Allocation Buffers (TLABs) 的技术,该技术能改善多线程空间分配的吞吐量。首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区中进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了它的 TLAB,它才需要使用同步来获取一个新的缓冲区。HotSpot 使用了多项技术来降低 TLAB 对于内存的浪费。比如,TLAB 的平均大小被限制在 Eden 区大小的 1% 之内。TLABs 和使用指针碰撞的线性分配结合,使得内存分配非常简单高效,只需要大概 10 条机器指令就可以完成。

Concurrent Mark-Sweep(CMS)收集器

CMS 收集过程首先是一段小停顿 stop-the-world,叫做 初始标记阶段(initial mark),用于确定 GC Roots。然后是 并发标记阶段(concurrent mark),标记 GC Roots 可达的所有存活对象,由于这个阶段应用程序同时也在运行,所以并发标记阶段结束后,并不能标记出所有的存活对象。为了解决这个问题,需要再次停顿应用程序,称为 再次标记阶段(remark),遍历在并发标记阶段应用程序修改的对象(标记出应用程序在这个期间的活对象),由于这次停顿比初始标记要长得多,所以会使用多线程并行执行来增加效率

再次标记阶段结束后,能保证所有存活对象都被标记完成,所以接下来的 并发清理阶段(concurrent sweep) 将就地回收垃圾对象所占空间。下图示意了老年代中 串行、标记 -> 清理 -> 压缩收集器和 CMS 收集器的区别:

由于部分任务增加了收集器的工作,如遍历并发阶段应用程序修改的对象,所以增加了 CMS 收集器的负载。对于大部分试图降低停顿时间的收集器来说,这是一种权衡方案。

CMS 收集器是唯一不进行压缩的收集器,在它释放了垃圾对象占用的空间后,它不会移动存活对象到一边去。

这将节省垃圾回收的时间,但是由于之后空闲空间不是连续的,所以也就不能使用简单的 指针碰撞(bump-the-pointer) 进行对象空间分配了。它需要维护一个 空闲列表,将所有的空闲区域连接起来,当分配空间时,需要寻找到一个可以容纳该对象的区域。显然,它比使用简单的指针碰撞成本要高。同时它也会加大年轻代垃圾收集的负载,因为年轻代中的对象如果要晋升到老年代中,需要老年代进行空间分配。

另外一个缺点就是,CMS 收集器相比其他收集器需要使用更大的堆内存。因为在并发标记阶段,程序还需要执行,所以需要留足够的空间给应用程序。另外,虽然收集器能保证在标记阶段识别出所有的存活对象,但是由于应用程序并发运行,所以刚刚标记的存活对象很可能立马成为垃圾,而且这部分由于已经被标记为存活对象,所以只能到下次老年代收集才会被清理,这部分垃圾称为 浮动垃圾

最后,由于缺少压缩环节,堆将会出现碎片化问题。为了解决这个问题,CMS 收集器需要追踪统计最常用的对象大小,评估将来的分配需求,可能还需要分割或合并空闲区域。

不像其他垃圾收集器,CMS 收集器不能等到老年代满了才开始收集。否则的话,CMS 收集器将退化到使用更加耗时的 stop-the-world、标记-清除-压缩 算法。为了避免这个,CMS 收集器需要统计之前每次垃圾收集的时间和老年代空间被消耗的速度。另外,如果老年代空间被消耗了 预设占用率(initiating occupancy),也将会触发一次垃圾收集,这个占用率通过 –XX:CMSInitiatingOccupancyFraction=n 进行设置,n 为老年代空间的占用百分比,默认值是 68

CMS 收集器可以使用增量模式,在并发标记阶段,周期性地将自己的 CPU 时钟周期让出来给应用程序。这个功能适用于需要 CMS 的低延时,但是 CPU 核心只有 1 个或 2 个的情况。

何时使用 CMS 收集器

适用于应用程序要求低停顿,同时能接受在垃圾收集阶段和垃圾收集线程一起共享 CPU 资源的场景,典型的就是 web 应用了。

G1收集器

G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,这一点非常重要。

G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。

如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。

而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。

G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间。

用 -XX:MaxGCPauseMillis=200 指定期望的停顿时间。

G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收。

G1 比 ParallelOld 和 CMS 会需要更多的内存消耗,那是因为有部分内存消耗于簿记(accounting)上,如以下两个数据结构:

  • Remembered Sets:每个区块都有一个 RSet,用于记录进入该区块的对象引用(如区块 A 中的对象引用了区块 B,区块 B 的 Rset 需要记录这个信息),它用于实现收集过程的并行化以及使得区块能进行独立收集。总体上 Remembered Sets 消耗的内存小于 5%。

  • Collection Sets:将要被回收的区块集合。GC 时,在这些区块中的对象会被复制到其他区块中,总体上 Collection Sets 消耗的内存小于 1%。

G1 收集器主要包括了以下 4 种操作:

  • 1、年轻代收集

  • 2、并发收集,和应用线程同时执行

  • 3、混合式垃圾收集

  • 必要时的 Full GC

  1. 初始标记:stop-the-world,它伴随着一次普通的 Young GC 发生,然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。

    因为 Young GC 是需要 stop-the-world 的,所以并发周期直接重用这个阶段,虽然会增加 CPU 开销,但是停顿时间只是增加了一小部分。

  2. 扫描根引用区:因为先进行了一次 YGC,所以当前年轻代只有 Survivor 区有存活对象,它被称为根引用区。扫描 Survivor 到老年代的引用,该阶段必须在下一次 Young GC 发生前结束。

    这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Young GC。

  3. 并发标记:寻找整个堆的存活对象,该阶段可以被 Young GC 中断。

    这个阶段是并发执行的,中间可以发生多次 Young GC,Young GC 会中断标记过程

  4. 重新标记:stop-the-world,完成最后的存活对象标记。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。

    Oracle 的资料显示,这个阶段会回收完全空闲的区块

  5. 清理:清理阶段真正回收的内存很少。

那么什么时候会启动并发标记周期呢?这个是通过参数控制的,下面马上要介绍这个参数了,此参数默认值是 45,也就是说当堆空间使用了 45% 后,G1 就会进入并发标记周期。

G1 参数配置和最佳实践

G1 调优的目标是尽量避免出现 Full GC,其实就是给老年代足够的空间,或相对更多的空间。

有以下几点我们可以进行调整的方向:

  • 增加堆大小,或调整老年代和年轻代的比例,这个很好理解
  • 增加并发周期的线程数量,其实就是为了加快并发周期快点结束
  • 让并发周期尽早开始,这个是通过设置堆使用占比来调整的(默认 45%)
  • 在混合垃圾回收周期中回收更多的老年代区块

G1 的很重要的目标是达到可控的停顿时间,所以很多的行为都以这个目标为出发点开展的。

我们通过设置 -XX:MaxGCPauseMillis=N 来指定停顿时间(单位 ms,默认 200ms),如果没有达到这个目标,G1 会通过各种方式来补救:调整年轻代和老年代的比例,调整堆大小,调整晋升的年龄阈值,调整混合垃圾回收周期中处理的老年代的区块数量等等。

什么时候回收 Metaspace 空间

分配给一个类的空间,是归属于这个类的类加载器的,只有当这个类加载器卸载的时候,这个空间才会被释放。

所以,只有当这个类加载器加载的所有类都没有存活的对象,并且没有到达这些类和类加载器的引用时,相应的 Metaspace 空间才会被 GC 释放。

所以,一个 Java 类在 Metaspace 中占用的空间,它是否释放,取决于这个类的类加载器是否被卸载。

内存通常会被保留

释放 Metaspace 的空间,并不意味着将这部分空间还给系统内存,这部分空间通常会被 JVM 保留下来。

这部分被保留的空间有多大,取决于 Metaspace 的碎片化程度。另外,Metaspace 中有一部分区域 Compressed Class Space 是一定不会还给操作系统的。

什么是 Compressed Class Space

在 64 位平台上,HotSpot 使用了两个压缩优化技术,Compressed Object Pointers (

“CompressedOops”

) 和 Compressed Class Pointers

压缩指针,指的是在 64 位的机器上,使用 32 位的指针来访问数据(堆中的对象或 Metaspace 中的元数据)的一种方式。

这样有很多的好处,比如 32 位的指针占用更小的内存,可以更好地使用缓存,在有些平台,还可以使用到更多的寄存器。

当然,在 64 位的机器中,最终还是需要一个 64 位的地址来访问数据的,所以这个 32 位的值是相对于一个基准地址的值。

Metaspace 可能在两种情况下触发 GC:

1、分配空间时:虚拟机维护了一个阈值,如果 Metaspace 的空间大小超过了这个阈值,那么在新的空间分配申请时,虚拟机首先会通过收集可以卸载的类加载器来达到复用空间的目的,而不是扩大 Metaspace 的空间,这个时候会触发 GC。这个阈值会上下调整,和 Metaspace 已经占用的操作系统内存保持一个距离。

2、碰到 Metaspace OOM:Metaspace 的总使用空间达到了 MaxMetaspaceSize 设置的阈值,或者 Compressed Class Space 被使用光了,如果这次 GC 真的通过卸载类加载器腾出了很多的空间,这很好,否则的话,我们会进入一个糟糕的 GC 周期,即使我们有足够的堆内存。

内存什么时候会还给操作系统

当一个 VirtualSpaceListNode 中的所有 chunk 都是空闲的时候,这个 Node 就会从链表 VirtualSpaceList 中移除,它的 chunks 也会从空闲列表中移除,这个 Node 就没有被使用了,会将其内存归还给操作系统。

猜你喜欢

转载自juejin.im/post/7017734259649544199