jvm中有一个很重要的部分是堆(Heap)。
经常听到的jvm优化,也包括了在堆内存上调节参数,使得堆内存工作更顺畅。
那么堆内存的结构是怎么样的呢?
主要是存在了三个区:新生区,养老区和永久区(java8中改为元空间)。
新生区中还可细分为伊甸园(Eden)和幸存者0和幸存者1区。
怎么这么复杂?这些区都是干什么的?
其实也很好理解,通过一个比较形象的例子就可以明白。
我们知道java中新建的对象都会存在堆内存中。
他们就如同战争时期的士兵们,许许多多为国捐躯的战士们都没有姓名。
而Eden区就如同战场一般,士兵们在这里冲上了战场报效国家,也在这里壮烈牺牲。
但也有少数的士兵在战争过后生存了下来,继续参加下一场战斗。
java的对象最早的产生也在Eden区中。
同样,java对象也要为了有限的资源而抗争,当Eden区被充满时,无用的对象最后也会如同那些牺牲的士兵一样从堆中消失。
只不过,杀死他们的不是枪支,而是Minor GC算法。
通过Minor GC算法的横扫,很多没有被引用的临时对象就此消失,只有少数仍有引用的对象得到了存活。
这些对象,就被送入了幸存者区。
就像那些战争后仍然存活的士兵,就将被送入下一场战争。
不过随着战争资历的提升,他们就可能成为了战场的高级军官。
他们可能就真正来到了指挥中心,从此不用再赶往前线冒着生命危险的对象。
对应到堆中便是在经过了15次Minor GC后,依旧存活的对象,他们会被直接送入养老区。
那么,养老区也是有大小限制的,如果说养老区也满了,那会怎么办呢?
这时候,终极武器Full GC就会登场。
时代变了,我们不需要那么多指挥官了,你们退休吧!
老年代的对象也会在FullGC后被清理一部分。
但是存在一种情况,Eden区在Minor GC清理后依旧很满,并且不断向老年代输送新对象,老年代也多次触发Full GC,还是不能清除这些对象,在老年代释放允许新对象进入的空间,此时又该怎么办呢?
此时,熟悉的OOM就会出现。
OOM是java.lang.OutOfMemoryError错误,也就是说,堆内存满啦,装不下更多东西了!此时虚拟机就停止工作了。
大致的流程是这样,但是具体的细节还有那么一些小不同。
我们来详细分解一下Minor GC是如何清理新生区的。
一般情况下,新生区的Eden和两个Survivor占比为8:1:1。
而Survivor区通常被分别称为SurvivorFrom和SurvivorTo。
但是他们两个并不是固定的。
我们从头模拟一下整个过程。
当第一次Eden满的时候,便会触发首次Minor GC清理Eden区,如同战争般,会有一定数量的对象幸存,那么他们会被拷贝到SurvivorFrom区。
继续,此时Eden又满了,这次Minor GC并不会只清理Eden区,而是会将上次幸存的在SurvivorFrom中的对象一并清理。
那这次幸存的对象,不再进入SurvivorFrom了,而是被拷贝到SurvivorTo。
由于战功卓著,这次存活的对象会被记一次功勋,也就是对象的年龄会被+1,一旦其中有对象的年龄到达了15,他们便会被送入老年区。
还没结束,为了能够使算法没有那么复杂,jvm会将SurvivorFrom和SurvivorTo进行互换。这样上次战争存活到SurvivorTo的对象又回到了SurvivorFrom,之前的Minor GC过程便可以重复执行了。
Full GC就只有等老年代也充满之后才会触发,但触发的过程中,也会调用多次Minor GC,从而最终达到较优的清理效果。
知道了处理流程,我们继续深挖这两个GC其中运用的算法。
通常的GC算法分为四个:引用计数、复制算法、标记清除、标记压缩。
由于引用计数在每次对象赋值都要进行维护,消耗比较大,并且循环引用较难处理,所以目前jvm实现一般不采用这种方式。
而另外三种是采用的较多的方式。
复制算法也就是刚才提到的新生代中采用的算法,在GC后找到存活的对象,并且拷贝到SurvivorTo区,之后将SurvivorTo与SurvivorFrom交换,下次复制从原本的SurvivorTo开始,也就是交换后的SurvivorFrom。
这样的算法其实也有一定的缺陷,那就是对内存的要求很高,因为要分为SurvivorTo和SurvivorFrom两个区,复制前后,总有一个区是空的。
而且如果对象很多的话,复制也需要消耗一定的时间,效率也会产生问题。
但为什么有那么多缺陷,新生代仍然使用了这种算法呢?
最主要的一点是,新生代对象的存活率很低,一般90%以上的对象都会被回收。所以即使需要复制,也不会有太多的对象等待复制,既然没有很多对象需要复制,那么内存占用的空间也不会很大。
但是Full GC的环境下使用复制算法就没有那么合适了。
Full GC需要处理的对象远远多于Minor GC,如果都要复制且开辟两块内存的话,内存可能就要溢出了。
所以也就有了标记清除(Mark-Sweep)算法。
这种算法分为标记和清除两个阶段。
在程序运行期间,一旦出现程序可能要内存溢出的情况,就暂停程序,先标记处要回收的对象,然后再进行清除,之后让程序恢复运行。
但是它也有它的缺点。
首先,效率比较低,因为要遍历堆中所有对象,而且要暂停程序,这给用户的体验就很差。
其次,由于通过标记清理内存,空闲内存的空间变得不连续,从而导致了很多内存碎片。
针对内存碎片的问题,也就有了标记压缩(Mark-Compact)这一个算法。
它在标记清除基础上再次遍历了所有对象,并将对象整合到内存的同一端,使得他们变得连续起来。
这样解决了内存碎片问题,但带来的新问题就是整合交换的过程中,也需要一定的性能消耗,可能使得本来就已经不那么高效的算法变得更低效。
Full GC就是综合了二者,形成的Mark-Sweep-Compact,也就是标记-清除-压缩算法。
不过其实问题也不大,因为Full GC的触发概率也不是很高,一般情况下不会轻易被触发。
但是老年代区域大,存活率较高,所以用复制算法就不是很合适。
因此GC算法的选择,依靠的还是不同区域不同的特点,而不是光看其性能。
讲完了这些,就来到了优化的问题,在知道jvm的堆是如何运作了之后,那势必应该让jvm最高效的运行。
堆内存主要调节的参数有两个:-Xms和-Xmx。
那这分别是什么呢?
-Xms代表的是初始内存分配大小,默认是物理内存的1/64。
-Xmx代表的是最大分配内存,默认为物理内存的1/4。
实际使用中,应当将两个值调为相同值。
为什么呢?因为我们可以看到,1/64和1/4相比较而言,其实相差还是很大的。
如果二者不相同,那么内存势必在二者之间波动,那么就可能造成内存的忽高忽低。
忽高忽低带来的一大影响就是,可能会产生停顿,使得虚拟机运行不稳定,导致一些莫名的异常。
但是,内存值也必须调到一个比较合适的值,不能太小,如果太小,很可能导致老年代很快就满了,因此会频繁触发Full GC,当Full GC仍然无法释放空间时,就会抛出OOM。
此外还要提一下java7的永久带和java8的元空间区别,永久带使用的是jvm的堆内存,而java8中的元空间不在虚拟机中而使用的是本机物理内存。
永久带的调整依赖的是参数PermSize和MaxPermSize。
java8之后的元空间不再依赖MaxPermSize来控制。
如果说我们要查看GC日志,可以通过-XX:+PrintGCDetails来查看对应的日志。
至此,介绍完了堆内存与GC的一些基本概念。