JVM(一)内存模型

165 阅读15分钟

什么是JVM

JVM 是Java Virtual Machine (Java虚拟机) 的缩写,JVM是一种用于计算设备的规范,它是一个虚拟出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改的运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,只需要需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java编译程序只需要生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改的运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java能够“一次编译,到处运行”的原因。

JVM的组成

image.png

图片中是JDK1.6、1.7、1.8的内存模型演变过程,其实这个内存模型就是JVM运行时数据区按照JVM虚拟机规范的具体实现过程。

各个版本的迭代都是为了更好的适应CPU性能提升,最大限度提升的JVM运行效率。这些版本的JVM内存模型主要有以下差异:

  • JDK 1.6: 有永久代,静态变量存放在永久代上。
  • JDK 1.7: 有永久代,但已经把字符常量池、静态变量,存放在堆上。逐渐的减少永久代的使用。
  • JDK 1.8: 无永久代,运行时常量池、类常量池,都保存在元数据区,也就是常说的元空间。但字符串常亮池仍然存放在堆上。

那为什么一直在修改永久代,继而之后干脆都取消了永久代呢?

永久代与方法区的关系

如果在HotSpot虚拟机上开发,部署,方法区是规范,永久代是HotSpot针对该规范进行的实现。在JDK1.7及以前的版本中,方法区都是永久代实现的。

元空间与方法区的关系

对于Java8而言,HotSpot取消了永久代,取而代之的是元空间(MetaSpace),换言之,就是方法区是在的,只是实现方式由原来的永久代变成了现在的元空间了。

使用元空间替换永久代的原因

image.png

如图所示,永久代的方法区和堆使用的物理内存是连续的。

永久代的大小配置:

  • -XX:PermSize:设置永久代的初始大小。
  • -XX:MaxPermSize:设置永久代的最大值,默认是64M。

对于永久代,如果动态生成很多的class的时候,很有可能出现java.lang.OutOfMemoryError: PermGen Space错误,这是因为永久代空间配置的大小有限。

在典型的单一应用中,需要编写和加载很多的jsp页面,就会出现java.lang.OutOfMemoryError。

在JDK1.8版本之后,方法区存在于元空间(MetaSpace)。物理内存不再与堆连续,而是直接存在于本地内存中,理论上,机器内存有多大,元空间就有多大。

image.png

基于上图可知,元空间存在于本地内存中,我们可以通过一些参数对元空间的大小进行配置:

  • -XX:MetaspaceSize: 初始空间大小,达到这些值就会触发垃圾回收器进行类型卸载,同时GC会对该值进行调整;如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • -XX:MaxMetaspaceSize: 最大空间,默认是没有限制的。
  • -XX:MinMetaspaceFreeTatio:在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾回收。
  • -XX: MaxMetaspaceFreeRatio: 在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间锁导致的垃圾回收。

综上所述,表面上是为了避免OOM异常,因为通常使用PermSize和MaxPerSize设置了永久代的大小上限,但是不是总能设置到刚刚合适的大小,而使用默认值是容易遇到OOM错误。当使用元空间时,可以加载多少类的元数据就不再由MaxPermSize控制,而是由系统的实际可用空间来控制。

JVM 中各个区域的作用

JVM内存模型主要是指运行时数据区。JVM主要包含: 运行时数据区、类装载系统、字节码执行引擎、本地方法接口。

image.png

运行时数据区又区分为堆、方法区、线程栈(虚拟机栈)、本地方法栈、程序计数器。

咱们从上往下说

类加载器

类加载器简单的说就是JVM通过类加载器ClassLoader,把class文件中的信息,拼装成Class对象放入内存中。

类加载的过程

类的加载主要有三步:加载->连接->初始化。连接的过程又分为:验证-> 准备-> 解析

加载(Load)

指的是类加载,即 Class Loading,虚拟机加载完成三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

连接(Linking)

链接分为三大步骤:验证、准备、解析。

验证(Verification)

为了确保Class文件的字节流中包含的信息符合虚拟机要求,并且不会危害虚拟机,所以验证分为四大验证阶段,分别为:文件格式验证、元数据验证、字节码验证、符号引用验证。

image.png

注意:

一个类的方法的字节码没有通过字节码验证,那肯定是有问题;如果一个方法体通过字节码验证,也不能表示一定就是安全的;因为程序去校验程序逻辑是无法做到绝对准确。

准备(Preparation)

目的: 正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

以下参考:深入理解 java 虚拟机第三版

image.png

解析(Resolution)

解析目的:Java虚拟机将常量池的符号引用替换为直接引用的过程,相关解析有:类或接口的解析、字段解析、方法解析、接口方法解析。

什么是符号引用?

以一组符号来描述所引用的目标,符号可以是在任何形式的字面量,只要使用时能无歧义地定位到目标即可。

什么是直接引用?

可以直接执行目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。区别于符号引用就是直接引用必须引用的目标已经在内存中存在。

初始化(Initialization)

初始化是类加载过程中最后一步,初始化目的:根据程序员程序编码指定的主观计划去初始化类变量和其他资源。

Java ClassLoader(类加载器)分类

Bootstrap Classloader(启动类加载器)

最顶层的加载类,由C或C++语言实现。主要加载核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。另外需要注意的是可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。

Extension ClassLoader(扩展类装载器)

主要负责加载Java的扩展类库,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-D java.ext.dirs选项指定的目录。

System Classloader(系统装载器)

也称为Appclass Loader 加载当前应用的classpath的所有类。

image.png

加载顺序

Bootstrap Classloader->Extension ClassLoader->system classloader

image.png

代码实现

public class ClassLoaderTest {
    public static void main(String[] args) {
        //获取系统类加载器 sun.misc.Launcher$AppClassLoader@18b4aac2
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);

        //获取拓展类加载器 sun.misc.Launcher$ExtClassLoader@14ae5a5
        ClassLoader extClassLoader = systemClassLoader.getParent();
        System.out.println(extClassLoader);

        //获取根类加载器 null
        ClassLoader bootstrapClassLoader = extClassLoader.getParent();
        System.out.println(bootstrapClassLoader);

        //用户自定类加载器 sun.misc.Launcher$AppClassLoader@18b4aac2(使用系统加载器进行加载)
        ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
        System.out.println(classLoader);
        //获取strng 类加载器 null(使用引导类加载器)java核心都是使用该种加载方式
        ClassLoader stringClassLoader = String.class.getClassLoader();
        System.out.println(stringClassLoader);
    }
}

结果

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@14ae5a5
null
sun.misc.Launcher$AppClassLoader@18b4aac2
null

个人理解:引导类加载器和自定义类加载器,自定义类就像你自己的手机随时想用就用,或者你家人想用直接向你借就OK了,但是引导类就像某个大领导的个人手机,你跟你家人一般是无法直接借到的(基本不可能)。

全盘负责委托机制是什么?

  • 全盘委派模型是双亲委派模型的扩展。它允许类加载器在加载类时不仅仅委派给父加载器,还可以自行加载一些类。这样,类加载器可以选择性地覆盖某些类的加载过程。
  • 全盘委派模型允许类加载器自行定义加载顺序,而不是完全遵循双亲委派模型的规则。这种灵活性使得在某些情况下可以实现一些特定需求,例如在运行时动态加载类。

image.png

双亲委派是什么?

  • 双亲委派模型是Java类加载机制的核心。根据这个模型,类加载器在尝试加载一个类时,首先会委托给其父类加载器去加载,如果父类加载器无法找到该类,才会尝试自己加载。
  • 这个模型的优势在于安全性和一致性。通过父类加载器加载的类可以在整个类加载层次结构中共享,这有助于避免不同类加载器加载相同的多个副本,并降低了类加载的风险。
  • 具体来说,Java类加载器层次结构通常包括三个层次:启动类加载器、扩展类加载器和应用程序类加载器。每个类加载器在尝试加载类时都会先委派给其父类加载器,直到达到根加载器(启动类加载器)。如果根加载器无法加载,就会回到应用程序类加载器;然后再尝试加载。

Jvm运行时数据区

Jvm运行时数据区有:方法区、堆区、栈、程序计数器、本地方法区

方法区

方法区从1.7到1.8已经从jvm的内存区域中去掉,而是直接使用计算机的内存。

主要用来存放类元信息,变量名,静态变量,方法名,方法代码,访问权限,返回值,运行时常量池等等。

方法区是所有线程共享区域

  1. 对象实例数据: JVM堆是用来存储Java对象实例的主要区域。这包括应用程序中创建的类的实例,以及类的字段和成员变量。
  2. 数组: 数组也是Java对象的一种,它们在堆中存储,可以包括基本数据类型的数组和对象类型的数组。
  3. 垃圾回收信息: 堆中包含了垃圾回收所需的信息,如对象引用关系,用于标记和清理不再被引用的对象。
  4. 元空间(Metaspace): 在JDK1.8中,堆区不再包含类数据,而是类元数据(Class Metadata)通常被存储在元空间中。元空间是一个本机内存区域,用于存储类加载器、类、方法、字段等元数据信息。相对于JDK1.7中的永久代(Permanent Generation),元空间具有更高的灵活性,允许动态分配内存以适应不断加载的类。

堆区是所有线程共享区域,也是垃圾回收器的主要工作区域。

导致堆区可能会发生内存溢出的情况:

  1. 对象过多: 当应用程序创建大量对象实例,并且堆内存不足以容纳这些对象时,就会 导致堆内存溢出。
  2. 内存泄漏: 如果应用程序中的对象不被正确释放,即使堆内存有限,也可能导致内存泄漏。内存泄漏会导致堆内存逐渐耗尽,最终引发内存溢出错误。
  3. 大对象: 创建大型的对象或数组,其大小超出了堆内存的可用空间,也可能导致堆内存溢出。
  4. 持久对象: 有些对象可能在堆中长时间存活,例如,缓存对象或长时间运行的会话对象,这可能会导致堆内存过度占用,最终导致内存溢出。

虚拟机栈

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame),对应着一次次的Java方法调用,其中栈的特点如下:

  1. 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
  2. Jvm直接对Java栈的操作只有两个:每个方法执行伴随着入栈和执行结束后的出栈。
  3. 对于栈来说不存在垃圾回收问题GC,但存在内存溢出问题OOM

虚拟机栈是线程独享的,并且在jdk1.8中默认大小为1MB

本地方法栈

和虚拟机栈相似,其区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

程序计数器

  1. 较小的内存空间,线程私有,记录当前线程所执行的字节码行号。
  2. 如果执行Java方法,计数器记录虚拟机字节码当前指令的地址,本地方法则为空。
  3. 这一块区域没有任何OutOfMemoryError定义

字节码执行引擎

负责执行 Java 字节码的核心组件。它的作用是将编译后的 Java 字节码指令翻译成本地机器码并执行它们。字节码执行引擎是 JVM 的关键组成部分之一,它扮演了以下重要作用:

  1. 字节码解释执行: 字节码执行引擎负责解释执行字节码指令,逐条执行字节码程序。这意味着它会逐条分析字节码指令,将其翻译成本地机器码并执行。这种方式使得 Java 程序在不同的平台上都能运行,因为字节码是跨平台的。
  2. 即时编译(Just-In-Time Compilation,JIT): 为了提高性能,字节码执行引擎通常会使用即时编译器,将频繁执行的字节码编译成本地机器码,从而减少字节码解释执行的开销。这样,Java 程序的性能可以与本地代码相媲美。
  3. 垃圾回收协调: 字节码执行引擎与 JVM 的垃圾回收子系统协同工作,确保内存管理的一致性。它会在适当的时候协助垃圾回收器标记和清理不再被引用的对象。
  4. 安全性检查: 字节码执行引擎执行字节码指令时,会进行各种安全性检查,以确保程序不会访问越界的内存,不会执行非法操作,以及不会破坏 JVM 的安全性。
  5. 多线程协调: 字节码执行引擎支持多线程程序的执行,它会协调线程之间的并发执行,确保多线程程序的正确性和一致性。

总的来说,字节码执行引擎是 JVM 的核心组件之一,它是 Java 跨平台性和性能的关键保障。它通过解释执行和即时编译,实现了 Java 程序的高效执行,同时也提供了一些重要的安全性和内存管理功能。

本地方法接口

JVM(Java Virtual Machine)中的本地方法接口(Native Method Interface,通常缩写为 JNI)是一种用于与本地代码(通常是使用C、C++或其他本地编程语言编写的代码)进行交互的机制。它的主要作用包括以下几点:

  1. 与本地代码交互: 本地方法接口允许 Java 程序与本地代码进行交互。这对于使用 Java 编写的应用程序来说是非常重要的,因为它们可以调用本地库中的函数,以便访问底层系统资源或执行与 Java 不擅长的操作(如硬件访问)。
  2. 跨平台兼容性: JNI 提供了一种机制,使 Java 程序可以在不同的操作系统和硬件平台上访问本地功能。虽然 Java 是跨平台的,但某些任务需要与特定平台的本地代码进行交互。JNI 允许 Java 程序在不同平台上调用相同的本地方法。
  3. 性能优化: JNI 允许 Java 程序通过本地方法调用来实现性能优化。本地代码通常比 Java 代码更接近硬件,并且可以使用底层系统调用,以提高性能和效率。例如,JNI 可用于与操作系统API、硬件驱动程序或性能敏感的计算库进行交互。
  4. 资源管理: JNI 还可以用于管理本地资源,例如文件句柄、内存区域、网络套接字等。Java 程序可以使用 JNI 来创建、释放和管理这些资源,以确保资源的有效使用和释放。

需要注意的是,使用 JNI 需要小心,因为它涉及到与本地代码的交互,可能引入内存泄漏、安全漏洞和其他问题。正确地编写和测试 JNI 代码是至关重要的,以确保它不会导致应用程序崩溃或不稳定。