五、JVM(1)

173 阅读14分钟

一、 JVM内存结构

image-20230213205149650.png

二、JVM内存区域(运行时数据区)

67d08f01b2794005ba6b9cd19f67b667.png

JVM架构图

20210221160754132.png

  1. 一个Class File文件首先经过ClassLoader的加载、链接(包括:验证、准备、解析)、初始化加载到元空间,一些符号引用被解析为直接引用或等到运行时分派(动态绑定)。   
  2. 然后程序通过class对象来访问元空间里的各种类型数据,当加载完之后,执行引擎发现main方法,也就是程序入口,那么就会创建相应的栈帧,执行引擎逐行读取方法内的代码转换为机器码,而这些指令大多已经被解析为直接引用了,那么执行引擎通过持有这些直接引用去元空间寻找变量对应的字面量来进行方法操作。
  3. 完成操作后,栈帧出栈,内存空间被GC回收。

三、类加载机制

3.1【1.8以及之前版本】

  • 启动类加载器(Bootstrap Class Loader)

    该加载器使用C++实现(不会继承ClassLoader),是虚拟机自身的一部分。该类加载器主要是负责加载存放在JAVA_HOME\lib目录,或者被-Xbootclasspath参数指定路径存放的,并且是java虚拟机能识别的类库加载到虚拟机内存中。(eg:主要是加载java的核心类库,即加载lib目录下的所有class)

  • 扩展类加载器(Extension Class Loader)

    这个类加载器主要是负责加载JAVA_HOME\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有类库

  • 应用类加载器(Application Class Loader)

    这个类加载器是由sun.misc.Launcher$AppClassLoader来实现,因为该加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以一般也称为该加载器为系统类加载器。该加载器主要是加载用户类路径上所有的类库,如果应用程序中没有定义过自己的类加载器,一般情况下这个就是程序的默认加载器。

在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

好处:

(1)避免了类的重复加载

(2)保护了程序的安全性,防止核心的API被修改

3.2【1.9以及之后版本】

在java9以及以后的版本中,为了模块化系统的顺利实施,模块下的类加载器主要有几个变动:

(1)扩展类加载器(Extension Class Loader)被平台类加载器(Platform ClassLoader)取代(java9中整个JDK都是基于了模块化的构建,原来的rt.jar和tools.jar都被拆分了数十个JMOD文件)。因为java类库可以满足扩展的需求并且能随时组合构建出程序运行的jre,所以取消了JAVA_HOME\lib\ext和JAVA_HOME\jre目录

(2)平台类加载器和应用程序类加载器都不在派生自java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。

就是说平台以及应用程序类加载器收到类的加载请求的时候,在委派给父类加载器家在之前,要先判断该类是否归属于摸一个系统模块,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。如下图:

1.9类加载器.png

3.3、如果不想用双亲委派模型怎么办?

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

3.4、为什么说 Java SPI 的设计会违反双亲委派原则呢?

首先双亲委派原则本身并非 JVM 强制模型。

Java 在核心类库中,定义了许多接口,并给出了针对这些接口的调用逻辑,但未给出接口实现。核心类库由启动类加载器加载。接口的实现类交由开发者实现,然而实现类不会被启动类加载器所加载,基于双亲委派的可见性原则,SPI 调用方无法拿到实现类。

SPI Serviceloader 通过线程上下文获取能够加载实现类的classloader,一般情况下是 application classloader,绕过了双亲委派模型的层级限制,逻辑上打破了双亲委派原则。

四、运行时数据区

4.1 虚拟机栈

image-20230213222902833.png

栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

4.1.1、局部变量表

主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

4.1.2、操作数栈

主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

4.1.3、动态链接

主要服务一个方法需要调用其他方法的场景。在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。

4.1.4、方法返回地址

方法正常退出或异常退出的地址,无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛StackOverFlowError 错误。程序运行中栈可能会出现的错误:

(1)StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

(2)OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

4.2 本地方法栈

和虚拟机栈所发挥的作用非常相似,一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

虚拟机栈和本地方法栈为什么是私有的?

为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

4.3 堆

Java 虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。几乎所有的对象实例以及数组都在这里分配内存。 ​ Java 世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从JDK1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

image-20230213230342541.png

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

(1)年轻代(Young Generation) 年轻代又分为伊甸园(Eden)和幸存区(Survivor区);幸存区又分为S0(From Survivor)空间和S1( To Survivor)空间。

(2)老年代(Old Generation)

(3)永久代(Permanent Generation)

JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存 。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

4.4 方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

它存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。

image-20230213230511638.png

4.4.1 方法区和永久代以及元空间是什么关系?

方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

4.4.2 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

(1)整个永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace 

可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。

-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。 

(2)元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

(3)在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

4.4.3 方法区常用参数有哪些?

-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。下面是一些常用参数:

  -XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
  -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

4.5 程序计数器

程序计数器主要有两个作用:

(1)字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

(2)在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

⚠️ 注意 :

(1)如果执行的是 native 方法,那么程序计数器记录的是 undefined地址,只有执行的是Java代码时程序计数器记录的才是下一条指令的地址。所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。

(2)程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

4.6 总结

① 类加载器 如果 JVM 想要执行这个 .class 文件,我们需要将其装进一个 类加载器 中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进JVM里面来。

② 方法区 方法区 是用于存放类似于元数据信息方面的数据的,比如类信息,常量,静态变量,编译后代码···等 类加载器将 .class 文件搬过来就是先丢到这一块上

③ 堆 堆主要放了一些存储的数据,比如对象实例,数组···等,它和方法区都同属于 线程共享区域 。也就是说它们都是 线程不安全 的

④ 栈 栈是我们的代码运行空间。我们编写的每一个方法都会放到 栈 里面运行。我们会听说过 本地方法栈 或者 本地方法接口 这两个名词,不过我们基本不会涉及这两块的内容,它俩底层是使用C来进行工作的,和Java没有太大的关系。

⑤ 程序计数器 主要就是完成一个加载工作,类似于一个指针一样的,指向下一行我们需要执行的代码。和栈一样,都是 线程独享 的,就是说每一个线程都会有自己对应的一块区域而不会存在并发和多线程的问题。

虚拟机主要的5大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行