技术干货,第一时间推送
背景
最近同事W一直反馈有个服务一到高峰期超时比较严重,因为该服务是个计数服务,主要是以操作redis为准,当时想着扩一波机器缓解下,但是查看高峰期的内存和CPU,发现并没有到瓶颈,但是查看GC的时候发现高峰期的YGC占用了ms,平时YGC在20ms左右,该服务提供给外部调用的最大超时时间是50ms,因此如果高峰期YGC占用了ms,那在这ms内所有的请求都属于超时异常的,所以就想着适当调整GC来控制超时的量。
GC知识点回顾
垃圾回收(GarbageCollection)是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。
注意:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身。换言之,垃圾回收只会负责释放那些对象占有的内存。对象是个抽象的词,包括引用和其占据的内存空间。当对象没有任何引用时其占据的内存空间随即被收回备用,此时对象也就被销毁。但不能说是回收对象,可以理解为一种文字游戏。
引用
如果Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。引用又分为强引用,软引用,弱引用,虚引用。
(1)强引用(StrongReference):如“Objectobj=newObject()”,这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
(2)软引用(SoftReference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2之后提供了SoftReference类来实现软引用。
(3)弱引用(WeakReference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
(4)虚引用(PhantomReference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference类来实现虚引用。
判断对象是否是垃圾的算法
Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾回收算法一般要做2件基本的事情:
(1)找到所有存活对象;
(2)回收被无用对象占用的内存空间,使该空间可被程序再次使用。
通常有以下几种算法来判断对象是否已死:
引用计数算法(ReferenceCountingCollector)堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1(a=b,b被引用,则b引用的对象计数+1)。当引用失效时(一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时),计数器值就减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。优点:引用计数收集器执行简单,判定效率高,交织在程序运行中。对程序不被长时间打断的实时环境比较有利(OC的内存管理使用该算法)。缺点:难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历(根搜索算法)。根搜索算法(TracingCollector)首先了解一个概念:*根集(RootSet)*
所谓根集(RootSet)就是正在执行的Java程序可以访问的引用变量(注意:不是对象)的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。
这种算法的基本思路:(1)通过一系列名为“GCRoots”的对象作为起始点,寻找对应的引用节点。(2)找到这些引用节点后,从这些节点开始向下继续寻找它们的引用节点。(3)重复(2)。(4)搜索所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链相连时,就证明此对象是不可用的。
标记可达对象:
JVM中用到的所有现代GC算法在回收前都会先找出所有仍存活的对象。根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图。下图所展示的JVM中的内存布局可以用来很好地阐释这一概念:
首先,垃圾回收器将某些特殊的对象定义为GC根对象。所谓的GC根对象包括:
(1)虚拟机栈中引用的对象(栈帧中的本地变量表);
(2)方法区中的常量引用的对象;
(3)方法区中的类静态属性引用的对象;
(4)本地方法栈中JNI(Native方法)的引用对象。
(5)活跃线程。
接下来,垃圾回收器会对内存中的整个对象图进行遍历,它先从GC根对象开始,然后是根对象引用的其它对象,比如实例变量。回收器将访问到的所有对象都标记为存活。
存活对象在上图中被标记为蓝色。当标记阶段完成了之后,所有的存活对象都已经被标记完了。其它的那些(上图中灰色的那些)也就是GC根对象不可达的对象,也就是说你的应用不会再用到它们了。这些就是垃圾对象,回收器将会在接下来的阶段中清除它们。
关于标记阶段有几个关键点是值得注意的:
(1)开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便JVM可以尽情地收拾家务的这种情况又被称之为安全点(SafePoint),这会触发一次StopTheWorld(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。
(2)暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。
(3)在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:
1.如果对象在进行根搜索后发现没有与GCRoots相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
2.如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
1.JVM内存结构
整个JVM内存结构图如下表示:
Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
具体划分为如下5个内存空间:(非常重要)
.栈:存放局部变量.堆:存放所有new出来的东西.方法区:被虚拟机加载的类信息、常量、静态常量等。.程序计数器(和系统相关):每个线程拥有一个PC寄存器,在线程创建时创建,指向下一条指令的地址,执行本.地方法时,PC的值为undefined.本地方法栈
因为本次GC优化的点是调整堆,所以我们重点讲解下堆的组成成分。
堆是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。
堆在JVM启动的时候就被创建,堆中储存了各种对象,这些对象被自动管理内存系统(AutomaticStorageManagementSystem),也就是常说的“GarbageCollector(垃圾回收器)”管理。这些对象无需、也无法显示地被销毁。
JVM将Heap分为两块:新生代NewGeneration和老年代OldGeneration
堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,导致new对象的开销比较大。
SunHotspotJVM为了提升对象内存分配的效率,对于所有创建的线程都会分配一块独立的空间TLAB(ThreadLocalAllocationBuffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样的,但如果对象过大的话则仍然要直接使用堆空间分配。
TLAB仅作用于新生代的EdenSpace,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。
所有新创建的Object都将会存储在新生代YoungGeneration中。如果YoungGeneration的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在EdenSpace。
2.GC算法
1)标记-清除算法:
标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象;然后,在清除阶段,清除所有未被标记的对象。
它的做法是当堆中的有效内存空间(availablememory)被耗尽的时候,就会停止整个程序(也被成为stoptheworld),然后进行两项工作,第一项则是标记,第二项则是清除。
标记:标记的过程其实就是,遍历所有的GCRoots,然后将所有GCRoots可达的对象标记为存活的对象。清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
也就是说,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
标记-清除算法的缺点
(1)首先,它的缺点就是效率比较低(递归与全堆对象遍历),导致stoptheworld的时间比较长,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?
(2)第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
2)复制算法:(新生代的GC)
将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
.与标记-清除算法相比,复制算法是一种相对高效的回收方法.不适用于存活对象较多的场合,如老年代(复制算法适合做新生代的GC)
复制算法的最大的问题是:空间的浪费
复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,这个太要命了。
所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保,所以大对象直接进入老年代。
3)标记-整理算法:(老年代的GC)
如果在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都%存活的极端情况,所以在老年代一般不能直接选中这种算法。
标记-压缩算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记;但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。
标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GCRoots,然后将存活的对象标记。整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。上图中可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
但是,标记/整理算法唯一的缺点就是效率也不高。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
标记-清除算法、复制算法、标记整理算法的总结:
三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域,而不应该使用C/C++式内存管理方式。
在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stoptheworld)。
它们的区别如下:(表示前者要优于后者,=表示两者效果一样)
(1)效率:复制算法标记/整理算法标记/清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
(2)内存整齐度:复制算法=标记/整理算法标记/清除算法。
(3)内存利用率:标记/整理算法=标记/清除算法复制算法。
注1:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。
注2:时间与空间不可兼得。
4)分代收集算法:(新生代的GC+老年代的GC)
当前商业虚拟机的GC都是采用的“分代收集算法”,这并不是什么新的思想,只是根据对象的存活周期的不同将内存划分为几块儿。一般是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代。
少量对象存活,适合复制算法:在新生代中,每次GC时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。大量对象存活,适合用标记-清理/标记-整理:在老年代中,因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”/“标记-整理”算法进行GC。注:老年代的对象中,有一小部分是因为在新生代回收时,老年代做担保,进来的对象;绝大部分对象是因为很多次GC都没有被回收掉而进入老年代。
3.GC日志
要查看GC日志,需要设置一下jvm的参数。关于输出GC日志的参数有以下几种:
-XX:+PrintGC输出GC日志-XX:+PrintGCDetails输出GC的详细日志-XX:+PrintGCTimeStamps输出GC的时间戳(以基准时间的形式)-XX:+PrintGCDateStamps输出GC的时间戳(以日期的形式,如-05-04T21:53:59.+)-XX:+PrintHeapAtGC在进行GC的前后打印出堆的信息-Xloggc:../logs/gc.log日志文件的输出路径
通常GC的日志大概长这样:
[GC(System.gc())[PSYoungGen:K-K(K)]K-K(K),0.secs][Times:user=0.00sys=0.00,real=0.00secs][FullGC(System.gc())[PSYoungGen:K-0K(K)][ParOldGen:8K-K(K)]K-K(K),[Metaspace:K-K(K)],0.secs][Times:user=0.00sys=0.00,real=0.01secs]HeapPSYoungGentotalK,usedK[0xd5c,0xd,0x)edenspaceK,1%used[0xd5c,0xd5ca8,0xd7c8)fromspaceK,0%used[0xd7c8,0xd7c8,0xd818)tospaceK,0%used[0xd818,0xd818,0xd)ParOldGentotalK,usedK[0x814,0x8698,0xd5c)objectspaceK,0%used[0x814,0x81a0,0x8698)MetaspaceusedK,capacityK,