一、 JVM概念模型
我们整天都在使用JVM,但是你知道JVM的内存布局是怎么样的么?
它可以被分为两种情况
1. 线程共享:所有的线程都可以去访问的区域
1). 堆
2). 方法区
2. 线程私有:每个线程都有的区域
1). 虚拟机栈
2). 本地方法栈
3). 程序计数器
JVM的内存区域基本就可以分为上面的五种区域,那么他们的作用是什么?什么数据会存放在什么地方?
程序计数器(PC): 它是这五种区域中最小的一块内存,它里面存放的是当前线程的字节码指示器,通过这个字节码指示器来决定下一条需要执行的字节码指令,比如循环、跳转、异常退出等。为了保证线程安全,并且可以让每个功能可以正常的执行,所以就把程序计数器作为一个线程私有的区域了,这样我们就不会因为CPU调度线程而导致当前线程执行的任务丢失。举个栗子,就是我们宿舍合资买了一本书,所以我们宿舍就决定用时间来分配,每个人看2个小时,当我拿到这本书的时候,我首先会按照我的进度去看。两个小时时间过去之后,我应该把这本书交给下一个人,但是我们不能就这么直接交过去,要不然下一次我拿到书的时候我都不知道看到了哪里,所以,我得要给这本书夹一个书签,表示我已经看到这里了, 下一次拿到书的时候我就不用从头开始看,直接从这里开始看就可以了。
虚拟机栈(VM Stack): Java方法的执行的地方。每个线程去执行方法的时候,都会先去创建一个栈帧,然后让这个栈帧去入虚拟机栈,等到方法执行完毕之后再把这个栈帧出栈。所以一个方法的调用到执行结束,意味着一个栈帧的入栈到出栈。
那么,这个栈帧存放的是什么呢?
它存放的是四种东西
1. 局部变量表
局部变量表里面存储的是这个方法里面所有编译期已知的基本类型。byte,short,char,int,long,double,float,boolean,reference(在Java中没有指针的概念,只有引用,你可以将这个reference理解为对某个对象的起始位置的指针),returnAddress(指向了一条字节码指令的地址)。
2. 操作数栈
我们的所有计算都是通过操作数栈来进行完成的。就比如说 c=a+b;
3. 动态链接
指向运行时常量池中这个栈帧所属方法的引用,在运行时我们就可以知道具体去调用哪个方法了。
4. 方法出口
一个方法的所有正常退出(return)和非正常退出(异常)的出口
本地方法栈(Native Method Stack): 和虚拟机栈类似,不过里面执行的方法是本地方法.什么是本地方法?本地方法就是被native 修饰的方法,就比如CAS底层就是Unsafe类里面的本地方法。这些方法不是由Java去写的,而是由其他语言,大部分是C/Cpp,然后由JNI去调用这个方法。
堆(Heap): JVM内存布局中最大的一块空间,所有的对象的创建、数组的创建都是在堆里面进行的。HotSpot虚拟机在1.7的时候,将运行时常量池从方法区放在了堆里面。它是GC的重点关注对象。为了可以让我们看到堆里面的内存占用情况,HotSpot提供了一条命令用以监控堆里面的信息。Jmap uid uid是你执行方法时的id。你会看到堆被分为新生代与老年代,新生代有From Space、To Space 、Eden。Eden区存放的大多是一些朝生夕死的对象,它们大部分连第一次GC都没有扛过去,就被回收了。而抗过了第一次GC的对象则会使用复制算法被转移到To Survivor区,然后开始清理From Survivor区与Eden区的空间。这时被转移到To Survivor区的对象的对象头中的生存年龄改为1.等到下一次GC的时候就会去先存活的对象用复制算法放在From Survivor区中,然后将清除To Survivor区和Eden中死亡的对象。
方法区(Method Area): 存储的是被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码。它其实也是在堆里面的,但是逻辑上是单独存在的。HotSpot在之前是用永久代的形式去构建了方法区,后来因为GC效果不好,总是发生OOM,并且为了与Jrocket虚拟机融合,所以就把永久代剔除了,改用元数据区去实现方法区。也就是说,方法区是JVM规范里面规范的,是必须存在的。
运行时常量池是方法区的一部分,它里面主要存放的是编译期生成的直接引用和字符量,
二、 对象
我们整天创建对象,用对象。那么问题来了。对象是怎么创建的?创建完之后我们应该去怎么访问?对象的组成结构是什么?
对象是怎么创建的?可能有人就会说这个简单,不就是 A a=new A(); 但是 这个在JVM里面的创建过程你知道吗?
当JVM碰到new这条指令的时候,就会先去常量池定位这个类的符号引用,并且看是否已经被加载,解析,初始化,如果不存在的话就会先去进行类加载,再去给对象分配内存,然后给分配到内存的对象赋值为0.再去设置对象头的数据,执行方法。如果存在的话就跳过类加载,因为已经被加载过了,就直接到给对象分配内存阶段,后面的流程是一样的。
类加载机制:加载-->链接,链接完成之后就开始初始化,初始化就是后面出现分配内存、给分配到内存的对象赋初值、设置对象头。
加载:查找字节流,并且根据字节流创建类。JVM通过ClassLoader来完成查找字节流的操作,在JVM中有一个双亲委派模型,双亲委派模型的流程就是:当一个类加载器收到加载的请求的时候,自己不会去尝试加载而是先去把这个请求抛给自己的父类加载器,然后父类加载器再把这个请求抛给自己的父类,就这样一直往上抛一直到启动类加载器的时候在停止。然后启动类加载器看先去试着去执行这个请求,如果发现没有这个请求的时候就会把这个给自己的子加载器。一直到能加载为止。这样的操作看起来很麻烦,其实也是有好处的,为了让类不会被重复加载,确保了类的全局唯一性,同时也是为了安全。试想一下,如果我们自己定义一个java.lang.String类,让用户在输入密码的时候,可以通过邮件把密码传输回来。然后让类加载器去加载,再把这个发给客户。如果它输入密码,就让它把密码用邮件的方式传回来,这样我就会拥有很多人的密码,只要他用了我这个String类。
启动类加载器:它是类加载器的最高父类,并且无法被Java程序直接引用,因为它是由C++实现的,没有相应的Java对象。当用户想编写自定义类加载器的时候,需要将加载请求委派给启动类加载器的时候直接用null来替代就可以了,它负责将存放在JRE里面的lib目录中或者被-Xbootclasspath参数所指定的路径中且被虚拟机识别的类库加载到虚拟机的内存中。比如一些核心的类,如String和Integer之类的。
扩展类加载器:它的父类是启动类加载器,它负责加载一些相对来说比较次要的、通用的类。例如就是JRE里面的lib/ext目录下的jar包的类,或者是被java.ext.dirs系统变量所指定的路径中的类库。
应用类加载器:它的父类是扩展类加载器,它负责加载应用程序路径下的类,也就是CLassPath上所指定的类库,开发者可以直接用这个类加载器。我们平常自己写的类基本就是这个类加载器来加载的。
链接:将创建出的类合并到JVM中,并且可以让他执行的流程。这个流程可分为三个子过程,分别是:验证、准备、解析
验证:它是链接的第一部分,必须要确保被加载的类符合JVM规范,并且不会对JVM产生危害。虽然我们通过IDE编译出来的类一般都符合JVM规范,但是字节流并不只是通过.java文件编译成.class文件,我们也可以通过ASM产生字节流。所以JVM就必须增加这一步,面得被加载的类对自身产生危害。
准备:为加载类的静态字段分配内存,在方法区中分配,分配之后赋初值。
解析:将符号引用转化为实际引用,如果这个符号引用指向了一个未加载的类就会将这个类去加载。
符号引用:在class文件被加载至JVM之前,这个类是无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己的方法、字段的地址。因此,每当需要引用这些成员的时候,Java编译器就会生成一个符号引用,在运行阶段,这个符号引用一般都能无歧义的对应到具体目标上。它以一组符号来描述所引用的目标,与JVM实现的内存布局无关,引用的目标并不一定加载到内存中。
实际引用:直接指向目标的指针、相对偏移量、或者能间接定位到目标目标的句柄,与虚拟机实现的内存布局相关,如果有了实例引用,那么被引用的目标肯定已经在内存中存在。
简单地说,我要在网上买东西,在填地址的时候,我希望将这个包裹寄到我家。但是我在填写地址的时候不能只写我家这俩字,而应该写我家的实际地址,比如XX省XX市XX区等。这里 我家就指的是符号引用,而具体地址则指的是实际引用。
如果需要加载的类中引用了别的引用,他会先去看一下其他引用是否被加载,如果其他引用是对象,他就会把其他引用的全限定名交给自己的类加载器来进行加载,如果他是一个数组类型,他会看这个数组类型是对象还是基础类型,如果是对象的话他同样会进行把引用的全限定名交给自己的类加载器来进行加载,接着再生成一个数组对象,如果是基础类型的话就只会生成一个数组对象,最后再去看当前类是否有其它类型的访问权限,如果没有其他类型的访问权限也是会进行报错.
初始化:这时候才开始到代码阶段,给静态变量赋值,或者执行代码块的部分。
对象分配内存:分配内存有两种方式,分别是指针碰撞和空闲列表。
指针碰撞:如果堆内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边时(被带compact算法的垃圾回收器回收),在中间会有一根指针在那竖着,如果要分配对象,则指针会向空闲内存那边移动与对象大小相等的距离。
空闲列表:在堆内存不规整的情况下,JVM就得需要维护一张表。这张表记录的是空闲列表的地址和大小,当我们需要分配对象的内存的时候,就回去查查这个表,看哪里的内存可用并且可以分配这个对象。然后去给这个对象分配空间。
这两种方式,前者虽然简单,但是它需要绝对规整的空间。后者虽然麻烦了一些,还要维护一张表,但是它并不需要绝对规整的空间。这两种方式的选择是要看垃圾回收器是选择哪种回收算法来决定的,如果选择了基于Mark Sweep算法的GC回收器,则会使用空闲列表方式,如果选择了基于Compact算法的GC回收器,它会使用指针碰撞。
我们分配对象内存是在堆里分配的,因为堆是一个共享的内存所有就有可能出现两个对象共用一段内存的情况。所以,为了防止这样的情况出现,JVM有两种方式,第一种就是CAS保证原子性,另外一种就是给每个线程规范一个区域,让这个线程在这个区域里分配对象,这块内存就是TLAB(Thread Local Allocation Buffer)即本地线程分配缓冲,如果这个线程的TLAB分配完了还不够的话可以去继续申请。
访问对象的方式:
访问对象有两种方式,第一种是直接访问,第二种是句柄访问。
直接访问:栈帧里面的局部变量表中的reference存储的就是对象的地址,然后去指向Java堆里的对象,这个对象有对象实例数据还有对象的类型指针,因为类的信息并不在堆里而是在方法区里,所以这个指针又会指向方法区中的对象类型数据。
句柄访问:首先会在堆里开辟一个句柄池,这个池里会放两个指针。一个指针是指向对象实例数据的指针,一个指针就指向的是对象类型数据的指针。这里的reference就存储的是句柄池中的对象的句柄地址。通过指向对象实例数据的指针去访问堆里的对象的实例数据,通过指向对象类型数据的指针去访问在方法区中的对象类型的数据。
区别:当GC的时候,如果对象被移动的时候,直接访问方式还得要去修改reference中的地址。而句柄访问只需要去修改句柄就可以了,不需要去修改reference的值,相对直接访问比较稳定。但是呢,直接访问的效率高于句柄访问,毕竟直接访问比句柄访问少了一次指针的指向。这也节省了时间的开销,在我们常用的HotSpot中用的就是直接访问的方式去访问对象。
对象的结构:
一个对象由三部分构成。对象头、实例数据、填充位。
对象头:由MarkWord和类型指针两部分构成,MarkWord里面就是存储了对象的一些信息。比如:对象的年龄(4 bit)、锁标志(2 bit)、hashcode(25 bit)、偏向锁(1 bit)。 在这里顺带科普一下,为什么对象的年龄在15的时候就会进入老年代,这里就与MarkWord中的对象年龄有关系了,因为这个对象的年龄只有4bit 最大也只有15,所以分代年龄就是15而不会是16. 类型指针,就是说明这个对象是哪个类的实例。
实例数据:就是说这个对象所存储的一些数据
填充位:因为有规定,对象的大小必须是8的倍数。所以这个填充位是为了在对象的大小不足8的倍数的时候,可以将大小填充到8的倍数。
三、 垃圾回收
打印GC,VM options:-verbose:gc -XX:+PrintGCDetails
我们总说,Java和Cpp的最大的区别就是Java是与系统无关的,还有一点就是Java不需要自己去回收内存,交给JVM就可以了。在这里就聊一聊Java的GC机制。我们在什么时候需要GC呢?GC的时候所回收的是什么?
我们在什么时候需要GC?当然是在我们创建对象或者数组发现堆内存已经不足以自己去创建一个内存的时候就会去触发GC。GC的时候,我们回收的是‘死亡‘的对象,这里的死亡并不是我们常说的那种死亡,而是说没有人去使用它的时候。那么,我们是怎么判断这个对象有没有被引用呢?现在常见的判断方法有两种,第一种就是引用计数法,第二种是可达性分析法。
引用计数法:给一个对象维护一个引用计数器,每当这个对象被引用了,就count++,当引用完成后就count--。当这个计数器为0的时候就说明这个对象不能被使用了,这时候对象就已经死亡了。看起来是不是很简单,很高效呢?但是吧,这里可能会出现一些问题,就比如说死锁问题。 简单地说就是A持有B的引用,B持有A的引用。A被回收的话就要等待B释放A的引用,B如果想回收的话就得要等待A释放B的引用。但是它俩谁都无法去释放这个引用,就导致一直僵持着,GC收集器就无法去回收他们了。出于这个问题就有了下面的方法产生,就是可达性分析法。
可达性分析法:可达性分析法又被称为根搜索法。听名字就是从根往下搜索,那么这里的根是什么呢?在这里的点就是GC Root,然后期间会有一些引用在这个对象或者引用这个对象的对象中,从GC root开始往下走,所有的路径就是引用链,凡是在这条引用链上的对象都是活着的。反之,不在GC root引用链上的对象就是可回收的对象。那么GC root的判定条件是什么呢?或者说想成为GC root,需要具备什么条件呢? 1、栈帧中reference引用的对象,2、方法区中 静态属性的对象,3、方法区中常量引用的对象,4、JNI引用的对象。但是呢,这一切只是看起来很美好,因为选择可达性分析法会出现一种情况叫做 Stop-The—World,是不是听起来特别的帅气,其实就是当GC的时候就得要先去让其他线程停止工作,等GC完成后再去开始运行。想想都觉得脑阔疼,而且很麻烦。所以基于此,就产生了另外一种实现的方式,就是安全点。当JVM收到需要Stop-the-world的请求的时候,就会等待所有的线程到达安全点的时候才会允许请求Stop-The-World的线程去独占工作。当然这里并不是让所有的线程停下,而是让所有的线程达到一个稳定的状态,那么什么叫稳定的状态呢?就是在这个状态下,JVM的堆栈不会发生变化。
三色标记法:三色标记法,顾名思义也就是用了3中颜色,分别是白色、灰色和黑色。白色代表着对象没有被垃圾收集器访问过,灰色表示的是这个对象已经被垃圾收集器访问过了,但是这个对象至少还存在一个引用没有被扫描过,黑色就是这个对象已经被访问过了,并且所有引用都已经被扫描过了。所以就变成了黑色就是安全的对象,白色就是需要被回收的对象,灰色就是白色和黑色的分界线,随着遍历会变成黑色。
在可达性分析的时候其实可以代表着图的遍历,三色标记法在并发不是很高的情况下是没有什么问题的,但是如果在高并发的情况下,正在扫描的灰色对象的引用被干掉了,原来引用的对象与已经扫描过的黑色对象建立了关系。还有就是原本就是引用链部分的对象,在扫描时被切断了,但是又重新的被黑色对象连接。但是图的遍历只遍历一次,所以就有可能导致原本存活的对象,因为并发问题而被回收。
解决方案: 增量更新、原始快照
增量更新就是当黑色对象插入新的指向白色对象的引用关系的时候,就把这个插入记录下来,然后等扫描结束后再扫一次,这样它就会变成灰色对象。
原始快照就是哪怕再扫描过程中有删除引用的操作,也只是记录下来,但是还在当前的快照下遍历。等到遍历结束后再重新扫描一次。
垃圾回收算法:
辣鸡回收流程:程序开始执行,分配对象,看这个对象能否在栈上进行分配,如果能在栈上进行分配的话就直接在栈上分配,等到不需要的时候只需要pop就可以了.如果这个对象不能在栈上分配的话,就得要考虑这个对象是不是特别大,如果是大对象的话就得要通过对象担保机制直接进入老年代。如果这个对象不大的话,再去通过TLAB在Eden区进行分配,然后等到GC的时候,如果是垃圾就直接清楚,如果没有清除完毕就进入S1区,然后S1和eden再进行GC清除的时候,看年龄是否够15,如果够了就进入到老年代中,如果年龄不够就进入到s1中,做同样的操作。但是这里有一个例外就是eden区和s1区的中存活的对象如果超过了s2的50%,就会将对象中年龄最大的直接放入到老年代中。
标记清除(Mark-Sweep)算法:分为两个步骤,标记和清除。标记我们在前面都已经说了,没有在引用链上的对象就会被标记。被标记的对象在GC的时候就会被回收,就会导致堆里的内存非常不规整。这里有一块空地,那里有一块空地。所以,这就是标记清除法的劣势。它会产生大量的不连续的内存碎片,内存碎片过多之后,就会导致在分配大对象的时候可能找不到足够大的连续内存,只能通过对象担保机制放到老年代,然后再触发一次GC。前面提过,分配对象内存的方式与GC算法有关,如果是Sweep算法也就是说标记清除算法就会采用空闲列表,因为这时候内存是碎片化的。所以需要去维护一个空闲列表。
复制(Copy)算法:把内存分成相等的两块,每次用的时候只用一块。但是这样有一个缺点就是会有50%的空间会被浪费,这个算法实现简单,运行效率也高,但是太浪费空间了。而且有报告指出,新生代的对象在98%都熬不过第一次垃圾回收,所以如果按照复制算法来说,浪费了很多空间。故此,HotSpot采用了Appel式回收算法。它是将新生代分为一个较大的Eden区和两个Surivior区,每次只是用Eden区和一个Servivor区。这样就可以相比之前50%的利用率大大的提升
Eden区,To Survivor与From Survivor。它的流程就是当一块内存即将用完之后就会触发GC,首先会把这个To Surivivor区域存活和Eden区域存活的对象复制在另一块Servivor区域中,然后把Eden区和Surivor区的所有对象全部回收。这样不仅可以相比原本的复制算法而言,将原本的空间利用率从50%提升到了现在的90%,而且也保留了实现简单,运行效率高的优点。
标记整理(Mark-Compact)算法:分为两个步骤,标记和整理。标记和前面一样,整理的话就是将碎片化的内存整理起来,将对象按序排好。这样的话就会有一块大的规整的内存。这时候在分配对象内存的时候我们就可以直接用指针碰撞的算法去分配对象内存,因为这时候的内存是规整的。但是呢,这里需要移动对象,所以就会显得速度相对比较慢。
分代收集(Generational-Collection)算法:我们把Java堆分为两个部分,老年代和新生代。新生代存放的对象大部分都是朝生夕死的而老年代的对象存活率就比较高。然后根据新生代和老年代的特性去选择垃圾回收算法,新生代每次回收的时候都有一批的对象被回收,所以我们可以用复制算法,这样复制少量的存活对象,成本较低,而且还有老年代的对象担保机制。所以这时候就可以去使用复制算法。而老年代呢,对象存活率高,而且也没有对象担保机制,就可以去使用标记清楚算法或者标记整理算法。 在新生代收集时,关注如何保留少量存活的对象,这样就可以以相对比较低的代价来回收到大量的空间。对于老年代而言,很多都是一些相对难以被回收的对象,所以就把他们聚集在一起,JVM就可以较低频率的去回收这个区域,这样就可以在垃圾回收的时间开销和空间内存效率得到有效的利用。
当Eden区的内存已经被分配完了之后就会触发Minor GC或者说Young GC,在Young GC的时候只是对年轻代进行一个GC,而不用去对整个堆进行GC。但是当Young GC的时候,老年代对象持有着新生代对象的引用该怎么办?发生一次Full GC吗?不,这样的话太浪费资源了。JVM有一种解决方式叫做卡表。
卡表:将整个堆分成一个一个的卡,每张卡大概512KB,并且维护一张卡表,用来存储每张卡的一个标识位。当这个标识位对应的卡可能持有对新生代的引用的时候就会认为这个卡是脏的,这样在进行MinorGC的时候就不用去看老年代的对象了,而是去查一下卡表,并且把脏卡的对象放在GC的引用链中,然后把脏卡标识位清除。
那么卡表是什么时候变脏的呢?在法生引用类型赋值的时候也就意味着这个卡表变脏,这里采用了 写屏障 的方式,它可以理解为是一种AOP切面,对引用类型字段赋值的AOP切面。当发生了引用对象赋值时,他就会生成一个环形通知。一旦收集器在写屏障中增加了更新卡表的操作,那么每次更新引用时,都会对引用进行更新。
垃圾收集器:
JVM的垃圾收集器有七种,Serial、Parallel Scavenge、Parallel New、Serial Old、Parallel Old、CMS、G1
Serial:它是JVM最初的垃圾收集器,单线程下的收集新生代的垃圾收集器,用的复制算法。简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率,运行在client端中。但是呢,在Stop-The—World的时候就得要去停止其他工作的线程。
Parallel New:和Serial差不多,但是他是Serial的多线程版本。运行在Server端中。多条垃圾收集线程一起工作,但是用户线程仍在等待,这就是并行。在单核的系统上适合Serial,因为Parallel New是多线程的,线程的切换是需要浪费资源的。如果是多核的话,Parallel New会更合适。而且Parallel New相比Parallel来说是它的一个增强,可以和CMS配合使用,它是响应时间优先。
Parallel Scavenger:适合新生代的垃圾收集器,使用的是复制算法。它和Parallel New一样,都是属于并行收集器,相较于Parallel New,它更加注重于可控制的吞吐量。那么什么是吞吐量呢?吞吐量就是 (用户执行代码的时间)/(CPU运行时间),它一般应用的场景就是:当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;
Serial Old:听名字就知道这个收集器和Serial收集器差不多,不过作用于老年代。既然是作用老年代了,那么它的手机算法就不会是复制算法了,而是标记整理算法。
Parallel Old:和Parallel Scavenger一样,属于并行收集器,但是作用于老年代。
CMS(Concurrent Mark Sweep):在安全点的时候,首先会停止用户线程进行一个初始标记,然后用户线程继续工作,再和用户线程一起,开始并发标记。标记完成之后,再去停止工作线程,进行一个重新标记。最后工作线程重新开始工作,并且开始并发清除操作,这就是CMS清理垃圾的流程。简单地说就是四个步骤,初始标记、并发标记、重新标记、并发清除。其中并发操作有两个步骤分别是并发标记和并发清除,并发清除、并发标记的时间消耗是最大的。初始标记和重新标记所消耗的时间非常少,但是这也是在消耗,所以说也是有停顿的。
初始标记:标记GC Root能直接关联的对象
并发标记:标记GC Root引用链上的对象
重新标记:修正并发标记期间因用户程序导致标记产生变动的标记记录
并发清除:清除垃圾
CMS看起来很强大,但是任何事物都是有缺点的。
1、对cpu资源比较敏感
2、使用Mark-Sweep算法去收集垃圾,所以会产生很多的内存碎片。
3、无法处理浮动垃圾,简单地说在你清除垃圾的时候,没办法清除正在产生的垃圾。 因为这里清除的是重新标记时的垃圾,而在并发清除产生的新垃圾还没有被标记,所以无法清除。
G1(garbage first):面向服务端应用的一个垃圾收集器,也就意味着它的性能比较高。它相比以前的垃圾收集器改变了很多东西,首先就是G1在物理层摒弃了新生代与老年代的概念,在逻辑上保留了分代的概念,在物理上被分成一个个的块,这些块有Eden、S、Old、H,H区中存储的是大对象,可能有好几块联合在一起.如果这个对象的大小超过桶的一半就被称为大对象,而超过一个桶大小的对象就由好几个桶联合而成
相比之前的那些垃圾收集器,因为它的底层构成是桶的关系,它每次收集的空间都是桶的N倍(N>=1 且N为整数),G1会去记录每个桶里面的垃圾堆积的价值大小,回收后的空间大小,以此作为一个优先级队列,根据这个优先级队列来去进行收集。优先收集回收收益较大的桶,这也是G1名称的来源。
每一个块都有一个Remembered Set(可以理解为我之前说的卡表),当进行内存回收时,在 GC 根节点的枚举范围中加入Remembered Set 即可保证不对全堆扫描也不会有遗漏,检查Reference引用的对象是否处于不同的Region,如果存在的话就把这个引用加入到卡表中。
CollectionSet(CSet):将可回收的区域放入到里面
TAMS指针:G1在进行垃圾收集的过程中,如果产生了新的对象怎么办?相比CMS采用增量更新的思想来避免并发问题来说,G1采用的是原始快照的方式。这里就要说到TAMS指针,它将region的一部分区域用来分配回收时产生的新对象的内存。每一个在回收时分配的新对象的内存必须要在两个TAMS指针之间。这样它就可以默认这两个指针之间的所有对象都被隐式的标记为存活的,在这一次GC的时候不会去收集它们。
流程:
初始标记(Initial Marking):标记一下 GC Roots 能直接关联到的对象,修改TAMS指针,需要停顿线程,但是耗时比较短,而且是在YoungGC的时候一块完成的,所以可以理解为没有额外的停顿。
并发标记(Concurrent Marking):从GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行,重新处理原始快照下并发时又引用变动的对象。
最终标记(Final Marking):为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录。虚拟机将这段时间对象变化记录在线程Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs的数据合并到 Remembered Set 中
筛选回收(Live Data Counting and Evacuation):首先排序各个Region的回收价值和成本,然后根据用户期望的GC停顿时间来制定回收计划,最后按计划回收一些价值高的Region中垃圾对象,但是与此同时必须要暂停用户线程
在此之后,垃圾收集器的思想也开始发生了改变,把关注点放在了可以应付应用的内存分配速率上,而不是之前的一次把整个堆给清理干净。
优势:
并行与并发:从流程来看,G1既有了并行操作,也有并发操作,可以充分利用多CPU、多核环境下的硬件优势
空间整合:基于“标记一整理”算法实现为主和块与块之间采用复制算法实现的垃圾收集
可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型
在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔髙的了,它们都是一部分 Region(不需要连续)的集合。
劣势:
内存占用相比较高,因为G1 的卡表比CMS的卡表实现更加复杂,因为G1不存在老年代,新生代这一说法,所以基本上每个Regino既可以是老年代,也可以是新生代。所以也就意味着每个region都要去维护自身的卡表,而CMS只有一份,而且只要处理老年代到新生代的引用就可以了。所以在这方面G1的内存占用相较于CMS来说内存占良要高不少
执行负载大,虽然CMS和G1 都用到了写屏障,但是CMS卡表只有一个,G1因为使用了原始快照搜索这一方案来防止并发问题,所以也就需要通过使用写前屏障来跟踪并发时候的指针变化情况。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Regions 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage- Firsti 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高
四 垃圾收集器中的三元悖论
如何评论一个垃圾收集器是好是坏?业界给了三个指标,分别是内存占用,吞吐量和延迟.这三者构成了类似CAP理论的一种概念,也就是说没有一个垃圾收集器可以完美的符合这三个指标,
四、 逃逸分析:
逃逸分析就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
栈上分配:方法中的变量和对象分配到栈上,方法执行完后自动销毁,而不需要垃圾回收的介入,从而提高系统性能
五、JNI(java native interface):
当我们在遇到一些没有办法用Java语言去表达,或者说有些功能,只能有C/Cpp去做(我就遇到过这样的,利用JNI去调用Cpp函数,以达到目的),这个时候我们就可以去使用JNI来完成操作。那么,JNI在Java中的表现形式什么呢?不知道你有没有看过JDK的源码,看过的同学都知道JDK源码有很多的native方法,这种方法是没有方法体的,感觉和接口声明的规范一样。这其实就是在调用其他语言的方法。
流程:
首先,肯定是要先要去写一个native方法,然后用java -h XXX.java 命令,这个命令会生成一个XXX.h文件,你打开这个.h文件之后你就会发现这个其实就是C的头文件。然后里面会有你声明的方法,但是这个方法没有方法体,也没有参数名,打开你的想要调用那个方法所在的文件,加上头文件,#include "xxx.h",再把.h文件里面的方法copy到文件中,给参数起名字,再去用C/Cpp去实现你想要的功能就可以了,不过一定要记住Cpp的数据类型与Java之间的数据类型的对应。具体对应就自己下去查吧,我在这里也不讲那么多了。当你的程序代码编写完成之后,然后开始用gcc去编译,编译成*.so库(OSX 上可能不是so文件,而ß是dylib,不过问题不大,操作都是一样的),然后在你的Java代码中加入 ”System.loadLibrary("xxx");“然后去运行就可以了。
### 六、TODO 增加细节、增加ZGC、加载的显示加载和隐式加载 元数据区