JVM总结笔记

================================
什么是Java虚拟机?
简单的说,一个JVM是一个软件模块,用于执行Java应用的字节码,并且把字节码转化到硬件,操作系统的指令。通过这样做,JVM允许Java程序在第一次编写后,不需要更改原始的代码,就能在不同的环境中执行。Java的可移植性是通往企业应用语言的关键:开发者并不需要为不同平台重写应用代码,因为JVM负责翻译和平台优化。你可以把JVM当做是一个专门为Java运行的操作系统;它的工作是为Java应用管理运行环境。一个JVM是一个通过与底层交互的虚拟执行环境,作为一个字节码指令机器,而用于分配执行任务和执行内存操作。

================================
编译器
关于Java编译器的介绍?
从一种语言到另一种——关于Java编译器
编译器是输入一种语言,然后输出另一种可执行的语句。Java编译器有两个主要任务:
  1. 让Java语言更加轻便,不用每次在特定的平台上写代码。
  2. 确保在特定的平台产生有效的可执行的代码。
编译器可以是静态也可以是动态。
静态编译器:一个静态编译的例子是javac。它把Java代码当做输入,并转化为字节码(一种在Java虚拟机执行的语言)。静态编译器一次解释输入的代码,输出可执行的形式,这个是在程序执行时将被用到。因为输入是静态的,你将总能看到结果相同。只有当你修改原始代码并重新编译时,你才能看到不同的输出。
动态编译器:例如Just-In-Time (JIT)编译器,把一种语言动态的转化为另一种,这意味着它们在运行时执行代码。一个JIT编译器让你收集或创建运行数据分析(通过插入性能计数的方式实现),并且编译器使用这些环境数据快速的做出决定。动态的编译器在编译的过程中,实现更好的指令序列,把一系列的指令替换成更有效的,并消除多余的操作。随着时间的增长,你将收集更多的代码生成数据,
做更多更好的编译决定;整个过程就是我们通常称为的代码优化和重编译。动态编译给了你根据行为进行动态调整的优势,或随着应用装载次数的增加从而进行新的优化。这就是为什么动态编译器非常适合Java运行。值得注意的是,动态编译器请求外部数据结构,线程资源,CPU周期分析和优化。越深层次的优化,你将需要越多的资源。然而在大多数环境中,顶层对执行性能的提升帮助非常小——比你纯粹的解释要快5到10倍的性能。

从java字节码到可执行
一旦将你的java代码编译成字节码,接下来的一步就是将字节码指令翻译成机器代码。这一步可以通过解释器来实现,也可以通过编译器来实现。
解释
解释是编译字节码最简单的方式。解释器以查表的形式找到每条字节码指令对应的硬件指令,然后将它发送给CPU执行。你可以将解释器想象成查字典:每一个特定的单词(字节码指令),都有一个具体的翻译(机器代码指令)与之对应。因为解释器每读一条指令就会马上执行该指令,所以该方式无法对一组指令集进行优化。同时每调用一个字节码都要马上对其进行解释,因此解释器运行速度是相当慢得。解释器以一种非常准确的方式来执行代码,但是由于没有对输出的指令集进行优化,因此它对目标平台的处理器来说可能不是最优的结果。
编译
编译器则是将所有将要执行的代码全部装载到运行时。这样当它翻译字节码时,就可以参考全部或部分的运行时上下文。它做出的决定都是基于对代码图分析的结果。如比较不同的执行分支以及参考运行时上下文数据。在将字节码序列被翻译成机器代码指令集后,就可以基于这个机器代码指令集进行优化。优化过的指令集存储在一个叫代码缓冲区的结构中。当再次执行这些字节码时,就可以直接从这个代码缓冲区中取得优化过的代码并执行。在有些情况下编译器并不使用优化器来进行代码优化,而是使用一种新的优化序列—“性能计数”。使用代码缓存器的优点是结果集指令可以被立即执行而不再需要重新解释或编译!这可以大大的降低执行时间,尤其是对一个方法被多次调用的java应用程序。
优化
通过动态编译的引入,我们就有机会来插入性能计数器。例如,编译器插入性能计数器,每次字节码块(对应某个具体的方法)被调用时对应的计数器就加一。编译器通过这些计数器找到“热块”,从而就能确定哪些代码块的优化能对应用程序带来最大的性能提升。运行时性能分析数据能够帮助编译器在联机状态下得到更多的优化决策,从而更进一步提升代码执行效率。因为得到越多越精确的代码性能分析数据,我们就可以找到更多的可优化点从而做出更好的优化决定,例如:怎样更好的序列化指令、是否用更有效率的指令集来替代原有指令集,以及是否消除冗余的操作等。

不同的编译器适用于不同的应用
不同的应用程序拥有不同的需求。企业服务器端应用通常需要长时间运行,所以通常希望对其进行更多的性能优化;而客户端小程序可能希望更快的响应时间和更少的资源消耗。下面让我们一起讨论三种不同的编译器以及他们的优缺点。
客户端编译器(Client-side compilers)
C1是一种大家熟知的优化编译器。当启动JVM时,添加-client参数即可启动该编译器。通过它的名字我们即可发现C1是一种客户端编译器。它非常适用于那种系统可用资源很少或要求能快速启动的客户端应用程序。C1通过使用性能计数器来进行代码优化。这是一种方式简单,且对源代码干预较少的优化方式。
服务器端编译器(Server-side compilers)
对于那种长时间运行的应用程序(例如服务器端企业级应用程序),使用客户端编译器可能远远不能够满足需求。这时我们应该选择类似C2这样的服务器端编译器。通过在JVM启动行中加入 –server 即可启动该优化器。因为大部分的服务器端应用程序通常都是长时间运行的,与那些短时间运行、轻量级的客户端应用相比,通过使用C2编译器,你将能够收集到更多的性能优化数据。因此你也将能够应用更高级的优化技术和算法。对于服务器端的部署,编译器可能需要一些时间来优化那些“热点”代码。所以服务器端的部署常常需要一个“加热”阶段。所以当对服务器端的部署进行性能测量时,务必确保你的应用程序已经达到了稳定状态!给予编译器充足的时间进行编译将会给你的应用带来很多好处。服务器端编译器相比客户端编译器来说能够得到更多的性能调优数据,这样就可以进行更复杂的分支分析,从而找到性能更优的优化路径。拥有越多的性能分析数据就能得到更优的应用程序分析结果。当然,进行大量的性能分析也就需要更多的编译器资源。如JVM若使用C2编译器,那么它将需要使用更多的CPU周期,更大的代码缓存区等等。
多层编译
多层编译混合了客户端编译和服务器端编译。Azul第一个在他的Zing JVM中实现了多层编译。最近,这项技术已经被Oracle Java Hotspot JVM采用(Java SE7 之后)。多层编译综合了客户端和服务器端编译器的优点。客户端编译器在以下两种情况表现得比较活跃:应用启动时;当性能计数器达到较低级别的阈值时进行性能优化。客户端编译器也会插入性能计数器以及准备指令集以备接下来的高级优化—服务器端编译器—使用。多层编译是一种资源利用率很高的性能分析方式。因为它可以在低影响编译器活动时收集数据,而这些数据可以在后面更高级的优化中继续使用。这种方式与使用解释性代码分析计数器相比可以提供更多的信息。
总结编译器:
根据不同的目的,选择不同的编译器。
解释器是将字节码翻译成机器指令的最简单形式。它的实现基于一个指令查询表。
编译器可以基于性能计数器进行优化,但是需要消耗一些额外的资源(代码缓存,优化线程等)。
客户端编译器相对于解释器可以带来5到10倍的性能提升。
服务器端编译器相对于客户端编译器来说可以带来30%到50%的性能提升,但需要消耗更多的资源。
多层编译则综合了两者的优点。使用客户端编译来获取更快的响应速度,接着使用服务器端编译器来优化那些被频繁调用的代码。

================================

内存分配会导致什么?
内存分配会导致垃圾回收和碎片
1.垃圾回收
每一个线程基于每个“Java进程分配内存地址空间” 完成内存分配,叫Java堆,或简称堆。在Java世界中单线程分配在客户端应用程序中很常见。然而,单线程分配在企业应用和工作装载服务端变的没有任何益处,因为它并没有使用现在多核环境的并行优势。并行应用设计同样迫使JVM保证在同一时间,多线程不会分配同一个地址空间。你可以通过在整个分配空间中放把锁来控制。但这种技术(通常叫做堆锁)很消耗性能,持有或排队线程会影响资源利用和应用优化的性能。多核系统好的一面是,它们创造了一个需求,为各种各样的新的方法在资源分配的同时去阻止单线程的瓶颈和序列化。一个常用的方法是把堆分成几部分,在对应用来说每个合适分区大小的地方——显然它们需要调优,分配率和对象大小对不同应用来说有显著的变化,同样线程的数量也不同。线程本地分配缓存(Thread Local Allocation Buffer,简写:TLAB),或者有时,线程本地空间(Thread Local Area,简写:TLA),是一个专门的分区,在其中线程不用声明一个全堆锁就可以自由分配。当区域满的时候,堆就满了,表示堆上的空闲空间不够用来放对象,需要分配空间。当堆满的时候,垃圾回收就会开始。
2.碎片
使用TLABs捕获异常,是把堆碎片化来降低内存效率。如果一个应用在要分配对象时,正巧不能增加或者不能完全分配一个TLAB空间,这将会有空间太小而不能生成新对象的风险。
这样的空闲空间被当做“碎片”。如果应用程序一直保持对象的引用,然后再用剩下的空间分配,最后这些空间会在很长一段时间内空闲。碎片就是当碎片被分散在堆中的时候——通过一小段不用的内存空间来浪费堆空间。为你的应用分配 “错误的”TLAB空间(关于对象的大小、混合对象的大小和引用持有率)是导致堆内碎片增多的原因。在随着应用的运行,碎片的数量会增加在堆中占有的空间。碎片导致性能下降,系统不能给新应用分配足够的线程和对象。垃圾回收器在随后会很难阻止out-of-memory异常。TLAB浪费在工作中产生。一种方法可以完全或暂时避免碎片,那就是在每次基础操作时优化TLAB空间。这种方法典型的作法是应用只要有分配行为,就需要重新调优。通过复杂的JVM算法可以实现,另一种方法是组织堆分区实现更有效的内存分配。例如,JVM可以实现free-lists,它是连接起一串特定大小的空闲内存块。一个连续的空闲内存块和另一个相同大小的连续内存块相连,这样会创建少量的链表,每个都有自己的边界。在有些情况下free-lists导致更好的合适内存分配。线程可以对象分配在一个差不多大小的块中,这样比你只依靠固定大小的TLAB,潜在的产生少的碎片。

================================
垃圾回收GC算法
垃圾收集器的目标有以下两个:

迅速释放没有引用的内存以满足应用程序的内存分配需要从而避免内存溢出。
回收内存时对正在运行的应用程序性能(延迟和吞吐量)的影响最小化。

垃圾收集最主要的两个方法是引用计数和跟踪收集器。
引用计数收集器 :会跟踪一个对象指向多少个引用。当一个对象的引用为0时,内存将被立即回收,这是这种方法的优点之一。引用计数方法的难点在于环形数据结构和保持所有的引用即时更新。
跟踪收集器:对仍在引用的对象标记,用已经标记的对象,反复的跟随和标记所有的引用对象。当所有的仍然引用的对象被标记为“live”时,所有的不被标记的空间将被回收。这种方法用于管理环形数据结构,但是在很多情况下收集器在回收不被引用的内存之前,需要等待所有标记完成。跟踪收集器不同于引用计数收集器主要在于它可以处理循环引用结构。多数的跟踪收集器都是在标记阶段发现那些循环引用结构中的无引用对象。跟踪收集器是动态语言中最常用的内存管理方式,也是目前Java中最常见的方式,同时在生产环境中也被验证了很多年。
有很多种的算法来实现上面的方法。最著名的算法是 marking 或copying 算法, parallel 或 concurrent算法。通常来说垃圾回收的意义是,致力于在堆中给新对象和老对象分配地址空间。其中“老对象”是指在许多垃圾回收后幸存的对象。用新生代来存放新对象,老年代存放老对象,这样能通过快速回收短时间占据内存的对象来减少碎片,同样通过把长时间存在的对象聚合在一起,并把它们放到老年代地址空间中。所有这些在长时间对象和保存堆内存不碎片化之间减少了碎片。新生代的一个积极作用是延迟了需要花费更大代价回收老年代对象的时间,你可以为短暂的对象重复利用相同的空间。(老空间的收集会花费更多,是因为长时间存在的对象们,会包含更多的引用,需要更多的遍历。)

碎片管理

举例,碎片算法是compaction,这是管理内存碎片的方法。Compaction基本来说就是把对象移动到一起,从来释放更大的连续内存空间。如果你熟悉磁盘碎片和处理它的工具,你会发现compaction跟它很像,不同的是这个运行在Java堆内存中。

=====================================
垃圾回收算法

=====================================

1.跟踪收集算法
      复制垃圾收集器和标记-清除垃圾收集器并不是什么新东西,但它们仍然是目前实现跟踪收集的两种最常见算法。
复制垃圾收集器
  传统的复制垃圾收集器使用堆中的两个地址空间(即from空间和to空间),当执行垃圾收集时from空间的活动对象被复制到to空间,当from空间的所有活动对象都被移出(复制到to空间或者老年代)后,就可以回收整个from空间了,当再次开始分配空间时将首先使用to空间(即上一轮的to空间作为新一轮的from空间)。最新的复制算法允许堆内任意地址空间作为to空间和from空间。这样它们不需要彼此交换位置,而只是逻辑上变换了位置。复制收集器的优点是在to空间被复制的对象紧凑排列,完全没有碎片。
复制收集器的缺陷
  通常来说复制收集器是stop-the-world的,也就是说只要垃圾收集在进行,应用程序就无法执行。对于这种实现来说,你需要复制的东西越多,对应用程序性能的影响就越大。对于那些响应时间敏感的应用来说这是个缺点。使用复制收集器时,你还要考虑最坏的场景(即from空间中的所有对象都是活动对象),这时你需要为移动这些活动对象准备足够大的空间,因此to空间必须大到可以装下from空间的所有对象。由于这个限制,复制算法的内存利用率稍有不足(在最坏的情况下to空间需要和from空间大小相同,所以只有50%的利用率)。
标记-清除收集器
  部署在企业生产环境上的大多数商业JVM采用的都是标记-清除(或者叫标记)收集器,因为它没有复制垃圾收集器对应用程序性能的影响问题。其中最有名的标记收集器包括CMS、G1、GenPar和DeterministicGC。
标记-清除收集器跟踪对象引用,并且用标志位将每个找到的对象标记为live。这个标志位通常对应堆上的一个地址或是一组地址。例如:活动位可以是对象头的一个位(bit)或是一个位向量、一个位图。在标记完成之后就进入了清除阶段。清除阶段通常都会再次遍历堆(不仅是标记为live的对象,而是整个堆),用来定位那些没有标记的连续内存地址空间(没有被标记的内存就是空闲并可回收的),然后收集器将它们整理为空闲列表。垃圾收集器可以有多个空闲列表(通常按照内存块的大小划分),有些JVM(例如:JRockit Real Time)的收集器甚至基于应用程序的性能分析和对象大小的统计结果来动态划分空闲列表。清除阶段过后,应用程序就可以再次分配内存了。从空闲列表中为新对象分配内存时,新分配的内存块需要符合新对象的大小,或是线程的平均对象大小,或是应用程序的TLAB大小。为新对象找到大小合适的内存块有助于优化内存和减少碎片。
标记-清除收集器的缺陷
  标记阶段的执行时间依赖于堆中活动对象的数量,而清除阶段的执行时间依赖于堆的大小。因此对于堆设置较大并且堆中活动对象较多的情况,标记-清除算法会有一定的暂停时间。对于内存消耗很大的应用程序来说,你可以调整垃圾收集参数以适应各种应用程序的场景和需要。在很多情况下,这种调整至少推迟了标记阶段/清除阶段给应用程序或服务协议SLA(SLA这里指应用程序要达到的响应时间)带来的风险。但是调优仅仅对特定的负载和内存分配率有效,负载变化或是应用程序本身的修改都需要重新调优。
标记-清除收集器的实现
至少有两种已经在商业上验证的方法来实现标记-清除垃圾收集。一种是并行垃圾收集,另一种是并发(或者多数时间是并发)垃圾收集。
并行收集器
  并行收集是指资源被垃圾收集线程并行使用。大多数并行收集的商业实现都是stop-the-world收集器,即所有的应用程序线程都暂停直到完成一次垃圾收集,因为垃圾收集器可以高效地使用资源,所以通常会在吞吐量的基准测试中得到高分,如SPECjbb。如果吞吐量对你的应用程序至关重要,那么并行垃圾收集器是一个很好的选择。并行收集的主要代价(特别是对于生产环境)是应用程序线程在垃圾收集期间无法正常工作,就像复制收集器一样。因此那些对于响应时间敏感的应用程序使用并行收集器会有很大的影响。特别是在堆空间中有很多复杂的活动对象结构时,有很多的对象引用需要跟踪。(还记得吗标记-清除收集器回收内存的时间取决于跟踪活动对象集合的时间加上遍历整个堆的时间)对于并行方法来说,整个垃圾收集时间应用程序都会暂停。
并发收集器
  并发垃圾收集器更适合那些对响应时间敏感的应用程序。并发意味着垃圾收集线程和应用程序线程并发执行。垃圾收集线程并不独占所有资源,因此需要决定何时开始一次垃圾收集,需要有足够的时间跟踪活动对象集合并在应用程序内存溢出前回收内存。如果垃圾收集没有及时完成,应用程序就会抛出内存溢出错误,另一方面又不希望垃圾收集执行时间太长因为那样会消耗应用程序的资源进而影响吞吐量。保持这种平衡是需要技巧的,因此在确定开始垃圾收集的时机以及选择垃圾收集优化的时机时都使用了启发式算法。另一个难点在于确定何时可以安全执行一些操作(需要完整准确的堆快照的操作),例如:需要知道何时标记阶段完成,这样就可以进入清理阶段。对于stop-the-world的并行收集器来说这不成问题,因为世界已经暂停了(应用程序线程暂停,垃圾收集线程独占资源)。但对于并发收集器而言,从标记阶段立刻切换到清理阶段可能不安全。如果应用程序线程修改了一段内存,而这段内存已经被垃圾收集器跟踪并标注过了,这就可能产生了新的没有标注的引用。在一些并发收集实现中,这会使应用程序陷入长时间重复标注的循环,当应用程序需要这段内存时也无法获得空闲内存。
压缩和应用程序内存墙
  应用程序内存墙是指在垃圾收集产生的暂停(例如:压缩)前可以设置的堆大小。根据系统和应用的不同,大多数的Java应用程序内存墙都在4GB到20GB之间。这也是多数的企业应用都是部署在多个较小的JVM上,而不是少数较大的JVM上的原因。
让我们考虑一下这个问题:有多少现代企业的Java应用程序设计、部署是根据JVM的压缩限制来定义的。在这种情况下,为了绕过整理堆碎片的暂停时间,我们接受了更耗费管理成本的多个实例部署方案。
考虑到现在硬件的大容量存储能力和企业级Java应用对增加内存的需求,这就有点奇怪了。为什么为每个实例只设置了几个GB的内存。并发压缩将会打破内存墙,这也是我下一篇文章的主题。
垃圾收集的要点:
  不同的垃圾收集算法和实现适应不同的应用程序需要,跟踪垃圾收集器是商业Java虚拟机中使用的最多的垃圾收集器。并行垃圾收集在执行垃圾收集时并行使用所有资源。它通常是一个stop-the-world垃圾收集器,因此有更高的吞吐量,但是应用程序的工作线程必须等待垃圾收集线程完成,这对应用程序的响应时间有一定影响。并发垃圾收集在执行收集时,应用程序工作线程仍然在运行。并发垃圾收集器需要在应用程序需要内存之前完成垃圾收集。分代垃圾收集有助于延迟碎片化,但无法消除碎片化。分代垃圾收集将堆分为两个空间,其中一个空间存放新对象,另一个空间存放老对象。分代垃圾收集适合有很多存活时间很短的小对象的应用程序。压缩是解决碎片化的唯一方法。多数的垃圾收集器都是以stop-the-world的方式执行压缩的,程序运行时间越久,对象引用越是复杂,对象的大小越是分布不均匀都将导致压缩时间延长。堆的大小也会影响压缩时间,因为可能有更多的活动对象和引用需要更新。

================================
碎片

================================
解决碎片化的两个办法:分代垃圾收集和压缩
分代垃圾收集
你可能听过这样的理论:在生产环境中绝大多数对象的存活时间都很短。分代垃圾收集正是由这一理论衍生出的一种垃圾收集策略。在分代垃圾收集中,我们将堆分为不同的空间(或者叫做代),每个空间中保存着不同年龄的对象,所谓对象的年龄就是对象存活的垃圾收集周期数(也就是该对象多少个垃圾收集周期后仍然被引用)。当新生代没有剩余空间可分配时,新生代的活动对象会被移动到老年代中(通常只有两个代。只有满足一定年龄的对象才会被移动到老年代),分代垃圾收集常常使用单向的复制收集器,一些更现代的JVM新生代中使用的是并行收集器,当然也可以为新生代和老年代分别实现不同的垃圾收集算法。如果你使用并行收集器或复制收集器,那么你的新生代收集器就是一个stop-the-world的收集器(参见之前的解释)。老年代分配给那些从新生代移出的对象,这些对象要么是被引用很长一段时间,要么是被一些新生代中对象集合所引用。偶尔也有大对象直接被分配到了老年代,因为移动大对象的成本相对较高。
压缩
尽管分代垃圾收集延迟了出现碎片化和内存溢出错误的时间,然而压缩才是真正解决碎片化问题的唯一办法。压缩是指通过移动对象来释放连续内存块的垃圾收集策略,这样通过压缩为创建新对象释放了足够大的空间。移动对象并更新对象引用是stop-the-world操作,会带来一定的消耗(有一种情况例外,将在本系列的下一篇中讨论)。存活的对象越多,压缩造成的暂停时间就越长。在剩余空间很少并且碎片化严重的情况下(这通常是因为程序运行了很长的时间),压缩存活对象较多的区域可能会有几秒种的暂停时间,而当接近内存溢出时,压缩整个堆甚至会花上几十秒的时间。压缩的暂停时间取决于需要移动的内存大小和需要更新的引用数量。统计分析表明堆越大,需要移动的活动对象和更新的引用数量就越多。每移动1GB到2GB活动对象的暂停时间大约是1秒钟,对于4GB大小的堆很可能有25%的活动对象,因此偶尔会有大约1秒的暂停。

================================
评估一个垃圾收集(GC)算法如何根据如下两个标准:

吞吐量越高算法越好
暂停时间越短算法越好

首先让我们来明确垃圾收集(GC)中的两个术语:吞吐量(throughput)和暂停时间(pause times)。 JVM在专门的线程(GC threads)中执行GC。只要GC线程是活动的,它们将与应用程序线程(application threads)争用当前可用CPU的时钟周期。 简单点来说,吞吐量是指应用程序线程用时占程序总用时的比例。 例如,吞吐量99/100意味着100秒的程序执行时间应用程序线程运行了99秒, 而在这一时间段内GC线程只运行了1秒。
术语”暂停时间”是指一个时间段内应用程序线程让与GC线程执行而完全暂停。 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。 如果说一个正在运行的应用程序有100毫秒的“平均暂停时间”,那么就是说该应用程序所有的暂停时间平均长度为100毫秒。 同样,100毫秒的“最大暂停时间”是指该应用程序所有的暂停时间最大不超过100毫秒。

========================================
吞吐量垃圾收集算法(Full GC)
HotSpot提供的面向吞吐量(throughput-oriented)垃圾收集算法。 当年老代中由于缺乏空间导致对象分配失败时会触发垃圾收集器(事实上,”分配”的通常是指从年轻代提升到年老代的对象)。
从所谓的”GC根”(GC roots)开始,搜索堆中的可达对象并将其标记为活着的,之后,垃圾收集器将活着的对象移到年老代的一块无碎片(non-fragmented)内存块中,并标记剩余的内存空间是空闲的。
也就是说,我们不像复制策略那样移到一个不同的堆区域,像年轻代垃圾收集算法所做的那样。 相反地,我们把所有的对象放在一个堆区域中,从而对该堆区域进行碎片整理。 垃圾收集器使用一个或多个线程来执行垃圾收集。
当使用多个线程时,算法的不同步骤被分解,使得每个收集线程大多时候工作在自己的区域而不干扰其他线程。 在垃圾收集期间,所有的应用程序线程暂停,只有垃圾收集完成之后才会重新开始。
现在让我们来看看跟面向吞吐量垃圾收集算法有关的重要JVM配置参数。
并发标记收集器(CMS收集器)
HotSpot JVM的并发标记清理收集器(CMS收集器)的主要目标就是:低应用停顿时间。该目标对于大多数交互式应用很重要,比如web应用。在我们看一下有关JVM的参数之前,让我们简要回顾CMS收集器的操作和使用它时可能出现的主要挑战。
就像吞吐量收集器,CMS收集器处理老年代的对象,然而其操作要复杂得多。吞吐量收集器总是暂停应用程序线程,并且可能是相当长的一段时间,然而这能够使该算法安全地忽略应用程序。
相比之下,CMS收集器被设计成在大多数时间能与应用程序线程并行执行,仅仅会有一点(短暂的)停顿时间。GC与应用程序并行的缺点就是,可能会出现各种同步和数据不一致的问题。为了实现安全且正确的并发执行,
CMS收集器的GC周期被分为了好几个连续的阶段。
CMS收集器的过程
CMS收集器的GC周期由6个阶段组成。其中4个阶段(名字以Concurrent开始的)与实际的应用程序是并发执行的,而其他2个阶段需要暂停应用程序线程。

初始标记:为了收集应用程序的对象引用需要暂停应用程序线程,该阶段完成后,应用程序线程再次启动。
并发标记:从第一阶段收集到的对象引用开始,遍历所有其他的对象引用。
并发预清理:改变当运行第二阶段时,由应用程序线程产生的对象引用,以更新第二阶段的结果。
重标记:由于第三阶段是并发的,对象引用可能会发生进一步改变。因此,应用程序线程会再一次被暂停以更新这些变化,并且在进行实际的清理之前确保一个正确的对象引用视图。这一阶段十分重要,因为必须避免收集到仍被引用的对象。
并发清理:所有不再被应用的对象将从堆里清除掉。
并发重置:收集器做一些收尾的工作,以便下一次GC周期能有一个干净的状态。

一个常见的误解是,CMS收集器运行是完全与应用程序并发的。我们已经看到,事实并非如此,即使“stop-the-world”阶段相对于并发阶段的时间很短。
应该指出,尽管CMS收集器为老年代垃圾回收提供了几乎完全并发的解决方案,然而年轻代仍然通过“stop-the-world”方法来进行收集。对于交互式应用,停顿也是可接受的,背后的原理是年轻带的垃圾回收时间通常是相当短的。
挑战
当我们在真实的应用中使用CMS收集器时,我们会面临两个主要的挑战,可能需要进行调优:

堆碎片
对象分配率高

堆碎片是有可能的,不像吞吐量收集器,CMS收集器并没有任何碎片整理的机制。因此,应用程序有可能出现这样的情形,即使总的堆大小远没有耗尽,但却不能分配对象——仅仅是因为没有足够连续的空间完全容纳对象。当这种事发生后,并发算法不会帮上任何忙,因此,万不得已JVM会触发Full GC。回想一下,Full GC 将运行吞吐量收集器的算法,从而解决碎片问题——但却暂停了应用程序线程。因此尽管CMS收集器带来完全的并发性,但仍然有可能发生长时间的“stop-the-world”的风险。这是“设计”,而不能避免的——我们只能通过调优收集器来它的可能性。想要100%保证避免”stop-the-world”,对于交互式应用是有问题的。
第二个挑战就是应用的对象分配率高。如果获取对象实例的频率高于收集器清除堆里死对象的频率,并发算法将再次失败。从某种程度上说,老年代将没有足够的可用空间来容纳一个从年轻代提升过来的对象。这种情况被称为“并发模式失败”,并且JVM会执行堆碎片整理:触发Full GC。
当这些情形之一出现在实践中时(经常会出现在生产系统中),经常被证实是老年代有大量不必要的对象。一个可行的办法就是增加年轻代的堆大小,以防止年轻代短生命的对象提前进入老年代。另一个办法就似乎利用分析器,快照运行系统的堆转储,并且分析过度的对象分配,找出这些对象,最终减少这些对象的申请。

猜你喜欢

转载自blog.csdn.net/sdtvyyb_007/article/details/80306618
今日推荐