JVM——内存管理

在面试的时候,我们经常被问道:请描述下你对于JVM的理解。

这个问题很容易把人问懵,很多兄弟可能和我一样,懵懵懂懂的写了好几年代码,但是对于Java赖以生存的JVM并没有一个明确的概念和了解。在这里,仅对个人学习的心得做一点梳理和记录。

1.JVM中的内存划分

Java虚拟机在运行过程中,会把内存划分为几个不同的区域,我们可以参考下面这个图:

先看第一个,Java虚拟机栈。每个方法在执行的时候,都会生成一个栈帧,里面包含了方法运行所需要的一些信息如:局部变量表,操作数栈,动态链接,方法出口等。方法从调用到执行完成,对应着它的栈帧在虚拟机栈的入栈、出栈过程。它是线程私有的。

局部变量表中存储了 编译器可知的信息如:基本数据类型,对象的引用,returnAddress类型。

栈的使用中可能会出现2种异常:

StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度,将会抛出此异常。

这个异常如何理解呢?什么叫线程请求的栈深度大于虚拟机所允许的深度呢?举个典型的错误例子,你就明白了。当你的Java代码出现了错误的,无法跳出的递归调用时,往往就会抛出StackOverflowError。如下图:

OutOfMemoryError:当可动态扩展的虚拟机栈在扩展时无法申请到足够的内存,就会抛出该异常。

OOM又该如何理解呢?无法申请到足够的内存,我们举个非常简单的例子,当你申请一个非常大的数组的时候,它需要的连续内存空间非常大,那么就有可能出现OOM的错误,如下图:

知道这两个错误的意义是什么呢?避免你在写代码的时候犯傻,写出这种乍一看之下很懵逼的BUG。

Java堆,Java Heap,常规情况下,我们的项目中堆都是最大的。它是线程共享的。我们前面说了,栈中存了对象的引用(简单理解位指针),而堆中,就存储的是对象的示例了。如果用Java代码来理解,

WeChatUserInfo userInfo = new WeChatUserInfo();

这句最常见的Java代码中,userInfo这个对象的名字就是我们所说的对象的引用,我们可以认为它是存储在栈中,new WeChatUserInfo,new出来的东西,里面包含了很多字段之类的东西,这个对象的实例,我们认为它是存储在堆中。

常规情况下,我们可以简单的认为,所有的对象都存储在Heap里。

这里不难想到,Heap就相当于一个巨大的停车场。所有小区业主的车都会停在这里。有的业主会把自己的车开走,有的业主不小心忘记了自己的车这回事,很有可能就会出现停车场的停车位不够用的情况。

当然,我们可以开辟一个更大的停车场,但是,在Java程序中,内存是昂贵的,我们不可能无限制的扩大这个停车场。我们必须用某种手段定时清理这个停车场,来保证我们总是有足够的停车位可以使用。

这个手段当然有,那就是GC。当然,是Garbage Collected,垃圾回收,不是什么少儿不宜的GC。从GC的角度来看,我们可以把Heap划分如下图:

Perm,永久代,意思是不会被垃圾回收释放掉的区域,它主要存储类信息、常量、静态变量,也就是Java代码在编辑器编译后的这部分数据。

Young,新生代,新生成的对象总是先放在新生代中。它的划分更为细致,分为Eden,from,to三部分,默认配置中,比例位8:1:1。当一些对象,活过了若干次GC,还没有被回收,它会被移动到老年代。

Old,老年代,通常情况下我们可以认为老年代中的生命周期比较长,这部分的GC频率会比新生代要低。

其中,JDK1.8之前,方法区是使用永久代来实现的。这对于Spring这种使用了动态代理的框架来说,往往需要比较大的空间来存储代理生成的方法。如果空间分配不合理,会报错:java.lang.OutOfMemoryError: PermGen space。

在使用Heap时,我们可能会遇到一个异常:java.lang.OutOfMemoryError: Java heap space,这个异常的意思是,堆空间不足。但是我们之前说过,堆内存是有垃圾回收机制的,当堆空间不够用的时候,很有可能是代码出现了问题,导致对象不能正常的被垃圾回收释放掉。

那,什么样的对象会被GC掉呢?

引用计数为空的对象,什么意思呢。GC算法中有一个引用标记算法,你可以简单的理解为你Java代码new出来的对象,它被人引用的时候,计数器会+1,同样的,当它释放掉这个引用时会-1,当一个堆里面的对象实例没有被任何一个地方引用时,它就失效了。

超出作用域的对象,这个非常好理解,我们在Java的方法中,new出来的作用域局限于方法内部的局部变量,一旦Java方法运行完毕,这个对象就不会被任何地方再次引用到,它也属于应该被回收的对象。

GC Root搜索不到的对象,GC中有一个根搜索算法。可以简单的理解为,如果一个对象,从任意一个GC Root节点向下遍历,都找不到它的时候,这个对象就应该被回收。

为什么会出现有的对象始终释放不掉呢?

在上图的代码中,我们实例化了一个ArrayList,并且add了多个元素,然后在方法结束的时候,我们把userInfos = null了。

看似,我们释放了userInfos这个list,但实际上,你只是把userInfos的栈里的指针指向了一个空地址。而你最开始new出来的ArrayList它还在堆里存着,它里面存的5个WeChatUserInfo还被这个堆里的ArrayList引用着。那么这段代码无疑会导致对象无法正常GC,这一现象,我们叫做内存泄漏

内存泄漏就是指本该被释放的对象,因为某种原因无法被正常释放掉,一直占用着堆内存的现象。内存泄漏发生的次数多了,无法回收的内存会越来越多,最终会导致java.lang.OutOfMemoryError: Java heap space内存溢出。

所以,内存泄漏很可能导致内存溢出,但是内存溢出却不总是由内存泄漏引发。

学习这部分的知识,可以说对实际的Java code工作非常有意义。你只有知道什么样的代码会出问题,才能确保自己不写出那种弱智代码。

2.几种常见的GC算法。

很多时候。我们在面试中还会被问到GC相关的内容,除了前面分享的关于什么样的对象会被GC,什么样的对象会导致内存问题之外,几种常见的GC算法也很有学习的价值,我们在这里做一个简单的分享。

垃圾回收算法常见的有三种,标记-清除法,标记-整理法和复制法。

标记清除法很好理解,把需要GC的对象先进行标记,然后清除,大概如下图:

缺点显而易见:可能会使内存碎片化,导致无法发配连续的大块内存给新的对象,如一个size超大的数组。

标记整理法,也非常好理解,进行一轮存活标记,然后把对象做一次整理,移动到内存一块区域中,然后把区域外的内存全部释放掉,如下图:

复制法,会把内存划分为两块区域,当一块快用完的时候,进行一次标记,把存活对象移动到另外一块空白区域,然后把之前的区域释放掉,如下图:

对以上几种GC方法综合使用之后,会产生一个新的分代算法:即,新生代使用复制算法,老年代使用标记-清除或者标记-整理算法。

3.垃圾收集器

我们对GC算法有了初步的认识后,我们再来看看垃圾收集器的相关内容。目前来说,几种垃圾回收器如下图:

区域划分表明了它们各自的作用区域,连线则表示可以组合使用。

Serial,采用标记-复制算法,是一个单线程的收集器,意思是,它只使用一个线程来进行垃圾收集的处理。但是它在进行垃圾收集时,还会暂停其他所有的工作线程。因为没有线程切换带来的额外开销,它是单线程手机效率最高的收集器。但是会暂停其他线程的特性,并不是很理想。在早期单核CPU的环境下,它经常被使用来作为client端的垃圾回收。因为client占用的内存一般不大(如安卓手机的APP),几十几百M的内存,垃圾回收时间也控制在毫秒级别,短暂的停顿并不是很难接受。这也是为什么一些垃圾安卓APP会频繁未响应的原因,因为它的内存使用太过狂野,经常触发GC。

ParNew,Serial的多线程版本,是Server环境首选的收集器之一,可配合CMS收集器使用。

Parallel Scavenge,采用标记-复制算法,和Serial的设计思路相反,Serial和ParNew的首要考虑目标是缩短GC造成的停顿时间。而Parallel Scavenge的目标是提高CPU的运行效率,即扩大用户代码占用CPU资源和GC占用CPU资源的比例。因为不太关注停顿时间,所以更适合后台运算而不和前端用户发生太多交互的任务场景,简单来说,适合离线计算,尽可能多的压榨CPU资源。

Serial Old,采用标记-整理算法,Serial的老年代版本。

Parallel Old,采用标记-整理算法,Parallel Scavenge的老年代版本。

CMS,Concurrent Mark Sweep,并发标记清理收集器,从名字可以知道它是一个基于标记-清除算法的收集器。这个是我们Java后端生成环境下,最常用的垃圾收集器。它实现了垃圾收集和用户线程的同时工作,并且停顿较低,有着良好的服务响应速度。

CMS的垃圾收集过程分为4步,

a,初始标记,标记所有GC ROOT能直接关联到的对象。虽然会停止其他的工作线程,但是因为数量很小并且速度很快,实际上认为几乎没有影响。

b,并发标记,对GC ROOT做搜索,标记出存活对象,这个步骤中不会停止工作线程,但不保证能标记出全部活动对象。

c,重新标记,多线程并发标记用户活动线程新产生的变动,需要停止其他工作线程,但是耗时比并发标记过程短,可以忽略不计。

d,并发清除,回收所有垃圾对象。

整个处理过程中,耗时组多的并发标记和并发清除过程,因为不影响工作线程,所以它的整体性能还是相当可观的。

需要额外注意的是,CMS对CPU资源相当敏感,并发过程虽然不会暂停用户工作线程,但是会消耗一定的CPU资源。CMS的默认收集线程数量是=(CPU数量+3)/4;

未完待续。。。

参考&引用:
Crow@Github《深入理解JVM(1)——Java内存区域与Java对象》