单核CPU, 1G内存,也能做JVM调优吗?
最近,技术群里有人问了一个有趣的技术话题:单核CPU, 1G内存的超低配机器,怎么做JVM调优?
这实际上是两个问题。单核CPU的超低配机器,怎么充分利用CPU?单核CPU, 1G内存的超低配机器,怎么做JVM调优?
怎么充分利用CPU?
这个问题不能一概而论,要结合具体场景。对于IO密集型和CPU密集型的应用调优的方法会截然不同。
IO密集型:有频繁外部设备访问的应用,如磁盘访问和网络访问等。由于CPU性能相对硬盘读写和网络访问要好很多,系统执行任务时,大部分的情况是CPU在等I/O (磁盘/网络) 的读/写操作,在发生I/O操作时cpu处于等待状态,这就可能导致cpu的利用率不高。
CPU密集型: 以计算为主,很少有磁盘和网络访问的应用。这种任务CPU一直在运行,CPU的利用率很高。
在给出CPU调优结论之前,先花两分钟熟悉一下I/O基础。
所谓的I/O(Input/Output)操作实际上就是输入输出的数据传输行为。程序员最关注的主要是磁盘IO和网络IO,因为这两个IO操作和应用程序的关系最直接最紧密。
磁盘IO:磁盘的输入输出,比如磁盘和内存之间的数据传输。
网络IO:不同系统间跨网络的数据传输,比如两个系统间的远程接口调用。
下面这张图展示了应用程序中发生IO的具体场景:
通过上图,我们可以了解到IO操作发生的具体场景。一个请求过程可能会发生很多次的IO操作:
1,页面请求到服务器会发生网络IO
2,服务之间远程调用会发生网络IO
3,应用程序访问数据库会发生网络IO
4,数据库查询或者写入数据会发生磁盘IO
下面是执行top命令查看CPU状况的截图:
从上图,我们可以看到:
CPU空闲率是0%(上图中红框id)
CPU使用率是22%(上图中红框 us 13% 加上 sy 9%,us可以理解成用户进程占用的CPU,sy可以理解成系统进程占用的CPU)
CPU 在等待磁盘IO操作上花费的时间占比是76.6% (上图中红框 wa)
不少人会这样理解,如果CPU空闲率是0%,就代表CPU已经在满负荷工作,没精力再处理其他任务了。真是这样的吗?
我们先看一下计算机是怎么管理磁盘IO操作的。计算机发展早期,磁盘和内存的数据传输是由CPU控制的,也就是说从磁盘读取数据到内存中,是需要CPU存储和转发的,期间CPU一直会被占用。我们知道磁盘的读写速度远远比不上CPU的运转速度。这样在传输数据时就会占用大量CPU资源,造成CPU资源严重浪费。
后来有人设计了一个IO控制器,专门控制磁盘IO。当发生磁盘和内存间的数据传输前,CPU会给IO控制器发送指令,让IO控制器负责数据传输操作,数据传输完IO控制器再通知CPU。因此,从磁盘读取数据到内存的过程就不再需要CPU参与了,CPU可以空出来处理其他事情,大大提高了CPU利用率。这个IO控制器就是“DMA”,即直接内存访问,Direct Memory Access。现在的计算机基本都采用这种DMA模式进行数据传输。
通过上面内容我们了解到,IO数据传输时,是不占用CPU的。当应用进程或线程发生IO等待时,CPU会及时释放相应的时间片资源并把时间片分配给其他进程或线程使用,从而使CPU资源得到充分利用。所以,假如CPU大部分消耗在IO等待(wa)上时,即便CPU空闲率(id)是0%,也并不意味着CPU资源完全耗尽了,如果有新的任务来了,CPU仍然有精力执行任务。如下图:
在DMA模式下执行IO操作是不占用CPU的,所以CPU IO等待(上图的wa)实际上属于CPU空闲率的一部分。所以我们执行top命令时,除了要关注CPU空闲率,CPU使用率(us,sy),还要关注IO Wait(wa)。注意,wa只代表磁盘IO Wait,不包括网络IO Wait。
了解完IO的基础知识,我们看看在单核CPU的超低配机器上,怎么充分利用CPU?
对于IO密集型应用。CPU会有很多时间花在IO等待上,发生IO时虽然CPU空闲率(上图的id)受到影响,但是实际上cpu并没有干活。这时就需要较多的线程数量,当一部分线程因为IO问题被阻塞时,其他空闲线程还能继续接收并执行其他请求任务。这样cpu利用率就会更高。同时还要考虑线程间上下文切换带来的性能开销,线程数量不能太高。对于单核CPU,要根据IO的密集程度设置线程数。由于CPU只有一核,资源有限,所以除了对线程数的优化外,主要还是要优化IO操作,减少IO操作频率,缩短IO操作时间。IO操作优化之后,线程数可以设置成更少,线程切的换频率和性能开销也会随之降低。
对于CPU密集型应用。线程数应该尽可能少一些,在没有任何IO操作的情况下,为了减少线程切换带来的性能开销,理论上最佳的线程数量应该设置成CPU的核数。不过实际场景中,绝大多数应用或多或少都会有一定的IO操作(比如记录Log,访问数据库或者跨网络的远程调用等),这样线程数就需要适当调大。至于设置成多少,就没有定论了,需要我们多次调整验证(取性能测试的最优结果)。对于单核CPU,为了减少线程切换带来的性能开销,一两个线程基本就够了。
怎么做JVM调优?
选择合适的垃圾收集器
CMS和G1是目前最炙手可热的两个垃圾回收器,基本上所有公司都在使用CMS或G1。不过,在单核CPU,内存只有1G的机器上,CMS和G1就不太合适了。
以CMS回收过程为例,在耗时较长的并发标记和并发清除阶段,垃圾收集线程和用户线程是同时并行工作的,也就是说并发阶段不会导致用户线程停顿。不过CMS对CPU资源非常敏感。 其实,所有高并发的应用对CPU资源都很敏感。在CMS并发阶段(并发标记和并发清除阶段),虽然不会导致用户线程停顿,但是垃圾收集线程会占用一部分CPU资源,进而导致应用程序变慢,吞吐量降低。CMS默认启动的垃圾收集线程数是(CPU核数+3)/4,当CPU核数在4个以上时,并发回收阶段垃圾收集线程不少于25%的CPU资源(CPU核数)。但是当CPU核数不足4个时,比如CPU核数为2个,CMS对用户程序的影响就可能变得很大,此时需要分配1个核的资源去执行垃圾收集任务,如果本来CPU负载就比较大,还要分出一半的计算能力去执行垃圾收集任务,就可能导致应用程序的执行速度大幅下降,甚至忽然降低50%以上,着实让人无法接受。
在单核CPU环境下,并发标记和并发清除阶段是无法真正做到并发的,当垃圾收集线程执行标记和清除任务时,单核CPU唯一的核就无法执行用户线程,这样就会造成严重的用户线程阻塞问题,导致应用程序响应超慢。
说到这有人可能会问:换成其他垃圾收集器,在单核CPU环境下,不一样会有这种因为线程阻塞导致的应用程序执行变慢的问题吗?
没错,换成其他垃圾收集器,在单核CPU环境下,一样会有同样的问题。不过情况应该会比使用CMS或者G1要好!CMS是响应速度优先的老年代垃圾收集器,是一种以降低GC全局停顿时间(Stop The World)为目标的收集器。为了实现这一目标,CMS把垃圾回收分成了初始标记,并发标记,重新标记和并发清除4个阶段。其中初始标记和重新标记两个阶段会停止所有用户线程(发生STW),不过耗时很短。并发标记和并发清除两个阶段耗时最长,但是这两个阶段垃圾收集线程可以和用户线程一起工作,不会停止用户线程。CMS的这种设计虽然缩短了STW的时间,但是整个GC过程(四个阶段加在一起的总时间)更长了。如果在单核CPU环境下,并发标记和并发清除两个阶段就无法做到真正的并发,因为单核的问题,垃圾收集线程和用户线程不可能同时占用唯一的CPU资源,所以在垃圾收集线程运行时所有用户线程都会被停止,相当于发生了STW。基本上可以这样理解,在单核CPU环境下,CMS的四个阶段都会发生Stop The World。也就是说,在单核CPU环境下,CMS的Stop The World时间比传统的老年代收集器Serial Old和Parallel Old还要长。所以在单核CPU环境下,绝对不能选择CMS和G1这种对CPU特别敏感的收集器。考虑到Parallel Old是一款多线程并发收集器,主要为了利用多核CPU来提高垃圾回收效率,不适合单核环境。所以,基本上最古老的Serial Old收集器就成了单核CPU的最佳选择啦。
另外,1G的内存空间太小,也不适合CMS和G1。数年前,在CMS和G1还没诞生之前,很多互联网系统使用Serial Old和Parallel Old做为老年代收集器,这样会带来一个严重问题,堆内存越大垃圾回收时STW(Stop The World)时间就越长,在互联网系统中,堆内存往往会超过4G,每次Full GC时STW时间会很长,可能会达到几秒钟甚至更长,也就是说JVM在这几秒钟内无法处理任何用户请求。这在高并发的互联网系统中是无法接受的。后来随着CMS和G1先后应运而生,解决了较大堆内存GC时STW时间过长的问题。所以说CMS和G1只是为了大内存场景设计的,不适合小内存场景,在小内存场景下不能发挥自己的优势。如果内存只有1G,单核CPU下为了提高吞吐量可以选择Serial Old。多核CPU下,为了充分发挥多核作用提高垃圾收集效率,可以选择多线程并发收集器Parallel Old。
降低GC频次
JVM将堆内存分为了三部分:新生代(Young Generation),老年代(Old Generation),永久代(Permanent Generation)。其中新生代又分为三部分:伊甸园区(Eden),和两个幸存区S0和S1。
注:JDK1.8之后,Java官方的HotSpot JVM去掉了永久代,取而代之的是元数据区Metaspace。Metaspace使用的是本地内存,而不是堆内存,也就是说在默认情况下Metaspace的大小只与本地内存的大小有关。因此JDK1.8之后,就见不到java.lang.OutOfMemoryError: PermGen space这种由于永久代空间不足导致的内存溢出的问题了。
堆内存中对象的分配和流转过程
新创建的对象会先被分配到到Eden区。JVM刚启动时,Eden区对象数量较少,两个Survivor区S0、S1几乎是空的。
随着时间的推移,Eden区的对象越来越多。当Eden区放不下时(占用空间达到容量阈值),新生代就会发生垃圾回收,我们称之为Minor GC或者Young GC。
发生GC时,第一步会通过可达性分析算法找到可达对象。如上图,蓝色为可达对象,其他紫色为不可达对象。第二步,被标示的可达对象会被转移到S0(此时S0是From Survivor),此时存活对象年龄加1,三个对象年龄都变为1。第三步,清除Eden区所有对象。
GC后各区域对象占用情况,如上图所示。
程序继续运行,Eden区再次达到容量阈值时,会再次发生GC。这时S0(From Survivor)已经有了对象。还是同样的步骤,通过可达性分析算法找到可达对象,然后再将Eden和S0中的可达对象转移到S1(To Survivor),各存活对象年龄加1。最后将Eden和S0中的所有对象清除。
通过上面的图文内容,我们了解了堆内存中对象的分配和流转过程。那么可以基于这些知识来做一些JVM调优的工作。
所谓降低GC频次,主要指的是降低Major GC(老年代GC)次数。内存只有1G,为了减少Major GC,最简单的做法是适当调大老年代比例,但是老年代空间总有个上限,需要在老年代和年轻代之间找一个平衡点。还可以适当调大MaxTenuringThreshold,来提高年轻代幸存区s0和s1的交换次数,进而减少对象晋升到老年代的几率。另外调大幸存区比例,也可以减少基于动态对象年龄判定导致对象晋升老年代的几率。不管是哪种优化手段,都需要反复调整和验证(可以做性能测试验证调整结果)。
再补充一个基础知识点。Full GC,Major GC,Minor GC之间是什么关系?
当前绝大部分垃圾收集器都采用分代回收的策略,年轻代和老年代的GC分别独立进行。一般情况下,老年代Major GC是由年轻代Minor GC触发的,Minor GC会导致部分存活时间较长的对象晋升到老年代,在晋升过程中如果老年代使用空间达到阈值就会发生Major GC。这种由Minor GC触发Major GC引发整个堆内存GC的情况,我们一般称之为Full GC。还有一些情况也会触发Major GC,比如大对象初始化时会跨过年轻代直接分配到老年代,这种情况触发的Major GC和Minor GC就没半点关系了。可以通过-XX:PretenureSizeThreshold参数设置大对象的大小,如果参数被设置成5MB,超过5MB的大对象会直接分配到老年代。
缩短GC时间
缩短GC时间和降低GC频次,两者是鱼和熊掌的关系,不可兼得。如上面所说,在1G内存单核CPU的场景下,响应时间优先的CMS和G1都不适合。在垃圾收集器没有太多选择的情况下,如果想缩短Major GC时间,基本上只能减小老年代的比例了,老年代空间越小,每次Major GC需要处理的对象就越少,GC时间也就越短。老年代空间越小,GC的频次自然也会更高,内存空间就那么多,所以我们需要反复试验,在GC频次和GC时间上找到最佳平衡点来满足业务系统的要求。
结语
JVM调优没有什么可以拿来即用的固定模板或规范,每个应用都有自己的独特场景。不同的应用并发程度不一样,对响应时间和吞吐量要求也不一样,堆内存对象规模、对象生命周期、对象大小等等都不会完全一样,这些因素都会影响到JVM的性能。所以,JVM调优是一个循序渐进的过程,必然需要经历多次迭代,最终才能得到一个较好的折中方案。