深入JVM内核(四)——GC 算法与种类

由于之前看的容易忘记,因此特记录下来,以便学习总结与更好理解,该系列博文也是第一次记录,所有有好多不完善之处请见谅与留言指出,如果有幸大家看到该博文,希望报以参考目的看浏览,如有错误之处,谢谢大家指出与留言。

一、GC的概念

1、Garbage Collection 垃圾收集  

这里的含义就是垃圾回收,所谓的垃圾,就是系统在运行当中,产生的无用的对象,那么他们是占据着一些内存空间的,长期占据内存会导致内存被占用完了,那么就会导致所谓的内存溢出。那么这些无用的对象就必须在一定时间内能够及时的被回收掉,以确保整个系统能够有足够的内存可以用 ,在C++或C都有程序员自己主动申请和释放内存空间,因此没有主动gc这个概念,那么在java中,由程序员自己主动申请和释放内存空间管理操作做出调整,使得程序员从这当中得到解脱。不需要太多过于关于内存的回收,垃圾的释放,这个过程则专门有一个内存算法,在java后台有一个专门做垃圾回收的线程,不定时的进行监控、扫面,自动的将一些无用的内存进行释放,这就是垃圾收集的思想;

主要目的:防止由程序员认为导致的内存的泄漏。

2、1960年 List 使用了GC 

在这个时候,list已经使用的gc,并不是从java开始,或者专门由java实现的;所以,java只是借鉴以前老的一些概念而已。

3、Java中,GC的对象是堆空间和永久区

堆和永久区是受java管理的,java做gc的时候是对堆和永久区里面无效对象和空间进行释放。

三、引用计数法(老牌的垃圾回收算法,比较古老的)

1、老牌垃圾回收算法

2、通过引用计算来回收垃圾

3、使用者

     COM

      ActionScript3

      Python

4、引用计数器的实现很简单,对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,则对象A就不可能再被使用。

5、引用计数法的问题

         问题一、引用和去引用伴随加法和减法,(因为要不停计算对象对他的引用等,他这个过程又是实时,无处不存在的,所以影响性能)

         问题二、很难处理循环引用

四、标记-清除

1、标记-清除算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后在清除阶段,清除所有未被标记的对象

如图:箭头表示引用,进行标记阶段

然后就开始清除:

五、标记-压缩

1、标记-压缩算法适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。

他与标记-清除在标记是一样的,但清除是不一样的。

如下过程:先比价对象是否存活,然后开始移动存活对象,或者复制对象

然后就开始清除

标记压缩对标记清除而言,有什么优势呢?

六、复制算法

1、与标记-清除算法相比,复制算法是一种相对高效的回收方法

2、不适用于存活对象较多的场合 如老年代

3、基本思想:将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

实现如下:

两块空间完全相同,每次只用一块

当使用复制算法之后,把所有存活对象复制到另一个空间,

复制算法完成之后,第一块空间完全清空,第二块只保留存活对象。并且在复制算法之后两块空间的角色发生变化,在系统里,本来他是使用第一块空间的,复制算法完成之后,他就开始使用第二块空间;以此类推,当第二块空间用完的时候,他把可存活对方复制到一地块空间,清除第二块空间,继续使用第一块,就这样以此类推。所以说复制算法一旦使用它就会存在一个的问题,空间浪费,而且至少浪费一半的空间。

4、复制算法的最大问题是:空间浪费

      因此采用复制算法来与整合标记清理思想来处理这个问题。—— 整合标记清理思想

想办法让空间不做过多的浪费。因此就把复制算法做一定的扩展。

如下图例:下面有三块空间,最下面那个老年代的大对象空间与复制算法是没有关系的,是单保的关系。主要的对象,也就是对象的产生主要放至在第一块最大的空间。 第二块空间,则是复制算法的核心。

系统开始运行的时候,总会有一块无用的空闲空间,两块有用的空间;当垃圾回收进行的时候,首先,大对象直接进入单保空间(也就是独立的空间),所以一般认为老年代空间就是单保空间了。所以说复制算法是需要空间做担保的,因为大对象直接放在复制空间是不合理的,因为,第一复制空间不一定会很大,甚至复制空间会很小,因为我们知道复制空间越大,就会浪费很多空间,因此对大对象来说,尽可能不要往复制空间里放,第二、如果大对象塞到复制空间进去了,那么会导致两种情况,1.大对象放进去了,那么会导致很多复制空间小对象就没地方去了,那么他们很可能会排挤到老年代中,这就很不合理,2.第二个情况是,那么大对象在复制空间中就跟本赛不进去,这时候那么他只能跑去老年代,所以复制算法他在交换的时候是需要有另一块空间去做担保的是,所以实现的时候就让老年代做担保,所以大对象进入老年代。

同时老年对象进入老年代。因为复制算法不适合老年代,他就是一个年轻代的算法。但当对象在几次回收都没有被清空,没有被回收掉,那么每一次回收,对象的年龄就会加一。那么当这个对象到达一定的年龄阶段之后,这个对象就是一个老年对象。所以老年对象就是好几次回收都没有把你回收掉的对象。你是一个被长期引用的对象,长期有用的对象,那么就放在老年代。所以说老年对象放在老年代。

那么剩余的对象怎么办呢?就是做复制:那么小的对象,和年轻的对象就会在回收的时候被复制到空闲空间里去(红色箭头所示)。那么原先的复制空间中的对象一些年轻的对象,和小对象也会被复制到另外一个空闲空间中去(如黄色箭头)。

那么这时候,他就会根据复制算法清空原先使用的空间。结果如下:

可看到,大的对象被复制到老年代,小的年轻的被复制到空闲那块空间中。原先的则清空。最后系统就成了右侧这个图所示。

-XX:+PrintGCDetails的输出接下来使用这个上一篇说过的堆空间信息的log参数来看下:

-XX:+PrintGCDetails的输出
  Heap
  def new generation   total 13824K, used 11223K [0x27e80000, 0x28d80000, 0x28d80000)
  eden space 12288K,  91% used [0x27e80000, 0x28975f20, 0x28a80000)
  from space 1536K,   0% used [0x28a80000, 0x28a80000, 0x28c00000)
  to   space 1536K,   0% used [0x28c00000, 0x28c00000, 0x28d80000)
  tenured generation   total 5120K, used 0K [0x28d80000, 0x29280000, 0x34680000)
  the space 5120K,   0% used [0x28d80000, 0x28d80000, 0x28d80200, 0x29280000)
  compacting perm gen  total 12288K, used 142K [0x34680000, 0x35280000, 0x38680000)
  the space 12288K,   1% used [0x34680000, 0x346a3a90, 0x346a3c00, 0x35280000)
  ro space 10240K,  44% used [0x38680000, 0x38af73f0, 0x38af7400, 0x39080000)
  rw space 12288K,  52% used [0x39080000, 0x396cdd28, 0x396cde00, 0x39c80000)

如上所看,新生代则分三个区,eden,from,to。eden区也就是上面所示的

这个地方,即,对象所产生的地方。而from和to大小是一样的,则他们就是复制算法的两块复制空间。所以说新生代的空间只有13兆,但是事实上我们可以根据地址范围求得一个公式,

他们两个地址线度之差就是新生代的大小,在转换成兆。我们算出的事15兆,但是可用空间只有13兆,所以少点的空间就是复制算法中所浪费的空间。那个浪费掉的一般空间。所以就浪费了1536K的空间。

七、分代思想

原则:依据对象的存活周期进行分类,短命对象归为新生代,长命对象归为老年代。

1、根据不同代的特点,选取合适的收集算法,所以中介如下:

       1、少量对象存活,适合复制算法

       2、大量对象存活,适合标记清理或者标记压缩 -大量对象存活一般都是老年代(如果使用复制算法会浪费大量空间,而且性能非常低的,因为如果有大量甚至全部存活的老年对象,你需要把整个空间都要全部复制的)

八、GC算法总结整理

1、引用计数

       没有被Java采用

2、标记-清除

3、标记-压缩

4、复制算法  

       是在新生代明确被使用的算法

 标记-压缩 比 标记-清除 有何优势?

所有的算法,需要能够识别一个垃圾对象,因此需要给出一个可触及性的定义

九、可触及性(下面有三种状态)

java中除了可触及对象和不可触及的对象还有可复活的对象;为什么会有三种状态,因为所谓的可复活对象就是,不可触及的,但是仅是间阶段不可触及的,当下不可触及,也许过一会这个对象又可触及了。 也就是说他会触一个绝无可能在被触及的的这么一个状态,像这样的对象也是不能做回收的。 因为他有可能复活,而不可触及的对象则是真的不可能再被其他人引用了。

1、可触及的

         从根节点可以触及到这个对象

2、可复活的

         一旦所有引用被释放,就是可复活状态(也就是在调用Object中的finalize()方法之前,就是可复活的,因为在这个方法当中这个对象就有可能被复活)

         因为在finalize()中可能复活该对象

3、不可触及的

        在finalize()后,可能会进入不可触及状态

        不可触及的对象不可能复活

        可以回收

不可触及的对象才是真正意义上的可回收,而可复活的对象虽然不可达,但是他是有可能复活的。

下面是通过finalize()方法把可复活的对象进行复活的例子:

public class CanReliveObj {
	public static CanReliveObj obj;
	@Override
	protected void finalize() throws Throwable {
	    super.finalize();
	    System.out.println("CanReliveObj finalize called");
	    obj=this;
	}
	@Override
	public String toString(){
	    return "I am CanReliveObj";
	}
public static void main(String[] args) throws
     InterruptedException{
obj=new CanReliveObj();
obj=null;   //可复活
System.gc();
Thread.sleep(1000);
if(obj==null){
    System.out.println("obj 是 null");
}else{
    System.out.println("obj 可用");
}
System.out.println("第二次gc");
obj=null;    //不可复活
System.gc();
Thread.sleep(1000);
if(obj==null){
System.out.println("obj 是 null");
}else{
System.out.println("obj 可用");
}
}

第一次调用则:CanReliveObj finalize called obj 可用  复活了

但第二次gc obj 是 null  则不能复活因为finalize只能被调用一次。

总结:

(1)、经验:避免使用finalize(),操作不慎可能导致错误。

(2)、优先级低,何时被调用, 不确定; 最主要的是finalize方式什么时候执行是不确定的,他什么时候被掉不确定。

        他是在GC是进行调用的但是 何时发生GC是不确定。所以代码中尽可能不要有不可能性情况存在。

(3)、如果需要去释放时可以使用try-catch-finally来替代它  

4、根

可触及性概念还有一个根概念,可触及性一般说是从根节点,去查找,去引用。在这个根节点的引用链上是否能够找到这个对象;因此这里来说明下根。什么是根:

     1.  栈中引用的对象(一般认为在栈中引用的对象可以认为是根,因为线程栈,所调用的当前函数中的一些局部变量,他们是真实存在的,如果一旦被他们引用了,则说明这个对象才是真实有效的存在,所以线程栈这些正在被使用的局部变量则可以认为是根。)

     2. 方法区中静态成员或者常量引用的对象(也就是全局对象,因为全局对象是在全局使用,任何时候都可能被引用)

     3.  JNI方法栈中引用对象

十、Stop-The-World(全局停顿)

1、Stop-The-World

     1、 Java中一种全局暂停的现象

     2、全局停顿,所有Java代码停止,native代码可以执行,但不能和JVM交互

     3、Stop-The-World多半由于GC引起

           (1)、Dump线程

           (2)、死锁检查

           (3)、 堆Dump

其他一些引起这是程序员引起的,比如:dump线程,堆等但发生的可能性小。而gc则是又与系统自行的判断等,产生的停顿,而且确确实实对java程序产生影响的,所以需要重视的。

2、GC时为什么会有全局停顿?

类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。所以会产生这里的垃圾是永远清理不完的,要清理完则需要让聚会停止,清理完在开始。又会产生垃圾,所以只是某一段时间是干净的。那么gc也是这个道理。如果在gc的时候java进程,jvm线程还在不断地产生垃圾。那么这时候gc是没办法彻底的将垃圾清理干净,并且这时候去清理也会gc的线程造成很大的负担,包括给gc的算法造成很大的难度。很难判断这到底是不是垃圾。所以比较好的方式则是当gc线程清理垃圾时(当然这个线程时jvm线程),java的所有线程都要停下来,保证没有新的垃圾产生。这时候gc线程才可以顺利的做垃圾标记与清除,尤其是垃圾标记。这就是gc产生的停顿的原因。也是很难避免的。

一般新生代的gc停顿是比较短的,一般是0.00几秒。可能对系统不会造成多大影响。但是针对老年代gc,可能会花很多时间,甚至几十分钟,也可能是0.00几秒,这个根据堆的大小来看。

3、危害

1.长时间服务停止,没有响应

2.遇到HA系统,可能引起主备切换,严重危害生产环境。

在高可用的情况下有主备机。

主备机之间是非常危险的,他们彼此判断,如果对方没有起来,自己会起来,但最终主机会起来,备机也在运行中,当两个同时起来时候,会造成数据的不一致性。

看一个例子:消耗内存的工作线程。

每秒打印10条

public static class PrintThread extends Thread{
	public static final long starttime=System.currentTimeMillis();
	@Override
	public void run(){
		try{
			while(true){
				long t=System.currentTimeMillis()-starttime;
				System.out.println("time:"+t);
				Thread.sleep(100);
			}
		}catch(Exception e){
			
		}
	}
}
public static class MyThread extends Thread{
	HashMap<Long,byte[]> map=new HashMap<Long,byte[]>();
	@Override
	public void run(){
		try{
			while(true){
				if(map.size()*512/1024/1024>=450){   //大于450M时,清理内存

					System.out.println(“=====准备清理=====:"+map.size());
					map.clear();
				}
				
				for(int i=0;i<1024;i++){
					map.put(System.nanoTime(), new byte[512]);  //每一毫米产生一个垃圾
				}
				Thread.sleep(1);
			}
		}catch(Exception e){
			e.printStackTrace();
		}
	}
}

启动上面程序,使用下面参数

-Xmx512M -Xms512M -XX:+UseSerialGC

-Xloggc:gc.log -XX:+PrintGCDetails  -Xmn1m

-XX:PretenureSizeThreshold=50

-XX:MaxTenuringThreshold=1

预期,应该是每秒中有10条输出

time:2018

time:2121

time:2221

time:2325

time:2425

time:2527

time:2631

time:2731

time:2834

time:2935

time:3035

time:3153

time:3504

time:4218

======before clean map=======:921765

time:4349

time:4450

time:4551

从红色位置3153秒产生了停顿,那么他们去哪了呢?

可以想象由Gc停顿导致的,打印日志没法工作了。

下面发生了3次GC,从3次GC的时间来看,中间发生了停顿。

猜你喜欢

转载自blog.csdn.net/gududedabai/article/details/81073192
今日推荐