「总结篇」别再说自己不会JVM了,看完这篇能和面试官扯上半小时(上)

17,129 阅读19分钟

前言

如果本篇文章有错,欢迎各路大神疯狂diss~~当然喽,如果你看了这篇文章有所收获,那就疯狂点赞吧,你的点赞就是对我的最大鼓励。可以顺便加个关注哦,回家不迷路,不定期更新博客~~

周志明那本《深入理解 JAVA 虚拟机》翻了一遍又一遍,终于鼓起勇气在这里写下关于 JVM 的博客!!!现在,我要开始把我所理解到的记录在这里,和各位朋友一起分享!!!

我相信点开这篇文章的小伙伴一定知道JVM是啥了吧?What,还不知道?好吧,看看维基我想你应该就会明白了:Java虚拟机- 维基百科,自由的百科全书

不过,作为一个爱思考的在校大学生,我也总结了以下三点:

  • 一个能够运行字节码的虚拟机。
  • 屏蔽了具体的操作系统的信息。
  • 正是以上两点,使得Java程序具有一次编译,到处执行的特性。

关于JVM是什么的介绍就到这里,还是老样子,先来看看这篇文章的结构:

运行时数据区域

什么是运行时数据区域

Java程序在运行时,会为JVM单独划出一块内存区域,而这块内存区域又可以再次划分出一块运行时数据区,运行时数据区域大致可以分为五个部分:

在这里插入图片描述

从上面的图中,有两种颜色不同的区域,红色的是线程共享区域,绿色的是线程私有区域。下面我们一个一个讲清楚,不过在学习这部分的时候,最好先思考为什么会有这些区域。难道是因为存在即合理

堆(Heap)

很多做开发的同学,会格外关注堆和栈,这是不是就从另一个角度说明了堆和栈的重要性?既然如此,我们就从同学们关注的点开始说。(贴心吧,是不是感觉眼角再一次被打湿?)

先把干货放上来,首先,Java堆区具有下面几个特点:

  • 存储的是我们new来的对象,不存放基本类型和对象引用。
  • 由于创建了大量的对象,垃圾回收器主要工作在这块区域。
  • 线程共享区域,因此是线程不安全的。
  • 能够发生OutOfMemoryError。

其实,Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区。具体比例参数的话,可以看一下下面这张图。

在这里插入图片描述

我想图中已经解释相当清楚了,就没有必要文字说明了吧?关于Java堆对象的创建,以及何时会发生内存泄漏,我后面应该会专门写一篇文章,这里的话就只是一些理论介绍。

虚拟机栈(VM Stack)

Java虚拟机栈也是一块被开发者重点关注的地方,同样,先把干货放上来:

  • 线程私有区域,每一个线程都有独享一个虚拟机栈,因此这是线程安全的区域。
  • 存放基本数据类型以及对象的引用。
  • 每一个方法执行的时候会在虚拟机栈中创建一个相应栈帧,方法执行完毕后该栈帧就会被销毁。方法栈帧是以先进后出的方式虚拟机栈的。
  • 每一个栈帧又可以划分为局部变量表、操作数栈、动态链接、方法出口以及额外的附加信息。
  • 这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(通常是递归导致的);JVM动态扩展时无法申请到足够内存则抛出OutOfMemoryError异常。

同样,这篇文章反响好的话,实战练习后面单独出文章。

本地方法栈(Native Method Stack)

本地方法栈其实可以和Java虚拟机栈进行对比理解,唯一不同的是本地方法栈是Java程序在调用本地方法的时候创建栈帧的地方。和JVM栈一样,这个区域也会抛出StackOverflowError和OutOfMemoryError。

方法区(Method Area)

方法区,也应该是以一块被重点关注的区域。同样,方法区的主要特点如下:

  • 线程共享区域,因此这是线程不安全的区域。
  • 方法区也是一个可能会发生OutOfMemoryError的区域。
  • 方法区存储的是从Class文件加载进来的静态变量、类信息、常量池以及编译器编译后的代码。

对于方法区,我觉得重点应该说一下常量池。常量池可以分为Class文件常量池以及运行时常量池,Java程序运行后,Class文件中的信息被字节码执行引擎加载到了方法区,从而形成了运行时常量池。

另外,说起方法区,可能还有人会把它与永久代、元空间混为一谈。那么他们之间的区别到底是什么?方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。不过Java 8以后就没有永久代这个说法了,元空间取代了永久代。

程序计数器(Program Counter Register)

程序计数器非常简单,想必大家都不是Java的初学者了,也都应该明白一点线程与进程的概念?(灵魂拷问,你明白么?)不明白没关系,我一句话给你讲清楚。

进程是资源分配的最小单位,线程是CPU调度的最小单位,一个进程可以包含多个线程, Java线程通过抢占的方法获得CPU的执行权。现在可以思考下面这个场景。

某一次,线程A获得CPU的执行权,开始执行内部程序。但是线程A的程序还没有执行完,在某一时刻CPU的执行权被另一个线程B抢走了。后来经过线程A的不懈努力,又抢回了CPU的执行权,那么线程A的程序又要从头开始执行?

这个时候程序计数器就粉墨登场了,它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。此外,程序计数器还具有以下特点:

  • 线程私有,每一个线程都有一个程序计数器,因此它是线程安全的。
  • 唯一一块不存在OutOfMemoryError的区域,可能是设计者觉得没必要。

对象的创建与访问

对象的创建

前面我们已经说过,对象是在堆中创建的,通常只需要new一个就行了。难道就是这么简单?确实没有这么简单,就单单是这样的new关键字,Java虚拟内部进行了一系列的sao操作。

当虚拟机遇到字节码new指令时,就会去运行时常量池寻找该实例化对象相对应的类是否被加载、解析和初始化。如果没有被加载,就会先加载该类的信息,否则就为新生对象分配内存。

分配内存无非有两种方法:

  • 指针碰撞:通过一个类似于指针的东西为对象分配内存,前提是堆空间是相对规整的。
  • 空闲列表:堆空间不规整,使用一个列表记录了哪些空间是空闲的,分配内存的时候会更新列表。

以上是两种不同的方法,至于虚拟机使用哪一种方法,这个就取决虚拟机的类型了。

对象的内存布局

对象在堆中的存储布局可以分为三个部分:

  • 对象头

    • 第一类信息:存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志等等。
    • 第二类信息:指针类型,Java虚拟机通过这个指针来确定该对象是那个类的实例。
  • 实例数据:对象真正存储的有效信息。

  • 对齐填充:没有实际的意义,起着占位符的作用。

对象的访问定位

我们前面说到过,Java虚拟机栈中存储的是基本数据类型和对象引用。基本数据类型我们已经很清楚了,那么,这个对象引用又是什么鬼?

是这样的,对象实例存储在Java堆中,通过这个对象引用我们就可以找到对象在堆中的位置。但是,对于如何定位到这个对象,不同的Java虚拟机又有不同的方法。

通常情况下,有下面两种方法:

  • 使用句柄访问,通常会在Java堆中划分一块句柄池。
  • 使用直接指针,这样Java虚拟机栈中存储的就是该对象在堆中的地址。

使用句柄访问对象 使用直接指针访问对象

这两种访问对象的方法各有优势。使用直接指针进行访问,就可以直接定位到对象,减小了一次指针定位的时间开销(使用句柄的话会通过句柄池的指针二次定位对象),最大的好处就是速度更快。但是使用句柄的话,就是当对象发生移动的时候,可以不用改变栈中存储的reference,只需要改变句柄池中实例数据的指针。

垃圾收集算法

论对象已死?

前面一部分我们都在讲对象,一个对象能够被创建,那么这个对象在什么时候被销毁了?通常,判断一个对象是否被销毁有两种方法:

  • 引用计数算法: 为对象添加一个引用计数器,每当对象在一个地方被引用,则该计数器加1;每当对象引用失效时,计数器减1。但计数器为0的时候,就表白该对象没有被引用。
  • 可达性分析算法: 通过一系列被称之为“GC Roots”的根节点开始,沿着引用链进行搜索,凡是在引用链上的对象都不会被回收。

就像上图的那样,绿色部分的对象都在GC Roots的引用链上,就不会被垃圾回收器回收,灰色部分的对象没有在引用链上,自然就被判定为可回收对象。

那么,问题来了,这个GC Roots又是什么?下面列举可以作为GC Roots的对象:

  • Java虚拟机栈中被引用的对象,各个线程调用的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象,比如引用类型的静态变量。
  • 方法区中常量引用的对象。
  • 本地方法栈中所引用的对象。
  • Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象。
  • 被同步锁(synchronized)持有的对象。

现在,我们已经知道哪些对像是可以回收的。那么又要采取什么方式对对象进行回收呢?垃圾回收算法主要有三种,依次是标记-清除算法标记-复制算法标记-整理算法。这三种垃圾收集算法其实也比较容易理解,下面我先介绍概念,然后在依次总结一下。

标记--清除算法

见名知义,标记--清除算法就是对无效的对象进行标记,然后清除。如下图:

对于标记--清除算法,你一定会清楚看到,在进行垃圾回收之后,堆空间有大量的碎片,出现了不规整的情况。在给大对象分配内存的时候,由于无法找到足够的连续的内存空间,就不得不再一次触发垃圾收集。另外,如果Java堆中存在大量的垃圾对象,那么垃圾回收的就必然进行大量的标记和清除动作,这个势必造成回收效率的降低

复制算法

标记--复制算法就是把Java堆分成两块,每次垃圾回收时只使用其中一块,然后把存活的对象全部移动到另一块区域。如下图:

标记--复制算法有一个很明显的缺点,那就是每次只使用堆空间的一半,造成了Java堆空间使用率的的下降

现在大部分Java虚拟机的垃圾回收器使用的就是标记--复制算法,但是,对于Java堆空间的划分,并不是简单地一分为二。

还记得这张图么?

前面讲Java内存结构的时候,提到过Java堆的具体划分,那现在就来好好的说一说。

首先得从两个分代收集理论说起:

  • 弱分代假说:大多数对象的生命存活时间很短。
  • 强分代假说:经过越多次垃圾收集的对象,存活的时间就越久。

正是这两个分代假说,使得设计者对Java堆的划分更加合理。下面,来说一下GC的分类:

  • Minor GC/Young GC:针对新生代的垃圾收集。
  • Major GC/Old GC:针对老年代的垃圾收集。
  • Full GC:针对整个Java堆以及方法区的垃圾收集。

好了,知道了GC的分类,是时候知道GC的流程了。

通常情况下,初次被创建的对象存放在新生代的Eden区,当第一次触发Minor GC,Eden区存活的对象被转移到Survivor区的某一块区域。以后再次触发Minor GC的时候,Eden区的对象连同一块Survivor区的对象一起,被转移到了另一块Survivor区。可以看到,这两块Survivor区我们每一次只使用其中的一块,这样也仅仅是浪费了一块Survivor区。

每经历过一次垃圾回收的对象,它的分代年龄就加1,当分代年龄达到15以后,就直接被存放到老年代中。

还有一种情况,给大对象分配内存的时候,Eden区已经没有足够的内存空间了,这时候该怎么办?对于这种情况,大对象就会直接进入老年代

标记--整理算法

标记--整理算法算是一种折中的垃圾收集算法,在对象标记的过程,和前面两个执行的是一样步骤。但是,进行标记之后,存活的对象会移动到堆的一端,然后直接清理存活对象以外的区域就可以了。这样,既避免了内存碎片,也不存在堆空间浪费的说法了。但是,每次进行垃圾回收的时候,都要暂停所有的用户线程,特别是对老年代的对象回收,则需要更长的回收时间,这对用户体验是非常不好的。如下图:

HotSpot的算法细节

根节点枚举

根节点枚举,其实就是找出可以作为GC Roots的对象,在这个过程中,所有的用户线程都必须停下。到目前为止,几乎还没有虚拟机可以做到GC Roots遍历与用户线程并发执行。当然,可达性分析算法中最耗时的寻找引用链的过程已经可以做到和用户线程并发执行了。那么,为什么需要在根节点枚举的时候停止用户线程?

其实也不难考虑,如果进行GC Roots遍历的时候,用户线程没有暂停,根节点集合的对象引用关系还在不断发生变化,这样遍历到的结果是不准确的。那么,Java虚拟机在查找GC Roots的时候,是真的需要进行全局遍历?

其实不是这样的,HotSpot虚拟机通过一个叫做OopMap的数据结构,可以知道哪些地方存储了对象引用。这样,大大减小了GC Roots的遍历时间。

安全点

安全点,是线程能够中断的点。我们在GC Roots遍历的时候,是一定要让用户线程停下来的。问题来了,线程是可以在任意位置停下来吗?为了使得线程到达最近的安全点停下来,有两种思路:

  • 抢先式中断: 暂停所有的用户线程,如果哪条线程没有在安全点,就恢复这条线程执行,直到它跑到安全点上在中断。不过没有Java虚拟机采用这种思路。
  • 主动式中断: 不对线程进行操作,仅仅设置一个简单的标志位,线程执行的时候不断区轮询这个标志位,当这个标志位为真的时候,线程就在离自己最近的安全点挂起。

安全区域

安全区域是安全点的拉伸和扩展,安全点解决了如何让线程停下,却没有解决如何让虚拟机进入垃圾回收状态。

安全区域是指能能够确保在某一代码片段中,引用关系不会发生变化的区域。因此,一旦线程进入了安全区域,就可以不去理会这些处于安全区域的线程。当线程离开安全区域的时候,虚拟机就会检查是否完成了根节点枚举。

记忆集与卡表

不知道大家是否考虑过这样的一个问题?既然Java堆有新生代老年代的划分,那么对象引用是否会存在跨代?如果存在跨代,又该如何解决老年代的GC Roots遍历问题?

首先,跨代引用是存在的。因此,垃圾收集器在新生代建立了一个叫做记忆集的数据结构,用来避免把整个老年代假如GC Roots的扫描范围。

记忆集是抽象的数据结构,而卡表是记忆集的具体实现,这种关系就类似与方法区与元空间。

写屏障

写屏障的作用很简单,就是对卡表进行维护和更新

并发的可达性分析

前面我们说到过为什么要暂停所有的用户线程(这个动作也被称之为Stop The World)?这其实是为了不让用户线程改变GC Roots对象的引用。试想,如果用户线程能够随便把死亡的对象重新标记为存活,或者把存活的对象标记为死亡,这岂不是会使的程序发生意想不到的错误。

经典的垃圾收集器

知道了不少的垃圾收集理论,但是具体到某一类的垃圾收集器,其实现方式又不全然相同。下面就介绍一些常见的垃圾收集器。

Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,它在进行垃圾收集的时候会暂停所有的工作线程,直到完成垃圾收集过程。下面是Serial垃圾收集器的运行示意图:

ParNew 收集器

ParNew 垃圾收集器实则是Serial 垃圾收集器的多线程版本,这个多线程在于ParNew垃圾收集器可以使用多条线程进行垃圾回收。

Parallel Scavenge 收集器

也是一款新生代垃圾收集器,同样的基于标记--复制算法实现的。它最大的特点是可以控制吞吐量。

那什么是吞吐量呢?

Serial Old 收集器

Serial Old 收集器是Serial 收集器的老年代版本。其垃圾收集器的运行原理和Serial 收集器是一样的。

Parallel Old 收集器

Parallel Old 收集器同样是Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。下面就是它的运行示意图:

CMS 收集器

前面说到过Parallel Scavenge 收集器,它是一个可以控制吞吐量的垃圾收集器。现在要说的CMS 收集器,它是一个追求最短停顿时间的垃圾收集器,基于标记--清除算法实现的。CMS 垃圾收集器的运作过程相对前面几个垃圾收集器来说比较复杂,整个过程可以分为四个部分:

  • 初始标记: 需要Stop The World,这里仅仅标记GC Roots能够直接关联的对象,所以速度很快。
  • 并发标记: 从关联对象遍历整个GC Roots的引用链,这个过程耗时最长,但是却可以和用户线程并发运行。
  • 重新标记: 修正并发时间,因为用户线程可能会导致标记产生变动,同样需要Stop The World。
  • 并发清除: 清除已经死亡的对象。

Garbage First 收集器

Garbage First(简称G 1)收集器是垃圾收集器发展史上里程碑式的成果,主要面向服务端应用程序。另外G 1收集器虽然还保留新生代和老年代的概念,但是新生代和老年代不在固定,它们都是一系列区域的动态集合。

好了,关于垃圾收集器就介绍到这里,至于G 1收集器还是有很多地方需要关注的,朋友们可以查阅相关资料。下一篇,我们讲类加载机制,你们说好么?

现在是凌晨两点半,庆幸我没看到凌晨四点的太阳。上帝会带走他最喜欢的儿子么?还是那句话,原创不易,点赞再看,最后加个关注,定期推送原创高质量文章。

最后的最后,上点干货。(但凡有个这样的女朋友,我会熬夜么?)