之所以想写这篇文章,是因为YGC过程对我们来说太过于黑盒,如果对YGC过程不是很熟悉,这类问题基本很难定位,我们就算开了GC日志,也最多能看到类似下面的日志
1
|
|
只知道耗了多长时间,但是具体耗在了哪个阶段,是基本看不出来的,所以要么就是靠经验来定位,要么就是对代码相当熟悉,脑袋里过一遍整个过程,看哪个阶段最可能,今天要讲的这个大家可以当做今后排查这类问题的一个经验来使,这个当然不是唯一导致YGC过长的一个原因,但却是最近我帮忙定位碰到的发生相对来说比较多的一个场景
具体的定位是通过在JVM代码里进行了日志埋点确定的,这个问题其实最早的时候,是帮助毕玄毕大师定位到这块的问题,他也在公众号里对这个问题写了相关的一篇文章YGC越来越慢,为什么,大家可以关注下毕大师的公众号HelloJava
,经常会发一些在公司碰到的诡异问题的排查,相信会让你涨姿势的,当然如果你还没有关注我的公众号你假笨
,欢迎关注下,后续会时不时写点或许正巧你感兴趣的JVM系列文章。
先上一个demo,来描述下问题的情况,代码很简单,就是不断创建UUID,其实就是一个字符串,并将这个字符串调用下intern方法
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
我们使用的JVM参数如下:
1
|
|
这里特意将新生代设置比较小,老生代设置比较大,让代码在执行过程中更容易突出问题来,大量做ygc,期间不做CMS GC,于是我们得到的输出结果类似下面的
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
|
有没有发现YGC不断发生,并且发生的时间不断在增长,从10ms慢慢增长到了100ms,甚至还会继续涨下去
从上面的demo我们能挖掘到的可能就是intern这个方法了,那我们先来了解下intern方法的实现,这是String提供的一个方法,jvm提供这个方法的目的是希望对于某个同名字符串使用非常多的场景,在jvm里只保留一份,比如我们不断new String(“a”),其实在java heap里会有多个String的对象,并且值都是a,如果我们只希望内存里只保留一个a,或者希望我接下来用到的地方都返回同一个a,那就可以用String.intern这个方法了,用法如下
1 2 3 |
|
这样b和a都是指向内存里的同一个String对象,那JVM里到底怎么做到的呢?
我们看到intern这个方法其实是一个native方法,具体对应到JVM里的逻辑是
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 32 33 34 35 36 37 38 39 40 41 |
|
也就是说是其实在JVM里存在一个叫做StringTable的数据结构,这个数据结构是一个Hashtable,在我们调用String.intern的时候其实就是先去这个StringTable里查找是否存在一个同名的项,如果存在就直接返回对应的对象,否则就往这个table里插入一项,指向这个String对象,那么再下次通过intern再来访问同名的String对象的时候,就会返回上次插入的这一项指向的String对象
至此大家应该知道其原理了,另外我这里还想说个题外话,记得几年前tomcat里爆发的一个HashMap导致的hash碰撞的问题,这里其实也是一个Hashtable,所以也还是存在类似的风险,不过JVM里提供一个参数专门来控制这个table的size,-XX:StringTableSize
,这个参数的默认值如下
1 2 |
|
另外JVM还会根据hash碰撞的情况来决定是否做rehash,比如你从这个StringTable里查找某个字符串是否存在,如果对其对应的桶挨个遍历,超过了100个还是没有找到对应的同名的项,那就会设置一个flag,让下次进入到safepoint的时候做一次rehash动作,尽量减少碰撞的发生,但是当恶化到一定程度的时候,其实也没啥办法啦,因为你的数据量实在太大,桶子数就那么多,那每个桶再怎么均匀也会带着一个很长的链表,所以此时我们通过修改上面的StringTableSize将桶数变大,可能会一定程度上缓解,但是如果是java代码的问题导致泄露,那就只能定位到具体的代码进行改造了。
YGC的过程我不打算再这篇文章里细说,因为我希望尽量保持每篇文章的内容不过于臃肿,有机会可以单独写篇文章来介绍,我这里将列出ygc过程里StringTable这块的具体代码
1 2 3 4 5 6 7 8 9 |
|
因为YGC过程不涉及到对perm做回收,因此collecting_perm_gen
是false,而JavaObjectsInPerm
默认情况下也是false,表示String.intern返回的字符串是不是在perm里分配,如果是false,表示是在heap里分配的,因此StringTable指向的字符串是在heap里分配的,所以ygc过程需要对StringTable做扫描,以保证处于新生代的String代码不会被回收掉
至此大家应该明白了为什么YGC过程会对StringTable扫描
有了这一层意思之后,YGC的时间长短和扫描StringTable有关也可以理解了,设想一下如果StringTable非常庞大,那是不是意味着YGC过程扫描的时间也会变长呢
这个问题其实是我写这文章的时候突然问自己的一个问题,于是稍微想了下来跟大家解释下,因为大家也可能会问这么个问题
要回答这个问题我首先得问你们的机器到底有多少个核,如果核数很多的话,其实影响不是很大,因为这个扫描的过程是单个GC线程来做的,所以最多消耗一个核,因此看起来对于核数很多的情况,基本不算什么
YGC过程不会对StringTable做清理,这也就是我们demo里的情况会让Stringtable越来越大,因为到目前为止还只看到YGC过程,但是在Full GC或者CMS GC过程会对StringTable做清理,具体验证很简单,执行下jmap -histo:live <pid>
,你将会发现YGC的时候又降下去了
利用午饭前的一点时间写下 2016/11/06 12:00~12:40
]]>本文要说的内容是今天公司有个线上系统踩了一个坑,并且貌似还造成了一定的影响,后来系统相关的人定位到了是java.lang.Class.getMethods
返回的顺序可能不同机器不一样,有问题的机器和没问题的机器这个返回的方法列表是不一样的,后面他们就来找到我求证是否jdk里有这潜规则
本来这个问题简单一句话就可以说明白,所以在晚上推送的消息里也将这个事实告诉了大家,大家知道就好,以后不要再掉到坑里去了,但是这个要细说起来其实也值得一说,于是在消息就附加了征求大家意见的内容,看大家是否有兴趣或者是否踩到过此坑,没想到有这么多人响应,表示对这个话题很感兴趣,并且总结了大家问得最多的两个问题是
那这篇文章主要就针对这两个问题展开说一下,另外以后针对此类可写可不写的文章先征求下大家的意见再来写可能效果会更好点,一来可以回答大家的一些疑问(当然有些问题我也可能回答不上来,不过我尽量去通读代码回答好大家),二来希望对我公众号里的文章继续保持不求最多,只求最精
的态度。
为了不辜负大家的热情,我连夜赶写了这篇文章,如果大家觉得我写的这些文章对大家有帮助,希望您能将文章分享出去,同时将我的公众号你假笨
推荐给您身边更多的技术人,能帮助到更多的人去了解更多的细节,在下在此先谢过。
如果大家看过或者实现过序列化反序列化的代码,这个问题就不难回答了,今天碰到的这个问题其实是发生在大家可能最常用的fastjson
库里的,所以如果大家在使用这个库,请务必检查下你的代码,以免踩到此坑
大家都知道当我们序列化好一个对象之后,要反序列回来,那问题就来了,就拿这个json序列化来说吧,我们要将对象序列化成json串,那意味着我们要先取出这个对象的属性,然后写成键值对的形式,那取值就意味着我们要遵循java bean的规范通过getter方法来取,那其实getter方法有两种,一种是boolean类型的,一种是其他类型的,如果是boolean类型的,那我们通常是isXXX()
这样的方法,如果是其他类型的,一般是getXXX()
这样的方法。那假如说我们的类里针对某个属性a,同时存在两个方法isA()
和getA()
,那究竟我们会调用哪个来取值?这个就取决于具体的序列化框架实现了,比如导致我们这篇文章诞生的fastjson
,就是利用我们这篇文章的主角java.lang.Class.getMethods
返回的数组,然后挨个遍历,先找到哪个就是哪个,如果我们的这个数组正好因为jvm本身实现没有保证顺序,那么可能先找到isA()
,也可能先找到getA()
,如果两个方法都是返回a这个属性其实问题也不大,假如正好是这两个方法返回不同的内容呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
如果是上面的内容,那可能就会悲剧了,如果选了isA()
,那其实是返回一个boolean类型的,将这个boolean写入到json串里,如果是选了getA()
,那就是将A这个类型的对象写到json串里
在完成了序列化过程之后,需要将这个字符串进行反序列化了,于是就会去找json串里对应字段的setter方法,比如上面的setA(A a)
,假如我们之前选了isA()
序列化好内容,那我们此时的值是一个boolean值false,那就无法通过setA
来赋值还原对象了。
相信大家看完我上面的描述,知道这个问题所在了,要避免类似的问题,方案其实也挺多,比如对方法进行先排序,又比如说优先使用isXXX()
方法,不过这种需要和开发者达成共识,和setter要对应得起来
JDK层面的代码我就暂时不说了,大家都能看到代码,从java.lang.Class.getMethods
一层层走下去,相信大家细心点还是能抓住整个脉络的,我这里主要想说大家可能比较难看到的一些实现,比如JVM里的具体实现
正常情况下大家跟代码能跟到调用了java.lang.Class.getDeclaredMethods0
这个native方法,其具体实现如下
1 2 3 4 5 6 7 8 |
|
其主要调用了get_class_declared_methods_helper
方法
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
|
从上面的k->method_with_idnum(idnums->at(i))
,我们基本知道方法主要是从klass里来的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
因此InstanceKlass里的methods是关键,而这个methods的创建是在类解析的时候发生的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
上面的parse_methods
就是从class文件里挨个解析出method,并存到_methods字段里,但是接下来做了一次sort_methods
的动作,这个动作会对解析出来的方法做排序
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
|
从上面的Method::sort_methods
可以看出其实具体的排序算法是method_comparator
1 2 3 4 5 |
|
比较的是两个方法的名字,但是这个名字不是一个字符串,而是一个Symbol对象,每个类或者方法名字都会对应一个Symbol对象,在这个名字第一次使用的时候构建,并且不是在java heap里分配的,比如jdk7里就是在c heap里通过malloc来分配的,jdk8里会在metaspace里分配
1 2 3 4 5 6 7 8 |
|
从上面的fast_compare方法知道,其实对比的是地址的大小,因为Symbol对象是通过malloc来分配的,因此新分配的Symbol对象的地址就不一定比后分配的Symbol对象地址小,也不一定大,因为期间存在内存free的动作,那地址是不会一直线性变化的,之所以不按照字母排序,主要还是为了速度考虑,根据地址排序是最快的。
综上所述,一个类里的方法经过排序之后,顺序可能会不一样,取决于方法名对应的Symbol对象的地址的先后顺序
其实这个问题很简单,就是为了快速找到方法呢,当我们要找某个名字的方法的时候,根据对应的Symbol对象,能根据对象的地址使用二分排序的算法快速定位到具体的方法。
]]>metaspace,顾名思义,元数据空间,专门用来存元数据的,它是jdk8里特有的数据结构用来替代perm,这块空间很有自己的特点,前段时间公司这块的问题太多了,主要是因为升级了中间件所致,看到大家讨论来讨论去,看得出很多人对metaspace还是模棱两可,不是很了解它,因此我觉得有必要写篇文章来介绍一下它,解开它神秘的面纱,当我们再次碰到它的相关问题的时候不会再感到束手无策。
通过这篇文章,你将可以了解到
metaspace的由来民间已有很多传说,不过我这里只谈我自己的理解,因为我不是oracle参与这块的开发者,所以对其真正的由来不怎么了解。
我们都知道jdk8之前有perm这一整块内存来存klass等信息,我们的参数里也必不可少地会配置-XX:PermSize以及-XX:MaxPermSize来控制这块内存的大小,jvm在启动的时候会根据这些配置来分配一块连续的内存块,但是随着动态类加载的情况越来越多,这块内存我们变得不太可控,到底设置多大合适是每个开发者要考虑的问题,如果设置太小了,系统运行过程中就容易出现内存溢出,设置大了又总感觉浪费,尽管不会实质分配这么大的物理内存。基于这么一个可能的原因,于是metaspace出现了,希望内存的管理不再受到限制,也不要怎么关注元数据这块的OOM问题,虽然到目前来看,也并没有完美地解决这个问题。
或许从JVM代码里也能看出一些端倪来,比如MaxMetaspaceSize
默认值很大,CompressedClassSpaceSize
默认也有1G,从这些参数我们能猜到metaspace的作者不希望出现它相关的OOM问题。
metaspace其实由两大部分组成
Klass Metaspace就是用来存klass的,klass是我们熟知的class文件在jvm里的运行时数据结构,不过有点要提的是我们看到的类似A.class其实是存在heap里的,是java.lang.Class的一个对象实例。这块内存是紧接着Heap的,和我们之前的perm一样,这块内存大小可通过-XX:CompressedClassSpaceSize
参数来控制,这个参数前面提到了默认是1G,但是这块内存也可以没有,假如没有开启压缩指针就不会有这块内存,这种情况下klass都会存在NoKlass Metaspace里,另外如果我们把-Xmx设置大于32G的话,其实也是没有这块内存的,因为会这么大内存会关闭压缩指针开关。还有就是这块内存最多只会存在一块。
NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool等,这块内存是由多块内存组合起来的,所以可以认为是不连续的内存块组成的。这块内存是必须的,虽然叫做NoKlass Metaspace,但是也其实可以存klass的内容,上面已经提到了对应场景。
Klass Metaspace和NoKlass Mestaspace都是所有classloader共享的,所以类加载器们要分配内存,但是每个类加载器都有一个SpaceManager,来管理属于这个类加载的内存小块。如果Klass Metaspace用完了,那就会OOM了,不过一般情况下不会,NoKlass Mestaspace是由一块块内存慢慢组合起来的,在没有达到限制条件的情况下,会不断加长这条链,让它可以持续工作。
如果我们要改变metaspace的一些行为,我们一般会对其相关的一些参数做调整,因为metaspace的参数本身不是很多,所以我这里将涉及到的所有参数都做一个介绍,也许好些参数大家都是有误解的
默认false,这个参数是说是否在metaspace里使用LargePage,一般情况下我们使用4KB的page size,这个参数依赖于UseLargePages这个参数开启,不过这个参数我们一般不开。
64位下默认4M,32位下默认2200K,metasapce前面已经提到主要分了两大块,Klass Metaspace以及NoKlass Metaspace,而NoKlass Metaspace是由一块块内存组合起来的,这个参数决定了NoKlass Metaspace的第一个内存Block的大小,即2*InitialBootClassLoaderMetaspaceSize,同时为bootstrapClassLoader的第一块内存chunk分配了InitialBootClassLoaderMetaspaceSize的大小
默认20.8M左右(x86下开启c2模式),主要是控制metaspaceGC发生的初始阈值,也是最小阈值,但是触发metaspaceGC的阈值是不断变化的,与之对比的主要是指Klass Metaspace与NoKlass Metaspace两块committed的内存和。
默认基本是无穷大,但是我还是建议大家设置这个参数,因为很可能会因为没有限制而导致metaspace被无止境使用(一般是内存泄漏)而被OS Kill。这个参数会限制metaspace(包括了Klass Metaspace以及NoKlass Metaspace)被committed的内存大小,会保证committed的内存不会超过这个值,一旦超过就会触发GC,这里要注意和MaxPermSize的区别,MaxMetaspaceSize并不会在jvm启动的时候分配一块这么大的内存出来,而MaxPermSize是会分配一块这么大的内存的。
默认1G,这个参数主要是设置Klass Metaspace的大小,不过这个参数设置了也不一定起作用,前提是能开启压缩指针,假如-Xmx超过了32G,压缩指针是开启不来的。如果有Klass Metaspace,那这块内存是和Heap连着的。
MinMetaspaceExpansion和MaxMetaspaceExpansion这两个参数或许和大家认识的并不一样,也许很多人会认为这两个参数不就是内存不够的时候,然后扩容的最小大小吗?其实不然
这两个参数和扩容其实并没有直接的关系,也就是并不是为了增大committed的内存,而是为了增大触发metaspace GC的阈值
这两个参数主要是在比较特殊的场景下救急使用,比如gcLocker或者should_concurrent_collect
的一些场景,因为这些场景下接下来会做一次GC,相信在接下来的GC中可能会释放一些metaspace的内存,于是先临时扩大下metaspace触发GC的阈值,而有些内存分配失败其实正好是因为这个阈值触顶导致的,于是可以通过增大阈值暂时绕过去
默认332.8K,增大触发metaspace GC阈值的最小要求。假如我们要救急分配的内存很小,没有达到MinMetaspaceExpansion,但是我们会将这次触发metaspace GC的阈值提升MinMetaspaceExpansion,之所以要大于这次要分配的内存大小主要是为了防止别的线程也有类似的请求而频繁触发相关的操作,不过如果要分配的内存超过了MaxMetaspaceExpansion,那MinMetaspaceExpansion将会是要分配的内存大小基础上的一个增量
默认5.2M,增大触发metaspace GC阈值的最大要求。假如说我们要分配的内存超过了MinMetaspaceExpansion但是低于MaxMetaspaceExpansion,那增量是MaxMetaspaceExpansion,如果超过了MaxMetaspaceExpansion,那增量是MinMetaspaceExpansion加上要分配的内存大小
注:每次分配只会给对应的线程一次扩展触发metaspace GC阈值的机会,如果扩展了,但是还不能分配,那就只能等着做GC了
MinMetaspaceFreeRatio和下面的MaxMetaspaceFreeRatio,主要是影响触发metaspaceGC的阈值
默认40,表示每次GC完之后,假设我们允许接下来metaspace可以继续被commit的内存占到了被commit之后总共committed的内存量的MinMetaspaceFreeRatio%,如果这个总共被committed的量比当前触发metaspaceGC的阈值要大,那么将尝试做扩容,也就是增大触发metaspaceGC的阈值,不过这个增量至少是MinMetaspaceExpansion才会做,不然不会增加这个阈值
这个参数主要是为了避免触发metaspaceGC的阈值和gc之后committed的内存的量比较接近,于是将这个阈值进行扩大
一般情况下在gc完之后,如果被committed的量还是比较大的时候,换个说法就是离触发metaspaceGC的阈值比较接近的时候,这个调整会比较明显
注:这里不用gc之后used的量来算,主要是担心可能出现committed的量超过了触发metaspaceGC的阈值,这种情况一旦发生会很危险,会不断做gc,这应该是jdk8在某个版本之后才修复的bug
默认70,这个参数和上面的参数基本是相反的,是为了避免触发metaspaceGC的阈值过大,而想对这个值进行缩小。这个参数在gc之后committed的内存比较小的时候并且离触发metaspaceGC的阈值比较远的时候,调整会比较明显
我们看GC是否异常,除了通过GC日志来做分析之外,我们还可以通过jstat这样的工具展示的数据来分析,前面我公众号里有篇文章介绍了jstat这块的实现,有兴趣的可以到我的公众号你假笨
里去翻阅下jstat的这篇文章。
我们通过jstat可以看到metaspace相关的这么一些指标,分别是M
,CCS
,MC
,MU
,CCSC
,CCSU
,MCMN
,MCMX
,CCSMN
,CCSMX
它们的定义如下:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
|
我这里对这些字段分类介绍下
MC表示Klass Metaspace以及NoKlass Metaspace两者总共committed的内存大小,单位是KB,虽然从上面的定义里我们看到了是capacity,但是实质上计算的时候并不是capacity,而是committed,这个是要注意的
MU这个无可厚非,说的就是Klass Metaspace以及NoKlass Metaspace两者已经使用了的内存大小
CCSC表示的是Klass Metaspace的已经被commit的内存大小,单位也是KB
CCSU表示Klass Metaspace的已经被使用的内存大小
M表示的是Klass Metaspace以及NoKlass Metaspace两者总共的使用率,其实可以根据上面的四个指标算出来,即(CCSU+MU)/(CCSC+MC)
CCS表示的是NoKlass Metaspace的使用率,也就是CCSU/CCSC算出来的
PS:所以我们有时候看到M的值达到了90%以上,其实这个并不一定说明metaspace用了很多了,因为内存是慢慢commit的,所以我们的分母是慢慢变大的,不过当我们committed到一定量的时候就不会再增长了
MCMN和CCSMN这两个值大家可以忽略,一直都是0
MCMX表示Klass Metaspace以及NoKlass Metaspace两者总共的reserved的内存大小,比如默认情况下Klass Metaspace是通过CompressedClassSpaceSize这个参数来reserved 1G的内存,NoKlass Metaspace默认reserved的内存大小是2* InitialBootClassLoaderMetaspaceSize
CCSMX表示Klass Metaspace reserved的内存大小
综上所述,其实看metaspace最主要的还是看MC
,MU
,CCSC
,CCSU
这几个具体的大小来判断metaspace到底用了多少更靠谱
本来还想写metaspace内存分配和GC的内容,不过那块说起来又是一个比较大的话题,因为那块大家看起来可能会比较枯燥,有机会再写
PS:本文最先发布在听云博客
]]>OutOfMemoryError,说的是java.lang.OutOfMemoryError,是JDK里自带的异常,顾名思义,说的就是内存溢出,当我们的系统内存严重不足的时候就会抛出这个异常(PS:注意这是一个Error,不是一个Exception,所以当我们要catch异常的时候要注意哦),这个异常说常见也常见,说不常见其实也见得不多,不过作为Java程序员至少应该都听过吧,如果你对jvm不是很熟,或者对OutOfMemoryError这个异常了解不是很深的话,这篇文章肯定还是可以给你带来一些惊喜的,通过这篇文章你至少可以了解到如下几点:
既然要说OutOfMemoryError,那就得从这个类的加载说起来,那这个类什么时候被加载呢?你或许会不假思索地说,根据java类的延迟加载机制,这个类一般情况下不会被加载,除非当我们抛出OutOfMemoryError这个异常的时候才会第一次被加载,如果我们的系统一直不抛出这个异常,那这个类将一直不会被加载。说起来好像挺对,不过我这里首先要纠正这个说法,要明确的告诉你这个类在jvm启动的时候就已经被加载了,不信你就执行java -verbose:class -version
打印JDK版本看看,看是否有OutOfMemoryError这个类被加载,再输出里你将能找到下面的内容:
1
|
|
这意味着这个类其实在vm启动的时候就已经被加载了,那JVM里到底在哪里进行加载的呢,且看下面的方法:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
|
上面的代码其实就是在vm启动过程中加载了OutOfMemoryError这个类,并且创建了好几个OutOfMemoryError对象,每个OutOfMemoryError对象代表了一种内存溢出的场景,比如说Java heap space
不足导致的OutOfMemoryError,抑或Metaspace
不足导致的OutOfMemoryError,上面的代码来源于JDK8,所以能看到metaspace的内容,如果是JDK8之前,你将看到Perm的OutOfMemoryError,不过本文metaspace不是重点,所以不展开讨论,如果大家有兴趣,可以专门写一篇文章来介绍metsapce来龙去脉,说来这个坑填起来还挺大的。
熟悉字节码增强的人,可能会条件反射地想到是否可以拦截到这个类的加载呢,这样我们就可以做一些譬如内存溢出的监控啥的,哈哈,我要告诉你的是NO WAY
,因为通过agent的方式来监听类加载过程是在vm初始化完成之后才开始的,而这个类的加载是在vm初始化过程中,因此不可能拦截到这个类的加载,于此类似的还有java.lang.Object
,java.lang.Class
等。
这个问题或许看了后面的内容你会有所体会,先卖个关子。包括为什么要预先创建这几个实例对象后面也会解释。
要抛出OutOfMemoryError,那肯定是有地方需要进行内存分配,可能是heap里,也可能是metsapce里(如果是在JDK8之前的会是Perm里),不同地方的分配,其策略也不一样,简单来说就是尝试分配,实在没办法就gc,gc还是不能分配就抛出异常。
不过还是以Heap里的分配为例说一下具体的过程:
正确情况下对象创建需要分配的内存是来自于Heap的Eden区域里,当Eden内存不够用的时候,某些情况下会尝试到Old里进行分配(比如说要分配的内存很大),如果还是没有分配成功,于是会触发一次ygc的动作,而ygc完成之后我们会再次尝试分配,如果仍不足以分配此时的内存,那会接着做一次full gc(不过此时的soft reference不会被强制回收),将老生代也回收一下,接着再做一次分配,仍然不够分配那会做一次强制将soft reference也回收的full gc,如果还是不能分配,那这个时候就不得不抛出OutOfMemoryError了。这就是Heap里分配内存抛出OutOfMemoryError的具体过程了。
想象有这么一种场景,我们的代码写得足够烂,并且存在内存泄漏,这意味着系统跑到一定程度之后,只要我们创建对象要分配内存的时候就会进行gc,但是gc没啥效果,进而抛出OutOfMemoryError的异常,那意味着每发生此类情况就应该创建一个OutOfMemoryError对象,并且抛出来,也就是说我们会看到一个带有堆栈的OutOfMemoryError异常被抛出,那事实是如此吗?如果真是如此,那为什么在VM启动的时候会创建那几个OutOfMemoryError对象呢?
这个问题或许你仔细想想就清楚了,如果没想清楚,请在这里停留一分钟仔细想想再往后面看。
抛出OutOfMemoryError异常的java方法其实只是临门一脚而已,导致内存泄漏的不一定就是这个方法,当然也不排除可能是这个方法,不过这种情况的可能性真的非常小。所以你大可不必去关心抛出这个异常的堆栈。
既然可以不关心其异常堆栈,那意味着这个异常其实没必要每次都创建一个不一样的了,因为不需要堆栈的话,其他的东西都可以完全相同,这样一来回到我们前面提到的那个问题,为什么要在vm启动过程中加载这个类
,或许你已经有答案了,在vm启动过程中我们把类加载起来,并创建几个没有堆栈的对象缓存起来,只需要设置下不同的提示信息即可,当需要抛出特定类型的OutOfMemoryError异常的时候,就直接拿出缓存里的这几个对象就可以了。
所以OutOfMemoryError的对象其实并不会太多,哪怕你代码写得再烂,当然,如果你代码里要不断new OutOfMemoryError()
,那我就无话可说啦。
如果都是用jvm启动的时候创建的那几个OutOfMemoryError对象,那不应该再出现有堆栈的OutOfMemoryError异常,但是实际上我们偶尔还是能看到有堆栈的异常,如果你细心点的话,可能会总结出一个规律,发现最多出现4次有堆栈的OutOfMemoryError异常,当4次过后,你都将看到无堆栈的OutOfMemoryError异常。
这个其实在我们上面贴的代码里也有体现,最后有一个for循环,这个循环里会创建几个OutOfMemoryError对象,如果我们将StackTraceInThrowable
设置为true的话(默认就是true的),意味着我们抛出来的异常正确情况下都将是有堆栈的,那根据PreallocatedOutOfMemoryErrorCount
这个参数来决定预先创建几个OutOfMemoryError异常对象,但是这个参数除非在debug版本下可以被设置之外,正常release出来的版本其实是无法设置这个参数的,它会是一个常量,值为4,因此在jvm启动的时候会预先创建4个OutOfMemoryError异常对象,但是这几个异常对象的堆栈,是可以动态设置的,比如说某个地方要抛出OutOfMemoryError异常了,于是先从预存的OutOfMemoryError里取出一个(其他是预存的对象还有),将此时的堆栈填上,然后抛出来,并且这个对象的使用是一次性的,也就是这个对象被抛出之后将不会再次被利用,直到预设的这几个OutOfMemoryError对象被用完了,那接下来抛出的异常都将是一开始缓存的那几个无栈的OutOfMemoryError对象。
这就是我们看到的最多出现4次有堆栈的OutOfMemoryError异常及大部分情况下都将看到没有堆栈的OutOfMemoryError对象的原因。
既然看堆栈也没什么意义,那只能从提示上入手了,我们看到这类异常,首先要确定的到底是哪块内存何种情况导致的内存溢出,比如说是Perm导致的,那抛出来的异常信息里会带有Perm
的关键信息,那我们应该重点看Perm的大小,以及Perm里的内容;如果是Heap的,那我们就必须做内存Dump,然后分析为什么会发生这样的情况,内存里到底存了什么对象,至于内存分析的最佳的分析工具自然是MAT啦,不了解的请google之。
PS:本文最先发布在听云博客
]]>jstat是hotspot自带的工具,和java一样也位于JAVA_HOME/bin
下面,我们通过该工具可以实时了解当前进程的gc,compiler,class,memory等相关的情况,具体我们可以通过jstat -options来看我们到底支持哪些类型的数据,譬如JDK8下的结果是:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
jstat大家用得其实挺多的,最常见的用法是jstat -gcutil,输出如下:
1 2 3 4 5 6 7 |
|
那每一列是怎么定义,怎么计算的呢,其实在tools.jar里存在一个文件叫做jstat_options,这个文件里定义了上面的每种类型的输出结果,比如说gcutil
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
|
从上面的定义我们知道gcutil的每一列是什么意思,怎么计算出来的,其中类似sun.gc.generation.0.space.0.capacity
这样的一些变量是jvm里创建并实时更新的值
变量值显然是从目标进程里获取来的,但是是怎样来的?local socket还是memory share?其实是从一个共享文件里来的,这个文件叫PerfData,主要指的是/tmp/hsperfdata_\
这个文件是否存在取决于两个参数,一个UsePerfData,另一个是PerfDisableSharedMem,如果设置了-XX:+PerfDisableSharedMem或者-XX:-UsePerfData,那这个文件是不会存在的,默认情况下PerfDisableSharedMem是关闭的,UsePerfData是打开的,所以默认情况下PerfData文件是存在的。对于UsePerfData和PerfDisableSharedMem这两个参数,这里着重讲一下:
具体代码在PerfMemory::create_memory_region里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
那这个文件什么时候删除?正常情况下当进程退出的时候会自动删除,但是某些极端情况下,比如kill -9,这种信号jvm是不能捕获的,所以导致进程直接退出了,而没有做一些收尾性的工作,这个时候你会发现进程虽然没了,但是这个文件其实还是存在的,那这个文件是不是就一直留着,只能等待人为的删除呢,jvm里考虑到了这种情况,会在当前用户接下来的任何一个java进程(比如说我们执行jps)起来的时候会去做一个判断,看/tmp/hsperfdata_\
由于这个文件是通过mmap的方式映射到了内存里,而jstat是直接通过DirectByteBuffer的方式从PerfData里读取的,所以只要内存里的值变了,那我们从jstat看到的值就会发生变化,内存里的值什么时候变,取决于-XX:PerfDataSamplingInterval这个参数,默认是50ms,也就是说50ms更新一次值,基本上可以认为是实时的了。
本人暂时想到的两大坑:
一次正常的Background CMS GC之后,发现FGC的值加了2次,后面发现主要原因是CMS有init mark和remark两个会暂停应用的阶段,同时因为是对old做gc,因此算了两次
JDK8下metaspace的使用情况不准确,比如说CCSC的值表示的是 Compressed Class Space Capacity,但是发现这个值的计算却不是reserve的值,所以我们可能会发现metaspace其实用了非常少,但是通过jstat看起使用率已经非常大了,因此这种情况最好是通过jmx的方式去取那些值做一个计算
1 2 3 |
|
之前写过篇文章,关于堆外内存的,JVM源码分析之堆外内存完全解读,里面重点讲了DirectByteBuffer的原理,但是今天碰到一个比较奇怪的问题,在设置了-XX:MaxDirectMemorySize=1G的前提下,然后统计所有DirectByteBuffer对象后面占用的内存达到了7G,远远超出阈值,这个问题很诡异,于是好好查了下原因,虽然最终发现是我们统计的问题,但是期间发现的其他一些问题还是值得分享一下的。
打开DirectByteBuffer这个类,我们会发现有5个构造函数
1 2 3 4 5 6 7 8 9 |
|
我们从java层面创建DirectByteBuffer对象,一般都是通过ByteBuffer的allocateDirect方法
1 2 3 |
|
也就是会使用上面提到的第一个构造函数,即
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 |
|
而这个构造函数里的Bits.reserveMemory(size, cap)
方法会做堆外内存的阈值check
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 32 33 |
|
因此当我们已经分配的内存超过阈值的时候会触发一次gc动作,并重新做一次分配,如果还是超过阈值,那将会抛出OOM,因此分配动作会失败。
所以从这一切看来,只要设置了-XX:MaxDirectMemorySize=1G
是不会出现超过这个阈值的情况的,会看到不断的做GC。
那其他的构造函数主要是用在什么情况下的呢?
我们知道DirectByteBuffer回收靠的是里面有个cleaner的属性,但是我们发现有几个构造函数里cleaner这个属性却是null,那这种情况下他们怎么被回收呢?
那下面请大家先看下DirectByteBuffer里的这两个函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
从名字和实现上基本都能猜出是干什么的了,slice其实是从一块已知的内存里取出剩下的一部分,用一个新的DirectByteBuffer对象指向它,而duplicate就是创建一个现有DirectByteBuffer的全新副本,各种指针都一样。
因此从这个实现来看,后面关联的堆外内存其实是同一块,所以如果我们做统计的时候如果仅仅将所有DirectByteBuffer对象的capacity加起来,那可能会导致算出来的结果偏大不少,这其实也是我查的那个问题,本来设置了阈值1G,但是发现达到了7G的效果。所以这种情况下使用的构造函数,可以让cleaner为null,回收靠原来的那个DirectByteBuffer对象被回收。
但是还有种情况,也是本文要讲的重点,在jvm里可以通过jni方法回调上面的DirectByteBuffer构造函数,这个构造函数是
1 2 3 4 5 6 |
|
而调用这个构造函数的jni方法是jni_NewDirectByteBuffer
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 32 33 34 35 36 37 38 39 |
|
想象这么种情况,我们写了一个native方法,里面分配了一块内存,同时通过上面这个方法和一个DirectByteBuffer对象关联起来,那从java层面来看这个DirectByteBuffer确实是一个有效的占有不少native内存的对象,但是这个对象后面关联的内存完全绕过了MaxDirectMemorySize的check,所以也可能给你造成这种现象,明明设置了MaxDirectMemorySize,但是发现DirectByteBuffer关联的堆外内存其实是大于它的。
我们都知道gc是为了释放内存,但是你是否碰到过ygc前后新生代反增不减的情况呢?gc日志效果类似下面的:
1
|
|
从上面的gc日志来看,我们新生代使用的是ParNew,而老生代用的是CMS GC,我们注意到ParNew的效果是新生代从636088K新增到了690555K,这是什么情况?
要解释这个问题,我们先要弄清楚YGC的过程,parNew是新生代的gc算法,简单来说从gc roots开始扫描对象,当扫到一个只要是属于新生代的对象就将其挪到to space,但是老的对象还不会做释放,直到gc完成之后再看是否释放老的对象(比如说上面我们看到了promotion failed
的关键字,意味着晋升失败了,也就是说to和old都装不下新生代晋升来的对象,那么在这种情况下其实是不会对eden和from里的老对象做释放的,尽管to space里已经可能存在一份副本了),但是在gc前后不管是否晋升成功,都会对from space和to space做一个对换,也就是原来的from变成to,原来的to变成from,再来看看打印gc前后内存变化的代码
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 |
|
从上面代码我们知道,gc之后的内存情况是used()方法返回的,其中新生代的used方法返回的是eden+from的内存,同样的上面的prev_used也是这么计算的,只是发生在gc之前,这样一来,根据我上面提到的情况,在gc之后不管是否成功都会做一次from和to的swap,那么gc之前新生代的使用大小,其实是gc之前eden+from的使用大小,而gc之后的新生代的使用大小,其实是eden+原来的to现在是使用的大小,原来的to现在使用的大小其实就是在gc过程中将eden和from拷贝过来的对象所占的大小。
综上分析你应该知道为什么会出现这种情况了,其实是一种特殊情况,只有在出现promotion failed
的情况下才会发生这样的情况,因为在这个情况下存在to里新增对象,而from和eden不会变化的情况
这篇文章基于最近在排查的一个问题,花了我们团队不少时间来排查这个问题,现象是有一些类加载器是作为key放到WeakHashMap里的,但是经历过多次full gc之后,依然坚挺地存在内存里,但是从代码上来说这些类加载器是应该被回收的,因为没有任何强引用可以到达这些类加载器了,于是我们做了内存dump,分析了下内存,发现除了一个WeakHashMap外并没有别的GC ROOT途径达到这些类加载器了,那这样一来经过多次FULL GC肯定是可以被回收的,但是事实却不是这样,为了让这个问题听起来更好理解,还是照例先上个Demo,完全模拟了这种场景。
首先我们创建两个类AAA和AAB,分别打包到两个不同jar里,比如AAA.jar和AAB.jar,这两个类之间是有关系的,AAA里有个属性是AAB类型的,注意这两个jar不要放到classpath里让appClassLoader加载到:
1 2 3 4 5 6 7 8 9 10 11 |
|
接着我们创建一个类加载TestLoader,里面存一个WeakHashMap,专门来存TestLoader的,并且复写loadClass方法,如果是加载AAB这个类,就创建一个新的TestLoader来从AAB.jar里加载这个类
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 |
|
再看我们的主类TTest,一些说明都写在类里了:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
运行的时候请跑在JDK8下,打个断点在System.out.println("finished")
的地方,然后做一次内存dump。
从上面的例子中我们得知,TTest是类加载器AppClassLoader加载的,其属性aaa的对象类型是通过TestLoader从AAA.jar里加载的,而aaa里的aab属性是从一个全新的类加载器TestLoader从AAB.jar里加载的,当我们做了多次System GC之后,这些对象会移到old,在做最后一次GC之后,aab对象会从内存里移除,其类加载器此时已经是没有任何地方的强引用了,只有一个WeakHashMap引用它,理论上做GC的时候也应该被回收,但是事实时这个AAB的这个类加载器并没有被回收,从分析结果来看,GC ROOT路径是WeakHashMap,如图所示:
这里不得不提的一个概念是JDK8里的metaspace,它是为了取代perm的,至于好处是什么,我个人觉得不是那么明显,有点费力不讨好的感觉,代码改了很多,但是实际收益并不明显,据说是oracle内部斗争的一个结果。
在JDK8里虽然没了perm,但是klass的信息还是要有地方存,jvm里为此分配了两块内存,一块是紧挨着heap来的,就和perm一样,专门用来存klass的信息,可以通过-XX:CompressedClassSpaceSize
来设置大小,另外一块和它们不一定连着,主要是存非klass之外的其他信息,比如常量池什么的,可以通过-XX:InitialBootClassLoaderMetaspaceSize
来设置,同时我们还可以通过-XX:MaxMetaspaceSize
来设置触发metaspace回收的阈值。
每个类加载器都会从全局的metaspace空间里取一些metaChunk管理起来,当有类定义的时候,其实就是从这些内存里分配的,当不够的时候再去全局的metaspace里分配一块并管理起来。
这块具体的情况后面可以专门写一篇文章来介绍,包括内存结构,内存分配,GC等。
每个类加载器都会对应一个ClassLoaderData的数据结构,里面会存譬如具体的类加载器对象,加载的klass,管理内存的metaspace等,它是一个链式结构,会链到下一个ClassLoaderData上,gc的时候通过ClassLoaderDataGraph来遍历这些ClassLoaderData,ClassLoaderDataGraph的第一个ClassLoaderData是bootstrapClassLoader的
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 32 33 34 35 36 37 38 39 40 41 42 |
|
这里提几个属性:
_class_loader
: 就是对应的类加载器对象_keep_alive
: 如果这个值是true,那这个类加载器会认为是活的,会将其做为GC ROOT的一部分,gc的时候不会被回收_unloading
: 表示这个类加载是否需要卸载的_is_anonymous
: 是否匿名,这种ClassLoaderData主要是在lambda表达式里用的,这个我后面会详细说_next
: 指向下一个ClassLoaderData,在gc的时候方便遍历_dependencies
: 这个属性也是本文的重点,后面会细说再来看下构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
可见,_keep_ailve
属性的值是根据_is_anonymous
以及当前类加载器是不是bootstrapClassLoader来的。
_keep_alive
到底用在哪?其实是在GC的的时候,来决定要不要用Closure或者用什么Closure来扫描对应的ClassLoaderData。
1 2 3 4 5 6 7 8 9 10 |
|
类加载器是否需要被回收,其实就是看这个类加载器对象是否是活的,所谓活的就是这个类加载器加载的任何一个类或者这些类的对象是强可达的,当然还包括这个类加载器本身就是GC ROOT一部分或者有GC ROOT可达的路径,那这个类加载器就肯定不会被回收。
从各种GC情况来看:
如果类加载器是与GC ROOT的对象存在真正依赖的这种关系,这种类加载器对象是活的无可厚非,我们通过zprofiler或者mat都可以分析出来,可以将链路绘出来,但是有两种情况例外:
lambda匿名类加载走的是unsafe的defineAnonymousClass方法,这个方法在vm里对应的是下面的方法
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 32 33 34 |
|
可见,在创建成功匿名类之后,会将对应的ClassLoaderData的_keep_alive
属性设置为false,那是不是意味着_keep_alive
属性在这之前都是true呢?下面的parse_stream
方法是从上面的方法最终会调下来的方法
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
|
从上面的代码得知,只要走了unsafe的那个方法,都会为当前类加载器创建一个ClassLoaderData对象,并设置其_is_anonymous
为true,也同时意味着_keep_alive
的属性是true,并加入到ClassLoaderDataGraph中。
试想如果创建的这个匿名类没有成功,也就是anon_klass()==null
,那这个_keep_alive
属性就永远无法设置为false了,这意味着这个ClassLoaderData对应的ClassLoader对象将永远都是GC ROOT的一部分,无法被回收,这种情况就是真正的僵尸类加载器了,不过目前我还没模拟出这种情况来,有兴趣的同学可以试一试,如果真的能模拟出来,这绝对是JDK里的一个BUG,可以提交给社区。
这里说的类加载器依赖,并不是说ClassLoader里的parent建立的那种依赖关系,如果是这种关系,那其实通过mat或者zprofiler这样的工具都是可以分析出来的,但是还存在一种情况,那些工具都是分析不出来的,这种关系就是通过ClassLoaderData里的_dependencies
属性得出来的,比如说如果A类加载器的_dependencies
属性里记录了B类加载器,那当GC遍历A类加载器的时候也会遍历B类加载器,并将其标活,哪怕B类加载器其实是可以被回收了的,可以看下下面的代码
1 2 3 4 5 6 7 8 9 10 11 12 |
|
那问题来了,这种依赖关系是怎么记录的呢?其实我们上面的demo就模拟了这种情况,可以仔细去看看,我也针对这个demo描述下,比如加载AAA的类加载器TestLoader加载AAA后,并创建AAA对象,此时会看到有个类型是AAB的属性,此时会对常量池里的类型做一个解析,我们看到TestLoader的loadClass方法的时候做了一个判断,如果是AAB类型的类加载,那就创建一个新的类加载器对象从AAB.jar里去加载,当加载返回的时候,在jvm里其实就会记录这么一层依赖关系,认为AAA的类加载器依赖AAB的类加载器,并记录下来,但是纵观所有的hotspot代码,并没有一个地方来清理这种依赖关系的,也就是说只要这种依赖关系建立起来,会一直持续到AAA的类加载器被回收的时候,AAB的类加载器才会被回收,所以说这算一种伪僵尸类加载器,虽然从依赖关系上其实并不依赖了(比如demo里将AAA的aab属性做clear清空动作),但是GC会一直认为他们是存在这种依赖关系的,会持续存在一段时间,具体持续多久就看AAA类加载器的情况了。
针对这种情况个人认为需要一个类似引用计数的GC策略,当某两个类加载器确实没有任何依赖的时候,将其清理掉这种依赖关系,估计要实现这种改动的地方也挺多,没那么简单,所以当时的设计者或许因为这样并没有这么做了,我觉得这算是偷懒妥协的结果吧,当然这只是我的一种猜测。
之所以想写这篇文章,其实是因为最近有不少系统出现了栈溢出导致进程crash的问题,并且很隐蔽,根本原因还得借助coredump才能分析出来,于是想从JVM实现的角度来全面分析下栈溢出的这类问题,或许你碰到过如下的场景:
这些都可能是栈溢出导致的。
上面提到的后面两种情况有可能不是我们今天要聊的栈溢出的问题导致的crash,也许是别的一些可能,那如何确定上面三种情况是栈溢出导致的呢?
-XX:MaxJavaStackTraceDepth=1024
,可以将这个参数设置为-1,那将会全部输出对应的堆栈ulimit -c unlimited
,然后再跑进程,在进程挂掉之后,会产生一个core.<pid>
的文件,然后再通过jstack $JAVA_HOME/bin/java core.<pid>
来看输出的栈,如果正常输出了,那就可以看是否存在很长的调用栈的线程,当然还有可能没有正常输出的,因为jstack的这条从core文件抓栈的命令其实是基于serviceability agent来实现的,而SA在某些版本里是存在bug的,当然现在的SA也不能说完全没有bug,还是存在不少bug的,祝你好运。这个需要具体问题具体分析,因为导致栈溢出的原因很多,提三个主要的: * java代码写得不当,比如出现递归死循环,这也是最常见的,只能靠写代码的人稍微小心了 * native代码有栈上分配的逻辑,并且要求的内存还不小 * 线程栈空间设置比较小
有时候我们的代码需要调用到native里去,最常见的一种情况譬如java.net.SocketInputStream.read0
方法,这是一个native方法,在进入到这个方法里之后,它首先就要求到栈上去分配一个64KB的缓存(64位linux),试想一下如果执行到read0这个方法的时候,剩余的栈空间已经不足以分配64KB的内存了会怎样?也许就是一开头我们提到的crash,这只是一个例子,还有其他的一些native实现,包括我们自己也可能写这种native代码,如果真有这种情况,我们就需要好好斟酌下我们的线程栈到底要设置多大了。
如果我们的代码确实存在正常的很深的递归调用的话,通常是我们的栈可能设置太小,我们可以通过-Xss
或者-XX:ThreadStackSize
来设置java线程栈的大小,如果两个参数都设置了,那具体有效的是写在后面的那个生效。顺便提下,线程栈内存是和java heap独立的内存,并不是在java heap内分配的,是直接malloc分配的内存。
在jvm里,线程其实不仅仅只有一种,比如我们java里创建的叫做java线程,还有gc线程,编译线程等,默认情况下他们的栈大小如下:
1 2 3 4 5 6 7 8 9 |
|
可见默认情况下编译线程需要的栈空间是其他种类线程的4倍。
各种类型的线程他们所需要的栈的大小其实是可以通过不同的参数来控制的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
java_thread
的stack_size,其实就是-Xss或者-XX:ThreadStackSize的值compiler_thread
的stack_size,是-XX:CompilerThreadStackSize指定的值JVM里的栈溢出到底是怎么实现的,得从栈的大致结构说起:
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 32 |
|
linux下java线程栈是从高地址往低地址方向走的,在栈尾(低地址)会预留两块受保护的内存区域,分别叫做yellow page和red page,其中yellow page在前,另外如果是java创建的线程,最后并没有图示的一个page的glibc guard page
,非java线程是有的,但是没有yellow和red page,比如我们的gc线程,注意编译线程其实是java线程。
除了yellow page和red page,其实还有个shadow page,这三个page可以分别通过vm参数-XX:StackYellowPages
,-XX:StackRedPages
,-XX:StackShadowPages
来控制。当我们要调用某个java方法的时候,它需要多大的栈其实是预先知道的,javac里就计算好了,但是如果调用的是native方法,那这就不好办了,在native方法里到底需要多大内存,这个无法得知,因此shadow page就是用来做一个大致的预测,看需要多大的栈空间,如果预测到新的RSP的值超过了yellowpage的位置,那就直接抛出栈溢出的异常,否则就去新的方法里处理,当我们的代码访问到yellow page或者red page里的地址的时候,因为这块内存是受保护的,所以会产生SIGSEGV的信号,此时会交给JVM里的信号处理函数来处理,针对yellow page以及red page会有不同的处理策略,其中yellow page的处理是会抛出StackOverflowError的异常,进程不会挂掉,也就是文章开头提到的第一个场景,但是如果是red page,那将直接导致进程退出,不过还是会产生Crash的日志,也就是文章开头提到的第二个场景,另外还有第三个场景,其实是没有栈空间了并且访问了超过了red page的地址,这个时候因为栈空间不够了,所以信号处理函数都进不去,因此就直接crash了,crash日志也不会产生。
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
|
了解上面的场景之后,再回过头来想想JVM为什么要设置这几个page,其实是为了安全,能预测到栈溢出的话就抛出StackOverfolwError,而避免导致进程挂掉。
最近公司在大面积推广JDK8,整体来说升级上来算顺利的,大部分问题可能在编译期就碰到了,但是有些时候比较蛋疼,编译期没有出现问题,但是在运行期就出了问题,比如今天要说的这个话题,所以大家再升级的时候还是要多测测再上线,当然JDK8给我们带来了不少收益,花点时间升级上来还是值得的。
还是老规矩,先上demo,让大家直观地知道我们要说的问题。
1 2 3 4 5 6 7 8 9 10 |
|
demo很简单,就是有个使用了泛型的函数getObject,其返回类型是Number的子类,然后我们将函数返回值传给StringBuilder的多态方法append,我们知道append方法有很多,参数类型也很多,但是唯独没有参数是Number的append方法,如果有的话,大家应该猜到会优先选择这个方法了,既然没有,那到底会选哪个呢,我们分别用jdk6(jdk7类似)和jdk8来编译下上面的类,然后用javap看看输出结果(只看main方法):
jdk6编译的字节码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
jdk8编译的字节码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
对比上面那个的差异,我们看到bci从12开始变了,jdk8里多了下面这行表示要对栈顶的数据做一次类型检查看是不是CharSequence类型:
1
|
|
另外调用的StringBuilder的append方法也是不一样的,jdk7里是调用的参数为Object类型的append方法,而jdk8里调用的是CharSequence类型的append方法。
最主要的是在jdk6和jdk8下运行上面的代码,在jdk6下是正常跑过的,但是在jdk8下是直接抛出异常的:
1 2 |
|
至此问题整个应该描述清楚了。
先来说说如果要我们来做这个java编译器实现这个功能,我们要怎么来做,其他的都是很明确的,重点在于如下这段如何来确定append的方法使用哪个:
1
|
|
我们知道getObject()返回的是个泛型对象,这个对象是Number的子类,因此我们首先会去遍历StringBuilder的所有可见的方法,包括从父类继承过来的,找是不是存在一个方法叫做append,并且参数类型是Number的方法,如果有,那就直接使用这个方法,如果没有,那我们得想办法找到一个最合适的方法,关键就在于这个合适怎么定义,比如说我们看到有个append的方法,其参数是Object类型的,Number是Object的子类,所以我们选择这个方法肯定没问题,假如说另外有个append方法,其参数是Serializable类型(当然其实并没有这种参数的方法),Number实现了这个接口,我们选择这个方法也是没问题的,那到底是Object参数的更合适还是Serializable的更合适呢,还有更甚者,我们知道StringBuilder有个方法,其参数是CharSequence,假如我们传进去的参数其实既是Number的子类,同时又实现了CharSequence这个接口,那我们究竟要不要选它呢?这些问题我们都需要去考虑,而且各有各的理由,说起来都感觉挺合理的。
这里分析的是jdk6的javac代码,不过大致看了下jdk7的这块针对这个问题的逻辑也差不多,所以就以这块为例了,jdk6里的泛型类型推导其实比较简单,从上面的输出结果我们也猜到了,其实就是选了参数为Object类型的append方法,它觉得它是最合适的:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
|
上面的逻辑大概是遍历当前类(比如这个例子中的StringBuilder)及其父类,依次从他们的方法里找出一个最合适的方法返回,重点就落在了selectBest这个方法上:
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 32 33 34 35 36 37 38 39 40 41 42 |
|
这个方法的主要逻辑落在rawInstantiate这个方法里(具体代码不贴了,有兴趣的去看下代码,我将最终最关键的调用方法argumentsAcceptable贴出来,主要做参数的匹配),如果当前方法也合适,那就和之前挑出来的最好的方法做一个比较看谁最适合,这个选择过程在最后的mostSpecific方法里,其实就和冒泡排序差不多,不过是找最接近的那个类型(逐层找对应是父类的方法,和最小公倍数有点类似)。
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 |
|
针对具体的例子其实就是看StringBuilder里的哪个方法的参数是Number的父类,如果不是就表示没有找到,如果参数都符合期望就表示找到,然后返回。
所以jdk6里的这块的逻辑相对来说比较简单。
jdk8里的推导相对来说比较复杂,不过大部分逻辑和上面的都差不多,但是argumentsAcceptable这块的变动比较大,增加了一些数据结构,规则变得更加复杂,考虑的场景也更多了,因为代码嵌套层数很深,具体的代码我就不贴了,有兴趣的自己去跟下代码(具体变化可以从AbstractMethodCheck.argumentsAcceptable这个方法开始)。
针对具体这个demo,如果getObject返回的对象既实现了CharSequence,又是Number的子类,那它认为这种情况其实选择参数为CharSequence类型的append方法比参数为Object类型的方法更合适,看起来是要求更严格一些了,适用范围收窄了一些,不是去匹配大而范的接口方法,因此其多加了一层checkcast的检查,不过我个人观点是觉得这块有点太激进了。
本文我想说的是最后一个阶段,类的初始化,但是也不细说其中的过程,只围绕我们今天要说的展开。
我们定义一个类的时候,可能有静态变量,可能有静态代码块,这些逻辑编译之后会封装到一个叫做clinit的方法里,比如下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
编译之后我们通过javap -verbose BadClass
可以看到如下字节码:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
|
我们看到最后那个方法static{}
,其实就是我上面说的clinit方法,我们看到静态字段的初始化和静态代码库都封装在这个方法里。
假如我们通过如下代码来测试上面的类:
1 2 3 4 5 6 7 8 9 |
|
大家觉得输出会是什么?是会打印多次before init
吗?其实不然,输出结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
也就是说其实是只输出了一次before init
,这是为什么呢?
clinit方法在我们第一次主动使用这个类的时候会触发执行,比如我们访问这个类的静态方法或者静态字段就会触发执行clinit,但是这个过程是不可逆的,也就是说当我们执行一遍之后再也不会执行了,如果在执行这个方法过程中出现了异常没有被捕获,那这个类将永远不可用,虽然我们上面执行BadClass.doSomething()
的时候catch住了异常,但是当代码跑到这里的时候,在jvm里已经将这个类打上标记了,说这个类初始化失败了,下次再初始化的时候就会直接返回并抛出类似的异常java.lang.NoClassDefFoundError: Could not initialize class BadClass
,而不去再次执行初始化的逻辑,具体可以看下jvm里对类的状态定义:
1 2 3 4 5 6 7 8 9 |
|
如果clinit执行失败了,抛了一个未被捕获的异常,那将这个类的状态设置为initialization_error
,并且无法再恢复,因为jvm会认为你这次初始化失败了,下次肯定也是失败的,为了防止不断抛这种异常,所以做了一个缓存处理,不是每次都再去执行clinit,因此大家要特别注意,类的初始化过程可千万不能出错,出错就可能只能重启了哦。
话不多说了,先来看代码吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
这个例子里新创建了11个线程,其中10个线程没干什么事,主要是sleep,另外有一个线程在循环里一直跑着,可以想象这个线程是这个进程里最耗cpu的线程了,那怎么把这个线程给抓出来呢?
首先我们可以通过top -Hp <pid>
来看这个进程里所有线程的cpu消耗情况,得到类似下面的数据
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 |
|
拿到这个结果之后,我们可以看到cpu最高的线程是pid为18250的线程,占了99.9%:
1 2 |
|
接着我们可以通过jstack <pid>
的输出来看各个线程栈:
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 32 33 34 |
|
上面的线程栈我们注意到nid的值其实就是线程ID,它是十六进制的,我们将消耗cpu最高的线程18250
,转成十六进制0x474a
,然后从上面的线程栈里找到nid=0x474a
的线程,其栈为:
1 2 3 4 |
|
即将最耗cpu的线程找出来了,是Businest Thread
本文其实一直都想写,因为各种原因一直拖着没写,直到开公众号的第一天,有朋友再次问到这个问题,这次让我静心下来准备写下这篇文章,本文有些东西是我自己的理解,比如为什么JDK一开始要这么设计,初衷是什么,没怎么去找相关资料,所以只能谈谈自己的理解,所以大家看到文章之后可以谈谈自己的看法,对于实现部分我倒觉得说清楚问题不大,code is here,看明白了就知道怎么回事了。
Object.wait/notify(All)大家都知道主要是协同线程处理的,大家用得也很多,大概逻辑和下面的用法差不多
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
看到上面代码,你会有什么疑惑吗?至少我会有几个问题会问自己:
如果上面这些问题也都是你想了解的,那这篇文章或许能给你一个答案。
从实现上来说,这个锁至关重要,正因为这把锁,才能让整个wait/notify玩转起来,当然我觉得其实通过其他的方式也可以实现类似的机制,不过hotspot至少是完全依赖这把锁来实现wait/notify的。
如果要我们来实现这种机制我们会怎么去做,我们知道wait/notify是为了线程间协作而设计的,当我们执行wait的时候让线程挂起,当执行notify的时候唤醒其中一个挂起的线程,那需要有个地方来保存对象和线程之间的映射关系(可以想象一个map,key是对象,value是一个线程列表),当调用这个对象的wait方法时,将当前线程放到这个线程列表里,当调用这个对象的notify方法时从这个线程列表里取出一个来让其继续执行,这样看来是可行的,也比较简单,那现在的问题这种映射关系放到哪里。而synchronized正好也是为线程间协作而设计的,上面碰到的问题它也要解决,或许正因为这样wait和notify的实现就直接依赖synchronzied(monitorenter/monitorexit是jvm规范里要求要去实现的)来实现了,这只是我的理解,可能初衷不是这个原因,这其实也是这篇文章迟迟未写的一个原因吧,因为我无法取证自己的理解是对的,欢迎各位在这块谈谈自己的见解。
这个问题其实要回答很简单,因为在wait处理过程中会临时释放同步锁,不过需要注意的是当某个线程调用notify唤起了这个线程的时候,在wait方法退出之前会重新获取这把锁,只有获取了这把锁才会继续执行,想象一下,我们知道wait的方法是被monitorenter和monitorexit包围起来,当我们在执行wait方法过程中如果释放了锁,出来的时候又不拿锁,那在执行到monitorexit指令的时候会发生什么?当然这可以做兼容,不过这实现起来还是很奇怪的。
这个异常大家应该都知道,当我们调用了某个线程的interrupt方法时,对应的线程会抛出这个异常,wait方法也不希望破坏这种规则,因此就算当前线程因为wait一直在阻塞,当某个线程希望它起来继续执行的时候,它还是得从阻塞态恢复过来,因此wait方法被唤醒起来的时候会去检测这个状态,当有线程interrupt了它的时候,它就会抛出这个异常从阻塞状态恢复过来。
这里有两点要注意:
这里要分情况:
其实这个大家可以验证一下,在notify之后写一些逻辑,看这些逻辑是在其他线程被唤起之前还是之后执行,这个是个细节问题,可能大家并没有关注到这个,其实hotspot里真正的实现是退出同步块的时候才会去真正唤醒对应的线程,不过这个也是个默认策略,也可以改的,在notify之后立马唤醒相关线程。
或许大家立马想到这个简单,一个for循环就搞定了,不过在jvm里没实现这么简单,而是借助了monitorexit,上面我提到了当某个线程从wait状态恢复出来的时候,要先获取锁,然后再退出同步块,所以notifyAll的实现是调用notify的线程在退出其同步块的时候唤醒起最后一个进入wait状态的线程,然后这个线程退出同步块的时候继续唤醒其倒数第二个进入wait状态的线程,依次类推,同样这这是一个策略的问题,jvm里提供了挨个直接唤醒线程的参数,不过都很罕见就不提了。
这个或许是大家比较关心的话题,因为关乎系统性能问题,wait/nofity是通过jvm里的park/unpark机制来实现的,在linux下这种机制又是通过pthread_cond_wait/pthread_cond_signal来玩的,因此当线程进入到wait状态的时候其实是会放弃cpu的,也就是说这类线程是不会占用cpu资源。
本文重点讲述毕玄大师在其公众号上发的一个GC问题一个jstack/jmap等不能用的case(PS:话说毕大师超级喜欢在题目里用case这个词,我觉得题目还是能尽量做到顾名思义好,不然要找起相关文章来真的好难找),对于毕大师那篇文章,题目上没有提到GC的那个问题,不过进入到文章里可以看到,既然文章提到了jstack/jmap的问题,这里也简单回答下jstack/jmap无法使用的问题,其实最常见的场景是使用jstack/jmap的用户和目标进程不是同一个用户,哪怕你执行jstack/jmap的动作是root用户也无济于事,详情可以参考我的这篇文章,JVM Attach机制实现,主要是讲JVM Attach机制的,不过毕大师这里主要提到的是jmap -heap/histo这两个参数带来的问题,如果使用-heap/histo的参数,其实和大家使用-F参数是一样的,底层都是通过serviceability agent来实现的,并不是jvm attach的方式,通过sa连上去之后会挂起进程,在serviceability agent里存在bug可能导致detach的动作不会被执行,从而会让进程一直挂着,可以通过top命令验证进程是否处于T状态,如果是说明进程被挂起了,如果进程被挂起了,可以通过kill -CONT [pid]来恢复。
再回到那个GC的问题,用的参数如下:
1
|
|
demo程序如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
执行效果如下
1 2 3 4 5 |
|
发现gc的时间越来越长,但是gc触发的时机以及回收的效果都差不多,那问题究竟在哪里呢?
虽然这个demo代码逻辑很简单,但是其实这是一个特殊的demo,并不简单,如果我们将XStream对象换成Object对象,会发现不存在这个问题,既然如此那有必要进去看看这个XStream的构造函数:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
|
这个构造函数还是很复杂的,里面会创建很多的对象,上面还有一些方法实现我就不贴了,总之都是在不断构建各种大大小小的对象,一个XStream对象构建出来的时候大概好像有12M的样子。
那到底是哪些对象会导致ygc不断增长呢,于是可能想到逐步替换上面这些逻辑,比如将最后一个构造函数里的那些逻辑都禁掉,然后我们再跑测试看看还会不会让ygc不断恶化,最终我们会发现,如果我们直接使用如下构造函数构造对象时,如果传入的classloader是AppClassLoader,那会发现这个问题不再出现了。
1 2 3 |
|
测试代码如下:
1 2 3 4 5 6 7 8 |
|
gc日志如下:
1 2 3 4 5 6 7 8 9 |
|
是不是觉得很神奇,由此可见,这个classloader至关重要。
这里着重要说的两个概念是初始类加载器
和定义类加载器
。举个栗子说吧,AClassLoader->BClassLoader->CClassLoader,表示AClassLoader在加载类的时候会委托BClassLoader类加载器来加载,BClassLoader加载类的时候会委托CClassLoader来加载,假如我们使用AClassLoader来加载X这个类,而X这个类最终是被CClassLoader来加载的,那么我们称CClassLoader为X类的定义类加载器,而AClassLoader为X类的初始类加载器,JVM在加载某个类的时候对AClassLoader和CClassLoader进行记录,记录的数据结构是一个叫做SystemDictionary的hashtable,其key是根据ClassLoader对象和类名算出来的hash值(其实是一个entry,可以根据这个hash值找到具体的index位置,然后构建一个包含kalssName和classloader对象的entry放到map里),而value是真正的由定义类加载器加载的Klass对象,因为初始类加载器和定义类加载器是不同的classloader,因此算出来的hash值也是不同的,因此在SystemDictionary里会有多项值的value都是指向同一个Klass对象。
那么JVM为什么要分这两种类加载器呢,其实主要是为了快速找到已经加载的类,比如我们已经通过AClassLoader来触发了对X类的加载,当我们再次使用AClassLoader这个类加载器来加载X这个类的时候就不需要再委托给BClassLoader去找了,因为加载过的类在JVM里有这个类加载器的直接加载的记录,只需要直接返回对应的Klass对象即可。
我们的demo里发现构建了一个CompositeClassLoader的类加载器,那到底有没有用这个类加载器加载类呢,我们可以设置一个断点在CompositeClassLoader的loadClass方法上,于是看到下面的堆栈:
1 2 3 4 5 6 7 8 9 |
|
可见确实有类加载的动作,根据类加载委托机制,在这个demo中我们能肯定类是交给AppClassLoader来加载的,这样一来CompositeClassLoader就变成了初始类加载器,而AppClassLoader会是定义类加载器,都会在SystemDictionary里存在,因此当我们不断new XStream的时候会不断new CompositeClassLoader对象,加载类的时候会不断往SystemDictionary里插入记录,从而使SystemDictionary越来越膨胀,那自然而然会想到如果GC过程不断去扫描这个SystemDictionary的话,那随着SystemDictionary不断膨胀,那么GC的效率也就越低,抱着验证下猜想的方式我们可以使用perf工具来看看,如果发现cpu占比排前的函数如果都是操作SystemDictionary的,那就基本验证了我们的说法,下面是perf工具的截图,基本证实了这一点。
想象一下这么个情况,我们加载了一个类,然后构建了一个对象(这个对象在eden里构建)当一个属性设置到这个类里,如果gc发生的时候,这个对象是不是要被找出来标活才行,那么自然而然我们加载的类肯定是我们一项重要的gc root,这样SystemDictionary就成为了gc过程中的被扫描对象了,事实也是如此,可以看vm的具体代码:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
|
看上面的SH_PS_SystemDictionary_oops_do
task就知道了,这个就是对SystemDictionary进行扫描。
但是这里要说的是虽然有对SystemDictionary进行扫描,但是ygc的过程并不会对SystemDictionary进行处理,如果要对它进行处理需要开启类卸载的vm参数,CMS算法下,CMS GC和Full GC在开启CMSClassUnloadingEnabled的情况下是可能对类做卸载动作的,此时会对SystemDictionary进行清理,所以当我们在跑上面demo的时候,通过jmap -dump:live,format=b,file=heap.bin <pid>
命令执行完之后,ygc的时间瞬间降下来了,不过又会慢慢回去,这是因为jmap的这个命令会做一次gc,这个gc过程会对SystemDictionary进行清理。
很遗憾hotspot目前没有对ygc的每个task做一个时间的统计,因此无法直接知道是不是SH_PS_SystemDictionary_oops_do
这个task导致了ygc的时间变长,为了证明这个结论,我特地修改了一下代码,在上面的代码上加了一行:
1 2 3 4 5 6 7 8 |
|
然后重新编译,跑我们的demo,测试结果如下:
1 2 3 4 5 6 7 8 |
|
我们会发现YGC的时间变长的时候,SystemDictionary_OOPS_DO的时间也会相应变长多少,因此验证了我们的说法。
]]>如果java层面发生了死锁,当我们使用jstack
命令的时候其实是可以将死锁的信息给dump出来的,在dump结果的最后会有类似Found one Java-level deadlock:
的关键字,接着会把发生死锁的线程的堆栈及对应的同步锁给打印出来,这次碰到一个系统就发生类似的问题,不过这个dump文档里虽然提到了如下的死锁信息:
1 2 3 4 5 6 7 8 9 10 11 |
|
但是我们在堆栈里搜索对应的锁的时候并没发现,也就是上面提到的
1
|
|
我们在HSFBizProcessor-4-thread-4
这个线程的堆栈里并没有看到对应的持锁信息。
附上线程dump详情
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
|
首先应该怀疑类加载的问题,因为我们看到导致死锁的对象是一个classloader对象:
1
|
|
然后我们再来分析下堆栈
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
我这里只把关键的线程栈贴出来,从栈顶知道正在等一把锁:
1
|
|
这把锁的对象是一个ClassLoader对象,我们找到对应的代码,确实存在synchronized的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
另外我们还知道它正在执行loadClass的动作,并且是从groovy调用来的,同样找到对应的代码:
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 |
|
执行到第238行的时候
1
|
|
突然发现调用了
1
|
|
而我们看到上面第238行的逻辑其实就是实例化一个对象,然后进行强转,我们看看对应的字节码:
1 2 3 4 |
|
其实就对应这么几条字节码指令,其实在jvm里当我们执行checkcast指令的时候会触发类加载的动作:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
|
SystemDictionary::resolve_instance_class_or_null
这个方法非常关键了,在里面我们看到会获取一把锁ObjectLocker,其相当于我们java代码里的synchronized
关键字,而对象对应的是lockObject,这个对象是上面的SystemDictionary::compute_loader_lock_object
方法返回的,从代码可知只要不是bootstrapClassloader加载的类就会返回当前classloader对象,也就是说当我们在加载一个类的时候其实是会持有当前类加载对象的锁的,在获取了这把锁之后就会调用ClassLoader.loadClass来加载类了。这其实就解释了HSFBizProcessor-4-thread-4
这个线程为什么持有了
1
|
|
这个类加载的锁,不过遗憾的是因为这把锁不是java层面来显示加载的,因此我们在jstack
线程dump的输出里居然看不到这把锁的存在.
先上堆栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
这个线程栈其实和之前那个线程差不多,只是等的锁不一样,另外触发类加载的动作是Class.forName
,获取大家也猜到了,其实是在下面两行堆栈之间同样获取了一把类加载器的锁
1 2 |
|
这里的代码我也不细贴了,最终调用的jvm里的方法都是一样的,获取锁的逻辑也是一样的
想象下这种场景,两个线程分别使用不同的classloader对两个类进行类加载,然而由于osgi类加载机制的缘故,在loadClass过程中可能会委托给别的classloader去加载,而正巧,这两个线程在获取当前classloader的锁之后,然后分别委托对方的classloader去加载,可以看到文章开头列的那个findLoadedClass方法,而synchronized的那个classloader正好是对方的classloader,从而导致了死锁
]]>注:文章首发于InfoQ:
JVM源码分析之javaagent原理完全解读
本文重点讲述javaagent的具体实现,因为它面向的是我们java程序员,而且agent都是用java编写的,不需要太多的c/c++编程基础,不过这篇文章里也会讲到JVMTIAgent(c实现的),因为javaagent的运行还是依赖于一个特殊的JVMTIAgent。
对于javaagent或许大家都听过,甚至使用过,常见的用法大致如下:
1
|
|
我们通过-javaagent来指定我们编写的agent的jar路径(./myagent.jar)及要传给agent的参数(mode=test),这样在启动的时候这个agent就可以做一些我们想要它做的事了。
javaagent的主要的功能如下:
想象一下可以让程序按照我们预期的逻辑去执行,听起来是不是挺酷的。
JVMTI全称JVM Tool Interface,是jvm暴露出来的一些供用户扩展的接口集合,JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。
比如说我们最常见的想在某个类的字节码文件读取之后类定义之前能修改相关的字节码,从而使创建的class对象是我们修改之后的字节码内容,那我们就可以实现一个回调函数赋给JvmtiEnv(JVMTI的运行时,通常一个JVMTIAgent对应一个jvmtiEnv,但是也可以对应多个)的回调方法集合里的ClassFileLoadHook,这样在接下来的类文件加载过程中都会调用到这个函数里来了,大致实现如下:
1 2 3 4 5 6 7 8 |
|
JVMTIAgent其实就是一个动态库,利用JVMTI暴露出来的一些接口来干一些我们想做但是正常情况下又做不到的事情,不过为了和普通的动态库进行区分,它一般会实现如下的一个或者多个函数:
1 2 3 4 5 6 7 8 |
|
Agent_OnLoad
函数,如果agent是在启动的时候加载的,也就是在vm参数里通过-agentlib来指定,那在启动过程中就会去执行这个agent里的Agent_OnLoad
函数。Agent_OnAttach
函数,如果agent不是在启动的时候加载的,是我们先attach到目标进程上,然后给对应的目标进程发送load命令来加载agent,在加载过程中就会调用Agent_OnAttach
函数。Agent_OnUnload
函数,在agent做卸载的时候调用,不过貌似基本上很少实现它。其实我们每天都在和JVMTIAgent打交道,只是你可能没有意识到而已,比如我们经常使用eclipse等工具对java代码做调试,其实就利用了jre自带的jdwp agent来实现的,只是由于eclipse等工具在没让你察觉的情况下将相关参数(类似-agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:61349
)给自动加到程序启动参数列表里了,其中agentlib参数就是用来跟要加载的agent的名字,比如这里的jdwp(不过这不是动态库的名字,而JVM是会做一些名称上的扩展,比如在linux下会去找libjdwp.so
的动态库进行加载,也就是在名字的基础上加前缀lib
,再加后缀.so
),接下来会跟一堆相关的参数,会将这些参数传给Agent_OnLoad
或者Agent_OnAttach
函数里对应的options
参数。
说到javaagent必须要讲的是一个叫做instrument的JVMTIAgent(linux下对应的动态库是libinstrument.so),因为就是它来实现javaagent的功能的,另外instrument agent还有个别名叫JPLISAgent(Java Programming Language Instrumentation Services Agent),从这名字里也完全体现了其最本质的功能:就是专门为java语言编写的插桩服务提供支持的。
instrument agent实现了Agent_OnLoad
和Agent_OnAttach
两方法,也就是说我们在用它的时候既支持启动的时候来加载agent,也支持在运行期来动态来加载这个agent,其中启动时加载agent还可以通过类似-javaagent:myagent.jar
的方式来间接加载instrument agent,运行期动态加载agent依赖的是jvm的attach机制JVM Attach机制实现,通过发送load命令来加载agent。
instrument agent的核心数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
这里解释下几个重要项:
* mNormalEnvironment:主要提供正常的类transform及redefine功能的。
* mRetransformEnvironment:主要提供类retransform功能的。
* mInstrumentationImpl:这个对象非常重要,也是我们java agent和JVM进行交互的入口,或许写过javaagent的人在写premain
以及agentmain
方法的时候注意到了有个Instrumentation的参数,这个参数其实就是这里的对象。
* mPremainCaller:指向sun.instrument.InstrumentationImpl.loadClassAndCallPremain
方法,如果agent是在启动的时候加载的,那该方法会被调用。
* mAgentmainCaller:指向sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain
方法,该方法在通过attach的方式动态加载agent的时候调用。
* mTransform:指向sun.instrument.InstrumentationImpl.transform
方法。
* mAgentClassName:在我们javaagent的MANIFEST.MF里指定的Agent-Class
。
* mOptionsString:传给agent的一些参数。
* mRedefineAvailable:是否开启了redefine功能,在javaagent的MANIFEST.MF里设置Can-Redefine-Classes:true
。
* mNativeMethodPrefixAvailable:是否支持native方法前缀设置,通样在javaagent的MANIFEST.MF里设置Can-Set-Native-Method-Prefix:true
。
* mIsRetransformer:如果在javaagent的MANIFEST.MF文件里定义了Can-Retransform-Classes:true
,那将会设置mRetransformEnvironment的mIsRetransformer为true。
正如『概述』里提到的方式,就是启动的时候加载instrument agent,具体过程都在InvocationAdapter.c
的Agent_OnLoad
方法里,简单描述下过程:
loadClassAndCallPremain
方法,在这个方法里会去调用javaagent里MANIFEST.MF里指定的Premain-Class
类的premain方法运行时加载的方式,大致按照下面的方式来操作:
1 2 |
|
上面会通过jvm的attach机制来请求目标jvm加载对应的agent,过程大致如下:
loadClassAndCallAgentmain
方法,在这个方法里会去调用javaagent里MANIFEST.MF里指定的Agent-Class
类的agentmain
方法不管是启动时还是运行时加载的instrument agent都关注着同一个jvmti事件—ClassFileLoadHook
,这个事件是在读取字节码文件之后回调时用的,这样可以对原来的字节码做修改,那这里面究竟是怎样实现的呢?
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 32 |
|
先根据jvmtiEnv取得对应的JPLISEnvironment,因为上面我已经说到其实有两个JPLISEnvironment(并且有两个jvmtiEnv),其中一个专门做retransform的,而另外一个用来做其他的事情,根据不同的用途我们在注册具体的ClassFileTransformer的时候也是分开的,对于作为retransform用的ClassFileTransformer我们会注册到一个单独的TransformerManager里。
接着调用transformClassFile方法,由于函数实现比较长,我这里就不贴代码了,大致意思就是调用InstrumentationImpl对象的transform方法,根据最后那个参数来决定选哪个TransformerManager里的ClassFileTransformer对象们做transform操作。
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
|
以上是最终调到的java代码,可以看到已经调用到我们自己编写的javaagent代码里了,我们一般是实现一个ClassFileTransformer类,然后创建一个对象注册了对应的TransformerManager里。
这里说的class transform其实是狭义的,主要是针对第一次类文件加载的时候就要求被transform的场景,在加载类文件的时候发出ClassFileLoad的事件,然后交给instrumenat agent来调用javaagent里注册的ClassFileTransformer实现字节码的修改。
类重新定义,这是Instrumentation提供的基础功能之一,主要用在已经被加载过的类上,想对其进行修改,要做这件事,我们必须要知道两个东西,一个是要修改哪个类,另外一个是那个类你想修改成怎样的结构,有了这两信息之后于是你就可以通过InstrumentationImpl的下面的redefineClasses方法去操作了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
在JVM里对应的实现是创建一个VM_RedefineClasses
的VM_Operation
,注意执行它的时候会stop the world的:
1 2 3 4 5 6 7 |
|
这个过程我尽量用语言来描述清楚,不详细贴代码了,因为代码量实在有点大:
上面是基本的过程,总的来说就是只更新了类里内容,相当于只更新了指针指向的内容,并没有更新指针,避免了遍历大量已有类对象对它们进行更新带来的开销。
retransform class可以简单理解为回滚操作,具体回滚到哪个版本,这个需要看情况而定,下面不管那种情况都有一个前提,那就是javaagent已经要求要有retransform的能力了:
我们从InstrumentationImpl的retransformClasses
方法参数看猜到应该是做回滚操作,因为我们只指定了class
1 2 3 4 5 6 7 8 |
|
不过retransform的实现其实也是通过redefine的功能来实现,在类加载的时候有比较小的差别,主要体现在究竟会走哪些transform上,如果当前是做retransform的话,那将忽略那些注册到正常的TransformerManager里的ClassFileTransformer,而只会走专门为retransform而准备的TransformerManager的ClassFileTransformer,不然想象一下字节码又被无声无息改成某个中间态了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
javaagent除了做字节码上面的修改之外,其实还有一些小功能,有时候还是挺有用的
1
|
|
1
|
|
1
|
|
1
|
|
1
|
|
1
|
|
最近经常被问到一个问题,"为什么我们系统进程占用的物理内存(Res/Rss)会远远大于设置的Xmx值",比如Xmx设置1.7G,但是top看到的Res的值却达到了3.0G,随着进程的运行,Res的值还在递增,直到达到某个值,被OS当做bad process直接被kill掉了。
1 2 3 4 5 6 7 |
|
先说下Xmx,这个vm配置只包括我们熟悉的新生代和老生代的最大值,不包括持久代,也不包括CodeCache,还有我们常听说的堆外内存从名字上一看也知道没有包括在内,当然还有其他内存也不会算在内等,因此理论上我们看到物理内存大于Xmx也是可能的,不过超过太多估计就可能有问题了。
我们知道os在内存上面的设计是花了心思的,为了让资源得到最大合理利用,在物理内存之上搞一层虚拟地址,同一台机器上每个进程可访问的虚拟地址空间大小都是一样的,为了屏蔽掉复杂的到物理内存的映射,该工作os直接做了,当需要物理内存的时候,当前虚拟地址又没有映射到物理内存上的时候,就会发生缺页中断,由内核去为之准备一块物理内存,所以即使我们分配了一块1G的虚拟内存,物理内存上不一定有一块1G的空间与之对应,那到底这块虚拟内存块到底映射了多少物理内存呢,这个我们在linux下可以通过/proc/<pid>/smaps
这个文件看到,其中的Size表示虚拟内存大小,而Rss表示的是物理内存,所以从这层意义上来说和虚拟内存块对应的物理内存块不应该超过此虚拟内存块的空间范围
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
此次为了排查这个问题,我特地写了个简单的分析工具来分析这个问题,将连续的虚拟内存块合并做统计,一般来说连续分配的内存块还是有一定关系的,当然也不能完全肯定这种关系,得到的效果大致如下:
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 |
|
当然这只是一个简单的分析,如果更有价值需要我们挖掘更多的点出来,比如每个内存块是属于哪块memory pool,到底是什么地方分配的等,不过需要jvm支持(注:上面的第一条,其实就是new+old+perm对应的虚拟内存及其物理内存映射情况
)。
当一个进程无故消失的时候,我们一般看/var/log/message
里是否有Out of memory: Kill process
关键字(如果是java进程我们先看是否有crash日志),如果有就说明是被os因为oom而被kill了:
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 32 33 34 35 36 37 38 39 40 41 42 43 |
|
从上面我们看到了一个堆栈,也就是内核里选择被kill进程的过程,这个过程会对进程进行一系列的计算,每个进程都会给它们计算一个score,这个分数会记录在/proc/<pid>/oom_score
里,通常这个分数越高,就越危险,被kill的可能性就越大,下面将内核相关的代码贴出来,有兴趣的可以看看,其中代码注释上也写了挺多相关的东西了:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
|
这是我们查这个问题首先要想到的一个地方,是否是因为什么地方不断创建DirectByteBuffer对象,但是由于没有被回收导致了内存泄露呢,之前有篇文章已经详细介绍了这种特殊对象JVM源码分析之堆外内存完全解读,对阿里内部的童鞋,可以直接使用zprofiler的heap视图里的堆外内存分析功能拿到统计结果,知道后台到底绑定了多少堆外内存还没有被回收:
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 |
|
对于动态库里频繁分配的问题,主要得使用google的perftools工具了,该工具网上介绍挺多的,就不对其用法做详细介绍了,通过该工具我们能得到native方法分配内存的情况,该工具主要利用了unix的一个环境变量LD_PRELOAD,它允许你要加载的动态库优先加载起来,相当于一个Hook了,于是可以针对同一个函数可以选择不同的动态库里的实现了,比如googleperftools就是将malloc方法替换成了tcmalloc的实现,这样就可以跟踪内存分配路径了,得到的效果类似如下:
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 |
|
从上面的输出中我们看到了zcalloc
函数总共分配了1616.3M的内存,还有Java_java_util_zip_Deflater_init
分配了1591.0M内存,deflateInit2_
分配了1590.5M,然而总共才分配了1670.0M内存,所以这几个函数肯定是调用者和被调用者的关系:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
|
上述代码也验证了他们这种关系。
那现在的问题就是找出哪里调用Java_java_util_zip_Deflater_init
了,从这方法的命名上知道它是一个java的native方法实现,对应的是java.util.zip.Deflater
这个类的init
方法,所以要知道init
方法哪里被调用了,跟踪调用栈我们会想到btrace工具,但是btrace是通过插桩的方式来实现的,对于native方法是无法插桩的,于是我们看调用它的地方,找到对应的方法,然后进行btrace脚本编写:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
于是跟踪对应的进程,我们能抓到调用Deflater构造函数的堆栈
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
从上面的堆栈我们找出了调用java.util.zip.Deflate.init()
的地方
上面已经定位了具体的代码了,于是再细致跟踪了下对应的代码,其实并不是代码实现上的问题,而是代码设计上没有考虑到流量很大的场景,当流量很大的时候,不管自己系统是否能承受这么大的压力,都来者不拒,拿到数据就做deflate,而这个过程是需要分配堆外内存的,当量达到一定程度的时候此时会发生oom killer,另外我们在分析过程中发现其实物理内存是有下降的
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 |
|
这也就说明了其实代码使用上并没有错,因此建议将deflate放到队列里去做,比如限制队列大小是100,每次最多100个数据可以被deflate,处理一个放进一个,以至于不会被活活撑死。
]]>注:文章首发于InfoQ:
JVM源码分析之FinalReference
JAVA对象引用体系除了强引用之外,出于对性能、可扩展性等方面考虑还特地实现了四种其他引用:SoftReference、WeakReference、PhantomReference、FinalReference,本文主要想讲的是FinalReference,因为我们在使用内存分析工具比如zprofiler、mat等在分析一些oom的heap的时候,经常能看到 java.lang.ref.Finalizer
占用的内存大小远远排在前面,而这个类占用的内存大小又和我们这次的主角FinalReference
有着密不可分的关系。
对于FinalReference及关联的内容,我们可能有如下印象:
* 自己代码里从没有使用过
* 线程dump之后,我们能看到一个叫做Finalizer
的java线程
* 偶尔能注意到java.lang.ref.Finalizer
的存在
* 我们在类里可能会写finalize方法
那FinalReference到底存在的意义是什么,以怎样的形式和我们的代码相关联呢,这是本文要理清的问题。
首先我们看看FinalReference在JDK里的实现:
1 2 3 4 5 6 7 |
|
大家应该注意到了类访问权限是package的,这也就意味着我们不能直接去对其进行扩展,但是JDK里对此类进行了扩展实现java.lang.ref.Finalizer
,这个类也是我们在概述里提到的,而此类的访问权限也是package的,并且是final的,意味着真的不能被扩展了,接下来的重点我们围绕java.lang.ref.Finalizer
展开(PS:后续讲Finalizer相关的其实也就是在说FinalReference)
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 32 33 34 35 36 37 38 39 40 |
|
从构造函数上我们获得下面的几个关键信息 * private:意味着我们在外面无法自己构建这类对象 * finalizee参数:FinalReference指向的对象引用 * 调用add方法:将当前对象插入到Finalizer对象链里,链里的对象和Finalizer类静态相关联,言外之意是在这个链里的对象都无法被gc掉,除非将这种引用关系剥离掉(因为Finalizer类无法被unload)
虽然外面无法创建Finalizer对象,但是注意到有一个register的静态方法,在方法里会创建这种对象,同时将这个对象加入到Finalizer对象链里,这个方法是被vm调用的,那么问题来了,vm在什么情况下会调用这个方法呢?
类其实有挺多的修饰,比如final,abstract,public等等,如果一个类有final修饰,我们就说这个类是一个final类,上面列的都是语法层面我们可以显示标记的,在jvm里其实还给类标记其他一些符号,比如finalizer,表示这个类是一个finalizer类(为了和java.lang.ref.Fianlizer类进行区分,下文要提到的finalizer类的地方都说成f类),gc在处理这种类的对象的时候要做一些特殊的处理,如在这个对象被回收之前会调用一下它的finalize方法。
在讲这个问题之前,我们先来看下java.lang.Object
里的一个方法
1
|
|
在Object类里定义了一个名为finalize的空方法,这意味着Java世界里的所有类都会继承这个方法,甚至可以覆写该方法,并且根据方法覆写原则,如果子类覆盖此方法,方法访问权限都是至少是protected级别的,这样其子类就算没有覆写此方法也会继承此方法。
而判断当前类是否是一个f类的标准并不仅仅是当前类是否含有一个参数为空,返回值为void的名为finalize的方法,而另外一个要求是finalize方法必须非空
,因此我们的Object类虽然含有一个finalize方法,但是并不是一个f类,Object的对象在被gc回收的时候其实并不会去调用它的finalize方法。
需要注意的是我们的类在被加载过程中其实就已经被标记为是否为f类了(遍历所有方法,包括父类的方法,只要有一个非空的参数为空返回void的finalize方法就认为是一个f类)。
对象的创建其实是被拆分成多个步骤的,比如A a=new A(2)
这样一条语句对应的字节码如下:
1 2 3 4 |
|
先执行new分配好对象空间,然后再执行invokespecial调用构造函数,jvm里其实可以让用户选择在这两个时机中的任意一个将当前对象传递给Finalizer.register方法来注册到Finalizer对象链里,这个选择依赖于RegisterFinalizersAtInit这个vm参数是否被设置,默认值为true,也就是在调用构造函数返回之前调用Finalizer.register方法,如果通过-XX:-RegisterFinalizersAtInit关闭了该参数,那将在对象空间分配好之后就将这个对象注册进去。
另外需要提一点的是当我们通过clone的方式复制一个对象的时候,如果当前类是一个f类,那么在clone完成的时候将调用Finalizer.register方法进行注册。
这个实现比较有意思,在这里简单提一下,我们知道一个构造函数执行的时候,会去调用父类的构造函数,主要是为了能对继承自父类的属性也能做初始化,那么任何一个对象的初始化最终都会调用到Object的空构造函数里(任何空的构造函数其实并不空,会含有三条字节码指令,如下代码所示),为了不对所有的类的构造函数都做埋点调用Finalizer.register方法,hotspot的实现是在Object这个类在做初始化的时候将构造函数里的return
指令替换为_return_register_finalizer
指令,该指令并不是标准的字节码指令,是hotspot扩展的指令,这样在处理该指令的时候调用Finalizer.register方法,这样就在侵入性很小的情况下完美地解决了这个问题。
1 2 3 |
|
在Finalizer类的clinit方法(静态块)里我们看到它会创建了一个FinalizerThread的守护线程,这个线程的优先级并不是最高的,意味着在cpu很紧张的情况下其被调度的优先级可能会受到影响
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 |
|
这个线程主要就是从queue里取Finalizer对象,然后执行该对象的runFinalizer方法,这个方法主要是将Finalizer对象从Finalizer对象链里剥离出来,这样意味着下次gc发生的时候就可能将其关联的f对象gc掉了,最后将这个Finalizer对象关联的f对象传给了一个native方法invokeFinalizeMethod
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
其实invokeFinalizeMethod方法就是调了这个f对象的finalize方法,看到这里大家应该恍然大悟了,整个过程都串起来了
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
不知道大家有没有想过如果f对象的finalize方法抛了一个没捕获的异常,这个FinalizerThread会不会退出呢,细心的读者看上面的代码其实就可以找到答案,在runFinalizer方法里对Throwable的异常都进行了捕获,因此不可能出现FinalizerThread因异常未捕获而退出的情况。
如果我们在f对象的finalize方法里重新将当前对象赋值出去,变成可达对象,当这个f对象再次变成不可达的时候还会被执行finalize方法吗?答案是否定的,因为在执行完第一次finalize方法之后,这个f对象已经和之前的Finalizer对象关系剥离了,也就是下次gc的时候不会再发现Finalizer对象指向该f对象了,自然也就不会调用这个f对象的finalize方法了。
除了这里要说的环节之外,整个过程大家应该都比较清楚了。
当gc发生的时候,gc算法会判断f类对象是不是只被Finalizer类引用(f类对象被Finalizer对象引用,然后放到Finalizer对象链里),如果这个类仅仅被Finalizer对象引用的时候,说明这个对象在不久的将来会被回收了现在可以执行它的finalize方法了,于是会将这个Finalizer对象放到Finalizer类的ReferenceQueue里,但是这个f类对象其实并没有被回收,因为Finalizer这个类还对他们持有引用,在gc完成之前,jvm会调用ReferenceQueue里的lock对象的notify方法(当ReferenceQueue为空的时候,FinalizerThread线程会调用ReferenceQueue的lock对象的wait方法直到被jvm唤醒),此时就会执行上面FinalizeThread线程里看到的其他逻辑了。
这里举一个简单的例子,我们使用挺广的socket通信,SocksSocketImpl的父类其实就实现了finalize方法:
1 2 3 4 5 6 |
|
其实这么做的主要目的是万一用户忘记关闭socket了,那么在这个对象被回收的时候能主动关闭socket来释放一些系统资源,但是如果真的是用户忘记关闭了,那这些socket对象可能因为FinalizeThread迟迟没有执行到这些socket对象的finalize方法,而导致内存泄露,这种问题我们碰到过多次,需要特别注意的是对于已经没有地方引用的这些f对象,并不会在最近的那一次gc里马上回收掉,而是会延迟到下一个或者下几个gc时才被回收,因为执行finalize方法的动作无法在gc过程中执行,万一finalize方法执行很长呢,所以只能在这个gc周期里将这个垃圾对象重新标活,直到执行完finalize方法从queue里删除,这样下次gc的时候就真的是漂浮垃圾了会被回收,因此给大家的一个建议是千万不要在运行期不断创建f对象,不然会很悲剧。
上面的过程基本对Finalizer的实现细节进行完整剖析了,java里我们看到有构造函数,但是并没有看到析构函数一说,Finalizer其实是实现了析构函数的概念,我们在对象被回收前可以执行一些『收拾性』的逻辑,应该说是一个特殊场景的补充,但是这种概念的实现给我们的f对象生命周期以及gc等带来了一些影响: * f对象因为Finalizer的引用而变成了一个临时的强引用,即使没有其他的强引用了,还是无法立即被回收 * f对象至少经历两次GC才能被回收,因为只有在FinalizerThread执行完了f对象的finalize方法的情况下才有可能被下次gc回收,而有可能期间已经经历过多次gc了,但是一直还没执行f对象的finalize方法 * cpu资源比较稀缺的情况下FinalizerThread线程有可能因为优先级比较低而延迟执行f对象的finalize方法 * 因为f对象的finalize方法迟迟没有执行,有可能会导致大部分f对象进入到old分代,此时容易引发old分代的gc,甚至fullgc,gc暂停时间明显变长 * f对象的finalize方法被调用了,但是这个对象其实还并没有被回收,虽然可能在不久的将来会被回收
]]>说到堆外内存,那大家肯定想到堆内内存,这也是我们大家接触最多的,我们在jvm参数里通常设置-Xmx来指定我们的堆的最大值,不过这还不是我们理解的Java堆,-Xmx的值是新生代和老生代的和的最大值,我们在jvm参数里通常还会加一个参数-XX:MaxPermSize来指定持久代的最大值,那么我们认识的Java堆的最大值其实是-Xmx和-XX:MaxPermSize的总和,在分代算法下,新生代,老生代和持久代是连续的虚拟地址,因为它们是一起分配的,那么剩下的都可以认为是堆外内存(广义的)了,这些包括了jvm本身在运行过程中分配的内存,codecache,jni里分配的内存,DirectByteBuffer分配的内存等等
而作为java开发者,我们常说的堆外内存溢出了,其实是狭义的堆外内存,这个主要是指java.nio.DirectByteBuffer在创建的时候分配内存,我们这篇文章里也主要是讲狭义的堆外内存,因为它和我们平时碰到的问题比较密切
DirectByteBuffer通常用在通信过程中做缓冲池,在mina,netty等nio框架中屡见不鲜,先来看看JDK里的实现:
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 |
|
通过上面的构造函数我们知道,真正的内存分配是使用的Bits.reserveMemory方法
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 32 33 |
|
通过上面的代码我们知道可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存,那么我们首先引入两个问题
如果我们没有通过-XX:MaxDirectMemorySize来指定最大的堆外内存,那么默认的最大堆外内存是多少呢,我们还是通过代码来分析
上面的代码里我们看到调用了sun.misc.VM.maxDirectMemory()
1 2 3 4 5 6 7 8 9 |
|
看到上面的代码之后是不是误以为默认的最大值是64M?其实不是的,说到这个值得从java.lang.System这个类的初始化说起
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 32 33 34 35 36 |
|
上面这个方法在jvm启动的时候对System这个类做初始化的时候执行的,因此执行时间非常早,我们看到里面调用了sun.misc.VM.saveAndRemoveProperties(props)
:
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
如果我们通过-Dsun.nio.MaxDirectMemorySize指定了这个属性,只要它不等于-1,那效果和加了-XX:MaxDirectMemorySize一样的,如果两个参数都没指定,那么最大堆外内存的值来自于directMemory = Runtime.getRuntime().maxMemory()
,这是一个native方法
1 2 3 4 5 6 7 8 9 10 11 |
|
其中在我们使用CMS GC的情况下的实现如下,其实是新生代的最大值-一个survivor的大小+老生代的最大值,也就是我们设置的-Xmx的值里除去一个survivor的大小就是默认的堆外内存的大小了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
既然要调用System.gc,那肯定是想通过触发一次gc操作来回收堆外内存,不过我想先说的是堆外内存不会对gc造成什么影响(这里的System.gc除外),但是堆外内存的回收其实依赖于我们的gc机制,首先我们要知道在java层面和我们在堆外分配的这块内存关联的只有与之关联的DirectByteBuffer对象了,它记录了这块内存的基地址以及大小,那么既然和gc也有关,那就是gc能通过操作DirectByteBuffer对象来间接操作对应的堆外内存了。DirectByteBuffer对象在创建的时候关联了一个PhantomReference,说到PhantomReference它其实主要是用来跟踪对象何时被回收的,它不能影响gc决策,但是gc过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在gc完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,而DirectByteBuffer关联的PhantomReference是PhantomReference的一个子类,在最终的处理里会通过Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块
JDK里ReferenceHandler的实现:
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 32 33 34 35 |
|
可见如果pending为空的时候,会通过lock.wait()一直等在那里,其中唤醒的动作是在jvm里做的,当gc完成之后会调用如下的方法VM_GC_Operation::doit_epilogue(),在方法末尾会调用lock的notify操作,至于pending队列什么时候将引用放进去的,其实是在gc的引用处理逻辑中放进去的,针对引用的处理后面可以专门写篇文章来介绍
1 2 3 4 5 6 7 8 9 10 11 12 |
|
对于System.gc的实现,之前写了一篇文章来重点介绍,JVM源码分析之SystemGC完全解读,它会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存,我们dump内存发现DirectByteBuffer对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为『冰山对象』,我们做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题,如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为heap明明剩余的内存还很多(前提是我们禁用了System.gc)。
DirectByteBuffer在创建的时候会通过Unsafe的native方法来直接使用malloc分配一块内存,这块内存是heap之外的,那么自然也不会对gc造成什么影响(System.gc除外),因为gc耗时的操作主要是操作heap之内的对象,对这块内存的操作也是直接通过Unsafe的native方法来操作的,相当于DirectByteBuffer仅仅是一个壳,还有我们通信过程中如果数据是在Heap里的,最终也还是会copy一份到堆外,然后再进行发送,所以为什么不直接使用堆外内存呢。对于需要频繁操作的内存,并且仅仅是临时存在一会的,都建议使用堆外内存,并且做成缓冲池,不断循环利用这块内存。
如果我们大面积使用堆外内存并且没有限制,那迟早会导致内存溢出,毕竟程序是跑在一台资源受限的机器上,因为这块内存的回收不是你直接能控制的,当然你可以通过别的一些途径,比如反射,直接使用Unsafe接口等,但是这些务必给你带来了一些烦恼,Java与生俱来的优势被你完全抛弃了—开发不需要关注内存的回收,由gc算法自动去实现。另外上面的gc机制与堆外内存的关系也说了,如果一直触发不了cms gc或者full gc,那么后果可能很严重。
]]>JVM的GC一般情况下是JVM本身根据一定的条件触发的,不过我们还是可以做一些人为的触发,比如通过jvmti做强制GC,通过System.gc触发,还可以通过jmap来触发等,针对每个场景其实我们都可以写篇文章来做一个介绍,本文重点介绍下System.gc的原理
或许大家已经知道如下相关的知识
如果你已经知道上面这些了其实也说明你对System.gc有过一定的了解,至少踩过一些坑,但是你是否更深层次地了解过它,比如
如果你上面这些疑惑也都知道,那说明你很懂System.gc了,那么接下来的文字你可以不用看啦
先贴段代码吧(java.lang.System)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
发现主要调用的是Runtime里的gc方法(java.lang.Runtime)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
这里看到gc方法是native的,在java层面只能到此结束了,代码只有这么多,要了解更多,可以看方法上面的注释,不过我们需要更深层次地来了解其实现,那还是准备好进入到jvm里去看看
上面提到了Runtime.gc是一个本地方法,那需要先在jvm里找到对应的实现,这里稍微提一下jvm里native方法最常见的也是最简单的查找,jdk里一般含有native方法的类,一般都会有一个对应的c文件,比如上面的java.lang.Runtime这个类,会有一个Runtime.c的文件和它对应,native方法的具体实现都在里面了,如果你有source,可能会猜到和下面的方法对应
1 2 3 4 5 |
|
其实没错的,就是这个方法,jvm要查找到这个native方法其实很简单的,看方法名可能也猜到规则了,Java_pkgName_className_methodName,其中pkgName里的".“替换成”_“,这样就能找到了,当然规则不仅仅只有这么一个,还有其他的,这里不细说了,有机会写篇文章详细介绍下其中细节
上面的方法里是调用JVM_GC(),实现如下
1 2 3 4 5 6 |
|
看到这里我们已经解释其中一个疑惑了,就是DisableExplicitGC
这个参数是在哪里生效的,起的什么作用,如果这个参数设置为true的话,那么将直接跳过下面的逻辑,我们通过-XX:+ DisableExplicitGC就是将这个属性设置为true,而这个属性默认情况下是true还是false呢
1 2 |
|
这里主要针对CMSGC下来做分析,所以我们上面看到调用了heap的collect方法,我们找到对应的逻辑
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 |
|
collect里一开头就有个判断,如果should_do_concurrent_full_gc返回true,那会执行collect_mostly_concurrent做并行的回收
其中should_do_concurrent_full_gc中的逻辑是如果使用CMS GC,并且是system gc且ExplicitGCInvokesConcurrent==true,那就做并行full gc,当我们设置-XX:+ ExplicitGCInvokesConcurrent的时候,就意味着应该做并行Full GC了,不过要注意千万不要设置-XX:+DisableExplicitGC,不然走不到这个逻辑里来了
说到GC,这里要先提到VMThread,在jvm里有这么一个线程不断轮询它的队列,这个队列里主要是存一些VM_operation的动作,比如最常见的就是内存分配失败要求做GC操作的请求等,在对gc这些操作执行的时候会先将其他业务线程都进入到安全点,也就是这些线程从此不再执行任何字节码指令,只有当出了安全点的时候才让他们继续执行原来的指令,因此这其实就是我们说的stop the world(STW),整个进程相当于静止了
这里必须提到CMS GC,因为这是解释并行Full GC和正常Full GC的关键所在,CMS GC我们分为两种模式background和foreground,其中background顾名思义是在后台做的,也就是可以不影响正常的业务线程跑,触发条件比如说old的内存占比超过多少的时候就可能触发一次background式的cms gc,这个过程会经历CMS GC的所有阶段,该暂停的暂停,该并行的并行,效率相对来说还比较高,毕竟有和业务线程并行的gc阶段;而foreground则不然,它发生的场景比如业务线程请求分配内存,但是内存不够了,于是可能触发一次cms gc,这个过程就必须是要等内存分配到了线程才能继续往下面走的,因此整个过程必须是STW的,因此CMS GC整个过程都是暂停应用的,但是为了提高效率,它并不是每个阶段都会走的,只走其中一些阶段,这些省下来的阶段主要是并行阶段,Precleaning、AbortablePreclean,Resizing这几个阶段都不会经历,其中sweep阶段是同步的,但不管怎么说如果走了类似foreground的cms gc,那么整个过程业务线程都是不可用的,效率会影响挺大。CMS GC具体的过程后面再写文章详细说,其过程确实非常复杂的
正常的Full GC其实是整个gc过程包括ygc和cms gc(这里说的是真正意义上的Full GC,还有些场景虽然调用Full GC的接口,但是并不会都做,有些时候只做ygc,有些时候只做cms gc)都是由VMThread来执行的,因此整个时间是ygc+cms gc的时间之和,其中CMS GC是上面提到的foreground式的,因此整个过程会比较长,也是我们要避免的
并行Full GC也通样会做YGC和CMS GC,但是效率高就搞在CMS GC是走的background的,整个暂停的过程主要是YGC+CMS_initMark+CMS_remark几个阶段
这里说的堆外内存主要针对java.nio.DirectByteBuffer,这些对象的创建过程会通过Unsafe接口直接通过os::malloc来分配内存,然后将内存的起始地址和大小存到java.nio.DirectByteBuffer对象里,这样就可以直接操作这些内存。这些内存只有在DirectByteBuffer回收掉之后才有机会被回收,因此如果这些对象大部分都移到了old,但是一直没有触发CMS GC或者Full GC,那么悲剧将会发生,因为你的物理内存被他们耗尽了,因此为了避免这种悲剧的发生,通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc来做一次full gc,以此来回收掉没有被使用的堆外内存,具体堆外内存是如何回收的,其原理机制又是怎样的,还是后面写篇详细的文章吧
]]>