一、引言

1.什么是JVM

JVM(Java Virtual Machine)是Java平台的核心组件之一,它是一个在计算机上运行Java字节码的虚拟机。JVM是Java语言的核心特性之一,它提供了Java程序的跨平台能力,因为Java代码可以在任何能够运行JVM的操作系统上运行。

JVM负责将Java源代码编译成字节码,然后在运行时解释执行这些字节码。JVM还负责内存管理、垃圾回收、安全性、异常处理等方面的任务。

2.JVM、JRE和JDK

JRE(Java Runtime Environment)、JVM(Java Virtual Machine)和JDK(Java Development Kit)是三个Java平台中不同的组件。

JVM是Java平台的核心组件之一,它是一个在计算机上运行Java字节码的虚拟机。JVM负责将Java源代码编译成字节码,然后在运行时解释执行这些字节码。JVM还负责内存管理、垃圾回收、安全性、异常处理等方面的任务。

JRE是Java平台的一部分,包含了Java应用程序所需要的运行时环境,包括JVM以及Java标准库中的类和资源文件。JRE可以用于在计算机上运行Java应用程序,但是不能用于编写Java程序。

JDK是Java开发工具包,它包含了JRE以及编写Java程序所需要的开发工具,包括编译器、调试器和其他实用工具。JDK提供了完整的Java开发环境,可以用于编写、编译和测试Java程序。

因此,JRE是Java应用程序运行时环境,JVM是在其中运行字节码的虚拟机,而JDK是开发Java应用程序所需的完整工具集。JRE和JDK都包含JVM,但是JDK还包含了其他的开发工具。

3.整体结构

image-20230516130943216

二、类加载机制

1.加载流程一览

  • 加载(Loading):将类的字节码文件加载到JVM内存中。类的字节码可以来自本地磁盘、网络或其他设备,由类加载器负责加载。类加载过程的核心任务是读取字节码文件并创建一个代表该类的Class对象。
  • 链接(Linking):将类的二进制数据合并到JVM的运行时环境中。链接过程可以分为三个阶段:
    • 验证(Verification):确保类的字节码文件符合JVM规范,不会危害JVM的安全。验证过程包括文件格式验证、字节码验证、符号引用验证和访问权限验证。
    • 准备(Preparation):为类的静态变量分配内存并设置默认初始值。例如,int类型的静态变量默认值为0,对象类型的静态变量默认值为null。
    • 解析(Resolution):将符号引用转换为直接引用。例如,将一个类名转换为对应的Class对象。
  • 初始化(Initialization):执行类构造器方法(),这个方法由编译器自动添加到类中。类构造器方法包括静态变量的初始化和静态代码块的执行。类初始化过程是在程序运行期间动态地执行的。

2.加载

加载是class文件进入虚拟机要经历的第一个阶段,它的主要任务是将编译后的.class文件加载到JVM中,生成对应的Class对象。加载过程可以分为以下三个步骤:

  • 读取:通过类的全限定名获取定义该类的二进制字节流。JVM通过类加载器查找类的过程来获取该类的字节码数据。
  • 转化:将字节流所代表的静态存储结构转化为方法区的运行时数据结构。在将字节流转换成运行时数据结构之前,需要对字节流进行一定的校验,确保其符合JVM规范和安全性要求。
  • 入口:在内存中生成一个代表该类的java.lang.Class对象,作为该类访问入口。同时,将该类的Class对象存放到方法区中,并建立相应的引用关系,使得JVM可以通过该Class对象访问到类的方法、变量等相关信息。

概述一下,加载的作用就是将编译后的Java类文件加载到JVM中,并在方法区中生成一个代表该类的Class对象,以便于JVM在运行时进行访问和使用

3.链接

链接的作用是将编译后的Java类文件中的符号引用转换为直接引用,以便于JVM在运行时能够正确地访问和使用类的方法、变量等相关信息

链接主要包括三个步骤,如下所示

  • 验证:确保类文件的字节流中包含的信息符合JVM规范和安全性要求,比如检查类文件的格式、语法、继承关系等
  • 准备:为类中定义的所有静态变量(static)分配内存,并赋上默认值
  • 解析:将编译时生成的符号引用(Symbolic Reference)转化为直接引用(Direct Reference),使得虚拟机能够正确地定位并访问类的方法、变量等相关信息

链接阶段最主要的作用就是将类文件中的符号应用转化为直接引用,那么什么是符号引用,什么又是直接引用呢?

3.1 符号引用

首先看比较官方的定义,符号引用主要包括以下信息:

  • 类的全限定名:用于唯一地标识一个类。
  • 方法的名称和描述符:用于描述方法的名称和参数类型、返回值类型等信息。
  • 变量的名称和描述符:用于描述变量的名称和类型信息。

是不是还有点懵?举个例子

1
2
3
4
5
6
public class Example {
public static void main(String[] args) {
String str = "Hello World";
System.out.println(str);
}
}

在这段代码中,StringSystemout等都是符号引用,printlnstr也是符号引用。具体来说:

  • String是一个类的符号引用,用于描述str变量的类型。它的全限定名是java.lang.String
  • System是一个类的符号引用,用于描述out变量的类型。它的全限定名是java.lang.System
  • out是一个静态变量的符号引用,用于描述println方法所输出的目标流。它的名称是out,描述符是Ljava/io/PrintStream;,表示一个PrintStream类型的对象引用
  • println是一个方法的符号引用,它的名称是println,描述符是(Ljava/lang/String;)V,表示一个接受一个String类型参数,返回值为void的方法
  • str是一个局部变量的符号引用,它的名称是str,描述符是Ljava/lang/String;,表示一个String类型的对象引用

简单点说,在编程中用到的类名,变量名,方法名,全是符号引用…

但是需要注意的是,类加载阶段是对静态变量进行初始化的阶段,普通方法内的局部变量只有在调用方法时才会初始化

3.2 直接引用

直接引用是可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。与符号引用相比,直接引用是一种较为底层的引用方式,它指向具体的内存地址或数据结构

所以将符号引用转换为直接引用后,虚拟机就可以直接访问到对应的内存地址了,可以加速访问速度

而这也是为什么解析这一步位于准备后面,因为在准备阶段才为静态变量分配内存,分配了内存之后才能进行符号引用转为直接引用

但是此时的静态变量只是被赋值给了默认值,还没有将代码中要赋的值交给静态变量,这个工作交给下一个阶段——初始化来完成

4.初始化

初始化是类加载机制的最后一个阶段,主要是为静态变量赋予初始值,并执行类构造器()方法的过程

那么到这里要产生疑惑了,静态变量赋予初值看得懂,执行类构造器()方法是什么意思呢?先看看类初始化的步骤

  • 虚拟机为类变量分配内存,并赋予默认值。对于基本类型,内存空间被初始化为0或false,对于引用类型,内存空间被初始化为null。
  • 如果静态变量在声明时就被赋值了,那么虚拟机会将这些赋值语句放到类构造器()方法中,并在初始化时执行这些语句。如果静态变量在静态代码块中被赋值了,那么虚拟机也会将这些赋值语句放到()方法中,并在初始化时执行。
  • 如果类存在父类,则会先初始化父类的()方法。
  • 如果静态变量的初始化涉及到其他类或接口,那么会先对这些类或接口进行初始化。
  • 最后,虚拟机会执行类构造器()方法的代码,包括所有静态变量初始化语句和静态代码块中的语句。如果类没有定义()方法,那么这个过程就是空的

定义:类构造器()方法是由编译器自动收集类中所有静态变量的赋值语句和静态代码块中的语句合并而成的

主要包含两个部分

  • 静态变量
  • 静态代码块

所以总结一句话:初始化阶段就是为静态变量和静态代码块赋予初始值的过程

三、类加载器

类加载器是 Java 虚拟机的一个重要组成部分,它的作用是将 Java 类文件加载到 JVM 中,并生成对应的 Java 类对象,为 Java 应用程序提供了动态加载类的功能

1.分类

ava 虚拟机内置了三种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):也称为引导类加载器,负责加载 Java 的核心类库,如 rt.jar 等。
  2. 扩展类加载器(Extension ClassLoader):也称为系统类加载器,负责加载 Java 的扩展类库,如 $JAVA_HOME/lib/ext 目录下的 jar 包。
  3. 应用程序类加载器(Application ClassLoader):也称为用户自定义类加载器,负责加载应用程序的类,也就是在程序中自己编写的类。

除了上述三种内置类加载器外,Java 还提供了一些可以自定义的类加载器,例如网络类加载器(NetworkClassLoader)和自定义类加载器(CustomClassLoader)等,用于满足一些特殊的加载需求。

2.双亲委派机制

2.1 概念

双亲委派机制是一种类加载器的工作机制,其主要思想是在类加载的过程中,每个类加载器都会先委派给其父类加载器进行加载,只有当父类加载器无法完成加载任务时,子类加载器才会尝试自行加载

这种机制的主要优势在于避免重复加载和类的版本冲突问题。当一个类加载器需要加载某个类时,它首先会将该请求委派给父类加载器处理。如果父类加载器能够完成加载任务,就直接返回该类对象;如果父类加载器无法完成加载任务,则会将该请求再委派给其父类加载器,直到委派到最顶层的启动类加载器为止。如果最终仍无法找到该类的定义,则会抛出 ClassNotFoundException 异常。

通过双亲委派机制,可以保证在 JVM 运行过程中,同一个类在不同的类加载器中只会被加载一次,避免了类的重复加载和版本冲突问题。此外,双亲委派机制还可以保证核心类库的安全性,因为核心类库都是由启动类加载器加载的,防止了恶意代码篡改核心类库的情况

那结合上面介绍过的类加载器分类,可以得到在JVM当中的类加载器启动流程

  • 当应用程序需要加载一个类时,它首先会请求应用程序类加载器进行加载
  • 应用程序类加载器会将该请求委托给扩展类加载器进行加载
  • 扩展类加载器又会将该请求委托给启动类加载器进行加载
  • 如果启动类加载器找到了该类,则直接返回该类的 Class 对象,否则它会让扩展类加载器去加载
  • 如果扩展类加载器找到了该类,则直接返回该类的 Class 对象,否则它会让应用程序类加载器去加载
  • 应用程序类加载器尝试去加载该类,如果它也找不到,则会抛出 ClassNotFoundException 异常

上述流程是从理论上来说的,那么在JDK源码之中,类加载机制是如何实现的呢?

2.2 源码实现

类加载器定义在Launcher类中,它是虚拟机启动的入口点,主要负责完成Java虚拟机的启动、类加载、运行等一系列操作,下面看一下删减之后的Launcher类结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Launcher {
// 静态变量launcher,调用无参构造器
private static Launcher launcher = new Launcher();
// 类加载器
private ClassLoader loader;

public static Launcher getLauncher() {
return launcher;
}

public Launcher() {...}

public ClassLoader getClassLoader() {
return this.loader;
}
...
...
...
// 应用类加载器
static class AppClassLoader extends URLClassLoader{...}
// 扩展类加载器
static class ExtClassLoader extends URLClassLoader{...}
}

由此可见,三种基本的类加载器,只有应用类加载器与扩展类加载器定义在Launcher类里面

  • AppClassLoader:继承自URLClassLoader的静态内部类
  • ExtClassLoader:也是继承自URLClassLoader的静态内部类

其中,URLClassLoader是继承自SecureClassLoader,SecureClassLoader继承自ClassLoader

那么引导类加载器在哪里呢?

事实上,引导类加载器是JVM内部的一部分,它不是Java类,也不位于Launcher类中

引导类加载器是由JVM自己实现的,用于加载JVM自身需要的类,例如java.lang.Objectjava.lang.Class等类。引导类加载器是JVM启动时创建的,且无法在程序中直接获取其实例

下面继续Launcher类源码,观察一下它的无参构造器内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public Launcher() {
// 扩展类加载器
ExtClassLoader var1;
try {
// 双重校验锁实现的单例模式,获取扩展类加载器实例
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}

try {
// 声明应用类加载器
// 应用类加载器的parent设置为var1,即扩展类加载器
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
// 设置上下文类加载器
Thread.currentThread().setContextClassLoader(this.loader);
String var2 = System.getProperty("java.security.manager");
if (var2 != null) {
SecurityManager var3 = null;
if (!"".equals(var2) && !"default".equals(var2)) {
try {
// 使用loadClass进行类加载,这里也是双亲委派实现的地方
var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
} catch (IllegalAccessException var5) {
} catch (InstantiationException var6) {
} catch (ClassNotFoundException var7) {
} catch (ClassCastException var8) {
}
} else {
var3 = new SecurityManager();
}

if (var3 == null) {
throw new InternalError("Could not create SecurityManager: " + var2);
}

System.setSecurityManager(var3);
}

}

上面看到,构造器里面调用了

1
(SecurityManager)this.loader.loadClass(var2).newInstance();

这一代码,事实上,双亲委派机制就是依赖类加载器的loadClass实现的,下面看下不同

首先是应用类加载器的loadClass方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public Class<?> loadClass(String var1, boolean var2) throws ClassNotFoundException {
int var3 = var1.lastIndexOf(46);
if (var3 != -1) {
SecurityManager var4 = System.getSecurityManager();
if (var4 != null) {
var4.checkPackageAccess(var1.substring(0, var3));
}
}

if (this.ucp.knownToNotExist(var1)) {
Class var5 = this.findLoadedClass(var1);
if (var5 != null) {
if (var2) {
this.resolveClass(var5);
}

return var5;
} else {
throw new ClassNotFoundException(var1);
}
} else {
// 调用ClassLoader类里面声明的loadClass方法
return super.loadClass(var1, var2);
}
}

看下ClassLoader里面的loadClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 寻找调用parent的loadClass
c = parent.loadClass(name, false);
} else {
// 没有parent,调用引导类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

通过上面的分析,就可以发现步骤很清晰了

在一般的类加载流程中,Launcher类调用无参构造器进行以下步骤

  • 生成扩展类加载器对象—单例生成
  • 生成应用类加载器对象,把它的parent设置为上面生成的扩展类加载器对象
  • 调用loadClass方法,这个方法最终是由他们的共同父类ClassLoad实现的
  • 在loadClass里面,如果对象有设置parent就调用parent的loadClass方法
  • 如果对象没有设置parent,则调用JVM实现的引导类加载器
  • 如果父类加载器无法加载指定类,则会尝试自己使用findClass方法加载

三、运行时数据区

image-20230409105202970

JVM运行时数据区主要由两大类组成,分别是线程共享区域与线程独有区域

  • 线程共享:方法区,堆
  • 线程私有:程序计数器,虚拟机栈,本地方法栈

1.方法区

方法区(Method Area)是JVM运行时数据区中的一部分,它是所有线程共享的内存区域。它用于存储类信息、常量、静态变量、即时编译器编译后的代码等数据。

在Java 8及以前的版本中,方法区是堆(Heap)的一部分

而在Java 8之后,方法区已经被彻底废弃,取而代之的是元空间(Metaspace)

方法区主要存储以下几类数据:

  • 类信息:每个类的完整结构信息,包括类名、父类名、接口名、字段、方法、访问标志等
  • 运行时常量池:每个类都有一个运行时常量池,用于存储编译时生成的字面量(如字符串、数字、类名、方法名等)和符号引用(如类和方法的全限定名、字段的名称和描述符等)
  • 静态变量:所有类共享的变量,它们在类加载时被分配空间,并在程序运行期间一直存在
  • 即时编译器编译后的代码:在JVM运行过程中,即时编译器会将一些频繁调用的代码编译成本地机器码,然后存储到方法区中,以提高程序运行的效率

需要注意的是,方法区的内存分配是在JVM启动时就完成的,其大小由-Xmx参数设置的最大堆大小决定。当方法区的空间不足时,会触发Full GC来回收无用的类信息、常量、静态变量等(1.8之前)。因此,当程序中使用大量类、方法、常量等时,需要适当调整JVM的-Xmx参数,以避免出现OOM异常

1.1 永久代、元空间与方法区

在JDK8之前,JVM虚拟机采用永久代的概念来描述方法区

在JDK8及之后,JVM虚拟机使用元空间的概念来描述方法区

那么永久代和方法区有哪些相似与不同之处呢?

相似点:他们都是用来存储类相关信息的,即方法区要存储的类信息

注意:

在JDK1.7及之前,运行时常量池位于永久代之中,一起放在堆之中

但在JDK1.8及之后,永久代被元空间取代,运行时常量池移动到了堆里面,不属于元空间

不同点:

  • 永久代
    • 永久代是堆的一部分,所以也会发生OOM,会有Full GC,产生内存碎片等
    • 永久代过小会导致频繁GC
    • 永久代过大会影响Young和Old的内存分配
  • 元空间
    • 使用本地内存,不受堆内存限制,避免内存碎片问题
    • 可以根据应用程序的需要进行动态扩容和释放
    • 内存回收不需要进行Full GC,减少了GC的时间消耗

他们都会发生OOM,但是出现原因不同,解决方式也不同

1.2 运行时常量池

运行时常量池(Runtime Constant Pool)是在类加载过程中的一部分,用于存储编译器生成的各种字面量和符号引用

字面量:包括各种基本类型的值、字符串常量和类名、方法名等

符号引用:指向一个在运行时期解析的位置,包括类和接口的全限定名、字段的名称和描述符以及方法的名称和描述符等信息

2.堆

在Java中,堆(Heap)是一个运行时数据区,用于存储对象实例

堆是JVM中最大的一块内存,是所有线程共享的

JVM启动时,就会自动为堆分配一定大小的初始空间,而且在运行期间也可以通过设置参数来调整堆的大小

下面看下不同版本的JVM内存分配区别

版本区别

再看下内存分配:

image-20230409141325294

由图可见,在JVM当中,堆主要由两部分组成:young区和old区

其中,young区由Eden、S01区(from Survivor)和S02(To Survivor)区组成

2.1 Young区

Young区是Java虚拟机堆中的一个部分,也称为年轻代,是对象分配的主要区域之一

Young区又被划分为三个部分:Eden区Survivor区(From Survivor和To Survivor,也称From和To区)

分配流程

Eden区是对象分配的初始区域,在Eden区分配的对象称为新生对象。当Eden区没有足够的空间分配新生对象时,会触发一次Minor GC,把Eden区中无用的对象及存活的对象移动到Survivor区,如果Survivor区空间不够,则使用分配担保机制把存活的对象移动到老年代中

Survivor区分为两个大小相等的区域,分别称为From Survivor和To Survivor。在Survivor区中存活的对象会被移动到另一个Survivor区,而非直接移动到老年代。在进行Minor GC的时候,会把Eden区和一个Survivor区中的存活对象复制到另一个Survivor区中,如果Survivor区空间不够,则同样使用分配担保机制把存活的对象移动到老年代中

内存划分

在Young区当中,按照内存大小划分,Eden:S0:S1 = 8:1:1

这是因为在Young区的对象大多是朝生夕死的,生命周期十分短暂,因此需要将Eden区的内存设置的大一点,而Survivor区两个部分设置的一样大小,是因为在Young区采用的GC算法是标记-复制算法,幸存对象会在From和To区之间来回复制,所以需要保持内存大小相同

更加详细的GC算法后面会分析到,此处不再赘述

对象晋升

年轻代中对象晋升到老年代的方式有两种,一种是根据对象年龄晋升,另一种是在新生代中存活的对象达到一定比例后直接晋升

1)根据对象年龄晋升

年轻代分为 Eden 区和两个 Survivor 区,当一个对象在 Eden 区出生并经过第一次 Minor GC 后仍然存活,则会被复制到第一个 Survivor 区中,并将对象年龄设为 1。当 Survivor 区中的对象再次经过 Minor GC 后仍然存活,则会被复制到另一个 Survivor 区中,并将对象年龄加 1。当对象年龄达到一定阈值(默认为 15 岁)时,该对象会被晋升到老年代中

需要注意的是,如果设置了 -XX:+NeverTenure 参数,则所有对象都不会晋升到老年代

2)存活对象直接晋升

当年轻代进行一次 Minor GC 后存活的对象占比达到一定值(默认为 50%),就会直接将这些对象晋升到老年代。这个阈值可以通过 JVM 参数 -XX:MaxTenuringThreshold 来调整

2.2 Old区

老年代(Old Generation)是Java堆的一部分,用于存储生命周期较长的对象。通常情况下,新生代的对象会被多次垃圾回收(Minor GC),而老年代中的对象则会经历较少的垃圾回收(Major GC或Full GC)。由于老年代中的对象数量较少,所以垃圾回收时的性能相对较低

老年代的大小一般要大于新生代,因为老年代中存储的对象寿命更长,而且垃圾回收时的性能相对较低,需要更大的空间来存储和管理对象。在一些JVM的实现中,老年代的大小是可以调整的,以适应不同的应用场景

一般来说,建议将老年代的大小设置为整个堆内存的50%到70%左右,这样可以在一定程度上平衡新生代和老年代的内存分配,提高应用的性能和稳定性

3.虚拟机栈

虚拟机栈是Java虚拟机运行时数据区之一,用于存储线程的局部变量、操作数栈、方法出口等信息。每个线程在创建时都会创建一个对应的虚拟机栈,用于存储该线程的方法执行过程中的临时数据

虚拟机栈由栈帧(Stack Frame)组成,每个栈帧对应着Java方法的执行,即在一个线程中,一个方法对应着一个栈帧

既然方法对应的是栈帧,那么栈帧内部对应的自然就是方法里面可能会有的一些东西了

栈帧包含局部变量表(Local Variable Table)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)和方法出口(Return Address)等信息

image-20230409141152351

对应一下关系:

  • 局部变量表:用于存放方法中定义的局部变量和参数,它的大小在编译期就已经确定了
  • 操作数栈:用于存放方法执行时所需要的操作数,比如加、减、乘、除等运算的操作数。操作数栈的大小在编译期也已经确定
  • 动态链接:动态链接是指在程序运行期间进行的链接过程,将代码中使用到的符号引用和方法调用映射到实际内存地址的过程
  • 方法返回地址:用于存放方法返回时的返回地址

其中,动态链接和类加载时的“链接”中的“解析”步骤作用类似,将符号引用转换为直接引用

但是他们的时机不同,前者是类加载时进行的,后者是程序运行时进行的

方法的递归调用就是利用栈帧实现的,递归一次就会生成一个栈帧,这也是为什么不设置递归出口,或者递归此时过多最终会报StackOverflowError的错误

4.本地方法栈

本地方法栈(Native Method Stack)是与虚拟机栈类似的一块内存区域,但其是为虚拟机执行本地方法(Native Method)服务的,也就是用来执行本地方法的栈。与虚拟机栈类似,本地方法栈也是线程私有的,其生命周期与线程相同

与虚拟机栈的区别在于,虚拟机栈为Java方法服务的,而本地方法栈则是为本地方法服务的。本地方法是使用其他语言(如C/C++)实现的方法,因此其执行方式不同于Java方法,需要使用不同的栈来支持本地方法的执行

本地方法栈的内部结构与虚拟机栈类似,也由栈帧(Stack Frame)组成。栈帧中包含了本地方法的参数、返回值和局部变量等信息。和虚拟机栈一样,本地方法栈也需要检查溢出。当栈空间无法容纳新的栈帧时,会抛出StackOverflowError异常。与虚拟机栈类似,本地方法栈的大小也可以通过参数进行调节

5.程序计数器

程序计数器是一块内存空间,它的作用是记录正在执行的线程下一条指令的地址,是线程私有的

程序计数器和虚拟机栈中的方法返回地址有相似之处

相似之处

都是为了支持Java程序的方法调用和返回

不同之处

  • 程序计数器记录的是当前线程正在执行的字节码指令的地址
  • 而虚拟机栈中的方法返回地址则是指向方法返回后继续执行的字节码指令地址
  • 另外,程序计数器不会发生OutOfMemoryError异常,而虚拟机栈则可能会发生

四、综合理解

1.区域交互

上面介绍了虚拟机栈的相关概念,那么虚拟机栈是如何跟其他区域进行交互的呢?运行时数据区的各区之间是如何交互的呢?

1.1 栈指向堆

假如栈帧中的变量,为符号引用,如

1
Object obj = new Object();

这时候的栈中元素肯定指向了堆,因为这个对象在堆内存中开辟空间进行存储

image-20230409141757104

1.2 方法区指向堆

方法区中会存放静态变量,常量等数据。如果是下面这种情况,就是典型的方法区中元素指向堆中的对象

1
private static Object obj=new Object();
image-20230409141951577

1.3 堆指向方法区

方法区中会包含类的相关信息,堆中则存储构建的类对象,所以如果想根据对象获取类相关信息,肯定有一个能够从堆去方法区找类信息的指向

image-20230409142224093

2.对象布局

对象分配在堆内存当中,由三部分组成:对象头、实例数据和对其填充

image-20230409143026721
  • 对象头
    • Mark Word:
      • 锁标志位:用于记录对象是否被锁定,如果为0,则表示对象没有被锁定;如果为1,则表示对象被轻量级锁定;如果为10,则表示对象被重量级锁定;如果为11,则表示对象是可偏向的
      • 偏向线程ID:用于记录偏向锁定的线程ID
      • 偏向时间戳:用于记录上一次偏向锁定的时间戳
      • 分代年龄:用于记录对象的分代年龄,当对象在新生代中经历了一次Minor GC,如果存活下来,则分代年龄+1,当分代年龄达到一定值时,就可以晋升到老年代中
      • HashCode:用于记录对象的哈希码,如果对象的哈希码没有被计算过,则JVM会对对象进行哈希码的计算,并将结果存储在Mark Word中
    • Class Pointer:指向方法区中的类信息
    • Length:主要用于数组长度保存
  • 实例数据:对象真正存储的数据,包括类的字段信息以及从父类继承下来的字段信息,以及一些对象实例相关的数据
  • 对其填充:对齐填充的主要目的是为了确保对象实例在内存中的起始地址是8字节的整数倍。这是因为现代处理器在访问内存时,通常会将内存按照一定的块大小(比如64字节)进行读取和写入,而不是逐字节地进行操作。如果对象实例的起始地址不是8字节的整数倍,就可能会导致需要进行跨块读取或写入操作,这样就会影响读写效率

其中,对象头中的Mark World在之前介绍sychronized锁时就已有涉及,使用锁标志位来记录对象的锁状态

3.对象创建

上面介绍了那么多,那么一个对象的具体创建过程,都经历了什么呢?

下面是对象在堆上分配的过程

image-20230409144502716

GC划分

Minor GC:新生代

Major GC:老年代

Full GC:新生代+老年代

1.7和之前Full GC还要加上永久代

五、小结

除了程序计数器之外,其他区域都可能发生OOM。具体而言:

  • 在堆中,由于存放了大量的对象实例,当堆空间被占满之后,就会导致OOM异常。当然,通过调整堆大小、调整垃圾回收策略等方式,可以尽可能地延缓这个过程
  • 在方法区(HotSpot虚拟机中是永久代或元空间)中,存放了类的信息、常量池等数据,当存储的数据过多,导致方法区满了,就会抛出OOM异常。Java 8之后,方法区被元空间所取代,但同样可能会因为存储过多的元数据而导致OOM异常
  • 在虚拟机栈和本地方法栈中,由于存储了方法的局部变量、参数、返回值等数据,当调用的方法层次过深,导致栈空间不足,就会发生栈溢出异常。栈空间的大小是可以通过参数进行调整的,但是过小的栈空间会限制程序的运行,过大的栈空间会占用过多的内存

此外,堆内存只所以设计成目前的样子,最主要的目的就是要配合GC合理的对堆上对象进行管理,所以有哪些GC算法,都有什么特征,这些GC算法都怎么用呢?后面都会讨论