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的一些基本概念。