详解JVM内存模型

了解JVM内存模型无论对于自己写出高质量的代码,还是调试BUG来说都是非常必要的。本文当做对JVM内存模型的一个总结或者记录。

一、JVM内存模型图

avatar

主要是分为五大块,分别是:JAVA堆、JAVA栈、方法区、本地方法栈和程序计数器。下面对各个区域的性质做出一些简要的说明:

1. JAVA堆
  1. 线程共享的,存放所有对象实例(包括实例变量)和数组。在虚拟机启动时创建。
  2. 生命周期与虚拟机相同,可以不使用连续的内存地址。
  3. 垃圾回收的主要区域。根据分代收集算法可以分为新生代和老年代。
  4. 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
2. JAVA栈
  1. 线程私有的,它的生命周期与线程相同。
  2. 每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  3. 栈的大小可以是固定的,或者是动态扩展的。如果请求的栈深度大于最大可用深度,则抛出StackOverflowError;如果栈是可动态扩展的,但没有内存空间支持扩展,则抛出OutofMemoryError。

下面对2.2中提到的几个概念做出说明:

栈帧

  1. 帧里面存放的是方法的局部变量,操作数栈,动态链接,方法返回地址和一些额外的附加信息组成。
  2. 活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

栈帧的结构如下图:

avatar

局部变量表

  1. 用于存放方法参数和方法内部定义的局部变量。
  2. 基本数据类型、对象引用和returnAddress 类型。

操作数栈

存储程序执行过程中的具体数据。

3. 方法区
  1. 方法区是线程安全的,各个线程共享该内存区域。
  2. 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  3. 运行时常量池(Runtime Constant Pool)是方法区的一部分,存储内容主要是编译期生成的各种字面量和符号引用。
  4. 方法区的大小不必是固定的,JVM可根据应用需要动态调整。
  5. 不需要连续的内存,可以选择固定大小或者可扩展,还可以选择不实现垃圾收集。
  6. 方法区也可被垃圾收集,当某个类不在被使用(不可触及)时,JVM将卸载这个类,进行垃圾收集。
  7. 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
4. 本地方法栈

为虚拟机使用到的Native 方法服务。

5. 程序计数器
  1. 每个线程有要有一个独立的程序计数器,记录下一条要运行的指令。
  2. 线程私有的内存区域。如果执行的是JAVA方法,计数器记录正在执行的java字节码地址,如果执行的是native方法,则计数器为空。
  3. 此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

总结:堆和方法区都是线程共享的,而栈和程序计数器都是线程私有的。

二、JVM体系结构

类装载器ClassLoader:用来装载.class文件

执行引擎:执行字节码,或者执行本地方法

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

虚拟机的类加载机制:JVM把描述类数据的字节码.Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。

三、JVM原理

​ JVM是java的核心和基础,在java编译器和OS平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行java的字节码程序。java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

四、JVM执行程序的过程

三步:

  1. 加载.class文件

  2. 管理并分配内存

  3. 执行垃圾收集

四步完成JVM环境:

  1. 创建JVM装载环境和配置

  2. 装载JVM.dll

  3. 初始化JVM.dll并挂界到JNIENV(JNI调用接口)实例

  4. 调用JNIEnv实例装载并处理class类

五、JVM的生命周期

JVM实例和JVM执行引擎实例:

  1. JVM实例对应了一个独立运行的java程序——进程级别

    一个运行时的Java虚拟机(JVM)负责运行一个Java程序。

    当启动一个Java程序时,一个虚拟机实例诞生;当程序关闭退出,这个虚拟机实例也就随之消亡。

    如果在同一台计算机上同时运行多个Java程序,将得到多个Java虚拟机实例,每个Java程序都运行于它自己的Java虚拟机实例中。

  2. JVM执行引擎实例则对应了属于运行程序的线程——线程级别

JVM的生命周期:

  1. 诞生

    当启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。

  2. 运行

    main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以标明自己创建的线程是守护线程。

  3. 消亡

    当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用java.lang.Runtime类或者java.lang.System.exit()来退出。

六、类加载机制

类加载时机

当应用程序启动的时候,并不是所有的类一次性被加载完毕,因为如果一次性加载,内存资源有限,可能会影响应用程序的正常运行。那类什么时候被加载呢?例如,A a=new A(),一个类真正被加载的时机是在创建对象的时候,才会去执行以上过程,加载类。当我们测试的时候,最先加载拥有main方法的主线程所在类。

类初始化时机

A.主动引用的五种情况(发生类初始化过程):

  1. 使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。
  2. 通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。
  3. 当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。
  4. 当虚拟机启动时,用户需要指定一个主类(包含main()方法的类),虚拟机会首先初始化这个类。
  5. 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。

B.被动引用的情况(不会发生类的初始化):

  1. 当访问一个静态变量时,只有真正声明这个变量的类才会初始化。(子类调用父类的静态变量,只有父类初始化,子类不初始化)。
  2. 通过数组定义类引用,不会触发此类的初始化。
  3. 静态常量不会触发此类的初始化,因为在编译阶段就存储在常量池中,不会引用到定义常量的类。
  4. 静态常量不会触发此类的初始化,因为在编译阶段就存储在常量池中,不会引用到定义常量的类

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括了:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称链接

avatar

1.加载(重点)

加载阶段是“类加载机制”中的一个阶段,这个阶段通常也被称作“装载”,主要完成:

1.通过“类全名”来获取定义此类的二进制字节流

2.将字节流所代表的静态存储结构转换为方法区的运行时数据结构

3.在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口

2.验证(了解)

验证是链接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

3.准备(了解)

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点,首先是这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量定义为:

1
public static int value  = 12;

那么变量value在准备阶段过后的初始值为0而不是12,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器方法之中,所以把value赋值为12的动作将在初始化阶段才会被执行。

上面所说的“通常情况”下初始值是零值,那相对于一些特殊的情况,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,建设上面类变量value定义为:

1
public static final int value = 123;

编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value设置为123。

4.解析(了解)

解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。

直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

解析的动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。分别对应编译后常量池内的CONSTANT_Class_Info、CONSTANT_Fieldref_Info、CONSTANT_Methodef_Info、CONSTANT_InterfaceMethoder_Info四种常量类型。

1.类、接口的解析

2.字段解析

3.类方法解析

4.接口方法解析

5.初始化(了解)

类加载最后阶段,若该类具有超类,则对其进行初始化,执行静态初始化器和静态初始化成员变量(如前面只初始化了默认值的static变量将会在这个阶段赋值,成员变量也将被初始化)。

类加载器的任务是根据一个类的全限定名来读取此类的二进制字节流到JVM中,然后转换为一个与目标类对应的java.lang.Class对象实例,在虚拟机提供了4种类加载器,启动(Bootstrap ClassLoader)类加载器、扩展(Extension ClassLoader)类加载器、应用程序(Application ClassLoader)类加载器、自定义(User ClassLoader)类加载器。

  1. 启动类加载器:这个类加载器负责放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库。用户无法直接使用。

  2. 扩展类加载器:这个类加载器由sun.misc.Launcher$AppClassLoader实现。它负责$JAVA_HOME\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。用户可以直接使用。

  3. 应用程序类加载器:这个类由sun.misc.Launcher$AppClassLoader实现。是ClassLoader中getSystemClassLoader()方法的返回值。它负责用户路径(ClassPath)所指定的类库。用户可以直接使用。如果用户没有自己定义类加载器,默认使用这个。

  4. 自定义加载器:用户自己定义的类加载器。

6.使用

正常使用。

7.卸载

GC把无用对象从内存中卸载。

七、JVM各区域潜在异常

程序计数器:

此区域是JVM规范中唯一一个不存在OOM(OutOfMemory)的区域。

JAVA栈:

  1. StackOverflowError :栈深度大于虚拟机所允许的深度。

  2. OOM :如果虚拟机栈可以动态扩展(当前大部分Java虚拟机都可以动态扩展,只不过Java虚拟机规范中的也允许固定长度的虚拟机栈),如果扩展是无法申请到足够的内存。

JAVA堆:

OOM: 堆无法扩展时。

本地方法栈:

  1. StackOverflowError :栈深度大于虚拟机所允许的深度。

  2. OOM

方法区:

OOM

坚持原创技术分享,您的支持将鼓励我继续创作!

------本文结束 感谢您的阅读------