JVM

面试漫谈(八)- JVM篇

Posted by YiBo on December 15, 2020

refercence:

https://mp.weixin.qq.com/s?__biz=MzAwNDA2OTM1Ng==&mid=2453141471&idx=2&sn=f1f8cf98ad0d0b4d3a9e328c3378b2e6&scene=21#wechat_redirect

前记

这几天是近一年来最用功的时候了,其实也就3个小时一天。。明天就要开始大厂面试了,快快快

说说JVM吧

首先JAVA中有俩种数据类型:基本类型和引用类型

java运行时的公有数据区:

  • 堆: 所有JVM线程共享,所有为类实例和数组分配的内存都来自于它。堆中对象不用显式释放。对象实例和数组都是在堆上分配的
  • 方法区:JVM线程共享,存储每一个类的结构。比如常量池,字段和方法数据,方法和构造函数的代码
    • 运行时常量池:就是类或者接口的字节码文件里的常量池的运行时表示形式,分配在JVM的方法区。类活接口被JVM创建才会构建

java运行时私有数据区:

  • 虚拟机栈:描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈帧。主要保存执行方法时的局部变量表,操作数栈,动态链接和方法返回地址等信息、方法执行时入栈,方法执行完出栈,出栈就相当于清空了数据。所以不需要进行GC
  • 本地方法栈:与虚拟机栈功能非常相似、区别在于虚拟机栈为虚拟机执行java方法时服务,而本地方法栈为虚拟机执行本地方法时服务的,也不需要GC
  • 程序计数器:线程独有的,可以看成是当前线程执行的字节码的行号指示器。通过程序计数器可以记录线程运行时的状态:线程的切换等。线程计数器是唯一一个在java虚拟机规范中没有规定任务OOM情况的区域,所以这块也不需要进行GC
  • 本地内存:通常说的堆外内存,java8之前的永久代,主要存储类的信息,常量,静态变量、java8之后,移动到了本地存储的元空间,不受JVM的控制,也不会进行GC,提升了性能

帧:

每次当一个方法被调用时一个新的帧会被创建,方法调用完成后,对应的帧会被销毁,无论是正常完成还是抛出异常

帧用来存储数据和部分计算结果,和执行动态链接,方法返回值,分发异常

java类库:

一些类库中没有JVM协助是无法实现的

  • 反射: JVM加载类 ClassLoader 背后委托给JVM来实现的
  • 安全 SecurityManager
  • 多线程 Thread
  • 弱引用 java.lang.ref 包下的类

如何识别垃圾

  • 引用计数法:对象被引用一次,引用次数+1,为0的时候可以回收。会有循环引用的问题。

    image-20201214162203795

  • 可达性算法:以GC ROOT为起点,引出他们只指向的下一个节点。如果对象不在任意一个GC root为起点的引用链中,就会被GC回收。可以作为GC ROOT的对象:

    • 虚拟机栈中引用的对象

      1
      2
      3
      4
      5
      6
      
      publicclass Test {
          public static  void main(String[] args) {
      	Test a = new Test();
      	a = null;
          }
      }
      
    • 方法去中静态属性引用的对象:由于给s赋值了变量的引用,对象依然存活

      1
      2
      3
      4
      5
      6
      7
      8
      
      public class Test {
          public static Test s;
          public static  void main(String[] args) {
      	Test a = new Test();
      	a.s = new Test();
      	a = null;
          }
      }
      
    • 方法区中常量引用的对象

      1
      2
      3
      4
      5
      6
      7
      
      public class Test {
      	public static final Test s = new Test();
              public static void main(String[] args) {
      	    Test a = new Test();
      	    a = null;
              }
      }
      
    • 本地方法栈中JNI(Native方法)引用的对象

      本地方法:java调用非java代码的接口。通过调用本地的库文件的内部方法,使java可以实现和本地机器的紧密联系

垃圾回收的主要算法

  • 标记清除算法:内存碎片

    1. 先根据可达性算法标记处可回收对象

    2. 对可回收对象进行回收

      image-20201214170816278

  • 复制算法:空间只能用一半

    image-20201214170956247

  • 标记整理法 在标记清除法的基础上添加了一个整理的过程:将所有存活对象都往一端移动,再清理另一端的所有区域。 缺点是每进一次垃圾清除都要频繁移动存活的对象,效率十分低下

    image-20201214171211613

  • 分代收集算法:就是把上述几种算法整合在一起

    新生代(复制算法) : 老年代(标记整理法) 1:2

    新生代分为 eden , from survivor , to survivor 8:1:1

    分代收集工作原理:

    1. 对象在新生代的分配和回收

      大部分对象分配在Eden区,快满的时候,触发Minor GC ,大部分对象会被回收,只有少部分会移到S0,同时对象年龄加一。当触发下一次Minor GC时,会把Eden区的存活对象和S0中的存活对象一起移到S1,同时清空Eden和S0的空间。再触发一次Minor GC则重复上一步,只不过此时变成了从Eden,S1区将存活对象复制到S0,每次垃圾回收,S0S1角色互换。

      在Eden区的垃圾回收采用复制算法。因为在Eden区分配的对象大部分在Minor GC后消亡了

    2. 对象何时晋升为老年代

      当对象的年龄达到了我们设定的阈值,会从S0 或者 S1晋升到老年代

      如果某个对象需要分配大量连续内存时,会直接分配在老年代。因为Eden S1 S0 复制算法有很大的开销

      还有一种情况会让对象晋升到老年代,即在S0或者S1相同年龄的对象大小只和大于S0或S1空间一半以上时,则年龄大于等于改年龄的对象也会晋升到老年代

    3. 空间分配担保

      在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,确保是安全的。如果不大于,那么虚拟机会查看HandlePromotionFailure 设置值是否允许担保失败。如果允许,会继续检查老年代的最大可用联系续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行Minor GC,否则可能进行一次Full GC

    4. stop the world

      如果老年代满了,会触发Full GC ,会同时回收新生代和老年代,会导致stop the world,造成挺大的性能开销

      stw就是在GC期间,只有垃圾回收期线程在工作,其他工作都被挂起、需要尽量减少full GC

      所以上述的 1:2 8:1:1 都是为了尽可能的避免对象过早的进入老年代,尽可能晚的触发full GC、

      由于Full GC会影响性能,所以要在一个合适的时间点触发GC,这个时间点成为Safe Point,主要指一下特定位置:

      1. 循环的末尾
      2. 方法返回前
      3. 调用方的call后
      4. 抛出异常的位置

垃圾收集器种类

收集算法是方法论,那么垃圾收集器就是内存回收的具体实现

新生代收集器

  • Serial收集器

    只会使用一个CPU,收集时,其他用户线程会暂停。适合客户端模式

  • ParNew收集器

    是Serial收集器的多线程版本,主要在Server模式使用。还可以与CMS配合

  • Parallel Scavenge收集器

    复制算法,多线程,更适合做后台运算等不需要太多用户交互的人途

老年代收集器

  • serial Old 收集器

    是对于serial老年代的单线程收集器

  • Paraller Old 收集器

    相对于Paraller Scavenge 收集器的老年代版本,使用多线程和标记整理法,实现了「吞吐量优先」的目标

  • CMS收集器

    CMS收集器是以实现最短STW时间为目标的收集器

    采用的是标记清除法(不是标记整理法),以下4个步骤

    1. 初始标记
    2. 并发标记
    3. 重新标记
    4. 并发清除

    初始标记和重新标记会发生STW。初始标记仅标记GC ROOT能关联的对象,速度很快,并发标记是进程GC ROOT TRACING 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,之一阶段停顿的时间比初始标记阶段稍长,但远比并发标记时间短

    耗时最长的是并发标记和并发清理,不过这俩个阶段用户线程都可以工作,不影响正常使用。所以总体上看,可以认为CMS收集器的内存回收过程是与用户线程一起并发执行的

    CMS有三个缺点

    1. CMS对CPU资源非常敏感,比如有10个用户线程,却要分出3个作为回收线程,默认的启动回收线程是(cpu的数量+3)/4
    2. CMS无法处理浮云垃圾(Floating Garbage)可能出现「Concurrent Mode Failure」 而导致另一次Full GC的产生,由于在并发清理阶段用户线程还在运行,当清理的同时新的垃圾也在出现,这部分垃圾只能一下GC时清理(即浮云垃圾)。同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间确保用户线程正常执行,意味着CMS不能像其他收集器一样等老年代满了在使用
    3. CMS 采用的是标记清除法,会产生大量的内存碎片,如果无法找到足够大的连续空间来分配对象,会触发Full GC,开启 -XX:+UserCMSCompactAtFullCollection 在CMS收集器顶不住要进行Full GC时开启内存碎片的合并过程,会导致STW

G1(Garbage First) 收集器

G1收集器是面向服务端的垃圾收集器,主要以下特点

  • 像CMS一样,能与应用程序线程并发执行
  • 整理空闲空间更快
  • 需要GC停顿费时间更好预测
  • 不会像CMS那样牺牲大量的吞吐性能
  • 不需要更大的JAVA HEAP

与CMS相比,它在以下俩个方面更出色

  1. 运行期间不会产生内存碎片。G1从整体上采用标记整理算法,局部(俩个Region)上看做基于复制算法实现的,俩个算法都不会产生内存碎片
  2. 在STW上简历了可预测的停顿时间模型,用户可以指定期望停顿时间,G1会将停顿的时间控制在用户设定的停顿时间以内

主要原因是G1对堆空间的分配与传统的垃圾收集器不一样,传统的内存分配是连续的,分成新生代,老年代。而G1的存储地址不是连续的,每一代都是用了N个不连续的大小相同的region,每个region占有一块连续的虚拟内存地址。除了传统的新老生代,还多一个 Humongous 表示这些Region存储的巨大对象,这样超大的对象就直接分配到了老年代

传统的收集器如果发生Full GC是对整个堆进行全区域的垃圾收集,而分配成各个region的话,方便G1跟踪各个Region里垃圾堆积的价值大小,避免了整个老年代的回收,减少了STW造成的停顿时间

步骤如下

  1. 初始标记
  2. 并发标记
  3. 最终标记
  4. 筛选回收