浅谈 Java 程序运行
JVM 是如何启动的?
- 配置 JVM 装载环境
- 解析虚拟机参数
- 设置线程栈大小
- 执行 JavaMain 方法
内存是如何管理的?
JVM 内存模型
程序运行视角下的 Java 内存管理
此处所说的 JVM 内存模型是一种通用逻辑模型,与具体的虚拟机实现无关,虚拟机可以根据实际情况基于通用逻辑模型,给出不同的具体实现模型
知识补充:方法区的不同实现
JDK8 之前,Hotspot 虚拟机的方法区实际上是永久代实现的,永久代是堆的一部分 → 方法区是堆的一部分
JDK8 之后,Hotspot 虚拟机不再使用永久代,而是采用了全新的元空间,实现了方法区和堆的分离
类的元信息被存储在元空间中。元空间使用与堆不相连的本地内存区域 → 本地内存即元空间
理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题
示意图
类比:C 语言内存模型
详细描述
-
方法区(Method Area)是一块用于存储被加载的类的信息、常量、静态变量等数据的内存区域。它在虚拟机启动时被创建,并在所有线程结束后销毁。
-
堆(Heap)是用于存储Java对象的内存区域。所有通过new关键字创建的对象和数组都会在堆中分配内存。堆的大小是动态可调整的,由JVM的参数决定。
-
虚拟机栈(VM Stack)用于存储Java方法的局部变量、中间计算结果和方法调用等信息。每个线程在运行时都会创建一个对应的虚拟机栈,每个方法在执行时都会创建一个栈帧,栈帧中存储了方法的参数、局部变量以及方法执行完后需要返回的地址等信息。
-
本地方法栈(Native Method Stack)用于支持由Java调用本地方法(使用Native关键字声明的方法)。本地方法栈类似于虚拟机栈,但是它不同于虚拟机栈的地方在于它是为本地方法而服务的。
-
程序计数器(Program Counter Register)是一块较小的内存区域,它存储了当前线程所执行的字节码指令的地址。在任何时候,每个线程都有一个独立的程序计数器,用于控制线程的执行。
程序计数器 (Program Counter Register) 是唯一一个不会导致 OOM (OutOfMemoryError) 异常的内存区域。
这是因为程序计数器是 线程私有的,它记录了当前线程执行的字节码指令的地址。
由于程序计数器的存储需求非常小,一般只需要存储一个指针地址,因此不会出现OOM的情况。
省流版本
- 线程私有:线程启动时会自动创建,结束之后会自动销毁。
- 程序计数器:保存当前程序的执行位置。
- 虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
- 本地方法栈:同上,作用与本地方法。
- 线程共享:随着虚拟机的创建而创建,虚拟机的结束而销毁
- 堆:所有的对象和数组都在这里保存。
- 方法区:类信息、即时编译器的代码缓存、运行时常量池。
JVM 内存区域划分
程序计数器(线程私有)
JVM 中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号正好就指的是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令
虚拟机栈(线程私有)
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(其实就是栈里面的一个元素),栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。
虚拟机栈中的栈帧最先创建的是主线程的栈帧,随着方法调用的深入,会依次创建更多的栈帧。执行完毕的方法栈帧会被销毁,最后线程执行完成时,虚拟机栈也会被销毁。
- 局部变量表:记录方法中的局部变量,在class文件中就已经定义好了
- 操作数栈:字节码执行时使用到的栈结构
- 动态链接:运行时确定方法的具体调用地址(当前方法调用其他方法,记录调用信息)
- 方法出口:方法执行完成后的返回地址(当前方法作为被调用的方法,记录返回结果)
本地方法栈(线程私有)
类似于虚拟机方法栈,但是处理的是本地方法
堆(线程共享)
堆是 Java 虚拟机中用于存储对象实例的内存区域。
方法区(线程共享)
方法区用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分
-
类信息表:存放当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息
-
运行时常量池:存放编译时生成的常量以及运行时动态生成的常量
-
编译时生成常量
- 内容:编译时生成的常量包括字符串常量、基本数据类型的常量、类和接口的全限定名、方法和字段的符号引用等。这些常量在运行时被加载到运行时常量池中,并在运行期间被使用。
- 过程
- 获取:在类加载过程中,获取字符串常量、基本数据类型的常量、类和接口的全限定名、方法和字段的符号引用等
- 转换:在类加载过程中,将将运行时需要使用的符号引用转换为直接引用
- 存储:将转换后的信息存储到运行时常量池中
-
运行时动态生成常量
-
字符串拼接:在代码中使用字符串拼接操作时,编译器会将其优化为StringBuilder的append操作,然后将结果放入运行时常量池。
String str = "Hello" + " World";
-
基本类型的计算结果:在编译时进行常量表达式计算时,如果结果是常量,会被放入运行时常量池。
int result = 2 + 3;s
-
Class.forName()加载类:运行时使用Class.forName()加载一个类时,该类的全限定名会被放入运行时常量池
Class<?> clazz = Class.forName("com.example.MyClass");
-
-
内存限制
堆栈内存限制
-
堆溢出:对象创建过多或是数组容量过大时,就会导致堆内存不足以存放更多新的对象或是数组
-
可以通过参数来控制堆内存的最大值和最小值
# Xms:初始堆大小(Initial Heap Size) # Xmx:最大堆大小(Maximum Heap Size) -Xms最小值 -Xmx最大值
-
-
栈溢出:虚拟机栈会在方法调用时插入栈帧,如果调用层数过多,就会导致栈内存不足以存放更多的栈帧
-
可以通过参数来控制堆内存的最大值和最小值
# Xss:栈大小(Stack Size) -Xss数值
-
物理内存限制
除了堆内存可以存放对象数据以外,也可以申请堆外内存(直接内存),也就是不受 JVM 管控的内存区域,这部分区域的内存需要我们自行去申请和释放,实际上本质就是JVM通过C/C++调用malloc
函数申请的内存,当然也需要手动释放了。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制
JMM
并发编程视角下的 Java 内存管理
Java 内存模型,基于 JVM 内存模型的二次抽象,此处提一嘴只是为了与 JVM 内存模型进行对比,JMM 相关的具体内容主要放在 Java 并发编程部分
示例图
类比:计算机缓存结构
详细描述
- Java线程:Java线程是Java程序中的执行单元,它可以独立执行、并发执行,并且具有独立的栈和程序计数器,能够运行在多核CPU上。
- 工作内存:工作内存是Java线程中的一个私有数据区域,用于存储线程执行时需要的数据。每个线程都拥有自己的工作内存,包含线程栈和程序计数器。工作内存存储了线程在执行过程中需要使用的局部变量、运算结果和中间值等数据。
- 主内存:主内存是Java中所有线程共享的共同数据区域,其中存储了所有的实例变量、静态变量和类定义等数据。主内存的数据可以被所有线程访问,线程之间通过主内存进行通信。线程在执行过程中,需要从主内存中读取数据到工作内存中进行操作,然后再将修改后的结果写回主内存。
省流版本
- Java 线程:运行中的程序
- 工作内存:线程私有内存
- 主内存:线程共享内存
垃圾回收机制
- 垃圾识别
- 垃圾清理
垃圾识别
对象存活判定
标记 + 计数
机制:引用计数为 0 的可回收
没人用的就回收
判定过程
实际上,我们会发现,只要一个对象还有使用价值,我们就会通过它的引用变量来进行操作,那么可否这样判断一个对象是否还需要被使用:
- 每个对象都包含一个 引用计数器,用于存放引用计数(其实就是存放被引用的次数)
- 每当有一个地方引用此对象时,引用计数
+1
- 当引用失效( 比如离开了局部变量的作用域或是引用被设定为
null
)时,引用计数-1
- 当引用计数为
0
时,表示此对象不可能再被使用,因为这时我们已经没有任何方法可以得到此对象的引用了
循环引用
无法识别循环引用的一组对象
可达性分析算法
遍历对象引用图
机制:在进行垃圾回收的过程中,GC Roots 不可达的对象将被回收,可达的对象不能被回收
可能用到的不回收
哪些引用适合作为 GC Roots?
不可达的会被回收 → 可达的不能被回收 → 保证可用性的对象更适合作为GC Root,以确保相关对象不会被错误地回收
但凡可能要用到的,都有必要保证可用性
- 栈帧中的对象:方法运行时用到的数据,有必要保证可用性
- 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象
- 位于本地方法栈中 JNI 引用的对象
- 类的静态成员变量引用的对象:静态成员变量引用的对象类相关,而类长存,有必要保证可用性
- 方法区中常量池里面引用的对象:常量池引用对象类相关,而类长存,有必要保证可用性
- 被添加了锁的对象:加锁证明在用,同时为保证线程安全,有必要保证可用性
- 虚拟机内部需要用到的对象:虚拟机内部要用,有必要保证可用性
分析过程
-
将符合条件的对象设定 GC Roots,根据递归引用关系建立连接
-
已经存在的根节点不满足存在的条件时,断开根节点和其他对象的连接
-
回收 GC Roots 不可达的对象
循环引用
对象1和对象2依然是存在循环引用的,但是只要他们各自的 GC Roots 断开,循环引用照样能回收
最终判定
可达性分析 + 二次标记
虽然在经历了可达性分析算法之后基本可能判定哪些对象能够被回收,但是并不代表此对象一定会被回收,我们依然可以在最终判定阶段对其进行挽留
扣 1 复活对象
兄弟们扣 1 真的有用
所有的类都继承于 Object 类,Object 类中有 finalize()
方法
如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()
方法,而在此方法中,当前对象完全有可能重新建立与 GC Roots 的连接!如果此时重新建立与 GC Roots 的连接,则不会被回收(打赢复活赛)
判定流程
注意事项
finalize()
方法也并不是专门防止对象被回收的,一般使用它来释放一些程序使用中的资源finalize()
方法并不是在主线程调用的 → 延迟复活- 同一个对象的
finalize()
方法只会有一次调用机会
垃圾清理机制
垃圾收集器会不定期地检查堆中的对象,查看它们是否满足被回收的条件。下一步要做的就是垃圾回收
一个个回收效率太低,需要提供更高效的批量回收机制
分代收集机制
某些对象,在多次垃圾回收时,都未被判定为可回收对象,我们完全可以将这一部分对象放在一起,并让垃圾收集器减少回收此区域对象的频率,这样就能很好地提高垃圾回收的效率了。
将堆内存划分为 新生代、**老年代 **和 永久代
回收流程
- 新创建的对象放在新生代的 Eden 区(如果是大对象会被直接丢进老年代)
- 对所有新生代对象进行扫描,回收废弃对象
- 在首次扫描后没被回收的,放到新生代的 Survivor 区中的 From 区,然后交换一次 From 区 和 To 区
- 二次 GC
- 对于 Eden 区 的操作与首次 GC 一致:存活的新对象放到 Survivor 的 From 区,再交换一次 From 和 To
- 对于 To 区的对象进行一次年龄判定
- 年龄 + 1
- 判断
年龄 > 默认值 15
是否成立,符合该条件的对象放入老年代,不符合该条件的放回 From,再交换一次 From 和 To(每次完整 GC,Survivor 区的对象都在 To 区,以保证对 To 区对象的年龄判定正常进行)
- ……
- n 次 GC
垃圾收集也分为:
- Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。
- 触发条件:新生代的Eden区容量已满时。
- Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。
- Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。
- 触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间
- 触发条件2:Minor GC后存活的对象超过了老年代剩余空间
- 触发条件3:永久代内存不足(JDK8之前)
- 触发条件4:手动调用
System.gc()
方法
空间分配担保
正常情况下新生代回收率高,不用考虑 Survivor 区的空间限制问题
但是当新生代回收率低时,就可能出现 Survivor 装不下的情况,为应对这种特殊情况,需要设定空间分配担保机制
- 老年代空间充足时,Survivor 区无法容纳的对象直接送到老年代,让老年代进行分配担保
- 若老年代空间可能不足,则先会判断之前的每次垃圾回收进入老年代的平均大小是否小于当前老年代的剩余空间,如果小于,那么说明也许可以放得下,尝试将对象直接送到老年代,否则,会先来一次Full GC,来尝试腾出空间,再次判断老年代是否有空间存放,要是还是装不下,直接抛出OOM错误
Minor GC 的整个过程:
垃圾清理算法
标记-清除算法
首先标记出所有需要回收的对象,然后再依次回收掉被标记的对象
- 优点:简单
- 缺点
- 如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。
- 一次标记清除之后,连续的内存空间可能会出现空隙,碎片化会导致连续内存空间利用率降低
标记-复制算法
将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。
相当于清完之后,再排队迁移,类比磁盘碎片整理
- 优点:避免碎片化问题
- 缺点
- 需要预留空间 → 无需阻塞(好处)
- 复制消耗时间
这种算法就非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收
标记-整理算法
标记-复制算法适合回收率高的情况(新生代),但是如果遇到回收率低的情况(老年代),就不值得了
一般长期都回收不到的对象,才有机会进入到老年代,所以老年代一般都是些钉子户,可能一次GC后,仍然存留很多对象。而标记复制算法会在GC后完整复制整个区域内容,并且会折损50%的区域,显然这并不适用于老年代。
在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。
- 优点
- 避免碎片化问题
- 无需预留空间 → 过程阻塞(代价)
- 缺点:效率更低,由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿
垃圾收集器
我们可以自由地为新生代和老年代选择更适合它们的收集器。
- Serial 收集器:元老级垃圾收集器,新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法
- ParNew 收集器:多线程版 Serial 收集器
- Parallel Scavenge/Parallel Old 收集器:引入自适应机制,自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间
- CMS 收集器:第一款真正意义上的并发垃圾收集器
- Garbage First (G1) 收集器:保留分代收集思想,但是引入 Region 分块机制
数据是如何存储的?
Java 数据类型
- 基本数据类型:在内存中直接存储数据的值
- 6 种数字类型:
- 4 种整数型:
byte
、short
、int
、long
- 2 种浮点型:
float
、double
- 4 种整数型:
- 1 种字符类型:
char
- 1 种布尔型:
boolean
- 6 种数字类型:
- 引用数据类型:存储对象在内存中位置的引用
- 类
- 接口
- 数组
存储形式
注:此处的栈指的是虚拟机栈,JVM 内存模型中对于栈的划分更为细致,分为虚拟机栈和本地方法栈两部分
- 基础数据类型(primitive data types)直接存储在栈(stack)
当我们声明并初始化一个基础数据类型的变量时,它会直接在栈中分配内存,并将对应的值存储在该内存空间中。 - 引用数据类型(reference data types)存储在堆(heap)中
当我们声明并初始化一个引用数据类型的变量时,变量本身会在栈中分配内存,但实际的数据存储会在堆中分配内存,并通过引用(地址)的方式存储在栈中。
总结:在 Java 中,基础数据类型(如 int、boolean、char 等)直接存储在栈中,而引用数据类型(如对象、数组)的引用存储在栈中,实际对象或数组的内容存储在堆中。
例如,我们假设有以下代码:
int number = 10;
Person person = new Person("Alice");
在内存中的存储情况如下:
栈中:
number
变量存储整数值 10person
变量存储对象引用,指向堆中的 Person 对象
堆中:
- Person 对象的内容,包括一个 name 字段和相应的值
对于基础数据类型,存储在栈中的变量直接存储其值; 对于引用数据类型,存储在栈中的变量存储的是对应对象在堆中的地址,通过这个地址可以找到存储在堆中的实际内容。
类文件结构
类文件信息
.class 文件
采用了一种类似于 C 中结构体的伪结构来存储数据
而结构体中,有两种允许存在的数据类型,一个是无符号数,还有一个是表。
- 无符号数一般是基本数据类型,用u1、u2、u4、u8来表示,表示1个字节~8个字节的无符号数。可以表示数字、索引引用、数量值或是以UTF-8编码格式的字符串。
- 表包含多个无符号数,并且以"_info"结尾。
结构:整理 By jclasslib Bytecode Viewer
- 一般信息
- 次版本号
- 主版本号
- 常量池计数
- 访问标志
- 本类索引
- 父类索引
- 接口计数
- 字段计数
- 方法计数
- 属性计数
- 常量池
- 接口
- 字段
- 方法
- 属性
字节码指令
虚拟机的指令是由一个字节长度的、代表某种特定操作含义的数字(操作码,类似于机器语言),操作后面也可以携带0个或多个参数一起执行。JVM实际上并不是面向寄存器架构的,而是面向操作数栈,所以大多数指令都是不带参数的。
public static void main(String[] args) {
int i = 10;
int a = i++;
int b = ++i;
}
ASM 字节码编程
可以直接编写一个字节码文件,这样能省去编译的过程
本质上都是 01,只不过字节码编程有点开历史倒车的感觉,工具类框架可以用这种机制实现代码生成或代码修改
类加载机制
什么时候自动加载?(类的加载触发条件)
一般在这些情况下,如果类没有被加载,那么会被自动加载:
- 使用new关键字创建对象时
- 使用某个类的静态成员(包括方法和字段)的时候(当然,final类型的静态字段有可能在编译的时候被放到了当前类的常量池中,这种情况下是不会触发自动加载的)
- 使用反射对类信息进行获取的时候(之前的数据库驱动就是这样的)
- 加载一个类的子类时
- 加载接口的实现类,且接口带有
default
的方法默认实现时
详细加载过程
- 加载
- 获取此类的二进制数据流
- 类加载器将类的所有信息加载到方法区中
- 在堆内存中生成一个代表当前类的Class类对象
- 链接
- 校验:对加载的类进行一次规范校验
- 准备:为类变量分配内存,并为一些字段设定初始值,注意是系统规定的初始值,不是我们手动指定的初始值
- 解析:将常量池内的符号引用替换为直接引用
- 初始化:会开始执行 Java 代码,为字段设定手动指定的初始值
- 使用
- 卸载
类加载器
- 一个类可以由不同的类加载器加载
- 只有来自同一个
.class文件
并且是由同一个类加载器加载的,才能判断为是同一个类 - 默认情况下,所有的类都是由JDK自带的类加载器进行加载
双亲委派机制
双亲委派(Parent Delegation)机制是Java虚拟机(JVM)用来加载类的一种机制。
根据这个机制,当程序加载一个类时,JVM会首先检查自己的内部类加载器是否已经加载过该类。如果已经加载过,就直接返回这个类的引用;如果没有加载过,JVM会将该类的加载请求委派给它的父类加载器去加载。一直到达 Bootstrap 类加载器。如果 Bootstrap 类加载器也无法加载该类,那么会将加载请求返回给发起类加载请求的类加载器,由该类加载器尝试加载。
可以避免重复加载已经被加载过的类,确保使用相同名称的类时,不同的类加载器加载的是同一个类,保证类的唯一性和安全性。
类和对象是如何存储的?
存储位置
在 Java 中,类的信息是存储在方法区中的类信息表中。类信息表包含了类的结构、字段、方法等信息。
而对象被实例化时,会在堆中分配内存空间,用于存储对象的实际数据。对象在内存中的存储包括实例变量、对象头信息、GC相关信息等。对象通过引用变量指向,并通过引用变量来操作对象。而类引用存储在虚拟机栈中。
- 类:存储在方法区的类信息表中
- 对象信息:存储在堆中
- 对象引用:存储在虚拟机栈中
存储流程
- 类加载:将类的信息存储到方法区中的运行时常量池和类信息表中
- 实例化
- 对象创建:在堆中分配空间,将对象信息存到堆中
- 对象引用:在虚拟机栈中分配空间,创建并绑定对象引用
指令是如何执行的?
程序 vs 进程
- 程序:静态描述
- 进程:动态过程
进程是程序的执行实例,进程是运行中的程序
源程序
public class Main {
public static void main(String[] args) {
int a = 0;
int b = 0;
int c;
c = a + b;
}
}
javac Main.java
每个 class 编译一个 .class 文件
,而非每个 .java 文件
编译一个 .class 文件
反编译
public class Main {
public Main() {
}
public static void main(String[] var0) {
byte var1 = 0;
byte var2 = 0;
int var10000 = var1 + var2;
}
}
class 反编译
javap -c Main.class
public class Main {
public Main();
Code:
0: aload_0 // 加载当前类的this对象到操作数栈
1: invokespecial #1 // 调用父类java/lang/Object的构造函数 "<init>"
4: return // 返回
public static void main(java.lang.String[]);
Code:
0: iconst_0 // 将常量0推送到操作数栈
1: istore_1 // 将操作数栈顶的值(0)存入本地变量表的索引1处
2: iconst_0 // 将常量0推送到操作数栈
3: istore_2 // 将操作数栈顶的值(0)存入本地变量表的索引2处
4: iload_1 // 将本地变量表的索引1处的值(0)推送到操作数栈
5: iload_2 // 将本地变量表的索引2处的值(0)推送到操作数栈
6: iadd // 将操作数栈顶的两个值相加
7: istore_3 // 将操作数栈顶的值(0+0=0)存入本地变量表的索引3处
8: return // 返回
}
模拟汇编
将函数代码看作 C 语言代码,转换为 x86 汇编语言
section .data
a dd 0
b dd 0
c dd 0
section .text
global _start
_start:
mov eax, [a] ; 将变量 a 的值加载到 eax 寄存器
mov ebx, [b] ; 将变量 b 的值加载到 ebx 寄存器
add eax, ebx ; 将 eax 和 ebx 寄存器中的值相加
mov [c], eax ; 将 eax 寄存器中的值保存到变量 c