JVM学习笔记:第四章——虚拟机栈

概述知识点

出现背景

由于Java的跨平台需求,Java的指令都是根据栈来设计的。

优点:

  1. 跨平台

  2. 指令集小

  3. 编译器容易实现

缺点:

  1. 性能下降

  2. 实现桐言的功能需要更多指令

JVM当中的栈&堆概述

栈:运行时单位,解决程序如何执行/如何处理数据的问题

堆:存储单位,数据存储问题

在内存空间当中:

0x0000000000000000 → 低地址

...

0x7FFFFFFFFFFFFFFF → 高地址

复制代码
低地址
----------------
代码段
数据段
----------------
Heap  ↑ 向上增长
----------------
Stack ↓ 向下增长
高地址

设计原因:

避免冲突

  • 栈从高地址向下

  • 堆从低地址向上

  • 两者"相向而行"

便于检测溢出

  • 栈溢出会撞到保护页
栈Stack(虚拟机栈)

每个线程创建的时候创建一个虚拟机栈,随着线程结束而被销毁。

本质定义

一块连续内存空间 ,CPU自动管理,用于存储(Java变量类型角度):

  1. 局部变量(包含基本类型)

  2. 方法参数

  3. 对象引用变量

    1. User ``u`` = new User();

      • u在栈

      • new User()在堆

栈帧结构角度:

  1. 局部变量

  2. 方法参数

  3. 返回地址

  4. 栈帧Stack Frame

逻辑关系:

复制代码
线程栈
 └── 栈帧
       ├── 局部变量表
       │     ├── 方法参数
       │     ├── 局部变量
       │     └── 对象引用
       ├── 操作数栈
       ├── 动态链接
       └── 返回地址

遵循LastInFirstOut原则

使用场景

经典场景:函数调用

复制代码
void main() {
    int a = 10;
    test(a);
}

void test(int x) {
    int y = x + 1;
}

执行流程:

main 栈帧入栈

test 栈帧入栈

test 执行完 -> 出栈

main 继续执行

核心特征

对于栈来说不存在垃圾回收问题,但是可能存在OOM问题

JVM直接对Java栈的操作只有两个:

  • 每个方法执行
栈的两种异常

Java规范允许Java栈的大小采用动态或者固定:

  • 采用固定大小的Java虚拟机栈,每个线程的JVM栈都可以在线程创建的时候独立选定。线程执行过程当中,方法调用层级过深/需要栈帧总量超过栈容量,JVM将抛出StackOverFlowError异常

  • 如果JVM栈可以动态扩展,并在尝试扩展 的时候无法申请到足够内存/创建新的线程 为其分配空间的时候系统无法提供足够内存,抛出OutOfMemory异常

栈帧Stack Frame
创建时机

当一个方法被调用的时候:

JVM为这个方法创建一个栈帧

也就是:每个方法相当于一个栈帧

结构
  1. 局部变量表

    1. 方法参数、局部变量、对象引用
  2. 操作数栈

    1. 执行字节码计算

    2. 存储:基本类型数值、对象引用、中间计算结果

  3. 动态链接

    1. 指向运行时常量池(运行时常量池:类被加载JVM,class文件被加载到运行时常量池,存储字面量、符号引用、方法句柄、类引用;运行时常量池当中的字符串字面量存储在字符串常量池当中(JDK8之后字符串常量池在堆中))
  4. 返回地址

    1. 方法执行之后返回位置

栈帧的大小主要是由局部变量表、操作数栈决定的。

设置栈内存大小

官方文档:Tools Reference

我们可以使用参数**-** Xss 选项设置线程的最大栈空间,栈的大小 直接决定了函数调用的最大可达深度

我们设置线程栈大小的单位是字节byte

我们使用k/K代表KB

m/M代表MB

g/G代表GB

默认值取决于不同的平台:

  • Linux/x64 (64-bit): 1024 KB

  • macOS (64-bit): 1024 KB

  • Oracle Solaris/x64 (64-bit): 1024 KB

  • Windows:默认值取决于虚拟内存,更细一点说是受可用虚拟地址空间和系统内存提交限制影响,不是一个固定的常数

为什么windows默认线程栈取决于虚拟内存?

面试回答版本:

因为windows为线程分配的是虚拟地址空间中的一段栈区域,线程栈大小受进程可用虚拟地址空间以及系统可提交内存限制影响,因此默认值不是一个固定常量,而是依赖操作系统内存配置。

使用不同单位设置栈内存大小

复制代码
-Xss1m
-Xss1024k
-Xss1048576
EXAMPLE
复制代码
public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count);
        count++;
        main(args);
    }
}
没有设置参数之前

部分输出结果:

复制代码
11404
11405
11406
Exception in thread "main" java.lang.StackOverflowError
        at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)

栈在11406这个深度溢出了。

设置参数之后

部分输出结果:

复制代码
2474
2475
2476
Exception in thread "main" java.lang.StackOverflowError
        at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)

参数起到了作用

栈运行原理

JVM直接对Java站的操作只有两个:对栈帧的压栈、出栈 ,遵循先进后出(LastInFirstOut)原则

在一条活动线程当中,一个时间点上,只会有一个活动的栈帧 。即只有当前正在执行方法的栈帧(栈顶栈帧 )是有效的。这个栈帧被称为当前栈帧Current Frame ,与当前栈帧对应的是当前方法Current Method ,定义这个方法的类就是当前类Current Class

执行引擎有粘性的所有字节码指令只针对当前栈帧进行操作

若当前方法当中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的栈帧。

注意:不同线程中所包含的栈帧是不允许存在相互作用的------不可能在一个栈帧当中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧。之后,虚拟机会丢弃当前栈帧,前一个栈帧成为栈顶栈帧(当前栈帧)

Java的两种返回方式:

  1. 正常函数(方法)返回,使用return指令

  2. 方法执行过程当中出现未捕获处理的异常,抛出异常方式结束

两种方式都导致栈帧被弹出

局部变量表

基础认识

概念

局部变量表,或称为:局部变量数组/本地变量表

def:数字数组,主要用于存储方法参数、定义在方法体内的局部变量、对象引用,数据类型包括各类基本数据类型、对象引用、returnAddress返回值类型(用于存储下一条要执行的字节码指令地址)

由于局部变量表建立在线程栈之上,是线程私有数据,因此不存在数据安全问题

局部变量表所需容量大小在编译期 就被确定下来,并保存在方法的Code属性的maximum local variables数据项当中。方法运行期间基本上是不会改变局部变量表大小的

一个方法的最大嵌套调用次数由栈的大小决定。一般来说,栈越大,方法能够嵌套调用的次数就越多。

  • 一个函数/方法而言,它的参数、局部变量越多,局部变量表就越大,栈帧也就越大。

  • 进而函数调用就会占用更多栈空间,最大嵌套次数就会减少。

局部变量表中变量只在当前方法调用中有效

  • 方法执行时,JVM通过使用局部变量表完成参数值到参数变量列表的传递过程(将参数值压入操作数栈,操作数栈将实参依次放入局部变量表当中)。

  • 方法调用结束之后,随着方法栈帧的销毁,局部变量表也随之销毁。

EXAMPLE
复制代码
public class LocalVariablesTest {
    private int count = 0;

    public static void main(String[] args) {
        LocalVariablesTest test = new LocalVariablesTest();
        int num = 10;
        test.test1();
    }

    //练习:
    public static void testStatic(){
        LocalVariablesTest test = new LocalVariablesTest();
        Date date = new Date();
        int count = 10;
        System.out.println(count);
        //因为this变量不存在于当前方法的局部变量表中!!,不能使用this
//        System.out.println(this.count);
    }

    //关于Slot的使用的理解
    public LocalVariablesTest(){
        this.count = 1;
    }

    public void test1() {
        Date date = new Date();
        String name1 = "atguigu.com";
        test2(date, name1);
        System.out.println(date + name1);
    }

    public String test2(Date dateP, String name2) {
        dateP = null;
        name2 = "songhongkang";
        double weight = 130.5;//占据两个slot
        char gender = '男';
        return dateP + name2;
    }

    public void test3() {
        this.count++;
    }

    public void test4() {
        int a = 0;
        {
            int b = 0;
            b = a + 1;
        }
        //变量c使用之前已经销毁的变量b占据的slot的位置
        int c = a + 1;
    }
}

我们编译main方法之后解析查看:

这里就可以看出来,局部变量表所需容量大小在编译器就确定了

main方法解析

这里使用jclasslib查看main 方法的字节码

  1. 字节码从0-15共16行,我们可以看到对应的字节码指令

  2. 之后切换到方法异常信息表查看异常,因为main方法没有抛出异常(存在try-catch/try-finally/synchronized,块时被捕获的异常),所以没有

  3. 查看misc部分

  4. 行号表 Java代码行号和字节码指令的对应关系可以在这里检查

  5. 查看局部变量表,发现对应的起始位置和长度(这里理解为生效范围/作用域)相加就是整个方法字节码行数

Start PC==11表示在字节码11行开始生效,对应Java代码第15行(行号表当中可以看到)。而声明int类型的num是在Java代码的14行,从声明的下一行开始生效

Length==5表示局部变量剩余有效行数,main方法共16行,从11行开始生效。

Ljava/lang/String当中L表示引用类型,而java/lang/String表示类对应位置

Slot 槽

概念&认识

局部变量表当中参数值存放从局部变量索引0位置开始(和数组一样),到数组长度为-1的索引结束

Slot(槽),是局部变量表当中最基本的存储单元,局部变量表当中存放了编译时期可知的各种基本数据类型、引用类型(reference),returnAddress类型变量。

在局部变量表当中,32位以内的类型只占用一个slot位(包括returnAddress类型、引用数据类型),64位类型占用两个slot位(long、double)

  • byte、short、char存储前被转换为int类型,boolean也被转换为int类型,0表示false,1表示true

JVM为局部变量表当中每个slot分配一个访问索引,通过所应访问到局部变量表当中指定的局部变量值。

当一个实例方法调用 的时候,方法参数和方法体内定义的局部变量按照顺序复制到每一个slot上。

顺序:

  1. 第一梯队:this

    1. 实例方法/构造方法,slot0永远为this
  2. 第二梯队:方法参数

    1. 按照参数列表从左往右顺序分配
  3. 第三梯队:方法体内局部变量

    1. 按照代码从上到下的声明顺序分配

(这是正常顺序,还存在复用槽位情况,后续讲解)

需要访问局部变量当中一个64bit的局部变量值时,使用起始索引即可(图中的long类型m就使用1,而double类型的q使用4即可)

若当前栈帧是由构造方法或者实例方法创建时,那么该方法对应的对象使用this时会将ths存放在索引(index)为0的位置,其余参数按照正常顺序排列。这也是为什么我们在方法当中能够使用this的原因,因为这里将this作为一个变量传入了方法当中。

slot代码示例&讲解
this存放在索引(index)为0的位置
复制代码
        public void test3() {
        this.count++;
    }

我们查看局部变量表:

64位类型(long、double)占用两个slot位,引用类型占据一个slot位
复制代码
         public String test2(Date dateP, String name2) {
        dateP = null;
        name2 = "songhongkang";
        double weight = 130.5;//占据两个slot
        char gender = '男';
        return dateP + name2;
    }

这里的weight为double类型,而dateP、name2为引用类型(Date、String对象)

注意(老生常谈)

static方法当中你是无法使用this的,理由也很简单,直接调用类本身也可以使用static方法(无法知道当前对象是谁)

从局部变量表的角度来讲,就是局部变量表当中没有存储this

复制代码
    public static void testStatic(){
        LocalVariablesTest test = new LocalVariablesTest();
        Date date = new Date();
        int count = 10;
        System.out.println(count);
        //因为this变量不存在于当前方法的局部变量表中!!
//        System.out.println(this.count);
    }
slot的重复利用

栈帧当中的局部变量表slot槽位是可以重用的 。局部变量过了其作用域,那么在作用域之后声明的新局部变量就很有可能会复用过期局部变量槽位,从而节省资源。

复制代码
    public void test4() {
        int a = 0;
        {
            int b = 0;
            b = a + 1;
        }
        //变量c使用之前已经销毁的变量b占据的slot的位置
        int c = a + 1;
    }

局部变量表:

  1. b索引对应位置被a复用了

变量分类&特点

变量分类:

  1. 按照数据类型分类:基本数据类型&引用类型

  2. 按照类的声明位置分类:

    1. 成员变量:使用前都经历过默认初始化赋值

      1. 类变量(static修饰,属于类本身):在类加载的链接阶段的准备段给类变量进行默认赋值,初始化阶段进行显式赋值------声明处的赋值/静态代码块赋值

      2. 实例变量(普通成员变量):随着对象创建,在堆空间当中分配实例变量空间,进行默认赋值

    2. 局部变量:使用前必须进行显示赋值,否则无法通过编译

操作数栈

特点

每个独立的栈帧除了包含局部变量表之外,还包含一个先进后出的操作数栈,或称为表达式栈

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入/提取数据,也就入栈(push)/出栈(pop)

  • 某些字节码指令将数值压入操作数栈,一些字节码指令将操作数取出栈。使用之后将结果压入操作数栈。

  • EX:复制、交换、求和等操作

作用

操作数栈,主要用于保存计算过程当中各种中间结果,同时作为计算过程当中变量的临时存储空间。

操作数栈,是JVM执行引擎的一个工作区/工作台(JVM执行引擎根据字节码指令操作操作数栈),当一个方法开始执行的时候,新的栈帧被创建的同时创建一个空的操作数栈。

每个操作数栈都会拥有一个明确的栈深度用于存储数值,所需的最大深度在编译期就已经定下来了,保存在方法的Code属性当中,为maxstack的值。

栈中可以存储任何Java数据类型元素:

  • 32bit类型占用一个栈单位深度

  • 64bit类型占用两个站单位深度

操作数栈使用数组结构进行实现 ,但是并非采用索引结构进行访问**,只能通过标准的入栈、出栈操作完成一次数据访问**。

A方法调用B方法时JVM流程:

假设A(调用者)调用B(被调用者)

阶段1:A执行

  • 栈帧:A栈帧在栈顶

  • PC:指向A当中的invokestatic(调用)指令,触发引用B

  • 动作:JVM暂停A,创建B栈帧,压入栈顶

阶段2:B执行

  • 栈帧:B栈帧为当前栈帧

  • PC:指向B当中指令

  • A状态:A栈帧被压在B栈帧之下,处于休眠状态。A之前记录了自己的执行位置(invoke指令的下一条指令地址,这个地址通常保存在栈帧的动态链接/返回地址信息当中)

阶段3:方法B执行return指令

当B执行ireturn指令时,JVM执行引擎执行一系列交接操作:

  1. 搬运返回值:将B操作数栈顶值(返回值),弹出

  2. 恢复栈帧:

    1. 弹出(销毁)B栈帧

    2. 唤醒A栈帧,重新成为当前栈帧

  3. 压入返回值:将B返回值压入A操作数栈

  4. 更新PC寄存器:跳转到invoke之后的指令

操作数栈当中元素数据类型必须与字节码指令序列严格匹配,这由编译器在编译期间(程序编译为class文件期间)进行验证,同时在类加载过程当中链接阶段的验证子阶段再次验证。

另外,我们说Java虚拟机解释引擎是基于栈的引擎,这里栈指的就是操作数栈。

操作数栈代码追踪

复制代码
        public void testAddOperation() {
        //byte、short、char、boolean:都以int型来保存
        byte i = 15;
        int j = 8;
        int k = i + j;
    }

对应的字节码指令:

复制代码
 0 bipush 15
 2 istore_1
 3 bipush 8
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return
流程追踪
  1. 执行第一条语句,,PC寄存器指向0,使用bipush将操作数15入栈

  2. 执行完成之后,PC寄存器下移,指向下一行代码,将操作数栈元素存储到局部变量表1号位置(istore_1,因为this占了0号位),局部变量表增加一个元素而操作数栈为空了

  3. PC继续下移, 操作数8入栈,之后执行istore操作,存入局部变量表

  4. 局部变量表中数据通过iload指令放入操作数栈当中,等待执行add操作

    1. iload_1:取出局部变量表中索引为1元素入操作数栈;iload_2同理
  5. 操作数栈中两个元素执行相加,,存储在局部变量表3位置

细节
类型转换说明
  • 8在byte类型范围内,压入操作数栈的类型为byte,而不是int,执行字节码指令为bipush 8

  • 存储到局部变量表时候依旧需要转换为int类型进行存储:istore_4

  • 当我们将m改为800的时候,就变成了short类型压入栈:sipush_800
被调用方法有返回值,返回值压入操作数栈
复制代码
  public int getSum(){
        int m = 10;
        int n = 20;
        int k = m + n;
        return k;
    }

    public void testGetSum(){
        //获取上一个栈桢返回的结果,并保存在操作数栈中
        int i = getSum();
        int j = 10;
    }

getSum() 方法字节码指令:最后带着个 ireturn

testGetSum() 方法字节码指令:

  1. 先加载当前实例对象this

  2. invokevirtual调用实例方法(我们这里写getSum实际上相当于this.getSum())

之后先加载实例才能知道调用的是哪个对象的

栈顶缓存技术Top Of Stack Cashing

之前说过,基于栈式架构的虚拟机使用的零地址指令更加紧凑,但是完成一项操作的时候必须要使用更多的入栈、出栈、存储到局部变量表的操作,这就代表着需要使用更多指令分派(instruction dispatch)(也就是指令更多),读写内存次数更多,效率不高。

而操作数存储在内存当中,频繁的执行内存读写操作必然影响执行速度。为了解决而这个问题,HotSpot JVM设计者提出类栈顶缓存TOS技术,将栈顶元素全部缓存在 CPU 的寄存器(物理结构)当中,降低内存读写次数,提升引擎执行效率。

动态链接

每个栈帧内部都包含了一个指向运行时常量池中该栈帧所属的方法的引用。包含这个引用的目的是为了支持当前方法的代码能够实现动态链接,EX:invokedynamic指令

Java源文件被编译到字节码文件当中时,所有变量和方法引用都作为符号引用保存在class文件常量池当中。

EX:

描述一个方法调用了其他方法时,通过常量池中只想方法的符号引用表示,通过动态链接,将符号引用转换为调用方法的直接引用。

运行时常量池&字符串常量池&引用

我们可以将运行时常量池想象为一个类的"核心档案"/"通讯录"

运行时常量池存储了什么?

主要存储两类:

  1. 字面量Literals

  2. 符号引用Symbolic References

A.字面量

  1. 文本字符串:例如 "Hello World"。(这里存储的实际上也是堆字符串的符号引用)

  2. 声明为final(也就无法被修改)的常量值:例如 final int Max=10000

  3. 基本数据类型值(特殊情况):虽然大部分基本类型直接嵌在字节码当中(例如 bipush 10),但是大整数、浮点数(long/double),或者为了复用,也会存储在常量池当中。

B.符号引用(最重要的部分)

这是JVM实现动态链接的关键。存储的不是物理内存地址,而是"描述符"。包含三类:

  1. 类和接口的全限定名:EX:java/lang/String

  2. 字段名称和描述符:EX:private int age,常量池当中就是 age和I(表示int)

  3. 方法名称和描述符:EX:public void main(String []args),常量池当中就是main 和([L java/lang/String;])V。(这里L表示引用类型,V表示void)

运行时常量池和字符串常量池之间的关系

  • 运行时常量池Runtime Constant Pool:位于运行时方法区,是每个类独一份的。里面存储的字符串信息实际上是索引/符号引用

  • 字符串常量池String Pool:位于堆,是全局共享的。里面存储的是真正的String对象实例。

两者之间的关系:

当类加载时,运行时常量池里的"Hello"只是对应的一个符号。代码真正执行到这个位置时,JVM会拿这个符号去全局字符串常量池当中寻找是否有对应的String类型对象为Hello:

  • 有,返回对象引用

  • 没有,堆当中创建一个,放入字符串常量池当中,返回引用

运行时常量池当中存储的实际上是"去哪里找这个字符串"的线索

为什么栈帧需要包含一个指向运行时常量池的引用?

执行引擎正在栈帧上执行某个方法

执行到类似invokevirtual #5

这时候,就需要查询栈帧当中指向属于当前方法的运行时常量池

  1. 查找对应的这5号方法是什么,例如对应的是:com/example/MyClass.methodB()(这是一个符号引用)

  2. 解析:JVM此时查找MyClass类是否加载,方法methodB实际内存入口地址在哪里

  3. 找到实际内存地址(直接引用),JVM记录/缓存这个地址

  4. 执行引擎跳转到对应地址执行代码

EXMAPLE

复制代码
public class DynamicLinkingTest {

    int num = 10;

    public void methodA(){
        System.out.println("methodA()....");
    }

    public void methodB(){
        System.out.println("methodB()....");

        methodA();

        num++;
    }

}

对应的字节码:

复制代码
Classfile /F:/IDEAWorkSpaceSourceCode/JVMDemo/out/production/chapter05/com/atguigu/java1/DynamicLinkingTest.class
  Last modified 2020-11-10; size 712 bytes
  MD5 checksum e56913c945f897c7ee6c0a608629bca8
  Compiled from "DynamicLinkingTest.java"
public class com.atguigu.java1.DynamicLinkingTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #9.#23         // java/lang/Object."<init>":()V
   #2 = Fieldref           #8.#24         // com/atguigu/java1/DynamicLinkingTest.num:I
   #3 = Fieldref           #25.#26        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = String             #27            // methodA()....
   #5 = Methodref          #28.#29        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #6 = String             #30            // methodB()....
   #7 = Methodref          #8.#31         // com/atguigu/java1/DynamicLinkingTest.methodA:()V
   #8 = Class              #32            // com/atguigu/java1/DynamicLinkingTest
   #9 = Class              #33            // java/lang/Object
  #10 = Utf8               num
  #11 = Utf8               I
  #12 = Utf8               <init>
  #13 = Utf8               ()V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               Lcom/atguigu/java1/DynamicLinkingTest;
  #19 = Utf8               methodA
  #20 = Utf8               methodB
  #21 = Utf8               SourceFile
  #22 = Utf8               DynamicLinkingTest.java
  #23 = NameAndType        #12:#13        // "<init>":()V
  #24 = NameAndType        #10:#11        // num:I
  #25 = Class              #34            // java/lang/System
  #26 = NameAndType        #35:#36        // out:Ljava/io/PrintStream;
  #27 = Utf8               methodA()....
  #28 = Class              #37            // java/io/PrintStream
  #29 = NameAndType        #38:#39        // println:(Ljava/lang/String;)V
  #30 = Utf8               methodB()....
  #31 = NameAndType        #19:#13        // methodA:()V
  #32 = Utf8               com/atguigu/java1/DynamicLinkingTest
  #33 = Utf8               java/lang/Object
  #34 = Utf8               java/lang/System
  #35 = Utf8               out
  #36 = Utf8               Ljava/io/PrintStream;
  #37 = Utf8               java/io/PrintStream
  #38 = Utf8               println
  #39 = Utf8               (Ljava/lang/String;)V
{
  int num;
    descriptor: I
    flags:

  public com.atguigu.java1.DynamicLinkingTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: putfield      #2                  // Field num:I
        10: return
      LineNumberTable:
        line 7: 0
        line 9: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/atguigu/java1/DynamicLinkingTest;

  public void methodA();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #4                  // String methodA()....
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/atguigu/java1/DynamicLinkingTest;

  public void methodB();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #6                  // String methodB()....
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: aload_0
         9: invokevirtual #7                  // Method methodA:()V
        12: aload_0
        13: dup
        14: getfield      #2                  // Field num:I
        17: iconst_1
        18: iadd
        19: putfield      #2                  // Field num:I
        22: return
      LineNumberTable:
        line 16: 0
        line 18: 8
        line 20: 12
        line 21: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/atguigu/java1/DynamicLinkingTest;
}
SourceFile: "DynamicLinkingTest.java"
  1. 字节码当中,methodB()方法通过invokevirtual #7指令调用方法A,对应的#7是什么?

  2. 我们查看常量池(Constant Pool)当中对于#7的定义: #7=Methodref #8.#31

    1. 先查找#8:

      1. #8=Class #32:查找#32

      2. #32 = Utf8 com/atguigu/java1/DynamicLickingTest

      3. 通过#8我们找到DynamicLickingTest这个类

    2. 之后查找#31:

      1. #31=NameAndType #19:#13:查找#19和#13

      2. #19 = Utf8 methodA:方法名为methodA

      3. #13 = Utf8 ()v:方法没有形参,返回值为void

  3. 通过#7我们就能找到需要调用的methodA()方法,并进行调用

  4. 我们可以看到上面还有很多符号引用,例如:Object、System、PrintSteam等等

运行时常量池和类&栈帧之间的关系

运行时常量池和类是一一对应关系

在一个类的不同的方法当中,都可能调用常量或者方法,对应一个类存储一份即可,记录对应的引用,节省空间。

常量池作用:提供常量用于字节码指令识别、使用

方法调用

静态链接&动态链接

JVM当中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关

编译期确定&运行期保持不变

含义

编译期确定

java编译器在生成.class文件时,能唯一且明确的确定 被调用方法的具体实现类&签名,无需依赖运行时对象实际类型。

运行期保持不变

方法在JVM运行期间不会因为多态重写等因素改变调用目标,JVM在类的加载的解析阶段(链接的子阶段)将符号固化为直接引用。

静态链接:

当一个字节码文件被装载进JVM内部时,若被调用目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法符号引用转换为直接引用的过程称为静态链接。

典型场景(非虚方法):

注:

  • final方法虽然在字节码中使用invokevirtual,但JVM知晓其无法重写,在解析阶段即可完成静态链接(JVM规范允许其优化)

动态链接

若被调用方法在编译期无法被确定下来,即只能在程序运行期将调用的方法符号转换为直接引用,由于这种转换过程具备动态性,成为动态链接。

对应的方法被称为虚方法(virtual methods):编译期间仅能确定"方法签名",具体实现有运行时对象实际类型动态决定,调用目标可随对象类型变化。

✅变化原因:

Java多态机制(方法重写+向上转型)导致同一符号在不同运行上下文中绑定到不同实现。

复制代码
class Animal { void speak() { System.out.println("Animal"); } }
class Dog extends Animal { 
    @Override void speak() { System.out.println("Dog"); } 
}
class Cat extends Animal { 
    @Override void speak() { System.out.println("Cat"); } 
}

public class Test {
    public static void main(String[] args) {
        Animal a1 = new Dog(); // 向上转型
        Animal a2 = new Cat();
        a1.speak(); // 运行时 → Dog.speak()
        a2.speak(); // 运行时 → Cat.speak()
    }
}
  1. 编译期:a1.speak()仅生成指向Animal.speak()的符号引用(字节码:invokevirtual Anima/speak)

  2. 运行期:JVM根据a1实际类型(Dog)查其虚拟方法表(vtable),动态定位到Dog.speak直接引用

  • "变"的本质:同一符号在不同对象上下文调用时,解析出的直接引用不同

✅其他动态场景:

  • 接口方法调用(invokeinterface):如 List list = new ArrayList<>(); list.add(...),具体实现类运行时确定。

  • 反射调用、invokedynamic(如lambda表达式):绑定逻辑完全由运行时决定。

早期绑定Early Binding&晚期绑定Late Binding(概念而非流程)

早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。

静态链接和动态连接对应的方法绑定机制为:早期绑定和晚期绑定。绑定是一个字段、方法、类在符号引用替换为直接引用的过程。

  • 早期绑定

被调用的目标方法如果在编译期可知,且运行时保持不变时即可将这个方法与所属的类型进行绑定,这样一来由于明确了被调用的目标方法是哪一个,就可以使用静态链接的方式将符号引用转换为直接引用

  • 晚期绑定

被调用方法在编译期无法被确定下来,只能在程序运行期间根据实际类型绑定相关方法

复制代码
class Animal {

    public void eat() {
        System.out.println("动物进食");
    }
}

interface Huntable {
    void hunt();
}

class Dog extends Animal implements Huntable {
    @Override
    public void eat() {
        System.out.println("狗吃骨头");
    }

    @Override
    public void hunt() {
        System.out.println("捕食耗子,多管闲事");
    }
}

class Cat extends Animal implements Huntable {

    public Cat() {
        super();//表现为:早期绑定
    }

    public Cat(String name) {
        this();//表现为:早期绑定
    }

    @Override
    public void eat() {
        super.eat();//表现为:早期绑定
        System.out.println("猫吃鱼");
    }

    @Override
    public void hunt() {
        System.out.println("捕食耗子,天经地义");
    }
}

public class AnimalTest {
    public void showAnimal(Animal animal) {
        animal.eat();//表现为:晚期绑定
    }

    public void showHunt(Huntable h) {
        h.hunt();//表现为:晚期绑定
    }
}

部分字节码:

复制代码
{
  public com.atguigu.java2.AnimalTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 54: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/atguigu/java2/AnimalTest;

  public void showAnimal(com.atguigu.java2.Animal);
    descriptor: (Lcom/atguigu/java2/Animal;)V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: aload_1
         1: invokevirtual #2                  // Method com/atguigu/java2/Animal.eat:()V
         4: return
      LineNumberTable:
        line 56: 0
        line 57: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/atguigu/java2/AnimalTest;
            0       5     1 animal   Lcom/atguigu/java2/Animal;

  public void showHunt(com.atguigu.java2.Huntable);
    descriptor: (Lcom/atguigu/java2/Huntable;)V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: aload_1
         1: invokeinterface #3,  1            // InterfaceMethod com/atguigu/java2/Huntable.hunt:()V
         6: return
      LineNumberTable:
        line 60: 0
        line 61: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/atguigu/java2/AnimalTest;
            0       7     1     h   Lcom/atguigu/java2/Huntable;
}
SourceFile: "AnimalTest.java"
  1. invokevirtual 体现为晚期绑定

  2. invokeinterface 也体现为晚期绑定

  3. invokespecial 体现为早期绑定

多态&绑定

Java当中任何普通方法实际上都具备虚函数特征,相当于C++当中的虚函数(C++当中需要使用 virtual进行显式定义)Java程序不希望某个方法拥有虚函数特征时,可以使用final标记这个方法

虚&非虚方法

虚方法&非虚方法区别

若方法在编译期就确定了具体的调用"版本",版本在运行时不可变的,这样的方法称为非虚方法。

  • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法

其他方法称为虚方法

子类对象多态使用前提:

  1. 类的继承关系

  2. 方法的重写

虚拟机中调用方法的指令:

  • 普通指令

    • invokestatic:调用静态方法

    • invokespecial:调用<init>方法(构造方法)、私有方法、父类方法

  • 动态调用指令

invokedynamic:动态解析出需要调用的方法,之后执行

前四条指令固化在虚拟机内部,方法的调用执行不可认为干预。而invokedynamic指令则支持用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其他方法(fianl修饰除外)称为虚方法。

EXMAMPLE
复制代码
class Father {
    public Father() {
        System.out.println("father的构造器");
    }

    public static void showStatic(String str) {
        System.out.println("father " + str);
    }

    public final void showFinal() {
        System.out.println("father show final");
    }

    public void showCommon() {
        System.out.println("father 普通方法");
    }
}

public class Son extends Father {
    public Son() {
        //invokespecial
        super();
    }

    public Son(int age) {
        //invokespecial
        this();
    }

    //不是重写的父类的静态方法,因为静态方法不能被重写!
    public static void showStatic(String str) {
        System.out.println("son " + str);
    }

    private void showPrivate(String str) {
        System.out.println("son private" + str);
    }

    public void show() {
        //invokestatic
        showStatic("atguigu.com");
        //invokestatic
        super.showStatic("good!");
        //invokespecial
        showPrivate("hello!");
        //invokespecial
        super.showCommon();

        //invokevirtual
        showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
        //虚方法如下:
        
        /*
        invokevirtual  你没有显示的加super.,编译器认为你可能调用子类的showCommon(即使son子类没有重写,也会认为),所以编译期间确定不下来,就是虚方法。
        */
        showCommon();
        info();

        MethodInterface in = null;
        //invokeinterface
        in.methodA();
    }

    public void info() {

    }

    public void display(Father f) {
        f.showCommon();
    }

    public static void main(String[] args) {
        Son so = new Son();
        so.show();
    }
}

interface MethodInterface {
    void methodA();
}

Son类当中show()方法字节码如下

关于invokedynamic命令

JVM字节码指令集一直很稳定,直到Java7中才增加了invokedynamic指令。这是Java为实现动态类型语言支持而做的一种改进。

但是Java7当中并没有提供直接生成invokedynamic的指令的方法,需要借助ASM这种底层字节码工具生成invokedynamic指令。直到Java8的Lambda表达式,invokedynamic指令,在Java中才有直接的生成方式。

Java7当中添加动态语言类型支持本质上是对Java虚拟机指令的修改,而不是对Java语言规则的修改。增加JVM当中方法调用,最直接的受益者是运行在Java平台上的动态语言编译器。

复制代码
@FunctionalInterface
interface Func {
    public boolean func(String str);
}

public class Lambda {
    public void lambda(Func func) {
        return;
    }

    public static void main(String[] args) {
        Lambda lambda = new Lambda();

        Func func = s -> {
            return true;
        };

        lambda.lambda(func);

        lambda.lambda(s -> {
            return true;
        });
    }
}

动态语言&静态语言

动态类型语言和静态类型语言之间的区别就是对类型的检查是在编译期还是运行期。满足前者的就是静态类型语言,反之是动态类型语言。

换种说法就是,静态类型语言判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

Java:String info = "mogu blog"; (Java是静态类型语言的,会先编译就进行类型检查) JS:var name = "shkstart"; var name = 10; (运行时才进行检查)

Python: info = 130.5 (运行时才检查)

Java语言中方法重写的本质

  1. 找到操作数栈栈顶元素执行对象的实际类型,记为C

  2. 如果类型C中找到与常量中描述符合的方法,进行访问权限校验。

    1. 通过则返回这个方法的直接引用,查找过程结束

    2. 不通过,则返回java.lang.IllegalAccessError异常

  3. 否则按照继承关系从下往上依次对C各个父类进行第二步的验证过程

  4. 始终没有找到合适的方法,抛出java.lang.AbstractMethodError异常

上述过程被称为动态分派

IllegalAccessError介绍

  1. 程序试图通过访问或者修改一个属性或者调用一个方法,这个属性或者方法你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

  2. 例如:将对应的jar包从工程当中拿走或者Maven当中jar包存在冲突

虚方发表

面向对象执行过程当中,会很频繁使用到动态分派,如果在每次动态分派的过程当中都要重新在类的方法元数据当中查找合适的目标的话可能影响到程序执行效率。为了提高性能,JVM采用在类方法区 建立一个虚方法表virtual method table,非方法不会出现在表当中。使用索引替代直接查找。

EX1

如图,若类当中重写类方法,那么在调用的时候,就会直接在该类的虚拟方法表当中查找

  1. 例如son在调用toString的时候,Son没有重写过,Son的父类Father也没有重写过,那就直接调用Object类的toString方法。直接在虚拟方法表当中知名toString方法指向Object类。

  2. 下次Son对象 调用toString方法就直接去找Object,不用找Son→再找Father→最后才到Object这样的过程

EX2

由于CockerSpaniel当中的sayHello方法和sayGoodbye方法都被重写过,所以指向的都是当前这个类自己

方法返回地址

注:

帧数据区=局部变量表+方法返回地址+操作数栈+动态链接+附加信息

  1. 方法返回地址存储的是调用者方法中 invoke*** 指令下一条指令的地址(PC寄存器当中值)

  2. 一个方法结束有两种方式:

    1. 正常执行完成

    2. 出现未处理异常,非正常退出

  3. 无论通过什么方式退出,在方法退出之后都返回到该方法被调用时候的位置,调用者的PC计数器值作为返回地址。通过异常退出的,返回地址则需要通过异常表 进行确定,栈帧当中一般不保存这类信息。

  4. 本质上,方法的退出就是当前栈帧出栈过程。此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,继续执行调用者方法。

  5. 正常完成出口和异常完成出口区别在于:通过异常完成出口退出不会给上层调用者任何返回值。

正常退出

执行迎请遇到任意一个方法返回的字节码指令也就是return ,会有返回值传递给上层调用者方法------即 正常完成出口

方法在正常调用完成之后使用什么返回指令,需要根据方法返回值实际情况而定。

字节码指令当中,返回指令包括:

  • ireturn:返回值是boolean、byte、char、short、int类型使用

  • lreturn:Long类型使用

  • freturn:Float类型使用

  • dreturn:Double类型使用

  • areturn:引用类型

  • return:返回类型为void方法、实例初始化方法、类&接口初始化方法

异常退出

方法执行过程当中遇到Exception,并且这个异常没有在方法内进行处理,也就是在本方法异常表当中没有搜索到匹配的异常处理器,到数值方法退出------即 异常完成出口

方法执行过程当中,trycatchfinally代码块/syncronized捕获的异常类型存储在异常表当中,也就是一张用于指示异常处理的规则表。"万一发生这种异常,请按照这张表导航"(异常表大小固定,与抛出异常数量无关,编译期生成)

  • 记录每一个try块的起止范围(start_pc/end_pc)

  • 对应的处理器入口地址

  • 以及匹配条件(catch_type,0表示finally,非0表示具体处理异常类)

方法执行过程当中抛出异常时,JVM遍历该查找表查找匹配项,一旦命中就将PC计数器设置为handler_pc,跳转到catch、fianlly执行。

(any对应的本质就是catch_type=0,表示捕获任意异常,即finally或者catch(Throwable),any是反编译工具对catch_type = 0的友好显示)

反向编译字节码文件能够得到Exception Table

  • from:字节码指令起始地址

  • to:字节码指令结束地址

  • target:出现异常跳转到地址为11的指令执行命令

  • type:捕获异常类型

附加信息

栈帧中允许携带与JVM实现相关的附加信息。EX:对程序调试提供支持的信息。

栈相关面试题

栈溢出情况

StackOverFlow:栈大小固定时可能出现

OutOfMemory:栈大小动态变化,内存不足时出现

调整栈大小,能保障不出现溢出?

不能保证不出现栈溢出,只能保证出现SOF的几率小。

分配栈内存越大越好么?

不是,一定时间内降低了OOM概率但是挤占了其他线程空间,因为整个JVM内存空间是有限的。

GC是否涉及VM栈

NO

方法当中定义的局部变量是否线程安全

具体问题具体分析:

  1. 一个线程才可以操作此数据,线程安全

  2. 多个线程能够操作此数据,此数据是共享数据,不使用同步机制的话存在线程安全问题

对象是当前线程内部产生并在内部消亡,没有给到外部,那么就在当前线程其是线程安全的

method1------栈封闭,100%安全(方法调用&本身)
复制代码
public static void method1() {
    StringBuilder sb = new StringBuilder(); // ✅ 栈封闭:仅本方法内创建、使用、消亡
    sb.append("a").append("b");
    String result = sb.toString(); // ✅ 返回新 String(不可变),同时 sb 引用未逃逸
}

线程安全原因:

  1. sb为局部变量,分配在当前线程栈帧当中

  2. 没有赋值给任何static字段、未放入共享容器、未传给其他线程

  3. toString返回新的String对象,原sb对象仍然被栈帧持有,方法退出即不可达

method2------方法本身并不安全,调用是否安全取决于参数
复制代码
public static void method2(StringBuilder sb) {
    sb.append("a").append("b"); // ❗ 危险:操作的是传入的 sb 对象
}

结论:

  • method2 方法本身不是线程安全的(它不控制 sb 的归属);

  • ✅ 但如果调用时 sb 是栈封闭的,则本次调用安全:

    public static void safeCall() {
    2 StringBuilder sb = new StringBuilder(); // ✅ 栈封闭
    3 method2(sb); // ✅ 安全调用
    4}

  • ❌ 如果 sb 是共享的,则调用不安全:

    private static StringBuilder sharedSb = new StringBuilder(); // ❌ static 共享

    public static void unsafeCall() {
    method2(sharedSb); // ❌ 多线程调用此方法 → sb 被并发修改 → 数据错乱
    }

原因:

  1. method2参数sb是引用传递,方法内对sb修改影响原始对象

  2. 原始对象被多个线程共享,必然存在数据竞争(并发写情况)

method3------方法本身不安全,返回值可能引发不安全
复制代码
public static StringBuilder method3() {
    StringBuilder sb = new StringBuilder();
    sb.append("a").append("b");
    return sb; // ❗ 返回的是 sb 的引用!
}

结论:

  • method3 方法本身不是线程安全的(它返回了可变对象引用);

  • ✅ 但如果调用方立即使用且不共享,则本次调用安全:

    public static void safeUse() {
    StringBuilder sb = method3(); // ✅ 接收返回值
    sb.append("c"); // ✅ 仍在本线程内使用
    System.out.println(sb); // ✅ 未发布
    }

  • ❌ 如果调用方将其发布到共享区,则不安全:

    private static StringBuilder globalSb; // ❌ static 共享

    public static void unsafePublish() {
    globalSb = method3(); // ❌ 将 method3 创建的对象发布到 static 字段!
    }

原因:

  • 返回sb的引用=将本线程创建的对象"递出窗外"

  • 接受方法若将其存入static或者共享容器就完成了不安全发布

method4------返回不可变对象,方法本身线程安全
复制代码
public static String method4() {
    StringBuilder sb = new StringBuilder();
    sb.append("a").append("b");
    return sb.toString(); // ✅ 返回新 String(final 字段,不可变)
}

方法本身线程安全

原因:

  • sb栈封闭

  • toString()创建新的String对象,内部char[]在Java9是final且String类被设计为不可变

  • 返回值是不可变对象,接受方法无法改变其状态或者影响原sb对象

Method5------main方法中典型不安全调用
复制代码
public static void main(String[] args) {
    StringBuilder s = new StringBuilder(); // ❌ 这里创建了共享对象!

    new Thread(() -> {
        s.append("a"); // ❌ 线程 A 修改 s
        s.append("b");
    }).start();

    method2(s); // ❌ 线程 B(主线程)也修改 s → 竞态条件!
}

❌绝对线程不安全

原因:

  • s为局部变量,但是被显示传递给另外一个线程发生线程间引用传递

  • s称为两个线程共享可变状态

  • StringBuilder.append()非原子操作(读数组长度→扩容→写入),必然导致数据错乱或者ArrayIndexOutOfBoundException

相关推荐
Coder_Boy_1 小时前
Java高级_资深_架构岗 核心知识点全解析(通俗透彻+理论+实践+最佳实践)
java·spring boot·分布式·面试·架构
识君啊1 小时前
Java 动态规划 - 力扣 零钱兑换与完全平方数 深度解析
java·算法·leetcode·动态规划·状态转移
HoneyMoose1 小时前
Eclipse Temurin JDK 21 ubuntu 安装
java·ubuntu·eclipse
笨蛋不要掉眼泪1 小时前
Sentinel 热点参数限流实战:精准控制秒杀接口的流量洪峰
java·前端·分布式·spring·sentinel
蜜獾云1 小时前
Java集合遍历方式详解(for、foreach、iterator、并行流等)
java·windows·python
※DX3906※1 小时前
Java多线程3--设计模式,线程池,定时器
java·开发语言·ide·设计模式·intellij idea
weixin_448119941 小时前
Datawhale Easy-Vibe 202602 第3次笔记
笔记
知识分享小能手1 小时前
SQL Server 2019入门学习教程,从入门到精通,SQL Server 2019 数据库的备份与恢复 — 语法知识点及使用方法详解(19)
数据库·学习·sqlserver
風清掦2 小时前
【江科大STM32学习笔记-06】TIM 定时器 - 6.2 定时器的输出比较功能
笔记·stm32·单片机·嵌入式硬件·学习