- 为什么要有JVM
- JVM是什么?
- JVM的工作流程和组成部分
- JVM规范和JVM实现
- JVM原理详解
带着以上问题,我将尝试对JVM作出一些简单的介绍。
一、JVM 简介
在90年代初,软件开发面临一个大问题,即不同的操作系统和硬件架构要求开发不同的版本。这不仅增加了开发的复杂性,还大大增加了维护成本。而JVM 的设计即源于一种强烈的需求——实现“一次编写,到处运行”(Write Once, Run Anywhere)的理念。这一理念的核心是希望开发者能编写一次代码,然后在任何支持JVM的平台上无需修改代码就能运行。
逻辑上,Java虚拟机(JVM)是一种能够执行Java字节码(Bytecode)的抽象计算机。物理上,它是Java运行时环境(Java Runtime Environment,JRE)的核心部分,提供了一个平台无关的运行环境,确保Java程序可以在任何设备上运行,只要这些设备上安装了兼容的JVM。
这里有必要简要介绍一下JRE和JDK,也许能够使你更加理解JVM。
JRE
JRE是一个用于运行Java程序的计算机软件执行环境。Java程序是由Java编程语言编写的,这些程序需要在计算机上安装Java Runtime Environment才能运行。
JRE包含Java虚拟机(JVM),Java类库和Java运行时工具。 Java虚拟机是Java程序的关键组件,它将Java程序翻译成计算机可以理解的语言。 Java类库是一组预定义的类和接口,可以帮助程序员编写Java程序并简化开发过程。Java运行时工具包括Java Applet插件和Java Web Start等工具,用于在Web浏览器和桌面环境中执行Java应用程序。JRE是Java平台的核心组件之一,可以在多个操作系统上运行,包括Windows、Mac和Linux等。无论是开发Java程序还是运行Java程序,都需要安装相应版本的JRE。
JDK
JDK是Java开发工具包(Java Development Kit)的缩写,是一个用于开发和编译Java应用程序的软件开发工具包。JDK包含了JRE(Java Runtime Environment)中的所有组件,并加入了Java编译器、Java文档生成器、调试器等开发工具。因此,如果需要进行Java程序的开发和编译,需要安装JDK。如果只需要运行Java程序,可以仅安装JRE。
简而言之,JDK和JRE是Java开发和运行的两个不可或缺的组件,它们之间的关系是JDK包含了JRE,JRE是JDK的子集,因为JDK中包含了Java编译器和其他开发工具,所以开发人员需要安装JDK才能进行Java程序的开发和编译。而JRE仅包含Java运行时环境,可以用于运行Java程序,但不能进行Java程序的开发和编译。
不过,从 JDK 9 开始,就不需要区分 JDK 和 JRE 的关系了,取而代之的是模块系统(JDK 被重新组织成 94 个模块)+ jlink 工具 (随 Java 9 一起发布的新命令行工具,用于生成自定义 Java 运行时映像,该映像仅包含给定应用程序所需的模块) 。并且,从 JDK 11 开始,Oracle 不再提供单独的 JRE 下载。
在 Java 9 新特性概览这篇文章中,介绍模块化系统的时候提到:
在引入了模块系统之后,JDK 被重新组织成 94 个模块。Java 应用可以通过新增的 jlink 工具,创建出只包含所依赖的 JDK 模块的自定义运行时镜像。这样可以极大的减少 Java 运行时环境的大小。
也就是说,可以用 jlink 根据自己的需求,创建一个更小的 runtime(运行时),而不是不管什么应用,都是同样的 JRE。
定制的、模块化的 Java 运行时映像有助于简化 Java 应用的部署和节省内存并增强安全性和可维护性。这对于满足现代应用程序架构的需求,如虚拟化、容器化、微服务和云原生开发,是非常重要的。
JVM规范与JVM实现
在具体讨论JVM之前,必须区分和明确两个概念--JVM规范与JVM实现。Java虚拟机(JVM)规范和JVM的实现是两个互补的概念,它们共同确保Java程序的可移植性、稳定性和效率。JVM规范为Java虚拟机的设计和行为提供了严格的指导,而JVM的实现则是这些规范的具体执行,不同的厂商可以基于规范开发自己的JVM版本。
JVM规范
Java虚拟机规范是一份技术文档,由Sun Microsystems(现为Oracle Corporation的一部分)发布,用于定义Java虚拟机的正确行为。这份规范详细描述了JVM的行为,包括其类加载机制、内存模型、垃圾回收机制、执行引擎、字节码指令集等方面。规范的主要目的是确保任何遵循这些规范的JVM实现都能无缝执行编译后的Java程序。
下面我们将简要解释这些组件。
1. 类文件格式
Java类文件是一种包含Java编译后代码(字节码)的文件,其格式严格定义在JVM规范中。类文件格式使得Java程序可以被各种不同的JVM实现所加载和执行。每个类文件都包括以下主要部分:
- 魔数(Magic Number):每个类文件的前四个字节是0xCAFEBABE,用于识别文件是否为有效的Java类文件。
- 版本号:紧随魔数之后的是该类文件的版本号,包括主版本号和次版本号,它们表明类文件是由哪个版本的Java编译器生成的。
- 常量池(Constant Pool):类文件中的一部分,用于存储各种文字字符串、类名、接口名、字段名和其他常量。几乎每种类型的数据都可以在常量池中找到。
- 访问标志(Access Flags):指示类或接口层次结构的访问信息(如public或private类)。
- 类索引、父类索引与接口索引集合:这些索引指向常量池中的项,标识这个类、其父类及实现的接口。
- 字段表集合:列出类中的字段,每个字段都有自己的结构,包括访问权限、名称和描述符。
- 方法表集合:列出类中的方法,包括方法的访问权限、名称、描述符及字节码。
- 属性表集合:在类、字段和方法表中,都可以包含属性表。属性表用于存储额外的和自定义的数据,例如,代码方法的字节码操作指令存储在这里。
2. 数据类型
JVM支持的数据类型主要分为两大类:基本类型和引用类型。
- 基本类型:包括整型、浮点型、长整型、双精度浮点型、字符型、布尔型等。
- 引用类型:包括类类型、接口类型和数组类型。
这些类型的行为在JVM中是预定义的,例如,整型总是按32位处理,而对象引用是对堆中对象的引用。
3. 操作指令集
JVM的指令集是为操作和转换JVM堆中的数据设计的。指令分为几大类:
- 加载和存储指令:用于从局部变量表或数组中加载数据到栈顶,或者将栈顶数据存储到局部变量表或数组。
- 算术指令:执行基本的算术运算,如加法、减法、乘法和除法。
- 类型转换指令:将一种类型转换成另一种类型,比如
i2l
(int转long)。 - 对象操作指令:包括对象的创建(
new
),字段访问指令(getfield
和putfield
),以及数组操作指令。 - 控制转移指令:进行条件分支、循环和跳转,控制执行流程。
- 方法调用与返回指令:包括对静态、非静态方法的调用(
invokevirtual
、invokeinterface
等)以及方法返回指令(ireturn
、areturn
等)。
4. 运行时数据区
JVM规定了多种运行时数据区域,其中每一种都有特定的用途:
- 程序计数器(Program Counter, PC):每个线程都有一个PC,用于存储当前线程执行的字节码指令地址。
- Java虚拟机栈(JVM Stack):存储帧(frames),每个帧包含了方法调用的局部变量表、操作栈等信息。
- 堆(Heap):JVM管理的最大数据区,用于存储所有的对象实例和数组。
- 方法区(Method Area):用于存储每个类的结构如运行时常量池、字段和方法数据、构造函数和普通方法的代码等。
- 本地方法栈(Native Method Stack):为JVM使用到的本地方法服务。
5. 执行模型
JVM使用帧(frames)来处理方法调用和方法执行。每当一个方法被调用,一个新的帧就会被创建并被压入当前线程的栈中。这个帧包含了方法的局部变量、操作数栈和一个指向运行时常量池中该方法的引用。方法结束时,帧被弹出栈,并将结果(如果有的话)返回给调用者。
6. 多线程
JVM支持从单一进程中派生多线程。JVM允许多个线程并发执行,并提供同步机制来控制对数据的访问,防止数据的不一致性。同步是通过监视器(monitor)实现的,它是JVM用于同步多个线程对共享资源访问的一种机制。
关于这部分的详细内容,我在Java内存模
型详解一文中做了更详细的介绍,感兴趣同学欢迎自行查阅。
7. 垃圾回收
JVM规范没有指定使用特定的垃圾回收算法,但定义了垃圾回收的基本概念和需求。主要目的是自动管理内存,识别并丢弃那些不再被程序使用的对象。JVM的垃圾回收机制是自动的,通常基于可达性分析来确定对象是否可回收。
这些详细的规范和定义确保Java程序的高效、稳定和可预测的执行,同时为JVM实现提供了严格的标准。
JVM实现
JVM的实现指的是根据JVM规范开发的具体软件,这些软件能够在特定系统和硬件上运行,解释和执行Java字节码。不同的供应商可能会根据自己的产品需求,优化其JVM实现,以提高性能、增强安全性或者改进垃圾回收机制。
主要实现包括:
- Oracle HotSpot:最广泛使用的JVM实现之一,它是Oracle JDK和OpenJDK的一部分。HotSpot著名的即时编译器(JIT)可以优化频繁执行的代码,提高程序性能。本文如果没有特别指出,则都是基于Oracle HotSpot介绍的。
- OpenJ9:由Eclipse Foundation维护,原先是IBM的J9 VM。它以较低的内存占用和快速的启动时间为特点。
- Azul Zing JVM:一个专为企业和云应用设计的JVM实现,能够处理大规模内存分配和实时垃圾回收。
- GraalVM:一个高性能JVM实现,支持多种编程语言,包括JavaScript、Python、Ruby等,通过使用先进的即时编译技术来提高各种语言的性能。
规范与实现的关系
JVM规范为Java虚拟机的行为设定了基准,确保了不同实现之间的一致性。JVM的每个实现都必须遵守这些规范,但可以在此基础上进行创新和优化。例如,虽然垃圾回收机制的具体算法不在规范中明确规定,但实现者可以选择或开发最适合其场景需求的垃圾回收算法。
这种规范与实现的关系保证了Java应用程序的高度可移植性。开发人员只需编写一次代码,就可以在各种不同的JVM实现上运行,而不需要做任何修改。同时,这也促进了JVM实现之间的健康竞争和技术创新,推动了Java技术的发展和完善。
而且虽然 Java 语言“一次编译,随处可以运行”,但是JVM 其实有针对不同系统的特定实现(Windows,Linux,macOS),目的是使相同的字节码运行之后,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处可以运行”的关键所在。接下来我们具体探究一下JVM的作用,包括其工作流程和组成部分。
JVM 工作流程简介
当一个Java程序被开发完成后,它首先被编译成一种中间形式,称为字节码,这一步由Java编译器完成(通常是javac
)。字节码是一种中立于平台的二进制格式,设计目的是使得Java代码可以在多种硬件和操作系统上运行,只需相应的JVM支持即可。
运行Java程序时,JVM通过其类加载器(ClassLoader)加载这些字节码文件,然后由执行引擎将字节码转换成特定机器的机器码。在这个过程中,JVM也负责管理程序所需的资源,包括内存分配、垃圾回收等。
JVM主要组成部分
JVM主要包含以下几个核心组件:
- 类加载器(Class Loader):负责加载Java应用的类文件到运行时数据区。类加载器在加载类文件时,会将它们的内容(如方法定义)转换为一个内部数据结构,存放在方法区内。
- 运行时数据区(Runtime Data Area):存放Java运行时数据,包括堆(Heap)、栈(Stack)、方法区(Method Area)、程序计数器(Program Counter)等。其中,堆是JVM中最大的一块内存区域,用于存放对象实例;栈用于存放局部变量和方法调用;方法区用于存储已被虚拟机加载的类信息、常量、静态变量等数据。
- 执行引擎(Execution Engine):是JVM中的一个非常核心的部分,负责执行类文件中的字节码。执行引擎可以使用解释执行(即逐条将字节码翻译成机器码并执行)或者JIT编译执行(将热点代码编译成本地代码以提高效率)。
- 本地接口(Native Interface):为Java应用提供调用操作系统服务及第三方库的接口。
- 垃圾收集器(Garbage Collector):自动管理内存,回收不再被使用的对象,以释放和重用资源。
二、类文件格式
根据 Java 虚拟机规范,Class 文件通过 ClassFile
定义,有点类似 C 语言的结构体。
ClassFile
的结构如下:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//字段数量
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
通过分析 ClassFile
的内容,我们便可以知道 class 文件的组成。
下面这张图是通过 IDEA 插件 jclasslib
查看的,你可以更直观看到 Class 文件结构。
使用 jclasslib
不光可以直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、函数等信息。
下面详细介绍一下 Class 文件结构涉及到的一些组件。
魔数(Magic Number)
u4 magic; //Class 文件的标志
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。
Class 文件版本号(Minor&Major Version)
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。
每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v
命令来快速查看 Class 文件的版本号信息。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。
常量池(Constant Pool)
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
紧接着主次版本号之后的是常量池,常量池的数量是 constant_pool_count-1
。
Java 类文件中的常量池(Constant Pool)是一个非常核心的组件,它存储了类中所有的符号引用和字面量,包括类和接口的全名、字段的名称和描述符、方法的名称和描述符、以及其他常量如字符串常量、数值常量等。常量池作为类文件结构的一部分,其目的是为了减少类文件中重复信息的大小,提供高效的数据管理和快速的查找能力。
constant_pool_count
表示常量池数组中,常量项的数量。重要的是要注意,这个计数值比实际常量项的数目多一,这是因为常量池的索引是从 1 开始的,而不是从 0 开始。因此,如果 constant_pool_count
为 1,表示常量池中没有任何常量项。常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”
Java类文件的常量池(Constant Pool)是类文件结构中的一个关键组件,其作用是集中存储字面量和符号引用,这些信息在类的生命周期中被多次使用。常量池使得Java类文件的结构更为紧凑,增加了模块化,同时也提高了JVM处理类定义的效率。在深入了解常量池之前,我们需要明白它在Java类文件中的基本作用和构成。
常量池的作用
- 复用和优化:常量池存储了各种文字和数字常量,这些常量可能在多个地方被引用,通过集中存储,相同的常量不需要被重复定义。
- 解耦和链接:常量池中包含了对其他类、接口、字段和方法的符号引用,这些引用在类被加载进JVM时被解析。这种机制支持了Java的动态链接,即类的实际引用在运行时才确定。
常量池的组成
常量池中的项可以是多种类型,包括:
- 数值常量:包括整型、浮点型等基本类型的值。
- 字符串常量:用于存储文本字符串,例如类中使用的所有字符串字面值。
- 类和接口的符号引用:包括类和接口的全限定名。
- 字段的符号引用:包括字段的名称和描述符,描述符用来表示字段的数据类型。
- 方法的符号引用:包括方法的名称和方法描述符,描述符包含方法的返回类型及其参数类型。
- 方法句柄和方法类型:用于支持Java的动态语言特性。
- 动态计算的常量:支持从Java 11开始的动态常量。
每个常量池条目都有其类型标识,例如:
CONSTANT_Class
: 类或接口的符号引用。CONSTANT_Fieldref
: 字段的符号引用。CONSTANT_Methodref
: 类中方法的符号引用。CONSTANT_InterfaceMethodref
: 接口中方法的符号引用。CONSTANT_String
: 字符串类型的常量。CONSTANT_Integer
: 整数类型的常量。CONSTANT_Float
: 浮点类型的常量。CONSTANT_Long
: 长整型常量。CONSTANT_Double
: 双精度浮点类型的常量。CONSTANT_NameAndType
: 描述字段或方法的名称和类型。CONSTANT_Utf8
: UTF-8编码的字符串。CONSTANT_MethodHandle
: 方法句柄。CONSTANT_MethodType
: 方法类型。CONSTANT_InvokeDynamic
: 支持动态语言调用。
.class
文件可以通过javap -v class类名
指令来看一下其常量池中的信息(javap -v class类名-> temp.txt
:将结果输出到 temp.txt 文件)。
常量池在类加载过程中的作用
当JVM加载一个类文件时,常量池中的符号引用会被用来进行链接。在解析阶段,JVM会查找这些符号引用对应的实际地址,从而使得类、字段和方法的引用都转变为可直接访问的直接引用。
常量池的设计不仅减少了类文件的大小,通过消除重复的常量信息,也优化了内存的使用和处理速度。此外,它支持Java语言的动态特性,例如反射和动态类型语言的支持,这些都依赖于常量池中的动态解析功能。
访问标志(Access Flags)
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。
类访问和属性修饰符:
我们定义了一个 Employee 类
通过javap -v class类名 指令来看一下类的访问标志。
当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合
Java 类的继承关系由类索引、父类索引和接口索引集合三项确定。类索引、父类索引和接口索引集合按照顺序排在访问标志之后,
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。
接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。
字段表集合(Fields)
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
field info(字段表) 的结构:
●access_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
●name_index: 对常量池的引用,表示的字段的名称;
●descriptor_index: 对常量池的引用,表示字段和方法的描述符;
●attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
●attributes[attributes_count]: 存放具体属性具体内容。
上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。
字段的 access_flag 的取值:
方法表集合(Methods)
methods_count 表示方法的数量,而 method_info 表示方法表。
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
method_info(方法表的) 结构:
方法表的 access_flag 取值:
注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。
属性表集合(Attributes)
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
在Java类文件中,属性表(attributes)是一种用于描述类、字段或方法的附加信息的结构。属性表包含了各种元数据,用于提供关于类、字段或方法的额外信息,例如编译器版本、注解信息、调试信息等。属性表的结构在Java虚拟机规范中有详细描述。
与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
以下是Java类文件属性表的一些常见属性及其含义:
- SourceFile(源文件):描述了源文件的名称。这个属性通常包含在类文件中,用于指示编译该类的源文件的文件名。
- ConstantValue(常量值):用于描述一个字段的常量初始值。例如,如果一个字段被声明为
static final int x = 10;
,那么它的初始值为10,这个信息就可以通过ConstantValue属性来存储。 - Code(代码):用于描述一个方法的字节码指令和相关信息。每个方法都有一个对应的Code属性,其中包含了方法的字节码以及与该方法执行相关的各种信息,如局部变量表、操作数栈、异常处理器等。
- Exceptions(异常表):用于描述一个方法可能抛出的异常类型。该属性包含了一个异常表,列出了方法可能抛出的各种异常类型。
- LineNumberTable(行号表):用于描述Java源代码中行号与字节码指令之间的对应关系。这个属性可以帮助调试器在源代码级别上进行调试。
- LocalVariableTable(局部变量表):用于描述方法中局部变量的信息。包括局部变量的名称、作用域、数据类型等。
- InnerClasses(内部类):用于描述类中嵌套的内部类信息,包括内部类的名称、访问修饰符等。
- Deprecated(已废弃):用于标记一个类、字段或方法已经被废弃,不推荐再使用。这个属性可以提醒开发者在使用该类、字段或方法时注意替代方案。
这些是Java类文件中常见的属性表及其含义。每个属性都有一个特定的结构和格式,用于存储不同类型的元数据信息。属性表的存在使得Java类文件能够存储丰富的附加信息,为Java虚拟机和开发工具提供了丰富的元数据支持。
三、运行时数据区
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
JDK 1.8 和之前的版本略有不同,我们这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。
JDK 1.7:
JDK 1.8:
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的。
程序计数器
程序计数器(Program Counter Register)是Java虚拟机(JVM)的一个核心组成部分,它在JVM架构中扮演着至关重要的角色。程序计数器是一种非常小的内存区域,它可以被看作是当前线程所执行的字节码的行号指示器。每个线程都有自己的程序计数器,这是线程私有的内存。
程序计数器的主要功能是存储指向当前线程正在执行的JVM指令的地址,或者,如果是执行本地方法,则该计数器值为空(Undefined)。这个特性使得CPU在多线程切换后能够恢复到正确的执行位置。
由于Java虚拟机支持多线程运行,每个线程都需要一个独立的程序计数器,以便在任何时刻能够知道接下来需要执行哪一条指令。例如,在Java的并发环境中,程序计数器帮助记录每个线程在源代码中的执行位置,从而使线程在获得CPU时间片时可以从正确的位置开始或继续执行代码。并且,由于每个线程都有自己的程序计数器,一个线程的程序计数器不会影响到另一个线程。这种设计自然地提供了线程安全性,因为每个线程的程序计数器独立于其他线程操作。
程序计数器为每个线程的执行流提供连续的跟踪,因此其对于内存管理和异常处理也具有重要意义。例如,在发生异常时,JVM可以利用程序计数器的信息来确定发生异常的确切位置,并据此处理异常或进行相应的跳转。
除此之外,在JVM的执行过程中,程序计数器帮助虚拟机执行精确的控制流管理,这对于JVM的性能优化也是非常关键的。由于程序计数器帮助管理指令流,它使得JVM能够更有效地处理指令跳转、循环以及方法调用等,这对于整体性能是有益的。
尽管程序计数器是一个相对独立的部分,它与JVM的其他内存区域如堆(Heap)、栈(Stack)和方法区(Method Area)等是相互作用的。例如,当一个方法被调用时,程序计数器会记录这个方法中当前执行指令的位置,而方法的具体数据则存储在栈和堆中。
程序计数器是JVM的基础设施之一,它确保了多线程环境下的高效和准确执行。每个线程拥有自己的程序计数器,从而确保线程之间的执行是隔离的,增强了多线程程序的稳定性和可靠性。此外,程序计数器对于JVM的异常管理、性能优化以及准确的控制流执行同样至关重要。通过这种方式,Java虚拟机能够支持复杂的多线程操作并保持高效的运行。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
Java虚拟机栈是Java虚拟机(JVM)中的一块内存区域,用于存储方法的局部变量、部分方法参数、方法的返回地址以及操作数栈等数据。这块内存区域是线程私有的,也就是说每个线程在执行的时候都会有自己的虚拟机栈。
让我们来看一下虚拟机栈中的主要元素:
- 局部变量表(Local Variable Table):局部变量表存储了方法参数和方法内部定义的局部变量。对于实例方法来说,局部变量表中第一个位置存储的是this引用,对于静态方法来说则不包含this引用。
- 操作数栈(Operand Stack):操作数栈用于存储方法执行过程中的操作数,包括方法的参数、方法返回值以及方法执行过程中的临时变量等。大多数Java虚拟机指令都是针对操作数栈进行操作的。
- 动态链接(Dynamic Linking):每个栈帧(Stack Frame)中包含了一个指向运行时常量池中该栈帧所属方法的引用,这个引用被称为方法的动态链接。在Java虚拟机规范中,方法的动态链接被分为两个阶段:解析和绑定。解析阶段主要是将常量池中的符号引用转换为直接引用;绑定阶段主要是将方法的调用和字段的访问转换为对应的内存地址。
- 返回地址(Return Address):返回地址指向了方法执行完成后需要返回的地址。当一个方法被调用时,返回地址会被压入虚拟机栈中;当方法执行完毕时,虚拟机栈会根据返回地址返回到方法被调用的位置继续执行。
Java虚拟机栈的大小可以在启动时通过参数来指定,如果方法调用的层次太深导致栈空间不足,就会抛出栈溢出异常(StackOverflowError)。同样地,如果方法执行过程中需要的栈空间超出了虚拟机栈的最大限制,也会抛出栈溢出异常。
为什么Java虚拟机需要虚拟机栈呢?这是因为Java是一种面向对象的编程语言,它支持方法的递归调用和多线程并发执行。虚拟机栈的存在能够有效地管理方法调用过程中的局部变量和方法执行的状态,同时也能保证线程间的独立性,确保每个线程都能够安全地执行自己的方法调用。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError
错误。
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
除了 StackOverFlowError
错误之外,栈还可能会出现OutOfMemoryError
错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
简单总结一下程序运行中栈可能会出现两种错误:
StackOverFlowError
: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
错误。OutOfMemoryError
: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法栈(Native Method Stack)是Java虚拟机(JVM)中的另一块内存区域,与虚拟机栈类似,但是它用于执行本地方法,即使用非Java语言编写的方法。本地方法栈的作用是为虚拟机提供与本地(Native)方法的接口,使得Java程序能够调用底层的系统功能或者其他语言(如C、C++)编写的库。
让我们来详细了解本地方法栈的结构和作用:
- 执行本地方法:本地方法栈与虚拟机栈类似,但是它不存储Java方法调用的相关信息,而是存储本地方法调用的相关信息。当Java程序调用本地方法时,虚拟机会在本地方法栈中创建一个新的栈帧,用于执行本地方法的代码。
- 与虚拟机栈的关系:本地方法栈与虚拟机栈之间是相互独立的,每个线程都有自己的本地方法栈。虚拟机栈主要用于执行Java方法,而本地方法栈主要用于执行本地方法。两者的区别在于虚拟机栈中存储的是Java方法调用的相关信息,而本地方法栈中存储的是本地方法调用的相关信息。
- 内存分配:本地方法栈的大小可以在启动时通过参数来指定,与虚拟机栈类似。如果本地方法调用的层次太深导致本地方法栈空间不足,就会抛出栈溢出异常(StackOverflowError)。同样地,如果本地方法执行过程中需要的栈空间超出了本地方法栈的最大限制,也会抛出栈溢出异常。
- 性能优化:由于本地方法调用涉及到与底层系统交互的操作,因此本地方法栈的性能优化对于提高Java程序的整体性能非常重要。一些Java虚拟机实现会采用特定的优化策略来加速本地方法的执行,比如使用本地方法栈的缓存技术来减少本地方法调用的开销。
总的来说,本地方法栈是Java虚拟机的重要组成部分,它为Java程序提供了与底层系统交互的接口,使得Java程序能够调用本地方法并与底层系统进行通信。通过对本地方法栈的管理和优化,可以提高Java程序的性能和可靠性,同时也能够更好地支持与底层系统的集成。
java 线程是内核级还是用户级线程
Java线程本质上是由底层操作系统的线程模型来实现的,这意味着它们可以是用户级线程也可以是内核级线程,具体取决于Java运行在其上的操作系统和Java虚拟机(JVM)的实现。
用户级线程与内核级线程的概念
- 用户级线程(User-Level Threads, ULT):
-
- 这类线程完全在用户空间中管理和调度,不依赖于操作系统核心支持。
- 用户级线程的调度不需要操作系统内核的介入,这可以减少创建和管理线程时的开销。
- 缺点是,如果一个用户级线程开始执行系统调用并阻塞了,它可能会导致其它同进程的线程全部阻塞,因为操作系统只看到一个执行线程(进程)。
- 内核级线程(Kernel-Level Threads, KLT):
-
- 内核级线程由操作系统内核直接支持和管理。
- 这种线程可以被操作系统调度器独立调度,如果一个线程阻塞,不会影响同一进程中的其它线程。
- 内核级线程的创建、销毁和同步通常比用户级线程更耗时,因为涉及到更多的系统调用和上下文切换。
Java线程的实现
Java平台试图将其线程模型映射到宿主操作系统的线程模型上。这意味着Java线程的行为(用户级或内核级)取决于JVM如何在特定平台上实现这一映射。
- 在早期的Java版本中,如Java 1.2以前,线程模型由纯Java实现的用户级线程和基于操作系统的内核级线程混合实现。
- 从Java 1.3开始,大多数主流JVM实现(如Sun/Oracle HotSpot JVM)采用了一对一的模型,将每个Java线程直接映射到一个内核级线程上。这样做可以充分利用多核处理器的优势,以及更好地支持现代操作系统的线程调度和管理特性。
当前实践
当前,大多数现代JVM实现,如HotSpot JVM,都使用操作系统的内核级线程来实现Java线程。这意味着Java线程在运行时表现为内核级线程,能够利用操作系统提供的多线程能力,例如在多核处理器上的真正并行执行。
总结来说,Java线程在现代JVM中通常是内核级线程,它们的行为和性能特征受底层操作系统线程管理策略的直接影响。这种实现方式帮助Java应用有效利用多线程环境,提高了应用的并发性能和响应能力。
堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。堆(Heap)是Java虚拟机中最重要的内存区域之一,用于存储对象实例和数组对象。堆是所有线程共享的内存区域,在Java程序运行期间被动态地分配和管理。
以下是堆的详细介绍:
- 对象存储:堆主要用于存储Java程序中创建的对象实例和数组对象。在Java程序中,使用
new
关键字创建的对象都会被存储在堆中。对象在堆中分配的内存空间大小由对象的类型和实例变量决定,而对象的引用则存储在栈上。 - 堆的分代结构:为了更有效地管理堆中的内存空间,Java堆通常被划分为几个不同的分代。通常情况下,Java虚拟机将堆分为新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation)或者元空间(Metaspace)等部分。新生代主要存放新创建的对象,老年代主要存放长期存活的对象,而永久代(或元空间)主要存放类的元数据信息。
- 自动内存管理:Java堆的内存空间由垃圾回收器(Garbage Collector)负责管理。垃圾回收器会定期扫描堆中的对象,标记出哪些对象是不再被引用的,然后将这些对象占用的内存空间释放出来以便重新利用。垃圾回收器的目标是尽可能地减少内存碎片并保证程序的性能。
- 堆的大小设置:堆的大小可以在启动时通过命令行参数来指定,常见的参数包括
-Xms
用于设置堆的初始大小,-Xmx
用于设置堆的最大大小。通过适当地设置堆的大小,可以避免堆溢出(OutOfMemoryError)的问题,同时也能够提高Java程序的性能。 - 内存分配策略:Java堆中的内存空间是动态分配的,通常采用指针碰撞(Pointer Bumping)或者空闲列表(Free List)等方式来分配内存空间。指针碰撞是将堆分为已分配区域和未分配区域,通过移动指针来分配内存空间;而空闲列表则是维护一个列表,记录着可用的内存块,分配内存时从列表中找到合适的内存块分配给对象。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。 (我会在方法区这部分内容详细介绍到)。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:
堆这里最容易出现的就是 OutOfMemoryError
错误,OutOfMemoryError
是 Java 虚拟机(JVM)在无法分配对象因为堆内存不足时抛出的一个错误,这表明 JVM 在尝试扩展堆至其可用最大内存限制但仍然不足以满足内存需求时遇到了问题。这种错误通常是内存管理问题的信号,需要开发者对应用的内存使用情况进行仔细分析和优化。下面,我来介绍常见的几种 OutOfMemoryError
的表现形式及其原因。
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
这个错误表明,垃圾回收器花费了大量时间(默认是超过了98%的时间)来回收非常少的内存(不到2%的堆),并且这种情况连续多次发生。在这种情况下,JVM 抛出此错误是为了防止应用长时间无响应。它通常出现在堆大小接近阈值且无法有效释放足够内存的情况下。
处理方法
- 增加堆大小:可以通过调整
-Xmx
参数来增加JVM的最大堆内存。 - 优化应用逻辑:减少内存消耗,改进程序的数据处理逻辑,使用更高效的算法或数据结构。
- 垃圾回收调优:调整垃圾回收器的设置,选择适合当前应用的垃圾回收策略。
java.lang.OutOfMemoryError: Java heap space
这种错误发生在 JVM 的堆内存中没有足够的空间来为新对象分配内存。这通常发生在应用尝试创建大量对象,尤其是大对象(如大数组或大集合),并且堆的大小不足以支持这些对象的存储。
处理方法
- 增加堆大小:通过
-Xmx
参数增加堆的最大大小。 - 内存泄露检查:使用内存分析工具(如 VisualVM, Eclipse Memory Analyzer)来检测和解决内存泄露问题。
- 代码优化:优化代码以减少不必要的对象创建,使用内存更高效的数据处理方法。
其他常见的 OutOfMemoryError
类型
- java.lang.OutOfMemoryError: PermGen space / Metaspace:这种类型的错误发生在持久代(PermGen)或元空间(Metaspace)空间不足的情况下。持久代用于存放类的元数据信息,Java 8之后被元空间替代。解决方法包括增加持久代或元空间的大小,以及检查可能的内存泄露问题。
- java.lang.OutOfMemoryError: Requested array size exceeds VM limit:这种错误发生在尝试创建一个超过JVM限制的大数组时。例如,尝试创建一个非常大的数组,其大小超过了
Integer.MAX_VALUE
。
处理 OutOfMemoryError
需要根据错误的具体类型来采取相应的措施。通常,这包括增加JVM的内存设置,优化代码,以及使用专门的工具进行性能和内存分析。理解不同类型的 OutOfMemoryError
可以帮助开发者更有效地诊断和解决内存使用相关的问题。
方法区
方法区(Method Area)是Java虚拟机(JVM)中的一块内存区域,用于存储类的结构信息、常量、静态变量、即时编译器编译后的代码等数据。方法区在JVM规范中是一种逻辑上的内存模型,其实现可以是堆上的一部分,也可以是非堆内存中的一部分。
1. 类信息存储:方法区主要用于存储已加载类的元数据信息,包括类的完整结构、方法信息、字段信息、接口信息、父类信息等。这些信息在类加载时被存储在方法区,并在整个程序的运行期间保持不变。
2. 运行时常量池:方法区包含运行时常量池,用于存储类文件中的常量池数据。在类加载过程中,类文件中的常量池会被解析为JVM运行时常量池,其中存储着类的字面量和符号引用。这些常量包括字符串、类和接口的全限定名、字段和方法的名称和描述符等。
3. 静态变量:方法区存储类的静态变量,这些变量在类加载时被分配内存空间,并在整个程序的生命周期内存在。静态变量被所有实例共享,可以通过类名直接访问。
4. 即时编译器编译后的代码:方法区可能包含即时编译器(JIT)编译后的本地机器代码。当JVM检测到某段代码被频繁执行时,会将其编译为本地机器代码,并将其存储在方法区以提高执行效率。
5. 常量池中的符号引用解析:方法区包含常量池中的符号引用解析结果,这些解析结果在运行期间用于动态链接和方法调用。
6. 类和方法的字节码:方法区存储类和方法的字节码,这些字节码在类加载后被解析为可执行代码,并在运行时执行。
7. 类加载器的相关信息:方法区还包含有关类加载器的相关信息,包括类加载器的结构和类加载过程中的各种信息。
总的来说,方法区在JVM中扮演着非常重要的角色,它不仅存储类的结构信息和运行时常量池,还包含静态变量和即时编译器编译后的代码等数据。了解方法区的工作原理和特性有助于开发者更好地理解Java虚拟机的内存模型,从而编写出高效、稳定的Java应用程序。
常量池、运行时常量池和字符串常量池
Java虚拟机(JVM)中的常量池概念是理解Java内存管理和类加载机制的重要部分。我们将探讨三种类型的常量池:类文件中的常量池、运行时常量池和字符串常量池,并讨论它们的概念、位置以及相互之间的联系和区别。
1. 类文件中的常量池(Constant Pool Table)
类文件中的常量池,通常被称为静态常量池,是存在于Java .class文件中的结构。它包含了类中所有的字面量和符号引用,这些信息在编译期被收集并存入常量池。字面量如文本字符串、定义的数值;符号引用如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。
位置:类文件中的常量池位于Java类文件的结构中,它在类加载时被读取并用于构造运行时常量池。
2. 运行时常量池(Runtime Constant Pool)
运行时常量池是类文件中静态常量池的运行时表示。它是方法区(或Java虚拟机的元空间)的一部分,每个加载的类或接口都有一个运行时常量池,用于存储编译期生成的各种字面量和符号引用。
位置:运行时常量池位于方法区中。
功能:它的主要作用是为Java类提供动态链接的部分信息。比如,当一个类需要引用另一个类的方法时,这种引用将存储在运行时常量池中,直到第一次使用时才被解析成实际的内存地址。
3. 字符串常量池(String Pool)
字符串常量池,也就是Java中的String Intern Pool,是一个特殊的存储区域,用来存储Java字符串对象。它的目的是节省存储空间,通过共享所有的字符串字面值。当创建一个字符串时,JVM首先检查字符串常量池,如果字符串已经存在,则返回引用同一对象的引用;如果不存在,字符串将被添加到池中,并返回其引用。
位置:字符串常量池位于Java堆内。JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
联系与区别:
- 联系:运行时常量池和类文件中的常量池在概念上是连续的,前者是后者在JVM内部的运行时表示。字符串常量池则与运行时常量池有交集,因为运行时常量池中的字符串字面量引用都指向字符串常量池中的对象。
- 区别:类文件中的常量池是静态的、编译期决定的数据集合,主要存储信息和符号引用;运行时常量池是这些静态数据的动态对应物,它随着程序的运行可能会更改其内容(例如,字符串的引用可能会被添加到字符串池中)。字符串常量池专门用于管理字符串实例,优化存储空间和提高性能。
通过这些常量池,Java能够高效地管理类信息、提高字符串操作的效率并实现一定程度的动态链接。这些机制的共同作用,使得Java程序能够在运行时保持性能的同时,确保动态特性的实现。
四、JVM 指令集
Java虚拟机(JVM)的操作指令集是一组标准化的操作码(opcode),用于执行Java程序中的各种操作,如数据加载、存储、传递、控制流、方法调用等。这些指令构成了Java字节码,是JVM在运行Java程序时所遵循的指令。JVM指令集的设计原则是简单性和功能性,以确保它可以被高效地映射到各种硬件架构上。
JVM指令集分类
JVM的指令集可以根据指令的功能大致分为以下几类:
- 加载和存储指令
-
- 加载指令(load): 将数据从局部变量表或数组加载到操作栈。
- 存储指令(store): 将数据从操作栈存储到局部变量表或数组。
- 常量指令(const): 将常数推送到操作栈上,例如
iconst_1
将整数1推送到栈顶。
- 算术和逻辑指令
-
- 整数运算指令(如
iadd
,isub
,imul
,idiv
): 对整型数进行加、减、乘、除等操作。 - 浮点数运算指令(如
fadd
,fsub
,fmul
,fdiv
): 对浮点数进行加、减、乘、除等操作。 - 逻辑指令(如
iand
,ior
,ixor
): 对整型数进行逻辑与、或、异或操作。 - 移位指令(如
ishl
,ishr
): 对整数进行位移操作。
- 整数运算指令(如
- 类型转换指令
-
- 用于将一种类型的数据转换为另一种类型,例如
i2l
(int转换为long),f2d
(float转换为double)等。
- 用于将一种类型的数据转换为另一种类型,例如
- 对象创建和操作指令
-
- 创建指令(
new
): 创建一个类实例。 - 数组创建指令(如
newarray
,anewarray
): 创建一个新的数组。 - 字段访问指令(
getfield
,putfield
): 访问对象的字段。 - 数组操作指令(如
iaload
,iastore
): 加载和存储数组元素。
- 创建指令(
- 控制转移指令
-
- 条件分支指令(如
ifeq
,ifne
,iflt
): 根据条件跳转。 - 无条件跳转指令(
goto
): 无条件地跳转到指定位置。 - 表跳转指令(
tableswitch
,lookupswitch
): 用于实现switch语句。
- 条件分支指令(如
- 方法调用和返回指令
-
- 方法调用指令(如
invokevirtual
,invokestatic
,invokeinterface
,invokedynamic
): 调用方法。 - 返回指令(如
ireturn
,lreturn
,freturn
,dreturn
,areturn
,return
): 从当前方法返回值或结束方法。
- 方法调用指令(如
- 异常处理指令
-
- 抛出异常指令(
athrow
): 抛出异常对象。
- 抛出异常指令(
- 同步指令
-
- 进入和退出监视器(
monitorenter
,monitorexit
): 用于实现同步代码块,保证多线程访问的互斥性。
- 进入和退出监视器(
指令的特点
- 宽度可变:JVM指令的长度不固定,由操作码本身和后续的操作数决定。
- 栈操作:JVM是基于栈的虚拟机,大多数指令都涉及到直接或间接的栈操作。在基于栈的架构中,操作主要通过栈来进行数据的传递与临时存储,这种方式使得指令集可以更加简洁,因为它们不需要明确指定寄存器。这样的设计对于虚拟机来说,在概念上是更简单的,因为它降低了实现的复杂性,也使得JVM能够更容易地在不同的硬件架构上移植。
- 操作码明确:每条JVM指令由一个字节的操作码(Opcode)开始,这决定了指令的功能和后续操作数的类型及数量。这种设计允许快速解析和执行,因为操作码直接映射到虚拟机内部的操作。例如,
iload
指令用于从局部变量数组中加载一个 int 类型值到操作栈顶。 - 符号引用:JVM使用符号引用与直接引用结合的方式进行类、方法和字段的访问。这为动态链接提供了基础,允许JVM在运行时解析这些引用。例如,在类加载阶段,JVM将符号引用解析为具体的内存地址,从而在执行时能够直接访问相关的类和方法。
- 类型检查:虽然JVM在执行指令时进行类型检查,确保数据类型的正确性和操作的安全性,但它也在类加载期间进行字节码验证,确保加载的代码符合JVM规范。这一过程增加了虚拟机的稳定性和安全性。
- 异常处理:JVM指令集包括操作异常的指令。这些指令支持在运行时处理异常情况,如
athrow
指令用于抛出异常。JVM还定义了特定的方式来处理异常表,这使得异常处理既高效又符合逻辑。 - 指令优化:尽管JVM的指令集是高度抽象的,它还是支持一些底层优化,以提高执行效率。例如,即时编译器(JIT)可以在运行时将热点代码(频繁执行的代码)编译成本地机器代码,从而大大提高程序的执行速度。
五、执行模型
Java虚拟机(JVM)的执行模型是基于栈的架构,它通过使用帧(frames)来处理方法的调用和执行。这种模型支持Java语言的运行时特性,如多态、异常处理和方法调用。
栈帧是JVM用于支持单个方法调用和执行的数据结构。每个栈帧都对应于一个被调用的方法。当一个方法被调用时,一个新的栈帧会被创建并压入当前线程的虚拟机栈中。这个栈帧包含了所有必要的数据来执行一个方法,包括局部变量、操作数栈、动态链接信息以及方法返回时的操作。方法执行完毕后,相应的栈帧会被弹出栈,并将结果传递回方法的调用者。
具体来说,当一个方法被调用时,JVM做的第一件事是推一个新的栈帧到调用线程的虚拟机栈上。随着方法的执行,它的参数会被加载到局部变量表中,方法体中的指令会操作这些变量和操作数栈上的值。方法可以调用其他方法,这将导致更多的栈帧被推到虚拟机栈上,形成一个栈帧的链条。
当方法执行完毕后,它的栈帧从虚拟机栈中弹出,其结果(如果有的话)会被传递到上一个栈帧的操作数栈中,等待进一步处理。如果方法是通过异常终止的,那么异常处理机制将介入,可能会导致多个栈帧被弹出,直到找到合适的异常处理器。
为了更深入地理解Java虚拟机(JVM)如何处理方法调用和执行,我们可以从字节码层面以及运行时数据区的角度来分析前面提到的Java程序。
假设我们有以下Java类:
public class Example {
public static void main(String[] args) {
int a = 5;
int b = 7;
int sum = calculateSum(a, b);
System.out.println("Sum: " + sum);
}
public static int calculateSum(int x, int y) {
int result = x + y;
return result;
}
}
我们可以使用javac
编译器编译这个类,并使用javap -c Example
来查看其字节码。这里我们关注几个关键部分:
public static void main(java.lang.String[]);
Code:
0: iconst_5
1: istore_1
2: iconst_7
3: istore_2
4: iload_1
5: iload_2
6: invokestatic #2 // Method calculateSum:(II)I
9: istore_3
10: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
17: return
public static int calculateSum(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn
方法区是所有线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。当类Example
被加载到JVM时,它的类信息(包括类变量、方法信息、类的结构信息等)以及常量池都会被存储在方法区中。main
方法和calculateSum
方法的字节码也存储在方法区内。这包括所有的字节码指令,如iconst_5
、istore_1
、iload_1
等。
每个线程在Java虚拟机中都有自己的Java栈,这个栈与线程同时创建。Java栈由多个栈帧(Frame)组成,每个栈帧对应一个方法调用。当main
方法被调用时,一个新的栈帧被创建并压入当前线程的Java栈中。这个栈帧包含局部变量表、操作数栈、动态链接信息和方法出口信息。局部变量表存储方法的参数和局部变量。操作数栈是一个后进先出(LIFO)的栈,用于存放操作指令过程中的各种操作数和结果。当main
方法中的字节码指令invokestatic #2
被执行时,JVM会从方法区中查找到calculateSum
方法的字节码并执行它。对于calculateSum
方法,同样一个新的栈帧被创建并压入栈中。
每个线程都有一个程序计数器,是当前线程所执行的字节码的行号指示器。当一个具体的方法被执行时,程序计数器会指向当前执行的字节码指令地址。
方法执行的流程
- 当
main
方法开始执行时,程序计数器指向第一个指令iconst_5
,之后依次执行到return
指令。 - 在执行过程中,各个指令可能会对局部变量表和操作数栈进行操作,比如
iconst_5
会将常量5压入操作数栈,然后istore_1
会将这个值存入局部变量表的第一个位置。 - 当执行到方法调用指令
invokestatic
时,JVM从方法区中找到calculateSum
方法的相关字节码,并为此方法创建一个新的栈帧。 calculateSum
方法执行完毕后,其结果会被放入调用者(main
方法)的栈帧的操作数栈中。- 最终,
main
方法执行完毕后,其栈帧被弹出Java栈,线程继续执行下一步操作或者结束。
通过这种基于栈的执行模型,JVM能够有效地管理方法的调用与执行,支持复杂的控制结构,如方法调用、异常处理和同步。每个栈帧为一个方法调用提供了一个隔离的环境,保证了方法执行的独立性和安全性。这种模型是Java平台能够运行在多种硬件和操作系统平台上的关键因素之一,提供了必要的抽象和灵活性。
六、垃圾回收
Java的自动内存管理系统,尤其是堆内存的管理,是理解Java垃圾回收机制的关键。Java堆(Garbage Collected Heap)是Java内存管理中的核心,它被设计为垃圾收集器管理的主要区域。在这个区域中,对象被分配、管理和回收。让我们深入探讨Java堆的结构,以及如何通过分代垃圾收集算法进行高效的内存管理。
Java堆的结构
Java堆是一个运行时数据区,用于存放Java应用程序创建的对象实例和数组。从垃圾回收的角度看,Java堆通常被划分为几个不同的区域,或者说是“代”,以优化垃圾收集过程。这种分代的垃圾回收策略基于这样一个观察:不同对象的生命周期各不相同。
通常,Java堆分为以下几个部分:
- 年轻代(Young Generation):
-
- 这部分内存是新创建的对象首先被分配的区域。年轻代通常包含三个部分:一个Eden区和两个幸存者区(Survivor spaces,通常称为S0和S1)。
- 新创建的对象首先在Eden区分配。当Eden区满时,进行一次Minor GC(也称为Young GC),此时存活的对象会被移动到一个幸存者区(如S0),而之前在S0的对象会被移动到S1,如果它们仍然存活的话。不断重复这个过程中,一些经常存活的对象最终会被晋升到老年代。
- 老年代(Old Generation/Tenured Generation):
-
- 这部分内存用于存放长时间存活的对象。当对象在年轻代中存活足够长的时间后(或者大小超过了年轻代中Eden区的能够容纳的大小),它们会被移动到老年代。Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过
-XX:TargetSurvivorRatio=percent
来设置,参见 issue1199 ),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。除此之外,大对象也会直接放在老年代。 - 老年代的垃圾回收频率较低,但每次回收的时间较长,因为涉及的对象数量较多且对象大小较大。
- 这部分内存用于存放长时间存活的对象。当对象在年轻代中存活足够长的时间后(或者大小超过了年轻代中Eden区的能够容纳的大小),它们会被移动到老年代。Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过
- 元空间(Metaspace):
-
- 在Java 8及以上版本中,类元数据(如类的结构、方法数据、字段和其他内部信息)被存储在一个称为元空间的区域,此区域不在传统的堆内存中,而是使用本地内存。
死亡对象判断方法
Java虚拟机(JVM)在管理内存的过程中,具体地说,在执行垃圾回收(Garbage Collection, GC)时,必须能够准确地判断哪些对象是“死亡对象”(即不再被任何活动的线程引用的对象)。这一过程是确保JVM高效运行和利用内存的关键部分。为了达到这个目标,JVM采用了多种方法来检测和回收不再需要的对象。这些方法主要基于两大策略:可达性分析(Reachability Analysis)和引用计数(Reference Counting)。
引用计数法
引用计数法是一种简单的内存管理方法,其核心思想是为对象分配一个引用计数器,每当有一个地方引用它时,计数器就增加一;每当有一个引用失效时,计数器就减一。当某个对象的引用计数为0时,意味着没有任何地方引用这个对象,因此可以将其视为“死亡对象”,随即进行回收。
实现步骤:
- 对象创建:每个新创建的对象都有一个引用计数器,初始化为1(或者当第一次被引用时变为1)。
- 增加引用:当对象被另一个对象引用,或者被作为方法参数传递,或者赋值给另一个变量时,对象的引用计数器加一。
- 释放引用:当持有对象引用的变量被赋予新的对象,或者超出了其作用范围,相应的对象引用计数减一。
- 回收判断:系统定期检查所有对象的引用计数,如果某对象的引用计数为0,表示程序不再需要这个对象,可以将其占用的内存释放。
引用计数法的优缺点
优点:
- 简单直观:引用计数法实现简单,易于理解和实现。它可以即时回收无用的对象,理论上可以保证系统有最大的内存空间被利用。
- 减少延迟:由于不需要像其他GC算法那样经常暂停程序来执行垃圾回收,因此可以减少因垃圾收集引起的延迟。
缺点:
- 无法处理循环引用:如果两个或多个对象相互引用,即使它们已不再被其他活动的部分程序所引用,它们的引用计数也不会降到零,导致这部分内存永远不会被释放。这是引用计数法的一个主要缺陷,也是它在Java中不被作为主要GC策略的原因之一。
- 维护成本:每次引用变更都需要实时调整计数器,这在多线程环境中尤其复杂,可能需要额外的锁定或同步机制,影响性能。
- 空间开销:每个对象需要额外的空间来维护引用计数器,这对内存的使用是一种额外负担。
虽然引用计数法本身在Java虚拟机中不是主要的垃圾回收机制,但其概念对于理解更高级的垃圾回收技术如增量收集、分代收集以及现代JVM中使用的G1、ZGC等收集器的设计有着启示意义。例如,这些高级收集器在处理循环引用问题时,会采用更为复杂的算法(如标记-清除或标记-整理算法)来检测并回收那些实际上不再被程序所需要的对象。
总结起来,引用计数法是理解垃圾回收原理的一个重要步骤,尽管它由于自身的局限性在现代JVM中并未直接使用,但其核心思想在某些场景下仍然具有参考价值。通过分析其优缺点,我们可以更好地理解现代垃圾收集器的设计选择,以及它们如何在保证性能的同时,确保内存的有效管理和应用程序的稳定运行。
在Java虚拟机(JVM)的垃圾收集(GC)过程中,可达性分析是一种用来判定对象是否还“活着”的主要方法。这种分析的核心理念是,通过从一组被称为“GC Roots”的起点开始,沿着对象之间的引用链进行遍历,以此来查找所有从这些根节点可达的对象。任何从GC Roots不可达的对象都可以认定为是“死亡对象”,从而可以被GC回收。
可达性分析
可达性分析主要通过以下几个步骤进行:
- 确定GC Roots:GC Roots通常包括以下几类元素:
-
- 活动线程中的局部变量(Local variables)
- 活动线程(例如执行中或暂停的线程)
- 静态字段(属于类的成员变量)
- JNI引用(由本地代码创建的引用)
- 系统类加载器和其他类加载器
- 从GC Roots开始遍历:从上述根节点开始,GC遍历所有通过引用关系可达的对象。这个过程通常使用图遍历算法,例如深度优先搜索(DFS)或广度优先搜索(BFS)。
- 标记存活对象:在遍历过程中,所有可以被访问到的对象都被标记为存活。这些标记的对象不会在本次垃圾收集中被回收。
- 回收未标记对象:遍历完成后,未被标记的对象将被视为不可达,即认为是“死亡对象”,它们将成为垃圾收集的目标。
可达性分析的实现
在实际的JVM实现中,可达性分析可能会与多种垃圾收集算法结合使用,例如:
- 标记-清除(Mark-Sweep):首先标记所有从GC Roots可达的对象,然后清除所有未标记的对象。
- 标记-压缩(Mark-Compact):与标记-清除类似,但在清除死亡对象后,将存活的对象压缩到内存的一端,以减少内存碎片。
- 复制(Copying):将内存分为两块,一块用于使用,一块处于空闲状态。在GC时,将所有存活的对象从当前使用的内存块复制到空闲内存块,然后清除旧内存块的所有内容。
可达性分析的优点与缺点
优点:
- 精确性:可达性分析能够准确识别所有存活的对象,不会错误地回收正在使用的对象。
- 自动解决循环引用:可以正确处理并回收循环引用的对象,即使这些对象互相引用,但如果从根节点开始无法到达,它们仍将被回收。
缺点:
- 性能影响:在进行可达性分析时,需要暂停应用程序执行(Stop-the-World),这可能导致应用响应时间变长,尤其是在内存和对象数量非常大的情况下。
- 实现复杂度:相较于引用计数等其他方法,可达性分析的实现更为复杂,需要维护大量的引用链信息。
可达性分析是现代JVM垃圾回收技术的核心,它通过有效地标识出所有存活的对象,确保了内存的有效利用和应用的稳定运行。尽管其实现可能较为复杂,并可能引入应用执行的暂停,但现代JVM通过采用并发标记和增量标记等技术大大减少了这些停顿的影响。通过这种方式,JVM能够在管理大量对象和大块内存时,维持良好的性能和稳定性。
引用类型总结
在Java中,除了常规的强引用,还提供了四种特殊的引用类型:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、和虚引用(Phantom Reference)。这些引用类型都在java.lang.ref
包中定义,它们各自有不同的用途和垃圾回收行为,使得开发者可以根据具体需求灵活地管理内存。
1. 强引用(Strong Reference)
强引用是Java中最常见的引用类型。如果一个对象具有强引用,那么它永远不会被垃圾回收器回收。只要强引用还存在,垃圾回收器绝不会回收对象,即使这可能导致OOM(OutOfMemoryError)错误。
用途:强引用适用于管理应用程序的核心对象,这些对象在整个生命周期内必须保持活跃。
2. 软引用(Soft Reference)
软引用通过java.lang.ref.SoftReference
类实现。如果一个对象只有软引用,那么它只有在JVM即将耗尽内存且没有足够内存来分配给新对象时才会被回收。
用途:软引用主要用于实现内存敏感的缓存。例如,图片缓存或文件缓存可以使用软引用来存储数据,当系统内存不足时,这些缓存的内容可以被垃圾回收器清理掉。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。程序可以通过轮询引用队列来获取已被回收的对象的软引用,并执行相应的处理逻辑。
3. 弱引用(Weak Reference)
弱引用通过java.lang.ref.WeakReference
类实现。与软引用不同,弱引用的生存时间更短。如果一个对象仅被弱引用指向,那么它会在下一次垃圾回收发生时被回收,不管当前内存空间是否足够。
用途:弱引用适合于实现无需显式删除的引用映射关系,例如在缓存对象或元数据时。例如,WeakHashMap
利用弱引用的键,当键不再被其他对象强引用时,相关的条目可以自动移除。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。当被弱引用引用的对象不再被其他强引用所引用时,垃圾收集器会识别并回收该对象。在对象被回收时,如果该弱引用与一个引用队列关联,虚拟机会自动将这个弱引用对象添加到关联的引用队列中。程序可以通过轮询引用队列来获取已被回收的对象的弱引用,并执行相应的处理逻辑。
4. 虚引用(Phantom Reference)
虚引用通过java.lang.ref.PhantomReference
类实现。虚引用是所有引用类型中最弱的一种,设置虚引用的唯一目的是在这个对象被收集器回收时收到一个系统通知。虚引用必须和一个引用队列(ReferenceQueue)联合使用。
用途:虚引用主要用于管理对象回收前的清理工作,例如资源释放、对象销毁前的清理等。由于finalize()
方法的不可预见性和性能问题,虚引用提供了一种更灵活的方式来执行清理工作。
总结
Java的四种引用类型提供了不同级别的可达性,从而允许开发者根据具体场景优化内存使用和垃圾回收行为。通过合理利用这些引用类型,可以构建出既灵活又高效的内存管理策略:
- 强引用用于生命周期内必须持续存在的对象。
- 软引用适合实现内存敏感的缓存机制。
- 弱引用适用于需要自动释放的缓存项。
- 虚引用用于在对象销毁前进行特定的清理工作。
正确地理解和使用这些引用类型,是进行Java内存管理和优化的关键。
如何判断一个类是无用的类?
在Java中,方法区(Method Area)是用于存放由Java虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。由于这些数据的性质和生命周期,方法区的垃圾回收机制较堆空间更为复杂和谨慎。其中,类的回收是方法区垃圾回收中一个特别重要且复杂的话题,因为涉及到类的生命周期以及类加载器的动态性。
“无用的类”定义为不再有任何方式使用的类。要准确判定一个类是否为“无用的类”,必须满足以下三个条件:
- 该类的所有实例已被回收:这意味着Java堆中不再存在任何该类的实例。类实例的存在直接关联到类自身的可用性,若实例还在,类通常还有使用的可能。
- 加载该类的ClassLoader已被回收:在Java中,类加载器负责加载类。如果一个类加载器被回收,意味着由该类加载器所加载的所有类也都不再被需要,因为在Java中,类加载器和类之间存在绑定关系。
- 该类的java.lang.Class对象没有被任何地方引用:每个类在JVM中都有一个
java.lang.Class
对象,通过这个对象可以反射访问类的信息。如果这个对象不再被任何引用所持有,说明没有任何地方需要通过反射访问该类的元数据,这是类可回收的另一个重要信号。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
方法区的垃圾回收主要关注常量池的清理和类型的卸载,类型的卸载是特别复杂的,因为涉及到类及其类加载器的整个生命周期。尽管JVM具备在方法区进行垃圾回收的能力,这种回收活动并不频繁,通常只有在必要时才会进行。
垃圾回收的挑战:
- 性能考虑:频繁地对方法区进行垃圾回收可能会影响虚拟机的性能。
- 复杂性:类及其对象的生命周期管理相较于简单的堆对象更为复杂,涉及到多层级的依赖和动态加载/卸载。
优化手段:
- 类加载器的设计:通过设计专用的类加载器,可以实现类的动态加载和卸载,使得不再需要的类可以被回收。
- 谨慎回收:JVM通常会评估方法区的回收价值与风险,仅在检测到显著好处时才进行。
类在JVM中的管理是一个动态且复杂的过程,涉及到多个组件的协同工作。方法区的垃圾回收,尤其是类的回收,不仅仅是简单地判定对象是否还被引用那么直接。它需要综合考虑类的实例、类加载器以及类的Class对象的状态。因此,虽然JVM提供了进行这种回收的机制,实际执行时却相对保守,确保不会误卸载仍被需要的类。这种设计体现了Java虚拟机对性能稳定性和功能完整性的双重保证。
垃圾收集器与垃圾收集方式
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区
1. Serial Collector
- 部分收集(Minor GC):Serial收集器在新生代使用复制算法进行Minor GC。当新生代(Eden和Survivor区)满时,它会暂停应用线程(Stop-the-World),复制存活对象到另一个Survivor区或老年代。
- 整堆收集(Full GC):当老年代空间不足以容纳新晋升的对象或系统显式调用System.gc()时,Serial收集器将进行Full GC,使用标记-整理算法处理整个堆。
2. Parallel Collector
- 部分收集(Minor GC):Parallel收集器(也称为吞吐量收集器)在新生代使用并行的复制算法进行Minor GC,类似于Serial收集器,但所有的GC相关任务都是多线程并行执行的。
- 整堆收集(Full GC):Parallel收集器同样支持Full GC,在老年代空间不足或System.gc()被调用时,使用并行的标记-整理算法处理整个堆。
3. Concurrent Mark Sweep (CMS) Collector
- 部分收集(Minor GC):CMS收集器在新生代使用并行的复制算法进行Minor GC。
- 部分收集(Major GC):CMS主要设计目标是减少应用停顿时间,它在老年代使用并发的标记-清除算法进行收集,避免了Full GC的执行。但在某些情况下(例如碎片化严重),CMS可能退化为全堆收集。
4. G1 Garbage Collector
- 部分收集(Minor GC):G1收集器在新生代使用并行复制算法进行Minor GC。
- 混合收集(Mixed GC):G1收集器的一个特点是可以进行混合收集,同时清理整个新生代和部分老年代。
- 整堆收集(Full GC):在极端情况下,如果G1无法通过常规的部分收集来回收足够的内存或者内存分配速度超过了并发收集的速度,它将执行Full GC,使用标记-整理算法处理整个堆。
HotSpot虚拟机的各种垃圾收集器设计用于应对不同的性能和暂停时间要求。它们可以执行部分收集或整堆收集,具体取决于各自的算法和收集策略。理解每个收集器的行为有助于更好地进行性能调优和应对内存管理挑战。这种灵活性允许HotSpot虚拟机在多种部署环境中有效地运行,从低延迟的交互式应用到高吞吐量的数据处理应用。
分代垃圾回收算法
分代垃圾回收算法的基本思想是管理堆中不同年龄段的对象,并对它们应用不同的垃圾收集策略。这种策略有效地利用了对象生命周期的不同特点,减少了垃圾收集的总开销。主要的收集算法包括:
在Java虚拟机(JVM)中,垃圾回收算法是内存管理的核心部分。Java的垃圾回收算法主要目标是在运行时自动识别和回收不再被程序所引用的对象,以释放内存空间并提高系统性能。下面详细解释几种常见的Java垃圾回收算法:
1. 标记-清除(Mark and Sweep)算法:
这是最基本的垃圾回收算法之一,分为两个主要阶段:
- 标记阶段(Marking):从根对象(如堆栈、静态变量等)开始,遍历所有可达对象,并标记它们为活动对象。所有未被标记的对象被视为垃圾。
- 清除阶段(Sweeping):遍历整个堆,释放未被标记的对象占用的内存空间。
优点:简单直观,易于实现。
缺点:内存碎片化:清除阶段会导致内存碎片化,当内存中有很多碎片化的空间时,可能会导致分配较大对象时无法找到连续的空间。
2. 复制(Copying)算法:
这种算法将堆内存分为两个相等的区域,通常称为“from”区和“to”区:
- 标记阶段:遍历堆中所有存活的对象,并将它们复制到“to”区。
- 清除阶段:清空“from”区中的所有对象,然后交换“from”和“to”区的角色。
优点:解决了内存碎片化的问题,内存分配时总是从空闲的一侧进行分配,不会产生碎片。
缺点:需要额外的空间:因为每次只使用堆内存的一半,所以对于大型对象或长时间存活的对象可能会浪费较多的空间。
3. 标记-整理(Mark and Compact)算法:
这种算法结合了标记-清除和复制算法的优点,分为三个阶段:
- 标记阶段:与标记-清除算法相同,标记出所有存活的对象。
- 整理阶段(Compacting):将所有存活的对象移动到堆的一端,然后清理剩余的空间,使得内存分配更加连续。
优点:解决了内存碎片化问题,并且不需要额外的内存空间。
缺点:整理阶段会增加垃圾回收的时间开销。
4. 分代(Generational)算法:
这种算法根据对象的存活时间将内存分为不同的代,通常分为年轻代和老年代:
- 年轻代:大多数对象在创建后很快就变得不可达,因此分配在年轻代中。年轻代通常使用复制算法进行垃圾回收。
- 老年代:存活时间较长的对象存放在老年代中。老年代通常使用标记-整理算法进行垃圾回收。
优点:针对不同对象的存活特性采用不同的垃圾回收算法,提高了垃圾回收的效率。
缺点:需要根据对象的存活特性进行合理的分代划分,不同应用的分代划分可能会有所不同。
以上是常见的Java垃圾回收算法,每种算法都有其适用的场景和优缺点。在实际应用中,选择合适的垃圾回收算法对于提高应用程序的性能和稳定性至关重要。
垃圾收集器的选择和配置
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。
JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version
命令查看):
- JDK 8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
- JDK 9 ~ JDK20: G1
Java虚拟机(JVM)提供了多种垃圾收集器,每种收集器都有其特定的使用场景和优化目标。不同的垃圾收集器在性能、暂停时间(GC引起的停顿)、吞吐量以及内存占用等方面有不同的表现和特点。选择适当的垃圾收集器对于优化应用性能和资源利用率至关重要。下面是一些常用的JVM垃圾收集器:
- Serial Collector:
-
- 它是一个单线程的收集器,使用标记-复制(年轻代)和标记-清除-压缩(老年代)算法。
- 由于它只使用一个线程进行垃圾回收,因此它适用于较小的数据集和单核处理器上的应用。
- Parallel Collector:
-
- 又称为Throughput Collector,它是一个多线程的垃圾收集器,主要关注达到一个高吞吐量。
- 它在年轻代使用标记-复制算法,在老年代使用标记-清除-压缩算法。适用于多核服务器上,可以在维持较高应用吞吐量的同时进行垃圾回收。
- Concurrent Mark Sweep (CMS) Collector:
-
- 主要目标是获取最短的垃圾收集停顿时间,它主要用于老年代的垃圾收集。
- CMS收集器采用标记-清除算法,能够让大部分的垃圾收集工作与应用线程并发执行。
- Garbage-First (G1) Collector:
-
- G1是一种服务器端的垃圾收集器,适用于多核处理器和大内存服务器。
- 它将堆划分为多个小块(regions),每块可以独立地被回收。G1收集器旨在以可预测的停顿时间进行垃圾回收,适用于需要低延迟的大型应用。
- Z Garbage Collector (ZGC) and Shenandoah:
-
- 这两种收集器是相对较新的,它们的设计目标是减少停顿时间,即使在非常大的堆上也能保持低延迟。
- 它们通过使用高度并发的算法来达到这个目标,适用于那些对停顿时间有极低容忍度的应用。
关于垃圾收集器的详细内容敬请参看java垃圾收集器详解。
Java堆内存的优化
对Java堆内存的优化通常涉及调整垃圾收集器的配置,以及优化堆的大小和各代的比例。一些通用的优化技巧包括:
- 调整堆的大小:根据应用程序的需要调整最小(
-Xms
)和最大(-Xmx
)堆大小。 - 调整年轻代与老年代的比例:这可以通过参数如
-XX:NewRatio
来调整。 - 使用适当的垃圾收集器:根据应用的需求选择最适合的垃圾收集器。
- 监控和调优:使用各种监控工具(如JConsole,VisualVM等)来监控堆内存使用情况和垃圾收集的性能,基于实际的监控数据进行调优。
通过这些策略,开发者可以有效地管理Java应用程序的内存,优化应用性能,同时减少垃圾收集对应用响应时间的影响。这不仅提高了应用的效率,也改善了用户体验。