关于Java虚拟机这块内容的学习我是看CSDN的一位博客专家大佬@黄小斜的文章学习的,它写的内容更为全面详细,有兴趣可以去看它的文章:深入理解JVM虚拟机3:垃圾回收器详解。
这里仅对此文进行笔记整理。
GC 的工作内容:
• 分配内存,为每个新建的对象分配空间
• 确保还在使用的对象的内存一直还在,不能把有用的空间当垃圾回收了
• 释放不再使用的对象所占用的空间
对于内存分配请求,实现的难点在于在堆中找到一块没有被使用的确定大小的内存空间。所以,对于大部分垃圾回收算法来说避免内存碎片化是非常重要的,它将使得空间分配更加高效。
年轻代中的收集是非常频繁的、高效的、快速的,因为年轻代空间中,通常都是小对象,同时有非常多的不再被引用的对象。
那些经历过多次年轻代垃圾收集还存活的对象会晋升到老年代中,老年代的空间更大,而且占用空间增长比较慢。这样,老年代的垃圾收集是不频繁的,但是进行一次垃圾收集需要的时间更长。
对于新生代,需要选择速度比较快的垃圾回收算法,因为新生代的垃圾回收是频繁的。
对于老年代,需要考虑的是空间,因为老年代占用了大部分堆内存,而且针对该部分的垃圾回收算法,需要考虑到这个区域的垃圾密度比较低。
如果垃圾收集完成后,存在大片连续的内存可用于分配给新对象,这种情况下分配空间是非常简单快速的,只要一个简单的指针碰撞就可以了,每次分配对象空间只要检测一下是否有足够的空间,如果有,指针往前移动 N 位就分配好空间了,然后就可以初始化这个对象了。
对于多线程应用,对象分配必须要保证线程安全性,如果使用全局锁,那么分配空间将成为瓶颈并降低程序性能。HotSpot 使用了称之为 Thread-Local Allocation Buffers (TLABs) 的技术,该技术能改善多线程空间分配的吞吐量。首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区中进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了它的 TLAB,它才需要使用同步来获取一个新的缓冲区。HotSpot 使用了多项技术来降低 TLAB 对于内存的浪费。比如,TLAB 的平均大小被限制在 Eden 区大小的 1% 之内。TLABs 和使用指针碰撞的线性分配结合,使得内存分配非常简单高效,只需要大概 10 条机器指令就可以完成。
J2SE 5.0 HotSpot 虚拟机包含四种垃圾收集器,都是采用分代算法。包括串行收集器、并行收集器、并行压缩收集器 和 CMS 垃圾收集器。
使用串行收集器,年轻代和老年代都使用单线程进行收集(使用一个 CPU),收集过程中会 stop-the-world。所以当在垃圾收集的时候,应用程序是完全停止的。
在年轻代中使用串行收集器(复制算法)
下图展示了年轻代中使用串行收集器的流程。
年轻代分为一个 Eden 区和两个 Survivor 区(From 区和 To 区)。年轻代垃圾收集时,将 Eden 中活着的对象复制到空的 Survivor-To 区,Survivor-From 区的对象分两类,一类是年轻的,也是复制到 Survivor-To 区,还有一类是老家伙,晋升到老年代中。
如果复制的过程中,发现 Survivor-To 空间满了,将剩下还没复制到 Survivor-To 的来自于 Eden 和 Survivor-From 区的对象直接晋升到老年代。
年轻代垃圾收集完成后,Eden 区和 Survivor-From 就干净了,此时,将 Survivor-From 和 Survivor-To 交换一下角色。得到下面这个样子:
在老年代中使用串行收集器(标记整理算法)
如果使用串行收集器,在老年代和永久代将通过使用 标记 -> 清除 -> 压缩 (整理)算法。标记阶段,收集器识别出哪些对象是活的;清除阶段将遍历一下老年代和永久代,识别出哪些是垃圾;然后执行压缩,将活的对象左移到老年代的起始端(永久代类似),这样就留下了右边一片连续可用的空间,后续就可以通过指针碰撞的方式快速分配对象空间。
现在大多数 Java 应用都运行在大内存、多核环境中,并行收集器,也就是大家熟知的吞吐量收集器,利用多核的优势来进行垃圾收集,而不是像串行收集器一样将程序挂起后只使用单线程来收集垃圾。
并行垃圾收集器这一类组合, 在年轻代使用 标记-复制(mark-copy)算法, 在老年代使用 标记-清除-整理(mark-sweep-compact)算法。年轻代和老年代的垃圾回收都会触发STW事件,暂停所有的应用线程来执行垃圾收集。两者在执行 标记和 复制/整理阶段时都使用多个线程, 因此得名“(Parallel)”。通过并行执行, 使得GC时间大幅减少。
在年轻代中使用并行收集器
并行收集器在年轻代中其实就是串行收集器收集算法的并行版本。它仍然使用 stop-the-world 和复制算法,只不过使用了多核的优势并行执行,降低垃圾收集的时间,从而提高吞吐量。下图示意了在年轻代中,串行收集器和并行收集器的区别:
在老年代中使用并行收集器
在老年代中,并行收集器使用的是和串行收集器一样的算法:单线程,标记 -> 清除 -> 压缩。并行收集器只能在年轻代中并行。
何时使用并行收集器?
其适用于多核、不要求低停顿的应用,因为老年代的收集虽然不频繁,但是每次老年代的单线程垃圾收集依然可能会需要很长时间。比如说,它可以应用在批处理、账单计算、科学计算等。
并行压缩收集器于 J2SE 5.0 update 6 引入,和并行收集器的区别在于它在老年代也使用并行收集算法。注意:并行压缩收集器终将会取代并行收集器。
在年轻代中使用并行压缩收集器
并行压缩收集器在年轻代中使用了和并行收集器一样的算法。即使用 并行、stop-the-world、复制 算法。
在老年代中使用并行压缩收集器
在老年代和永久代中,其使用 并行、stop-the-world、滑动压缩 算法。
一次收集分三个阶段,首先,将老年代或永久代逻辑上分为固定大小的区块。
何时使用并行压缩收集器?
首先是多核上的并行优势,这个就不重复了。其次,前面的并行收集器对于老年代和永久代使用串行,而并行压缩收集器在这些区域使用并行,能降低停顿时间。
并行压缩收集器不适合运行在大型共享主机上(如 SunRays),因为它在收集的时候会独占几个CPU,在这种机器上,可以考虑减少垃圾收集的线程数(通过 –XX:ParallelGCThreads=n),或者就选择其他收集器。
重头戏 CMS 登场了,至少对于我这个 web 开发者来说,目前 CMS 最常用(使用 JDK8 的应用一般都切换到 G1 收集器了)。前面介绍的都是并行收集,这里要介绍并发收集了,也就是垃圾回收线程和应用程序线程同时运行。
对于许多程序来说,吞吐量不如响应时间来得重要。通常年轻代的垃圾收集不会停顿多长时间,但是,老年代垃圾回收,虽然不频繁,但是可能导致长时间的停顿,尤其当堆内存比较大的时候。为了解决这个问题,HotSpot 虚拟机提供了 CMS 收集器,也叫做 低延时收集器。
如果服务器是多核CPU,并且主要调优目标是降低延迟, 那么使用CMS是个很明智的选择。
在年轻代中使用 CMS 收集器
在年轻代中,CMS 和 并行收集器 一样,即:并行、stop-the-world、复制。
在老年代中使用 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。
这个数字到 Java8 的时候已经变为默认 92 了。如果老年代空间不足以容纳从新生代垃圾回收晋升上来的对象,那么就会发生 concurrent mode failure,此时会退化到发生 Full GC,清除老年代中的所有无效对象,这个过程是单线程的,比较耗时。
另外,即使在晋升的时候判断出老年代有足够的空间,但是由于老年代的碎片化问题,其实最终没法容纳晋升上来的对象,那么此时也会发生 Full GC,这次的耗时将更加严重,因为需要对整个堆进行压缩,压缩后年轻代彻底就空了。
总结下来,和并行收集器相比,CMS 收集器降低了老年代收集时的停顿时间(有时是显著降低),稍微增加了一些年轻代收集的时间、降低了吞吐量 以及 需要更多的堆内存。
何时使用 CMS 收集器 ?
适用于应用程序要求低停顿,同时能接受在垃圾收集阶段和垃圾收集线程一起共享 CPU 资源的场景,典型的就是 web 应用了。
在 web 应用中,低延时非常重要,所以 CMS 几乎就是唯一选择,直到后来 G1 的出现。
串行收集器:在年轻代和老年代都采用单线程,年轻代中使用 stop-the-world、复制 算法;老年代使用 stop-the-world、标记 -> 清理 -> 压缩算法。
并行收集器:在年轻代中使用 并行、stop-the-world、复制 算法;老年代使用串行收集器的 串行、stop-the-world、标记 -> 清理 -> 压缩 算法。
并行压缩收集器:在年轻代中使用并行收集器的 并行、stop-the-world、复制 算法;老年代使用 并行、stop-the-world、标记 -> 清理 -> 压缩算法。和并行收集器的区别是老年代使用了并行。
CMS 收集器:在年轻使用并行收集器的 并行、stop-the-world、复制 算法;老年代使用 并发、标记 -> 清理 算法,不压缩。本文介绍的唯一一个并发收集器,也是唯一一个不对老年代进行压缩的收集器。
另外,在 HotSpot 中,永久代使用的是和老年代一样的算法。到了 J2SE 8.0 的 HotSpot JVM 中,永久代被 MetaSpace 取代了。
G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,这一点非常重要。
G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。
如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。
首先是内存划分上,之前介绍的分代收集器将整个堆分为年轻代、老年代和永久代,每个代的空间是确定的。
而 G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。
执行垃圾收集时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,标记结束后,G1 也就知道哪些区块基本上是垃圾,存活对象极少,G1 会先从这些区块下手,因为从这些区块能很快释放得到很大的可用空间,这也是为什么 G1 被取名为 Garbage-First 的原因。
G1的工作流程
,G1 收集器主要包括了以下 4 种操作:
• 1、年轻代收集
• 2、并发收集,和应用线程同时执行
• 3、混合式垃圾收集
• *、必要时的 Full GC
接下来,我们进行一一介绍。
首先,我们来看下 G1 的堆结构:
年轻代中的垃圾收集流程(Young GC):
我们可以看到,年轻代收集概念上和之前介绍的其他分代收集器大差不差的,但是它的年轻代会动态调整。
接下来是 Old GC 的流程(含 Young GC 阶段),其实把 Old GC 理解为并发周期是比较合理的,不要单纯地认为是清理老年代的区块,因为这一步和年轻代收集也是相关的。下面我们介绍主要流程:
并发周期结束后是混合垃圾回收周期,不仅进行年轻代垃圾收集,而且回收之前标记出来的老年代的垃圾最多的部分区块。
混合垃圾回收周期会持续进行,直到几乎所有的被标记出来的分区(垃圾占比大的分区)都得到回收,然后恢复到常规的年轻代垃圾收集,最终再次启动并发周期。
到这里我们已经说了年轻代收集、并发周期、混合回收周期了,大家要熟悉这几个阶段的工作。
下面我们来介绍特殊情况,那就是会导致 Full GC 的情况,也是我们需要极力避免的:
看完上面的 Young GC 和 Old GC 等,很多读者可能还是很懵的,这里说几句不严谨的白话文帮助读者进行理解:
首先,最好不要把上面的 Old GC 当做是一次 GC 来看,而应该当做并发标记周期来理解,虽然它确实会释放出一些内存。
并发标记结束后,G1 也就知道了哪些区块是最适合被回收的,那些完全空闲的区块会在这这个阶段被回收。如果这个阶段释放了足够的内存出来,其实也就可以认为结束了一次 GC。
我们假设并发标记结束了,那么下次 GC 的时候,还是会先回收年轻代,如果从年轻代中得到了足够的内存,那么结束;过了几次后,年轻代垃圾收集不能满足需要了,那么就需要利用之前并发标记的结果,选择一些活跃度最低的老年代区块进行回收。直到最后,老年代会进入下一个并发周期。
那么什么时候会启动并发标记周期呢?这个是通过参数控制的,此参数默认值是 45,也就是说当堆空间使用了 45% 后,G1 就会进入并发标记周期。
G1 调优的目标是尽量避免出现 Full GC,其实就是给老年代足够的空间,或相对更多的空间。
有以下几点我们可以进行调整的方向:
• 增加堆大小,或调整老年代和年轻代的比例,这个很好理解
• 增加并发周期的线程数量,其实就是为了加快并发周期快点结束
• 让并发周期尽早开始,这个是通过设置堆使用占比来调整的(默认 45%)
• 在混合垃圾回收周期中回收更多的老年代区块
G1 的很重要的目标是达到可控的停顿时间,所以很多的行为都以这个目标为出发点开展的。