今天面美团问题遇到的,JVM调优问题,求解答

6136人阅读
写在前面的话:
一转眼已经十一月下旬,也终于得空能够把刚过去不久的秋招记录一下,本人是软件工程专业,工作职位投递的主要是大数据开发和软件开发。这篇文章会涉及一些公司的面经以及我个人的一些心得体会,希望能够对能够看到这篇文章的朋友有所帮助。
八月到十月,三个多月的时间里,投递了116份简历,遍布我所能知道的所有互联网相关的公司,面试了18家公司,拿到了6份offer,工作地点遍布北京、上海、西安、成都,其中拿得出手的的offer包括中国互联网应急中心,新华三,上海微盟,ofo。其中有痛苦,有挫败,有惊喜,有失望,当然也有欣慰,下面待我慢慢表述。
本人985渣硕,之前一直在导师公司实习,由于涉及到论文毕业问题,在秋招的时候找导师请假未果,又不敢跟导师撕逼,所以只能假借工作之名在工作时间偷偷的准备相关知识,包括数据结构,数据库,计算机网络,操作系统以及Java基础等。到这些东西准备的差不多的时候(当然后来证明还差很多),也就是八月下旬的时候,这段时间互联网公司内推全面开始,冒着顶撞导师的风险毅然决然的跟导师请了一个月的假(目前此事对以后的论文能够造成什么影响依旧不明)。之后便开始了自己的秋招之旅,顺便感慨一下别人的导师。
八月下旬主要就是内推,目标瞄准的就是一线互联网公司,主要就是找各种一线互联网公司的学长以及各种内推的微信群以及公众号,疯狂的内推,可能是本人运气差,或者是简历账面实力差(简历还是很重要的),内推了很多家公司但是只是收到了百度的电话面试,面试的职位是软件后台开发,面试了将近两个小时,我自认为答得还可以,本来自信满满的等着二面电话,然后就没有然后了!(WTF)
百度面经(软件后台开发):
1.volatile关键字的作用以及能不能实现线程安全
2.聊自己简历上面的项目
3.Hbase插入和查看数据的实现机制(本人项目设计内容)
4.spark和Hadoop的区别
5.线程安全以及可重入
6.自增自减运算是否线程安全,为什么?
7.实现线程同步有哪几种方法?
8.synchronized和lock的区别?
9.hashmap和map的区别
10.hashmap和treemap的区别
11.jvm内存管理和GC管理
12.依赖注入和控制反转
13.在线写代码:二叉树反转递归和非递归实现
然后便来到了九月份,秋招正式的拉开了序幕,第一仗就是广联达,本来只是拿来练手的,结果当时一个做C++的面试官面的我Java,两人就递归内存溢出和Java垃圾回收进行了激烈的争辩,最终导致面试被刷,回来和朋友交流了一下,我的观点并没有问题,我还能说什么,经验总结不能和面试官硬刚,即使你是对的,此时也发觉之前准备的东西已经开始模糊,此时便开始了新一轮的知识巩固和复习,与此同时开始了简历的海投,以及各种各样的线上和线下笔试(此时要明白小伙伴的重要作用,你不是一个人在战斗),之后收到了远景能源和搜狗的面试通知,职位都是大数据开发,远景能源问的比较基础,包括数据结构,操作系统,计算机网络,以及问了一个算法实现问题,两个数据数组,设计一个算法找出两个数组中的相同元素,后来顺利进入二面,只是问了一下项目,然后就没有然后了。下面重点来了,搜狗面试,第一个问题我就懵逼了,这是要让我干啥,之后的问题也是让我脑袋很大,三个问题一个也没有回答出来,而且面试官顺带告知我我对大数据开发这个职位理解有偏差,从面试的酒店出来我便开始怀疑人生了。后来我才知道当时我们班也有一个女生去面试了,出来直接哭了,没错,哭了,也是被面试官打击了!
搜狗面经(大数据开发):
1.1000亿条数据如何进行全排序,在纸上写出算法工程
2.shell脚本编程实现合并两个文件,在纸上写出代码
3.spark RDD执行不完的原因
之后我便开始搜索各种大数据开发职位的面经,开始针对性突破。之间还有一个插曲,之前我是把宝压在华为上的,之前做了华为的笔试,按照他们的说法,三道编程题只要做对一道就有面试机会,而我做对了一道半,第一题全部通过,第二题通过了百分之六十的测试用例,但是却并没有通知我面试,我当时特别沮丧,后来才知道,由于我当时是和同学一块做的,提交的代码也没有改动,导致重复率太高被认定为作弊,血泪的教训希望各位不要重蹈我的覆辙。之后又有几家面试,包括海能达,科大讯飞以及凡普金科,其中海能达没有笔试,直接面试,一面让手写了个快排,问了点Java基础的东西,接口和抽象类的区别以及Java的三大特征,之后问了点项目,后来由于二面和科大讯飞时间冲突就放弃了海能达,后来才知道,二面只是问些家庭情况,而且最后给的待遇很不错,只能说跟我无缘了。这里先说凡普金科,北京的一家金融互联网公司,数据分析岗,一面主要问了下项目以及职业规划,二面问了点数据库的知识,左连接右链接,以及一些数据分析流程的问题,还有就是些数学概率题以及一道智力题,之后面试官跟我说“不错,我觉得你挺好”,但是然后就没有然后了。接下来重点就是科大讯飞了,面试了将近一个半小时,把我的简历从头到尾在从尾到头问了三遍,最后搞得我都有点崩溃了,但是由于我的知识盲点,我还是被刷了,当时的心情真的是沮丧加沮丧啊!
科大讯飞面经(大数据开发):
1.快排和堆排序如何实现,快排的时间复杂度是多少以及(nlogn)代表什么?
2.平衡二叉树和红黑树的区别
3.操作系统作业如何调度以及哪些算法
4.计算机网络:三次握手,四次挥手,以及滑动窗口和拥塞控制
5.Java基础 int a= 128 ,int b = 128, a==b 返回true还是false ;string
S1 = 'abc'
S2 = 'abc'
S1 == S2 返回true还是false(我就死在了这里)
6.问简历上的项目,每一个都问,问的特别详细(一定要对自己的东西有足够的了解,并且能够清楚表述)
7.Hbase插入数据的过程
8.spark有哪些算子
9.Linux相关知识(没有回答上来)
至此,已到九月中旬,请的假已经到达期限,必须要回公司实习,而此时并没有哪怕一个确定的offer,心情那个糟糕啊!压力那个山大啊!但是由于之前做的笔试,也在陆续的收到面试通知,其中有分量的有京东和美团。但中间还有两家公司,浪潮集团和诺瓦科技,浪潮面了两面,最后到谈薪资由于要的太高没有收到offer,-_-||,诺瓦科技还是想讲一讲,一面问了点Java基础,几种修饰符的区别,进程的通信方式,以及Java的三大特征,还有就是介绍了一下项目,问的也比较详细,结果面试官表示比较满意,起立握手,目送离开,感觉很靠谱,二面聊了一下职业规划,还有一些智力问题,设立一些实际工作场景,让你设计解决方案,最后起立握手,目送离开,第二天收到offer,由于此时京东的二面已经通过就把这个offer拒了,之后去霸面了去哪儿的软件开发,一面被刷,惭愧,上来问项目,然后给个题目让你手撕代码,我的题目是让一棵树的兄弟节点相连接,第二道题目是给了一幅图,图中有障碍物,如何找a,b两点的最短路径。
京东面经(大数据开发):
1.你的研究方向以及研究内容
2.你对spark和Hadoop的理解
3.union和union all的区别
4.hive的架构
5.数据倾斜问题
6.京东和某宝相比有什么优势
7.你的职业规划以及薪资期待
1.你对机器学习有什么了解
2.hive和Hbase的区别以及各自的优势劣势
3.union和union all的区别
4.数据倾斜问题(比较幸运,问到了一面的问题)
5.jvm优化以及jvm内存模型和垃圾回收机制以及垃圾回收算法
6.项目(问的很细)
7.京东和某宝相比有什么优势(hr面也被问到,难道是必问的问题)
8.自己的职业规划以及现在的研究方向(hr面也被问到)
当时得到京东的二面结果后,着实兴奋了一把,高悬的心也算是暂时放松了下来,在这之后还参加的美团的机器学习算法岗位的面试,上去尽力往自己的项目上扯,果然面试官很敢兴趣,聊了很久,我以为差不多了,面试官回过神来,开始让我手撕代码,开始问我机器学习算法的问题,我就只能GG了,可恨当时报错了岗位。九月二十八号下午京东hr电话面试,结束之后被告知十月二十日之前会告知结果,就开始了十一长假,由于此时依旧没有满意的offer,便开始了忐忑的十一假期,但也就是在京东电话面试的时候做了ofo的线上笔试,十一假期结束,收到了ofo的面试通知,虽然有点波折,但是最后收到了ofo的offer,但是在此之前收到了京东的拒信,当时那个沮丧啊!
ofo面经(大数据开发):
1.10G的数据,200M内存,设计一个解决方案,将数据中的内容进行全排序,并取出top 10(搜狗的经验排上用场)
2.手写快排
3.两个无序数组,排序输出,解释思路
4.hashmap底层如何实现
5.设计一个场景,写sql语句
6.之后又是职业规划问题
二面(电话面,在线编程):
1.三次握手,四次挥手
2.hashmap底层实现,以及存储时间复杂度
3.在线编程,链表相邻两元素交换位置
4.在线编程,动态规划
在ofo面试期间也面试了几家公司,有上海微盟、新华三、国家互联网应急中心、华数和亚信,其中只有因为华数要的太高没有拿到offer,这几家公司里上海微盟的面试有点难度,但是要求不算高,新华三和亚信纯粹扯项目,国家互联网应急中心特别高大上的国家战略部署的机构,二面是去北京公司总部面的,很气派,面试的内容也偏简单,会问一些基础的东西,但在和ofo的对比中选择了ofo。
找工作真的是一半运气一半实力,不要太过于纠结,我准备的有点仓促,建议看到此文的朋友能尽快准备,我知道的我同学有从三月份就开始准备的,你的付出肯定会和你的收入成正比的,最后祝愿大家都能找到满意的工作。
注:本人秋招期间整理了很多软件开发以及大数据开发相关的知识,如有需要(有偿)可以加QQ今天面美团遇到的,JVM调优问题,求解答。_技术交流_牛客网
今天面美团遇到的,JVM调优问题,求解答。
一个java进程在运行,通过操作系统监控到,它的内存会有规律的变的很大然后又降下来,这是什么原因?怎么解决?
扫描二维码,关注牛客网
下载牛客APP,随时随地刷题
京ICP备号-4
扫一扫,把题目装进口袋Linux与JVM的内存关系分析引言
在一些物理内存为8g的服务器上,主要运行一个Java服务,系统内存分配如下:Java服务的JVM堆大小设置为6g,一个监控进程占用大约600m,Linux自身使用大约800m。从表面上,物理内存应该是足够使用的;但实际运行的情况是,会发生大量使用SWAP(说明物理内存不够使用了),如下图所示。同时,由于SWAP和GC同时发生会致使JVM严重卡顿,所以我们要追问:内存究竟去哪儿了?
要分析这个问题,理解JVM和操作系统之间的内存关系非常重要。接下来主要就Linux与JVM之间的内存关系进行一些分析。
一、Linux与进程内存模型
JVM以一个进程(Process)的身份运行在Linux系统上,了解Linux与进程的内存关系,是理解JVM与Linux内存的关系的基础。下图给出了硬件、系统、进程三个层面的内存之间的概要关系。
从硬件上看,Linux系统的内存空间由两个部分构成:物理内存和SWAP(位于磁盘)。物理内存是Linux活动时使用的主要内存区域;当物理内存不够使用时,Linux会把一部分暂时不用的内存数据放到磁盘上的SWAP中去,以便腾出更多的可用内存空间;而当需要使用位于SWAP的数据时,必须先将其换回到内存中。
从Linux系统上看,除了引导系统的BIN区,整个内存空间主要被分成两个部分:内核内存(Kernel space)、用户内存(User space)。内核内存是Linux自身使用的内存空间,主要提供给程序调度、内存分配、连接硬件资源等程序逻辑使用。用户内存是提供给各个进程主要空间,Linux给各个进程提供相同的虚拟内存空间;这使得进程之间相互独立,互不干扰。实现的方法是采用虚拟内存技术:给每一个进程一定虚拟内存空间,而只有当虚拟内存实际被使用时,才分配物理内存。如下图所示,对于32的Linux系统来说,一般将0~3G的虚拟内存空间分配做为用户空间,将3~4G的虚拟内存空间分配为内核空间;64位系统的划分情况是类似的。
从进程的角度来看,进程能直接访问的用户内存(虚拟内存空间)被划分为5个部分:代码区、数据区、堆区、栈区、未使用区。代码区中存放应用程序的机器代码,运行过程中代码不能被修改,具有只读和固定大小的特点。数据区中存放了应用程序中的全局数据,静态数据和一些常量字符串等,其大小也是固定的。堆是运行时程序动态申请的空间,属于程序运行时直接申请、释放的内存资源。栈区用来存放函数的传入参数、临时变量,以及返回地址等数据。未使用区是分配新内存空间的预备区域。
二、进程与JVM内存模型
JVM本质就是一个进程,因此其内存模型也有进程的一般特点。但是,JVM又不是一个普通的进程,其在内存模型上有许多崭新的特点,主要原因有两个:1.JVM将许多本来属于操作系统管理范畴的东西,移植到了JVM内部,目的在于减少系统调用的次数;2. Java NIO,目的在于减少用于读写IO的系统调用的开销。 JVM进程与普通进程内存模型比较如下图:
需要说明的是,这个模型的并不是JVM内存使用的精确模型,更侧重于从操作系统的角度而省略了一些JVM的内部细节(尽管也很重要)。下面从用户内存和内核内存两个方面讲解JVM进程的内存特点。
1.用户内存
上图特别强调了JVM进程模型的代码区和数据区指的是JVM自身的,而非Java程序的。普通进程栈区,在JVM一般仅仅用做线程栈。JVM的堆区和普通进程的差别是最大的,下面具体详细说明:
首先是永久代。永久代本质上是Java程序的代码区和数据区。Java程序中类(class),会被加载到整个区域的不同数据结构中去,包括常量池、域、方法数据、方法体、构造函数、以及类中的专用方法、实例初始化、接口初始化等。这个区域对于操作系统来说,是堆的一个部分;而对于Java程序来说,这是容纳程序本身及静态资源的空间,使得JVM能够解释执行Java程序。
其次是新生代和老年代。新生代和老年代才是Java程序真正使用的堆空间,主要用于内存对象的存储;但是其管理方式和普通进程有本质的区别。普通进程在运行时给内存对象分配空间时,比如C++执行new操作时,会触发一次分配内存空间的系统调用,由操作系统的线程根据对象的大小分配好空间后返回;同时,程序释放对象时,比如C++执行delete操作时,也会触发一次系统调用,通知操作系统对象所占用的空间已经可以回收。JVM对内存的使用和一般进程不同。JVM向操作系统申请一整段内存区域(具体大小可以在JVM参数调节)作为Java程序的堆(分为新生代和老年代);当Java程序申请内存空间,比如执行new操作,JVM将在这段空间中按所需大小分配给Java程序,并且Java程序不负责通知JVM何时可以释放这个对象的空间,垃圾对象内存空间的回收由JVM进行。
JVM的内存管理方式的优点是显而易见的,包括:第一,减少系统调用的次数,JVM在给Java程序分配内存空间时不需要操作系统干预,仅仅在Java堆大小变化时需要向操作系统申请内存或通知回收,而普通程序每次内存空间的分配回收都需要系统调用参与;第二,减少内存泄漏,普通程序没有(或者没有及时)通知操作系统内存空间的释放是内存泄漏的重要原因之一,而由JVM统一管理,可以避免程序员带来的内存泄漏问题。
最后是未使用区,未使用区是分配新内存空间的预备区域。对于普通进程来说,这个区域被可用于堆和栈空间的申请及释放,每次堆内存分配都会使用这个区域,因此大小变动频繁;对于JVM进程来说,调整堆大小及线程栈时会使用该区域,而堆大小一般较少调整,因此大小相对稳定。操作系统会动态调整这个区域的大小,并且这个区域通常并没有被分配实际的物理内存,只是允许进程在这个区域申请堆或栈空间。
2.内核内存
应用程序通常不直接和内核内存打交道,内核内存由操作系统进行管理和使用;不过随着Linux对性能的关注及改进,一些新的特性使得应用程序可以使用内核内存,或者是映射到内核空间。Java NIO正是在这种背景下诞生的,其充分利用了Linux系统的新特性,提升了Java程序的IO性能。
上图给出了Java NIO使用的内核内存在linux系统中的分布情况。nio buffer主要包括:nio使用各种channel时所使用的ByteBuffer、Java程序主动使用ByteBuffer.allocateDirector申请分配的Buffer。而在PageCache里面,nio使用的内存主要包括:FileChannel.map方式打开文件占用mapped、FileChannel.transferTo和FileChannel.transferFrom所需要的Cache(图中标示 nio file)。
通过JMX可以监控到NIO Buffer和 mapped 的使用情况,如下图所示。不过,FileChannel的实现是通过系统调用使用原生的PageCache,过程对于Java是透明的,无法监控到这部分内存的使用大小。
Linux和Java NIO在内核内存上开辟空间给程序使用,主要是减少不要的复制,以减少IO操作系统调用的开销。例如,将磁盘文件的数据发送网卡,使用普通方法和NIO时,数据流动比较下图所示:
将数据在内核内存和用户内存之间拷贝是比较消耗资源和时间的事情,而从上图我们可以看到,通过NIO的方式减少了2次内核内存和用户内存之间的数据拷贝。这是Java NIO高性能的重要机制之一(另一个是异步非阻塞)。
从上面可以看出,内核内存对于Java程序性能也非常重要,因此,在划分系统内存使用时候,一定要给内核留出一定可用空间。
三、案例分析
1.内存分配问题
通过上面的分析,省略比较小的区域,可以总结JVM占用的内存:JVM内存 ≈ Java永久代 + Java堆(新生代和老年代)
+ 线程栈+ Java NIO
回到文章开头提出的问题,原来的内存分配是:6g(java堆) + 600m(监控) + 800m(系统),剩余大约600m内存未分配。
现在分析这600m内存的分配情况:(1)Linux保留大约200m,这部分是Linux正常运行的需要,(2)Java服务的线程数量是160个,JVM默认的线程栈大小是1m,因此使用160m内存,(3)Java NIO buffer,通过JMX查到最多占用了200m,(4)Java服务使用NIO大量读写文件,需要使用PageCache,正如前面分析,这个暂时不好定量估算大小。前三项加起来已经560m,因此可以断定Linux物理内存不够使用。
细心的人会发现,引言中给出两个服务器,一个SWAP最多占用了2.16g,另外一个SWAP最多占用了871m;但是,似乎我们的内存缺口没有那么大。事实上,这是由于SWAP和GC同时进行造成的,从下图可以看到,SWAP的使用和长时间的GC在同一时刻发生。
SWAP和GC同时发生会导致GC时间很长,JVM严重卡顿,极端的情况下会导致服务崩溃。原因如下:JVM进行GC时,时需要对相应堆分区的已用内存进行遍历;假如GC的时候,有堆的一部分内容被交换到SWAP中,遍历到这部分的时候就需要将其交换回内存,同时由于内存空间不足,就需要把内存中堆的另外一部分换到SWAP中去;于是在遍历堆分区的过程中,(极端情况下)会把整个堆分区轮流往SWAP写一遍。Linux对SWAP的回收是滞后的,我们就会看到大量SWAP占用。
上述问题,可以通过减少堆大小,或者增加物理内存解决。
因此,我们得出一个结论:部署Java服务的Linux系统,在内存分配上,需要避免SWAP的使用;具体如何分配需要综合考虑不同场景下JVM对Java永久代 、Java堆(新生代和老年代)、线程栈、Java NIO所使用内存的需求。
2.内存泄漏问题
另一个案例是,8g内存的服务器,Linux使用800m,监控进程使用600m,堆大小设置4g;系统可用内存有2.5g左右,但是也发生了大量的SWAP占用。分析这个问题如下:(1)在这个场景中, Java永久代 、Java堆(新生代和老年代)、线程栈所用内存基本是固定的,因此,占用内存过多的原因就定位在Java NIO上。(2)根据前面的模型,Java NIO使用的内存主要分布在Linux内核内存的System区和PageCache区。查看监控的记录,如下图,我们可以看到发生SWAP之前,也就是物理内存不够使用的时候,PageCache急剧缩小。因此,可以定位在System区的Java NIO Buffer发生内存泄漏。
(3)由于NIO的DirectByteBuffer需要在GC的后期被回收,因此连续申请DirectByteBuffer的程序,通常需要调用System.gc(),避免长时间不发生FullGC导致引用在old区的DirectByteBuffer内存泄漏。分析到此,可以推断有两种可能的原因:第一,Java程序没有在必要的时候调用System.gc();第二,System.gc()被禁用。(4)最后是要排查JVM启动参数和Java程序的DirectByteBuffer使用情况。在本例中,查看JVM启动参数,发现启用了-XX:+DisableExplicitGC导致System.gc()被禁用。
本文详细分析了Linux与JVM的内存关系,比较了一般进程与JVM进程使用内存的异同点,理解这些特性将对Linux系统内存分配、JVM调优、Java程序优化有帮助。限于篇幅关系仅仅列举两个案例,希望起到抛砖引玉的作用。
《深入分析Java Web技术内幕》
回答“思考题”、发现文章有错误、对内容有疑问,都可以来微信公众号(美团点评技术团队)后台给我们留言。我们每周会挑选出一位“优秀回答者”,赠送一份精美的小礼品。快来扫码关注我们吧!关注我们扫码关注技术博客微信搜索 "美团技术团队"从实际案例聊聊Java应用的GC优化当Java程序性能达不到既定目标,且其他优化手段都已经穷尽时,通常需要调整垃圾回收器来进一步提高性能,称为GC优化。但GC算法复杂,影响GC性能的参数众多,且参数调整又依赖于应用各自的特点,这些因素很大程度上增加了GC优化的难度。即便如此,GC调优也不是无章可循,仍然有一些通用的思考方法。本篇会介绍这些通用的GC优化策略和相关实践案例,主要包括如下内容:
优化前准备: 简单回顾JVM相关知识、介绍GC优化的一些通用策略。优化方法: 介绍调优的一般流程:明确优化目标→优化→跟踪优化结果。优化案例: 简述笔者所在团队遇到的GC问题以及优化方案。
一、优化前的准备
GC优化需知
为了更好地理解本篇所介绍的内容,你需要了解如下内容。
GC相关基础知识,包括但不限于:a) GC工作原理。b) 理解新生代、老年代、晋升等术语含义。c) 可以看懂GC日志。
GC优化不能解决一切性能问题,它是最后的调优手段。
如果对第一点中提及的知识点不是很熟悉,可以先阅读小结-JVM基础回顾;如果已经很熟悉,可以跳过该节直接往下阅读。
JVM基础回顾
JVM内存结构
简单介绍一下JVM内存结构和常见的垃圾回收器。
当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。
Hotspot VM将内存划分为不同的物理区,就是“分代”思想的体现。如图所示,JVM内存主要由新生代、老年代、永久代构成。
① 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。
新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。
② 老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。
③ 永久代(Perm Generation):主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。
常见垃圾回收器
不同的垃圾回收器,适用于不同的场景。常用的垃圾回收器:
串行(Serial)回收器是单线程的一个回收器,简单、易实现、效率高。
并行(ParNew)回收器是Serial的多线程版,可以充分的利用CPU资源,减少回收的时间。
吞吐量优先(Parallel Scavenge)回收器,侧重于吞吐量的控制。
并发标记清除(CMS,Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标的回收器,该回收器是基于“标记-清除”算法实现的。
每一种回收器的日志格式都是由其自身的实现决定的,换而言之,每种回收器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个回收器的日志都维持一定的共性。 中简单介绍了这些共性。
参数基本策略
各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。
活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下(见参考文献1):
3-4 倍活跃数据的大小
1-1.5 活跃数据的大小
2-3 倍活跃数据的大小
1.2-1.5 倍Full GC后的永久代空间占用
例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:
总堆:1200MB = 300MB × 4新生代:450MB = 300MB × 1.5老年代: 750MB = 1200MB - 450MB*
这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。
二、优化步骤
GC优化一般步骤可以概括为:确定目标、优化参数、验收结果。
明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:
高可用,可用性达到几个9。
低延迟,请求必须多少毫秒内完成响应。
高吞吐,每秒完成多少次事务。
明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。
由于笔者所在团队主要关注高可用和低延迟两项指标,所以接下来分析,如何量化GC时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前GC情况对服务的影响,也能评估出GC优化后对响应时间的收益,这两点对于低延迟服务很重要。
举例:假设单位时间T内发生一次持续25ms的GC,接口平均响应时间为50ms,且请求均匀到达,根据下图所示:
那么有(50ms+25ms)/T比例的请求会受GC影响,其中GC前的50ms内到达的请求都会增加25ms,GC期间的25ms内到达的请求,会增加0-25ms不等,如果时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T 。可见无论降低单次GC时间还是降低GC次数N都可以有效减少GC对响应时间的影响。
通过收集GC信息,结合系统需求,确定优化方案,例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等。
进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上GC的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。
验收优化结果
将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。
接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为ParNew+CMS,CMS失败时Serial Old替补)。
三、GC优化案例
案例一 Major GC和Minor GC频繁
服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。
由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。
(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。
优化目标:降低TP99、TP90时间。
首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。
这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?根据上面公式,如果单次Minor GC时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次Minor GC时间主要受哪些因素影响?是否和新生代大小存在线性关系?首先,单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)如下图。(注:这里为了简化问题,我们认为T1只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)
扩容前:新生代容量为R ,假设对象A的存活时间为750ms,Minor GC间隔500ms,那么本次Minor GC时间= T1(扫描新生代R)+T2(复制对象A到S)。
扩容后:新生代容量为2R ,对象A的生命周期为750ms,那么Minor GC间隔增加为1000ms,此时Minor GC对象A已不再存活,不需要把它复制到Survivor区,那么本次GC时间 = 2 × T1(扫描新生代R),没有T2复制时间。
可见,扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:
通过上图GC日志中两处红色框标记内容可知:
new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。
Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。
由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。
通过扩容新生代为为原来的三倍,单次Minor GC时间增加小于5ms,频率下降了60%,服务响应时间TP90,TP99都下降了10ms+,服务可用性得到提升。
如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
关于上文中提到晋升年龄阈值为2,很多同学有疑问,为什么设置了MaxTenuringThreshold=15,对象仍然仅经历2次Minor GC,就晋升到老年代?这里涉及到“动态年龄计算”的概念。
动态年龄计算:Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor区 = 64M,desired survivor = 32M,此时Survivor区中age&=2的对象累计大小为41M,41M大于32M,所以晋升年龄阈值被设置为2,下次Minor GC时将年龄超过2的对象被晋升到老年代。
JVM引入动态年龄计算,主要基于如下两点考虑:
如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。
相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。
总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。
请求高峰期发生GC,导致服务可用性下降
GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。
Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。
Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
并发清理,进行并发的垃圾清理。
可见,Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?结论是不可行,原因如下:如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。
灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。分析GC日志可以得出同样的规律,Remark耗时&500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。
新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。
除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。
对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。
经过增加CMSScavengeBeforeRemark参数,单次执行时间&200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。
通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。
案例中只涉及老年代GC,其实新生代GC存在同样的问题,即老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。
JVM是如何避免Minor GC时扫描全堆的?经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:
卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。
总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。
发生Stop-The-World的GC
GC日志如下图(在GC日志中,Full GC是用来说明这次垃圾回收的停顿类型,代表STW类型的GC,并不特指老年代GC),根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性。
首先,什么时候可能会触发STW的Full GC呢?
Perm空间不足;
CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间;
主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。
然后,我们来逐一分析一下:
排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。
排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。
排除原因4:因为当时没有相关命令执行。
锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的。
找到原因后解决方法有两种:
通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。
CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收。
由于该服务没有生成大量动态类,回收Perm区收益不大,所以我们采用方案1,启动时将Perm区大小固定,避免进行动态扩容。
调整参数后,服务不再有Perm区扩容导致的STW GC发生。
对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致(JDK8开始,Perm区完全消失,转而使用元空间。而元空间是直接存在内存中,不在JVM中),Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。
结合上述GC优化案例做个总结:
首先再次声明,在进行GC优化之前,需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用,通过GC优化令其性能达到一个质的飞跃。
其次,通过上述分析,可以看出虚拟机内部已有很多优化来保证应用的稳定运行,所以不要为了调优而调优,不当的调优可能适得其反。
最后,GC优化是一个系统而复杂的工作,没有万能的调优策略可以满足所有的性能指标。GC优化必须建立在我们深入理解各种垃圾回收器的基础上,才能有事半功倍的效果。
本文中案例均来北京业务安全中心(也称风控)对接服务的实践经验。同时感谢风控的小伙伴们,是他们专业负责的审阅,才让这篇文章更加完善。对于本文中涉及到的内容,欢迎大家指正和补充。
录录,2016年加入美团点评,主要负责北京业务安全中心对接服务的后台研发工作。
美团点评北京业务安全中心致力于建设公司平台级业务安全基础设施、保障业务安全运行,工作涵盖交易秩序、帐号安全、爬虫防控等风控方向,基于千万级订单、千万级日活跃用户、亿级存量用户进行数据挖掘,实时处理每日百亿级流量,热诚期待各位开发、算法、策略产品经理人才加入。联系邮箱:tangyizhe#meituan.com。
Scott O. Java Performance:The Definitive Guide. O'Reilly, 2014.
周志明,深入理解Java虚拟机[M],机械工业出版社,2013.
回答“思考题”、发现文章有错误、对内容有疑问,都可以来微信公众号(美团点评技术团队)后台给我们留言。我们每周会挑选出一位“优秀回答者”,赠送一份精美的小礼品。快来扫码关注我们吧!关注我们扫码关注技术博客微信搜索 "美团技术团队"

我要回帖

更多关于 美团出问题 的文章

 

随机推荐