JVM(四):对象与垃圾回收

目录

  • 对象创建
  • 对象的引用
  • 无效对象的判断
  • 对象的复活机会
  • 垃圾回收算法
  • 垃圾回收器

对象创建(以HotSpot为例)
   
创建对象在JVM中对应的指令是new,当JVM碰到一个new指令时,会先检查new后面跟着的符号引用(位于常量池,代表类)是否被加载,如果没有,则必须先对类进行加载、连接、初始化等操作,当类被加载后,JVM根据类信息则可以在堆上分配一个适当大小的空间来存储实例数据(分配方式依赖于具体的JVM,主要有“指针碰撞”、“空闲列表”等), 空间分配完成之后,接下来将对对象进行一些常规设置及初始化等等,在HotSpot中对象信息可分为对象头、实例数据、对齐填充三个部分。对象头中包含的信息主要是对象自身运行时的数据,比如指向类数据的指针、哈希码、GC分代年龄、锁数据等等;实例数据即常说的对象成员等信息; 至于对齐填充,在hotSpot中只是为了使对象的大小满足8字节的整数倍的规范而已。至此对象创建完成,其生存时间主要取决于对象大小、对象关联的引用垃圾回器策略

对象的引用
     1.2之前,Java中对象的引用只有强引用一种,即常见的Object obj = new Object();对象只要还存在强引用,就永远不会被回收。1.2之后,Java中提供了通过其它引用对象来关联引用目标的功能,这样做的主要目的是为了在引用对象指向引用目标的同时,还允许垃圾回收器对引用目标进行收集(支持在某种程度上与垃圾回收器之间的交互)。 
     从引用强度或可触及性的角度上来说有三种引用:软引用、弱引用、虚引用。在Java中分别对应java.lang.ref.Reference的三个子类:java.lang.ref.SoftReference、java.lang.WeakReference、java.lang.PhantomReference。为了了解引用目标的回收情况,Java中提供了一个引用队列来记录引用对象。比如当垃圾回收器发现objA只存在软引用后,会释放objA的占用内存,同时也会把相关的软引用对象加入到引用队列中。
     软引用:对于只被软引用关联着的对象,当内存不足时(在JVM抛出 OutOfMemoryError 之前)都会被垃圾回收器清除,同时会断开所有软引用与该对象的连接(调用reference.clear()),所以软引用可以用来实现对内存比较敏感的功能,比如缓存;
     弱引用对于只被弱引用关联的对象,无论当前内存是否足够,当垃圾回收器工作时,这些对象都会被回收。同时也会断开连接。弱引用的引用强度比软引用更低, 一般用来对某些不重要的数据进行短暂的存储。比如Java中自带的WeakHashMap。
     虚引用:虚引用关联的对象,不会对对象的存活时间产生任何的影响,它仅仅只作为一个通知使用,相当于finalize()的作用。另外与上面两种的区别在于,通过虚引用无法获取引用目标,其get()总返回Null,这是因为被虚引用关联的对象必须保持可回收状态,不允许复活; 创建一个虚引用必须给定一个引用对列,这是因为垃圾回收器在碰到虚引用时就会立即把它加入到引用队列中;另外垃圾回收器碰到只被虚引用关联的对象时,不会立即清除虚引用与引用目标之间的关联关系,需要在程序中手动调用reference.cleaer()。
下面是三种引用的测试代码:

public class ObjectReferenceTest {
	
	ReferenceQueue<Object> queue = new ReferenceQueue<>();

	/**
	 * 软引用测试:当内存溢出时会自动执行垃圾回收器进行回收,不需要手动gc()。
	 */
	@Test
	public void testSoftReference() throws Exception {
		// 软引用:连接一个Date对象
		SoftReference<Object> sr = new SoftReference<Object>(new Date(), queue);
		// 使内存溢出,溢出时垃圾回收器会自动执行,这里不需要手动gc()
		System.out.println("SR回收前:" + sr.get());
		try {
			byte[] bytes = new byte[1600000000];
		} catch (Throwable e) {
		}
		Thread.sleep(10);
		System.out.println("SR回收后:" + sr.get());
		// 打印引用队列:阻塞,直到队列中添加元素。
		System.out.println("SR----" + queue.remove());
		
		//测试是否清除了关联关系
		Field f = Reference.class.getDeclaredField("referent");
		f.setAccessible(true);
		System.out.println(f.get(sr));
	}
	
	@Test
	public void testWeakReference() throws Exception {
		// 弱引用:连接一个字符串对象
		WeakReference<String> wr = new WeakReference<String>(new String("weak reference str obj"), queue);
		System.out.println("WR回收前:" + wr.get());
		// 请求回收
		System.gc();
		Thread.sleep(10);
		System.out.println("WR回收后:" + wr.get());
		// 打印引用队列:阻塞,直到队列中添加元素。
		System.out.println("WR----" + queue.remove());
		
		Field f = Reference.class.getDeclaredField("referent");
		f.setAccessible(true);
		System.out.println(f.get(wr));
	}
	
	@Test
	public void testPhantomReference() throws Exception {
		// 虚引用:连接一个字符串对象
		PhantomReference<String> pr = new PhantomReference<String>(new String("pr str obj"), queue);
		System.out.println("PR回收前:" + pr.get());
		// 请求回收
		System.gc();
		Thread.sleep(10);
		System.out.println("PR回收后:" + pr.get());
		// 打印引用队列:阻塞,直到队列中添加元素。
		System.out.println("PR----" + queue.remove());
		
//		pr.clear(); 需要手动清除
		
		Field f = Reference.class.getDeclaredField("referent");
		f.setAccessible(true);
		System.out.println(f.get(pr));
	}
	
}
WR回收前:weak reference str obj
WR回收后:null
WR----java.lang.ref.WeakReference@2b88689a
null
SR回收前:Tue Sep 11 14:09:03 CST 2018
SR回收后:null
SR----java.lang.ref.SoftReference@69e295f8
null
PR回收前:null
PR回收后:null
PR----java.lang.ref.PhantomReference@671cab1c
pr str obj

无效对象的判断:引用计数法、可达性算法
      引用计数法是一种比较老的算法,原理比较简单,为堆上的每个对象设一个引用计数器,记录自身被引用的次数。当对象被分配给某个变量时,其计数器+1,当该变量超出作用域或者指向其它对象时,计数器-1,当对象引用计数器为0时,则回收该对象。这种方式的优点是,由于是在运行时跟踪对象的引用计数,所以一旦没有任何变量引用对象时,对象就可以被马上回收,及时性很高,但这种方式有一个致命的缺陷就是互相引用的情况下,对象将永远无法释放。比如下方代码用引用计数的方式就永远
无法释放。(能输出结果,是因为目前的JVM都不采用引用计数法了)

public class Parent {
	
	public static void main(String[] args) throws InterruptedException {
		Rubbish1 r1 = new Rubbish1();
		Rubbish2 r2 = new Rubbish2();

		r1.setRubbish2(r2);
		r2.setRubbish1(r1);

		r1 = null;
		r2 = null;
		System.gc();
		Thread.sleep(1000);
	}
	
}

class Rubbish1{
	
	private Rubbish2 rubbish2;
	
	public void setRubbish2(Rubbish2 rubbish2) {
		this.rubbish2 = rubbish2;
	}

	@Override
	protected void finalize() throws Throwable{ 
		System.out.println(this + "----Rubbish1----finalize");
	}
	
}

class Rubbish2{
	
	private Rubbish1 rubbish1;
	
	public void setRubbish1(Rubbish1 rubbish1) {
		this.rubbish1 = rubbish1;
	}

	@Override
	protected void finalize() throws Throwable{ 
		System.out.println(this + "----Rubbish2----finalize");
	}
}

上方代码对应下图,即使r1,r2变量不再引用任何对象,但位于堆中的两个对象仍处于互相引用的状态,永远无法释放,造成内存泄漏。

       可达性算法的基本思路是通过对一系列的"GC Root"对象进行往下搜索,搜索路径一般称为引用链,当从GC  Root到一个对象没有任何可到达的引用链时,则认为该对象是不可达了,垃圾回收器就可以在适当的时机回收它。
      可以作为GC Root对象的主要包括以下几种:栈桢中的引用变量、方法区中的类变量引用的对象等等。比如private static CacheConfig c = new CacheConfig();。


图中虽然obj02相关还在引用,但并没有到GC Root对象的引用链,所以整个Obj02都是可以被回收的,这算法其实就解决了前面提到的“引用计数法的缺陷“。

对象最后的通知、最后复活的机会:finalize()
       对象在被回收的过程中,垃圾回收器会调用它的finalze()(前提是对象包含了该方法),做一些最后的处理,在finalzer()中可以重新复活当前对象,但finalzer只被会调用一次,以防止对象不停复活,无法被回收。下面是关于finalzer()方法的一个简单测试。最终结果,输出且只输出了一次“com.tvl.cache.config.CachePoolConfig@6084fa6a----finalize”,即finalize()调且只调用了一次

/**
 * @description
 * @author shite.zhu
 * @date 2016年8月16日 上午11:07:37
 * 
 */
public class CachePoolConfig {
	
	@Override
	protected void finalize() throws Throwable{ 
		//再次引用当前对象,并再次改变指向,此时不会再调用finalize()方法,
		//finalize()只被调用一次,parent最终被释放
		System.out.println(this + "----finalize");
		CachePoolConfig poolConfig = this;    //如果用成员变量或类变量再次引用的话,复活更彻底
		poolConfig = null; 
		System.gc(); 
		Thread.sleep(500);
	}
	
	public static void main(String[] args) throws Exception {
		//创建对象,然后改变变量的指向,此时无任何变量引用parent。
		CachePoolConfig poolConfig = new CachePoolConfig();	
		poolConfig = null;
		System.gc();
		Thread.sleep(1000);
	}	
	
}

垃圾回收算法  
      出于安全或简便性等各种原因,Java并没有提供由开发人员手动释放内存的功能,而是由JVM内置的垃圾回收器来实现这一功能。垃圾回收面临的问题很明显:回收对象、回收时机、回收策略。在Java中,当一个对象在程序中不再被任何变量引用时就是对象回收的时机,这些对象被回收后,其占用的空间将由垃圾回收器重新进行管理(关于重分配的引起对象地址的变化的相关内容参考第一篇博客中“堆的可能设计”)。
最基础的标记-清除算法
      标记-清除算法:先标记出需要回收的对象,然后再统一回收这些对象。上方说到的引用计数为0、找不到引用链的情况下都是标记的场景。这种方式的缺点很明显,一是标记和清除的效率并不高,二是由于被回收的对象在内存中的分布并不规则,所以简单的释放会产生很多内存碎片,这样就很可能导致在以后分配大对象时找不到连续的内存空间,从而又认为内存不存,又触发一次垃圾回收。

     复制算法,这种算法以标记-清除算法为基础,但它把可用内存分成两个相等的块,每次只使用一块,当第一块用完了的时候,就把其中还活动的对象依次复制到第二块区域中,然后再释放掉要回收的对象,依次循环,这样就保证了内存的连续性,解决了标记-清除算法产生内存上碎片的问题。缺点就是如果有大量对象生存时间长,占用空间也大,那么复制的次数和复制的成本 都比较高,效率并不高;还有就是由于每次都只使用了一半的内存,也就相当达到实际的50%时就会进行回收,这样相当于浪费了50%的空间,另外就是如果“正在被使用”的那半内存中的对象全部存活呢?这种情况下复制算法就并不合适了。

     标记-整理算法,这种与复制算法的区别在于,它采用移动的方式来整理内存,将活动对象往一端移,然后清理掉最后一个活动对象边界之后的空间。从效果上这种算法相当于剪切与粘贴一样,而不是复制粘贴。

   分代收集算法,这种算法根据对象存活时间的不同把内存分为不同的块,一般是分成新生代老年代。不同的块采用不同的收集算法。新生代由于存储对象一般小而且生命周期较短,所以一般采用“标记-复制”算法,老年代则恰恰相反适宜采用“标记-整理”或“标记-清除”算法。
       新生代:又分为1个Eden区和2个Survivor区(一个from、一个to), 其中比例默认8:1,每次只会使用Eden区和from Survivor块,另一个存储经过Minor GC后依然存活的对象。
      
 由于大多数对象的生命周期都比较短,所以大部分对象也都在新生代的Eden区分配(少数情况也会直接分配在老年代,比如大对象),当Eden区内存不足时会触发一次Minor GC,如果某个对象经过一次Minor GC后还存在,则会被复制到to Survorf块中,当对象经过一定次数的GC后(默认15次,可以通过-XX:MaxTenuringThreshold=N设置)如果还依然存活,则会移到老年代中。在复制过程中如果to Survor块空间不够了,这个时候就需要依赖老年代,由于JVM检查老年代大小的操作发生在GC之前,所以JVM只能通过记录每次往老年代中移入对象的总大小,然后取平均值,以这个平均值作为参考值来预估本次可能会移入的大小,如果此时老年代可用空间大小 > 该平均值,则认为空间足够,从而移入存活对象(但如果此次GC后实际存活对象大小还是>老年代可用空间,则最终移入失败,此时触发一次Full GC)。如果<该平均值,则认为空间不足,此时触发一次Full GC。
      老年代:老年代中的对象一般是经过了一定次数的Maniot GC后还存活的对象,或者是超过一定大小(可通过-XX:
PretenureSizeThreshold=N设置,经测试只对Serial和ParNew收集器有效)的对象。默认情况下,如果一个对象经过15次Minor GC还没有被回收,就会把它移至老年代中(可以通过-XX:MaxTenuringThreshold=N设置,如果设置为0的话,则对象直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概率)。由于老年代中的对象一般大且生存时间较长,所以这种情况下不适应“标记-复制”算法,而采用“标记-整理”或“标记-清除”算法就比较合适。
下面是一个简单的测试代码,测试空间不足的情况回收情况

//版本1.7,默认收集器PS,测试参数参数:-verbose:gc  -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
	public static void test(){
		byte[] b,b1;
		b = new byte[1*1024*1024]; 
		b1 = new byte[10*1024*1024]; 
	}

打印结果如下,这里使用的默认收集器Parallel Scavenge。当为b1分配空间时,此时新生代Eden空间已经不足,由此触发一次GC,GC之后Eden的空间仍然不足,而且老年代空间也不足以存放b1,此时触发一次Full GC(Full GC同时又执行了一次GC),Full GC之后Eden空间被完全释放,对象被移到了老年代中,但发现Eden或老年代还是无法存放b1,此时进行了最终的重试Full GC,最终抛出OutOfMemoryError异常。经过测试还发现采用不同的回收器,GC结果也不同。

[GC [PSYoungGen: 1859K->568K(9216K)] 1859K->1592K(19456K), 0.0015262 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC [PSYoungGen: 568K->536K(9216K)] 1592K->1560K(19456K), 0.0007950 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC [PSYoungGen: 536K->0K(9216K)] [ParOldGen: 1024K->1488K(10240K)] 1560K->1488K(19456K) [PSPermGen: 2567K->2566K(21504K)], 0.0158898 secs] [Times: user=0.05 sys=0.00, real=0.02 secs] 
[GC [PSYoungGen: 0K->0K(9216K)] 1488K->1488K(19456K), 0.0035244 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
[Full GC [PSYoungGen: 0K->0K(9216K)] [ParOldGen: 1488K->1476K(10240K)] 1488K->1476K(19456K) [PSPermGen: 2566K->2566K(21504K)], 0.0208845 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.tvl.cache.config.ObjectReferenceTest.test(ObjectReferenceTest.java:34)
	at com.tvl.cache.config.ObjectReferenceTest.main(ObjectReferenceTest.java:26)
Heap
 PSYoungGen      total 9216K, used 409K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 5% used [0x00000000ff600000,0x00000000ff666740,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 1476K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 14% used [0x00000000fec00000,0x00000000fed71290,0x00000000ff600000)
 PSPermGen       total 21504K, used 2597K [0x00000000f9a00000, 0x00000000faf00000, 0x00000000fec00000)
  object space 21504K, 12% used [0x00000000f9a00000,0x00000000f9c895b0,0x00000000faf00000)


垃圾收集器
Serial收集器:一个单线程的收集器,每次只会有一个线程去收集,当它运行时会暂停所有其它线程(Stop The World)。这种方式的优点就是它没有线程交互的开销,在收集过程中不会受其它线程影响;但缺点更致命,它很可能会造成程序长时间的停顿,自身就很可能成为一个性能瓶颈。所以它比较适合于较小的桌面应用程序,不适合大型的Web服务程序。
Serial Old收集器:Serial的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。这种收集器也主要是给Client模式下的虚拟机使用。


ParNew收集器:除了支持多线程运行外(默认开启的线程数==Cpu核数),与Serial基本相同,但在单CPU环境中,多线程的ParNew绝对没有Serial效率高,甚至由于线程切换的原因,线程越多效率反而会下降。(但目前ParNew是唯一可以与CMS收集器配合使用的)


Parallel Scavenge收集器:PS是一个可并行运行的新生代收集器(使用的复制算法)。与其它收集器的不同在于,上面说过垃圾收集器会导致用户线程停顿,所以大部分收集器的主要关注点都是如何尽量缩短停顿时间,但PS收集器的目的却是尽量提高吞吐量但同时也不能过于频繁的收集,说白了就是在每次吞吐量和收集次数之间找到一个平衡点,吞吐量 = 用户代码运行时间 / (用户代码运行时间 + 垃圾回收器运行时间),所以基于这点出发PS提供了两个参数用于控制吞吐量 和 最大垃圾收集时间。MaxGCPauseMills用于设置垃圾回收器运行时间(即最大的停顿时间),这个参数并不是越小越好,因为减少GC停顿时间是以牺牲新生代空间和吞吐量来换取的,值设置过小,系统会通过减少收集的空间,来缩短收集的时间在设置时间之内,但这样由于每次收集空间变小,所以总收集次数也就变多了,很可能吞吐量就下降了。比如以前10秒收集一次,每次停顿100毫秒,现在5秒收集一次,每次停顿70毫秒,停顿时间虽然在下降,但吞吐量也下降了。除了手动设置之外,PS也自带了自适应调节策略,虚拟机可以根据当前系统的运行情况,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这也是与parNew的区别之一。
Parallel Old收集器:PS收集器的老年代版本,同样支持多线程运行,采用“标记-整理”算法,主要用来配合PS收集器使用。在JKD1.6之前,新生代PS收集器,除了与老年代Serial Old组合之外没有其它选择(PS不能与CMS配合工作) ,但由于单线程的Serial Old收集器并不适应于服务端应用,所以整体组合并不一定能提高回收效率, 所以JDK1.6之提供了该收集器,对于注重“吞吐量”以及CPU资源比较敏感的场合,目前使用PS+PO组合是一个比较好的选择。

CMS收集器:一种尽力减少回收停顿时间的并发收集器。cms收集器是基于“标记-清除”算法实现的,整个过程可以分为4个步骤:初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记仍然会暂停所有线程,但初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记能与用户线程同进运作,进行GC Roots Tracing的标记过程;而重新标记是为了修改在并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段停顿的时间一般比初始标记要稍长一些,但远比并发标记的时间要短;并发清除即能与用户线程并发运行执行清除操作。虽然初始标记和重新标记会暂停用户线程,但从整体上来看,占用停顿时间最长的并发标记和并发清除的过程是可以与用户线程一起工作的,所以从整体上来说,CMS收集器是与用户线程一起并发执行的。 
   CMS缺点:对CPU资源非常敏感,CMS默认启用的回收线程数是(CPU核数+3)/4,与CPU数量成正比,当CPU核数较小时,相对来说占用的比例就更大,比如双核的情况下,如果CPU负载本身就比较高,此时再分出50%给垃圾回收线程,就可能严重影响性能。另外CMS无法清理浮动垃圾(在并发清理的同时,由用户线程产生的新垃圾),浮动垃圾只能依赖于下次回收进行清理,所以在CMS运行期间需要预留一定的内存空间,存储并发清理期间由用户线程产生的新数据。另外由于"标记-清除“算法回收后会产生大量空间碎片,所以虽然CMS提供了额外的参数来启用碎片整理,但碎片整理的过程是无法并发的,所以停顿时间相应的就会变长。

G1收集器:G1是面向服务端的一款收集器,开发目的就是为了在干掉CMS。G1同样是并发运行,也同样是分代收集,但采用的算法与CMS不同,从整体上来看G1是基于“标记-整理”算法实现的,最大的优点是可预测停顿时间,在G1之前的其它收集器收集的范围都是整个新生代或老年代,但在G1中Java堆内存的布局发生的很大的变化,整个Java堆被划分为N个大小相等的独立区域(Region),G1通过一个优先列表来维护各个Region的回收价值(回收Region中数据需要花费的时间及回收后可获得的空间),每次根据允许收集的时间,优先回收价值最大的Region,这种方式保证G1在有限时间内可以回收的尽可能多的空间。G1收集器的运行可以大致分为初始标记、并发标记、最终标记、刷选回收。其中前三个基本与CMS一样,不过最终标记可以"并行“运行,在刷选回收阶段会先对Region按价值排序,然后根据所期望的GC停顿时间来回收最大的一部分Region。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值