之前一直有在看这一块的视频,或则资料,但是回过头来发现,自己记忆不是很深刻,应该是自己不太理解这一块,也不知道如何从源码看到关于这一块的内容,所以呢多找了几篇博客,自己看了几遍,看自己能不能总结一些东西出来。
首先还是上图(jvm内存模型图)
首先,我们可以注意到 jvm由这五大部分组成,程序计数器,虚拟机栈,本地方法栈,方法区,堆,其中呢,程序计数器,虚拟机栈,本地方法栈是线程所私有的,每个线程都独立的一块内存区,而方法区和堆是共享的,我们一个个聊一下。
1.程序计数器:
我们知道线程在抢占cpu资源的时候呢,运行时可能会出现时间片用完,或则被其他权限更高的线程抢占而暂时挂起,那么等cpu资源释放时,这个线程重新运行起来的时候,cpu如何知道执行到那一步了呢?这就是程序计数器的功能,能让cpu切换时,知道线程下一步的执行位置。为线程切换恢复到正确的执行位置,且每个线程所独有的。
2.本地方法栈:
而本地方法栈和虚拟机栈的作用十分相似,这个也相对于来说比较好理解,就是在我们写代码的时候,发现有很多是需要向下调用的,比如调用一些c++,c的函数时,统称为native方法,同样也需要像虚拟机栈一样存储相关的信息。
3.虚拟机栈:
这是经常考核面试的重点,这一块我觉得好理解的地方就是,虚拟机栈是由很多个栈帧所组成的,最顶上面的栈帧即为线程当前正在运行的方法,每一个方法都会创建一个栈帧(相当于每个方法对应一个栈帧,栈帧的入栈和出栈相当于方法的调用和释放),这里我们就能够理解了,栈帧存放的就是方法里面的各种数据和操作。
栈帧由局部变量表,操作数栈,动态链接,方法出口组成。
1).局部变量表:
a.存放的是当前方法中,八大基本类型的变量,其中局部变量表中的最小单元叫做slot,long和double占两个slot,其他数据类型占一个,
b.存放对象的引用,即存放的是其他对象的地址,
c.存放returnAddress.
2.)操作数栈:这个地方记录的是方法执行的操作,用栈来记录
3.)动态链接:指的是这个方法调用另一个方法的时候,对方法名在运行时解析和链接的过程(动态链接:常量池中一部分符号引用在运行时被转变为直接引用的过程)--(静态解析:常量池中,静态方法,final修饰的方法,类构造器,private方法,父类方法,这五种在编译期间(类加载过程中)就完成了从符号引用到直接引用的过程)
4.)方法出口:就是方法返回的地址
需要注意的是,局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小。
java虚拟机栈可能出现的两种异常
线程请求的栈深度大于虚拟机允许的的栈深度,将抛出StackOverflowError
虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常。(创建的线程太多)
4.方法区
聊方法区之前,我们先聊一下,方法区内常量池,这一块是比较让人头痛的,
从这张图中我们可以知道,一般我们看到的常量池分为三种,静态常量池,运行时常量池,字符串常量池。至于在1.6和1.7为什么会不同,我们这里不讲,网上有很多答案,我们先分别聊下这三个不同
先看下这张图:
1.)静态常量池:即.class文件中的常量池,class文件中常量池不仅仅包含字符串/数字这些字面量。还包含类,方法信息,占用class文件的绝大部分空间。
这种常量池主要用于存放两大类常量:字面量和符号引用量
字面量:相当于java语言层面常量的概念,如文本字符串(String),声明为final的常量值(final int a =1)等
符号引用则属于编译原理方面的概念,包含了如下三种类型的常量:
类和接口的全限定名
字段名称描述符(public,private,final等)
方法名称描述符
2.)运行时常量池:虚拟机会将在类加载后把各个class文件中的常量池载入到运行时常量池中,前面的静态常量池只是一个静态的文件结构,运行时常量池时方法区的一部分,是一块内存区域,运行时常量池可以在运行期间将符号引用解析为直接引用。
3.)字符串常量池:字符串常量池也可以理解为运行时常量池分出来的部分。加载时,对于class的静态常量池,如果是字符串会被装到字符串常量池中。字符串是JVM层面的技术。jdk1.7之后,字符串常量池留在了堆内。
这样我们大致了解三个常量池的不同,于是我们可以知道,当我们创建一个String 类型的时候,首先会被放在静态常量池中,再运行时,会存放到字符串常量池中。
总结:
方法区里存储着class文件(每一个对应的二进制流都会产生一个java.lang.class对象)的信息和动态常量池,class文件的信息包括类信息和静态常量池。可以将类的信息是对class文件内容的一个框架,里面具体的内容通过常量池来存储。
动态常量池里的内容除了是静态常量池里的内容外,还将静态常量池里的符号引用转变为直接引用,而且动态常量池里的内容是能动态添加的。例如调用String的intern方法就能将string的值添加到String常量池中,这里String常量池是包含在动态常量池里的,但在jdk1.8后,将String常量池放到了堆中。
我们再来看下如下图
方法区实际上就是存放的虚拟机以加载的类型信息,每一个类在方法去里面对应一个Class,其中包含内容
类型信息:
1.类的全限定名
2.超类的全限定名
3.直接超接口的全限定名
4.类型标致(是类类型还是接口类型)
5.类的访问描述符(public,private,default,abstract,final,static)
常量池:
存放改类型的所有常量的有序集合,包括直接常量(如字符串,整数,浮点数的final修饰的常量)和对其他类型,字段,方法的符号引用。
字段信息:
该类声明的所有字段,字段修饰符(public,protect,private,default)字段的类型 字段名称
方法信息
方法修饰符,方法返回类型,方法名,方法参数个数、类型、顺序等,方法字节码,操作数栈和该方法在栈帧中的局部变量区大小,异常表
类变量(静态变量)
指该类所有对象的共享的静态变量,在编译期就会被存进来,所以不需要创建对象就可以使用
指向类加载器的引用
每一个被JVM加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。
指向Class实例的引用
类加载的过程中,虚拟机会创建该类型的Class实例,方法区中必须保存对该对象的引用。通过Class.forName(String className)来查找获得该实例的引用,然后创建该类的对象。
方法表
为了提高访问效率,JVM可能会对每个装载的非抽象类,都创建一个数组,数组的每个元素是实例可能调用的方法的直接引用,包括父类中继承过来的方法。这个表在抽象类或者接口中是没有的,类似C++虚函数表vtbl。
原文链接:https://blog.csdn.net/ystyaoshengting/article/details/104207449
5.堆(heap)
我们知道在每次new 一个对象的时候,对象的实例都会被创建在堆中,且堆分配的内存一般也是比较大的,jvm中堆的结构是由新生代,老年代,和永久代组成(jdk1.8之后称为元空间,与1.8之前的区别是,元空间不放在JVM中,放在本地内存中)其中新生代由Eden(生成区)和Survivor区(又细分为FromSpace(s0),toSpace(s1))组成
其中,堆内存默认的大小假设是600M,新生代和老年代占的比例为1:2即,新生代为200M,老年代为400M.而新生代中,Eden和Survivor各所占的比例为8:1:1(160,20,20);新创建的对象都会存放到Eden区,然后每次通过GC操作会将一部分幸存对象,存放到Survivor区,并将对象的”年龄“加一,当对象的年龄”达到15“会存放到老年代。
新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC仍然存活的对象移动到老年代。
老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。
Minor GC : 清理年轻代
Major GC : 清理老年代
Full GC : 清理整个堆空间,包括年轻代和永久代
所有GC都会停止应用所有线程。
- GC
1.)那些内存需要回收?
根据从网上看到的文章内容说,jvm中的程序计数器,本地方法栈,虚拟机栈,由线程生,随线程而灭,就好比栈帧的内存,是在编译期间就已经确定下来的,当方法或者线程执行完毕,内存就随着回收,因此无需关心。而堆和方法区则不一样,堆因为存放的是所有对象的实例,是动态的,而方法区内的运行时常量池也是需要在运行期间才知道符号引用需要多少内存,这些都是GC关注的。
2.)如何判断内存是否需要回收?
这里就涉及两个算法了,引用计数法,和可达性分析算法
引用计数法:早期判断对象是否存活用的都是这个算法,实现比较简单,用一个引用计数器,当对象被引用一次就加1,当引用失效就减1,当引用为0的时候就判断这个对象是需要回收的,但是这个算法由一个缺点就是,如果两个对象互相引用的时候,导致一直不为0,就不会被回收。
可达性分析算法:这个是前主流的分析算法,基本思路是通过一个称为"Gc roots"的对象为起始点,搜索所经过的路径为引用链,当一个对象到”Gc roots“是没有任何路径的,则证明对象是不可用的。
可作为GC ROOT的对象有四种:
②方法区中的类静态属性引用的对象,一般指被static修饰引用的对象,加载类的时候就加载到内存中。
③方法区中的常量引用的对象,
①虚拟机栈(栈桢中的本地变量表)中的引用的对象,就是平时所指的java对象,存放在堆中。
④本地方法栈中JNI(native方法)引用的对象
注意:用可达性算法发现对象不可达时还需要判断对象是否有finalize方法(这里有疑问)
如果有的话,则执行finalize方法,执行完之后判断该对象到Gcroot是否可达。如果可达则不需要回收
工作原理:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用其finalize()方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。所以如果用finalize()就能在垃圾回收时刻做一些重要的清理工作。
3.)怎么回收(垃圾收集算法)
垃圾收集算法又有三种,标记清除算法,复制算法,标记清理算法等
a.复制算法
由于新生代中的对象都是“朝不保夕”的,按照我的理解就是,百分之90以上不会存活的太久,所以当GC开始时,新幸存的对象会被复制到formSpace区中,然后将Eden区的内存清空,当GC进行时,Eden幸存的对象和formSpace部分幸存对象会被复制到toSpace区内(ps:因为fromSpace区域部分幸存对象,要判断它的阈值是否大于15,大于15则要被移动到老年代)。复制算法的好处就是,每次移动的之后使用的内存都是连续的这样可以解决内存碎片的问题。简单高效,就是需要额外的空间。
具体实现
将内存按容量划分为两块,每次只使用其中一块。当这一块内存用完了,就将存活的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。这样使得每次都是对半个内存区回收,也不用考虑内存碎片问题,简单高效。缺点需要两倍的内存空间。
b.标记清除算法
GC分为两个阶段,标记和清除。首先标记所有可回收的对象,在标记完成后统一回收所有被标记的对象。同时会产生不连续的内存碎片。碎片过多会导致以后程序运行时需要分配较大对象时,无法找到足够的连续内存,而不得已再次触发GC。
c.标记整理算法
也分为两个阶段,首先标记可回收的对象,再将存活的对象都向一端移动,然后清理掉边界以外的内存。此方法避免标记-清除算法的碎片问题,同时也避免了复制算法的空间问题。
一般年轻代中执行GC后,会有少量的对象存活,就会选用复制算法,只要付出少量的存活对象复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外过多内存空间分配,就需要使用标记-清理或者标记-整理算法来进行回收。
ps:1.为什么要进行分代?
将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。
2.什么情况对象会被放入老年代
1.)当幸存对象的阈值达到15了的时候,证明对象的存活时间够长了,则会放入老年代
2.)当对象太大时,新生代无法存放的时候,会被放入老年代
3.)当Survivor区域所有相同年龄的总和,大于Survivor内存的一半时,Survivor年龄大于等于这个相同年龄的会被放到老年代
4.)空间担保原则:就是说当当发生MinorGc之前的时候,虚拟机会新生代所有对象的总空间,是否大于老年代剩余的最大连续的内存空间,如果大于则这次MinorGc是安全的(这里考虑的是最坏的情况)如果小于虚拟机则会查看HandlePromotionFailure设置值是否允许担保失败,
如果为true,虚拟机查看老年代最大可用连续空间是否大于晋升进入老年代对象的平均大小,如果大于则进行一次MinorGC,但这次MinnorGc依然是有风险的,如果小于则进行FULL GC为腾出位置
如果为false,则进行FULL GC
5).为什么要进行空间担保?
是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
3.Eden又分为survior区域?
是为了减少大量的对象进入老年代,如果只有一块Eden园区的时候,内存可能产生大量的碎片化,当分配新对象时可能造成没有足够的连续内存分配,这时候又会进行GC,同时对又一次幸存下来的对象阈值加1,当阈值达到15时,则会放入老年代且较大的对象会被送放到老年代。
4.survivor区域分为fromspace和tospace
是为了,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)
5.什么情况触发各种GC
Minnor GC 自然是新生代内存不足,或则Surivivor区域满了的时候都会触发
Fullgc
一是分配担保原则时,新生代存活的对象大于老年代剩余最大连续内存空间时且HandlePromotionFailure为FALSE时会触发FULLGC
二是,对象大于老年代剩余最大连续内存空间时且HandlePromotionFailure为TRUE时,最大连续内存空间是否大于历代晋升老年代的平均大小,小于则进行Full gc
三是 如果创建一个大对象,Eden区域当中放不下这个大对象,会直接保存在老年代当中,如果老年代空间也不足,就会触发Full GC。为了避免这种情况,最好就是不要创建太大的对象。
四是如果Survivor的相同年龄大于Survivor剩余空间大小时,会晋升到老年代,这个时候老年代剩余空间不足的话也会触发FULL gc
- 2.垃圾收集器
下面我们聊一下,常见的七种垃圾收集器:新生代收集器:Serial,ParNew,Parallel scavenge 老年代收集器:SerialOld,ParallerOld,CMS,整个堆的垃圾收集器:G1
- Serial:
Serial 作为新生代的第一款收集器,它的特点就是简单高效,占用内存最小、消耗资源最少,使用单线程收集(因为那个年代CPU还没有这么多核心,使用多线程反而效率会更低),垃圾回收算法采用的是标记复制法;
注意:这里作下说明,因为新生代对象朝生夕死的特性,往往存活下来的对象都是极少数的,所以新生代的垃圾收集器统统都是使用的“标记复制法”,如果不能理解请回头理解上一篇垃圾回收算法的内容。
Serial 垃圾收集流程
Serial 垃圾收集过程很简单,根据图一眼就能明白,Serial会开启一个线程进行垃圾收集,在收集的整个过程都会暂停用户线程(Stop the Word),直到垃圾收集完毕。
注意:说到“暂停用户线程”,这里也是各种垃圾收集器的一个区分点,有些垃圾收集器收集的某些阶段是不需要暂停用户线程的。
Serial 的特点
收集区域:新生代。
使用算法:标记复制法。
搜集方式:单线程。
搭配收集器:Serial Old。
优势:内存占用少、单核CPU环境最佳选项。
劣势:整个搜集过程需要停顿用户线程。多核CPU、内存富足的环境,资源优势无法利用起来。
- SerialOld
Serial old收集器
理解了Serial 后 再来看Serial Old就非常简单了, Serial Old 就是Serial 的老年代产品,它们两个的唯一区别就是使用的垃圾回收算法不同,Serial Old使用的是"标记清除法"。
注意:因为老年代进行垃圾收集的时往往大部分对象都是存活的,同时也因为存活对象多所需要的内存也比较多,也不能忍受内存空间的浪费,所以"标记复制法“ 是不适合老年代收集器的,所以老年的垃圾搜集算法往往是在标记清除法和标记整理法中选择。
Serial Old垃圾收集流程
同Serial 。
Serial 的特点
收集区域:老年代。
使用算法:标记清除法。
搜集方式:同Serial。
搭配收集器:Serial。
优势:同Serial。
劣势:同Serial。
- ParNew :
ParNew 收集器
ParNew 是新生代的第二款收集器, 其实我们思考一下作为第二款收集器一定是在某一方面改善了Serial存在的问题,而Serial的核心问题就是使用的单线程收集,所以ParNew 唯一核心特点就是在进行垃圾搜集的时候采用的是多线程收集的,在CPU多核的情况下它的性能是优于Serial收集器的。
还有一个ParNew兴起的因素是ParNew是唯一一个能与CMS配合一起使用新生代收集器,因为CMS的优秀所以让ParNew也出了名,这个就有点傍到大款的感觉。
ParNew 收集器流程
ParNew 垃圾收集过程和Serial差不多,只不过 PawNew会开启多个线程一起收集,同样在收集的整个过程都会暂停用户线程(Stop the Word),直到垃圾收集完毕。
PawNew的特点
收集区域:新生代。
使用算法:标记复制法。
搜集方式:多线程。
搭配收集器:CMS。
优势:相对于Serial 在多核CPU环境效率要高,新生代唯一一个能与CMS配合的收集器。
劣势:整个搜集过程需要停顿用户线程。
-*CMS
CMS收集器
我想从PawNew那里你就听到了CMS的大名,究竟是什么让CMS这么备受瞩目,其实说到底是因为BS系统的兴起,我们对用户体验越来越关注了,而在这几款垃圾收集器中CMS是第一个提出要“关注用户体验”的收集器,CMS的目标是尽可能的减少垃圾收集造成的用户线程的停顿。
那么要达成这个目标就要达到两个点,一是垃圾收集的收集过程要尽可能的快,二是在垃圾收集的过程我尽可能的不停止用户线程,进行垃圾收集时自己可以分出一部分资源让用户线程可以同时运行,通过这种方式来减少用户线程的停顿时间,提升用户体验。
CMS算法的选型:首先第一个要求是垃圾收集速度要快,那么我们就可以猜测到CMS在老年代垃圾回收算法中就肯定是选择的标记清理法,因为标记整理法很显然在速度上是无法和标记情理法比的,然而标记清理法又是个有后遗症的算法,因为会有空间碎片,所以最后CMS一考虑就决定,在空间碎片可以忍受的程度内这段时间都使用标记清除法,如果空间碎片达到了一定的程度,那么CMS又会采用标记整理法对内存进行一次整理,所以这样也不失为一个好的策略。
至于CMS是怎样实现“不停止用户线程”同时进行垃圾收集,我们可以通过下面的CMS工作流程里详细了解。
CMS工作流程
上面我们说了为什么减少垃圾收集对用户线程的影响,在垃圾收集的时候可以让用户线程和垃圾收集线程同时运行,CMS 把整个垃圾收集的过程分成初始标记、并发标记、重新标记、并发清理四个阶段,然后CMS会根据每个阶段不同的特性来决定是否停顿用户线程。
阶段一:初始标记
初始标记额目的是先把所有GC Root直接引用的对象进行标记,因为需要避免在标记GC Root的过程还有程序在继续产生GC Root对象,所以这个过程是需要需要停止用户线程 ,因为这个过程只会标记GC Root的直接引用,并不会对整个GC Root的引用进行遍历,所以这个过程速度也是所有阶段中最快的。
阶段二:并发标记
并发标记阶段的工作就是把阶段一标记好的GC Root对象进行深度的遍历,找到所有与GC Root关联的对象并进行标记,这个过程中是采用多线程的方式进行遍历标记,对整个JVM 的GC Root进行遍历的过程是垃圾收集过程中最耗时的一步,所以CMS为了考虑尽量不停顿用户线程,所以这个阶段是不停止用户线程的,也就是说这个阶段JVM会分配一些资源给用户线程执行任务,通过这样的方式减少用户线程的停顿时间。
阶段三:重新标记
因为在阶段二的时候用户线程同时也在运行,这个过程中又会产生新的垃圾,所以重新标记阶段主要任务是把上一个阶段中产生的新垃圾进行标记( 使用多线程标记),很显然这个过程是对上一个阶段用户线程运行遗留的垃圾进行标记,所以数量 是非常少执行时间也是最短的,当然为了避免这个过程再次产生新的垃圾,所以重新标记的过程是会停顿用户线程的。
阶段四:并发清理
并发清理阶段是对那些被标记为可回收的对象进行清理,因为清理对象的过程是比较耗时的,所以CMS在清理阶段也是不停顿用户线程的。因为在清理阶段程序也在同时运行,那么这个过程中必然也会产生新的垃圾,这也就是导致CMS收集器会产生“浮动垃圾”的原因。
注意:在有一种情况下此阶段CMS也会停顿用户线程,这就和我们之前说过的CMS选用的垃圾回收算法有关系,在可以忍受的情况下使用的是标记清除法,但是空间碎片到达了一定程度,那么此时CMS会使用标记整理法解决空间碎片的问题,不过使用标记整理法的时候必须得停顿用户线程,因为标记整理法会将对象的位置进行挪动,挪动了对象的位置就必须要更新对象的引用的指向地址,那么这个过程中用户线程同时运行的话会产生并发问题。
CMS的特点
收集区域:老年代。
使用算法:标记清除法+标记整理法。
搜集方式:多线程。
搭配收集器:PawNew。
优势:用户请求停顿时间短,多线程收集效率高。
劣势:会有浮动垃圾。
- Serial old
Serial 收集器的老年代版本,同样也是单线程的。它有一个实用的用途作为CMS收集器的备选预案,后面介绍CMS的时候会详细介绍。
线程类型:单线程
使用算法:标记-整理
- Parallel Scavenge(并发清除收集器)
是一个并发的多线程收集器,主要是关注的目标是吞吐量(用户代码执行时间 / (用户代码执行时间+ 垃圾收集时间))
优点:有一个自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的的停顿时间或则最大的吞吐量。
Parallel Scavenge收集器
按道理说同样作为新生代的收集器Parallel Scavenge,我们应该拿它与ParNew 对比,但他们两个的确没太大差异性,Parallel Scavenge大体的机制是与 ParNew相同的,同样是新生代的收集器,也是采用的多线程,标记复制算法,到最后也发现Parallel Scavenge 其实也只是在ParNew的基础上做了一些优化和智能的配置,这个智能配置体现在Parallel Scavenge里我们可以设置的垃圾收集停顿时间和吞吐量,根据我们的设置Parallel Scavenge会动态调整新生代各个内存的比率来达到我们的设置目标。
这样看下来我们发现Parallel Scavenge并没什么特别的,直到后来我们发现Parallel Scavenge的战略口号是“关注吞吐量”(吞吐量就是让有限的时间内能做更多事情),而这无形之中和另外一个口号“关注用户体验”的CMS收集器产生了强烈的对比。那么是优先提升整体系统的吞吐量,还是减少停顿时间提升用户体验,也正是它们的定位不同决定了他们的使用场景的不同。
Parallel Scavenge工作流程
Parallel Scavenge的垃圾收集过程和ParNew一样,在收集过程中会开启多个线程一起收集,同样在收集的整个过程都会暂停用户线程,也许这里有人会有一点疑问,Parallel Scavenge为什么不能学CMS一样进行垃圾收集的时候也允许用户线程一起执行,这个也正是因为Parallel Scavenge关注的是吞吐量,它认为我一心一意清理垃圾不把精力分出去做别的事情效率是最高的,然而事实也如此
Parallel Scavenge的特点
收集区域:新生代。
使用算法:标记复制法。
搜集方式:多线程。
搭配收集器:Parallel Old。
优势:多线程收集,吞吐量高。
劣势:整个搜集过程需要停顿用户线程。
Parallel Old
上面我们已经看到了Parallel Old 作为 Parallel Scavenge配合的老年代垃圾收集器,它们除了负责的区域和垃圾收集算法不同,其他的都一样,都是关注“吞吐量”的收集器。
Parallel Old工作流程
Parallel Old在收集过程中会开启多个线程一起收集,同样在收集的整个过程都会暂停用户线程(Stop the Word),直到垃圾收集完毕。
Parallel Old的特点
收集区域:老年代。
使用算法:标记整理法。
搜集方式:多线程。
搭配收集器:Parallel Scavenge。
优势:多线程收集,吞吐量高。
劣势:整个搜集过程需要停顿用户线程。
G1收集器
G1收集器的目标是致力于“在延迟可控的情况下达到更高的吞吐量”;这就有点像是 CMS 和Parallel Scavenge的合体了,既想提升用户体验又要有效率,有点鱼与熊掌我要兼得的感觉,不过这个事情真能做到的话我想前面两个收集器就不会那么纠结于是选择“低延时的方案来提升用户体验”还是“关注提升吞吐量了”,所以G1真正的绝招是在延时和吞吐量这两个之间做平衡和动态控制,又由于G1不仅仅可以用户新生代同时又可用应用于老年代,正因为这两点所以也有看“全能型的垃圾收集器”的称号。
为了实现这个目标,G1可谓是做了非常大的改变,这就不仅仅是垃圾回收的方式和算法上的改变了,G1连我们对于堆内存最基本分代的认知也颠覆了,在G1里面再也没有新生代、老年代、Eden、Survior1、Survior2这种物理区域的而划分了,而只是在逻辑概念里面进行了分区,某一块内存区域并不会固定的属于哪个分代,而是会动态变化的,下面我们要重点了解下G1是如何去实现这些的。
Region
G1最核心的分区基本单位Region ,G1没有像之前一样把堆内存划分为固定连续的几块区域,而是把堆内存拆分成了大小为1M-32M的Region块,然后以Region为单位自由的组合成新生代、老年代、Eden区和survior区,随着垃圾回收和对象分配每个Region也不会一直固定属于哪个分代,我们可以认为Region可以随时扮演任何一个分代区域的内存。
我想通过上图你大概也明白GC的分区逻辑了,下面我们看看这样分区方式对比其他收集器会有什么样的优势。
优势1:没有固定的分代,那么以为着内存中只要有空白的Region那么就可以继续分配对象,而不是像之前只要Eden区、survior区、老年代只要有一个空间不足了都会发起GC。
优势2:G1还有一个巧妙设计让垃圾收集变得更智能化,G1里面会维护一个Collect Set集合,这个里面记录了待回收的Region块信息同时也包括了每个Region块可回收的大小空间,有了这个信息我们就可以在收集的时候选择优先收集哪部分的区域,这样每次收集的性价比就相当高了。
优势3:GC回收时间的可控性,在G1里面我们可以通过配置去指定一个预期的垃圾收集的指定时间,只要知道了每个Region块的可回收对象大小,那么就可以根据设置的时间计算出收集哪些Region块可以达到指定的目标。当然这个时间不是设置得越小越好,时间少就意味着可以收集的空间少,那么同时也意味着收集次数会变得频繁,官方建议是在100ms-300ms之间。
劣势:因为每个Region区里的对象很有可能会引用其他Region区里的对象,那么对于这种跨Region引用的对象就需要有一个记忆集来维护这个关系,所以G1里面会为每个Region单独维护保存一个卡表,这个卡表就是用来记录这些信息的,当然这个卡表也要占用内存空间的,所以G1会相比CMS要多占用差不多10%-20%的内存空间,而且每次移动Region里的对象都要变更卡表里面的引用关系,这个过程也是非常复杂的。
G1的回收算法
可以说G1回收是用的标记复制法,针对Region来说,每次回收都是把Region里面的对象拷贝到另外一个Region区,然后清除原来Region里的对象。但是从整个堆内存来看又可以说G1使用的是标记整理法,每次垃圾收集都是对整个堆的Region块进行一次整理。再清除另一旁的的Region区域,不过我们知道不管是标记复制还是标记整理都是可以避免产生空间碎片的。
G1工作流程
G1的回收流程和CMS逻辑大致相同,分别进行初始标记、并发标记、重新标记、筛选清除,区别在最后一个阶段G1不会直接进行清除,而是会根据设置的停顿时间计算回收哪一部分的Region。
阶段一:初始标记
初始标记额目的是先把所有GC Root直接引用的对象进行标记,因为需要避免在标记GC Root的过程还有程序在继续产生GC Root对象,所以这个过程是需要需要停止用户线程 ,因为这个过程并不会对整个GC Root的引用进行遍历,所以这个过程速度是非常快的。
阶段二:并发标记
并发标记阶段的工作就是把阶段一标记好的GC Root对象进行深度的遍历,找到所有与GC Root关联的对象并进行标记,这个过程中是采用多线程的方式进行遍历标记,对整个JVM 的GC Root进行遍历的过程是垃圾收集过程中最耗时的一步,为了尽量不停顿用户线程,所以这个阶段GC线程会和用户线程同时运行,通过这样的方式减少用户线程的停顿时间。
阶段三:最终标记
因为在上个阶段用户线程同时也在运行,用户线程运行的过程中又会产生新的垃圾,所以重新标记阶段主要任务是把上一个阶段中产生的新垃圾进行标记( 使用多线程标记),很显然这个过程是对上一个阶段用户线程运行遗留的垃圾进行标记,所以数量是非常少执行时间也是非常短的,当然为了避免这个过程再次产生新的垃圾,所以重新标记的过程是会停顿用户线程的。
阶段四:筛选回收
根据Collect Set集合记录的可回收Region信息进行筛选,计算Region回收成本,根据用户设定的停顿时间值制定回收计划,根据回收计划筛选合适的Region区域进行回收。
G1的特点
收集区域:整个堆内存。
使用算法:标记复制法||标记整理法。
搜集方式:多线程。
搭配收集器:无需其他收集器搭配。
优势:停顿时间可控,吞吐量高,可根据具体场景选择吞吐量有限还是停顿时间有限,不需要额外的收集器搭配。
劣势:需要内存空间大,6G以上的内存才能考虑使用G1收集器。
- G1
G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 JDK 9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。
G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理(Mark-Compact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更加明显。
Region 作为单次回收的最小单元,每个Region都是大小相等且独立。且Region中有一个特殊的Humongous区域,专门用来存储大对象,G1认为只要超过Region的一半就可以被认为为大对象,一般Region的取值范围在1MB~32MB之间,且为2的整数次幂,那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humonggous Region之中。
1、初始标记
标记 GC Roots 直接关联的对象,需要 Stop The World 。
2、并发标记
从 GC Roots 开始对堆进行可达性分析,找出活对象。当对象图扫描完成之后,还要重新SATB(原始快照)记录下的在并发时有引用变动的对象
3、最终标记
对用户线程做一个做另一个短暂的暂停(STW),用于处理并发阶段结束后仍遗留下的最后少量的SATB记录
4、筛选回收
首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段可以与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的。
线程类型:多线程
使用算法:复制、标记-整理
不同垃圾收集器得不同场景
什么时候选择 Serial + Serial Old?
一般在资源有客观的局限性的条件下才选择 ,比如单核CPU,内存非常小,JDK版本限制;像桌面应用、客户端程序一般都不会分配太大的内存,所以比较Serial收集器比较适用。。
什么场景选择 Parallel Scavenge + Parallel Old
多核CPU,对吞吐量有要求但对停顿时间没什么要求的应用,适合以后台任务为主应用、计算型的应用、不太需要关心用户交互的场景。
什么场景选择 ParNew+CMS?
注重用户交互的场景,然后内存空间还不是太富足的情况下(6G及以下),不过如果在内存空间富足的情况下,同样的场景可以选更优的G1收集器。
什么场景选择G1?
注重低停顿,响应速度快的用户交互场景,然而内存又比较富足(大于6G),比如说web网站,APP应用。
-增量更新/原始快照
当且仅当以下两个条件同时满足时会产生“对象消失问题”。即原本应该是黑色的对象,被误认为白色
- 赋值器插入一条或多条从黑色对象道白色对象的引用
-赋值器删除了全部从灰色对象到白色对象的直接或间接引用
因此要解决这两个问题,产生了两种解决方案:增量更新 和 原始快照
增量更新:增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这两个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次,这可以理解为,黑色对象一旦插入了指向白色对象的引用之后,它就变成了灰色对象。
原始快照:原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发结束扫描之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照进行搜索。
- 对象的内存布局
对象的内存布局主要是由三部分组成的,对象头,实例数据,对齐填充
1.对象头
- 对象自身运行时数据:hash码,分代年龄,线程持有锁,锁状态标志,偏向时间戳,偏向线程ID。
- 指向对象类型指针:java虚拟机可以通过这个指针来确定该对象是那个实例
-如果是数组还有数组的长度
2.实例数据
实例数据指的是,我们在类中定义的各种类型的字段内容,无论是从父类继承下来还是我们自己定义的,都会被记录下来
3.对齐填充
虚拟机规定,任何对象的大小都必须规定为8字节的整数倍,对象头是被精确的控制在8字节的的整数倍,但是实例数据则不一定,所以需要对齐填充来使之达到8字节的整数倍。
-对象创建过程
当遇到new关键字创建对象时,首先回去方法区的常量池拿到这个类的符号引用,判断该类有没有被类加载过,如果没有被类加载过的话,则先加载
然后,就是给该对象分配堆内存空间,这里有两种方式,根据堆内存的规整程度,采用指针碰撞或则空闲列表的方式分配内存,至于什么是指针碰撞呢,就是说如果采用的是serial和parnew这种收集器的话,就会将堆内存划分为空闲内存和已分配内存区域,所以当分配对象内存时,只需要将指针移动到空闲内存那部分,就表示分配了内存。空闲列表是因为采用了 cms的这种标记清除去收集,所以得用空闲列表记录空闲内存得地址。
然后呢,就是得将对象头部的一些信息进行初始化了,比如对象得hashcode,对象的分代年龄等信息,进行设置,同时将对象头得类型指针指向改类
最后就是,调用构造函数,对对象中的字段和从父类继承过来的字段属性值,赋初始值。
1.判断这个类是否被类加载过,如果没有则先加载
2.给对象分配内存空间,采用碰撞指针或则空闲列表的方式
3.将对象头的信息进行初始化,对象的hashcode,分代年龄
4.调用构造函数,执行构造函数中赋值语句的操作,同时将对象中的字段和从父类继承的字段进行初始化