JVM & GC

一:Write Once, Run Everywhere

在计算机的世界,操作系统封装了各种硬件平台的操作指令,我们大部分使用的开发语言都是基于或依赖于操作系统和硬件打交道。这样的话编程语言如何能够兼容所有的硬件平台和操作系统成了一个问题。

我们先了解下C语言是如何实现兼容性的。C语言实现系统兼容性的思路很简单,那就是通过在不同的硬件平台和操作系统开发各自特定的编译器,从而将相同的C语言源代码翻译为底层平台相关的硬件指令。这种思路很棒,但缺陷在于,当涉及到系统调用时,开发者仍然要关注具体底层系统的API。例如,在Linux平台,创建线程的接口是pthread_create(),而在Windows平台则是CreateThread()。

这样有时为了适用不同的操作系统我们依然要开两套或者发多套代码。另外一点在于底层的细节比较复杂,要熟悉各种底层API需要大量的实践,学习成本比较高,开发效率比较底下。

那么如何使开发者写出可以兼容所有底层平台的程序?

于是就产生了中间语言。

此时一个人物出现了:James Gosling。

James_Gosling

James定义了一门中间语言的规范,也就是字节码规范,同时开发了虚拟机,由虚拟机将字节码转换成不同平台上的特定API调用。在字节码的基础上开发了一门高级语言。

这门高级语言就是Java,虚拟机被称为Java Virtual Machine,简称JVM,而定义的一系列字节码规范就是JVM指令集。

整个流程变成了:Java源代码通过Java编译器编译成与平台无关的字节码文件(.class),虚拟机负责将字节码转换成系统调用的API。

jvm_compile

此时,James针对不同的操作系统发明了不同的虚拟机,我们只需安装好虚拟机,只需熟悉Java语言规范和API就可以快速开发程序。用一句话总结就是:

write once, run everywhere

二:Runtime Data Area:

jvm_structure

根据JVM规范,JVM内存共分为程序计数器、虚拟机栈、本地方法栈、方法区、堆五个部分。

内存空间按照线程数是否共享分为两块:线程私有和线程共享。而计算的本质可以理解为指令+数据。正常线程私有的为指令,线程共享的为数据。

具体到JVM的5部分内存结构:

指令(线程私有):

程序计数器,虚拟机栈,本地方法栈。

数据(线程共享):

方法区,堆。

  • 程序计数器:

    JVM是支持多线程的,多线程的执行最终依赖于内核的调度,在让出CPU时间片或者重新拥有CPU时间片的节点上我们都需要记住当前线程执行到的位置,而程序计数器就是做了这个事情。每个线程都有自己的程序计数器,当抢占到CPU时间片的时候就从这个位置开始继续执行。如果当前执行的是JVM方法,则该计数器记录的是当前执行指令的地址;若当前执行的是native方法,则为空。这个内存区域是唯一一个在JVM中没有规定任何OOM的区域。

  • 虚拟机栈:

    jvm_stack

    栈本身是一种先进后出(LIFO)的数据结构。每个线程都有一个私有的栈,随着线程的创建而创建。栈里面存放着一种叫做“栈帧”的东西。每个方法在执行的时候都会创建一个栈帧,栈帧中存储了:局部变量表(基本数据类型和对象引用),操作数栈,动态链接,方法出口等信息。每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。栈的大小可以固定也可以动态扩展,当扩展到无法申请足够的内存,则OOM,当栈调用深度(使用-Xss设置每个线程的Stack大小)大于JVM所允许的范围,会抛出StackOverflowError。

  • 本地方法栈:

    和虚拟机栈类似,主要为虚拟机使用到的Native方法服务,同样会抛出StackOverflow和OutOfMemoryError。

  • 方法区:

    方法区主要存储了整个程序的元数据信息:

    • 类信息:

      • 类型信息:类型的全限定名,超类的全限定名,接口的全限定名,类型标志(类类型还是接口类型),类的访问描述符(public、private、default、abstract、final、static)。
      • 方法信息:包含类的所有方法,每个方法包含:方法修饰符、方法返回类型、方法名、方法参数个数、类型、顺序等、方法字节码等
      • 指向类加载器的引用(每一个被JVM加载的类型,都保存着这个类加载器的引用)和指向Class实例的引用(类加载的过程中,JVM会创建该类型的Class实例,方法区中必须保存对该对象的引用,通过Class.forName(className)来查找该实例的引用,然后创建该对象)。
    • 常量池:

      • Class文件中的常量池,主要为字面量(文本字符串,声明为final的常量值等)和符号引用量(类和接口的全限定名,字段名称和描述符,方法名称和描述符)。

      • 运行时常量池:用于存放编译期生成各种字面量和符号引用。Java语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,比如最常用的String的intern()方法。

      • 基本数据类型的包装类和常量池:Java中基本类型的包装类大都实现了常量池技术,即Byte、Short、Integer、Long、Character、Boolean。这五种包装类默认创建了数值[-128, 127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。两种浮点数类型的包装类Float、Double并没有实现常量池技术。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        		
        String str1 = new String("hello");
        String str2 = str1.intern();
        String str3 = "hello";
        System.out.println("str1=str2: " + (str1 == str2));
        System.out.println("str2=str3: " + (str2 == str3));

        Integer i1 = 40;
        Integer i2 = 40;
        Integer i3 = 200;
        Integer i4 = new Integer(40);
        Integer i5 = new Integer(40);
        System.out.println("i1地址:" + System.identityHashCode(i1));
        System.out.println("i2地址:" + System.identityHashCode(i2));
        System.out.println("i4地址:" + System.identityHashCode(i4));
        System.out.println("i1=i2: " + (i1 == i2));
        System.out.println("i4=i5: " + (i4 == i5));
思考:以上代码创建了几个新的对象?

result:

1
2
3
4
5
6
7
str1=str2: false
str2=str3: true
i1地址:672320506
i2地址:672320506
i4地址:1349414238
i1=i2: true
i4=i5: false

注意:

方法区是JVM规范层面的东西,规定了这一个区域应该存放那些东西。而对于如何实现并没有强制规定。

我们最常用的是在HotSpot虚拟机上进行开发,在JDK1.7中我们称之为方法区为永久代,而在JDK8之后已经将方法区移动到元空间(直接内存)中。而永久代和元空间不过是针对方法区的不同实现。包括字符串常量池JDK1.6是存在于方法区中,而1.7之后就直接放入堆空间中。

思考:

为什么将方法区移动到元空间(metaspace)?

元空间没有使用堆内存,而是使用与堆不相连的本地内存区域。所以理论上可以使用的内存有多大,元空间就有多大。这项改造的原因在于永久代的调优是很困难的,因为很难确定一个合适的大小,因为影响的因素很多,比如类数量的多小,常量的大小,常量的数量等。另外元数据的位置也会随着一次full GC发生移动,比较消耗虚拟机性能。

  • 堆(Heap):

    Java Heap是JVM所管理的内存中最大的一块。此内存区域唯一的作用就是存放对象实例。几乎所有的对象实例都需要在堆中分配内存。而堆空间还可以细分为:新生代和老年代。具体到新生代又分为Eden空间、From Survivor空间和To Survivor空间。

    heap

    无论哪个区域,存储的都是对象实例。那为什么还需要分代。唯一的目的就是为了更好的回收内存,或者更快的分配内存。也就是垃圾回收。

思考:

  • 堆与栈里到底存了什么?

堆中存的是对象,栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是很难估计准确的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4byte的引用。

  • 基本类型为什么不放入堆中?

基本类型一般都是长度固定,栈中存储已足够,放在堆中没有意义。

  • Java中的参数传递是传值还是传引用?

程序运用永远都在栈中进行的,因而参数传递时,在存在传递基本类型和对象引用的问题,不会直接传对象本身。

  • 为什么本地方法栈、程序计数器、虚拟机栈不需要垃圾回收?

因为他们是线程私有的,生命周期是和线程同步的,随着线程的销毁,占用的内存会自动释放。

三:GC

理论上,如果有一些数据已经不再使用了,我们要释放掉它所占用的内存空间。

垃圾自动回收最早可以追溯到50年代的Lisp语言,后续大部分的高级语言都实现了自动回收。但相对来说Java的垃圾回收是比较复杂比较成熟的。

对象创建:

jvm_create_obj

TLAB:

如果有大片连续的内存可用于分配给新对象,这种情况下分配空间时非常简单快速的,只需要一个简单的指针碰撞就可以,每次分配对象空间只要检测一下是否有足够的空间,如果有,指针往前移动N位就分配好空间了,然后就可以初始化这个对象了。

问题在于对于多线程应用,对象分配必须要保证线程安全性,如果使用同步锁(CAS + 失败重试),那么分配空间将成功程序性能瓶颈。HotSpot使用了称之为TLAB(Thread-Local Allocation Buffers)的技术,该技术能改善多线程空间分配的吞吐量。首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了自己的TLAB,它才需要使用同步来获取一个新的缓冲区。

JVM堆空间为什么需要分代?为什么说分代是为了更好的垃圾回收?

2:如何辨别对象是否是垃圾:
  • 引用计数法:

    实现:为每个对象添加一个引用计数器,用来统计该对象的引用个数。一旦某个对象的引用计数为0,则说明该对象已死亡,便可以回收。

    缺陷:除了需要额外的空间存储计数器,以及繁琐的更新操作。还有最大的漏洞,就是无法处理循环引用对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class  A {
    public B bb;
    }
    class B {
    public A aa;
    }
    public class TestGC {
    public static void main(String[] args) {
    A a = new A();
    B b = new B();
    a.bb = b;
    b.aa = a;
    a = null;
    b = null;
    }
    }

    以上程序中,b对象因为有a的引用,计数器+1,a对象因为有b的引用,计数器+1。但其实最终a和b对象都已经成为了垃圾却因为计数器不为0导致无法回收。

  • 可达性分析:

    JVM的主流垃圾回收器都采用可达性分析算法。这个算法的实质在于将一系列GC Roots作为初始的存活对象合集(live set),然后从集合出发,探索所有能能够被集合引用的对象,并将其加入到该集合中,这个过程我们称之为标记,最终,未被探索到的对象便是可以被回收的。

    gc_roots

    那么什么可以作为GC Roots呢:

    • Java方法栈中的局部变量。
    • 已加载类的静态变量。
    • 方法区中常量引用的对象。
    • JNI 引用。

    缺陷:虽然可达性分析算法很简明,但在多线程情况下,其他线程可以会更新已经访问过对象的引用,从而造成漏报。

    漏报的问题在于垃圾回收器可能会回收事实上仍被引用的对象内存,一旦从原引用访问已经被回收的对象,可能会导致JVM崩溃。传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是STW(Stop-The-World),停止其他非垃圾回收线程的工作,直到完成垃圾回收,造成了服务短暂的停止状态。

3:垃圾回收算法:

标记:从GC-Roots对象集合中探索到所有被集合引用的对象。

  • 标记–清除(Mark-Sweep):

    mark_clean

将标记为可回收的对象拎出来清理掉。

缺陷:内存碎片。因为回收完之后,内存会被切成很多段,我们知道开辟内存空间时,需要连续的内存空间,一些小的内存块会变成碎片导致不能被再次使用。

  • 复制(Copying):

    mark_copying

复制算法主要解决标记-清理的内存碎片问题,它将内存划分为大小相等的两块,每次都只使用其中的一块,当其中一块使用完了,就将还存活着的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉,保证了内存的连续可用。

缺陷:每次只能使用一半的空间,代价实在太高。

  • 标记–压缩(Mark-Compact):

    mark_compact

标记压缩算法同样是在标记-清理的基础上做了升级:当所有的对象被标记完成之后,将所有的存活的对象都向一端移动,再清理掉端边界意外的内存区域。这样很好地解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。

缺陷:看起来很美好,但它对内存的变动特别频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

由此可见,每一种回到算法都不是完美的,怎样在不完美的情况下更好的实现自动垃圾回收?

JVM采用了分代收集算法,融合上述三种基础的算法思想,根据对象存活周期的不同将内存划分为新生代和老年代。这样根据各个年代的特点采用最适当的算法。

大部分的对象都是朝生夕死的,所以在新生代发生GC时都会发现大批对象死去,只有少量存活。而老年代中因为对象存活率比较高,没有额外空间进行分配担保。

所以,对于新生代的垃圾收集需求是频繁、高效、快速,使用只需要付出少量对象复制成本的复制算法最合适。对于老年代需要考虑的是空间,因为老年代占用了大部分堆内存,而且针对该部分的垃圾回收算法,需要考虑到这个区域的垃圾密度比较低,自然采用清楚或者压缩算法来进行回收比较合适。

4:垃圾收集器:

垃圾收集器是自动回收算法的实现。

串行&并行收集器

针对新生代的垃圾回收器共有三个:Serial,Paraller Scavenge和Parallel New。这三个采用的都是标记-复制算法。其中,Serial是一个单线程的,Parallel New可以看成是Serial的多线程版本。Paraller Scavenge和Parallel New类似,但更加注重吞吐量。
针对老年代的回收器也有三个:Serial Old和Parallel Old以及CMS。Serial Old和Parallel Old都是标记-压缩算法。

别管新生代和老年代,对于Serial和Paraller收集器只不过是串行并行的区别。

parallel_collection

串行非常简单,当串行收集器工作时,Stop The World,应用被完全挂起,挂起后堆内存空间不再发生变化,回收起来很简单。缺陷在于STW的时间有时是我们不能接受的。并行完全利用了现在大内存多核心的能力,多个GC线程同时进行,会大量降低STW的时间。

那么还有没有优化的空间?

有没有可能垃圾回收线程和程序线程同时工作,也就是垃圾回收的同时程序不需要挂起?或者说可不可以永远不STW,答案是NO!最起码目前是NO,但可以以获取最短回收停顿时间为目标。

CMS

而CMS收集器就是为此而生。cms_gc

CMS采用的是标记-清除算法,并且是并发的。

整个工作流程主要分为以下四个步骤:

  • 初始标记:仅仅只是标记一下GC Roots能直接关联的对象,速度很快,需要STW。
  • 并发标记:进行GC Roots Tracing的过程,耗时较长。但好在此过程是和程序线程是并发执行的。
  • 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分的标记记录,此阶段同样需要STW。
  • 并发清除。

优点:并发收集、低停顿。

由于整个过程中耗时最长的并发标记和并发清除过程中,收集线程和程序线程一起工作,所以总体上来说,CMS收集器的内存回收过程和程序线程是并发执行,两次STW的过程都特别短暂。

缺点:

  • 吞吐量降低:

    在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量降低。

  • 无法处理浮动垃圾:

    由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现标记过程之后,CMS无法再次收集集中处理它,只好留到下一次GC时再清理掉。

  • 空间碎片:

    由于CMS是一款基于“标记-清理”算法实现的收集器,意味着收集结束后会有大量空间碎片产生。

即使如此,扔不可否认CMS是一款优秀的垃圾回收器。不过CMS在Java9中被废弃。

G1

g1_gc
G1是一个横跨新生代和老年代的垃圾回收器。它已经打乱了前面所说的堆结构,直接将堆分成多个区域(Region)。虽然依旧保留了新生代和老年代的概念,但已经不是隔离的啦,而都是一部分Region的集合。

两个概念:

  • RememberSets:Rsets是每个region中都有的一份存储空间,用于存储本region的对象被其他region对象的引用记录。
  • CollectionSets:Csets是一次GC中需要被清理的regions集合。

rset

YGC:

STW,复制算法,将E和S(from)区复制到S(to),注意S(to)一开始是没有标识的,就是个free region。

ygc_start

ygc_end

MixGC:

G1对于老年代的GC本质上不是只针对老年代,也有部分年轻代,所以叫MixGC。

  • 初次标记(STW):标记GCroot直接引用的对象以及所在的Region。注意初次标记一般和YGC同时发生,利用YGC的STW时间,顺带把这是干了。

  • RootRegion扫描:扫描O区region的rset,看有没有Y区的引用,如果有标记出来。

  • 并发标记:标记O区,同CMS,只不过遍历范围缩小,只遍历RootRegion中标记的region就可以了。这期间如果发现某个region所有的对象都是“垃圾”的话则标记为X。

    ogc_concurrent_mark

  • 重新标记(STW):同CMS,只不过使用SATB,标记速度更快。

  • 复制/清理(STW):只选择垃圾较多的region清理(Garbage First的由来),每次只清理大部分也能保证系统的正常运行。

整体上G1的回收流程和CMS类似。优势在于G1是一个由整理内存过程的垃圾收集全,解决了CMS的空间碎片问题。以及G1的STW更可控。

G1总结:

首先由于G1将内存分为多个Region,这样有利于直接使用复制压缩的算法对内存进行整理而不需要必须采用标记-清除。另外,G1跟踪各个Region里面垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这也是Garbage-First的由来。这种使用Region划分内存以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

ZGC:

无论你开了多大的堆内存(2T?),硬是能保证低于10ms的JVM的停顿。

R大:

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。

快掏出你的大手机扫我

快掏出你的大手机扫我