Java虚拟机详解04----GC算法和种类

【声明】 html

欢迎转载,但请保留文章原始出处→_→ java

生命壹号:http://www.cnblogs.com/smyhvae/算法

文章来源:http://www.cnblogs.com/smyhvae/p/4744233.html
数据库

 

本文主要内容:编程

  • GC的概念
  • GC算法

    引用计数法(没法解决循环引用的问题,不被java采纳)数组

      根搜索算法ide

      现代虚拟机中的垃圾搜集算法:布局

      标记-清除性能

      复制算法(新生代)字体

      标记-压缩(老年代)

      分代收集

  • Stop-The-World

 

1、GC的概念:

  • GC:Garbage Collection 垃圾收集
  • 1960年 Lisp使用了GC
  • Java中,GC的对象是Java堆和方法区(即永久区)

咱们接下来对上面的三句话进行一一的解释:

(1)GC:Garbage Collection 垃圾收集。这里所谓的垃圾指的是在系统运行过程中所产生的一些无用的对象,这些对象占据着必定的内存空间,若是长期不被释放,可能致使OOM

在C/C++里是由程序猿本身去申请、管理和释放内存空间,所以没有GC的概念。而在Java中,后台专门有一个专门用于垃圾回收的线程来进行监控、扫描,自动将一些无用的内存进行释放,这就是垃圾收集的一个基本思想,目的在于防止由程序猿引入的人为的内存泄露

(2)事实上,GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC须要完成的3件事情:

哪些内存须要回收?

何时回收?

如何回收?

(3)内存区域中的程序计数器、虚拟机栈、本地方法栈这3个区域随着线程而生,线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操做,每一个栈帧中分配多少内存基本是在类结构肯定下来时就已知的。在这几个区域不须要过多考虑回收的问题,由于方法结束或者线程结束时,内存天然就跟着回收了

Java堆和方法区则不一样,一个接口中的多个实现类须要的内存可能不一样,一个方法中的多个分支须要的内存也可能不同,咱们只有在程序处于运行期间时才能知道会建立哪些对象,这部份内存的分配和回收都是动态的,GC关注的也是这部份内存,后面的文章中若是涉及到“内存”分配与回收也仅指着一部份内存。

 

2、引用计数算法:(老牌垃圾回收算法。没法处理循环引用,没有被Java采纳)

一、引用计数算法的概念:

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任什么时候刻计数器为0的对象就是不可能再被使用的。

e6217360-0985-46e8-88fd-022f1fc0fba5

二、使用者举例:

引用计数算法的实现简单,断定效率也高,大部分状况下是一个不错的算法。不少地方应用到它。例如:

微软公司的COM技术:Computer Object Model

使用ActionScript3的FlashPlayer

Python

可是,主流的java虚拟机并无选用引用计数算法来管理内存,其中最主要的缘由是:它很难解决对象之间相互循环引用的问题

三、引用计数算法的问题:

  • 引用和去引用伴随加法和减法,影响性能
  • 致命的缺陷:对于循环引用的对象没法进行回收

1a489e67-e047-408f-a97e-4a141e6ab3b0

上面的3个图中,对于最右边的那张图而言:循环引用的计数器都不为0,可是他们对于根对象都已经不可达了,可是没法释放。

循环引用的代码举例:

复制代码
 1 public class Object {
 2 
 3     Object field = null;
 4     
 5     public static void main(String[] args) {
 6         Thread thread = new Thread(new Runnable() {
 7             public void run() {
 8                 Object objectA = new Object();
 9                 Object objectB = new Object();//位置1
10                 objectA.field = objectB; 11                 objectB.field = objectA;//位置2 12                 //to do something
13                 objectA = null;
14                 objectB = null;//位置3
15             }
16         });
17         thread.start();
18         while (true);
19     }
20     
21 } 
复制代码

上方代码看起来有点刻意为之,但其实在实际编程过程中,是常常出现的,好比两个一对一关系的数据库对象,各自保持着对方的引用。最后一个无限循环只是为了保持JVM不退出,没什么实际意义。

代码解释:

代码中标注了一、二、3三个数字,当位置1的语句执行完之后,两个对象的引用计数所有为1。当位置2的语句执行完之后,两个对象的引用计数就所有变成了2。当位置3的语句执行完之后,也就是将两者所有归为空值之后,两者的引用计数仍然为1。根据引用计数算法的回收规则,引用计数没有归0的时候是不会被回收的。

对于咱们如今使用的GC来讲,当thread线程运行结束后,会将objectA和objectB所有做为待回收的对象。而果咱们的GC采用上面所说的引用计数算法,则这两个对象永远不会被回收,即使咱们在使用后显示的将对象归为空值也毫无做用。

 

3、根搜索算法:

一、根搜索算法的概念:

  因为引用计数算法的缺陷,因此JVM通常会采用一种新的算法,叫作根搜索算法。它的处理方式就是,立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是能够被回收的

7ab0f17b-13f7-4886-a24d-3813c2173891

如上图所示,ObjectD和ObjectE是互相关联的,可是因为GC roots到这两个对象不可达,因此最终D和E仍是会被当作GC的对象,上图如果采用引用计数法,则A-E五个对象都不会被回收。

 

二、可达性分析:

 咱们刚刚提到,设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是能够被回收的。咱们在后面介绍标记-清理算法/标记整理算法时,也会一直强调从根节点开始,对全部可达对象作一次标记,那什么叫作可达呢?这里解释以下:

可达性分析:

  从根(GC Roots)的对象做为起始点,开始向下搜索,搜索所走过的路径称为引用链”,当一个对象到GC Roots没有任何引用链相连(用图论的概念来说,就是从GC Roots到这个对象不可达)时,则证实此对象是不可用的。

 

三、根(GC Roots):

说到GC roots(GC根),在JAVA语言中,能够当作GC roots的对象有如下几种:

一、栈(栈帧中的本地变量表)中引用的对象。

二、方法区中的静态成员。

三、方法区中的常量引用的对象(全局变量)

四、本地方法栈中JNI(通常说的Native方法)引用的对象。

注:第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。

在根搜索算法的基础上,现代虚拟机的实现当中,垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法。这三种算法都扩充了根搜索算法,不过它们理解起来仍是很是好理解的。

 

4、标记-清除算法:

一、标记清除算法的概念:

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

7de44970-2e02-46a1-a5d0-0663b21906c6

二、标记-清除算法详解:

它的作法是当堆中的有效内存空间(available memory)被耗尽的时候,就会中止整个程序(也被成为stop the world),而后进行两项工做,第一项则是标记,第二项则是清除。

  • 标记:标记的过程其实就是,遍历全部的GC Roots,而后将全部GC Roots可达的对象标记为存活的对象
  • 清除:清除的过程将遍历堆中全部的对象,将没有标记的对象所有清除掉

也就是说,就是当程序运行期间,若可使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中全部没被标记的对象所有清除掉,接下来便让程序恢复运行

来看下面这张图:

47146934-c3a3-4976-991f-77e84ae008cc

上图表明的是程序运行期间全部对象的状态,它们的标志位所有是0(也就是未标记,如下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,JVM将会中止应用程序的运行并开启GC线程,而后开始进行标记工做,按照根搜索算法,标记完之后,对象的状态以下图:

5cbf57ce-c83a-40d2-b58a-b37d3eee3803

上图中能够看到,按照根搜索算法,全部从root对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。接下来,就要执行第二阶段清除了,那么清除完之后,剩下的对象以及对象的状态以下图所示:

8654ed59-fc00-446d-8995-a02ab57cf213

上图能够看到,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,而且会将标记位从新归0。接下来就不用说了,唤醒中止的程序线程,让程序继续运行便可。

疑问:为何非要中止程序的运行呢?

答:

这个其实也不难理解,假设咱们的程序与GC线程是一块儿运行的,各位试想这样一种场景。

假设咱们刚标记完图中最右边的那个对象,暂且记为A,结果此时在程序当中又new了一个新对象B,且A对象能够到达B对象。可是因为此时A对象已经标记结束,B对象此时的标记位依然是0,由于它错过了标记阶段。所以当接下来轮到清除阶段的时候,新对象B将会被苦逼的清除掉。如此一来,不难想象结果,GC线程将会致使程序没法正常工做。

上面的结果固然使人没法接受,咱们刚new了一个对象,结果通过一次GC,突然变成null了,这还怎么玩?

三、标记-清除算法的缺点:

(1)首先,它的缺点就是效率比较低(递归与全堆对象遍历),致使stop the world的时间比较长,尤为对于交互式的应用程序来讲简直是没法接受。试想一下,若是你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?

(2)第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,咱们的死亡对象都是随即的出如今内存的各个角落的,如今把它们清除以后,内存的布局天然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。并且在分配数组对象的时候,寻找连续的内存空间会不太好找。

 

5、复制算法:(新生代的GC)

复制算法的概念:

将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,以后,清除正在使用的内存块中的全部对象,交换两个内存的角色,完成垃圾回收

  • 与标记-清除算法相比,复制算法是一种相对高效的回收方法
  • 不适用于存活对象较多的场合,如老年代(复制算法适合作新生代的GC

ff1e1846-e49c-4663-aee1-7c63628f567c

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

复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂状况,只要移动堆顶指针,按顺序分配内存便可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,这个太要命了。

因此从以上描述不难看出,复制算法要想使用,最起码对象的存活率要很是低才行,并且最重要的是,咱们必需要克服50%内存的浪费。

如今的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,因此并不须要按照1:1的比例来划份内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。

固然,98%的对象可回收只是通常场景下的数据,咱们没有办法保证每次回收都只有很少于10%的对象存活,当Survivor空间不够用时,须要依赖于老年代进行分配担保,因此大对象直接进入老年代。整个过程以下图所示:

7e1f6ed2-e0c4-45e4-b7db-b59c28e1ee9c

上图中,绿色箭头的位置表明的是大对象,大对象直接进入老年代。

根据上面的复制算法,如今咱们来看下面的这个gc日志的数字,就应该能看得懂了吧:

6d59301f-f0c9-4fed-ba36-e66bc6574e8f

上方GC日志中,新生代的可用空间是13824K(eden区的12288K+from space的1536K)。而根据内存的地址计算得知,新生代的总空间为15M,而这个15M的空间是 = 13824K +to space 的 1536K。

 

6、标记-整理算法:(老年代的GC)

引入:

    若是在对象存活率较高时就要进行较多的复制操做,效率将会变低。更关键的是,若是不想浪费50%的空间,就须要有额外的空间进行分配担保,以应对被使用的内存中全部对象都100%存活的极端状况,因此在老年代通常不能直接选中这种算法。

概念:

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

cc79889a-0856-4018-92c3-c51108c9caea

  • 标记:它的第一个阶段与标记/清除算法是如出一辙的,均是遍历GC Roots,而后将存活的对象标记。
  • 整理:移动全部存活的对象,且按照内存地址次序依次排列,而后将末端内存地址之后的内存所有回收。所以,第二阶段才称为整理阶段。

上图中能够看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当咱们须要给新对象分配内存时,JVM只须要持有一个内存的起始地址便可,这比维护一个空闲列表显然少了许多开销。

标记/整理算法不只能够弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价

  • 可是,标记/整理算法惟一的缺点就是效率也不高。

不只要标记全部存活对象,还要整理全部存活对象的引用地址。从效率上来讲,标记/整理算法要低于复制算法。

标记-清除算法、复制算法、标记整理算法的总结:

三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法能够正常工做的理论依据,就是语法中变量做用域的相关内容。所以,要想防止内存泄露,最根本的办法就是掌握好变量做用域,而不该该使用C/C++式内存管理方式。

在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。

它们的区别以下:(>表示前者要优于后者,=表示二者效果同样)

(1)效率复制算法>标记/整理算法>标记/清除算法(此处的效率只是简单的对比时间复杂度,实际状况不必定如此)。

(2)内存整齐度:复制算法=标记/整理算法>标记/清除算法。

(3)内存利用率:标记/整理算法=标记/清除算法>复制算法。

注1:能够看到标记/清除算法是比较落后的算法了,可是后两种算法倒是在此基础上创建的。

注2:时间与空间不可兼得

 

7、分代收集算法:(新生代的GC+老年代的GC)

当前商业虚拟机的GC都是采用的“分代收集算法”,这并非什么新的思想,只是根据对象的存活周期的不一样将内存划分为几块儿。通常是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代

  • 少许对象存活,适合复制算法:在新生代中,每次GC时都发现有大批对象死去,只有少许存活,那就选用复制算法,只须要付出少许存活对象的复制成本就能够完成GC。
  • 大量对象存活,适合用标记-清理/标记-整理:在老年代中,由于对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-清理”/“标记-整理”算法进行GC。

注:老年代的对象中,有一小部分是由于在新生代回收时,老年代作担保,进来的对象;绝大部分对象是由于不少次GC都没有被回收掉而进入老年代

8、可触及性:

全部的算法,须要可以识别一个垃圾对象,所以须要给出一个可触及性的定义。

可触及的:

  从根节点能够触及到这个对象。

      其实就是从根节点扫描,只要这个对象在引用链中,那就是可触及的。

可复活的:

  一旦全部引用被释放,就是可复活状态

  由于在finalize()中可能复活该对象

不可触及的:

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

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

      要被回收。

finalize方法复活对象的代码举例:

复制代码
 1 package test03;
 2 
 3 /**
 4  * Created by smyhvae on 2015/8/19.
 5  */
 6 public class CanReliveObj {
 7     public static CanReliveObj obj;
 8 
 9     //当执行GC时,会执行finalize方法,而且只会执行一次
10     @Override
11     protected void finalize() throws Throwable {
12         super.finalize();
13         System.out.println("CanReliveObj finalize called");
14         obj = this;   //当执行GC时,会执行finalize方法,而后这一行代码的做用是将null的object复活一下,而后变成了可触及性
15     }
16 
17     @Override
18     public String toString() {
19         return "I am CanReliveObj";
20     }
21 
22     public static void main(String[] args) throws
23             InterruptedException {
24         obj = new CanReliveObj();
25         obj = null;   //可复活
26         System.out.println("第一次gc");
27         System.gc();
28         Thread.sleep(1000);
29         if (obj == null) {
30             System.out.println("obj 是 null");
31         } else {
32             System.out.println("obj 可用");
33         }
34         obj = null;    //不可复活
35         System.out.println("第二次gc");
36         System.gc();
37         Thread.sleep(1000);
38         if (obj == null) {
39             System.out.println("obj 是 null");
40         } else {
41             System.out.println("obj 可用");
42         }
43     }
44 }
复制代码

 

咱们须要注意第14行的注释。一开始,咱们在第25行将obj设置为null,而后执行一次GC,本觉得obj会被回收掉,其实并无,由于GC的时候会调用11行的finalize方法,而后obj在第14行被复活了。紧接着又在第34行设置obj设置为null,而后执行一次GC,此时obj就被回收掉了,由于finalize方法只会执行一次。

31011217-d3a2-4e5b-9503-4f0b9bad9161

finalize方法的使用总结:

  • 经验:避免使用finalize(),操做不慎可能致使错误。
  • 优先级低,什么时候被调用,不肯定

什么时候发生GC不肯定,天然也就不知道finalize方法何时执行

  • 若是要使用finalize去释放资源,咱们可使用try-catch-finally来替代它

 

9、Stop-The-World:

一、Stop-The-World概念:

  Java中一种全局暂停的现象。

全局停顿,全部Java代码中止,native代码能够执行,但不能和JVM交互

多半状况下是因为GC引发

    少数状况下由其余状况下引发,如:Dump线程、死锁检查、堆Dump。

 

二、GC时为何会有全局停顿?

    (1)避免没法完全清理干净

打个比方:类比在聚会,忽然GC要过来打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让你们中止活动了,才能将房间打扫干净。

    何况,若是没有全局停顿,会给GC线程形成很大的负担,GC算法的难度也会增长,GC很难去判断哪些是垃圾。

  (2)GC的工做必须在一个能确保一致性的快照中进行。

这里的一致性的意思是:在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不能够出现分析过程当中对象引用关系还在不断变化的状况,该点不知足的话分析结果的准确性没法获得保证。

这点是致使GC进行时必须停顿全部Java执行线程的其中一个重要缘由。

三、Stop-The-World的危害:

长时间服务中止,没有响应(将用户正常工做的线程所有暂停掉)

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

  备注:HA:High Available, 高可用性集群。

d07bb3ea-1235-41d5-9fb1-56b4087d1acf

好比上面的这主机和备机:如今是主机在工做,此时若是主机正在GC形成长时间停顿,那么备机就会监测到主机没有工做,因而备机开始工做了;可是主机不工做只是暂时的,当GC结束以后,主机又开始工做了,那么这样的话,主机和备机就同时工做了。主机和备机同时工做实际上是很是危险的,颇有可能会致使应用程序不一致、不能提供正常的服务等,进而影响生产环境。

代码举例:

(1)打印日志的代码:(每隔100ms打印一条)

复制代码
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){

        }
    }
}
复制代码

 

上方代码中,是负责打印日志的代码,每隔100ms打印一条,并计算打印的时间。

(2)工做线程的代码:(工做线程,专门用来消耗内存)

复制代码
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){   //若是map消耗的内存消耗大于450时,那就清理内存
                    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();
        }
    }
}
复制代码

 

而后,咱们设置gc的参数为:

-Xmx512M -Xms512M -XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails -Xmn1m -XX:PretenureSizeThreshold=50 -XX:MaxTenuringThreshold=1

 

打印日志以下:

8a8de388-7989-47f2-a7e1-496487e4be57

上图中,红色字体表明的正在GC。按道理来讲,应该是每隔100ms会打印输出一条日志,可是当执行GC的时候,会出现全局停顿的状况,致使没有按时输出。

 

下篇文章中,咱们将对各类垃圾收集器进行介绍。

相关文章
相关标签/搜索