JVM 知识点总结

59 阅读15分钟

JVM组成

image.png java 虚拟机主要由类加载器、运行时数据区、执行引擎、本地方法库组成

  • ClassLoader类加载器

ClassLoader 负责加载字节码文件即 class 文件,class 文件在文件开头有特定的文件标示,并且 ClassLoader 只负责class 文件的加载,至于它是否可以运行,则由 Execution Engine 决定。

  • Runtime Data Area运行时数据区

Runtime Data Area 是存放数据的,分为五部分:Stack(虚拟机栈),Heap(堆),Method Area(方法区),PC Register(程序计数器),Native Method Stack(本地方法栈)。几乎所有的关于 Java 内存方面的问题,都是集中在这块。

  • 执行引擎

执行引擎,也叫 Interpreter。Class 文件被加载后,会把指令和数据信息放入内存中,Execution Engine 则负责把这些命令解释给操作系统,即将 JVM 指令集翻译为操作系统指令集。

  • 本地方法库

负责调用本地接口的。他的作用是调用不同语言的接口给 JAVA 用,他会在 Native Method Stack 中记录对应的本地方法,然后调用该方法时就通过 Execution Engine 加载对应的本地 lib。原本多用于一些专业领域,如JAVA驱动,地图制作引擎等,现在关于这种本地方法接口的调用已经被类似于Socket通信,WebService等方式取代。

JVM内存区域

image.png

内存区域包含程序计数器、虚拟机栈、本地方法栈、堆、方法区五个部分,其中程序计数器、虚拟机栈、本地方法栈是线程私有的,方法区和堆是线程共享的。

  • 程序计数器

程序计数器用于存储当前线程执行的字节码文件的行号,因为 cpu 在执行过程中是不停切换线程执行的、以此达到多线程执行的效果,所以当 cpu 切换到本线程时、需要从切换前的字节码行号继续执行。如果正在执行的是 native 方法,那么程序计数器为空,程序计数器也是唯一没有定义 OOM 异常的内存区域

  • java 虚拟机栈

java 虚拟机栈是 java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧、用于存放局部变量表、操作数栈、方法出口等信息,每个方法从执行到结束的过程就是一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表就是我们常说的栈,他需要的内存空间在编译期间分配完成,当进入一个方法的时候,执行这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

java 虚拟机栈定义了 StackOverFlowError 和 OutOfMemoryError 异常

  • 本地方法栈

本地方法栈和 java 虚拟机栈类似,只不过服务于本地方法,也会抛出 StackOverFlowError 和 OutOfMemoryError 异常

堆是 JVM 中占用内存最大的一块,在虚拟机启动时创建,堆的作用就是存储对象实例。

堆也是垃圾收集器所管理的主要区域,现在很多垃圾收集器都采用分代收集算法,因此堆还分为新生代、老年代,再继续细分可以分为:伊甸区、幸存区,从内存分配的角度看,线程共享的堆可以划分出多个私有的分配缓冲区(TLAB),

堆可能会抛出 OOM 异常

  • 方法区

用于存储已被虚拟机加载的类信息、常量、静态变量、JIT 编译后的代码等数据,Java 虚拟机规范将方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做非堆。同样会抛出 OOM。

  • 直接内存

这部分内存不属于虚拟机运行时区域的一部分,这也不是 JVM 规范中定义的内存区域,主要用来服务于基于 Channel 和 Buffer 的 IO 类,可以避免在 Native 堆和 Java 堆之间来回复制数据从而提升性能。这部内存不受 Java 堆内存大小的限制。

垃圾收集器

在 JVM 内存管理的五大区域中,线程私有的虚拟机栈、本地方法栈、程序计数器都是伴随线程的创建而分配、随着线程的销毁而释放,因此不需要考虑内存的回收。但是堆和方法区是线程共享的区域,这部分内存的分配和回收都是动态的,GC 主要关注的也是这部分内存。

判断对象是否存活的方法

GC 时首先要考虑的是哪些对象能否被回收,主要用到引用计数法和可达性分析算法

  • 引用计数法

给对象添加一个引用计数器,每当有新的引用指向这个对象时、计数器+1,引用失效计数器-1,如果计数器为 0,那么表示这个对象可以被回收了。优点是简单高效,但是无法解决循环引用的情况。

  • 可达性分析

通过一系列称为 GC-Roots 的对象作为起点向下搜索,搜索过的路径叫做引用链,如果一个对象没有任何引用链与其相连时说明该对象不可达,即不可能再被使用到,这时便被判定为可回收的对象。在 Java 中 可作为 GC Roots 的对象 有以下几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象
本地方法栈中 Native 方法引用的对象
方法区中的常量引用的对象

在可达性分析中不可达的对象并非一定是“非死不可”的,GC 时在第一次可达性分析时发现对象不可达时进行一次标记,同时会判断该对象是否有必要执行 finalize()方法(对象复写了 finalize()方法且改方法没有被调用过时才会被认定有必要执行)。如果判断有必要执行则将其加入一个叫做 F-Queue 队列,稍后启动一个低优先级的线程去依次执行对象中的 finalize()方法,在对象的 finalize()方法中如果将 this 与引用链上的任意对象建立连接,那么在下一次可达性标记的时候会将这个对象从“即将回收”的集合中移除。

垃圾回收算法

  • 复制算法

将内存等分为两部分,每次只使用其中的一半,这一半使用完了就将其中还存活的对象复制到另一块内存上,然后将这一半内存进行回收,循环往复。简单高效、但是会浪费一半的空间。

复制算法主要被用来回收新生代,因为新生代的对象 98% 都是需要被回收的,那么就没有必要将内存按照 1:1 的比例进行分配,可以将内存分为一块较大的伊甸区,和两块较小的幸存区,比例为8:1:1,每次只使用其中的伊甸区和其中一块幸存区(From),然后回收时将伊甸区和 From 幸存区中存活的对象复制到另一块幸存区(To),把刚才的伊甸区和 From 幸存区中对象全部回收。这样空间利用率在 90%,但是如果新生代中存活的对象过多,超过了 10%,这时候幸存区将会不够用,此时需要依赖老年代进行分配担保。

  • 标记-清除算法

按照引用计数法或者可达性分析算法标记哪些对象时可回收的,标记完成后统一对被标记的对象进行回收。显然这种方式会产生大量的内存碎片,从而导致后续再分配较大的对象时无法找到足够的连续内存,从而触发另一次的垃圾回收

  • 标记-整理算法

老年代的存活率会比较高,因此并不适用于复制算法,标记整理算法首先将需要回收的数据标记出来,然后将存活的对象都向内存的一端移动,然后直接清理掉边界以外的区域。这样也能保证内存的连续性

当代虚拟机都采用 “分代收集” 的思想,一般根据对象存活周期将 Java 堆分为新生代和老年代,分别根据其特点选择相应的收集算法:新生代对象存活率低,则采用 复制算法 只需要对极少比例的存活对象进行复制即可完成收集;而老年代因为存活率高,没有额外空间对其进行分配担保,就必须使用 “标记 - 清理”或者 “标记 - 整理” 算法 来回收。

  • 如何高效的在堆上分配对象空间

JVM 是在堆上分配对象,在栈上分配对象的引用及常量,JVM 在堆上分配对象的速度非常快,因为在分配堆空间时只是将“堆指针”移动到尚未分配的区域。gc 时垃圾回收线程一边回收空间、一边将堆中的对象重新紧密排列,这样堆指针就可以更容易的移到空闲区域的开始处。通过 gc 的对空间的重新排列,实现了一种高速的堆分配模型

垃圾回收思想

对任何 “活” 的对象,一定能最终追溯到其存活在堆栈或静态存储区中的引用。

基于此从堆栈和静态存储区开始遍历所有的引用,就能找到所有 “活” 的对象,对这些对象进行标记,将其余的对象回收。不同的虚拟机对这个过程有不同的具体实现。

  1. 停止 - 复制模式 先暂停程序运行(不属于后台回收模式),将所有活得对象从当前堆复制到另一个堆,没有复制的对象都当作垃圾回收,复制到新堆时对象会被一个挨着一个整齐的排列,这样便可以按照前面说的移动 “堆指针” 的方式直接分配新空间了。当然这种 “复制移动” 式的回收方法效率较低,通常做法是按需从堆中分配几块较大的内存,复制动作发生在这几块较大的内存之间。
  2. 标记 - 清扫模式 前一种 “停止 - 复制” 模式在垃圾较少的情况下效率仍然很低下,因为这时大量的复制行为其实没有必要,于是另一种新的方法:遍历所有引用进而找到所有存活的对象并对其标记,标记完成以后将没有标记的对象清理,这个过程中并不做任何复制。当然这样的话剩下的堆空间并不是连续的。

两种方式个有利弊,一般 java 虚拟机会采用一种自适应的方式,即如果监控到所有对象都很稳定垃圾回收器效率较低时,切换到 “标记 - 清扫模式”,同样监控发现堆空间出现很多碎片时又切回“停止 - 复制” 模式。

GC的触发条件

  • FullGC

    • 显式的调用 System.gc()
    • serial GC 中,老年代内存剩余已经小于之前年轻代晋升老年代的平均大小,则进行 Full GC;
    • 而在 CMS 等并发收集器中则是每隔一段时间检查一下老年代内存的使用量,超过一定比例时进行 Full GC 回收。
  • Young GC

    • 当伊甸区的空间耗尽时 JVM 会触发一次 Young GC 来收集新生代的垃圾,存活下来的对象被复制到幸存区(To)。简单讲就是当新生代的伊甸区满的时候触发 Young GC
  • YoungGC 过程

    • 新生代共有 两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivor 区是空的。当发生 Minor GC时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制(此处采用标记 - 复制算法)到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。from 与 to 只是两个指针,它们变动的,to 指针指向的 Survivor 区是空的
  • 幸存区晋升至老年代条件

    • Java 虚拟机会记录 Survivor 区中的对象在 from 和 to 之间一共被来回复制了几次。如果一个对象被复制的次数为 15 (对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升为至老年代;另外,如果单个 Survivor 区已经被占用了 50% (对应虚拟机参数: -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代

垃圾收集器

  • 新生代垃圾收集器

    • Serial 收集器

      串行收集器,是最基本、最悠久的采用复制算法算法的新生代收集器,他在执行 GC 时会 Stop The World。是 HotSpot 虚拟机运行在 Client 模式下默认的新生代收集器

    • ParNew 收集器

      Serial 收集器的多线程版本,也是一个新生代收集器。他只是多个线程执行 GC,仍然需要 Stop The World。如果在单 CPU 环境下 ParNew 收集器因为存在线程切换的损耗、实际效率会更低。

    • Parallel Scavenge 收集器

      也是一个并行的采用复制算法的多线程新生代收集器,它的特点是会根据当前系统的运行情况、动态的调整新生代的大小,伊甸区和幸存区的比例,以保证达到最合适的停顿时间或者最大的吞吐量。

  • 老年代垃圾收集器

    • Serial Old 收集器

      Serial 收集器的老年代的版本,单线程、采用标记-整理算法

    • Parallel Old 收集器

      Parallel Scavenge 收集器的老年代版本,多线程、采用标记-整理算法

    • CMS 收集器

      CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器,他非常适用那些对服务响应速度要求高的应用。采用标记-清除算法实现。

      CMS 收集器的工作流程分为 4 个步骤

      • 初始标记

        先标记 GC ROOTS 能直接关联到的对象,速度很快,需要 Stop The World

      • 并发标记

        进行 GC ROOTS Tracing 的过程,与用户线程并发执行,耗时最长

      • 重新标记

        为了修正并发标记期间因用户线程继续执行而导致的标记产生的变动,重新进行标记、这个阶段也需要 Stop The World

      • 并发清除

        与用户线程一起并发执行

      耗时最长的并发标记与并发清除过程都可以与用户线程一起工作,所以总体上 CMS 收集器的内存回收过程非常高效

      优点:

      并发收集、低延时

      缺点:

      并发执行、对 CPU 资源较敏感

      采用标记-清除算法、会导致磁盘碎片

    • G1 收集器

      G1(Garbage-First)收集器是当前收集器技术发展最前沿的成果,它是一款面向服务端的垃圾收集器,HotSpot 开发团队对 G1 收集器的定位是在未来可以替换掉 CMS 收集器、当然这是一个很漫长的计划。

      具备如下特点:

      • G1 收集器能充分利用多 CPU 的硬件优势,使用多个 CPU 来缩短 Stop The World 的时间
      • G1 仍采用分代收集的概念,虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。
      • G1 从整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的。
      • 降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了降低停顿外,还能建立可预测的停顿时间模型

类加载器

  • 引导类加载器

    负载加载 JVM 运行的位于 JRE 的 lib 目录下的核心类库,比如 rt.jar,charsets.jar

  • 扩展类加载器

    负责加载 JVM 运行的位于 JRE 的 lib 目录下的 ext 扩展目录中的 JAR

  • 应用程序类加载器

    负责加载 ClassPath 路径下的类,主要就是加载我们自己写的那些类

  • 自定义类加载器

    负责加载用户自定义路径下的类

双亲委派

  • 沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改

  • 避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性

  • 加载过程

    • 我们自己写的类,首先找到应用程序类加载器
    • 应用程序类加载器委托给扩展类加载器
    • 扩展类加载器发现不是自己负责的类、再委托给引导类加载器
    • 引导类加载器发现不是自己负责的类,则向下回退给扩展类加载器
    • 扩展类加载器再会推给应用程序类加载器
    • 应用程序类加载器负责最终的类加载过程
  • tomcat 类加载

    • tomcat 的类加载机制打破了双亲委派机制
    • 一个 tomcat 的 web 容器可能同时部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,因此 tomcat 容器需要保证每个应用程序的类库都是独立的,保证相互隔离

调优