写时复制
写时复制(copy-on-write)是众多 UNIX 操作系统用到的内存优化的方法。比如在 Linux 系统中使用 fork() 函数复制进程时,大部分内存空间都不会被复制,只是复制进程,只有在内存中内容被改变时才会复制内存数据。 但是如果使用标记清除算法,这时内存会被设置标志位,就会频繁发生不应该发生的复制。
另外,关于标记清除的变形,还有一种叫做标记压缩(Mark and Compact)的算法,它不是将被标记的对象清除,而是将它们不断压缩。
Hotspot虚拟机
HotSpot的正式发布名称为"Java HotSpot Performance Engine",是Java虚拟机的一个实现,包含了服务器版和桌面应用程序版。
Snapshot-At-The-Beginning (SATB)
G1 使用的是 SATB 标记算法,主要应用于垃圾收集的并发标记阶段,解决了CMS 垃圾收 集器重新标记阶段长时间 Stop The World(STW) 的潜在风险。其算法全称是 Snapshot At The Beginning,由字面理解,是垃圾回收器开始时活着的对象的一个快照。
它是通过 “根集合”穷举可达对象得到的,穷举过程中采用了三色标记法:
- 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
- 灰:对象被标记了,但是它所在的Field还没有被标记或标记完(可达对象还未被标记)。
- 黑:对象被标记了,且它的所有Field也被标记完了。
所以,漏标的情况只会发生在白色对象中,且满足以下任意一个条件:
- 并发标记时,应用线程给一个黑色对象的引用类型字段赋值了该白色对象
- 并发标记时,应用线程删除所有灰色对象到该白色对象的引用
SATB 利用 write barrier 将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根 Stop The World 地重新扫描一遍即可避免漏标问题。 因此 G1 Remark阶段 Stop The World 与 CMS 了的remark有一个本质上的区别,那就是这个暂停只需要扫描有 write barrier 所追中对象为根的对象, 而 CMS 的remark 需要重新扫描整个根 集合,因而CMS remark有可能会非常慢。
Remembered Set(RSet)概念
RSet全称是Remembered Set,是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。
G1收集器中,Region之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用是使用Remembered Set来避免扫描全堆。
G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序对Reference类型数据进行写操作时,会产生一个Write Barrier(写屏障)暂时中断写操作(虽然写屏障使得应用线程增加了一些性能开销,但Minor GC变快了许多,整体的垃圾收集效率也提高了许多,通常应用的吞吐量也会有所改善),检查Reference引用的对象是否处于不同的Region之间(在分代中就是检查老年代中的对象是否引用了新生代的对象),如果是便通过CardTable(卡表)把相关引用信息记录到被引用对象所属的Region的Remembered Set中。
当内存回收时,在GC根节点的枚举范围加入Remembered Set即可保证不对全局堆扫描也不会有遗漏。
卡表(Card Table)
为了支持高频率的新生代的回收,虚拟机使用一种叫做卡表(Card Table)的数据结构,卡表作为一个比特位的集合,每一个比特位可以用来表示年老代的某一区域中的所有对象是否持有新生代对象的引用。当老年代中的某个区域持有了新生代对象的引用时,JVM就把这个区域对应的Card所在的位置标记为dirty(bit位设置为1)。
这样新生代在GC时,可以不用花大量的时间扫描所有年老代对象,来确定每一个对象的引用关系,而可以先扫描卡表,只有卡表的标记位为1时,才需要扫描给定区域的年老代对象。而卡表位为0的所在区域的年老代对象,一定不包含有对新生代的引用。这样子可以提高效率减少MinorGC的停顿时间。
如上图,卡表中每一个位表示年老代4K的空间,卡表记录未0的年老代区域没有任何对象指向新生代,只有卡表位为1的区域才有对象包含新生代引用,因此在新生代GC时,只需要扫描卡表位为1所在的年老代空间。使用这种方式,可以大大加快新生代的回收速度。
配置每次扫描的Card数量
在JVM中,一个Card覆盖的默认大小是512字节,在多个线程并行收集时,JVM通过ParGCCardsPerStrideChunk参数设置每个线程每次扫描的Card数量,默认是256。
相当于是把老年代分成许多strides,每个线程每次扫描一个stride,每个stride大小为512*256 = 128K。
如果你的老年代大小为4G,那总共有4G/128K=32K个Strides。多线程在扫描这么多的strides时就涉及到调度和分配的问题,stride数量太多就会导致线程在stride之间切换的开销增加,进而导致GC暂停时间增长。
因此JVM提供了ParGCCardsPerStrideChunk这个参数来配置每个stride对应的card数量,这个数量要根据实际的业务场景进行调优,网上一般流传3个魔术数字:32768、4K和8K。
例如,配置每次扫描的Card数量: (UnlockDiagnosticVMOptions:解锁任何额外的隐藏参数)
代码语言:javascript复制-XX: UnlockDiagnosticVMOptions
-XX:ParGCCardsPerStrideChunk=4096
这个值不能设置的太大,因为GC线程需要扫描这个stride中老年代对象持有的新生代对象的引用,如果只有少量引用新生代的对象那就导致浪费了很多时间在根本不需要扫描的对象上。 (在JVM调优过程中,没有一个参数的值完美的,只有经过不断的调优过程,慢慢的摸索到适合自己应用的最佳参数范围,除非应用对YGC的耗时特别敏感,不到万不得已,不用优化该参数,默认的256也适合大部分情况。但是随着现在机器内存的扩大,适当的增大该参数值(4K),也是可以的)
"空闲列表"内存分配&"指针碰撞"内存分配
虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后为新生对象分配内存。内存分配方式有两种:
(1)指针碰撞
指针碰撞的前提是Java堆是绝对规整的,有用的和空闲各自放在一边,中间放着一个指针作为分界点指示器,所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。在使用Serial,和ParNew等收集器时候使用的是指针碰撞。
(2)空闲列表
如果Java堆不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录了哪些是可用的内存,在分配的时候从列表中找到一块足够大的空间分配给对象实例,并更新列表上的记录。在使用CMS这种基于Mark-Sweep(标志-清除)算法的收集器时,通常采用空闲列表。
Promotion Failure
在minor gc过程中,survivor(幸存者)的剩余空间不足以容纳eden(伊甸园)及当前在用的survivor区间存活对象,只能将容纳不下的对象移到年老代(promotion),而此时年老代满了无法容纳更多对象,通常伴随full gc,因而导致的promotion failure。 这种情况通常需要增加年轻代大小,尽量让新生对象在年轻代的时候尽量清理掉。
Concurrent Mode Failure
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;
这时JVM启用后备预案:临时启用Serail Old收集器(为什么不启用Parallel old?这样并行回收的停顿时间会更短。 https://stackoverflow.com/questions/39569649/why-is-cms-full-gc-single-threaded#39573243 ),而导致另一次Full GC的产生;
这样的代价是很大的,此时JVM将采用停顿的方式进行Full gc,整个gc时间会相当可观,完全违背了采用CMS GC的初衷,所以CMSInitiatingOccupancyFraction不能设置得太大。
出现该现象的原因主要是由于cms的无法处理浮动垃圾(Floating Garbage)引起的。 (出现此现象的原因主要有两个:一个是在年老代被用完之前不能完成对无引用对象的回收;一个是当新空间分配请求在年老代的剩余空间中无法得到满足(比如在年老代申请大空间对象))
这个跟cms的机制有关。cms的并发清理阶段,用户线程还在运行,因此不断有新的垃圾产生,而这些垃圾不在这次清理标记的范畴里头,cms无法在本次gc清除掉,这些就是浮动垃圾。
由于这种机制,cms年老代回收的阈值不能太高,否则就容易预留的内存空间很可能不够(因为本次gc同时还有浮动垃圾产生),从而导致concurrent mode failure发生。
-XX:CMSInitiatingOccupancyFraction
要避免此现象,可以降低触发CMS的阀值,即参数-XX:CMSInitiatingOccupancyFraction的值,该值代表老年代堆空间的使用率,通常JDK默认值是68;可以选择调低到50或者以下(指设定CMS在对老年代占用率达到50%的时候开始GC),让CMS GC尽早执行,以保证有足够的空间
-XX: UseCMSInitiatingOccupancyOnly
有一个需要注意的点,仅仅设置CMSInitiatingOccupancyFraction的值的值表示第一次CMS收集按照该比例收集,后面JVM将会自动进行调节。配置该参数使用的还有一个参数。 -XX: UseCMSInitiatingOccupancyOnly可以设置true和false,默认为false。
将-XX UseCMSInitiatingOccupancyOnly 值设置为true来命令 JVM 不基于运行时收集的数据来启动 CMS 垃圾收集周期。而是,当该标志被开启时,JVM 通过 CMSInitiatingOccupancyFraction 的值进行每一次 CMS 收集,而不仅仅是第一次。
然而,请记住大多数情况下,JVM 比我们自己能作出更好的垃圾收集决策。因此,只有当我们充足的理由 (比如测试) 并且对应用程序产生的对象的生命周期有深刻的认知时,才应该使用该标志。 不建议开启该属性值。
垃圾收集器期望的目标
(1)、停顿时间
- 停顿时间越短就适合需要与用户交互的程序;
- 良好的响应速度能提升用户体验;
(2)、吞吐量
- 高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;
- 主要适合在后台计算而不需要太多交互的任务;
(3)、覆盖区(Footprint)
- 在达到前面两个目标的情况下,尽量减少堆的内存空间;
- 可以获得更好的空间局部性;
吞吐量(Throughput)
- CPU用于运行用户代码的时间与CPU总消耗时间的比值;
- 即吞吐量=运行用户代码时间/(运行用户代码时间 垃圾收集时间);
- 比如,虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
- 高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;
Stop TheWorld说明
- JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,即GC停顿;
- 会带给用户不良的体验;
Jvm的client&server版本
Jvm有client和server两个版本,分别针对桌面应用程序和服务端应用做了相应的优化,client版本加载速度较快,server版本加载速度较慢但运行起来较快。
简而言之:client版本启动快,server版本运行快。
由于服务器的CPU、内存和硬盘都比客户端机器强大,所以程序部署后,都应该以server模式启动,获取较好的性能。
小提示:可以通过运行:java -version来查看jvm默认工作在什么模式。
Minor GC & Full GC & Major GC
Minor GC
- 又称新生代GC(Young GC),指发生在新生代的垃圾收集动作;
- 因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;
Full GC
- 一般很多人称Major GC或老年代GC,指发生在老年代的GC;但是实际上,Full GC指的是一次完整GC。
- 出现Full GC经常会伴随至少一次的Minor GC,原因就是减轻Full GC 的负担。(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);
- Major GC速度一般比Minor GC慢10倍以上;
Minor GC和Major GC是俗称,在Hotspot JVM实现的Serial GC, Parallel GC, CMS, G1 GC中大致可以对应到某个Young GC和Old GC算法组合;
Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“Major GC”的时候一定要问清楚他想要指的是上面的Full GC还是Old GC。
程序中主动调用System.gc()强制执行的GC为Full GC。
新生代收集器&老年代收集器
- 新生代收集器:Serial、ParNew、Parallel Scavenge;
- 老年代收集器:Serial Old、Parallel Old、CMS;
- 整堆收集器:G1;
并行和并发的概念
如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。 如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。
在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。 这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。 如果程序能够并行执行,那么就一定是运行在多核处理器上。 此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。
可以得出结论:“并行”概念是“并发”概念的一个子集。 也就是说,编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码,只能称为并发。 看下图来进行理解: (Erlang 之父 Joe Armstrong 用该图解释了并发与并行的区别)
并发是两个队列交替,但是只能使用一台咖啡机,并行是两个队列可以同时使用两台咖啡机 (任何属于冯诺依曼结构体系的计算机(经典计算机,目前应该除了实验中的量子计算机,都是属于该范畴的),其中的CPU(或者说核)必然是串行执行指令的。所以在任何单CPU机器上,是不存在严格意义上,或者说狭义上的并行的--在指令级别的严格意义上的并行,是指在一个足够小的时刻,可以允许大于一条的指令在执行。)
要很好的理解这个图,请记住下面几点: 一定要把图中的一个queues来对应一个任务,队列中的每一个人对应这个任务的步骤,并发和并行的共同前提肯定是有多个任务要处理(也就是图中的队列数量大于等于2) 再进一步区分:
并发强调了其实现的前提是:要处理的任务必须是可分步骤的(队列中的人数大于等于2); 并行强调了其实现的前提是:必须是多个处理器(咖啡机的数量大于等于2).
生活中的一个例子
- 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
- 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
- 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。 并行的关键是你有同时处理多个任务的能力。 在这里,将吃饭和接电话理解为两个任务。
并发垃圾收集和并行垃圾收集的区别
并行(Parallel)
- 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
- 如ParNew、Parallel Scavenge、Parallel Old;
并发(Concurrent)
- 指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行);
- 户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
- 如CMS、G1(也有并行);
没关注公众号的朋友,可以关注一波,干货多多