写点什么

垃圾回收算法有哪些?了解哪些垃圾回收器?

  • 2025-07-15
    福建
  • 本文字数:8706 字

    阅读完需:约 29 分钟

垃圾回收算法有哪些?


垃圾回收算法有四种,分别是标记清除法、标记整理法、复制算法、分代收集算法。

  • 标记清除算法:首先利用可达性去遍历内存,把存活对象和垃圾对象进行标记。标记结束后统一将所有标记的对象回收掉。这种垃圾回收算法效率较低,并且会产生大量不连续的空间碎片。

  • 复制清除算法:半区复制,用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。


    特点:实现简单,运行高效,但可用内存缩小为了原来的一半,浪费空间。

  • 标记整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

  • 分类收集算法:根据各个年代的特点采用最适当的收集算法。


一般将堆分为新生代和老年代。新生代使用复制算法,老年代使用标记清除算法或者标记整理算法。

在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用标记-清理或者标记-整理算法进行垃圾回收。


JVM 新生代垃圾回收如何避免全堆扫描?


JVM 在进行新生代垃圾回收时,通过以下方式避免全堆扫描:

  • 卡表(Card Table)机制:JVM 使用卡表记录老年代引用新生代对象的指针变化,从而在进行新生代回收时,只扫描那些老年代中确实有引用指向新生代的区域,避免了全堆扫描。

  • 写屏障(write Barier):当老年代中的对象引用新生代对象时,写屏障会拦载这种引用,并在卡表中标记相关信息。这样,垃圾回收器在扫描时只需要检查标记的区域,而不是遍历整个老年代。


什么是指针碰撞


在 Java 中,指针碰撞是一种垃圾收集算法中用于分配内存的一种方式。它通常用于实现停顿时间较短的垃圾收集器,如复制算法和标记整理算法。


指针碰撞的基本思想是将堆内存分为两个区域:一个是已分配的对象区域,另一个是未分配的空闲区域。通过一个指针来分隔这两个区域。当需要分配对象时,垃圾收集器将对象的大小与空闲区域的大小进行比较,如果空闲区域足够容纳对象,则将指针碰撞指针向前移动对象的大小,并返回指针碰撞指针的旧值作为对象的起始地址。如果空闲区域不足以容纳对象,则进行垃圾回收操作,释放一些内存后再进行分配。


指针碰撞的优点是分配内存的速度很快,只需简单地移动一个指针即可完成。而且由于已分配的对象区域和未分配的空闲区域是连续的,所以内存的利用率也比较高。


然而,指针碰撞算法的缺点是需要保证堆内存的连续性,即堆内存必须是一块连续的内存空间。这对于某些情况下的内存分配来说可能是一个限制,因为连续的内存空间可能会受到碎片化的影响,导致无法分配足够大的对象。因此,在实际应用中,指针碰撞算法通常与其他内存分配算法结合使用,以克服其局限性。


有哪些垃圾回收器?



以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

并行收集: 指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

并发收集: 指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上

吞吐量: 即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99%


串行 Serial / Serial Old 收集器


特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)参数:-XX:+UseSerialGC -XX:+UseSerialOldGC



安全点: 让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态

Serial Old 是 Serial 收集器的老年代版本:采用标记整理算法

特点:

  • 单线程收集器

    收集效率高,不会产生对象引用变更

    STW 时间长

  • 使用场景:适合内存小几十兆以内,比较适合简单的服务或者单 CPU 服务,避免了线程交互的开销。

  • 优点:小堆内存且单核 CPU 执行效率高。

  • 缺点:堆内存大,多核 CPU 不适合,回收时长非常长。


ParNew 收集器


年轻代:-XX:+UserParNewGC 老年代搭配 CMS

ParNew 收集器其实就是 Serial 收集器的多线程版本

  • 特点: 多线程、ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的环境中,可以使用-XX:ParallelGCThreads 参数来限制垃圾收集的线程数。和 Serial 收集器一样存在 Stop The World 问题



CMS 收集器


老年代:-XX:+UserConcMarkSweepGC 年轻代搭配 ParNew

Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器

  • 特点: 基于标记清除算法实现。并发收集、低停顿,但是会产生内存碎片



运行过程分分为下列 4 步:


1、初始标记: 标记 GCRoots 直接关联的对象以及年轻代指向老年代的对象,会发生 Stop the word。但是这个阶段的速度很快,因为没有向下追溯,即只标记一层。

例如:Math math = new Math();此时 new Math()即为 math 的直接引用对象,再往下为间接引用不做记录,例如构造方法中引用了其他成员变量


2、并发标记: 接着从 gc roots 的直接引用对象开始遍历整条引用链并进行标记,此过程耗时较长,但无需停顿用户线程,可与垃圾收集线程一起并发运行。由于用户线程继续运行,因此可能会导致已经标记过的对象状态发生改变,这个阶段采用三色标记算法, 在对象头(Mark World)标识了一个颜色属性,不同的颜色代表不同阶段,扫描过程中给与对象一个颜色,记录扫描位置,防止 cpu 时间片切换不需要重新扫描。


3、重新标记: 为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World 问题,这里会慢一些


4、并发清理: 标记结束之后开启用户线程,同时垃圾收集线程也开始对未标记的区域进行清除,此阶段若有新增对象则会被标记为黑色,不做任何处理

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。


  • 应用场景:

适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务


  • 优点:

并发收集;

STW 时间相对短,低停顿;


  • 缺点:

吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。

内存碎片问题:CMS 本质上是实现了标记清除算法的收集器,这意味着会产生内存碎片,当碎片化非常严重的时候,这时候有大对象进入无法分配内存时会触发 FullGC,特殊场景下会使用 Serial 收集器,导致停顿不可控。

无法处理浮动垃圾,需要预留空间,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,会导致停顿时间更长


三色标记算法


三色标记

  • 黑色:代表了自己已经被扫描完毕,并且自己的引用对象也已经确定完毕。

  • 灰色:代表自己已经被扫描完毕了, 但是自己的引用还没标记完。

  • 白色:则代表还没有被扫描过。标记过程结束后,所有未被标记的对象都是不可达的,可以被回收。

三色标记算法的问题场景:当业务线程做了对象引用变更,会发生 B 对象不会被扫描,当成垃圾回收。


public class Demo3 {     public static void main(String[] args) {        R r = new R();        r.a = new A();        B b = new B();        // GCroot遍历R, R为黑色, R下面的a引用链还未扫完置灰灰色,R.b无引用, 切换时间分片        r.a.b = b;        // 业务线程发生了引用改变, 原本r.a.b的引用置为null        r.a.b = null;        // GC线程回来继续上次扫描,发现r.a.b无引用,则认为b对象无任何引用清除        r.b = b;        // GC 回收了b, 业务线程无法使用b    }} class R {    A a;    B b;} class A {    B b;} class B {}
复制代码



当 GC 线程标记 A 时,CPU 时间片切换,业务线程进行了对象引用改变,这时候时间片回到了 GC 线程,继续扫描对象 A, 发现 A 没有任何引用,则会将 A 赋值黑色扫描完毕,这样 B 则不会被扫描,会标记 B 是垃圾, 在清理阶段将 B 回收掉,错误的回收正常的对象,发生业务异常。

CMS 基于这种错误标记的解决方案是采取写屏障 + 增量更新 Incremental Update , 在业务线程发生对象变化时,重新将 R 标识为灰色,重新扫描一遍,Incremental Update 在特殊场景下还是会产生漏标。即当黑色对象被新增一个白色对象的引用的时候,记录下发生引用变更的黑色对象,并将它重新改变为灰色对象,重新标记。



public class Demo3 {     public static void main(String[] args) {        // Incremental Update还会产生的问题        R r = new R();        A a = new A();        A b = new A();        r.a1 = a;        // GC线程切换, r扫完a1, 但是没有扫完a2, 还是灰色        r.a2 = b;        // 业务线程发生引用切换, r置灰灰色(本身灰色)        r.a1 = b;        // GC线程继续扫完a2, R为黑色, b对象又漏了~    }} class R {    A a1;    A a2;} class A {}
复制代码


当 GC 1 线程正在标记 O, 已经标记完 O 的属性 O.1, 准备标记 O.2 时,业务线程把属性 O,1 = B,这时候将 O 对象再次标记成灰色, GC 1 线程切回,将 O.2 线程标记完成,这时候认为 O 已经全部标记完成,O 标记为黑色, B 对象产生了漏标, CMS 针对 Incremental Update 产生的问题,只能在 remark 阶段,暂停所有线程,将这些发生过引用改变过的,重新扫描一遍。


吞吐量优先 Parallel


  • 多线程

  • 堆内存较大,多核 CPU

  • 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短

  • JDK1.8 默认使用的垃圾回收器



Parallel Scavenge 收集器


新生代收集器,基于复制算法实现的收集器。特点是吞吐量优先,故也称为吞吐量优先收集器,能够并行收集的多线程收集器,允许多个垃圾回收线程同时运行,降低垃圾收集时间,提高吞吐量。 Parallel Scavenge 收集器关注点是吞吐量,高效率的利用 CPU 资源。 CMS 垃圾收集器关注点更多的是用户线程的停顿时间。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的

-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。

  • -XX:MaxGCPauseMillis 参数的值是一个大于 0 的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户设定值。

  • -XX:GCTimeRatio 参数的值大于 0 小于 100,即垃圾收集时间占总时间的比率,相当于吞吐量的倒数。


该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC 自适应调节策略(与 ParNew 收集器最重要的一个区别)


GC 自适应调节策略: Parallel Scavenge 收集器可设置-XX:+UseAdptiveSizePolicy 参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为 GC 的自适应调节策略。


  • 使用场景:适用于内存在几个 G 之间,适用于后台计算服务或者不需要太多交互的服务,保证吞吐量的服务。

  • 优点:可控吞吐量、保证吞吐量,并行收集。

  • 缺点:回收期间 STW,随着堆内存增大,回收暂停时间增大。


Parallel Old 收集器


是 Parallel Scavenge 收集器的老年代版本

特点:多线程,采用标记整理算法(老年代没有幸存区)

  • 响应时间优先

  • 多线程

  • 堆内存较大,多核 CPU

  • 尽可能让单次 STW 时间变短(尽量不影响其他线程运行)


CMS 垃圾回收流程是怎样的?


  1. 初始标记(initial mark):在这个阶段,CMS 会进行一个快速的初始标记,标记所有根对象(如栈中的引用)直接可达的对象。此过程是 STW 的,但时间较短

  2. 并发标记(Concurrent marking):初始标记后,CMS 进入并发标记阶段,在此阶段,垃圾收集器与应用线程并发运行,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。此阶段可能会持续较长时间。

  3. 并发预清理(Concurrent precleaning):这个阶段也是和应用线程并发,就是想帮重新标记阶段先做点工作,扫描一下卡表脏的区域和新晋升到老年代的对象等,因为重新标记是 STW 的,所以先分担一点。

  4. 可中断的预清理阶段(AbortablePreclean):这个和上一个阶段基本上一致,就是为了分担重新标记标记的工作,但是它可以被中断

  5. 重新标记(remark):这个阶段是 STW 的,因为并发阶段引用关系会发生变化,所以要重新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记

  6. 并发清理(Concurrent sweeping):CMS 进行并发清除阶段,标记为不可达的对象会被清除。此过程与应用线程并发运行,旨在减少停顿时间。

  7. 并发重置(Concurrent reset):这个阶段和应用线程并发,重置 cms 内部状态。

cms 的瓶颈就在于重新标记阶段,需要较长花费时间来进行重新扫描。


G1 垃圾回收流程是怎样的?


G1 从大局上看分为两大阶段,分别是并发标记和对象拷贝。

并发标记:并发标记是基于 SATB 的,可以分为四大阶段:

  1. 初始标记(initial marking),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象即可。在 G1 中标记对象是利用外部的 bitmap 来记录,而不是对象头。

  2. 并发阶段(concurent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。SATB 也会在这个阶段记录着变更的引用。

  3. 最终标记(final marking),这个阶段是 STW 的,处理 SATB 中的引用。

  4. 清理阶段(clenaup),这个阶段是 STW 的,根据标记的 bitmap 统计每个 region 存活对象的多少,如果有完全没存活的 region 则整体回收.


对象拷贝阶段(evacuation):这个阶段是 STW 的。根据标记结果选择合适的 reigon 组成收集集合(collection set 即 CSet),然后将 CSet 存活对象拷贝到新 region 中

G1 的瓶颈在于对象拷贝阶段,需要花较多的时间来转移对象。


什么是三色标记算法


三色标记法是一种用于垃圾回收算法中的对象标记方法,特别用于标记清除型垃圾回收器。这种方法通过使用三种颜色(白色、灰色和黑色)来跟踪对象的可达性和垃圾回收状态,以避免对象的重复回收和丢失。


三色标记的基本概念:

  • 白色:表示对象尚未被检查。白色对象可能是垃圾,直到证明它们是可达的。

  • 灰色:表示对象被检查过,并且其本身是可达的,但其引用的对象还未全部检查。

  • 黑色:表示对象和它所有引用的对象都已检查且是可达的。


三色标记步骤:

  1. 初始化:所有对象开始时都是白色。

  2. 标记开始:从 GC Roots 开始,根对象标记为灰色。

  3. 扫描灰色对象:将灰色对象引用的所有白色对象标记为灰色。然后将该灰色对象标记为黑色。

  4. 重复步骤 3 直到没有更多的灰色对象。

  5. 清除:未标记为黑色的对象为白色,即垃圾,可被回收。


G1 收集器的最大特点


  • G1 最大的特点是引入分区的思路,弱化了分代的概念。

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,不会产生空间碎片;从局部上来看是基于“标记-复制”算法实现的。

  • 可预测的停顿:G1 垃圾回收器设定了用户可控的停顿时间目标,开发者可以通过设置参数来指定允许的最大垃圾回收停顿时间。G1 会根据这个目标来动态调整回收策略,尽可能地减少长时间的垃圾回收停顿。


G1 如何完成可预测的停顿?


G1 根据历史数据来预测本次回收需要的堆分区数量,也就是选择回收哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去 10 次一共收集了 10GB 的内存,花费了 1s。那么在 200ms 的时间下,最多可以收集 2GB 的内存空间。而 G1 的预测逻辑是基于衰减平均值和衰减标准差来确定的。


什么是 PLAB?


PLAB(Promotion Local Alocation Buffer)是 Java 垃圾回收器中的一种优化机制,主要用于 G1 垃圾收集器,目的是提高对象晋升(Promotion)到老年代时的效率,在垃圾回收过程中,新生代中的某些对象由于生命周用较长,会被置升到老年代。


为了减少线程竞争和是升晋升效率,G1 为每个 GC 线程分配一个局部缓中区,称为 PLA8,每个线程可以在其本地 PL8 中直接进行对象晋升操作,而不需事竞争全局老年代的内存空间,减少了锁竞争,提高了多线程垃圾回收的效率。


在多线程并行执行 YGC 时,可能有很多对象需要晋升到老年代,此时老年代的指针就“热”起来了,于是搞了个 PLAB。每个线程先从老年代 freelist(空闲内存链表)申请一块空间(PLAB),然后单个线程在这一块空间中就可以通过指针加法 (bump the pointer) 来分配内存,这样对 freeist 竟争也少了,分配空间也快了。

和 TLAB 的思想类似


CMS 和 G1 的区别


  • CMS 中,堆被分为 PermGen,YoungGen,OldGen ;而 YoungGen 又分了两个 survivo 区域。在 G1 中,堆被平均分成几个区域 (region) ,在每个区域中,虽然也保留了新老代的概念,但是收集器是以整个区域为单位收集的。

  • G1 在回收内存后,会立即同时做合并空闲内存的工作;而 CMS ,则默认是在 STW(stop the world)的时候做。

  • G1 会在 Young GC 中使用;而 CMS 只能在 Old 区使用


CMS 垃圾回收器和 G1 垃圾回收器在记忆集的维护上有什么不同?


CMS 垃圾回收器:

  • CMS 使用卡表(Card Tabe,记忆集的一种实现)来记录老年代中引用新生代的对象。卡表的维护较为简单,老年代对象指向新生代对象时,会触发写屏障并标记相应的卡片。

  • CMS 的卡表是通过写屏障维护的,当老年代对象引用新生代对象时,CMS 会在卡表中将对应区域标记为“脏卡”,以便在 GC 时扫描这些区域。


G1 垃圾回收器:

  • G1 的记忆集(Remembered set),其粒度可以细化到堆的各个区域(Region)。记忆集用于跟踪一个 Region 中的对象引用了其他 Region 的对象。

  • G1 采用多层次的记忆集维护机制,将老年代对新生代的引用、其他 Region 之间的引用关系都记录在记忆集中。每个 Regqion 都有自己的记忆集,维护成本相对较高,但有助于 G1 进行精准的增量式回收。

  • 精确度:G1 的记忆集在某些情况下会比 CMS 的卡表更加精细和准确,可以根据需要选择扫描的具体区域,而 CMS 的卡表往往只能标记大范围的区域。


G1 如何解决漏标问题吗?为什么 G1 采用 SATB 而不用 incremental update?


SATB 算法:是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到 satb_mark_queue 中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。


因为采用 incremental update 把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1 有 RSet 与 SATB 相配合。Card Table 里记录了 RSet,RSet 里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描 RSet 就可以了。


也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被 push 到堆栈,下次扫描时拿到这个引用,由于有 RSet 的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB 配合 RSet 浑然天成。


CMS 和 G1 垃圾收集器如何维持并发的正确性?


CMS 和 G1 分别通过增量更新(lincremental update)和 SATB (snapshot at-the-begining)来打破这两个充分必要条件,维持了 GC 线程与应用线程并发的正确性。


并发执行可能出现对象漏标,而漏标的两个充分必要条件是:

  1. 将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用,

  2. 删除了灰色对象到白色对象的引用。


CMS 用了增量更新,打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在 remark 阶段再扫描,防止漏标情况。


G1 用了 SATB,打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系扫描一遍。


G1 一定不会产生内存碎片吗?


堆内存的动态变化、分配模式以及回收行为等因素影响下,仍然可能出现一些碎片问题。当某些 Region 中存在多个不连续的小块空闲内存,无法完全满足某些大对象的内存需求时,仍然可以称之为碎片问题。


  1. 分配模式不规律: 如果应用程序的内存分配模式不规律,频繁地分配和释放不同大小的对象,可能会导致一些小的空闲内存碎片在堆中产生。

  2. 大对象分配: G1 回收器的区域被划分为不同大小的 Region,当一个大对象无法在单个 Region 中分配时,G1 可能会在多个 Region 中分配这个大对象,这可能导致跨多个 Region 的碎片。

  3. 并发情况下的内存变化: G1 回收器会在后台进行并发的垃圾回收,如果在回收过程中发生了内存变化,如某个区域中的对象被回收,留下一些零散的空闲空间,也有可能会导致内存碎片。

  4. 频繁的 Full GC: 尽管 G1 垃圾回收器的设计可以减少 Full GC(全局垃圾回收)的频率,但如果频繁发生 Full GC,可能会导致内存布局的重组,产生一些碎片。


文章转载自:程序员Seven

原文链接:https://www.cnblogs.com/seven97-top/p/18980456

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

用户头像

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

还未添加个人简介

评论

发布
暂无评论
垃圾回收算法有哪些?了解哪些垃圾回收器?_Java_不在线第一只蜗牛_InfoQ写作社区