面试-JVM

99 阅读23分钟

JVM 类加载器有哪几种

1.启动(Bootstrap)类加载器

启动类加载器主要加载的是 JVM 自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的一部分,它负责将 <JAVA_HOME>/lib路径下的类库或 -Xbootclasspath 参数指定的路径下的jar包加载到内存中。

注意虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的。

出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

由于启动类加载器涉及虚拟机的本地实现,开发者无法直接获取到启动类加载器的引用。

2.扩展(Extension)类加载器

扩展类加载器负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量 -Djava.ext.dir 指定位路径中的类库,开发者可以直接使用标准扩展类加载器。

3.系统(System)类加载器

又称为应用程序类加载器。它负责加载系统类路径 classpath 或 -Djava.class.path 指定路径下的类库。

开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader.getSystemClassLoader()方法可以获取到该类加载器。

4.用户自定义类加载器

通过继承 java.lang.ClassLoader 类 的方式实现。

双亲委派模式介绍

双亲委派模式要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,请注意双亲委派模式中的父子关系并非通常所说的类继承关系,而是采用组合关系来复用父类加载器的相关代码,类加载器间的关系如下:

双亲委派模式.png

工作原理的是:

如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。

双亲委派模式优势

  • 可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要子加载器再加载一次。

  • 其次是考虑到安全因素,java核心api中定义类型不会被随意替换。

    假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

    可能你会想,如果我们在classpath路径下自定义一个名为java.lang.SingleInterger,该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为java.lang是核心API包,需要访问权限,强制加载将会报出如下异常:

    java.lang.SecurityException: Prohibited package name: java.lang
    

类加载过程

整体流程如下图所示:

类的生命周期.png

具体步骤如下:

  • 加载

    • 通过一个类的全限定名来获取定义此类的二进制字节流;
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
    其中获取二进制字节流可以通过Class文件、ZIP包、网络、运行时(动态代理)、JSP生成、数据库等途径获取。
    
    需要注意的是数组类的加载,数组类并不通过类加载器加载,而是由Java虚拟机直接创建,但数组类的元素还是要依靠类加载器进行加载。
    
    这些二进制字节流加载完成之后,按照指定的格式存放于于方法区内(Java7及以前方法区位于永久代,Java8位于Metaspace)。然后在方法区生成一个比较特殊的java.lang.Class对象,用来作为程序访问方法区中这些类型数据的外部接口。
    
  • 验证

    • 文件格式验证:验证字节流是否符合Class文件格式的规范;比如,是否以魔术0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。只有验证通过才会进入方法区进行存储。

    • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;比如,是否有父类(除Object类)、父类是否为final修饰、是否实现抽象方法或接口、重载是否正确等。

    • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。比如,保证数据类型与指令正常配合工作、指令不会跳转到方法体外的字节码上,方法体中的类型转换是有效的等。

    • 符号引用验证:在虚拟机将符号引用转化为直接引用的时候进行验证,可以看做是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。常见的异常比如:java.lang.NoSuchMethdError、java.lang.NoSuchFiledError等。

  • 准备

    准备阶段主要是正式为类静态变量分配内存并设置初始值,变量所使用的内存都将在方法区中进行分配。

    变量的初始化值并不是类中定义的值,而是该变量所属类型的默认值。
    当然,也有特殊情况,比如当变量是静态常量时,会在准备阶段初始化为指定的值。
    
  • 解析

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

  • 初始化

    初始化阶段才是真正执行类中定义的Java程序代码(字节码)。在此阶段会根据代码进行类变量和其他资源的初始化,或者可以从另一个角度来表达:初始化阶段是执行类构造器()方法的过程。

    ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static语句块)中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

    <clinit>()方法与实例构造器<init>()方法不同,它不需要显示地调用父类构造器,虚拟机会保证在子类<cinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。最开始的面试题中打印出父类静态块的方法就是这个原因。
    
    由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
    
    <clinit>()方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生产<clinit>()方法。
    
    接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
    
    虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。
    

    虚拟机规范严格规定了有且只有5种情况必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)

    • 使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
    • 当使用jdk1.7动态语言支持时,如果一个java.lang.in~voke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
  • 使用

  • 卸载

对象创建的流程是怎样的

整体流程如下图所示:

对象创建流程.png

JVM 内存模型是怎样的

JVM 将虚拟机分为5大区域:

  • 程序计数器:线程私有的。是一块非常小的内存空间,可以作为当前线程的行号指示器,用来记录当前线程正在执行的指令地址。
  • 本地方法栈:线程私有的。存储的 native 的方法信息。
  • JAVA 虚拟机栈:线程私有的。每个线程执行时都会创建虚拟机栈,存储的是方法的栈帧。
  • 元空间:线程共享。存储的已被加载的类信息、静态变量、常量、即时编译器编译的代码数据等。使用的是本地内存。
  • 堆:线程共享。几乎所有对象的实例和数组都需要在堆上分配内存,因此这个区域会经常发生垃圾回收的操作。

image-20230512194519966.png

JVM 虚拟机栈内存模型

虚拟机栈线程私有的,栈的生命周期和线程的生命周期是一样的。

虚拟机栈中存储的是线程调用的方法的栈帧。每一个方法执行的时候,都会创建一个栈帧。

栈帧的基本结构如下:

  • 局部变量表
  • 操作栈
  • 动态链接
  • 返回地址

image-20230512191904630.png

静态链接:当一个字节码文件被装载到JVM内存中,如果被调用的方法在编译期可知,且运行时期保持不变,这种情况下将调用方的符号引用转为直接引用的过程称为静态链接。
动态链接:当一个字节码文件被装载到JVM内存中,如果被调用的方法无法在编译期确定下来,只能在运行期将方法的符合引用转为直接引用,这种引用转换过程具有动态性,称为动态链接。

什么情况下会发生栈内存溢出

当线程申请的栈内存超过了虚拟机允许的栈内存最大深度时,就会报错栈内存溢出。

  • 方法创建了一个很大的对象,如List、Array。
  • 方法循环调用过深、死循环
  • 方法引用了较大的全局变量。

可以通过调整参数来调整 JVM 栈内存的大小。

Java 四种引用

String str=new String("abc");                                     // 强引用
SoftReference<String> softRef=new SoftReference<String>(str);     // 软引用
WeakReference<String> abcWeakRef = new WeakReference<String>(str);// 弱引用

强引用

使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。

当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

如果不使用时,需要显式地将对象引用设置为null,或超出对象的生命周期范围,则gc认为该对象不存在引用,这时就可以回收这个对象。

软引用

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

弱引用

只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

虚引用

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。

如何判断对象是否存活

判断对象是否存活有两种算法:

  • 引用计数法

    给对象添加引用计数器,当一个地方引用,则计数器加1,反之减1,当计数器的值为0,则该对象为可回收对象。

    无法解决对象循环引用的问题,主流的虚拟机都没有采用这种算法。
    
  • 可达性分析算法

    通过一系列称为 GC Roots 的对象作为起点开始向下搜索,经过的路径叫做引用链,如果一个对象到 GC Roots 没有任何引用链相连的话,该对象就为可回收对象。引用链说白了就是一个对象中引用另外一个对象。

    GC Roots 的对象可以是:

    • 虚拟机栈(栈帧中的本地变量表)中引用的对象(不理解的需要深度学习一下JVM内存结构)
    • 方法区/元数据区中类的静态变量引用的对象
    • 类的静态常量引用的对象
    • 本地方法栈中本地方法引用的对象
    在可达性分析算法中,判断对象真正死亡,需要进行二次标记。
    在第一次发现对象不可达后,会对对象进行一次标记,此时一次标记的对象会被放在等待回收的集合中,如果该对象所属的类重写了finalize(),且该方法没有被执行过,那么该对象会被取出放入专门的队列,虚拟机会启动后台线程顺序去执行队列中对象的finalize(),如果被救活,则移除被回收内存,如果没被救活,则等待二次标记,被标记2次以后,就等待回收。
    
    如果第一次不可达标记后没能调用finalize()方法,那么该对象也直接被二次标记等待回收,finalize()方法可以理解为拯救对象的方法,在垃圾回收过程中,只会被执行一次。
    

JVM 堆内存分配策略

image-20230512194612083.png

堆内存分为新生代和老年代。其中新生代又分为:Eden区、Survivor0区、Survivor1区。

默认内存比例如下:

Eden : From : To = 8 : 1 : 1

新生代 : 老年代 = 1 : 2

JVM 垃圾回收过程

Minor GC 过程详解

minor gc的触发条件是:Eden区满时。

  • 在初始阶段,新创建的对象被分配的Eden区,2个Survivor都是空的。

  • 当Eden区满了的时候,触发minor gc,经过扫描和标记,存活的对象被复制到s0区,同时年龄+1,清空Eden区。

  • 再下一次minor gc的时候,如果s0的空间满了,会将s0中存活的对象和Eden区存活的对象复制到s1区,同时年龄+1,清空Eden区和s0区。然后交换s0和s1。

  • 再下一次minor gc的时候,重复上述步骤。当存活的对象年龄达到一个阈值后(-XX:MaxTenuringThreshold,默认是15),就会被从年轻代晋升到老年代。

    从上述流程可以看出,Eden区是一块连续的空间,Survivor总有一块空间为空。新生代采用的垃圾回收算法是标记复制法,垃圾回收时,会停止用户线程(STW)。
    
    在发生Minor GC时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC,
    否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),
    如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;
    如果不允许,则仍然进行Full GC(**这代表着如果设置-XX:+Handle PromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。
    
    

Full GC 触发条件

Full GC 是针对整个新生代、老年代、元空间(metaspace,java8 以上版本取代永久代)的全局范围的 GC

  • 调用System.gc时,系统建议执行Full GC,但是不一定会执行。

  • 老年代空间不足。

  • 方法区空间不足。

  • 通过minor gc后进入老年代的平均大小大于老年代可用内存时。

  • 由Eden区、From区向To区复制时,对象大小大于To区可用内存,则把该对象转移到老年代,老年代可用内存小于该对象大小时。

    当老年代空间不足时,会采用标记压缩算法,标记出存活的对象,将存活的对象像一端移动,然后回收不存活的对象。
    

对象进入老年代的四种情况

  • 进行minor gc时,存活的对象在To区存放不下,那么把存活的对象移动到老年代。
  • 大对象直接进入老年代(-XX:PretenureSizeThreshold,默认3M)。
  • 长期存活的对象进入老年代(-XX:MaxTenuringThreshold,默认是15)。
  • 动态年龄判断:在From区,如果相同年龄对象的大小总和大于From区总空间的一半,那么年龄大于等于该年龄的对象就会被直接移到老年代。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。

  • 大于:那么Minor GC可以确保是安全的。
  • 小于:检查JVM参数 HandlerPromotionFailure(是否允许担保失败)
    • 允许:继续尝试执行Minor GC(尽管是有风险的,比如本次要晋升到老年代的对象很多,老年代存不下,那么Minor GC还是无法执行,此时还得改为Full GC)。
    • 不允许:这次Minor GC将升级为Full GC。

JVM 垃圾回收算法

标记清除算法

  • 标记:利用可达性分析来遍历内存,把需要垃圾回收对象进行标记。
  • 清除:把标记阶段确定不可用的对象清除。
缺点:
1.标记和清除的效率都不高。
2.会产生大量不连续的空间碎片,当程序需要分配大对象而找不到连续的内存空间时,不得不触发一次垃圾回收。

标记整理算法

  • 标记:利用可达性分析来遍历内存,把需要垃圾回收对象进行标记。
  • 整理:把存活的对象往内存的一端移动,然后直接回收边界以外的内存。
缺点:算法复杂度大,执行步骤较多。
适用于存活对象多,垃圾回收少的情况,消耗的时间成本较高,但是不会产生空间碎片。

复制算法

把内存分成大小相等的2个部分,每次使用其中的一块,当垃圾回收的时候,把存活的对象复制到另一块上,然后把这快内存整个清理掉。

缺点:
1.需要浪费额外的内存作为复制区。
2.当存活率较高时,复制算法效率会下降。 

分代收集算法

根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为:新生代、老年代、永久代。

  • 新生代:存放新创建的对象,对象生命周期非常短,几乎用完可以立即回收。
  • 老年代:新生代区多次回收后存活下来的对象。
  • 永久代:主要存储加载的类信息,生命周期长,几乎不会被回收。

老年代的特点是每次垃圾收集时只有少量的对象需要被回收,而新生代的特点是每次垃圾回收都有大量的对象需要被回收,那么就可以根据不同代的特点选择最适合的垃圾收集算法。

1.老年代一般采用:标记清除算法或者标记整理算法
2.新生代一般采用:复制算法

JVM 垃圾回收器分类

  • 新生代垃圾回收器

    • Serial 收集器

      只能使用一条线程进行垃圾回收,并且进行垃圾回收的过程中,所有工作线程都处于暂停状态,等垃圾回收结束后,工作线程才可以继续运行。

      使用算法:复制算法

    • ParNew 收集器

      是 Serial 收集器的多线程版本。为了利用 CPU 多核多线程的优势,ParNew 收集器可以运行多个收集线程来进行垃圾收集工作。这样可以提高垃圾收集过程的效率。

      使用算法:复制算法

    • Parallel Scavenge 收集器

      是一款多线程的垃圾收集器,但是它又和 ParNew 有很大的不同。

      它关注的是如何控制系统运行的吞吐量(CPU 用于运行应用程序的时间和 CPU 总时间的占比),吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间)

      使用算法:复制算法

  • 老年代垃圾回收器

    • Serial Old 收集器

      是 Serial 收集器的老年代版本。这款收集器主要用于客户端应用程序中作为老年代的垃圾收集器,也可以作为服务端应用程序的垃圾收集器。

      使用算法:标记-整理算法

    • Parallel Old 收集器

      是 Parallel Scavenge 收集器的老年代版本。

      使用算法:标记-整理算法

    • CMS 收集器

      是目前老年代收集器中比较优秀的垃圾收集器。CMS 是 Concurrent Mark Sweep 的缩写,从名字可以看出,这是一款使用 标记-清除 算法的并发收集器。

      image-20230512233318353转存失败,建议直接上传图片文件

      从图中可以看出,CMS 收集器的工作过程可以分为4个阶段:

      • 初始标记阶段
      • 并发标记阶段
      • 重新标记阶段
      • 并发清除阶段

      使用算法:标记-清除算法

  • G1 垃圾收集器

    是一款新生代和老年代都可以使用的垃圾收集器。

    主要步骤:初始标记、并发标记、重新标记、复制清除

    使用算法:复制 + 标记-整理算法

    G1 与 CMS 两个垃圾收集器对比

    1.G1 在压缩空间方面有优势。
    2.G1 通过将内存空间分成区域的方式避免内存碎片问题。
    3.Eden、Suvivor、Old 区不再固定,在内存使用效率上来说更为灵活。
    4.G1 可以通过设置预期停顿时间来控制垃圾收集时间,避免应用雪崩现象。
    5.G1 在回收内存后会马上做合并空闲内存的工作,而 CMS 默认是在 STW(stop the world)的时候做。
    6.G1 在年轻代和老年代都可以使用,而 CMS 只能在老年代使用。
    7.吞吐量优先使用 G1,响应优先使用 CMS

JVM 哪些会造成 OOM 的情况

除了数据运行区,其他区域都有可能发生 OOM 情况

  • 堆溢出:java.lang.OutOfMemeryError: Java heap space
  • 栈溢出:java.lang.StackOverflowError
  • 永久代溢出:java.lang.OutOfMemeryError: PermGen space

JVM 参数配置

  • 数据区设置

    • Xms 初始堆内存大小。

    • Xmx 最大堆内存大小。

    • Xss:Java 每个线程的的 Stack 内存大小。

    • XX:NewSize=n 设置年轻代的内存大小。

    • XX:NewRatio=n 设置新生代和老年代的比值。如 XX:NewRatio=3,表示 新生代 : 老年代 = 1:3,一个Suvivor 区占整个年轻代的1/5。

    • XX:SurivorRation=n 设置年轻代中 Eden 区和两个 Suvivor 区的比值。注意 Suvivor 区有2个。如 XX:SurivorRation=3,表示 Eden:Suvivor = 3:2,一个Suvivor 区占整个年轻代的1/5。

    • XX:MaxPermSize=n 设置持久代大小。

  • 收集器设置

    • XX:+UseSerialGC 设置串行垃圾收集器。

    • XX:+UseParallelGC 设置并行垃圾收集器。

    • XX:+UseParallelOldGC 设置并行年老代垃圾收集器。

    • XX:+UseConcMarkSweepGC 设置并发垃圾收集器。

  • GC 日志打印设置

    • XX:+PringGC 打印 GC 的简要信息。

    • XX:+PringGCDetails 打印 GC 的详细信息。

    • XX:+PringGCTimeStamps 打印 GC 的时间戳。

线上问题排查

1.CPU 资源占用过高

# 查看当前 CPU 状态,找到占用 CPU 过高的进程 PID
top

# 找出进程 CPU 占用过高的线程 PID
top -H pPID

# 打印当前进程的线程栈
jstack -l pid

# 分析CPU 占用过高的线程的运行栈,分析代码

2.OOM 异常排查

# 使用 top 指令查看当前服务器系统状态
top

# 查询服务器进程 PID
ps -aux|grep java 或 jps -l

# 查询当前 GC 的状态
jstat -gcutil pid interval

# 统计存活对象的分布情况,从高到低查看占据内存最多的对象
jmap -histo:live pid

# dump 出当前服务器内存情况,使用性能分析工具分析
jmap -dump:format=b,file=文件名 pid