1. 概述

Java 平台标准版(Java SE™)被广泛应用于各种应用,从桌面上的小小的 applet 到大型服务器上的 Web Service 无处不在。为了支持各种不同的部署场景,Java HotSpot™ 虚拟机提供了多种垃圾回收器,每种都为满足不同的需求而设定。这是也为了满足大大小小不同应用需求的一部分。不过,那些需要高性能应用的用户、开发者和管理员们也被选择适合他们应用的恰当的垃圾回收器的繁琐困扰着。取消这些额外操作的重要一步是在 J2SE™ 5.0 中作出的:垃圾回收器会根据应用运行的计算机类型而作出选择。

这个垃圾回收器的“更好的选择”总的说是一种进步,不过,这并不意味着对所有的应用这都是最好的选择。对于有极端的性能或其他需求的用户,仍需要显式地指定垃圾回收器,并调优某些参数,以达到满意的性能。本文就为这些需求提供了一些相关信息。首先,本文会基于串行的 stop-the-world 垃圾回收器来介绍垃圾回收器的一般性特征和基本调优开关。接下来会介绍其他垃圾回收器的特点和如何选择一个垃圾回收器。

何时选择垃圾回收器?对于一些应用,这个答案可能是“永远不”。也就是说,在有低频率、短时的垃圾收集器造成的停顿的情况下,大部分程序都运行良好。不过,这并不适用于很多程序,特别是那些处理大量数据(若干GB)、很多线程和需要处理很多事务的情况。

Amdahl 观察到,大部分工作负载并不能被很好的并行化;有部分情况下总是会被顺序执行,无法从并行化中获益。这对 Java™ 平台也是如此。特别的,在 J2SE 1.4 以前,Sun Java 平台的虚拟机并不支持并行垃圾回收,这样,在多处理器系统中,垃圾回收会对并行应用产生严重影响。

下图显示了一个除了垃圾回收以外均为完美可伸缩的理想系统的性能曲线。红色曲线是一个在但处理器系统中会花费 1% 的时间在垃圾回收上的程序。它在 32 处理器的系统中,将损失 20% 的吞吐量。而一个花费 10% 时间在垃圾回收上的应用(不考虑单处理器系统中额外的垃圾回收时间)在系统扩张到 32 处理器系统中时,会损失超过 75% 的吞吐量 。

Java SE 6 Hotspot 虚拟机垃圾回收调优

这意味着在小型开发系统中微不足道的速度问题当扩张到大规模系统中就可能成为严重的性能瓶颈。从另一个角度看,减少这样的性能瓶颈的小改动就可以获得很大的性能收益。对足够大规模的系统,选择合适的垃圾收集器并进行必要调优是绝对值得的。

对于大多数“小”应用(在现代处理器上大约需要100MB堆内存的应用)来说通常是足够的。其他垃圾收集器会带来额外的负载或复杂性,这回让系统的某些行为付出一定的代价。如果一个应用不需要一个垃圾收集器的某个功能。那么就使用串行的垃圾收集器好了。一个不应该使用串行垃圾收集器场景是一个超多线程的大程序运行在一个大型的、有大量内存和两个或多个处理器的系统中。当应用运行在这些服务器级的计算机上的时候,并行垃圾收集器会被缺省选择(参见下面的功效学 )。

本文以 Solaris™ 操作系统(SPARC(R) 平台版本)中的 Java SE 6 作为参考。不过,文中所述的概念和建议适用于所有支持的平台,包括 Linux, Microsoft Windows 和 Solaris 操作系统(x86 平台版本)。此外,文中的命令行参数也对所有平台有效,虽然它们的缺省值在各个平台可能有所不同。

2. 功效学(Ergonomics)

“功效学”是一个 J2SE 5.0 引入的概念。引入功效学概念是为了通过不设置或设置很少的几个命令行参数的情况下提供更好的性能,这些参数包括:

● 垃圾收集器,

● 堆尺寸,

● 和运行时编译器

这里的参数选择假定应用所运行的主机类型和应用的类型一致(也就是说,大型应用运行在大型的机器上)。这些选项简化了垃圾回收的调优。选择并行垃圾回收器,用户可以指定应用的最大中断时间和希望的吞吐量。这和指定堆大小来调优性能是相对应的。最常用的功效学相关的内容在可以参考 “Ergonomics in the 5.0 Java Virtual Machine” 这篇文章。建议在尝试本文提到的细节配置之前尝试该文章中介绍的功效学手段。

本文中的功效学特性被作为并行垃圾回收器的自适应尺寸策略的一部分。这包括指定垃圾回收性能的目标和性能调优的一些附加选项。

3.代

J2SE 平台的优势之一是它将内存分配、垃圾回收这些繁复的细节屏蔽了起来。然而,一旦垃圾回收成为主要的瓶颈,那么理解一下这些隐藏在背后的细节就变得有必要了。垃圾回收器对应用程序对对象的使用方式进行判断,这个判断会反映在可调优参数中,他们可以被调整,以提高性能而不牺牲掉抽象性。

当一个对象不再可能被从其他任何地方访问到的时候就会被认为是垃圾了。最直接的垃圾回收算法就是简单地迭代所有可找到的对象。任何没有被跌带到的对象都可以被认为是垃圾了。这个方法的用时和活着的对象数量成正比,这对于那些维护着大量活数据的程序来说是不可接受的。

从 J2SE 1.2 开始,虚拟机就引入了各种不同的垃圾回收算法,这些算法都使用分代垃圾收集。尽管原生的垃圾回收会检查堆中的所有活着的对象,分代垃圾收集采用了很多观测到的大部分应用程序的经验特征,用来最小化发现废弃的对象的工作量。最重要的经验特征是 weak generational 假设,该假设认为大部分对象都只存活一少段时间。

下图中的蓝色区域是对象生存期的典型分布。横轴是对象被分配后的生存期。纵轴方向计算的字节数是相应生存期的对象的总字节数。左侧的尖峰表明,对象在分配之后不久就被废弃了。比如,迭代器对象常常只会在一个循环中被用到。

Java SE 6 Hotspot 虚拟机垃圾回收调优

当然,有些对象确实活得要长一些,于是,分布曲线延伸到了右边。比如,典型情况下,有些对象在初始化的时候被创建,并一直存活到进程结束。在这两种极限情况之间,那些对象活的时间也是中等的,在图中表现初来的就是从开始的峰值泄漏初来的蓝色区域。有些应用可能会有看起来十分不同的分布曲线,不过绝大多数的进程都是这个常见的形状。大部分对象都会“英年早逝”这个事实让高效的垃圾收集变得具有可能性了。

为了为这样的应用环境优化,内存被按照“代” (generation)进行管理,或者说,内存池中存放不同年龄的对象。当一个年龄断被填满后,就对该代的垃圾进行回收。在内存池中的大部分对象都是年轻的对象(年轻的代),而大部分对象也会在年轻的时候就成为垃圾。当年轻代被填满的时候,会导致一次“小回收”(译注:原文minor,似乎“未成年”更贴切一些,不过咱们读起来会很别扭),这里只有年轻代的对象惠北回收,而其他年龄断的垃圾则不与理会。该回收算法的成本是,一阶情况下,正比于被回收的活的对象的数量;年轻代因为满是死对象,所以回收非常迅速。而在“小回收”中存活下来的对象于是乎就会被转移到所谓的年老代(tenured generation)。最终,当年老代被填满而需要回收的时候,就会导致一次主回收,这时整个堆都会被回收。主回收通常会运行锝比小回收慢很多,因为大量的对象都会被处理。

如上文记述,对不同的应用,“工效学”会动态选择垃圾收集器来提供较好的性能。串行垃圾收集器用于哪些数据量比较小的程序,而且它的缺省参数也让大多数小程序能够高效工作。而大吞吐量垃圾收集器用于那些有中到大数据量的数据集。工效学选择的堆尺寸参数和自适应尺寸策略用于为服务器提供更好的性能。这些选择的大多数而不是所有的情况下工作得很不错。这就引出了本文的核心宗旨:

如果垃圾收集器成为了瓶颈,你可能不得不调整整个堆的大小乃至每个代的尺寸。检查垃圾收集器的详细输出,然后检查垃圾收集器对你关注的各个性能指标的影响。

(并行垃圾收集器之外的)缺省的代排布大概就是这样的。

Java SE 6 Hotspot 虚拟机垃圾回收调优

初始化的时候,最大的地址空间虚拟地保留住而没有分配出去,直到真的需要的时候为止。整个保留的对象地址空间被分给了年轻的和年老的代。

年轻代包括“伊甸园”和两个幸存者空间。大部分对象最初在伊甸园里被分配出来。一个幸存者空间在任意时刻都是空的,作为伊甸园中的活对象的目的地,另一个是用于下一次收集。对象在幸存者空间之间停留到足够老之后,就会被复制到年老代去了。

另一个和年老代有密切关系的代是永久的(permanent)代,这里保存着虚拟机需要的用来描述那些 Java 语言层面没有等价物的对象。比如,那些描述类和方法的的对象就存放在永久代。

3.1 性能考虑

对于垃圾回收的性能,主要有两种量度方法:

1.吞吐量。吞吐量是在一段足够长的时间中,没有花费在垃圾回收上的时间占总时间的百分比。吞吐量包含了花在空间费配上的时间(不过空间分配速度的调优一般是没有必要的)。

2.延时。延时是由于等待垃圾回收而导致的程序没有响应的时间。

不同的用户对垃圾收集有不同的需求。比如,对于一个web server而言,吞吐量是合理的量度,因为垃圾收集带来的短时时延是可以容忍的,或者说是很容易就被网络时延所掩盖了。不过,对于交互的图形界面程序而言,极短的停顿都会影响用户的使用体验。

有些用户对其他的因素很敏感。Footprint是一个进程的工作集,由页和cache line来量度。对于内存相对于进程数量很有限的系统而言。Footprint会影响到程序的可伸缩性。Promptness是对象死掉和该块内存重新可用之间的时间间隔的量度,这是分布式系统的一个重要考虑因素,包括远程方法调用(RMI)。

总的说,一个特定的代的尺寸选择是上述这些因素之间的权衡的结果。比如,一个非常大的年轻代的大小可以最大化吞吐律,但会以Footprint、Promptness和延时作为代价。而年轻代延时可以通过缩小该代的大小来达到最小化,但同样会损失吞吐量。近似地,调整一个代的尺寸不会影响到其他代的垃圾收集频率和时延。

没有一个简单的方法来设置代的尺寸。最好的选择由程序使用内存的方式和用户的需求来决定。这样,虚拟机对垃圾收集器的选择并不总是最优的,而且可以通过后面介绍的命令行参数来调整。

3.2 测量

使用应用特定的量度,吞吐量和footprint很容易被测量。例如,web服务器的吞吐量可以使用一个客户端负载生成器来测量,而该服务器的 footprint 则可以在 Solaris 操作系统中使用 pmap 命令来测量。另一方面,垃圾收集导致的时延可以方便地通过监测虚拟机自己的诊断输出来估算出来。

命令行参数 -verbos:gc 可以送出每一次垃圾收集时的堆和垃圾收集信息。比如,这是一个大型服务器应用的输出:

1
2
3
[GC 325407K->83000K(776768K), 0.2300771 secs]
[GC 325816K->83372K(776768K), 0.2454258 secs]
[Full GC 267628K->83769K(776768K), 1.8479984 secs]

这里是两次小回收和之后的一次主回收。箭头前后的数字(比如第一行的325407K->83000K)分别指垃圾回收前后的所有活着的对象占用的空间。在小回收之后,这个尺寸之中仍然包含一些没有被回收的垃圾(死掉的对象)。这些对象要么存在在年老代中,要么被年老或永久代中的对象所引用。

后面的括号中的数字(比如第一行中的 (776768K))是全部提交的堆大小,也就是虚拟己不向操作系统申请内存的情况下,全部 java 对象可用的存储空间。注意,这个数字不包括幸存者空间中的一个,因为幸存者空间在一个给定时间只有一个可用,同时也不包括永久代的空间,这里面是虚拟机使用的元数据。

最后一个数字(比如 0.2300771 secs)是垃圾收集所用的时间;这个例子里大约是四分之一秒。

第三行中主垃圾回收的格式也是类似的。

-verbos:gc 输出的格式可能在将来的版本里有所改变。

通过-XX:+PrintGCDetails参数可以查看更多垃圾回收相关的信息。下面是串行垃圾收集器使用该参数打印出来的信息。

[GC [DefNew: 64575K->959K(64576K), 0.0457646 secs] 196016K->133633K(261184K), 0.0459067 secs]

这个信息显示,这次小回收收回了 98% 的 DefNew 年轻代的数据,64575K->959K(64576K) 并在其上消耗了 0.0457646 secs(大约45毫秒)。

整个堆的占用率下降了大约51% 196016K->133633K(261184K),而且通过最终的时间 0.0459067 secs 显示在垃圾收集中有轻微的开销(在年轻代之外的时间)。

选项-XX:+PrintGCTimeStamps会提供每次回收开始时间的时间戳。这对于查看垃圾回收频率非常有用。

1
111.042: [GC 111.042: [DefNew: 8128K->8128K(8128K), 0.0000505 secs] 111.042: [Tenured: 18154K->2311K(24576K), 0.1290354 secs]  26282K->2311K(32704K), 0.1293306 secs]

如上,垃圾回收在程序运行后111秒开始。小回收同时启动。信息中还显示了主回收中的年老代的垃圾回收信息。年老代的空间使用率下降了大约 10% 18154K->2311K(24576K) ,用时 0.1290354(大约130毫秒)。

和 -verbose:gc 一样,-XX:+PrintGCDetails 的输出格式在将来的版本里也可能会有所变动。

4. 代的尺寸

很多参数会应想到代的尺寸。下图是堆中的提交空间和虚拟空间的差别。虚拟机初始化的时候,整个堆空间都是保留的。保留空间可以通过参数 -Xmx 指定。如果-Xms参数小于-Xmx参数,那么不是所有的保留空间都会立刻提交到虚拟机之中。未提交的空间在途中标记为 virtual。堆的不同部分(永久时间段、年老时间段和年轻时间段)可以按需生长到虚拟空间的限制为止。

一些参数可以调整堆的不同部分的比例,比如参数NewRatio指定年老代对年轻代的比例。这些参数将在下面讨论。

Java SE 6 Hotspot 虚拟机垃圾回收调优

4.1 全部堆

注意,下面的关于堆的生长、收缩和缺省堆大小都不适用于并行垃圾收集器,并行垃圾收集器请参考相关章节。不过,用于控制整个堆大小和代尺寸的参数对并行垃圾收集器都是适用的。

因为垃圾收集是发生在代被填满的时候,所以,吞吐量反比于可用此内存数量。总可用内存数是影响垃圾收集性能的最重要因素。

缺省情况下,虚拟己在每次垃圾收集后增加或减少堆尺寸,来尽量保持可用空间对活的对象之间的比例在一个区间之内。这个目标区间通过参数-XX:MinHeapFreeRatio=<minimum>和-XX:MaxHeapFreeRatio=<maximum>来设置,而总的堆大小的界限由-Xms<min>和-Xmx<max>来确定。这些参数在 32 位 Solaris 系统(SPARC 平台版本)中的缺省值如下表所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Parameter
 
Default Value
 
MinHeapFreeRatio
 
40
 
MaxHeapFreeRatio
 
70
 
-Xms
 
3670k
 
-Xmx
 
64m

64位系统中的堆尺寸的参数会大 30% 左右,这个增长用来补偿64位系统中更大的对象所带来的开销。

通过设置这些参数,当一个代的可用空间低于 40%,虚拟机就会把可用内存扩展到 40%,直到代的最大尺寸。同样的,如果可用空间超过 70%,代就会被缩小,使得只有 70% 可用空间,直到达到代最小的空间为止。

大型服务器程序在使用这些缺省设置时,经常遇到两种问题。其一是慢启动问题,初始的堆尺寸过小,经常需要经历多次主回收才能达到稳定值。另一个更现实的问题是,对于大多数服务器应用来说,这个缺省的最大堆大小太小了。对于服务器程序而言,设置的一般原则是:

● 除非遇到了时延问题,给虚拟机尽量多的内存。缺省尺寸(64MB)通常都太小了。

● 把-Xms 和 -Xmx 设置成相同的值,把最重要的尺寸决定从虚拟机收回来,从而增强可预见性。

● 一般地,随着处理器数量的增加而增加内存,因为内存分配可以被并行化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Default Value
 
Parameter
 
Client JVM
 
Server JVM
 
NewRatio
 
8
 
2
 
NewSize
 
2228K
 
2228K
 
MaxNewSize
 
not limited
 
not limited
 
SurvivorRatio
 
32
 
32

作为参考,有一个单独的页面会介绍各个命令行参数 。

4.2 年轻代

影响位居次席的是用于年轻代的堆比例。年轻代越大,小回收的次数也就越少。不过,在一定的堆大小的情况下,年轻代越大,年老代也就越小,这就增加了主回收的频率。最佳选择依赖于应用中分配的对象的生存期分布。

缺省的,年轻代的尺寸由 NewRatio 控制。比如,设置-XX:NewRatio=3意味着年轻代和年老代的比例是1:3。换句话说, eden 和幸存者空间的总和是整个堆大小的四分之一。

参数 NewSize 和 MaxNewSize 约束了年轻代的上下界限。可以把这两个参数设成相同的值来固定年轻代的大小,设置 -Xms 和 -Xmx 一样来设置堆大小为固定值。这样可以比使用NewRatio更细粒度地调整年轻代的大小。

4.2.1 幸存者空间

如果需要,SurvivorRatio 可以用来调整幸存者空间的大小,不过这对于性能一般影响不大。比如,-XX:SurvivorRatio=6 会设置幸存者空间和eden的比例是 1:6。换句话说,每个幸存者空间将是 eden 的六分之一,是整个年轻代空间的八分之一(不是七分之一,因为一共有两个幸存者空间)。

如果幸存者空间过小的话,拷贝收集到的幸存者将会直接溢出到年老代的空间中去。如果幸存者空间太大的话,他们也就是空着浪费掉。每次垃圾收集中,虚拟机会选择一个对象在成为年老的之前被复制的次数门限。这个门限的设置会保证幸存者空间是半满的。命令行参数-XX:+PrintTenuringDistribution 可以显示这个门限和年轻代中对象的年龄。这对于观测应用中对象的生存期分布也是有用的。

下面是 SPARC 上的 32 位 Solaris 的各个参数的缺省值,在其他平台上可能有所差异。

年轻代的最大尺寸通过最大堆尺寸和 NewRatio 计算而得。所谓的“无限制”的缺省值是说这个计算的值不会受到 MaxNewSize 的约束,除非命令行中指定了这个值。

服务应用的设置准则是:

● 首先确定可以提供给虚拟己的最大堆尺寸。然后根据性能需求来确定年轻代的尺寸,来找到最佳设置。

● 注意:最大堆尺寸一定要小于系统中的内存数量,以防止过多的缺页错误和换页。

● 如果总的堆尺寸是确定的,增加年轻代的尺寸就会减少年老代的尺寸。一定要保证年老代的尺寸,使之可以容纳所有在应用全程都要用到的活对象,并留有一定裕量(10-20%或更多)。

● 依照上述年老代的约束:

● 给年轻代分配足够的内存。

● 如果有多个处理器,那么分配更多的内存给年轻代,因为内存分配可以并行化。

5. 可用的垃圾收集器

到目前为止,我们讨论的还都是串行垃圾收集器。不过 Java HotSpot 虚拟机一共支持了三种不同的收集器,每种提供不同的性能特性。

1.串行垃圾收集器使用单线程进行所有垃圾收集工作,因为没有线程间通信的开销,串行垃圾收集器相当高效。串行垃圾收集器最适合于单处理器系统,因为它不会从多处理器硬件中获益,尽管在小数据量的应用中(不大于100MB的),它对于多处理器系统也是游泳的。串行垃圾收集器在一定的硬件和操作系统的配置时会缺省使用,也可以显式地用 -XX:+UseSerialGC 参数来指定。

2。并行垃圾收集器(或吞吐垃圾收集器)并行进行小垃圾收集,这会显著减少垃圾收集的的开销。它适用于中等或大尺寸数据的运行在多处理器或多线程硬件上的应用。并行垃圾收集器也会在一定的硬件和操作系统配置下被缺省使用,同时,也可以使用 -XX:+UseParallelGC 参数来指定。

● 更新:“并行压缩”是 J2SE 5.0 update 6 以上版本的新特性,并在 Java SE 6 之中得到加强,该特性允许主回收也并行收集。如果不使用并行压缩,主回收仍然会单线程运行,这会严重限制系统的可伸缩性。并行压缩可以使用命令行参数-XX:+UseParallelOldGC 来打开。

3并发垃圾收集器并发地进行大部分垃圾收集工作(也就是在应用运行当中进行)来尽可能煎炒垃圾收集带来的应用停顿。它是为哪些拥有中到大量数据的、对响应时间要求高于吞吐量要求的应用,因为最小化时延的技术会让吞吐能力付出代价。并发垃圾收集器通过 -XX:+UseConcMarkSweepGC 参数来启用。

5.1 选择垃圾收集器

除非你的应用有非常严酷的时延要求,那么就运行你的应用,并让系统自己选择垃圾收集器好了。如果有必要的话,就调整堆的大小来增进性能。如果性能仍然无法达到你的目标,那就按照如下设置来选择一个垃圾收集器。

1. 如果应用的数据很少(大约不超过100MB),那么

● 使用-XX:+UseSerialGC选择串行垃圾收集器。

2.如果应用运行在单处理器系统中,并且没有什么时延要求,那么

● 让虚拟机选择垃圾收集器,或者

● 使用-XX:+UseSerialGC选择串行垃圾收集器。

3.如果(a)程序峰值性能是第一位的,并且(b)没有时延要求,或时延要求是一两秒或更长,那么

● 让虚拟机选择垃圾收集器,或者

● 使用-XX:+UseParallelGC选择并行垃圾收集器,乃至(可选)通过 -XX:+UseParallelOldGC启用并行压缩。

4.如果响应时间比总体吞吐量更为重要,并且垃圾收集时延需要控制在1秒以内,那么

● select the concurrent collector with -XX:+UseConcMarkSweepGC. If only one or two processors are available, consider using incremental mode, described below.

● 通过 -XX:+UseConcMarkSweepGC 参数启用并发垃圾收集器。进当你有一个或两个处理器可用的时候,考虑使用下文将要介绍的“增量模式”。

这些指导意见仅仅是选择垃圾收集器的起点,因为性能依赖于堆的尺寸、应用中活数据的数量,以及处理器的数量和速度。时延参数对这些因素尤为敏感,所以,所谓的1秒门限值只是个大致数值:在很多硬件和数据量的组合情况下,并行垃圾收集器可能会导致停顿时间超过1秒;同样,在某些组合下,并发垃圾收集器也不能保证停顿小于1秒。

如果推荐的垃圾收集器没有达到期望的性能,首先应该尝试堆和代的尺寸,以期达到目标。如果仍然不成功的话,尝试更换一个垃圾收集器:使用并发垃圾收集器来减少停顿时间,使用并行垃圾收集器来增加多处理器系统中的吞吐量。

6. 并行垃圾收集器

并行垃圾收集器(也被称为吞吐量收集器)和串行收集器类似,也是一种分代垃圾收集器;其最大的不同在于它使用了多线程来加快垃圾收集的过程。并行垃圾收集器可以通过参数 -XX:+UseParallelGC 指定。缺省的,只有小回收会并行运行,主回收仍然单线程运行。不过,通过参数-XX:+UseParallelOldGC启动并行压缩可以让主回收和小回收都并行运行,从而进一步减少垃圾收集开销。

在一个有N个处理器的计算机上,并行垃圾收集器使用N个垃圾收集器线程。不过,这个数量可以在命令行参数里指定(参见下文)。在一台单处理器的计算机上,由于线程开销(比如同步),并行垃圾收集器的性能应该不如串行垃圾收集器。然而,当应用程序有中等或大尺寸的堆的时候,它在一个双处理器的机器上就会略优于串行垃圾收集器,而如果有多于两个处理器的话,它就能远胜于串行垃圾收集器。

垃圾收集器线程数的多少可以用-XX:ParallelGCThreads=<N>参数来控制。如果要使用命令行参数显式调整了堆的尺寸,使用并行垃圾收集器的情况下需要的堆的尺寸和使用串行垃圾收集器情况下的堆的尺寸是一阶相等的。使用并行垃圾收集器仅仅是让小回收造成的停顿更短一些。因为有多个垃圾收集器线程参与小回收的过程,有极少的可能性可能会在将年轻代移动到年老代的过程中造成一些碎片。每个垃圾收集线程都有一块专属的年老代的空间,用于年轻代向年老代的移动,将年老代的可用空间划分为“移动缓冲”(promotion buffer)的过程可能会造成一定的碎片效应。减少垃圾收集器线程的数量可以减少碎片、增加年老代的空间。

6.1 代

正如上面提到的,并行垃圾收集器的代的排布方式和串行垃圾收集器略有不同。其分布如下图所示。

Java SE 6 Hotspot 虚拟机垃圾回收调优

6.2 功效学

自 J2SE 5.0 以来,并行垃圾收集器成为了server级机器的缺省垃圾收集器,详细资料可以参考“Garbage Collector Ergonomics”。此外,并行垃圾收集器使用一种自动调整机制来指定期望的行为而不是指定代的大小和其他底层调整细节。这些行为包括:

● 最大垃圾收集停顿时间

● 吞吐量

● Footprint (也就是堆尺寸)

最大停顿时间的目标由参数-XX:MaxGCPauseMillis=<N>来指定。这个参数被解释为指定停顿时间不得大于<N>毫秒;缺省情况下没有最大停顿时间目标。如果指定了一个停顿时间目标,堆尺寸和其他垃圾回收相关参数就会被相应调整,以便保持垃圾回收时间小于指定的值。注意,这些调整可能会导致总体吞吐量的降低,而且,在某些情况下,要求的停顿时间目标可能无法达到。

吞吐量目标测量垃圾回收时间和非垃圾回收时间(也就是应用时间)的比例。这个目标时间可以用命令行参数-XX:GCTimeRatio=<N>来指定,这样,垃圾回收时间和应用时间的比例将是1 / (1 + <N>)。例如,-XX:GCTimeRatio=19设置1/20活5%的时间用于垃圾回收。缺省值是99,目标是1%的时间用于垃圾回收。

最大堆footprint使用已经存在的 -Xmx<N> 参数。此外,如果没有其他的优化目标的话,垃圾收集器有一个隐式的最小化堆尺寸的目标。

6.2.1 目标的优先级

目标的优先级顺序如下:

1.最大停顿时间目标

2.吞吐量目标

3.最小堆尺寸目标

最大停顿时间目标会被首先满足。仅当最大停顿目标被满足的情况下,才会去满足吞吐量目标。类似的,仅当前两个目标都会满足的情况下,才会考虑去满足footprint目标。

6.2.2 时间段尺寸调整

每次垃圾收集结束的时候,垃圾收集器都会更新其保存的平均停顿时间之类的统计参量。同时它会检查各个目标是否被满足了,是否有调整代尺寸的需要。这之中的意外情况就是显式的垃圾收集(比如调用 System.gc())会在统计和调整判断中被忽略掉。

增加和缩小一个代的大小是通过增加活缩小一个固定的百分比来达到的,这样一个代要分步来达到需要的尺寸。增加活所见是以不同的比率来进行的。缺省情况下,一次增加 20% 活减少 5%。年轻代和年老代增量的比例分别通过命令行参数 -XX:YoungGenerationSizeIncrement=<Y>和-XX:TenuredGenerationSizeIncrement=<T>来设定。而缩小比例的要通过-XX:AdaptiveSizeDecrementScaleFactor=<D>参数来设定。如果增量是X%,那么每次减小量就是(X/D)%。

如果垃圾收集器决定在启动的时候增加一个代的大小,会有一个额外的百分比的增量。这个附加的增量随着收集的次数而减少,不会长期影响。这个额外增量意在提高启动速度。缩小代的尺寸是没有这个额外的增量。

如果最大停顿时间目标没有达到,会有且仅有一个代的大小被缩小。如果两个代都在目标之上,停顿时间较大的那个代会首先被缩小。

如果总体吞吐量目标没有达到,那么两个代的大小都会增加。每个都按照各自对垃圾回收时间的贡献比例分别增加。比如,如果年轻代的垃圾回收时间占去了25%的总垃圾回收时间,并且年轻代的全部增量应该是20%,那么这时它的增量就是5%。

6.2.3 缺省堆尺寸

如果没有在命令行中进行设置,初始和最大堆尺寸会通过计算机内存计算而得。如下表所示,对大小占用的内存的比例是由参数 DefaultInitialRAMFraction和DefaultMaxRAMFraction来控制的。(表中的 memory 代表计算机的系统内存数量。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Formula
 
Default
 
initial heap size
 
memory / DefaultInitialRAMFraction
 
memory / 64
 
maximum heap size
 
MIN(memory / DefaultMaxRAMFraction, 1GB)
 
MIN(memory / 4, 1GB)

注意,缺省的最大堆尺寸不会超过1GB,不论系统中到底有多少内存。

6.3 过多的GC时间和OutOfMemory错误

当有过多的时间花费在垃圾收集上的时候,并行垃圾收集器会跑出 OutOfMemoryError 错误:如果超过 98% 的时间花费在垃圾收集上并且只有 2% 的堆被释放的话,就会抛出一个 OutOfMemory。这个功能是用来防止堆太小导致程序长时间无法正常工作而设计的。如果必要,这个功能可以使用命令行参数-XX:-UseGCOverheadLimit来关闭。

6.4 测量

并行垃圾收集器的垃圾收集器详细输出和串行垃圾收集器是一样的。

7. 并发垃圾收集器

并发垃圾收集器适用于那些需要更短的垃圾收集停顿,并能为此付出程序运行期处理器资源的应用。典型情况下,那些拥有较多长期存在的对象(年老代比较大),并且运行在拥有两个活更多处理器的应用可能会因此获益。不过,在任何要求很低停顿时间的应用都应该考虑这个垃圾收集器;比如,拥有较小年老代的交互程序在但处理器上使用并发垃圾收集器就可以收到明显的好处,特别是使用增量模式的时候。并发垃圾收集器可以通过命令行参数-XX:+UseConcMarkSweepGC来启动。

和其他垃圾收集器类似,并发垃圾收集器也是分代的;所以也有小回收和主回收。并发垃圾收集器通过使用独立的垃圾收集线程于应用本身的线程并发执行跟踪所有可及的对象,以期降低主回收导致的停顿。在每个主回收周其中,并发垃圾收集器会在垃圾收集的开始让所有应用线程暂停一下,并在回收中段再暂停一次。第二次暂停相对而言会更长一些,在此期间会有多个线程来进行收集工作。剩下的收集工作包括大部分的活对象跟踪和清除不可及的对象的工作都由一个或多个和应用并发的垃圾收集器线程来进行。小回收会在进行的主回收周其中穿插进行,其模式和并行垃圾收集器十分类似(特别需要说明的就是,在小回收期间,应用线程是会有停顿的)。

并发垃圾收集器的基本算法在技术报告 A Generational Mostly-concurrent Garbage Collector里有介绍。主义,实际的实现细节在不同版本里手有细微的变化的,因为垃圾收集器也在一直进步。

7.1 并发性的开销

并发垃圾收集器的短主回收停顿时间是以处理器资源作为代价的(这些资源如果不用在收集器上肯定就要用在应用上了)。最明显的开销就是并发地使用了一个或多个处理器资源。在N处理器系统中,垃圾收集的并发部分会使用K/N的可用处理器,其中 1<=K<=ceiling{N/4}。(注意,K值的上限将来可能会有变化。)并发垃圾收集器不仅在并发阶段使用处理器,还引入了其他的开销。所以,尽管并发垃圾收集器显著减少了程序的停顿,但和其他垃圾收集器相比,应用的总体吞吐量会受到轻微的影响。

在拥有多个处理器的计算机上,在并发垃圾收集器运行的时候,应用程序仍然能使用到CPU,所以,并发垃圾收集器并没有让程序停顿。这通常意味着更短的停顿,谈也意味着更少的应用可用的处理器资源,并且让它运行得相对比较慢,特别是当应用可以完全的利用多个CPU核心的时候更是如此。随着N的上升,垃圾收集器导致的损失会相对变小,而从并发垃圾收集的获益则相对提高。下一节“并发模式失败”会讨论这种规模扩张的潜在局限。

因为在并发阶段至少有一个处理器用于了垃圾收集,所以在单处理器(单核)系统中,并发垃圾收集器一般不会带来什么好处。不过,并发垃圾收集有一个分离模式可以在单处理器或双处理器系统中显著减少停顿时间;后面的增量模式中将会进一步介绍其细节。

7.2 并发模式失败

并发垃圾收集器使用一个或多个垃圾收集线程在应用线程执行的同时运行,从而在年老代和永久代变满之前就完成垃圾收集。如前文所述,在一般的操作中,并发垃圾收集器的大部分跟踪与清理工作是在程序运行的同时进行的,所以,程序线程只有极少的停顿。但是,如果并发垃圾收集器在年老代变满的时候仍没有完成垃圾清除工作,或是年老代中的可用空间无法满足一次分配操作的需要的时候,应用就不得不被暂停下来以等待应用线程结束了。这种无法并发地完成垃圾收集的情况被称为“并发模式失败”,这就需要对并发垃圾收集器的参数进行调整了。

7.3 过多的GC时间和OutOfMemory错误

并发垃圾收集器会在垃圾收集消耗时间过多的时候抛出 OutOfMemoryError 错误:如果多于 98% 的时间被花费在了垃圾手机上,并且仅有少于 2% 的堆被回收的话,就会抛出 OutOfMemoryError。这个功能是用来防止堆太小导致程序长时间无法正常工作而设计的。如果必要,这个功能可以使用命令行参数-XX:-UseGCOverheadLimit来关闭。

这个策略和并行垃圾收集器是基本一致的,惟一的区别就是并发的垃圾收集时间并未计算在内。也就是说,只有哪些程序停顿下来进行垃圾收集的时间才被计算在内了。这些垃圾收集常常是由于并发模式失败或是显式垃圾收集请求(如调用 System.gc())导致的。

7.4 浮动垃圾

并发垃圾收集器与 HotSpot 中的其他垃圾收集器一样,是一种识别至少所有在堆中可以被访问到的对象的跟踪收集器。按照Jones and Lins的说法,是一种增量更新(Incremental Update)垃圾收集器。因为应用现成和垃圾收集器线程在主回收过程中并发执行,那么那些垃圾收集器跟踪的对象就可能在垃圾收集完成之后变成垃圾这些无法访问却还没有被回收的对象被称为浮动垃圾(floating garbage)。浮动垃圾的数量取决于垃圾收集周期的长度和程序中引用更新的频率,也被称为转化率(mutation)。而且,另一个原因是年轻代和年老代的收集是独立的,彼此都是对方的根。一个粗略的配置规则是为年老代的浮动垃圾多预留出20%的空间来。一个垃圾回收周期中的堆中的浮动垃圾会在下一个垃圾回收周期中被回收。

7.5 时延(停顿)

并发垃圾收集器在一个并发回收周期中会两次暂停应用。第一次会从根从根(比如从对象线程栈和寄存器、静态对象等的引用)和堆的其他部分(如年轻代)开始标记所有直接可达的活的对象。第一次停顿被称为“初始标记停顿”(initial mark pause)。第二次停顿发生在并发跟踪阶段末尾,用来发现由于在垃圾收集线程跟踪完一个对象之后又被应用线程更新了其引用而没有被并发跟踪到的对象。这次停顿被称为“重标记停顿”(remark pause)。

7.6 并发阶段

可达对象的并发跟踪图发生在初始标记停顿和重标记停顿之间。在并发跟踪阶段中,一个或多个并发垃圾收集器线程会使用那些本来可能会被应用使用的处理器资源,所以尽管不会停顿,计算密集型应用可能会在此阶段和其他并发阶段受到相当的吞吐量损失。在重标记停顿之后,还有一个并发清理阶段,会收集所有标记为不可达的对象。一旦手机周期结束了,并发收集器就会进入等待阶段,这时就基本不会消耗任何计算资源了,直到下一个主回收周期开始为止。

7.7 开始并发收集周期

在串行收集器中,每当年老代满了的时候都会引发一次主回收,所有应用现成都会在主回收期间暂停运行。并发垃圾收集器与之不同,它需要在足够早的时间开始垃圾收集,以便能在年老代变满之前完成垃圾收集;否则的话就会因为并发模式失败而导致较长的时延。有很多种条件可以触发并发垃圾收集器启动。

基于最近的历史记录,并发垃圾收集器维护了一个年老代变满的预期剩余时间和一个垃圾收集周期的预期时间。基于这些动态估计,并发垃圾收集周期会以让垃圾收集周期在年老代变满之前完成为目标开始并发垃圾收集周期。因为并发模式失败的代价非常惨重,这些估值都流出了安全裕量。

并发垃圾收集在年老代的已用百分比超出了一个初始占有率值(initiating occupancy)的时候也会启动。这个初始占有率阈值的缺省值大约是 92%,不过这个值可能在不同版本中略有不同。它也可以通过命令行参数-XX:CMSInitiatingOccupancyFraction=<N> 来手工设置,其中N是一个0-100的整数,代表年老代的占用百分比。

7.8 调度中断

年轻代和年老代的垃圾收集的停顿发生彼此间是独立的。他们不会重合,但可能会连续发生,这样也就让一个垃圾收集的停顿连上下一个垃圾收集的停顿了,从外界来看就是一个长停顿了。为了避免这种情况,并发垃圾收集器会调度重标记停顿的时间,使之发生在前后两个年轻代停顿之间。这个调度目前还不应用于初始标记停顿,因为它通常会比重标记停顿短很多。

7.9 增量模式

并发垃圾收集器可以在这样一种模式下工作:并发阶段以增量的方式进行。回忆一下,在并发阶段,垃圾回收线程会使用一个或多个处理器。所谓增量模式是指减少长并发阶段的影响,周期性中断并发阶段,将处理器资源还给应用程序。这种模式又称为“i-cms”,将垃圾收集器的并发工作划分到小块时间,在年轻代垃圾收集之间进行。这个功能对于那些工作在没那么多处理器的机器上(1或2个处理器的)需要并发垃圾收集器的低时延应用非常有用。

并发垃圾收集周期通常包括如下几步:

● 停止所有的应用线程,标记从根开始可达的对象集,然后继续所有的应用线程

● 在应用线程运行的同时,使用一个或更多的处理器,并发跟踪可达的对象图

● 使用一个处理器,并发跟踪对象图中在上一步开始之后的各个改动的部分

● 停止所有的应用线程,重新跟踪根和对象图中自从上次检查开始发生了变化的部分,然后继续运行线程

● 使用一个处理器,并发地把不可达对象清理到用于分配空间的 free list 上面去。

● 使用一个处理器并发地调整堆的大小,准备下一个回收周期所需的数据结构

正常情况下,并发垃圾收集器在并发跟踪阶段使用一个或多个处理器,不会让出它们。类似的,在清理阶段也会始终独占地使用一个处理器。这对于对于一个程序的响应时间可能是个不小的影响,特别是系统中只有一两个CPU的时候。增量模式通过将并发阶段分解为一系列的突发行为来降低这一影响,这些突发行为会散布在小回收之间。

i-cms 使用占空比来控制并发收集器自发的放弃处理器之前的工作量。占空比是年轻代收集之间的允许并发垃圾收集器运行时间的百分比。i-cms 可以根据应用的行为自动计算占空比(这也是推荐的方法,称为自动步长(auto pacing)),当然,也可以通过命令行指定一个固定的值。

7.9.1 命令行参数

下面是控制 i-cms的命令行参数(参考下文的初始设置建议):

参数

描述

缺省值

J2SE 5.0 及以前

Java SE 6 及以后

-XX:+CMSIncrementalMode

启动增量模式。注意,并发垃圾收集器必须也被选择(-XX:+UseConcMarkSweepGC) ,否则此参数无效。

disabled

disabled

-XX:+CMSIncrementalPacing

打开自动步长,这样,增量模式占空比将根据JVM统计到的信息自动调整。

disabled

enabled

-XX:CMSIncrementalDutyCycle=<N>

两次小回收之间的允许并发收集器运行的时间的百分比(0-100)。如果打开自动步长,那么这个值就是初始值。

50

10

-XX:CMSIncrementalDutyCycleMin=<N>

自动步长打开后,占空比值的下限 (0-100)。

10

0

-XX:CMSIncrementalSafetyFactor=<N>

计算占空比值时使用的一个裕量(0-100)

10

10

-XX:CMSIncrementalOffset=<N>

在小回收之间,增量模式中占空比开始的时间,或说是向右的平移量(0-100)

0

0

-XX:CMSExpAvgFactor=<N>

当进行并发回收统计,计算指数平均值时,当前采样所用的权值(0-100)

25

25

7.9.2 建议参数

要在 Java SE 6 里使用 i-cms,需要使用如下命令行参数

1
2
-XX:+UseConcMarkSweepGC -XX:+CMSIncrementalMode \
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps

前两个参数分别启动并发垃圾收集器和 i-cms。后两个参数不是必须的,它们只是要求垃圾收集器将诊断信息打印到标准输出,这样,垃圾收集器的行为就可以被看到并用于以后分析了。

注意,对于 J2SE 5.0 和之前的版本,我们建议 i-cms 使用如下的初始命令行参数:

1
2
3
4
-XX:+UseConcMarkSweepGC -XX:+CMSIncrementalMode \
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps \
-XX:+CMSIncrementalPacing -XX:CMSIncrementalDutyCycleMin=0
-XX:CMSIncrementalDutyCycle=10

这样,就是用了和 Java SE 6 一致的参数了,多出的三个参数用于自动调整占空比。这些多余的参数值完全是使用的 Java SE 6 的缺省值。

7.9.3 基本问题处理

i-cms 的自动占空比计算模式使用了程序运行时收集到的统计信息进行占空比计算,以保证并发垃圾收集器可以在堆占满之前完成。不过,使用过去的行为预测将来的变化的估计方式可能并不总是足够准确,可能在某些情况下无法阻止堆用满。如果需要收集的垃圾太多,可以尝试下面这些步骤,一次使用一个:

Step

Options

1. 增加保险系数

-XX:CMSIncrementalSafetyFactor=<N>

2. 增加最小占空比

-XX:CMSIncrementalDutyCycleMin=<N>

3. 关闭自动占空比计算,使用固定占空比

-XX:-CMSIncrementalPacing -XX:CMSIncrementalDutyCycle=<N>

7.10 测量

下面是使用-verbose:gc和-XX:+PrintGCDetails参数时,并发垃圾收集器的输出,一些小细节已经被去掉了。注意,并发垃圾收集器的输出里掺杂着小回收的输出;典型情况下,很多小回收会发生在并发收集周期之中。其中的CMS-initial-mark表征了一个并发垃圾回收周期的开始。CMS-concurrent-mark: 标志着并发标记阶段的完成,而CMS-concurrent-sweep则标志着并发清除阶段的完成。之前没有提到过的预清除阶段以CMS-concurrent-preclean为标志。预清除可以和重标记阶段CMS-remark的准备工作同时运行。最后一个阶段是CMS-concurrent-reset,这是下一个并发收集周期的准备工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[GC [1 CMS-initial-mark: 13991K(20288K)] 14103K(22400K), 0.0023781 secs]
[GC [DefNew: 2112K->64K(2112K), 0.0837052 secs] 16103K->15476K(22400K), 0.0838519 secs]
...
[GC [DefNew: 2077K->63K(2112K), 0.0126205 secs] 17552K->15855K(22400K), 0.0127482 secs]
[CMS-concurrent-mark: 0.267 /0 .374 secs]
[GC [DefNew: 2111K->64K(2112K), 0.0190851 secs] 17903K->16154K(22400K), 0.0191903 secs]
[CMS-concurrent-preclean: 0.044 /0 .064 secs]
[GC [1 CMS-remark: 16090K(20288K)] 17242K(22400K), 0.0210460 secs]
[GC [DefNew: 2112K->63K(2112K), 0.0716116 secs] 18177K->17382K(22400K), 0.0718204 secs]
[GC [DefNew: 2111K->63K(2112K), 0.0830392 secs] 19363K->18757K(22400K), 0.0832943 secs]
...
[GC [DefNew: 2111K->0K(2112K), 0.0035190 secs] 17527K->15479K(22400K), 0.0036052 secs]
[CMS-concurrent-sweep: 0.291 /0 .662 secs]
[GC [DefNew: 2048K->0K(2112K), 0.0013347 secs] 17527K->15479K(27912K), 0.0014231 secs]
[CMS-concurrent-reset: 0.016 /0 .016 secs]
[GC [DefNew: 2048K->1K(2112K), 0.0013936 secs] 17527K->15479K(27912K), 0.0014814 secs]
 

初始标记停顿在典型情况下比小回收的停顿时间还要小。而如上例所示,并发阶段(并发标记、并发预清除和并发清除)通常会比小回收长很多。不过注意,应用并没有在这些并发阶段中停顿下来。重标记停顿通常和一个小回收的长度相当。重标记停顿挥手道应用的某些特征(如高对象修改频率可能会增加这个停顿)和上一次小回收的时间(即,更多的年轻代对象可能会增加这个停顿)的影响。

8. 其他考虑

8.1 永久代尺寸

在大部分应用中,永久代对于垃圾回收性能没有显著的影响。不过,一些应用会动态的生成与加载很多类;比如,一些 JavaServer Pages(JSP)页面的实现。这些应用可能需要很大的永久代去存放一些多余的类。如果这样的话,最大永久代的尺寸可以用命令行参数-XX:MaxPermSize=<N>来增大。

8.2 Finalization; Weak, Soft and Phantom References

一些应用使用 finalization 和 weak, soft, phantom 引用与垃圾收集器交互。这些特征可以 Java 语言层带来性能影响。一个例子是通过 finalization 来关闭文件描述符,这会导致一个外部资源依赖于垃圾收集器。以来垃圾收集器来管理内存之外的资源是个坏主意。

参考资料章节中的文章深度讨论了一些finalization的常见错误和用来避免这些错误的技术。

8.3 显式垃圾回收

应用程序和垃圾回收器的另一个交互途径是显式调用 System.gc() 进行完整的垃圾回收。这回强制进行一次主回收,即使没有必要(也就是说一次小回收可能就足够了),所以应该避免这种情况。显式垃圾回收对性能的影响可以通过使用 -XX:+DisableExplicitGC 进行比较来进行测量,这样虚拟机会无视 System.gc() 的。

最常见的显式调用垃圾回收的场景是 RMI 的分布式垃圾回收。使用 RMI 的应用会引用到其他虚拟机中的对象。在这种分布式应用的场景下,本地堆中的垃圾可能不能被回收掉,所以 RMI 会周期性强制进行完整的垃圾回收。这些回收的频率可以使用参数来控制。如

java -Dsun.rmi.dgc.client.gcInterval=3600000 -Dsun.rmi.dgc.server.gcInterval=3600000 …

这里指定了垃圾回收每小时运行一次,而不是缺省的每分钟一次。不过,这可能会导致某些对象的清除消耗太长时间。这些参数可以被设置到高达Long.MAX_VALUE来让显式垃圾回收的间隔时间无限长,如果没有合适的DGC上限时间的话。

8.4 Soft References

Soft reference在虚拟机中比在客户集中存活的更长一些。其清除频率可以用命令行参数 -XX:SoftRefLRUPolicyMSPerMB=<N>来控制,这可以指定每兆堆空闲空间的 soft reference 保持存活(一旦它不强可达了)的毫秒数,这意味着每兆堆中的空闲空间中的 soft reference 会(在最后一个强引用被回收之后)存活1秒钟。注意,这是一个近似的值,因为 soft reference 只会在垃圾回收时才会被清除,而垃圾回收并不总在发生。

8.5 Solaris 8 替换 libthread

Solaris 8 操作系统提供了一个替代的线程库,libthread, 它将线程直接绑定成了轻量级进程(LWP)。有些应用能够从中极大获益,并潜在的对所有多线程应用都或多或少的有好处。下面的命令会为 java 启用替换的 libthread(BASH 格式)

1
2
3
LD_PRELOAD= /usr/lib/lwp/libthread .so.1
export LD_PRELOAD
java ...

这个方法仅对 Solaris 8 适用,因为对 Solaris 9 操作系统来说,这是缺省的,而 Solaris 10 中,这是惟一的线程库。

9. 相关资源

1.HotSpot VM Frequently Asked Questions (FAQ)

2.GC output examples 介绍了如何解释不同垃圾收集器的输出。

3.How to Handle Java Finalization’s Memory-Retention Issues 介绍了一些容易犯的错误和避免他们的方法。

4.Richard Jones and Rafael Lins, Garbage Collection: Algorithms for Automated Dynamic Memory Management, Wiley and Sons (1996), ISBN 0-471-94148-4

名词“Java Virtual Machine” 和“JVM” 都代表 Java 平台虚拟机。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐