作者:吕宗胜
Java语言与C语言相比,最大的特点是编程人员无需过多的关心Java的内存分配和回收,因为所有这一切,Java的虚拟机都帮我们实现了。JVM的内存管理,大大降低了开发人员对内存管理的要求,也不容易出现C语言中的内存泄漏和溢出。但一旦应用内存发生问题,也会导致程序员难以定位。所以对于Java程序员来说认识和了解JVM的内存分配和回收对于代码的编写和应用的优化都有非常重要的意思。
1. JVM内存模型
Java的JVM的类型是非常多样的,不同的JVM对于内存的分配和回收机制都不尽相同。我们这里仅仅介绍的是最为流行的JVM,HotSpot VM,它是目前使用范围最广的Java虚拟机。但是JVM的更新速度也非常快,不同的版本之间也可能会存在一些区别,但总体来说,其构架还是相对稳定的。
说到内存管理,我们首先要了解的就是Java运行时的数据区域,包括线程私有数据和共享数据的分配等等方面。根据《Java虚拟机》中的描述,其运行时数据区域为:
从上图可以看出,运行时的数据区域主要分成了5个部分:方法区、堆、虚拟机栈、本地方法栈和程序计数器。下面我们分别来介绍这5个部分。
1. 方法区
方法区中存储的数据被各个线程所共享,用于存储被虚拟机加载的类信息、常亮、静态变量、编译后的代码等数据。
2. 堆
堆区域存储的所有数据被各个线程共享,也是我们程序中最为关心的内存区域。该区域的目的就是存放对象实例,所以我们程序中的几乎所有对象都是存储在这块区域的。同时,该区域也是JVM进行内存管理和回收的主要区域。
3. 虚拟机栈
虚拟机栈是除堆之外最重要的一块内存区域,虚拟机栈中的数据是线程私有的。虚拟机栈是Java方法执行的内存模型,在每个方法执行时都会创建栈帧,用于存储局部变量表,操作数栈等。
4. 本地方法栈
它与虚拟机栈的作用是十分相似的,而它们的区别是虚拟机栈是用于执行Java方法时的数据结构,而本地方法栈是Java使用的Native方法服务。
5. 程序计数器
程序计数器是非常小的一块内存,每个线程都有一个独立的程序计数器,通过程序计数器,我们可以知道当前线程的执行的字节码序号。
上面简单的了解了一下JVM运行时的数据区域和每个区域的基本功能,而在实际的使用过程中,我们最为关心的就是堆区域和虚拟机栈中的局部变量表。而对于线程私有的虚拟机栈而言,数据内存随着线程的消亡而回收,而堆数据的回收则成为JVM内存管理和回收的重点。
2. 内存管理的分代机制
JVM中,目前使用的内配管理是分代方式,即把内存分成新生代、老生代和永久代。这里我们讲的分代管理机制是针对线程共享的内存区域,主要是堆,也包括方法区。
JAVA分代机制的好处是可以根据Java的实际对象创建和销毁时机,在不同的生代中可以采用不同的垃圾回收策略,已提高垃圾回收的效率。在Java中,几乎所有对象的实例都分配与新生代,而大部分对象的存活时间都不长,新生代中的对象回收会比较频繁。而老年代中的存放是那些存活时间较长,或者对象过大导致无法在新生代中分配的对象。而永久代比较特殊,它一般是指内存区域中的方法区,HotSpot在实现方法区时作为永久代来处理,避免了额外来管理方法区。这块区域的内存回收我们一般不做考虑,因为效果不会很明显,而且回收的条件也非常苛刻。
3.垃圾清除算法
针对不同的分代以及其特性,不同分代使用的垃圾回收策略也是不一样的。
3.1标记-清除算法
标记清除算法其实非常简单,它是先标记那些已经死亡的对象,然后对这些死亡的对象进行清理。但是它的一个很大的不足在于直接清理会产生非常多得内存碎片,导致后续分配内存会因为碎片的问题而没有连续的大空间满足分配,从而触发下一次的垃圾回收。可想而知,该垃圾清除策略效率和空间上都不会是最优的。
3.2 复制算法
复制算法,其实本质上跟标记-清除算法没有区别,不过它解决了内存碎片化的问题,同时也解决了两次扫描的问题。它的实现方式是在内存分配时先预留一部分内存,当内存需要回收的时候,它会进行扫描,把没有过期的内存数据复制到预留的内存,而直接清理原先分配的内存,把原先分配的内存作为预留内存。这种方法的好处就是效率很高,缺点也非常明显,那就是要浪费一部分内存作为预留内存,而如果为了保证数据100%的不丢失,原则上我们需要预留所有可分配内存的一半,造成内存的大面积浪费。
在新生代中,JVM采用了复制算法,因为新生代中的对象基本都是朝生夕死的,所以每次垃圾回收效果会比较明显,我们也称之为MinorGC。这里新生代划分成3块区域,Eden区,From Survivor区和To Survivor区。两块Survivor互为备份,垃圾回收时,对象会集中复制到空闲的Survivor区中去。为了提高内存的利用率,这个Eden区会占用较大的比例,默认比例是8:1。这样新生代只有10%的内存被浪费掉,但是毕竟很是有大量对象不能被回收而导致Survivor区空间不足的问题。这里就涉及到分配担保问题,当Survivor区不够的时候,对象会直接进入老年代。
3.3 标记-整理算法
复制算法除了空间的浪费外,还有一个问题就是如果对象是长期存活的,将会导致内存回收的效率降低,因为复制的内存将会变大。所以复制算法比较适合那些对象存活期较短的内存区域回收。所以在复制和标记-清除算法的基础上,提出了标记-整理算法。标记-整理算法也是先对对象进行标记,而后该算法将存活的对象往内存的一个方向移动,最终的内存将是占用的内存和空闲的内存有明显的分界,它主要是解决了内存碎片化的问题。
与新生代的朝生夕死相比,老生代的对象存活时间会比较长,所以采用了标记-整理算法。如果发生了老生代的垃圾回收,我们称之为FullGC。老生代的回收效率较低,会导致系统暂停较长的时间,所以我们要尽量减少FullGC的发生。
4. 分配回收策略
上面我们看到了JVM分代的垃圾回收算法,下面我们来看看JVM在内存分配和回收中的一些最常见的几个点。
4.1 对象优化分配在Eden区
Java的对象优先分配在Eden区中,当Eden区中没有足够的内存分配时,JVM会进行一次MinorGC。所以JVM中MinorGC会是比较频繁的垃圾回收动作,一般回收速度也比较快。对象分配在Eden区也不是绝对的,有一种例外是大对象会直接进入老年代。这里的大对象是指需要连续内存空间的Java对象,比如说很长的字符串和数组等。大对象直接进入老年代非常不适合垃圾回收策略,特别是这些大对象也是那些朝生夕死的对象,这会造成比较频繁的FullGC,导致系统性能降低。
4.2 长期存活的对象进行老年代
每一个对象都有一个对象年龄,对象在新生代中每经过一次垃圾回收,对象年龄增长1,当对象年龄超过某个阈值时,该对象会进入老年代。所以这里就有一个问题,如果我们在非常频繁的进行垃圾回收时,对象的对象年龄就会快速增长,一个对象会非常容易的进行老年代,造成FullGC的次数增长。
5. 对象死亡的判断算法
上面我们介绍了垃圾回收,却一直没有介绍JVM中使用的判断对象死亡的算法。最简单的对象判断的算法是采用计数法。当对象被引用时,计数加1,当一个对象的引用计数为0时,表示该对象已经死亡,可以进行回收。计数的方法虽然简单,易实现,但是却不能解决相互引用的问题,比如说对象A引用B,B也引用A,而A和B不再被其他对象引用,这种情况下,如果AB对象是可以被回收的,但是计数确不为0。
目前,通用的判断对象死亡的方法是可达性分析算法。可达性分析是指从对象起点开始,如果该对象可以被引用到,则该对象是活着的,否则,该对象则死亡了。那么该算法中最基本的对象起点是哪些呢?这些对象是指虚拟机栈中引用的对象、方法区中引用的对象和本地方法栈中引用的对象。