jvm系列(七):如何优化Java GC「译」

本文由CrowHawk翻译,地址:如何优化Java GC「译」,是Java GC调优的经典佳做。java

Sangmin Lee发表在Cubrid上的”Become a Java GC Expert”系列文章的第三篇《How to Tune Java Garbage Collection》,本文的做者是韩国人,写在JDK 1.8发布以前,虽然有些地方有些许过期,但总体内容仍是很是有价值的。译者此前也看到有人翻译了本文,发现其中有许多错漏生硬和语焉不详之处,所以决定本身翻译一份,供你们分享。git

本文是“成为Java GC专家”系列文章的第三篇,在系列的第一篇文章《理解Java GC》中,咱们了解到了不一样GC算法的执行过程、GC的工做原理、新生代和老年代的概念、JDK 7中你须要了解的5种GC类型以及每一种GC对性能的影响。github

在系列的第二篇文章《如何监控Java GC》中笔者已经解释了JVM进行实时GC的原理、监控GC的方法以及可使这一过程更加迅速高效的工具。算法

在第三篇文章中,笔者将基于实际生产环境中的案例,介绍几个GC优化的最佳参数设置。在此咱们假设你已经理解了本系列前两篇文章的内容,所以为了更深刻的理解本文所讲内容,我建议你在阅读本篇文章以前先仔细阅读这两篇文章。数据库

GC优化是必要的吗?

或者更准确地说,GC优化对Java基础服务来讲是必要的吗?答案是否认的,事实上GC优化对Java基础服务来讲在有些场合是能够省去的,但前提是这些正在运行的Java系统,必须包含如下参数或行为:服务器

  • 内存大小已经经过-Xms和-Xmx参数指定过
  • 运行在server模式下(使用-server参数)
  • 系统中没有残留超时日志之类的错误日志

换句话说,若是你在运行时没有手动设置内存大小而且打印出了过多的超时日志,那你就须要对系统进行GC优化。并发

不过你须要时刻谨记一句话:GC tuning is the last task to be done.oracle

如今来想想GC优化的最根本缘由,垃圾收集器的工做就是清除Java建立的对象,垃圾收集器须要清理的对象数量以及要执行的GC数量均取决于已建立的对象数量。所以,为了使你的系统在GC上表现良好,首先须要减小建立对象的数量。jvm

俗话说“冰冻三尺非一日之寒”,咱们在编码时要首先要把下面这些小细节作好,不然一些琐碎的不良代码累积起来将让GC的工做变得繁重而难于管理:ide

  • 使用StringBuilderStringBuffer来代替String
  • 尽可能少输出日志

尽管如此,仍然会有咱们一筹莫展的状况。XML和JSON解析过程每每占用了最多的内存,即便咱们已经尽量地少用String、少输出日志,仍然会有大量的临时内存(大约10-100MB)被用来解析XML或JSON文件,但咱们又很难弃用XML和JSON。在此,你只须要知道这一过程会占据大量内存便可。

若是在通过几回重复的优化后应用程序的内存用量状况有所改善,那么久能够启动GC优化了。

笔者总结了GC优化的两个目的:

  1. 将进入老年代的对象数量降到最低
  2. 减小Full GC的执行时间

将进入老年代的对象数量降到最低

除了能够在JDK 7及更高版本中使用的G1收集器之外,其余分代GC都是由Oracle JVM提供的。关于分代GC,就是对象在Eden区被建立,随后被转移到Survivor区,在此以后剩余的对象会被转入老年代。也有一些对象因为占用内存过大,在Eden区被建立后会直接被传入老年代。老年代GC相对来讲会比新生代GC更耗时,所以,减小进入老年代的对象数量能够显著下降Full GC的频率。你可能会觉得减小进入老年代的对象数量意味着把它们留在新生代,事实正好相反,新生代内存的大小是能够调节的。

下降Full GC的时间

Full GC的执行时间比Minor GC要长不少,所以,若是在Full GC上花费过多的时间(超过1s),将可能出现超时错误。

  • 若是经过减少老年代内存来减小Full GC时间,可能会引发OutOfMemoryError或者致使Full GC的频率升高。
  • 另外,若是经过增长老年代内存来下降Full GC的频率,Full GC的时间可能所以增长。

所以,你须要把老年代的大小设置成一个“合适”的值。

影响GC性能的参数

正如我在系列的第一篇文章《理解Java GC》末尾提到的,不要幻想着“若是有人用他设置的GC参数获取了不错的性能,咱们为何不复制他的参数设置呢?”,由于对于不用的Web服务,它们建立的对象大小和生命周期都不相同。

举一个简单的例子,若是一个任务的执行条件是A,B,C,D和E,另外一个彻底相同的任务执行条件只有A和B,那么哪个任务执行速度更快呢?做为常识来说,答案很明显是后者。

Java GC参数的设置也是这个道理,设置好几个参数并不会提高GC执行的速度,反而会使它变得更慢。GC优化的基本原则是将不一样的GC参数应用到两个及以上的服务器上而后比较它们的性能,而后将那些被证实能够提升性能或减小GC执行时间的参数应用于最终的工做服务器上。

下面这张表展现了与内存大小相关且会影响GC性能的GC参数

表1:GC优化须要考虑的JVM参数
类型 参数 描述
堆内存大小 -Xms 启动JVM时堆内存的大小
  -Xmx 堆内存最大限制
新生代空间大小 -XX:NewRatio 新生代和老年代的内存比
  -XX:NewSize 新生代内存大小
  -XX:SurvivorRatio Eden区和Survivor区的内存比

笔者在进行GC优化时最经常使用的参数是-Xms,-Xmx-XX:NewRatio-Xms-Xmx参数一般是必须的,因此NewRatio的值将对GC性能产生重要的影响。

有些人可能会问如何设置永久代内存大小,你能够用-XX:PermSize-XX:MaxPermSize参数来进行设置,可是要记住,只有当出现OutOfMemoryError错误时你才须要去设置永久代内存。

还有一个会影响GC性能的因素是垃圾收集器的类型,下表展现了关于GC类型的可选参数(基于JDK 6.0):

表2:GC类型可选参数
GC类型 参数 备注
Serial GC -XX:+UseSerialGC  
Parallel GC -XX:+UseParallelGC</br>-XX:ParallelGCThreads=value  
Parallel Compacting GC -XX:+UseParallelOldGC  
CMS GC -XX:+UseConcMarkSweepGC</br>-XX:+UseParNewGC</br>-XX:+CMSParallelRemarkEnabled</br>-XX:CMSInitiatingOccupancyFraction=value</br>-XX:+UseCMSInitiatingOccupancyOnly  
G1 -XX:+UnlockExperimentalVMOptions</br>-XX:+UseG1GC 在JDK 6中这两个参数必须配合使用

除了G1收集器外,能够经过设置上表中每种类型第一行的参数来切换GC类型,最多见的非侵入式GC就是Serial GC,它针对客户端系统进行了特别的优化。

会影响GC性能的参数还有不少,可是上述的参数会带来最显著的效果,请切记,设置太多的参数并不必定会提高GC的性能。

GC优化的过程

GC优化的过程和大多数常见的提高性能的过程类似,下面是笔者使用的流程:

1.监控GC状态

你须要监控GC从而检查系统中运行的GC的各类状态,具体方法请查看系列的第二篇文章《如何监控Java GC》

2.分析监控结果后决定是否须要优化GC

在检查GC状态后,你须要分析监控结构并决定是否须要进行GC优化。若是分析结果显示运行GC的时间只有0.1-0.3秒,那么就不须要把时间浪费在GC优化上,但若是运行GC的时间达到1-3秒,甚至大于10秒,那么GC优化将是颇有必要的。

可是,若是你已经分配了大约10GB内存给Java,而且这些内存没法省下,那么就没法进行GC优化了。在进行GC优化以前,你须要考虑为何你须要分配这么大的内存空间,若是你分配了1GB或2GB大小的内存而且出现了OutOfMemoryError,那你就应该执行堆转储(heap dump)来消除致使异常的缘由。

注意:

堆转储(heap dump)是一个用来检查Java内存中的对象和数据的内存文件。该文件能够经过执行JDK中的jmap命令来建立。在建立文件的过程当中,全部Java程序都将暂停,所以,不要再系统执行过程当中建立该文件。

你能够在互联网上搜索heap dump的详细说明。对于韩国读者,能够直接参考我去年发布的书:《The story of troubleshooting for Java developers and system operators》 (Sangmin Lee, Hanbit Media, 2011, 416 pages)

3.设置GC类型/内存大小

若是你决定要进行GC优化,那么你须要选择一个GC类型而且为它设置内存大小。此时若是你有多个服务器,请如上文提到的那样,在每台机器上设置不一样的GC参数并分析它们的区别。

4.分析结果

在设置完GC参数后就能够开始收集数据,请在收集至少24小时后再进行结果分析。若是你足够幸运,你可能会找到系统的最佳GC参数。如若否则,你还须要分析输出日志并检查分配的内存,而后须要经过不断调整GC类型/内存大小来找到系统的最佳参数。

5.若是结果使人满意,将参数应用到全部服务器上并结束GC优化

若是GC优化的结果使人满意,就能够将参数应用到全部服务器上,并中止GC优化。

在下面的章节中,你将会看到上述每一步所作的具体工做。

监控GC状态并分析结果

在运行中的Web应用服务器(Web Application Server,WAS)上查看GC状态的最佳方式就是使用jstat命令。笔者在《如何监控Java GC》中已经介绍过了jstat命令,因此在本篇文章中我将着重关注数据部分。

下面的例子展现了某个尚未执行GC优化的JVM的状态(虽然它并非运行服务器)。

$ jstat -gcutil 21719 1s
S0    S1    E    O    P    YGC    YGCT    FGC    FGCT GCT
48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673
48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673

咱们先看一下YGC(从应用程序启动到采样时发生 Young GC 的次数)和YGCT(从应用程序启动到采样时 Young GC 所用的时间(秒)),计算YGCT/YGC会得出,平均每次新生代的GC耗时50ms,这是一个很小的数字,经过这个结果能够看出,咱们大可没必要关注新生代GC对GC性能的影响。

如今来看一下FGC( 从应用程序启动到采样时发生 Full GC 的次数)和FGCT(从应用程序启动到采样时 Full GC 所用的时间(秒)),计算FGCT/FGC会得出,平均每次老年代的GC耗时19.68s。有多是执行了三次Full GC,每次耗时19.68s,也有多是有两次只花了1s,另外一次花了58s。无论是哪种状况,GC优化都是颇有必要的。

使用jstat命令能够很容易地查看GC状态,可是分析GC的最佳方式是加上-verbosegc参数来生成日志。在以前的文章中笔者已经解释了如何分析这些日志。HPJMeter是笔者最喜欢的用于分析-verbosegc生成的日志的工具,它简单易用,使用HPJmeter能够很容易地查看GC执行时间以及GC发生频率。

此外,若是GC执行时间知足下列全部条件,就没有必要进行GC优化了:

  • Minor GC执行很是迅速(50ms之内)
  • Minor GC没有频繁执行(大约10s执行一次)
  • Full GC执行很是迅速(1s之内)
  • Full GC没有频繁执行(大约10min执行一次)

括号中的数字并非绝对的,它们也随着服务的状态而变化。有些服务可能要求一次Full GC在0.9s之内,而有些则会放得更宽一些。所以,对于不一样的服务,须要按照不一样的标准考虑是否须要执行GC优化。

当检查GC状态时,不能只查看Minor GC和Full GC的时间,还必需要关注GC执行的次数。若是新生代空间过小,Minor GC将会很是频繁地执行(有时每秒会执行一次,甚至更多)。此外,传入老年代的对象数目会上升,从而致使Full GC的频率升高。所以,在执行jstat命令时,请使用-gccapacity参数来查看具体占用了多少空间。

设置GC类型/内存大小

设置GC类型

Oracle JVM有5种垃圾收集器,可是在JDK 7之前的版本中,你只能在Parallel GC, Parallel Compacting GC 和CMS GC之中选择,至于具体选择哪一个,则没有具体的原则和规则。

既然这样的话,咱们如何来选择GC呢?最好的方法是把三种都用上,可是有一点必须明确——CMS GC一般比其余并行(Parallel)GC都要快(这是由于CMS GC是并发的GC),若是确实如此,那只选择CMS GC就能够了,不过CMS GC也不老是更快,当出现concurrent mode failure时,CMS GC就会比并行GC更慢了。

Concurrent mode failure

如今让咱们来深刻地了解一下concurrent mode failure。

并行GC和CMS GC的最大区别是并行GC采用“标记-整理”(Mark-Compact)算法而CMS GC采用“标记-清除”(Mark-Sweep)算法(具体内容可参照译者的文章《GC算法与内存分配策略》),compact步骤就是经过移动内存来消除内存碎片,从而消除分配的内存之间的空白区域。

对于并行GC来讲,不管什么时候执行Full GC,都会进行compact工做,这消耗了太多的时间。不过在执行完Full GC后,下次内存分配将会变得更快(由于直接顺序分配相邻的内存)。

相反,CMS GC没有compact的过程,所以CMS GC运行的速度更快。可是也是因为没有整理内存,在进行磁盘清理以前,内存中会有不少零碎的空白区域,这也致使没有足够的空间分配给大对象。例如,在老年代还有300MB可用空间,可是连一个10MB的对象都没有办法被顺序存储在老年代中,在这种状况下,会报出“concurrent mode failure”的warning,而后系统执行compact操做。可是CMS GC在这种状况下执行的compact操做耗时要比并行GC高不少,而且这还会致使另外一个问题,关于“concurrent mode failure”的详细说明,可用参考Oracle工程师撰写的《Understanding CMS GC Logs》

综上所述,你须要根据你的系统状况为其选择一个最适合的GC类型。

每一个系统都有最适合它的GC类型等着你去寻找,若是你有6台服务器,我建议你每两个服务器设置相同的参数,而后加上-verbosegc参数再分析结果。

设置内存大小

下面展现了内存大小、GC运行次数和GC运行时间之间的关系:

大内存空间

  • 减小了GC的次数
  • 提升了GC的运行时间

小内存空间

  • 增多了GC的次数
  • 下降了GC的运行时间

关于如何设置内存的大小,没有一个标准答案,若是服务器资源充足而且Full GC能在1s内完成,把内存设为10GB也是能够的,可是大部分服务器并不处在这种状态中,当内存设为10GB时,Full GC会耗时10-30s,具体的时间天然与对象的大小有关。

既然如此,咱们该如何设置内存大小呢?一般我推荐设为500MB,这不是说你要经过-Xms500m-Xmx500m参数来设置WAS内存。根据GC优化以前的状态,若是Full GC后还剩余300MB的空间,那么把内存设为1GB是一个不错的选择(300MB(默认程序占用)+ 500MB(老年代最小空间)+200MB(空闲内存))。这意味着你须要为老年代设置至少500MB空间,所以若是你有三个运行服务器,能够把它们的内存分别设置为1GB,1.5GB,2GB,而后检查结果。

理论上来讲,GC执行速度应该遵循1GB> 1.5GB> 2GB,1GB内存时GC执行速度最快。然而,理论上的1GB内存Full GC消耗1s、2GB内存Full GC消耗2 s在现实里是没法保证的,实际的运行时间还依赖于服务器的性能和对象大小。所以,最好的方法是建立尽量多的测量数据并监控它们。

在设置内存空间大小时,你还须要设置一个参数:NewRatioNewRatio的值是新生代和老年代空间大小的比例。若是XX:NewRatio=1,则新生代空间:老年代空间=1:1,若是堆内存为1GB,则新生代:老年代=500MB:500MB。若是NewRatio等于2,则新生代:老年代=1:2,所以,NewRatio的值设置得越大,则老年代空间越大,新生代空间越小。

你可能会认为把NewRatio设为1会是最好的选择,然而事实并不是如此,根据笔者的经验,当NewRatio设为2或3时,整个GC的状态表现得更好。

完成GC优化最快地方法是什么?答案是比较性能测试的结果。为了给每台服务器设置不一样的参数并监控它们,最好查看的是一或两天后的数据。当经过性能测试来进行GC优化时,你须要在不一样的测试时保证它们有相同的负载和运行环境。然而,即便是专业的性能测试人员,想精确地控制负载也很困难,而且须要大量的时间准备。所以,更加方便容易的方式是直接设置参数来运行,而后等待运行的结果(即便这须要消耗更多的时间)。

分析GC优化的结果

在设置了GC参数和-verbosegc参数后,可使用tail命令确保日志被正确地生成。若是参数设置得不正确或日志未生成,那你的时间就被白白浪费了。若是日志收集没有问题的话,在收集一或两天数据后再检查结果。最简单的方法是把日志从服务器移到你的本地PC上,而后用HPJMeter分析数据。

在分析结果时,请关注下列几点(这个优先级是笔者根据本身的经验拟定的,我认为选取GC参数时应考虑的最重要的因素是Full GC的运行时间。):

  • 单次Full GC运行时间
  • 单次Minor GC运行时间
  • Full GC运行间隔
  • Minor GC运行间隔
  • 整个Full GC的时间
  • 整个Minor GC的运行时间
  • 整个GC的运行时间
  • Full GC的执行次数
  • Minor GC的执行次数

找到最佳的GC参数是件很是幸运的,然而在大多数时候,咱们并不会如此幸运,在进行GC优化时必定要当心谨慎,由于当你试图一次完成全部的优化工做时,可能会出现OutOfMemoryError错误。

优化案例

到目前为止,咱们一直在从理论上介绍GC优化,如今是时候将这些理论付诸实践了,咱们将经过几个例子来更深刻地理解GC优化。

示例1

下面这个例子是针对Service S的优化,对于最近刚开发出来的Service S,执行Full GC须要消耗过多的时间。

如今看一下执行jstat -gcutil的结果

S0 S1 E O P YGC YGCT FGC FGCT GCT
12.16 0.00 5.18 63.78 20.32 54 2.047 5 6.946 8.993

左边的Perm区的值对于最初的GC优化并不重要,而YGC参数的值更加对于此次优化更为重要。

平均执行一次Minor GC和Full GC消耗的时间以下表所示:

表3:Service S的Minor GC 和Full GC的平均执行时间
GC类型 GC执行次数 GC执行时间 平均值
Minor GC 54 2.047s 37ms
Full GC 5 6.946s 1.389s

37ms对于Minor GC来讲还不赖,但1.389s对于Full GC来讲意味着当GC发生在数据库Timeout设置为1s的系统中时,可能会频繁出现超时现象。

首先,你须要检查开始GC优化前内存的使用状况。使用jstat -gccapacity命令能够检查内存用量状况。在笔者的服务器上查看到的结果以下:

NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC PGCMN PGCMX PGC PC YGC FGC
212992.0 212992.0 212992.0 21248.0 21248.0 170496.0 1884160.0 1884160.0 1884160.0 1884160.0 262144.0 262144.0 262144.0 262144.0 54 5

其中的关键值以下:

  • 新生代内存用量:212,992 KB
  • 老年代内存用量:1,884,160 KB

所以,除了永久代之外,被分配的内存空间加起来有2GB,而且新生代:老年代=1:9,为了获得比使用jstat更细致的结果,还需加上-verbosegc参数获取日志,并把三台服务器按照以下方式设置(除此之外没有使用任何其余参数):

  • NewRatio=2
  • NewRatio=3
  • NewRatio=4

一天后我获得了系统的GC log,幸运的是,在设置完NewRatio后系统没有发生任何Full GC。

这是为何呢?这是由于大部分对象在建立后很快就被回收了,全部这些对象没有被传入老年代,而是在新生代就被销毁回收了。

在这样的状况下,就没有必要去改变其余的参数值了,只要选择一个最合适的NewRatio值便可。那么,如何肯定最佳的NewRatio值呢?为此,咱们分析一下每种NewRatio值下Minor GC的平均响应时间。

在每种参数下Minor GC的平均响应时间以下:

  • NewRatio=2:45ms
  • NewRatio=3:34ms
  • NewRatio=4:30ms

咱们能够根据GC时间的长短得出NewRatio=4是最佳的参数值(尽管NewRatio=4时新生代空间是最小的)。在设置完GC参数后,服务器没有发生Full GC。

为了说明这个问题,下面是服务执行一段时间后执行jstat –gcutil的结果:

S0 S1 E O P YGC YGCT FGC FGCT GCT
8.61 0.00 30.67 24.62 22.38 2424 30.219 0 0.000 30.219

你可能会认为是服务器接收的请求少才使得GC发生的频率较低,实际上,虽然Full GC没有执行过,但Minor GC被执行了2424次。

示例2

这是一个Service A的例子。咱们经过公司内部的应用性能管理系统(APM)发现JVM暂停了至关长的时间(超过8秒),所以咱们进行了GC优化。咱们努力寻找JVM暂停的缘由,后来发现是由于Full GC执行时间过长,所以咱们决定进行GC优化。

在GC优化的开始阶段,咱们加上了-verbosegc参数,结果以下图所示:

图1:进行GC优化以前STW的时间

上图是由HPJMeter生成的图片之一。横坐标表示JVM执行的时间,纵坐标表示每次GC的时间。CMS为绿点,表示Full GC的结果,而Parallel Scavenge为蓝点,表示Minor GC的结果。

以前我说过CMS GC是最快的GC,可是上面的结果显示在一些时候CMS耗时达到了15s。是什么致使了这一结果?请记住我以前说的:CMS在执行compact(整理)操做时会显著变慢。此外,服务的内存经过-Xms1g=Xmx4g设置了,而分配的内存只有4GB。

所以笔者将GC类型从CMS GC改成了Parallel GC,把内存大小设为2GB,并把NewRatio设为3。在执行jstat -gcutil几小时后的结果以下:

S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 30.48 3.31 26.54 37.01 226 11.131 4 11.758 22.890

Full GC的时间缩短了,变成了每次3s,跟15s比有了显著提高。可是3s依然不够快,为此笔者建立了如下6种状况:

  • Case 1: -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=2
  • Case 2: -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=3
  • Case 3: -XX:+UseParallelGC -Xms1g -Xmx1g -XX:NewRatio=3
  • Case 4: -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=2
  • Case 5: -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=3
  • Case 6: -XX:+UseParallelOldGC -Xms1g -Xmx1g -XX:NewRatio=3

上面哪种状况最快?结果显示,内存空间越小,运行结果最少。下图展现了性能最好的Case 6的结果图,它的最慢响应时间只有1.7s,而且响应时间的平均值已经被控制到了1s之内。

图2:Case 6的持续时间图

基于上图的结果,按照Case 6调整了GC参数,但这却致使每晚都会发生OutOfMemoryError。很难解释发生异常的具体缘由,简单地说,应该是批处理程序致使了内存泄漏,咱们正在解决相关的问题。

若是只对GC日志作一些短期的分析就将相关参数部署到全部服务器上来执行GC优化,这将是很是危险的。切记,只有当你同时仔细分析服务的执行状况和GC日志后,才能保证GC优化没有错误地执行。

在上文中,咱们经过两个GC优化的例子来讲明了GC优化是怎样执行的。正如上文中提到的,例子中设置的GC参数能够设置在相同的服务器之上,但前提是他们具备相同的CPU、操做系统、JDK版本而且运行着相同的服务。此外,不要把我使用的参数照搬到你的应用上,它们可能在你的机器上并不能起到一样良好的效果。

总结

笔者没有执行heap dump并分析内存的详细内容,而是经过本身的经验进行GC优化。精确地分析内存能够获得更好的优化效果,不过这种分析通常只适用于内存使用量相对固定的场景。若是服务严重过载并占有了大量的内存,则建议你根据以前的经验进行GC优化。

笔者已经在一些服务上设置了G1 GC参数并进行了性能测试,但尚未应用于正式的生产环境。G1 GC的速度快于任何其余的GC类型,可是你必需要升级到JDK 7。此外,暂时还没法保证它的稳定性,没有人知道运行时是否会出现致命的错误,所以G1 GC暂时还不适合投入应用。

等将来JDK 7真正稳定了(这并非说它如今不稳定),而且WAS针对JDK 7进行优化后,G1 GC最终能按照预期的那样来工做,等到那一天咱们可能就再也不须要GC优化了。

想了解关于GC优化的更多细节,请前往Slideshare.com 查看相关资料。强烈推荐Everything I Ever Learned About JVM Performance Tuning @Twitter,做者是Attila Szegedi, 一名Twitter工程师,请花些时间好好阅读它。

相关文章
相关标签/搜索