3.JVM基础详解(含常见面试题)

1. JVM在哪?


在这里插入图片描述


2. JVM体系结构


在这里插入图片描述

类加载器(ClassLoader)会把 Java 代码转换成字节码,运行时数据区(Runtime Data Area)再把字节码加载到内存中,而字节码文件只是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。


3. 类加载器


作用:加载.class文件

  1. 启动类加载器(Bootstrap Class Loader)

    Java虚拟机内置的类加载器,它负责加载Java核心类库,例如java.lang包中的类。jre中lib目录下的rt.jar

  2. 扩展类加载器(Extension Class Loader)

    负责加载Java的扩展类,例如Java扩展API中的类。jre中lib目录下的ext文件夹

  3. 应用程序类加载器(Application Class Loader)

    负责加载应用程序的类和用户自定义类。


4. 双亲委派


Java中的双亲委派机制(Parent Delegation Model)是一种类加载机制,它是保证Java应用程序的安全性和稳定性的关键所在。

在这里插入图片描述

双亲委派机制是Java类加载器的一种工作模式。

当一个Java类需要被加载时,Java虚拟机会先将加载请求传递给它的父类加载器,如果父类加载器无法找到该类,才会将加载请求传递给子类加载器。

这个过程就像一个向上查找的层级结构,直到被找到或者抛出ClassNotFoundException异常。

双亲委派机制的作用

  1. 避免同一类被多次加载(防止内存中出现多份同样的字节码):双亲委派机制的作用在于这样可以避免类的冲突和数据不一致问题。例如,在一个Java应用程序中,如果同一个类被不同的类加载器加载,可能会导致不同的版本存在,从而导致运行时错误。通过双亲委派机制,Java虚拟机会保证同一个类只会被加载一次,这样可以避免这种错误的发生。保证了数据安全
  2. 提高类加载的效率:因为在加载一个类时,Java虚拟机会优先查找父类加载器中是否已经加载了该类,如果已经加载,则直接返回已经加载的类对象。这样可以减少重复加载的开销,提高类加载的效率。
public class ClassLoaderDemo {

    public static void main(String[] args) throws ClassNotFoundException {
        
        // 获取应用程序类加载器
        ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println("应用程序类加载器:" + appClassLoader);

        // 获取扩展类加载器
        ClassLoader extClassLoader = appClassLoader.getParent();
        System.out.println("扩展类加载器:" + extClassLoader);

        // 获取启动类加载器
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println("启动类加载器:" + bootstrapClassLoader);

        // 加载自定义类
        Class<?> customClass = appClassLoader.loadClass("com.example.CustomClass");
        System.out.println("自定义类加载器:" + customClass.getClassLoader());

        // 加载Java核心类库中的类
        Class<?> objectClass = appClassLoader.loadClass("java.lang.Object");
        System.out.println("Java核心类库中的类加载器:" + objectClass.getClassLoader());

        // 加载Java扩展API中的类
        Class<?> dateClass = extClassLoader.loadClass("java.util.Date");
        System.out.println("Java扩展API中的类加载器:" + dateClass.getClassLoader());

        // 加载Java核心类库中的类,使用启动类加载器
        Class<?> stringClass = bootstrapClassLoader.loadClass("java.lang.String");
        System.out.println("Java核心类库中的类加载器:" + stringClass.getClassLoader());
    }
}

运行上述程序,输出结果如下:

应用程序类加载器:sun.misc.Launcher$AppClassLoader@4e25154f
扩展类加载器:sun.misc.Launcher$ExtClassLoader@3d4eac69
启动类加载器:null
自定义类加载器:sun.misc.Launcher$AppClassLoader@4e25154f
Java核心类库中的类加载器:null
Java扩展API中的类加载器:sun.misc.Launcher$ExtClassLoader@3d4eac69
Java核心类库中的类加载器:null

怎么打破双亲委派模型?

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。这个委派和加载顺序完全是可以被破坏的。

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

典型的打破双亲委派模型的框架和中间件有tomcat

Tomcat如何打破双亲委派机制实现隔离Web应用的?_tomcat打破双亲委派机制_JavaEdge.的博客-CSDN博客


5. Native关键字


在介绍 native 之前,我们先了解什么是 JNI。

JNI:Java Native Interface

一般情况下,我们完全可以使用 Java 语言编写程序,但某些情况下,Java 可能会不满足应用程序的需求,或者是不能更好的满足需求,比如:

  1. 标准的 Java 类库不支持应用程序平台所需的平台相关功能。
  2. 我们已经用另一种语言编写了一个类库,如何用Java代码调用?
  3. 某些运行次数特别多的方法代码,为了加快性能,我们需要用更接近硬件的语言(比如汇编)编写。

JNI 的缺点

  1. 程序不再跨平台。要想跨平台,必须在不同的系统环境下重新编译本地语言部分。
  2. 程序不再是绝对安全的,本地代码的不当使用可能导致整个程序崩溃。一个通用规则是,你应该让本地方法集中在少数几个类当中。这样就降低了JAVA和C之间的耦合性。

一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法是一个原生态方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。

注:凡是带了native关键字的方法就会进入本地方法栈,其他的就是Java栈


6. PC寄存器


程序计数器:Program Counter Register

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向像一条指令的地址,也即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。


7. Method Area方法区


方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;

静态变量static、常量final、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关。


8. 栈


栈帧:一个栈帧随着一个方法的调用开始而创建,这个方法调用完成而销毁。栈帧内存放者方法中的局部变量,操作数栈等数据。

Java栈也称作虚拟机栈(Java Vitual Machine Stack),JVM栈只对栈帧进行存储,压栈和出栈操作。Java栈是Java方法执行的内存模型。下面我们来看一个Java栈图。

在这里插入图片描述

由上图可以看出,Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。

栈内存的大小可以有两种设置,固定值和根据线程需要动态增长。
在JVM栈这个数据区可能会发生抛出两种错误。

  1. StackOverflowError 出现在栈内存设置成固定值的时候,当程序执行需要的栈内存超过设定的固定值会抛出这个错误。
  2. OutOfMemoryError 出现在栈内存设置成动态增长的时候,当JVM尝试申请的内存大小超过了其可用内存时会抛出这个错误。

总结

  1. 每个线程包含一个栈区,栈中只保存基础数据类型的对象和自定义对象的引用(不是对象)。对象都存放在堆区中。
  2. 每个战中的数据(基础数据类型和对象引用)都是私有的,其他栈不能访问。
  3. 栈分为3个部分:基本类型变量,执行环境上下文,操作指令区(存放操作指令).
  4. 在函数中定义的一些基本类型的变量数据和对象的引用变量都在函数的栈内存中分配。
  5. 当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当该变量退出该作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

9. 堆


一个JVM只有一定一个堆内存

在这里插入图片描述

  1. JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。
  2. 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。
  3. 堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
  4. 非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。

在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。
元空间有注意有两个参数:

  • MetaspaceSize :初始化元空间大小,控制发生GC阈值
  • MaxMetaspaceSize : 限制元空间大小上限,防止异常占用过多物理内存

为什么移除永久代?

移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。
有了元空间就不再会出现永久代OOM问题了!

分代概念

新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC仍然存活的对象移动到老年代。

老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。

Minor GC : 清理新生代

Major GC : 清理老年代

Full GC : 清理整个堆空间,包括年轻代和永久代

所有GC都会停止应用所有线程。

为什么分代?

将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。

为什么survivor分为两块相等大小的幸存空间?

主要为了解决碎片化。如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发GC。

总结

  1. 存储的全部是对象,每个对象包含一个与之对应的class信息–class的目的是得到操作指令。
  2. jvm只有一个堆区(heap)被所有线程共享,堆区中不存放基本类型和对象引用,只存放对象本身。
  3. 堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。
  4. 缺点是,由于要在运行时动态分配内存,存取速度较慢。

10. 新生代


是用来存放新生的对象。一般占据堆的1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾 回收。新生代又分为Eden 区、ServivorFrom、 ServivorTo 3个区。

Minor GC:简单理解就是发生在年轻代的GC。三步(复制–清空–互换)

Minor GC的触发条件为:

当产生一个新对象,新对象优先在Eden区分配。如果Eden区放不下这个对象,虚拟机会使用复制算法发生一次Minor GC,清除掉无用对象,同时将存活对象移动到Survivor的其中一个区(fromspace区或者tospace区)。

虚拟机会给每个对象定义一个对象年龄(Age)计数器,对象在Survivor区中每“熬过”一次GC,年龄就会+1。待到年龄到达一定岁数(默认是15岁),虚拟机就会将对象移动到年老代。

如果新生对象在Eden区无法分配空间时,此时发生Minor GC。发生MinorGC,对象会从Eden区进入Survivor区,如果Survivor区放不下从Eden区过来的对象时,此时会使用分配担保机制将对象直接移动到年老代。

  1. 第一次Yong GC(Minor GC)后,Eden区还存活的对象复制到Surviver区的“To”区,“From”区还存活的对象也复制到“To”区,

  2. 再清空Eden区和From区,这样就等于“From”区完全是空的了,而“To”区也不会有内存碎片产生,

  3. 等到第二次Yong GC时,“From”区和“To”区角色互换,很好的解决了内存碎片的问题。


11. 老年代


主要存放应用程序中生命周期长的内存对象。

老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC 前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。

当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。

MajorGC采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM (Out of Memory)异常

Major GC的触发条件:

Major GC又称为Full GC。当年老代空间不够用的时候,虚拟机会使用“标记—清除”或者“标记—整理”算法清理出连续的内存空间,分配对象使用。


12. 永久代


指内存的永久保存区域,主要存放Class 和Meta (元数据)的信息,Class在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class 的增多而胀满,最终抛出OOM异常。

JAVA8与元数据 :

在Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。

元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

类的元数据放入native memory,字符串池和类的静态变量放入java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间来控制。


13. GC(垃圾回收器)


在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

在这里插入图片描述

以下是一些常见的Java垃圾回收器以及它们的特点:

  1. Serial GC (Serial Garbage Collector)

    特点:

    单线程垃圾回收器,适用于单核CPU或小型应用。

    使用标记-清除算法。

    Minor GC和Full GC都是暂停式的,会导致应用程序停顿。

    适用场景:

    适用于简单的命令行工具和小型客户端应用。

    不适用于多核CPU或大规模应用,因为无法充分利用多核处理器。

  2. Parallel GC (Parallel Garbage Collector)

    特点:

    多线程垃圾回收器,适用于多核CPU和多线程应用。

    使用标记-清除算法。

    Minor GC和Full GC都是并行执行的,可以提高垃圾回收效率,但会导致短暂停顿。

    适用场景:

    适用于中等到大型的服务器应用,可以充分利用多核处理器的优势。

    适合需要高吞吐量的应用,但可以容忍短暂的停顿时间。

  3. CMS GC (Concurrent Mark-Sweep Garbage Collector)

    特点:

    并发垃圾回收器,尽量减少停顿时间。

    使用标记-清除算法。

    Minor GC是并行执行的,而Full GC是并发执行的,可以最大程度减少停顿时间。

    适用场景:

    适用于需要低停顿时间的应用,如Web应用。

    但CMS GC可能会导致碎片问题,因此不适合长时间运行的应用。

  4. G1 GC (Garbage-First Garbage Collector)

    特点:

    并发垃圾回收器,尽量减少停顿时间。

    使用G1算法,将堆内存划分为多个区域。

    自动选择需要回收的区域,可以控制停顿时间。

    适用场景:

    适用于需要低停顿时间和可预测性的应用。

    适合大型应用,可通过参数来控制停顿时间和吞吐量。

    每个垃圾回收器都有其优点和局限性,适用于不同的应用场景。选择合适的垃圾回收器通常需要根据应用程序的性能需求、硬件配置和具体的使用情况来进行权衡和调整。在实际应用中,通常需要进行性能测试和调优,以确定最适合的垃圾回收器设置。


14. 垃圾回收算法


14.1 标记清除

分为两个阶段,标记----清除,标记阶段将所有需要回收的对象做标记,然后在清除阶段将所有的标记对象回收

但是这种回收方法有很大的缺点,那就是这两个过程的的效率并不高,两个过程都是效率很低的过程

另外一个缺点就是标记清除之后,因为之前并没有移动对象,每个标记的对象在空间的各个位置,清除之后会有很多不连续的内存,在遇到需要分配一个比较大的对象的时候,会出现虽然总量上有空间容纳,但实际上因为这些内存不连续无法分配一个连续的较大的内存给这个较大对象的情况,而导致系统再次触发一次GC


14.2 复制算法

为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清除完第一块内存,再将第二块上的对象复制到第一块。但是这种方式内存的代价太高,每次基本上都要浪费一半的内存。(空间复用率不高)

于是将该算法进行了改进,内存区域不再是按照1:1去划分,而是将内存划分为8:1:1三部分,较大那份内存交Eden区,其余是两块较小的内存区叫Survior区。每次都会优先使用Eden区,若Eden区满,就将对象复制到第二块内存区上,然后清除Eden区,如果此时存活的对象太多,以至于Survivor不够时,会将这些对象通过分配担保机制复制到老年代中。

这种复制算法,在存活对象比较多的情况下,比如老年代,效率自然就变低.所以产生了接下来这种算法--标记整理


14.3 标记整理

该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。

这算法之前的步骤跟第一个标记清除一样,将对象一一标记,但之后不同的是不对对象进行处理,而是将存活对象向一端移动然后清理另一边的内存,这种算法更适用于老年代

内存效率:复制算法 > 标记清除算法 > 标记整理算法(时间复杂度)
内存整齐度:复制算法 = 标记整理算法 > 标记清除算法
内存利用率:标记整理算法 = 标记清除算法 > 复制算法

14.4 分代收集

现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。

新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。

老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理或者标记-清除。


15. 配置和性能调优


垃圾回收器的性能可以通过调整Java虚拟机的参数来优化,例如初始堆大小、最大堆大小、垃圾回收算法的选择等。性能调优需要根据应用程序的需求和硬件配置来进行。

何时进行JVM调优?

遇到以下情况,就需要考虑进行JVM调优了:

  • Heap内存(老年代)持续上涨达到设置的最大内存值;
  • Full GC 次数频繁;
  • GC 停顿时间过长(超过1秒);
  • 应用出现OutOfMemory等内存异常;
  • 应用中有使用本地缓存且占用大量内存空间;
  • 系统吞吐量与响应性能不高或不降。

可调优参数:

**-Xms:**初始化堆内存大小,默认为物理内存的1/64(小于1GB)。

**-Xmx:**堆内存最大值。默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制。

**-Xmn:**新生代大小,包括Eden区与2个Survivor区。

**-XX:SurvivorRatio=1:**Eden区与一个Survivor区比值为1:1。

**-XX:MaxDirectMemorySize=1G:**直接内存。报java.lang.OutOfMemoryError: Direct buffer memory异常可以上调这个值。

**-XX:+DisableExplicitGC:**禁止运行期显式地调用System.gc()来触发fulll GC。

**Xss:**线程栈最大值


16. JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代


**思路:**先描述一下Java堆内存划分,再解释Minor GC,MajorGC, full GC,描述它们之间转化流程。

  • Java堆=老年代+新生代
  • 新生代= Eden + S0 + S1
  • 当Eden区的空间满了,Java虚拟机会触发一次Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到Survivor区。
  • 大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年态;
  • 如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一次Minor GC,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年态。
  • 老年代满了而无法容纳更多的对象,MinorGC之后通常就会进行Full GC,Full GC清理整个内存堆-包括年轻代和年老代。
  • Major GC发生在老年代的GC,清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上。

**思路:**先描述一下Java堆内存划分,再解释Minor GC,MajorGC, full GC,描述它们之间转化流程。

  • Java堆=老年代+新生代
  • 新生代= Eden + S0 + S1
  • 当Eden区的空间满了,Java虚拟机会触发一次Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到Survivor区。
  • 大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年态;
  • 如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一次Minor GC,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年态。
  • 老年代满了而无法容纳更多的对象,MinorGC之后通常就会进行Full GC,Full GC清理整个内存堆-包括年轻代和年老代。
  • Major GC发生在老年代的GC,清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值