跳转至

JVM Basic

Java 代码是如何运行起来的

字节码文件

Java 源代码经过编译后得到 .class 文件,又称为字节码文件

JDK, JRE, JVM

  • JDK : Java Development Kit
  • JRE : Java Runtime Environment

image-20241224160832801

各个操作系统有自己对应的 JDK,JDK 包含了 JRE

JVM 内存结构

JVM 内部的 java 内存模型在 逻辑上 划分为

  1. 线程栈(又称为方法栈 / 调用栈)
  2. 堆内存
  • 线程栈 中保存了调用链上正在执行的所有方法中的局部变量,每个线程只能访问自己的线程栈。
  • 堆内存 中保存了代码中创建的所有对象(不论是哪个线程创建的,且涵盖了 Integer 等包装类型)。

image-20241223141014557

总结:

  • 原始数据类型和对象引用地址在栈上
  • 对象、对象成员与类定义、静态变量在堆上
  • 当不同线程(通过本地的栈上的引用)访问同一个对象实例中的成员变量时,每个线程在本地会有一个变量的副本

    关于 volatile :

    • volatile 修饰的是共享内存中的数据,比如修饰 static 数据,其修饰的数据储存在方法区中
    • 对于方法区的变量,线程直接读取/改写,而非将其挪到线程的栈上
    • 但是注意到 CPU 和内存间的高速缓存,volatile 对于变量的修改不一定会直接反映在主存中
      • 这里可以结合 cache 的 write back 策略理解

JVM 运行时数据区

  • JVM 内存:受虚拟机内存大小的参数控制,当大小超过设置的大小时会报 OOM
  • 本地内存:被利用但是不虚拟机直接管理,其大小不受参数控制,但是当超过物理内存的容量限制时会报 OOM

1. 程序计数器

任何时间一个线程都只有一个方法在执行,称为当前方法

  • 当前线程正在执行 Java 方法,程序计数器记录 JVM 字节码指令的行号
  • 正在执行 native 方法,则是 undefined

程序计数器是线程私有的,生命周期与线程的生命周期一致,由字节码解释器 控制

此数据区是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 的区域

2. 虚拟机栈(Java栈)

img

注意,这里的“基本数据类型”指的是类型是基本数据类型的方法本地变量

Java 方法有两种返回函数的方式:

  1. 正常返回 return

  2. 抛出异常

    虚拟机栈有两种异常状况:

    • 请求的栈深度大于虚拟机允许的深度:StackOverFlowError
    • 如果虚拟机栈容量可以动态扩展,当无法在扩展时申请到足够的内存:OutOfMemoryError

3. 本地方法栈

  • 虚拟机栈为虚拟机执行 Java 方法(字节码)服务
  • 本地方法栈为虚拟机使用到的 native method 服务

二者都是线程私有的。

Hotspot 虚拟机直接把本地方法栈和虚拟机栈合二为一了

4. 堆

在 Java8 中主要存放:

  • 对象实例
  • 字符串常量池
  • 静态变量:又称为 类变量 ,static 修饰
  • 线程分配缓冲区:用于提升对象分配时的效率

Java 虚拟机规范规定,Java 堆可以处在物理不连续,但逻辑需要连续的区域中,堆的大小是可以拓展的(通过 -Xmx-Xms 控制)。

如果堆中的内存不够完成实例的内存分配,且堆无法再拓展,则会抛出 OutOfMemoryException 异常

堆内存的进一步划分

  • Eden 区:新对象最初会被分到 Eden 区,且 Eden 区较大,频繁进行垃圾回收
  • Survivor 区:两个 Suervivor 区 S0 和 S1 交替使用,新对象在 Eden 区经过一次垃圾回收后存放到其中的一个 Survivor 区,进一步存活的对象会移到另一个 Survivor 区,最终变为老年代
  • 老年代:长生命周期对象经过多次垃圾回收后会被移到老年代,Major GC 在老年代进行,频率低、耗时长

5. 方法区

方法区是所有线程共享的内存,

  • 在java8以前是放在JVM内存中的,由永久代实现,受JVM内存大小参数的限制
  • 在java8中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM参数的限制(当然,如果物理内存被占满了,方法区也会报OOM),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中,方法区与其他区域不同的地方在于,方法区在编译期间和类加载完成后的内容有少许不同,不过总的来说分为这两部分:

    1. 类元信息(Klass)

      • 类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)
      • 常量池表(Constant Pool Table)存储了类在编译期间生成的字面量、符号引用(什么是字面量?什么是符号引用?),这些信息在类加载完后会被解析到运行时常量池中
    2. 运行时常量池(Runtime Constant Pool)

      • 运行时常量池主要存放在类加载后被解析的字面量与符号引用,但不止这些
      • 运行时常量池具备动态性,可以添加数据,比较多的使用就是String类的intern()方法
  • JDK8 之前,HotSpot 使用永久代来实现方法区
  • JDK8 之后使用元空间 metaspace ,元空间使用的是本地内存

运行时常量池

当类加载到内存中后,JVM就会将class常量池中的内容存放到运行时常量池中;运行时常量池里面存储的主要是编译期间生成的字面量、符号引用等等。

  • 类加载在链接环节的解析过程,会符号引用转换成直接引用(静态链接)。此处得到的直接引用也是放到运行时常量池中的。
  • 运行期间可以动态放入新的常量。
  • 类加载 的“解析”阶段,使用 静态链接 来将符号引用转换为直接引用
    • 适用于 静态方法、私有方法、构造方法、final 方法等
  • 运行时 通过动态链接将符号引用替换为直接引用
    • 适用于实例方法(无论是否被重写)
// 动态链接的例子
public class Dog extends Animal {
 @Override
 public void sound() {
     System.out.println("Dog barks");
 }
}

public class Test {
 public static void main(String[] args) {
     Animal animal = new Dog(); // 向上转型
     // 静态类型为 Animal
     // 实际类型为 Dog
     // 编译器并不知道其实际类型,方法调用依赖于实际类型
     animal.sound();
 }
}

这段代码中的 animal.sound(); 就是符号引用,编译器会将 sound 方法的符号引用存在常量池中

这部分内容将在类加载后进入方法区的运行时常量池中存放。JVM 为每个已加载的类型(类 / 接口)都维护一个运行时常量池,在加载类和接口到虚拟机后创建。所以运行时常量池相对于 Class 文件常量池的一个重要特性是动态性。

不要求常量只有在编译期才能产生,运行时也可以把新的常量放入池中,例如:

// 使用 new 创建字符串,不会放入字符串常量池
String str3 = new String("Hello");

// 使用 intern() 方法将字符串放入字符串常量池
String str4 = str3.intern();