写点什么

JVM 实战—JVM 垃圾回收器的原理和调优

  • 2025-04-01
    福建
  • 本文字数:16837 字

    阅读完需:约 55 分钟

1.JVM 的新生代垃圾回收器 ParNew 如何工作


(1)JVM 的核心运行原理梳理点


一.对象在新生代分配,何时会触发 YGC

二.YGC 前会如何检查老年代大小,涉及哪些步骤条件

三.什么情况下 YGC 前会提前触发 FGC

四.FGC 的算法是什么

五.YGC 过后可能对应哪几种情况

六.YGC 后哪些情况对象会进入老年代

 

(2)最常用的新生代垃圾回收器—ParNew


一般在之前多年里,如果没有最新的 G1 垃圾回收器的话,通常线上系统都是用 ParNew 垃圾回收器作为新生代的垃圾回收器。当然后来即使有了 G1,很多线上系统还是用 ParNew。

 

一般运行在服务器上的 Java 系统,都能充分利用服务器的多核 CPU 优势。但 4 核服务器如果用单线程回收新生代垃圾,则没法充分利用 CPU 资源。



如上图示,在垃圾回收时:JVM 会把系统程序所有的工作线程停掉,只剩一个垃圾回收线程在运行,那么此时 4 核 CPU 的资源根本没法充分利用。理论上 4 核 CPU 可以支持 4 个垃圾回收线程并行执行,可以提升 4 倍性能。

 

Serial 和 ParNew 都是用于回收新生代垃圾的。这两者唯一区别就是单线程和多线程,它们的垃圾回收算法完全一样。新生代的 ParNew 垃圾回收器使用的是多线程垃圾回收机制,而新生代的 Serial 垃圾回收器使用的是单线程垃圾回收机制。

 

如下图示,ParNew 垃圾回收器一旦开始执行 Young GC,就会把系统程序的工作线程全停掉,禁止程序继续运行创建新的对象,然后就会使用多个垃圾回收线程去进行垃圾回收。



(3)如何为线上系统指定使用 ParNew 垃圾回收器


设置 JVM 参数有多种方式:

一.在 IDEA 中可以设置 Debug JVM Arguments

二.使用"java -jar"启动项目时可在后面跟上 JVM 参数

三.项目部署到 Tomcat 时可以在 Tomcat 的 catalina.sh 脚本中设置 JVM 参数

四.使用 Spring Boot 部署项目时也可以在启动 Spring Boot 时指定 JVM 参数

 

启动系统时如果要指定 ParNew 回收,可用-XX:+UseParNewGC 选项。只要加入该选项,JVM 对新生代进行垃圾回收时,就是用 ParNew 了。

 

在 ParNew 垃圾回收器中,YGC 时机、检查机制、垃圾回收过程、以及对象升入老年代的机制,都和前面介绍的一样,只不过 ParNew 会使用多个线程来进行垃圾回收。

 

(4)ParNew 垃圾回收器默认情况下的线程数量


服务器一般都是多核 CPU,为了在垃圾回收时充分利用多核 CPU 资源,指定使用 ParNew 后,默认会设置 ParNew 的垃圾回收线程数=CPU 核数。

 

比如线上服务器用的是 4 核 CPU、8 核 CPU、16 核 CPU,则 ParNew 的垃圾回收线程数分别是 4 个、8 个、16 个。这个垃圾回收线程数一般不用手动去调节,因为与 CPU 核数一样的垃圾回收线程数,可以充分进行并行处理。但如果一定要调节 ParNew 的垃圾回收线程数量,也是可以的。使用-XX:ParallelGCThreads 参数可设置 ParNew 的垃圾回收线程数,但是建议一般不要随意改动该参数。

 

(5)单线程和多线程说明


是用单线程垃圾回收好,还是用多线程垃圾回收好?

是 Serial 垃圾回收器好还是 ParNew 垃圾回收器好?

 

一.启动系统时可区分服务器模式和客户端模式

如果启动系统时加入-server 就是服务器模式。

如果启动系统时加入-cilent 就是客户端模式。

 

二.服务器模式和客户端模式的区别

如果系统部署在 4 核 8G 的 Linux 服务器上,那么就应该用服务器模式。如果系统是运行在 Windows 上的客户端程序,那么就应该用客户端模式。

 

服务器模式通常运行网站系统、电商系统等大型系统,一般用多核 CPU。如果要对这些大型系统进行垃圾回收,那么肯定是用 ParNew 更好。因为多线程并行垃圾回收,充分利用多核 CPU 资源,可以提升性能。反之如果部署在服务器上,但用了单线程垃圾回收,就浪费一些 CPU 了。



客户端模式通常运行一个客户端 Java 程序。比如某云笔记的 Windows 客户端,运行在 Windows 个人操作系统上,这种安装在个人操作系统上的应用程序很多是单核 CPU。如果这些应用程序使用 ParNew 来进行垃圾回收,可能会导致一个 CPU 运行多个线程,增加开销,可能效率还不如单线程。因为单 CPU 运行多线程会导致频繁的上下文切换,有额外开销。

 

所以对于那些运行在 Windows 上的客户端程序,可采用 Serial 垃圾回收器,单 CPU 单线程垃圾回收即可,效率会更高。

 

不过现在一般很少有用 Java 写客户端程序,几乎很少见,Java 现在主要是用来构建复杂的大规模后端业务系统。所以常用-server 指定服务器模式,再配合 ParNew 进行多线程垃圾回收。

 

2.JVM 老年代垃圾回收器 CMS 是如何工作的


(1)新生代垃圾回收总结


新生代的垃圾回收是通过标记-复制算法来实现的,我们最希望的是:

 

新对象都在新生代的 Eden 区分配内存。然后每次垃圾回收后,存活对象都进入 Survivor 区。然后下一次垃圾回收后的存活对象都进入另外一个 Survivor 区。这样几乎很少对象会进入老年代,几乎不会触发老年代的垃圾回收。

 

但是理想很丰满,现实是在写代码时,很少会考虑垃圾回收。都是不停写代码然后上线部署,很少考虑所写代码对垃圾回收的影响。最多有经验的工程师在系统上线前,通过前面案例介绍的方法:估算一下系统的内存压力以及垃圾回收的运行模型,然后合理设置一下内存各个区域大小,尽量避免太多对象进入到老年代。

 

实际中,线上系统很可能因各种各样原因导致很多对象进入老年代,然后频繁触发老年代的 Full GC。之前介绍的案例就演示过这种情况,比如 Survivor 区太小,容纳不了每次 YGC 后的存活对象,从而导致对象频繁进入老年代,最后频繁触发老年代 Full GC。

 

类似的情况其实很多,所以不能过于理想化的期待永远没有老年代 GC,还是要对老年代的垃圾回收器如何进行回收有一个充分的了解和认识。

 

(2)CMS 垃圾回收的基本原理


一般老年代选择的垃圾回收器是 CMS,它采用的是标记-清理算法。就是先标记出哪些对象是垃圾对象,然后就把这些垃圾对象清理掉。如下图示是一个老年代内存区域的对象分布情况:



现假设因老年代可用内存小于历次 YGC 后升入老年代对象的平均大小,判断出 YGC 有风险,于是就提前触发 FGC 回收老年代的垃圾对象。或者一次 YGC 后对象太多,都要升入老年代但空间不足,于是触发 FGC。

 

总之就是要进行 FGC,此时的标记-清理算法会如下处理:首先通过追踪 GC Roots,看看各个对象是否被 GC Roots 给引用了。如果是的话,那就是存活对象,否则就是垃圾对象。接着将垃圾对象都标记出来,然后再一次性把垃圾对象都回收掉。这种标记-清理算法最大的问题,其实就是会造成很多内存碎片。如下图示:



(3)如果 Stop the World 然后垃圾回收会如何


假如要先 STW,再采用标记-清理算法去回收垃圾,那会有什么问题?如果停止一切工作线程,然后慢慢去执行标记-清理算法,会导致系统卡死时间过长,很多响应无法处理。

 

所以 CMS 垃圾回收器采取的是:垃圾回收线程和系统工作线程尽量同时执行的模式来处理的。

 

(4)如何实现 JVM 垃圾回收的同时让应用也工作


CMS 在执行一次垃圾回收的过程共分为 4 阶段:

阶段一:初始标记

阶段二:并发标记

阶段三:重新标记

阶段四:并发清理

 

一.CMS 进行垃圾回收时会先进入初始标记阶段


这个阶段会让系统的工作线程全部停止,进入 Stop the World 状态。如下图示:



所谓初始标记,就是标记出所有 GC Roots 直接引用的对象,比如下面的代码:


public class Kafka {    private static ReplicaManager replicaManager = new ReplicaManager();}
public class ReplicaManager { private ReplicaFetcher replicaFetcher = new ReplicaFetcher();}
复制代码


在初始标记阶段,会通过类静态变量 replicaManager 这个 GC Roots,标记出它直接引用的 ReplicaManager 对象,不会管 ReplicaFetcher 对象,这就是初始标记大概过程。

 

因为 ReplicaFetcher 对象是被 ReplicaManager 类的实例变量引用的,方法的局部变量和类的静态变量是 GC Roots,类的实例变量不是 GC Roots。如下图示:



所以初始标记阶段虽然会造成 STW 暂停一切工作线程,但其实影响不大。因为它的速度很快,仅仅标记 GC Roots 直接引用的那些对象而已。

 

二.接着是并发标记阶段,该阶段系统线程可继续运行创建新对象


在并发标记运行期间,可能会创建新的存活对象,也可能会让部分存活对象失去引用变成垃圾对象。在这个过程中,垃圾回收线程会尽可能对已有的对象进行 GC Roots 追踪。

 

GC Roots 追踪,就是看 ReplicaFetcher 之类的的对象是在被谁引用。比如这里 ReplicaFetcher 对象被 ReplicaManager 对象的实例变量引用了,接着看 ReplicaManager 对象被谁引用,发现被 Kafka 类的静态变量引用。那么此时可以认定 ReplicaFetcher 对象是被 GC Roots 间接引用的,所以就不需要回收 ReplicaFetcher 对象了。如下图示:



在进行并发标记的这个过程中,系统程序会不停的工作。此时系统程序可能会创建出各种新的对象,部分对象可能成为垃圾。如下图示:



并发标记阶段会对老年代所有对象进行 GC Roots 追踪,其实是最耗时的,因为需要追踪所有对象是否从根源上被 GC Roots 引用了。但是这个最耗时的阶段,并发标记线程是和系统程序并发运行的,所以并发标记阶段不会对系统运行造成太大影响。

 

三.接着会进入重新标记阶段


由于在并发标记阶段里:一边是 JVM 在标记存活对象和垃圾对象,一边是系统程序在不停运行创建新对象让老对象变成垃圾。所以并发标记阶段结束后,会有很多存活对象和垃圾对象没被标记出来。如下图示:



于是在重新标记阶段需要让系统程序停下来,再次进入 STW。重新标记在并发标记阶段新创建的存活对象,以及失去引用的垃圾对象。如下图示:



重新标记阶段的速度是很快的,因为只是对并发标记阶段中系统程序运行变动过的少数对象进行标记。


四.接着恢复运行系统程序,进入并发清理阶段


这个阶段会让系统程序并发运行,然后 CMS 垃圾回收器会清理掉之前标记为垃圾的对象。这个并发清理阶段其实是很耗时的,因为需要进行对象的清理。但是它也会跟系统程序并发运行,所以其实也不影响系统程序的执行。如下图示:



(5)对 CMS 的垃圾回收机制进行性能分析


从 CMS 的垃圾回收机制可以发现,它已经尽可能的进行了性能优化了。

 

因为最耗时的是:一是并发标记阶段对老年代全部对象追踪 GC Roots,标记可回收对象。二是并发清理阶段对各种垃圾对象先清除后整理。

 

由于并发标记阶段和并发清理阶段都是和系统程序并发执行的,所以基本上这两个最耗时的阶段对性能影响不大。

 

虽然初始标记阶段和重新标记阶段需要 Stop the World,但是这两个阶段都是简单的标记而已,所以速度非常快,所以基本上这两个 STW 的阶段对系统运行响应也不大。

 

3.线上部署系统时如何设置垃圾回收相关参数


(1)CMS 的基本工作原理总结


为了避免长时间 Stop the World,CMS 采用了 4 个阶段来垃圾回收。其中初始标记和重新标记耗时很短,虽然会导致 STW,但是影响不大。然后并发标记和并发清理耗时最长,但可以和系统的工作线程并发运行。所以并发标记和并发清理两个阶段对系统也没太大影响,这就是 CMS 的基本工作原理。

 

接下来介绍 CMS 垃圾回收期间的一些细节和常见的 JVM 参数设置。

 

(2)并发回收垃圾导致 CPU 资源紧张


如下图示:



CMS 垃圾回收器有一个最大的问题:虽然能在垃圾回收的同时让应用程序也同时运行,但是在并发标记和并发清理两个最耗时的阶段,垃圾回收线程和应用程序工作线程同时工作,会导致有限的 CPU 资源被垃圾回收线程占用了一部分。

 

并发标记时要对 GC Roots 进行深度追踪,看所有对象里有多少是存活的。但因老年代里存活对象比较多,该过程又追踪大量对象,所以耗时较高。并发清理时要把垃圾对象从各种随机的内存位置清理掉,也是很耗时的。所以在并发标记和并发清理这两阶段,CMS 的垃圾回收线程会特别耗费 CPU。

 

CMS 默认启动的垃圾回收线程的数量是:(CPU 核数 + 3) / 4,下面用最普通的 2 核 4G 机器来计算一下。由于是 2 核的 CPU,所以 CPU 资源本来就有限,结果 CMS 还需要"(2 + 3) / 4 = 1"个垃圾回收线程,占用宝贵的 1 个 CPU。所以 CMS 这个并发垃圾回收的机制,最大的问题就是会消耗 CPU 资源。

 

(3)Concurrent Mode Failure 问题


一.什么是浮动垃圾


在并发清理阶段,CMS 只不过是回收之前标记好的垃圾对象。但这个阶段系统一直在运行,随着系统运行可能有些对象进入老年代。同时这些对象很快又失去引用变成垃圾对象,这种对象就是浮动垃圾。



上图的垃圾对象(新的)就是在并发清理期间,先被系统分配在新生代,然后触发一次 YGC,一些对象进入了老年代,短时间内又没被引用了。这种对象,就是老年代的浮动垃圾。浮动垃圾在本次的并发清理阶段中,由于没有被标记,所以不能被回收,需要等到下一次 GC 执行到并发清理阶段时才能进行回收。

 

二.CMS 垃圾回收的一个触发时机与预留空间


为了保证在 CMS 垃圾回收期间,能让一些对象可以进入老年代,JVM 会给老年代预留一些空间。

 

CMS 垃圾回收的一个触发时机就是:当老年代内存占用达到一定比例,就自动执行 FGC。这个比例是由-XX:CMSInitiatingOccupancyFaction 参数控制的,这个参数可以用来设置老年代占用达到多少比例时就触发 CMS 垃圾回收。

 

-XX:CMSInitiatingOccupancyFaction 参数在 JDK 1.6 里默认的值是 92%,也就是如果老年代占用了 92%的空间,就会自动进行 CMS 垃圾回收。此时会预留 8%的空间,这样在 CMS 并发回收期间,可让系统程序把一些新对象放入老年代中。

 

三.如果 CMS 垃圾回收期间,要放入老年代的对象已大于可用内存空间


这时就会发生 Concurrent Mode Failure,即并发垃圾回收失败了。CMS 一边回收,系统程序一边把对象放入老年代,内存不够了。此时就会自动用 Serial Old 替代 CMS,直接强行对系统程序 STW。重新进行长时间 GC Roots 追踪,标记全部垃圾对象,不允许新对象产生。最后再一次性把垃圾对象都回收掉,完成后再恢复系统程序。

 

所以在实践中:老年代占用多少比例时触发 CMS 垃圾回收,要设置合理。让 CMS 在并发清理期间,可以预留出足够的老年代空间来存放新对象,从而避免 Concurrent Mode Failure 问题。

 

(4)内存碎片问题


老年代的 CMS 垃圾回收器会采用"标记-清理"算法:每次都是标记出垃圾对象,然后一次性回收,这样会产生大量内存碎片。

 

内存碎片太多会导致对象进入老年代时找不到连续内存空间,触发 FGC。所以 CMS 不能只用标记-清理算法,因太多内存碎片会导致频繁 FGC。

 

"-XX:+UseCMSCompactAtFullCollection"这个 CMS 的参数,默认是打开的。意思是在 FGC 后要再次进行 STW,停止工作线程,然后进行碎片整理。碎片整理就是把存活对象移动到一起,空出大片连续内存空间,避免内存碎片。

 

"-XX:CMSFullGCsBeforeCompaction"这个 CMS 的参数,意思是执行多少次 FGC 后再执行一次内存碎片整理的工作。该参数值默认是 0,意思是每次 Full GC 后都会进行一次内存整理。

 

(5)为什么老年代的 FGC 要比新生代的 YGC 慢


为什么老年代的 FGC 要比新生代的 YGC 慢很多,一般在 10 倍+?其实原因很简单,下面分析它们的执行过程。

 

一.新生代 Young GC 执行速度很快


Young GC 时首先从 GC Roots 出发就可以追踪哪些对象是存活的了。由于新生代存活对象很少,这个速度会很快,不需要追踪多少对象。然后直接把存活对象放入 Survivor 中,接着再一次性回收 Eden 和之前使用的 Survivor。

 

二.CMS 的 Full GC 执行速度很慢


首先在并发标记阶段,需要去追踪所有存活对象。老年代存活对象很多,这个过程就会很慢。

 

其次在并发清理阶段,不是一次性回收一大片内存,而是要找到分散的垃圾对象,速度也很慢。

 

最后在完成 Full GC 后,还得执行一次内存碎片整理,把大量的存活对象给移动到一起,空出连续内存空间,这个过程还得 Stop the World,就更慢了。

 

此外万一并发清理期间,剩余内存空间不足以存放要进入老年代的对象,还会引发 Concurrent Mode Failure 问题,还得用 Serial Old 垃圾回收器,先进行 Stop the World,再重新来一遍标记清理的过程,这就更耗时了。

 

所以,老年代的垃圾回收比新生代的垃圾回收慢。

 

(6)触发老年代 GC 的时机总结


时机一:

老年代可用内存 < 新生代全部对象大小 + 没开启空间担保,触发 FGC。所以一般都会打开空间担保参数-XX:-HandlePromotionFailure。

 

时机二:

老年代可用内存 < 历次 YGC 后进入老年代的对象平均大小,触发 FGC。

 

时机三:

新生代 YGC 存活对象 > S 区(需进入老年代) + 老年代内存不足,触发 FGC。

 

时机四:

参数-XX:CMSInitiatingOccupancyFaction 可以设置 CMS 垃圾回收时的预留空间比例。进行 YGC 前的检查时,如果发现老年代可用内存大于历次新生代 GC 后进入老年代的对象平均大小,但老年代已使用的内存超过了这个参数指定的比例,就会触发 FGC。

 

4.新生代垃圾回收参数如何优化


(1)案例背景


下面通过一个案例分析如何在特定场景下:

一.预估系统的内存使用模型

二.合理优化新生代、老年代、Eden 和 S 区的内存大小

三.优化参数避免新生代对象进入老年代,让对象留在新生代里被回收

 

这里的背景是电商系统,电商系统一般会拆分为很多子系统独立部署。比如商品系统、订单系统、促销系统、库存系统、仓储系统、会员系统等等,这里以比较核心的订单系统作为例子来进行说明,案例背景是每日上亿请求量的电商系统。

 

下面推算一下,每日上亿请求量的电商系统,每日会有多少活跃用户。按每个用户平均访问 20 次,那么上亿请求量,大概需要 500 万日活用户。

 

继续推算一下,这 500 万的日活用户中多少用户会下订单?假设按 10%的付费转化率来计算,每天就会有 50 万用户支付订单。这 50 万订单假设集中在 4 小时高峰期内,那么平均每秒大概几十个订单。

 

在每秒几十个订单的压力下,其实根本就不需要对 JVM 过多关注。因为基本上就是每秒占用一些新生代内存,隔很久新生代才会满。然后一次 YGC 后垃圾对象清理掉,内存就空出来了,几乎无压力。但是如果考虑到特殊的电商大促场景,那么情况会怎样呢?

 

(2)特殊的电商大促场景


很多中小型的电商平台,平时系统压力不大,并发也不高。但如果遇到一些大促场景,比如双 11 什么的,情况就不同了。

 

假设在类似双 11 的节日里,零点开始大促活动,很大用户等待下单抢购。那么可能在大促开始的短短 10 分钟内就会有 50 万订单,那么此时每秒就会有接近 50 万 / 600 = 1000 的下单请求,所以下面就针对这种大促场景来对订单系统的内存使用模型进行分析。

 

(3)抗住大促的瞬时压力需要几台机器


那么要抗住大促期间的瞬时下单压力,订单系统需要部署几台机器呢?基本上可以按 3 台来算,就是每台机器每秒需要抗 300 个下单请求。这是非常合理的,假设订单系统部署的就是最普通的标配 4 核 8G 机器。从机器的 CPU 资源和内存资源看,抗住每秒 300 个下单请求是没问题的。

 

但是问题就在于需要对 JVM 有限的内存资源进行合理的分配和优化,包括对垃圾回收进行合理的优化,让 JVM 的 GC 次数尽可能最少,而且尽量避免 FGC,这样能尽可能减少 JVM 的 GC 对高峰期的系统影响。

 

(4)大促高峰期订单系统的内存使用模型估算


接下来预估订单系统的内存使用模型,基本上可以按照每秒处理 300 个下单请求来估算。因为处理下单请求是比较耗时的,涉及很多接口的调用,基本上每秒处理 100~300 个下单请求是差不多的。

 

每个订单按 1K 的大小来估算,那么 300 个订单就会有 300K 的内存开销。然后算上订单对象连带的商品、库存、促销、优惠券等其他业务对象,一般按照经验需要对单个对象开销放大 10 到 20 倍。除了下单之外,这个订单系统还会有很多订单相关的其他操作。比如订单查询之类的,所以连带算起来,可以往大了估算,再扩大 10 倍。那么每秒钟大概会有 300K * 20 * 10 = 60M 的内存开销。

 

但是经过一秒后,可以认为这 60M 的对象就是垃圾了。因为 300 个订单处理完了,所有相关对象都失去引用,进入可回收状态。如下图示:



(5)内存到底该如何分配


假设现在有 4 核 8G 的机器:那么给 JVM 的内存一般是 4G,剩下几个 G 会留给操作系统来使用。其中堆内存可以给 3G,新生代可以给 1.5G,老年代给 1.5G。然后每个线程的 Java 虚拟机栈会设置 1M,JVM 里如果有几百个线程大概也会有几百 M,然后再给永久代(方法区)256M 内存,基本上这 4G 内存就差不多了。

 

同时还要记得设置一些必要的参数:比如说打开空间担保参数-XX:HandlePromotionFailure;

 

一.整个 JVM 参数会如下所示


 -Xms3072M -Xmx3072M -Xmn1536M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:HandlePromotionFailure
复制代码


但是-XX:HandlePromotionFailure 参数在 JDK 1.6 以后就被废弃了。在 JDK 1.6 以后,只要判断:老年代可用空间 > 新生代对象总和,或者老年代可用空间 > 历次 YGC 升入老年代对象的平均大小,两个条件满足一个,就可以直接进行 YGC 而不需要提前触发 FGC。

 

所以如果用的是 JDK1.7 或者 JDK1.8,那么 JVM 参数就保持如下即可:


 -Xms3072M -Xmx3072M -Xmn1536M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M
复制代码


此时 JVM 内存如下图示:



二.订单系统的内存使用模型


接着就很明确了,订单系统在大促期间不停的运行,每秒处理 300 个订单,每秒会占据新生代 60M 的内存空间。但是 1 秒过后这 60M 对象都会变成垃圾,那么新生代 1.5G 的内存空间大概每 25 秒就会被占满,如下图示:



三.订单系统什么时候会进行 YGC


订单系统在大促期间运行,大概 25 秒过后就要进行 YGC 了。此时因为有-XX:HandlePromotionFailure 选项,所以需要进行 FGC 检查:比较老年代可用空间大小和历次 YGC 后进入老年代对象的平均大小,刚开始这个检查肯定是可以通过的。

 

所以 YGC 直接运行,一下子可以回收掉 99%的新生代对象。因为除了最近一秒的订单请求还在处理,大部分订单早就处理完了。所以此时存活的对象可能有 100M 左右。

 

四.订单系统 YGC 后的存活对象进入 Survivor 区


如果-XX:SurvivorRatio 参数是默认值 8,那么此时新生代 Eden 区占 1.2G 内存,每个 Survivor 区占 150M 内存。如下图示:



因此大概只需要 20 秒,就会把 Eden 区占满,就要进行 YGC 了。然后 YGC 后存活对象大概在 100M 左右,会放入 S1 区域内。如下图示:



然后再次运行 20 秒,把 Eden 区重新被占满。再次垃圾回收 Eden 和 S1 中的对象,存活对象还是 100M 左右,进入 S2 区。如下图示:



此时 JVM 参数如下:


 -Xms3072M -Xmx3072M -Xmn1536M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8
复制代码


(6)新生代垃圾回收优化之 Survivor 空间够不够


进行 JVM 优化时首先要考虑的问题是:新生代的 Survivor 区到底够不够。按上述逻辑,每次新生代垃圾回收的存活对象在 100~150M 左右。

 

一.如果 YGC 后的存活对象频繁突破 150M


那么就会出现 YGC 后的对象无法放入 S 区,然后频繁让对象进入老年代。

 

二.如果 YGC 后的存活对象少于 150M + 存活对象有大于 100M 进入 S 区


由于这一批都是同龄对象,且超过 S 区空间 50%。根据动态年龄判断规则,此时也可能会导致对象直接进入老年代。所以根据这个模型,Survivor 区的大小是明显不足的,这里建议调整新生代和老年代的大小。

 

这种业务系统的大部分对象都是短生存周期的,不应频繁进入老年代,没必要给老年代维持过大的内存空间,得先让对象尽量留在新生代里。所以可以考虑把新生代调整为 2G,老年代调整为 1G。那么此时 Eden 为 1.6G,每个 Survivor 为 200M。如下图示:



这时 S 区变大,大大减少了新生代 GC 后存活对象在 S 区放不下的情况,也大大减少了同龄对象超过 S 区 50%的问题。这样就大大降低了新生代对象进入老年代的概率,此时 JVM 的参数如下:


其实对任何系统:首先需要预估内存使用模型以及分配合理的内存,尽量让每次 Young GC 后的对象都停留在 S 区中,不要进入老年代,这是进行优化的一个地方。

 

(7)新生代对象躲过多少次垃圾回收后进入老年代


除了 YGC 后对象无法放入 S 区会导致一批对象进入老年代外,如果有些对象连续躲过 15 次垃圾回收也会自动升入老年代。

 

如果按照上述内存运行模型,基本上 20 多秒触发一次 YGC。根据-XX:MaxTenuringThreshold 默认值 15,如果一个对象躲过 15 次 GC,其实也就代表这个对象在新生代停留超过了 15 * 20 = 几分钟,此时该对象进入老年代也是合情合理的。

 

有些博客会说:应该提高这个-XX:MaxTenuringThreshold 参数。比如增加到 20 次或者 30 次,其实这种说法是不对的。因为对这个参数的考虑必须结合系统的运行模型,如果躲过 15 次 GC 都经过几分钟了,也就是一个对象几分钟都不能回收,说明这个对象肯定是要长期存活的核心组件(使用了类似 @Service 注解),那么这个对象就应该进入老年代。何况这种对象一般很少,一个系统累计起来最多也就几十 M 而已。所以提高-XX:MaxTenuringThreshold 参数的值,没有什么用。在上述系统的运行模型下,最多只能让这些对象在新生代里多留几分钟。因此要结合运行原理,根据运行模型的推演,不同业务系统是不一样的。其实甚至可以降低这个参数的值,比如降低到 5 次。也就是一个对象如果躲过 5 次 Young GC,在新生代里停留超过 1 分钟。那么就尽快就让它进入老年代,别在新生代里占着内存。

 

总之,-XX:MaxTenuringThreshold 参数务必结合系统具体运行模型考虑。此时,JVM 参数如下:


 -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5
复制代码


(8)多大的对象直接进入老年代


大对象可以直接进入老年代 ,因为大对象,说明是要长期存活和使用的。比如在 JVM 里可能会缓存一些数据,这个可结合系统实际来决定。但一般来说,设置大对象为 1M 即可,因为一般很少有超过 1M 的大对象。如果有,可能是提前分配了一个大数组、大 List 等用来进行缓存的数据。此时 JVM 参数如下:


 -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5  -XX:PretenureSizeThreshold=1M
复制代码


(9)指定垃圾回收器


指定垃圾回收器:新生代使用 ParNew,老年代使用 CMS。此时 JVM 参数如下:


 -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5  -XX:PretenureSizeThreshold=1M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
复制代码


ParNew 的核心参数就是新生代内存大小、Eden 和 Survivor 的比例。只要设置合理,给新生代里的 S 区充足空间,避免 YGC 后对象放不下 S 区进入老年代或者动态年龄判定要进入老年代,那么 YGC 一般就没什么问题。

 

然后根据系统运行模型,合理设置-XX:MaxTenuringThreshold。让那些长期存活的对象,尽快进入老年代,别在新生代里一直占用空间。这样便完成了 JVM 新生代参数的初步优化。

 

(10)如何估算系统运行模型


根据如下问题估算系统运行模型:

一.每秒占用多少内存

二.多长时间触发一次 YGC

三.一般 YGC 后有多少存活对象

四.S 区能否放得下

五.是否会频繁出现因 S 区放不下导致对象进入老年代

六.是否会因动态年龄判断规则进入老年代

 

5.老年代的垃圾回收参数如何优化


(1)总结新生代的垃圾回收参数如何优化


前面介绍了一个 JVM 新生代优化分析的案例,根据一个百万日活及上亿请求的中型电商在大促期间的高峰下单场景,推测出大促高峰期,每台机器每秒 300 个下单请求,每秒使用 60M 内存。

 

然后根据该背景推测应如何给 4 核 8G 机器的 JVM,合理分配各区域内存。让每隔 20 秒一次 YGC 产生的 100M 存活对象进入 200M 的 Survivor 区,不会因 Survivor 内存不足或动态年龄判定规则让存活对象进入老年代。

 

同时还根据 YGC 的频率,合理降低了大龄对象进入老年代的年龄,尽快让一些长期存活的对象赶紧进入老年代,不要停留在新生代里。如下图示:



此时的 JVM 参数如下所示:


 -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5  -XX:PretenureSizeThreshold=1M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
复制代码


(2)在案例背景下对象什么时候会进入老年代


在目前背景下,什么情况会让对象进入老年代?

 

第一种情况:年龄超过了-XX:MaxTenuringThreshold 的新生代对象

该参数会让在一两分钟内连续躲过 5 次 Young GC 的对象迅速进入老年代。这种对象一般是被 @Service、@Controller 等标注的系统业务逻辑组件。这种对象一般使用单例模式,全局一个实例,会长期被 GC Roots 引用。这种对象一般不会太多,大概一个系统最多就占用几十 M。所以类似这样的、需要长期存活的组件对象就会进入老年代中。如下图示:



第二种情况:分配一个大小超过了-XX:PretenureSizeThreshold 的新生代大对象

比如创建一个大数组或者是大 List 对象,就会直接进入老年代。但是这种大对象在这个案例里几乎是没有的,所以可以忽略不计。

 

第三种情况:存活的对象使 Survivor 内存不足或动态年龄判定规则生效

YGC 后的存活对象超过 200M 放不进 Survivor 区,或者 YGC 后的存活对象超过 Surviovr 区的 50%且年龄都一样,此时这些存活对象就会进入到老年代中。但是前面对新生代的 JVM 参数进行优化,就是为了避免这种情况。所以这种概率应该是很低的,但也不能完全避免这种情况。比如某次 GC 后刚好有超过 200M 存活对象,则这些对象就会进入老年代。

 

(3)大促期间多久会触发一次 FGC


下面假设该订单系统在大促期间,每隔 5 分钟在 YGC 后就有一小批对象进入老年代,这一小批对象的大小大概是 200M。如下图示:



那么 FGC 的触发条件有如下几种:


一.没有打开空间担保参数-XX:HandlePromotionFailure

老年代可用内存最多也就 1G,新生代对象总大小最多可以有 1.8G。这样导致每次 YGC 前检查都发现:老年代可用内存 < 新生代总对象大小。从而导致每次 YGC 前都触发 FGC,当然 JDK1.6+的版本废弃了这个参数。

 

二.老年代可用内存空间 < 历次 YGC 后升入老年代的对象的平均大小

其实按照目前设定的背景,要经过很多次 YGC 后才可能有一两次碰巧会有 200M 对象升入老年代。所以这个"历次 YGC 后升入老年代的平均对象大小",基本是很小的。

 

三.某次 YGC 后要升入老年代的对象有几百 M,但老年代可用空间不足

 

四.满足设置的老年代空间使用比例-XX:CMSInitiatingOccupancyFaction

比如设定值为 92%,那么此时可能前面几个条件都没满足。但刚好发现该条件满足了,即老年代空间使用超过 92%,就会触发 FGC。

 

实际上在系统运行期间,可能会慢慢地有对象进入老年代。但因为优化过新生代的内存分配,所以对象进入老年代的速度是很慢的。很可能是在系统运行半小时~1 小时后,才会有接近 1G 对象进入老年代。此时可能因为满足上述二三四条件中的一个,才会触发了 FGC。但是这三个条件一般都需要老年代近乎占满时,才有可能触发。所以可以预估在大促期间,订单系统运行 1 小时后,大促下单高峰期几乎都快过了,此时才可能会触发一次 FGC。

 

注意:订单系统运行 1 小时后才会触发一次 FGC 的推论很重要。因为按照大促开始 10 分钟就有 50 万订单来计算,1 小时可能有几万订单。这是一年难得罕见的节日大促才会有的,日常不会有这样的订单压力。等这个高峰期一过,订单系统访问压力就很小了,GC 问题几乎没影响。

 

所以经过新生代的优化,可以推算出:基本上在大促高峰期内,可能 1 小时才出现 1 次 FGC。然后高峰期一过,可能就要几个小时才有一次 FGC。

 

(4)老年代 GC 可能会发生 Concurrent Mode Failure


假设订单系统运行 1 小时后,老年代有 900M 对象,剩余可用空间只剩 100M,此时就会触发一次 FGC。如下图示:



此时在执行 FGC 时会有一个很大的问题:就是 CMS 在垃圾回收时的并发清理期间,系统程序是可以并发运行的。而此时老年代空闲空间仅剩 100M,但系统程序又在不停地创建对象。万一这时系统运行触发了某个条件,比如又有 200M 对象要进入老年代。如下图示:



那么此时就会触发 Concurrent Mode Failure 问题。因为此时老年代没有足够内存来放这 200M 对象,所以会导致系统 STW。然后切换 CMS 为 Serial Old + 禁止程序运行 + 使用单线程回收老年代垃圾。当回收掉 900M 对象后,再让系统继续运行。

 

以上这种情况虽然可能会发生,但是概率是挺小的。因为需要在触发 CMS 的同时,系统运行期间还让 200M 对象进入老年代。这个概率本身就很小,但理论上是有可能的。对于这种小概率的事件,其实没有必要去调整参数,不需要针对小概率事件特意优化参数。

 

所以最终的 JVM 参数如下:


 -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5  -XX:PretenureSizeThreshold=1M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92
复制代码


(5)如何设置 CMS 在 GC 后要进行内存碎片整理的频率


在完成 CMS 后,一般需要执行内存碎片的整理,可以设置多少次 FGC 后执行一次内存碎片整理。

 

但其实没必要去修改这个参数。因为通过前面分析,在大促高峰期,FGC 可能也就 1 小时执行一次。然后大促高峰期过后,就没那么多的订单了,可能几小时才一次 FGC。所以保持默认的设置,每次 FGC 后都执行一次内存碎片整理即可。

 

所以最终的 JVM 参数如下:


 -Xms3072M -Xmx3072M -Xmn2048M -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M  -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5  -XX:PretenureSizeThreshold=1M  -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92  -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0
复制代码


综上所述,可以看出:FGC 优化的前提是 YGC 的优化,YGC 的优化的前提是合理分配内存空间,合理分配内存空间的前提是对系统运行期间的内存使用模型进行预估。

 

其实对很多普通的 Java 系统而言,只要对系统运行期间的内存使用模型做好预估,然后合理分配内存空间,尽量让 YGC 后的存活对象留在 S 区不去老年代,基本上其余的 GC 参数不需要做太多优化,系统性能基本都不会太差。

 

6.问题汇总


问题一:


一个机器能开多少线程?取决于什么?

 

答:如果是 4 核 CPU。JVM 本身有一些后台线程,还有使用的一些框架也会有一些后台线程。所以应用系统一般开启线程数量在几十个,比如 50 左右基本就差不多了。这样 JVM 进程所有线程加起来有 100+,遇上高峰期 100+的多线程同时工作,CPU 基本就满负荷了。

 

问题二:


是不是 Stop the World 只在 FGC 时发生,在 YGC 时不会发生?

 

答:不管是老年代回收还是新生代回收,都要 Stop the World。因为必须让程序别创建新对象,才能回收垃圾对象。Old GC 和 Major GC 其实是一个概念,都是指的老年代的 GC。只不过一般会带着一次 Minor GC,也就是 Young GC。

 

问题三:


采用 ParNew + CMS 垃圾回收器如何只做 YGC?

 

答:实现只做 YGC,其实和垃圾收集器没有什么关系。不同垃圾回收器,差别只在于性能和吞吐量,并不影响垃圾回收时机。根据对象生存周期特点,合理分配各区大小,尽量让对象在新生代回收。也就是:避免新生代对象进入老年代 + 避免 FGC。

 

如何避免新生代对象进入老年代:


一.根据实际情况查看每次 YGC 后存活对象的大小,设置合适的 S 区大小,保证存活对象进入 S 区而不是老年代。

二.根据对象存活的时间以及 YGC 的间隔时间,确定年龄。比如:3 分钟一次 YGC,而对象可以存活 1 个小时。那就把对象年龄设置到 20,避免对象 15 岁进入老年代。

三.大对象如果偶尔创建一个,那么可以调大大对象的阈值,使大对象分配至新生代。即可以设置-XX:PretenureSizeThreshold,使其分配至新生代。如果大对象创建销毁频繁,就让其直接进入老年代。此时可以利用对象池避免大对象频繁创建销毁。

 

如何避免 FGC:


一.保证老年代的可用空间大于新生代的所有对象,避免 YGC 前进行 FGC。

二.如果一可以保证,那么就不需要考虑参数-XX:HandlePromotionFailure、以及进入老年代的对象平均大小的比较。

三.保证 YGC 后存活对象的大小不大于 Survivor 空间。

 

问题四:


为什么 YGC 比 FGC 快?

 

答:除了 FGC 慢的原因,还有 YGC 快的原因。


YGC 快的原因如下:


新生代垃圾回收存活对象很少,且采用复制算法,所以迁移内存很快。然后一次性清理垃圾对象,这个速度也很快,所以比标记整理效率高。

 

FGC 慢的原因如下:


老年代要先移动对象压在一起,存活对象又那么多,涉及到漫长的对象追踪和移动过程,所以速度慢。

 

问题五:


都已经 FGC 了程序还并行运行,创建出的对象放那?会一直触发 FGC 吗?如果对象太多放不下,会等 FGC 完成吗?这时候也是 Stop the World 吗?

 

答:会继续放新生代,可能会同步触发 YGC。所以有可能有新的对象进入老年代,还可能有些老年代对象失去了引用。所以 CMS 并发标记环节,标记出来的垃圾对象,可能是不准确的。如果对象太多放不下,会等着 FGC 完成,这时候也要 Stop the World。

 

问题六:


为什么老年代垃圾回收比新生代慢很多?

 

答:新生代一般存活对象少,采用标记复制算法。首先从 GC Root 出发标记存活对象,然后直接把少量存活对象复制到另一块内存,之后再清除原来那块内存。

 

老年代一般存活对象多,采用标记整理算法。首先也是需要从 GC Roots 出发遍历标记存活对象和垃圾对象。从 GC Roots 出发查找,直接或间接引用到的对象,就是存活的、要标记。剩下的就是垃圾对象,在并发清除阶段需要被清除。然后再遍历清除垃圾对象,最后还要移动存活对象,避免太多内存碎片。

 

问题七:


Eden 区内存大小超过老年代时:如果没开启允许担保失败参数,是否 YGC 前就 FGC 了?

 

答:是的,所以 JDK1.6+默认开启担保机制。

 

问题八:


是否会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况?

 

答:YGC 会直接 STW 系统不能运行,必须等垃圾回收完才能再次 YGC。老年代垃圾回收是有可能没执行完又被触发的,因为使用的是并发清理。一边垃圾回收一边系统运行,也许 FGC 没回收完就再次触发 FGC。如果此时老年代内存又不够,就会进入 STW,就会用 Serial Old 来回收。

 

问题九:


YGC 其实也会 STW,是不是在跟踪和复制阶段?

 

答:是的,新生代也要 Stop the World,但是它速度很快。从 GC Roots 出发标记出少量存活对象,然后转移到空的 Survivor 区里,最后直接清空所有垃圾对象。

 

问题十:


是否有一个通用的 JVM 优化参数?

 

答:并没有,对 JVM 参数优化,必须基于每个系统的运行模型。

 

问题十一:


在 Eden 和 Survivor 区 GC 时也是从 GC Root 开始标记和跟踪对象,在新生代的对象数量更多为什么在新生代就不耗时?在老年代 GC 时第二步并发清理的 GC Root 跟踪就很耗时?

 

答:从 GC Roots 开始标记跟踪,只跟踪直接引用,新生代存活对象极少。所以新生代很快就追踪完毕了,而老年代存活对象太多,追踪很耗时。

 

问题十二:


为什么说 CMS 使用了标记清理和标记整理两个算法?

 

答:标记-清理是先标记再清理,标记-整理是先标记然后整理,最后才清理。CMS 是先标记再清理,然后再整理,所以才说 CMS 使用了标记-清理和标记-整理两个算法。

 

问题十三:


新创建的对象,到底是往 Eden 区放,还是会往 Suivivor 区放?

 

答:新的对象只会放入 Eden,而 S 区只是用于存放每次 YGC 后的对象,新创建的对象不会往 S 区存放的。

 

问题十四:


通过动态年龄进入到老年代的对象,这批对象的年龄是否最多是 1 岁?动态年龄判断规则具体是怎样的?

 

答:动态年龄判断规则:Survivor 区中这个年龄以及低于这个年龄的对象占据超过 Survivor 区 50%,那么等于或高于这个年龄的对象就直接进入老年代。

 

比如 S 区内,年龄 1 + 年龄 2 + 年龄 3 + 年龄 n 的对象总和大于 S 区的 50%,此时年龄 n 以上的对象会进入老年代,不一定要达到 15 岁。

 

所以动态年龄判断规则有个推论:如果 S 区中的同龄对象大小超过 S 区内存的一半,就要直接升入老年代。

 

问题十五:


哪些情况下对象会进入老年代?

一.大对象直接分配到老年代

二.YGC 后对象的年龄到了 15

三.YGC 后存活对象大小大于 Survivor 区大小

四.动态年龄规则触发

五.YGC 前检查发现没有配置空间担保参数

六.YGC 前有配置空间担保参数 + 老年代可用内存小于历次晋升平均内存

七.老年代中已经被使用的内存空间达到了-XX:CMSInitiatingOccupancyFaction 设置的比例

 

问题十六:


案例总结如下:

一.首先计算系统高峰的 QPS

每秒内存开销 = QPS * 单个对象大小 * 扩大 20 倍 * 其他操作 10 倍。

二.根据系统可用内存分配堆大小

分配新生代大小根据每秒产生的内存开销来计算。比如每秒 60M 内存开销,则 2G 给新生代,默认 Eden : S1 : S2 为 8 : 1 : 1。

三.Eden 区有 1.6G,25s 左右会被占满而进行 YGC

此时存活的对象大概有百分之 10 大概为 160M,这些存活的对象进入 s1。有可能新生代回收后的存活对象 200M+,造成这些对象直接进入老年代。此时可继续往上调新生代大小,也可以调节 Eden 区和 s1、s2 的比例。

四.空间担保参数需要打开

避免检查时发现老年代可用空间小于新生代对象大小直接 FGC。

五.系统中可能会在内存缓存大对象、大集合

这种对象一般都是要频繁使用或者要一直缓存的,这时要设置直接晋升到老年代对象的大小。

六.设置在 S1 和 S2 区的对象晋升到老年代的年龄,一般采用默认的即可

可以根据实际项目,调小晋升年龄来让长期存活的对象尽快进入到老年代。

七.垃圾的回收器设置为 Parnew + Cms,可以充分发挥多核处理器的优势

整体来说,优化的思路是:尽量避免频繁 FGC,降低系统 STW 的次数和时间。

 

问题十七:


(1)YGC 前,新生代中对象的总大小会与老年代中可用内存进行比较。其中老年代的可用内存是指剩余内存空间还是指连续可用内存空间?

(2)老年代会默认预留 8%的空间给并发回收期间进入老年代的对象使用,CMS 在并发标记和清理期间需进入老年代的对象大于 8%的空间会怎样?

 

答:(1)连续可用空间。(2)这种情况可能会触发 Concurrent Mode Failure,此时 CMS 会被 Serial Old 替代进行垃圾回收,直接 Stop the World。

 

问题十八:


一.公司系统介绍

目前系统 16G 内存,JVM 6G 的内存,新生代 5.5G,永久代设置 128M,老年代就是 512 - 128 = 384M。

 

系统每秒在 Eden 区占用 12M,所以 YGC 大概每 450 秒(8 分钟)运行一次,一个 S 区 560M 内存,每次 YGC 回收后大概占 S 区空间的 40~60%。所以 S 区空间是够的,此外同年龄对象占比超过 50%的概率很低。

 

之前晋升年龄为 31,后来改成 4,由于一次 YGC 要 8 分钟,4 次约半小时。所以半小时后,需要长期存活的对象肯定会进入老年代。

 

目前系统一直线上运行,离上次更新已半个月,发生 FGC3 次。所以这就是堆内存进行了合理优化,实现了 YGC 后的存活对象不会那么快进入老年代。可见不是堆越大越好,而是要根据系统整体运行情况预估。如果预估不准确,就用工具检测,然后合理优化。

 

二.JVM 优化总结

进行 JVM 优化的第一步,就是分析系统运行的内存使用模型。然后合理预估合理分配内存。保证对象都在新生代里,进入老年代的速度要很慢很慢。其中对堆内存的调整,应该是观察 Survivor 区:看看 YGC 后的存活对象在 Survivor 区的占比是否过多。如果超过 70%时则可能需要加大堆内存,或者业务高峰期很快就占满 Eden 区,也要加大堆内存。

 

问题十九:


应该设置多少次 FGC 后才进行碎片整理?

 

答:如果 FGC 相对频繁一些,可以设置多次 FGC 再进行碎片整理。如果 FGC 不是很频繁,可以设置每次 FGC 都进行碎片整理。


文章转载自:东阳马生架构

原文链接:https://www.cnblogs.com/mjunz/p/18639663

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
JVM实战—JVM垃圾回收器的原理和调优_JVM_不在线第一只蜗牛_InfoQ写作社区