Java虚拟机的内存模型分为五个部分,分别是程序计数器、Java虚拟机栈、本地方法栈、堆和方法区(永久代/Perm Gen,jdk1.8后被元空间替代)。
这五个区域既然是存储空间,那么为了避免Java虚拟机在运行期间内存存满的情况,就必须得有一个垃圾收集者的角色,不定期地回收一些无效内存,以保障Java虚拟机能够健康地持续运行。
这个垃圾收集者就是平常我们所说的“垃圾收集器”,那么垃圾收集器在何时清扫内存?清扫哪些数据?Follow me !
程序计数器、Java虚拟机栈和本地方法栈都是线程私有的,也就是每条线程都拥有这三块区域,而且会随着线程的创建而创建,线程的结束而销毁。栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期间会由JIT编译器进行一些优化,但是在本文基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
然而,堆和方法区中的内存清理工作就没有那么容易了。
堆和方法区是所有线程共享,并且都在JVM启动的时候创建,一直得运行到JVM停止时。因此它们没办法根据线程的创建而创建、线程的结束而释放。
堆中存放JVM运行期间的所有对象,虽然每个对象的内存大小在加载该对象的所属类时就确定了,但究竟创建多少个对象只有在程序运行期间才能确定。
方法区中存放类信息、静态成员变量、常量、运行时常量池等。类的加载是在程序运行过程中,当需要创建这个类的对象的时候才加载这个类。因此JVM究竟要加载多少个类也需要在程序运行期间确定的。
因此,堆和方法区的内存回收具有不确定性,这部分内存的分配和回收都是动态的,因此垃圾收集器在回收堆和方法区内存的时候花了一些心思。
【1】判断对象是否需要回收
如何判定哪些对象需要回收?
在对堆进行对象回收前,首先要判断哪些是无效对象。我们知道,一个对象不被任何对象或变量引用,那么就是无效对象,需要被回收。
一般有两种判别方式:
引用计数法
每个对象都有一个计数器,当这个对象被一个变量或者另一个对象引用一次,该计数器加一;若该引用失效,则计数器减一。当计数器为0时,就认为该对象是无效对象。
可达性分析法(根搜索算法)
所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链(Reference Chain)”。当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,可以被回收的。
引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。因此目前主流语言均使用可达性分析方法来判断对象是否有效。
引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。因此目前主流语言均使用可达性分析方法来判断对象是否有效。
② GC Roots是什么
GC Roots是指:
- Java虚拟机栈所引用的对象(栈帧中局部变量表中引用类型变量所引用的对象);
- 方法区中类静态属性所引用的对象;
- 方法区中常量所引用的对象;
- 本地方法栈中JNI(即一般说的Native方法)所引用的对象。
【2】对象生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是次对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过时,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。
这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将很可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。
finalize()方法是对象逃脱死亡名义的最后一次机会,稍候GC将对F-Queue中的对象进行第二次小规模标记。如果对象要在finalize()中成功拯救自己–只要重新与引用链上的任何一个对象建立关联即可。譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合。如果对象这时候还没有逃脱,那基本上它就真的被回收了。
具体过程概括如下:
1.判断该对象是否覆盖了finalize()方法
若已覆盖该方法,并且该对象的finalize()方法还没有被执行过,那么就会将对象扔到F-Queue队列中。
若未覆盖该方法,直接释放对象内存。
2.执行F-Queue队列中的对象
虚拟机会以较低的优先级执行这些对象,也不会确保所有的finalize方法都会执行结束。如果finalize方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。
3.对象重生或死亡
如果在执行finalize方法时,将this赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。
强烈不建议使用finalize()函数进行任何操作。如果需要释放资源,请使用try-finally。因为finalize()不确定性大,开销大,无法保证各个对象的调用顺序。
③ 安全区域
JVM可以通过GC ROOT快速定位到要回收的对象,但是程序是在运行中的,对象的关系也会发生变化。JVM只有当程序进入安全点时,然后暂停程序进行回收。
安全点的选定是以程序 是否具有长时间执行的特征为标准进行选定。“长时间执行”最明显的特征就是指令序列复用(一组),如方法跳转、循环跳转、异常跳转等,具备这些功能的指令才会产生safePoint。
在GC发生时,一般会采用主动式中断,jvm在要发生GC时不会干预线程,会设置一个标志,所有的线程主动去轮询,当执行线程发现了这个标识就自己中断挂起。
【3】方法区的内存回收
由于方法区中存放生命周期较长的类信息、常量、静态变量等,因此方法区就像是堆的老年代,每次垃圾回收只有少量的垃圾被清除掉。
方法区主要清除两种垃圾:
- 废弃常量
- 废弃的类
① 如何判定废弃常量
清除废弃的常量和清除对象类似,只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。
回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统中没有任何一个String对象是叫做“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量。如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
这里以JDK1.6为例。
② 如何判定废弃的类
清除废弃的类条件较为苛刻,类需要同时满足下面3个条件才能算是“无用的类”:
该类的所有实例都已被清除,也就是Java堆中不存在该类的任何实例;
加载该类的ClassLoader已经被回收。
该类的java.lang.Class对象没有被任何对象或变量引用,无法在任何地方通过反射访问该类的方法。
只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除的时候清除。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。其中-verbose:class以及-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。
【4】垃圾回收算法
现在我们知道了判定一个对象是无效对象、判定一个类是废弃类、判定一个常量是废弃常量的方法,也就是知道了垃圾收集器会清除哪些数据,那么接下来学习如何清除这些数据。
① 引用计数(Reference Counting)
比较古老的回收算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。
② 标记-清除算法
此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除。此算法需要暂停整个应用,同时,会产生内存碎片。
分析:
这种算法标记和清除过程效率都很低,而且清除完后存在大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
CMS收集器采用该算法。
③ 复制算法
此算法把内存空间划为两个相等的区域,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
分析:
算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,将内存缩小为了原来的一半。
Serial、ParNew和Parallel Scavenge收集器采用该种算法。
解决空间利用率问题:
在新生代中,由于大量(98%)的对象都是“朝生夕死”,也就是一次垃圾收集后只有少量对象存活,因此我们可以将内存划分成三块:Eden,Survior1,Survior2(都属于新生代),内存的大小分别是8:1:1。
分配内存时,只使用Eden和一块Survior1。当发现Eden+Survior1的内存即将满时,将Eden+Survior1中还存活着的对象一次性地复制到另一块Survior2中,最后清理掉Eden和Survior1空间。那么,接下来就使用Survior2+Eden进行内存分配。
Hotspot虚拟机默认Eden和Survior的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),通过这种方式,只需要浪费10%的内存空间即可实现带有压缩功能的垃圾收集方法,避免了内存碎片的问题。不过没有办法保证每次回收都只有不多于10%的对象存活,当Survior2空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion).
当一个对象要申请内存空间时,发现Eden+Survior中空闲的空间无法放置该对象,此时需要进行MinorGC对象该区域的废弃对象进行回收,如果MinorGC过后空闲出来的内存空间仍然无法放置该对象,那么此时就需要将Eden+Survior中的所有对象转移到老年代中,然后再将新对象存入Eden区。这种方式叫做“分配担保”。
④ 标记-整理算法
复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
“标记-整理(Mark-Compact)”此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放(不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以为的内存)。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。
分析:
它是一种老年代的垃圾收集算法。老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活。因此如果选用“复制”算法,每次需要复制大量的存活对象,会导致效率很低。
而且,在新生代中使用“复制”算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行“分配担保”。而如果在老年代中使用该算法,那么在老年代中如果出现Eden+Survior装不下某个对象的情况时,没有其他区域给他做分配担保。
因此老年代一般使用“标记-整理”算法,Serial Old和Parallel Old收集器采用标记-整理算法。
【5】分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集(Generational Collection)”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用“复制”算法,只需要付出少量存活对象的成本就可以完成收集。而老年代中因为对象存活率高、没有额外的空间对它进行分配担保,就必须使用“标记-整理”或者“标记-清除”算法来进行回收。
增量收集(Incremental Collecting)
实时垃圾回收算法,即在应用进行的同时进行垃圾回收。不知道什么原因JDK5.0中的收集器没有使用这种算法的。
三种收集器种类
串行收集
Serial、SerialOld收集器是串行收集,不只使用单线程,而且会Stop The World。
串行收集使用单线程处理所有垃圾回收工作,因为无需多线程交互,实现容易,而且效率比较高。但是,其局限性也比较明显,即无法使用多处理器的优势,所以此收集适合单处理器机器。当然,此收集器也可以用在小数据量(100M左右)情况下的多处理器机器上。
并行收集
ParNew、Parallel Scavenge和Parallel Old是并行收集器,同样需要Stop The World。
并行收集使用多线程处理垃圾回收工作,因而速度快,效率高。而且理论上CPU数目越多,越能体现出并行收集器的优势。
并发收集
相对于串行收集和并行收集而言,前面两个在进行垃圾回收工作时,需要暂停整个运行环境,而只有垃圾回收程序在运行,因此,系统在垃圾回收时会有明显的暂停,而且暂停时间会因为堆越大而越长。
【6】Java中引用的种类
在JDK1.2以前,Java中的引用定义很传统:如果reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用的状态。对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。
在JDK1.2之后,Java对引用的概念进行扩充,将引用分为四类:强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference)和虚引用(Phantom Reference)。
① 强引用
就是我们一般声明对象是虚拟机生成的引用,强引用环境下,垃圾回收时需要严格判断当前对象是否被强引用,如果被强引用,则不会被垃圾回收。
我们平时所使用的引用就是强引用,如下:
A a = new A();
也就是通过关键字new创建的对象所关联的引用就是强引用。只要强引用存在,该对象永远也不会被回收。
② 软引用
软引用是用来描述一些还有用但并非是必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
软引用一般被做为缓存来使用。与强引用的区别是,软引用在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。
如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。换句话说,虚拟机在发生OutOfMemory时,肯定是没有软引用存在的。
在JDK1.2之后,软引用通过SoftReference类实现。
③ 弱引用
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
强引用不用说,我们系统一般在使用时都是用的强引用。而“软引用”和“弱引用”比较少见。他们一般被作为缓存使用,而且一般是在内存大小比较受限的情况下做为缓存。
因为如果内存足够大的话,可以直接使用强引用作为缓存即可,同时可控性更高。因而,他们常见的是被使用在桌面应用系统的缓存。
在JDK1.2之后,弱引用通过WeakReference类实现。弱引用的生命周期比软引用更短一些。
实例如ThreadLocal的静态内部类ThreadLocalMap 中的Entry:
static class ThreadLocalMap { /** * The entries in this hash map extend WeakReference, using * its main ref field as the key (which is always a * ThreadLocal object). Note that null keys (i.e. entry.get() * == null) mean that the key is no longer referenced, so the * entry can be expunged from table. Such entries are referred to * as "stale entries" in the code that follows. */ static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
④ 虚引用
虚引用也叫幽灵引用或者幻影引用,它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数。它是最弱的一种引用关系。
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
在JDK1.2之后,虚引用通过PhantomReference类来实现。
虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
当你的虚引用所引用的对象已经执行完finalize函数的时候,就会把对象加到queue里面。你可以通过判断queue里面是不是有对象来判断你的对象是不是要被回收了,让你知道你的对象什么时候会被回收。
【7】堆和方法区的内存模型与垃圾回收
Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象,如下图所示:
在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old)。新生代 ( Young ) 又被划分为三个区域:Eden、S0、S1。 这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
Java 中的堆是 GC 收集垃圾的主要区域。
GC通常来说分为三种:
Minor GC:指发生在新生代的垃圾收集动作,非常频繁,速度较快。
Old GC:有种说法指发生在老年代的GC,只有CMS的concurrent collection是这个模式。
Full GC:Full GC是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen-永久代/方法区)的全局范围的GC。
Perm,永久代即方法区,在逻辑上是堆的一部分。永久代是Hotspot虚拟机特有的概念,是方法区的一种实现,别的JVM都没有这个东西。在Java 8中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间。
永久代或者“Perm Gen”包含了JVM需要的应用元数据,这些元数据描述了在应用里使用的类和方法。永久代存放JVM运行时使用的类。永久代同样包含了Java SE库的类和方法。永久代的对象在full GC时进行垃圾收集。
JVM 常用参数如下
参数 | 描述 |
-Xms | 最小堆大小 |
-Xmx | 最大堆大小 |
-Xmn | 新生代大小 |
-XX:PermSize | 永久代大小 |
-XX:MaxPermSize | 永久代最大大小 |
-XX:PretrnureSizeThreshold | 设置大对象大小阈值 |
-XXMaxTenuringThreshold | 设置新生代的最大年龄 |
-XX:+PrintGC | 输出GC日志 |
-XX:+PrintGCDetails | 输出GC的详细日志 |
-XX:+PrintGCTimeStamps | 输出GC时间戳(以基准时间的形式) |
-XX:+PrintHeapAtGC | 在进行GC的前后打印出堆的信息 |
-Xloggc:/path/gc.log | 日志文件的输出路径 |
-XX:+PrintGCApplicationStoppedTime | 打印由GC产生的停顿时间 |
【8】垃圾回收会面临哪些问题
① 如何区分垃圾
上面说到的“引用计数”法,通过统计控制生成对象和删除对象时的引用数来判断。垃圾回收程序收集计数为0的对象即可。但是这种方法无法解决循环引用。
所以,后来实现的垃圾判断算法中,都是从程序运行的根节点出发,遍历整个对象引用,查找存活的对象(可达性分析法)。那么在这种方式的实现中,垃圾回收从哪儿开始的呢?即,从哪儿开始查找哪些对象是正在被当前系统使用的。
上面分析的堆和栈的区别,其中栈是真正进行程序执行地方,所以要获取哪些对象正在被使用,则需要从Java栈开始。同时,一个栈是与一个线程对应的,因此,如果有多个线程的话,则必须对这些线程对应的所有的栈进行检查。
同时,除了栈外,还有系统运行时的寄存器等,也是存储程序运行数据的。这样,以栈或寄存器中的引用为起点,我们可以找到堆中的对象,又从这些对象找到对堆中其他对象的引用,这种引用逐步扩展,最终以null引用或者基本类型结束,这样就形成了一颗以Java栈中引用所对应的对象为根节点的一颗对象树,如果栈中有多个引用,则最终会形成多颗对象树。在这些对象树上的对象,都是当前系统运行所需要的对象,不能被垃圾回收。而其他剩余对象,则可以视为无法被引用到的对象,可以被当做垃圾进行回收。
因此,垃圾回收的起点是一些根对象(java栈, 静态变量, 寄存器…)。而最简单的Java栈就是Java程序执行的main函数。这种回收方式,也是上面提到的“标记-清除”的回收方式。
② 如何处理碎片
由于不同Java对象存活时间是不一定的,因此,在程序运行一段时间以后,如果不进行内存整理,就会出现零散的内存碎片。碎片最直接的问题就是会导致无法分配大块的内存空间,以及程序运行效率降低。所以,在上面提到的基本垃圾回收算法中,“复制”方式和“标记-整理”方式,都可以解决碎片的问题。但是"标记-清除"会出现空间碎片问题。
③ 如何解决同时存在的对象创建和对象回收问题
垃圾回收线程是回收内存的,而程序运行线程则是消耗(或分配)内存的,一个回收内存,一个分配内存,从这点看,两者是矛盾的。因此,在现有的垃圾回收方式中,要进行垃圾回收前,一般都需要暂停整个应用(即:暂停内存的分配),然后进行垃圾回收,回收完成后再继续应用。这种实现方式是最直接,而且最有效的解决二者矛盾的方式。
但是这种方式有一个很明显的弊端,就是当堆空间持续增大时,垃圾回收的时间也将会相应的持续增大,对应应用暂停的时间也会相应的增大。
一些对相应时间要求很高的应用,比如最大暂停时间要求是几百毫秒,那么当堆空间大于几个G时,就很有可能超过这个限制,在这种情况下,垃圾回收将会成为系统运行的一个瓶颈。
为解决这种矛盾,有了并发标记清除垃圾回收算法(CMS)。使用这种算法,垃圾回收线程与程序运行线程同时运行。在这种方式下,解决了暂停的问题,但是因为需要在新生成对象的同时又要回收对象,算法复杂性会大大增加,系统的处理能力也会相应降低,同时,“碎片”问题将会比较难解决。故而又出现了G1收集器,采用标记-整理算法同时满足低停顿。
【9】HotSpot的算法实现
① 枚举根节点
从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行–这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun 将这件事情称为“Stop The World”)的其中一个重要原因。即使是在号称几乎不会发生停顿的CMS收集器中,枚举根结点时也是必须要停顿的。
由于目前的主流 Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
② Safepoint
在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多。如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上HotSpot也的确没有为每条指令都生成OopMap。前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为“安全点”。即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的–因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所有具有这些功能的指令才会产生Safepoint。
对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),其中抢先式中断不需要线程的执行代码主动配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从来响应GC事件。
而主动式中断的思想是当GC需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
③ 安全区域(Safe Region)
使用Safepoint似乎已经完美地解决了如何进入GC的问题,但是实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程被重新分配CPU时间片。对于这种情况,就需要安全区域“Safe Region”解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。所以也可以把Safe Region看做是被扩展了的Safe Point。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region。那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根结点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。