Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的 “高墙”,墙外面的人想进去,墙里面的人却想出来。——《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》周志明
Java 虚拟机作为运行 Java 程序抽象出来的计算机,具有内存管理的能力,像内存分配、垃圾回收等这些相关的内存管理问题,Java 虚拟机都会帮我们解决,所以作为一个 Java 程序员要比 C++ 程序员幸福,但是内存方面一旦出现问题,如果对虚拟机怎样使用内存不了解,就很难排查错误。
这段时间看周志明先生的《深入理解Java虚拟机:JVM高级特性与最佳时实践(第二版)》,下面就对 Java 虚拟机对内存的管理做一个系统的整理,本篇文章是该专题的第四篇。
4、垃圾回收
在前面我们模拟了内存异常,其实 Java 虚拟机的垃圾回收机制为避免内存异常已经做出了最大努力,但还是无法避免上面情况的发生。垃圾收集简称为 “GC”,垃圾回收主要解决下面三个问题:
- 哪些内存区域需要回收?
- 什么时候回收?
- 如何回收?
在前面对内存的划分中,程序计数器、虚拟机栈和本地方法栈都是随线程生和死的,栈中的栈帧随着方法的调用有序的进栈和出栈,每个栈帧上分配的内存在类结构确定时就已知了,所以这些区域内存的分配和回收都是具有确定性的,很容易回收内存。当方法调用结束或者该线程结束,占用的内存就要回收。而 Java堆和方法区中,每个类需要的内存都可能不一样,一个方法中多个分支需要的内存也可能不一样,只有在运行期才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾回收也主要是对这部分的内存进行回收。
理论上来说,如果一个对象已死,那么此时它占用的内存就应该被回收。但是怎么判断一个对象的生死,并不是那么容易的。我们也常说一些人虽然还活着但就像死了一样,而一些人虽然死了但仍然活在我们心中,在程序中生死可不能这么模棱两可,所以必须要有判断对象生死的方法。常见判断算法有下面两种:
1、引用计数算法
给每个对象添加一个引用计数器,当有一个地方引用该对象时,就将引用计数器的值加1,当引用失效时,就将引用计数器的值减1,当计数器的值为0时,就说明不存在对该对象的引用了,这个对象就没什么存活的意义了,也即是可以说这个对象是死的,可被回收。
缺点:这种判断方法虽然看起来简单高效,但是不能解决对象之间相互循环引用的问题。例如 A 对象和 B对象都是同一个类的对象实例,A 中字段 instance 引用对象 B,B 中字段 instance 也引用对象 A。如果垃圾收集器想要回收 A 对象,那么 A 的引用计时器值要为 0,也就是要清除 B 中字段 instance 对 A 的引用,也就是要清除 B 对象,而 B 对象又被 A 中的字段 instance 引用着,也就是要清除 A 对象,想要回收 A 对象,A 的 引用计数器值要为 0 ......这样就形成了循环,A 和 B 都不能被回收。不知道你有没有懵逼,反正垃圾收集器已经懵逼了。
2、可达性分析算法
从一个根节点开始向下搜索对象,搜索所走的路径称为是引用链,当一个对象从根节点开始找不到一条引用链时,就说明这个对象无法使用,或者说是对象已死可被回收。这个根节点叫做 GC Roots,是一个特殊的对象,且绝对不能被其他对象引用,不然也会像上面引用计数算法那样有循环引用的问题,GC Roots 对象包括虚拟机栈(栈帧本地变量表)中引用的对象、方法区中静态属性引用的对象、方法区常量引用的对象、本地方法栈中(Native 方法)引用的对象。
可达性分析算法判定对象是否回收.jpg上面这两种判断方法都是“引用”有关的,引用计数算法需要计算对象的引用数量,可达性分析算法需要判断对象是否有可达的引用链。引用也是一个很模糊的概念,为了更加清晰的描述 Java 中的对象引用,在 JDK1.2 后,Java 将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference) 4 种,并且除了强引用外都有与之对应的 Java 类,分别是基类 Reference、软引用类 SoftReference、弱引用类 WeakReference、虚引用类 PhantomReference。
强引用很常见,类似 Object obj = new Object()
这种引用就是强引用。
软引用是用来描述一些有用但非必须的对象引用,当内存紧张的时候,会把这些对象列为回收目标,进行二次回收,如果回收之后还是没有足够的内存,那么就会出现内存异常。
弱引用也是用来描述一些非必须的对象引用,但是引用的强度要比软引用弱,被弱引用关联的对象将会在下一次垃圾收集时回收,而不管内存是否充足。
虚引用又称为是幽灵引用或是幻影引用,是最弱的引用关系。一个对象是否有虚引用,完全不会对该对象的生存造成影响,也无法用虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是,在这个对象被垃圾收集器回收时收到一个系统的死亡通知,通俗的说也就是死的明明白白吧。
生存还是死亡?这是个问题。
在可达性分析算法中,即使是不可达的对象,也并非是要立即执行 “死刑”,它们暂时处于 “死缓”。就像 C++ 中,对象死亡要调用析构函数一样,Java 中对象在死亡时也有一个类似的 finalize() 方法,不可达的对象会被第一次标记并进行一次筛选,筛选的条件就是这个 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法被虚拟机调用了而这个对象还没有会回收,finalize() 方法都不会被执行。如果判定需要执行 finaliz() 方法,这个对象就会被放到一个队列中,由低优先级的单独线程(刽子手)执行对象中的 finalize() 方法。如果在 finalize() 方法中,该对象被引用链上的其他对象关联了,那么这个对象就可以被移出这个 “即将回收” 队列,从而死里逃生。这个 finalize() 方法可以说是对象逃脱死亡命运的最后一次机会,如果没有逃脱,这个对象就真正要被垃圾回收器执行死刑了。但是这个机会每个对象只有一次,第一次是可以逃脱的,第二次再次进入这个队列无论如何也逃脱不了被回收的命运。
虽然 Java 虚拟机规范中没有要求对方法区进行垃圾回收,但是一些虚拟机(如 HotSpot 虚拟机)仍然实现了方法区的垃圾回收,在 HotSpot 虚拟机中称方法区为 “永久代”,其实都是一个意思,方法区的垃圾主要是废弃的常量和无用的类。我们知道方法区中有一些常量池,如字符串常量池,如果系统中不存在引用常量池中常量的引用,那么在内存紧张的时候,这些常量就应该被废弃回收,常量池中的其他类(接口)、方法、字段、符号引用也是如此。
判断常量是否应该被废弃的方法比较简单,而判断一个类是无用的类,则需要满足下面三个条件:
- 该类的所有实例都已经被回收了,也即 Java堆内存中没有该类的对象实例。
- 加载该类的 ClassLoader 已经被回收了。
- 该类对应的 java.lang.Class 对象在任何地方都没有被引用,也即无法通过反射访问该类。
但满足了上述这些条件,也不是说这个类就要被非回收不可,我们可以通过设置虚拟机参数进行控制。
至此,哪些内存区域需要回收和什么时候回收就说完了,下面就是如何去回收了。垃圾回收是一个具体的过程,里面涉及到一些收集算法。几种常见的垃圾回收算法思想如下:
1、标记-清除算法
如同它的名字,算法分为 “标记” 和 “清除” 两个过程,首先标记出需要回收的对象,如下图中的灰色区域,然后再将标记出的区域内容清除。标记过程肯定需要遍历,这里面也涉及到广度优先搜索和深度优先搜索,这里就不多说了。
不足之处:一个是效率问题,搜索的效率;另外一个是空间问题,标记清除后会产生内存碎片,不利于给大对象分配内存空间。
标记-清除算法是最基础的收集算法,后续的收集算法都是对它的改进。
标记-清除算法.jpg2、复制算法
为了解决标记-清除算法的效率问题,复制算法将内存容量划分为两个等量的部分,每次只使用一块,当一块使用完后,就将还存活的对象复制到另一块内存区域,并把刚才使用的那块内存区域一次性清除,这样每次都只需要对一半内存区域进行垃圾回收。
不足之处:这种做法看似简单除暴,但实现简单,运行高效,确实可以解决产生内存碎片的问题,而牺牲了一半可以使用的内存空间的代价未免太大。另外在对象存活率较高的时候,就需要进行大量的复制操作,效率将会变低,所以对于存活时间长的对象一般不使用这种收集算法。
复制算法.jpg3、标记-整理算法
标记-整理算法和标记-清除算法,标记过程相同,但不同的是,标记-整理算法是将存活对象向同一端移动,然后再清除这之外的内存区域,而不是对可回收的内存直接清除。
标记-整理算法.jpg4、分代收集算法
分代收集算法中没有新的思想,只是根据对象存活的周期长短又对 Java堆内存进行了划分,一般是把 Java堆分为新生代(Young)和老年代(Old)。新生代和老年代的默认内存大小比例是 1 : 2,也即是新生代占据 1/3 的堆内存空间,老生代占据 2/3 的堆内存空间,这个比例值是可以通过 -XX:NewRatio 参数来动态设置的。而新生代又被划分为 Eden、From Survivor、To Survivor 三个区域,Eden 占据 8/10 的新生代内存空间,并且Java 虚拟机每次只会使用 Eden 区和一个 Survivor(From Survivor 和 To Survivor中的一个) 区,总有一个 Survivor 区是空闲着的。新生代区域用来存放那些朝生夕死的 Java 对象,这些对象存活时间很短,很容易就会被垃圾收集器回收,所以新生代使用复制算法会比较好,而老生代区域用来存放大对象(如对象数组),这些对象不是很容易被回收,存活时间比较长,使用标记-清除算法和标记-清理算法是比较好的。针对不同区域内对象存活时间的长短,使用合适的收集算法,可以最大的发挥出算法优势。
分代收集算法.jpg觉得文章还不错,可以关注 编程心路 微信公众号,在编程的路上,我们一起成长。
编程心路
网友评论