深入理解java虚拟机第2版的垃圾回收总结

HotSpot中对象的创建:

在了解垃圾回收之前,我们先了解对象的创建流程和访问:

1> 虚拟机遇到一条new指令时,先去检查指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个类是否已被加载、解析和初始化过。如果没有,那就执行类加载过程

2>类加载过程,分为加载、验证、准备、解析、初始化,其中验证、准备、解析属于连接阶段

1>>加载阶段,需要做3件事,(1)通过一个类的全限定名获取定义此类的二进制字节流;(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;(3)在内存中生成代表这个类的java.lang.class对象,作为方法区这个类的各种数据的访问入口。

2>>验证是连接阶段第一步,确保class文件的字节流包含的信息符合当前虚拟机的要求,文件格式验证、元数据验证、字节码验证,符号引用验证。

3>>准备阶段为类变量分配内存并设置类变量(static修饰的)初始值。这些变量所使用的内存都将在内存的方法区中进行分配。不包括实例变量(非static),初始值通常是数据类型的零值。比如 public static int value = 123,准备阶段的初始值为0,非123,

4>>解析阶段是将常量池内的符号引用替换为直接引用过程

5>>类初始化阶段是是类加载过程的最后一步,这个阶段才去执行真正的java代码

3> 类加载检查后,接下来就是为对象分配内存了。大小在类加载完成后就可以确定了。java堆中分配内存有“指针碰撞”(用过的内存在一边,没用过的内存在另一边,中间放着一个指针分为分界点的指示器,那分配内存就仅仅把那个指针指向空闲空间挪动一段与对象大小相等的距离)和“空闲列表”(用一个列表,记录那些是空闲的)2种方式,Serial、ParNew等带Compact过程的收集器用“指针碰撞”,使用CMS的Mark-Sweep算法的收集器一般采用空闲列表。并发线程安全情况下采用在java堆中分配一小块内存(本地线程分配缓冲TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB,才需要同步锁定。

4>内存分配完成后,虚拟机将分配的内存都初始化为零值(不包括对象头),如果使用TLAB,这一工作可以提前到TLAB分配完成。这样对象的实例字段在java代码可以不赋初始值就直接使用。

5> 接下来,虚拟机对对象进行必要的设置,比如对象是哪个类的实例,如何找到类的元数据信息,对象哈希码、对象的GC分代年龄,这些信息放在对象头中,根据虚拟机当前的运行状态不同,对象头会有不同的设置方式。对象头下面介绍。

6> 对象已经产生了,init方法还没执行,所以字段还为零,之后就执行init方法,按照我们的初始化意愿进行初始化。一个真正的对象就出来了。


对象头

对象头、实例数据、对齐填充是对象在内存存储的3块区域。

对象头包含2部分信息:

1> 第一部分是存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID。

2> 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定该对象属于哪个类的实例。查找对象的元数据信息不一定要通过对象本身,比如通过句柄池,如果对象是一个数组,对象头还需要有一块记录数组长度的数据

实例数据部分是对象真正存储的有效信息,也就是在代码中定义的各种类型的字段内容。

对齐填充仅仅起着占位符的作用。,对象必须是8字节的整数倍,对象头是8字节的整数倍,所以当对象实例数据部分没有对齐就需要对齐填充补全。


对象的访问定位

我们通过栈上的引用变量来操作堆上的具体对象,而这种访问方式是取决于虚拟机的实现而定,一般都使用句柄池和直接指针两种。

1>使用句柄池访问,先在堆中划出一块内存作为句柄池,引用变量中存储的是句柄池的句柄地址句柄池中包含对象实例数据类型数据各自的具体地址信息

2> 如果使用直接指针访问,那么java堆中就考虑如何放置访问类型数据的相关信息,引用变量存储的直接是对象地址

各有优势,使用句柄池访问最大的好处是引用变量存储的是稳定的句柄地址对象移动时只会改变句柄中实例数据的指针,而引用变量本身布需要改变;而使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销(句柄池需要两次),由于对象访问非常频繁,因此Sun HotSpot采用直接指针访问对象


垃圾回收


垃圾回收一般指回收java堆跟方法区的内存,这部分的内存是在程序运行期创建的,分配跟回收都是动态的。而程序计数器、虚拟机栈、本地方法栈三个区域是随线程而生灭,栈中的栈帧随着方法的进入和退出有条不紊地执行出栈和入栈操作,每一个栈帧中分配的内存基本是在类结构确定下来的,即在编译期确定下来,所以内存分配和回收具有确定性,所以不考虑回收问题,因为线程或者方法一结束,内存自然就跟着回收了。

垃圾收集器对堆回收前,是回收的对象是没被任何引用变量引用的对象,java虚拟机不是通过引用计数算法管理内存,主要原因是它很难解决对象之间相互循环引用的问题。举个简单的例子,请看代码清单3-1中的testGC()方法:对象objA和objB都有字段instance,赋值令objA.instance = objB及objB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。

/**  
* testGC()方法执行后,objA和objB会不会被GC呢?  
* @author zzm  
 
*/  
public class ReferenceCountingGC {  
public Object instance = null;  
private static final int _1MB = 1024 * 1024;  
/**  
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过  
*/  
private byte[] bigSize = new byte[2 * _1MB];  
public static void testGC() {  
     ReferenceCountingGC objA = new ReferenceCountingGC();  
     ReferenceCountingGC objB = new ReferenceCountingGC();  
     objA.instance = objB;  
     objB.instance = objA;  
 
     objA = null;  
     objB = null;  
 
     // 假设在这行发生GC,那么objA和objB是否能被回收?  
     System.gc();  
}  
} 
可达性分析算法:


Java使用可达性分析算法(GC Roots Tracing)判定对象是否存活的。这个算法的基本思路就是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图3-1所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
在Java语言里,可作为GC Roots的对象包括下面几种:虚拟机栈(栈帧中的本地变量表)中的引用的对象方法区中的类静态属性引用的对象。方法区中的常量引用的对象。本地方法栈中JNI(即一般说的Native方法)的引用的对象


谈引用

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。
强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用用来描述一些还有用,但并非必需的对象对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。

生存还是死亡?

在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

回收方法区

很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量无用的类,回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。常量池中的其他类(接口)方法、字段符号引用也与此类似

无用的类必须同时满足以下3个条件:

1>该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
2> 加载该类的ClassLoader已经被回收
3> 该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading查看类的加载和卸载信息


垃圾回收算法:


1.标记——清除算法(Mark-Sweep)

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2.复制算法:

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点采用这种收集算法来回收新生代,新生代中的对象98%是朝生夕死的,并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

3.标记——整理算法(Mark-Compact)

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存.

4.分代收集算法:

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。


HotSpot算法实现:

可达性分析对执行时间的敏感还体现在GC停顿上,因为分析工作必须在确保一致性快照中,即不可以在分析过程中出现对象引用关系不断变化的情况。这样分析结果准确性就无法得到保证。所以导致GC进行时必须停顿所以Java执行线程。当执行系统停顿后,并不需要一个不漏地检查完GC根结点的引用位置,虚拟机应该是知道哪些地方存放着对象的引用。HotSpot使用一组为OopMap的数据结构来达到目的,在类加载后,HotSpot把对象的偏移量跟类型计算出来JIT编译过程也在特定位置记下栈和寄存器哪些位置是引用。存储在对象头中,比如这样GC扫描就可以得知信息了。


垃圾回收器

1.Serial收集器:

曾经是新生代收集的唯一选择,是一个单线程收集器进行拉近回收时候需要暂停所有工作线程。直到他结束。卡顿现象比较严重。新生代采用复制算法暂停所有工作线程,老年代采用标记-整理算法暂停所有工作线程。

2.ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,却是许多运行在Server模式下的虚拟机中首选的新生代收集器,ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,因为存在线程交互的开销新生代采用复制算法暂停所有工作线程,老年代采用标记-整理算法暂停所有工作线程。

3.Parallel Scavenge收集器
Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。 

并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序继续运行,而垃圾收集程序运行于另一个CPU上
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

4.Serial Old收集器
Serial Old是Serial收集器的老年代版本,新生代采用复制算法暂停所有工作线程,老年代采用标记-整理算法暂停所有工作线程。

5.Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法
,直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

6.CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。出CMS收集器是基于“标记-清除”算法实现
缺点:CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure,还有最后一个缺点,CMS是一款基于“标记-清除”算法实现的收集器,意味着收集结束时会产生大量空间碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC,可以设置虚拟机的参数来应对碎片整理过程。

7.G1收集器
G1(Garbage First)收集器是当前收集器技术发展的最前沿成果
一是G1收集器是基于“标记-整理”算法实现的收集器,也就是说它不会产生空间碎片,这对于长时间运行的应用系统来说非常重要。二是它可以非常精确地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
G1收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收,这是由于它能够极力地避免全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个Java堆(包括新生代、老年代)划分为多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的来由)。区域划分及有优先级的区域回收,保证了G1收集器在有限的时间内可以获得最高的收集效率。


内存回收和分配策略

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

对象优先在Eden分配,

大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置
动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。



以上内容整理自深入理解java虚拟机,更详细内容看书内容。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值