接上次博客:初阶JavaEE(17)Linux 基本使用和 web 程序部署-CSDN博客
目录
JVM 简介
JVM 发展史
JVM 运行流程
JVM的内存区域划分
JVM 执行流程
堆
堆的作用
JVM参数设置
堆的组成
垃圾回收
堆内存管理
类加载
类加载的基本流程
1. 加载阶段(Loading)
1.1 文件定位与打开
1.2 获取二进制字节流
1.3 转化为运行时数据结构
1.4 生成Class对象
2. 验证阶段(Verification)
2.1 目的
2.2 验证选项
3. 准备阶段(Preparation)
3.1 定义
3.2 示例
4. 解析阶段(Resolution)
4.1 过程
5. 初始化阶段(Initialization)
双亲委派模型
背景
类加载器概念
各类加载器的详细说明
自定义类加载器
类加载器层次结构
双亲委派模型的工作原理
双亲委派模型的优点
类加载的详细过程(查找.class文件的过程)
垃圾回收相关
① 死亡对象的判断算法
a. 引用计数算法
b. 可达性分析算法
② 垃圾回收算法
a.标记-清除算法——比较简单粗暴
b.复制算法
c. 标记-整理算法
d. 分代算法
③ 垃圾收集器
JVM的初心本来就是为了把很多操作封装起来以方便Java程序猿的……
但是这个时代越来越卷,面试也开始问JVM相关的问题,我们还是得好好学习一下所谓“八股文”的。
1. 什么是 JVM?
2. 虚拟机的概念
3. 常见虚拟机
4. JVM 与其他虚拟机的区别
VMware 和 VirtualBox:
JVM:
5. JVM 的定制
总结: JVM 是 Java 程序运行的核心,通过模拟 Java 字节码的执行环境,实现了 Java 跨平台运行的特性。与其他虚拟机相比,JVM 更专注于执行 Java 程序,对计算资源进行了更精细的裁剪,使得 Java 应用具有较高的性能和可移植性。
1. Sun Classic VM (1996)
2. Exact VM (JDK 1.2)
3. HotSpot VM
4. JRockit
5. J9 JVM (IBM)
6. Taobao JVM
JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键,那么 JVM 是如何执行的呢?
我们得先了解一下JVM的内存区域划分:
程序计数器 (Program Counter Register):
方法入口地址记录: 当一个线程开始执行一个方法时,程序计数器会记录该方法的入口地址。这是因为每个方法在内存中都有一个唯一的入口地址,程序计数器通过记录这个地址,能够追踪线程的执行位置。
指令逐条执行: 随着线程的执行,程序计数器的值会自动更新,指向下一条即将执行的指令的地址。每一条指令对应着Java字节码中的一条指令,这些指令在方法区中以二进制形式存储。
顺序执行的递增: 对于顺序执行的代码,程序计数器的值会递增,指向下一条顺序执行的指令的地址。这确保了指令的有序执行,按照代码的编写顺序逐条执行。
条件或循环跳转: 在遇到条件语句或循环结构时,程序计数器可能会跳转到其他地址。这是因为条件语句或循环结构的执行需要根据特定的条件或循环条件决定下一条执行的指令,程序计数器负责记录这个跳转的地址。
Java 虚拟机栈 (JVM Stack):
栈帧 (Stack Frame):
栈帧中的局部变量表用于存储方法中的局部变量,包括方法参数和方法内部定义的变量。操作数栈用于执行计算,动态链接用于支持方法调用,方法返回地址用于指示方法调用结束后的返回位置。
当一个方法被调用时,Java 虚拟机会创建一个新的栈帧,并将其推入栈中,成为当前方法栈的栈顶。在方法执行过程中,栈帧中的信息会不断变化,包括局部变量表的变化、操作数栈的变化等。当方法调用结束后,对应的栈帧会被弹出,控制权返回到上一个方法的栈帧。
本地方法栈 (Native Method Stack):
本地方法栈(Native Method Stack)与 Java 虚拟机栈(JVM Stack)类似,都是用于支持方法调用的内存区域。它们存储的是方法调用过程中的相关信息,包括局部变量表、操作数栈、动态链接、方法出口等。然而,本地方法栈专门为执行本地方法(用其他语言如 C 或 C++ 编写的方法)而设计。
在 Java 虚拟机栈中,存储的是 Java 方法的调用信息,而在本地方法栈中,存储的是执行本地方法的调用信息。本地方法栈的作用是支持 Java 与其他语言(通常是本地语言)之间的交互,允许 Java 调用本地方法,同时也支持本地方法调用 Java 方法。
因此,本地方法栈可以被看作是 Java 虚拟机栈的补充,用于处理与本地方法相关的调用和执行。在涉及到本地方法调用的情况下,本地方法栈会记录执行的过程,包括方法之间的调用关系。
Java 堆 (Java Heap):
方法区 (Method Area)(Java1.7及其之前,Java1.8之后称为元数据区):
运行时常量池 (Runtime Constant Pool):
直接内存:
当一个Java程序启动时,会在操作系统中启动一个Java虚拟机进程。在这个Java虚拟机进程中,每个线程都拥有自己的独立的虚拟机栈和程序计数器(这也衍生出了一种说法:每个线程都有自己私有的栈空间)——这些是线程私有的内存区域,为每个线程提供了独立的执行环境。
这种说明也可以认为是正确的,
虚拟机栈: 每个线程都有自己的虚拟机栈,它的生命周期与线程相同。虚拟机栈会保存线程执行方法时的局部变量表、操作数栈、动态链接、方法出口等信息。每个方法在执行的时候都会在虚拟机栈中创建一个栈帧,栈帧用于存储方法的局部变量和运算过程中的临时数据。虚拟机栈的大小可以在启动时或运行时进行调整。
程序计数器: 每个线程都有自己的程序计数器,用于记录当前线程执行的字节码指令的地址。程序计数器是线程私有的,它在线程切换时能够保持线程独立的执行位置,确保每个线程都能够从正确的地方恢复执行。在Java虚拟机规范中,对程序计数器的操作都是原子性的。
堆和方法区: 堆和方法区是Java虚拟机中的内存区域,是所有线程共享的。堆用于存储对象实例和数组等动态分配的数据,而方法区用于存储类信息、常量、静态变量等。这两个区域的内存是在Java虚拟机启动时就分配好的,并且在整个运行过程中都存在。堆和方法区的大小可以通过启动参数进行调整。
总体来说,虚拟机栈和程序计数器是线程私有的,而堆和方法区是线程共享的。这种设计保证了每个线程都拥有独立的执行环境,同时又能够共享一些静态的数据和类信息。
常见的面试题:
class Test {
public int n = 100;
public static int a = 10;
}
public class Main {
public static void main(String[] args) {
Test t = new Test();
}
}
成员变量 n:
静态变量 a:
局部变量 t:
综上所述,不同变量的存储区域如下:
有一个问题容易让我们误会:变量处于哪个空间上,和变量是不是引用类型?是不是基本类型?没有关系!!!:
引用变量本身存储在栈内存中,但实际对象的数据存储在堆内存中:
class Test {
public int n = 100; // 基本类型变量,存储在栈内存中
public static int a = 10; // 静态变量,存储在方法区中
public static void main(String[] args) {
Test t = new Test(); // 引用类型变量,t存储在栈内存中,实际对象存储在堆内存中
}
}
我们再来看一个实例:
class Person {
private String name; // 引用类型变量,存储在堆内存中
private int age; // 基本类型变量,存储在栈内存中
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void celebrateBirthday() {
age++;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
public class Main {
public static void main(String[] args) {
Person person1 = new Person("Alice", 25); // 引用类型变量,person1存储在栈内存中,实际对象存储在堆内存中
Person person2 = new Person("Bob", 30); // 引用类型变量,person2存储在栈内存中,实际对象存储在堆内存中
System.out.println(person1.getName() + "'s age: " + person1.getAge()); // 输出:Alice's age: 25
System.out.println(person2.getName() + "'s age: " + person2.getAge()); // 输出:Bob's age: 30
person1.celebrateBirthday(); // 调用方法,person1的age递增
System.out.println(person1.getName() + "'s age after birthday: " + person1.getAge()); // 输出:Alice's age after birthday: 26
}
}
JVM(Java Virtual Machine)执行流程可以简要概括为以下四个主要部分:
编写和编译Java代码:
类加载器(ClassLoader):
运行时数据区(Runtime Data Area):
执行引擎(Execution Engine):
本地库接口(Native Interface):
综合以上四个部分,JVM能够加载、解释或编译Java程序,并在运行时管理内存、执行字节码,通过本地库接口与底层系统交互,实现了Java程序的跨平台特性。
new
关键字创建的对象实例都存储在堆中。-Xms
和-Xmx
分别代表了堆的最小启动内存和最大运行内存。堆的合理设置对于程序性能和内存利用率非常重要,通过调整-Xms
和-Xmx
参数,可以根据应用的需求进行优化。
其中前 5 步是固定的顺序并且也是类加载的过程,Java代码会被编译成.class文件(包含了一些字节码),Java程序想要运行起来就需要让JVM读取到这些.class文件,并且把里面的内容构造成类对象,保存到内存的方法区里。
所谓的“执行代码”,就是调用方法,我们就需要先知道每个方法,编译后生成的指令都是啥?
其中中间的 3 步我们都属于连接,所以对于类加载来说,总共分为以下几个步骤 :
1. 加载
2. 连接
3. 初始化
下面我们分别来看每个步骤的具体执行内容:
在加载阶段,系统首先要找到对应的 .class
文件。通常,代码中会提供某个类的“全限定类名”(例如:java.lang.String
)。JVM会根据这个类名在特定的指定目录范围内查找相应的 .class
文件,找到后就打开并读取文件内容。
通过打开的文件,系统获取到该类的二进制字节流。这个字节流包含了类的静态存储结构的表示,其中包括类的字段、方法、接口等信息。
获取的字节流表示的静态存储结构需要被转化为方法区的运行时数据结构。这个步骤是将类的信息在虚拟机中表示出来,以备后续的验证、准备、解析等步骤做准备。
在内存中生成代表这个类的 java.lang.Class
对象,成为方法区中类数据的访问入口。Class
对象包含了类的各种信息,通过它可以访问类的结构和内容。这个步骤为程序运行时提供了访问和操作类的入口。
验证阶段是连接阶段的首步,其目的在于确保Class文件的字节流符合《Java虚拟机规范》的约束要求,以保障在代码运行时不会危害虚拟机自身的安全。
Java SE Specifications
这个就是.class文件要遵守的格式:
u2就是2个字节的无符号整数 unsigned short;
u4就是4个字节的无符号整数 unsigned int。
ClassFile结构表示Java类文件的格式。让我们分析ClassFile结构中的每个元素:
总体而言,ClassFile结构全面展示了Java类文件的基本组成部分,包括版本信息、常量池、访问标志、类层次结构、字段、方法以及其他属性。这个结构对于JVM在运行时正确加载和理解类文件至关重要。
准备阶段是为类中的变量(静态变量,即被static修饰的变量)分配内存空间的正式阶段。
在准备阶段,主要是为类中的静态变量分配内存并设置默认初始值,这个值通常是零(zero)。此时并没有执行初始化代码,只是为静态变量分配了内存空间,并设置了默认初始值。
在Java中,基本数据类型的静态变量会被设置为默认值,例如,整数型为0,浮点型为0.0,布尔型为false等。引用类型的静态变量会被设置为null。
因此,在准备阶段结束后,虽然内存空间已经分配,但初始化代码尚未执行,所以如果尝试访问类的静态成员,会看到它们的默认初始值,即通常是全0。这是因为在准备阶段,只有分配了内存和设置了默认值,但还没有执行其他初始化操作。
public class MyClass { public static int value = 123; }
在准备阶段,value 会被初始化为0,而不是123。
针对类对象中包含的字符串常量进行处理,进行一些初始化操作。Java代码中用到的字符串常量在编译之后也会进入到.class文件中。
解析阶段是Java虚拟机将常量池中的符号引用替换为直接引用的过程,即将符号引用转化为可以直接被虚拟机使用的直接引用。
在这个过程中,特别针对类对象中包含的字符串常量进行处理。在Java代码中,字符串常量(如String s = "java";)在编译后会被添加到类的常量池中,并在.class文件中保留。
在.class文件的二进制指令中,对于这样的字符串常量,会创建一个引用(例如,s),而这个引用在文件中本质上保存的是一个偏移量,指向这个字符串常量在文件中的位置。
在.class文件中,由于它是一个文件而非内存地址,引用(例如,s)的初始化语句会被设置成这个字符串常量在文件中的偏移量。通过这个偏移量,可以在文件中找到实际的字符串内容。
当这个类真正被加载到内存中时,解析阶段会将这个偏移量替换回真正的内存地址,从而建立直接引用。这个过程被称为“符号引用(文件偏移量)”替换成“直接引用(内存地址)”。
这样,通过解析阶段,字符串常量在类加载过程中能够正确地转化为在内存中的实际地址,确保在程序运行时可以正确地访问和操作这些字符串。
初始化阶段是Java虚拟机开始执行类中编写的Java程序代码的阶段,主要任务是执行类构造器方法。在这个阶段,对类对象进行初始化,确保类的状态和结构在运行时处于正确的状态。以下是一些关键的初始化任务:
执行类构造器方法: 初始化阶段的主要任务之一是执行类构造器方法。对于类而言,这是
方法,它包含了静态变量的初始化和静态代码块的执行。对于接口而言,也会有类似的初始化步骤。
初始化静态成员: 在执行类构造器方法时,静态成员(静态变量或静态代码块)会被初始化。静态变量的初始化可以是在声明时赋值或在静态代码块中执行的。
加载父类: 如果当前类有父类,并且父类尚未被加载和初始化,那么会先递归地执行父类的加载和初始化阶段,确保继承链上的类都得到正确的初始化。
为类对象中的属性设置值: 初始化阶段还会为类对象中的各个属性设置具体的值,这些值可能是默认值或者在构造器中指定的初值。
执行静态代码块: 如果类中包含静态代码块,这些代码块也会在初始化阶段执行。静态代码块中的代码会按照它们在类中的顺序执行。
通过以上步骤,初始化阶段确保了类对象在使用之前处于一个合适的状态。这包括正确的静态变量值、已加载的父类、各个属性的初始化值等。这个阶段的完成为类的正确运行提供了必要的条件。
提到类加载机制,我们就不得不提的一个概念就是“双亲委派模型”。
“双亲委派模型”属于类加载中第一个步骤,是“加载”过程中期中的一个环节,负责根据全限定类名找到.class文件。而类加载器是JVM中的一个模块。
双亲委派模型是Java类加载机制中的一种设计思想,旨在解决类加载的重复和安全性问题。它属于类加载的第一个步骤——“加载”过程的一部分,负责根据全限定类名找到对应的.class
文件。
在Java虚拟机中,类加载器是一个独立的模块,负责加载类文件并将其转化为运行时的Java类。JVM内置了三个类加载器,分别是启动类加载器、扩展类加载器、和应用程序类加载器。这些类加载器之间的关系不是继承关系,而是通过一个parent属性指向父类加载器,形成了一个层次结构。
程序员也可以手动创建自定义的类加载器。
启动类加载器(BootStrap ClassLoader):
扩展类加载器(Extension ClassLoader):
应用程序类加载器(Application ClassLoader):
程序员可以根据需求创建自定义的类加载器,继承自 java.lang.ClassLoader。这样可以实现一些特殊的类加载需求,比如从网络加载类、动态生成类等。
通过双亲委派模型,自定义的类加载器可以在必要时覆盖默认的类加载行为。
启动类加载器(Bootstrap ClassLoader):
扩展类加载器(Extension ClassLoader):
应用程序类加载器(Application ClassLoader):
加载请求的传递:
递归委派:
启动类加载器加载:
反馈给子类加载器:
最终加载:
总体来说,双亲委派模型的优点在于提供了一种清晰、规范的类加载机制,保障了Java应用的稳定性、一致性和安全性。
.class
文件的过程)给定全限定类名: 例如,java.lang.String。
Application ClassLoader(应用程序类加载器)开始查找:
Extension ClassLoader(扩展类加载器)继续查找:
Bootstrap ClassLoader(启动类加载器)继续查找:
查找过程的结束:
任务回溯:
继续回溯:
通过这一层层的委派和回溯,实现了类加载的双亲委派模型,确保了类的一致性和安全性。如果类能够在上述的加载器层次结构中的某一个找到对应的.class文件,加载过程就算完成。
双亲委派模型通过层次结构的加载方式,确保了类的一致性和安全性,使得Java的类加载机制更加健壮和可靠。之所以搞这一套流程,就是为了确保标准库的类,被加载的优先级最高,其次是扩展库,最后是自己写的类和第三方库。所以所谓的“双亲委派模型”就是一个简单的查找优先级问题。
双亲委派模型的设计目的就是确保类的一致性和安全性,通过委派机制和查找优先级,保证了以下几个关键点:
避免重复加载: 通过层级结构,同一个类只被加载一次,避免了重复加载,提高了运行效率。
优先使用标准库: 标准库中的类具有最高的优先级,确保了核心API的一致性,防止被外部类库篡改。
确保安全性: 阻止每个类加载器加载自己的类,防止出现多个不同版本的同一个类,提高了系统的安全性。
规范类加载流程: 通过定义类加载器的层次结构和委派规则,使得类加载的过程更加规范和可控。
这种设计思想保证了Java应用的稳定性和可靠性,特别是在复杂的应用场景中,通过双亲委派模型,可以避免很多潜在的类加载问题。
当然,我们自己实现类加载器的时候不一定要符合上述流程,正如我们之前部署博客系统的时候,tomcat就是在我们指定的自定义的目录——webapps里面找,找不到就直接抛出异常并返回了。
在C语言和C++等语言中,程序员负责手动分配和释放内存。这种手动管理内存的方式可能导致内存泄漏、野指针等问题,使程序更容易出现错误。为了解决这些问题,Java引入了垃圾回收机制(Garbage Collection)。
垃圾回收机制的主要目标是自动管理程序中使用的内存,减轻程序员的负担。在Java中,程序员不需要手动释放不再使用的内存,而是由Java虚拟机(JVM)的垃圾回收器负责定期检测和回收不再被引用的对象。注意,垃圾回收是在程序运行时进行的,而不是在编译过程中。
关于垃圾回收的一些重要概念和原理包括:
可达性分析: 垃圾回收器通过可达性分析来确定哪些对象是"活动"的,即仍然被引用的对象。从根对象(如堆栈、静态变量等)开始,通过引用链追踪对象的引用关系,标记所有可达的对象。
标记-清除算法: 是一种常见的垃圾回收算法。在可达性分析后,标记阶段标记出所有活动对象,清除阶段将未标记的对象回收。这可能导致内存碎片问题。
复制算法: 一种通过将存活对象复制到一个新的空间,并清理旧空间的算法,解决了碎片问题。但它需要额外的空间。
标记-整理算法: 是一种结合了标记-清除和复制算法的算法,旨在减少内存碎片。
总的来说,垃圾回收机制使Java程序员不必过多关心内存管理,提高了开发效率并减少了潜在的内存错误。然而,了解垃圾回收的原理和机制有助于编写更加健壮和高效的Java程序。
既然GC那么好,为什么C++不引入?
GC也是有缺陷的……
C++没有引入垃圾回收的主要原因包括系统开销和效率问题:
系统开销: 垃圾回收需要一个专门的线程不断扫描内存中的对象,判断是否可回收。这个过程需要额外的内存和CPU资源。在一些配置特别低的系统中,这种额外的开销可能会对系统性能产生负面影响。C++通常被设计用于对系统资源更加细粒度的控制,以满足对性能和资源利用的高要求。
效率问题: 垃圾回收的扫描线程不一定能够及时释放内存,因为扫描通常是有周期的。这可能导致一些对象在被标记为可回收后,并不立即被释放,而是等待下一轮垃圾回收。在某些场景下,特别是对于对实时性要求较高的系统,这种延迟可能是不可接受的。C++通常被用于开发对实时性能要求较高的应用,如游戏引擎、嵌入式系统等。
长时间停顿(STW): 当进行垃圾回收时,可能会发生全局的停顿(Stop-The-World,STW),即整个应用程序的执行会暂时停止。在对于要求低延迟和高并发性能的场景中,这种长时间停顿是无法接受的。C++的设计目标之一是追求极致的性能和响应速度,因此避免了这类全局性的停顿。
总的来说,C++注重对系统资源和性能的细粒度控制,而垃圾回收机制引入了一些不可避免的系统开销和延迟,因此在某些特定的应用场景下,C++的设计理念更符合需求。
在Java运行时内存管理中,我们探讨了各个区域的作用,其中程序计数器、虚拟机栈、本地方法栈这三个区域的生命周期与相关线程紧密相连,随着线程的创建而生,随着线程的结束而消失。这三个区域的内存分配与回收是具有确定性的,因为当方法执行结束或者线程终结时,相应的内存也自然地随之回收。
因此,本讲探讨的关于内存分配和回收的内容主要关注于Java堆与方法区这两个区域。Java堆是存放几乎所有对象实例的地方,而垃圾回收器在执行堆的垃圾回收之前,首先需要判断哪些对象仍然存活,哪些已经变得"死去"。为了判断对象的生死状态,垃圾回收器采用了多种算法,其中包括引用计数算法和可达性分析算法等。
总的来说,通过对Java堆和方法区的探讨,我们能够深入了解内存管理中关键的区域及其内存分配和回收的特性。这为理解Java内存模型和垃圾回收机制提供了基础。
内存 VS 对象
在Java中,所有的对象都需要占用内存空间,可以说内存中存储的是一个个对象。因此,对于不再被使用的对象,我们需要进行内存回收,这个过程也可以称为死亡对象的回收。
GC回收的目标和生命周期管理
GC(垃圾回收)的目标是释放内存中的不再使用的对象,而对于Java来说,这主要指的是通过new关键字创建的对象。
局部变量和栈帧的生命周期:
局部变量: 存储在栈帧中的局部变量随着方法的执行而创建,方法执行结束后,栈帧销毁,局部变量的内存也会自然释放。这是由方法的生命周期控制的。
栈帧: 栈帧是用来支持方法调用和执行的数据结构,栈帧的生命周期与方法的调用关系密切相关。每次方法调用都会创建一个新的栈帧,方法执行结束后,相应的栈帧就会被销毁,伴随着局部变量的释放。这保证了栈中的局部变量在不再需要时会被及时释放。
静态变量的生命周期:
算法描述
引用计数算法的基本思想是为对象增加一个引用计数器,具体过程如下:
特点
应用场景
引用计数法在某些编程语言中被广泛应用,例如Python语言采用引用计数法进行内存管理。
限制和问题
尽管引用计数法具有一些优点,但在主流的Java虚拟机(JVM)中并未选用该算法,主要原因有两个:
1、比较浪费内存空间:每个对象都需要安排一个计数器来保持它的引用,一个计数器最少也需要2个字节。如果对象很少,或者对象都比较大,那么影响不大,但是如果你的对象本身就很小了,那么计数器占据的空间就会难以忽视。
2、无法解决循环引用问题: 引用计数法无法处理对象之间存在循环引用的情况。
循环引用指的是对象之间形成了循环的引用关系,使得引用计数器无法准确判断对象是否可达。
例子:观察循环引用问题 :这段代码演示了一个循环引用的情况,并通过强制触发JVM垃圾回收来观察其行为。
/**
* JVM参数: -XX:+PrintGC
* @author 38134
*/
public class Test {
public Object instance = null;
private static int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
Test test1 = new Test();
Test test2 = new Test();
test1.instance = test2;
test2.instance = test1;
test1 = null;
test2 = null;
// 强制 JVM 进行垃圾回收
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
在这个类中,Test 类包含一个 instance 成员变量,同时创建了两个 Test 对象 test1 和 test2,并让它们相互引用。这就构成了循环引用。
在 testGC 方法中,将 test1 和 test2 设为 null,然后强制执行 System.gc() 触发垃圾回收。通过观察GC日志输出,我们可以得到垃圾回收前后的堆内存使用情况。
从提供的GC日志中可以看到,"6092K->856K(125952K)"表示在垃圾回收之前,堆内存使用了6092K,回收后剩余856K,总共的堆内存大小为125952K。这表明虚拟机进行了垃圾回收,并成功回收了一部分内存,包括循环引用的对象。
这也说明JVM并不使用引用计数法来判断对象是否存活,而是采用其他更复杂的算法,如可达性分析,来处理循环引用等情况。
GC圈子中有两种主流的方案,如果面试官要你介绍GC,你可以说引用计数。但是引用计数主要用于python、PHP,如果问的是Java中的GC,那么介绍引用计数就不合适了。
在上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活(同样采用此法的还有C#、Lisp-最早的一门采用动态内存分配的语言)。
它的核心思想是通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链"。当一个对象到GC Roots没有任何引用链相连时(从GC Roots到这个对象不可达),证明此对象是不可用的,即可以被回收。
可达性分析的出发点有很多,不只是局部变量,还有一些别的:
GC Roots的对象包括:
虚拟机栈(栈帧中的本地变量表)中引用的对象: 属于方法执行的线程私有部分,保存了局部变量等信息,可以通过这些局部变量引用的对象作为GC Roots。
方法区中类静态属性引用的对象: 静态属性属于类的状态,可以作为GC Roots。
方法区中常量引用的对象: 常量池中的常量也可能被引用,因为它们在方法区中。
本地方法栈中 JNI(Native方法)引用的对象: JNI是Java Native Interface的缩写,允许Java代码调用和被调用者用其他语言编写的代码。在JNI中引用的对象也被视为GC Roots。
可达性分析的例子: 假设有对象Object1到Object7,它们之间存在一定的引用关系。通过可达性分析,如果从GC Roots(如虚拟机栈、方法区)无法通过引用链访问到Object5、Object6、Object7,那么这三个对象就被判定为不可达,即可被回收。
这种算法有效地判断了对象的可达性,保证了不再被引用的对象能够被垃圾回收机制及时释放。
简单来说,可达性分析本质上就是“时间换空间”。有一个线程周期性扫描我们代码中的所有对象,从一些特定对象出发,尽可能的进行访问的遍历,把所有能够访问到的对象都标记为“可达”,反之,经过扫描之后未被标记的对象就是“垃圾”了。这里的遍历大概率是N叉树,主要就是看你访问的某个对象里面有多少个引用类型的成员,针对每个引用类型的成员都需要进一步的进行遍历。而且这里的可达性分析都是周期性进行的,会定期确认当前的某个对象是否变成“垃圾”,因为里面的对象是会随着代码的执行发生改变的。
因此,可达性分析实际上还是比较消耗时间的。
综上,总结一下:
引用计数算法:
可达性分析算法:
引用在Java中有多种类型,每种类型的引用对对象的生命周期和垃圾回收行为有不同的影响。在JDK1.2之后,引入了强引用、软引用、弱引用和虚引用这四种引用类型,它们的强度依次递减。
强引用 (Strong Reference):
软引用 (Soft Reference):
弱引用 (Weak Reference):
虚引用 (Phantom Reference):
这些引用类型的引入,使得开发者能够更灵活地控制对象的生命周期,特别是在一些内存敏感的应用中,合理使用不同类型的引用可以优化内存的使用和垃圾回收的效率。
通过上面的学习我们可以将死亡对象标记出来了,标记出来之后我们就可以进行垃圾回收操作了。
在正式学习垃圾收集器之前,我们先看下垃圾回收机器使用的几种算法(这些算法是垃圾收集器的指导思想)
"标记-清除"算法是最基础的垃圾收集算法,它分为两个阶段:标记和清除。
首先标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象。
然而,这种算法存在一些不足之处:
效率问题: 标记和清除这两个过程的效率都不高。标记阶段需要遍历所有的对象来确定哪些是需要回收的,而清除阶段则需要回收被标记的对象,这两个过程都会占用一定的计算资源。
空间问题: 标记-清除算法在回收后可能会产生大量不连续的内存碎片。由于对象被不规则地分布在内存中,可能导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存,从而不得不提前触发另一次垃圾收集。
这些问题影响了"标记-清除"算法的实际运用,因此后续的垃圾收集算法都是基于这种思路进行改进,以提高效率和解决空间问题。
"复制"算法是为了解决"标记-清除"算法的效率问题而设计的。该算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。具体执行流程如下:
初始时刻: 将可用内存划分为两块,假设为From区和To区。
对象分配: 在程序运行过程中,对象首先被分配到From区。
垃圾回收: 当From区的内存空间即将用尽时,触发垃圾回收。此时,算法会扫描From区,将所有还存活的对象复制到To区,同时对From区进行整理,将已使用的内存空间清空。
切换区域: 将From区和To区的角色互换,使得To区变为新的From区,From区变为空闲的To区。
这样,每次垃圾回收都是对整个半区进行的,避免了标记-清除算法中的效率问题。而且,由于每次只使用一半的内存,不需要考虑内存碎片问题,大大简化了内存分配的复杂性。
当然,这个方案的缺点也很明显:
1、内存要浪费一半,利用率不高;
2、如果有效的对象过多,拷贝的开销就会很大。
在商用虚拟机中,包括HotSpot,通常采用复制算法来回收新生代。新生代中绝大多数对象都是“朝生夕死”的,因此可以使用一种更适合这种情况的垃圾回收算法。
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。 针对老年代的特点,提出了一种称之为"标记-整理算法"。
标记-整理算法是为了解决复制算法在老年代中可能面临的效率问题。该算法的标记过程与标记-清理算法一致,但后续步骤有所不同。在标记-整理算法中,不是直接清理掉可回收对象,而是将所有存活的对象向一端移动,然后清理掉端边界以外的内存。
具体步骤如下:
标记阶段(与标记-清理算法相同): 标记出所有需要回收的对象。
整理阶段: 将所有存活的对象都向一端移动。这一步的目的是为了减少内存碎片的产生。移动后,所有存活的对象都被紧凑地排列在一起。
清理阶段: 清理掉端边界以外的内存。因为所有存活的对象都被移动到了一端,清理时只需要清理掉不再使用的内存,而不用考虑中间的空隙。
标记-整理算法相对于复制算法在老年代的优势在于,它避免了大量的复制操作,减少了移动对象的次数,降低了垃圾回收的时间开销。然而,标记-整理算法仍然需要停止应用程序的执行(Stop-the-World)来进行垃圾回收操作。
类似于顺序表删除元素的搬运操作……
但是搬运的开销仍然很大。
事实上,由于每种方式都不尽如人意,JVM真正采取的释放思路是上述基础思路的结合体,让不同的方案能够扬长避短。
分代算法和上面讲的 3 种算法不同,分代算法是一种根据对象存活周期的不同将内存划分为不同区域,并采用不同的垃圾回收策略的算法。
这就好比中国的一国两制方针一样,对于不同的情况和地域设置更符合当地 的规则,从而实现更好的管理,这就时分代算法的设计思想。
在当前的 JVM 垃圾收集中,常用的是“分代收集算法(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。”。这个算法并没有新思想,只是根据对象存活周期的特点将堆内存划分为新生代和老年代。
具体来说:
新生代(Young Generation): 新生代中的对象存活周期较短,一般有大量的对象在短时间内变为垃圾。因此,采用复制算法,将新生代分为三个区域:Eden区和两个Survivor区(通常称为From区和To区)。对象首先在Eden区分配,经过一次Minor GC(年轻代垃圾回收)后,仍然存活的对象会被移到Survivor区,经过多次迭代后存活的对象会被晋升到老年代。
老年代(Old Generation): 老年代中的对象存活周期较长,因此采用标记-清理或标记-整理算法。在老年代中进行Major GC(老年代垃圾回收),清理不再使用的对象。由于老年代中存活对象较多,采用复制算法的代价较高,因此选择标记-清理或标记-整理。
分代算法的设计思想是根据不同对象的存活特点,采用适当的垃圾回收算法,以提高垃圾回收的效率。这样的设计在实际应用中能够更好地适应不同对象的生命周期,提高整体的垃圾回收性能。
哪些对象会进入新生代?哪些对象会进入老年代?
新生代(Young Generation):
老年代(Old Generation):
需要注意的是,具体的阈值和规则可能会因为不同的JVM实现而有所不同。例如,对于大对象的阈值、Minor GC的触发条件等可能会有调优选项。
新生代内存通常被划分为一块较大的Eden空间和两块较小的Survivor空间(通常称为From区和To区)。默认情况下,HotSpot采用8:1:1的比例,即Eden:Survivor From: Survivor To = 8:1:1。每次使用Eden和其中一块Survivor进行对象分配,当回收时,将Eden和Survivor中的存活对象复制到另一块Survivor空间,最后清理掉Eden和刚才用过的Survivor空间。
具体的HotSpot复制算法流程如下:
当程序执行时,新创建的对象首先会被分配到新生代的Eden空间。Eden空间是新生代内存的主要工作区域,它较大,用于存储刚刚被创建的对象。
第一次Minor GC(触发条件:Eden区满):
第二次Minor GC(触发条件:Eden和Survivor From区都满):
后续的Minor GC:
晋升到老年代:
这整个过程的核心思想是通过复制算法,将新生代中的存活对象拷贝到Survivor区域,并通过不断的复制和年龄控制,保证新生代的Eden和Survivor能够高效地回收不再使用的对象,同时降低了内存碎片的产生。最终,那些存活时间较长的对象会被晋升到老年代。
这样的设计有效地利用了新生代对象的“朝生夕死”特性,提高了内存利用率和回收效率。
面试题 : 请问了解Minor GC和Full GC么,这两种GC有什么不一样吗
Minor GC(新生代GC):
Full GC(老年代GC或Major GC):
总体来说,Minor GC 和 Full GC 都是垃圾回收的过程,主要区别在于它们涉及的内存区域和频率。Minor GC 针对新生代,频繁发生且速度较快;而 Full GC 针对整个堆,频率相对较低且速度相对较慢。
如果说上面我们讲的收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
垃圾收集器的作用:垃圾收集器是为了保证程序能够正常、持久运行的一种技术,它是将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间。
具体而言,垃圾收集器实现以下功能:
识别垃圾对象: 垃圾收集器通过某种算法识别程序中哪些对象不再被引用和访问,即垃圾对象。
释放内存空间: 一旦垃圾收集器确定了哪些对象是垃圾,它就会释放这些对象占用的内存空间,使这些空间可用于新的对象分配。
防止内存泄漏: 通过及时回收不再使用的对象,垃圾收集器可以防止内存泄漏,确保程序不会因为长时间运行而耗尽可用内存。
提高程序健壮性: 自动内存管理可以降低程序员犯错的可能性,提高程序的健壮性。程序员无需担心手动释放内存,减轻了开发的负担。
以下这些收集器是 HotSpot 虚拟机随着不同版本推出的重要的垃圾收集器:
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们之间可以搭配使用。所处的区域,表示它是属于新生代收集器还是老年代收集器。
在讲具体的收集器之前我们先来明确 三个概念:
并行(Parallel): 多条垃圾收集线程并行工作,即同时执行。在并行垃圾收集期间,用户线程可能处于等待状态。
并发(Concurrent): 用户线程与垃圾收集线程同时执行,但并不一定是真正的并行。它表示用户程序可以继续运行,而垃圾收集程序在另一个 CPU 上执行。这有助于提高程序的响应性。
吞吐量(Throughput): 吞吐量是指 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。它反映了系统在执行用户代码时的效率。吞吐量高表示系统更多的时间用于执行用户代码,而非垃圾回收。
例如,如果虚拟机总共运行了100分钟,其中垃圾收集花费了1分钟,那么吞吐量就是 99%。吞吐量是评价垃圾收集器性能的一个重要指标。
在实际应用中,根据应用的特点和性能需求,选择不同的垃圾收集器以达到更好的性能表现。Java 虚拟机提供了多种垃圾收集器,每个收集器都有其适用的场景和优缺点。
为什么会有这么多垃圾收集器?
有这么多垃圾收集器的存在主要是因为不同的应用场景和需求对垃圾收集的性能指标有不同的要求。垃圾收集器的设计和选择取决于以下一些因素:
应用场景: 不同的应用场景对垃圾收集器的要求不同。一些应用可能更注重吞吐量(Throughput),而另一些可能更注重最小的停顿时间(Pause Time)。
硬件环境: 不同的硬件环境可能对垃圾收集器的表现产生影响。一些收集器可能在多核处理器上表现更好,而另一些可能更适用于特定的内存结构。
内存分布和对象生命周期: 不同的应用程序有不同的内存使用模式。一些应用可能会产生大量的临时对象,而另一些应用可能会有长寿命的对象。垃圾收集器的选择通常取决于应用程序的内存分布和对象的生命周期。
性能指标: 不同的垃圾收集器对性能指标的优化有不同的重点。有些垃圾收集器更注重吞吐量,而另一些更注重最小化停顿时间。
以下是一些常见的垃圾收集器以及它们的特点:
Serial(串行)收集器: 适用于单核处理器的客户端应用,通过单线程进行垃圾收集,停顿时间相对较长。
ParNew(并行新生代)收集器: 是 Serial 收集器的多线程版本,适用于多核处理器的客户端应用,也可用于服务器端。
Parallel Scavenge(并行吞吐量优先)收集器: 适用于注重吞吐量的场景,通过并行多线程进行垃圾收集。
Serial Old(串行老年代)收集器: 是 Serial 的老年代版本,用于老年代的垃圾收集。
CMS(Concurrent Mark-Sweep)收集器: 适用于追求最小停顿时间的场景,通过并发方式进行垃圾标记和清理。
G1(Garbage-First)收集器: 适用于大内存和注重低延迟的应用场景,通过分区方式进行垃圾收集。
ZGC(Garbage-First)收集器:适用于大内存和注重低延迟的应用场景,通过分区方式进行垃圾收集。
每个收集器都有其优势和局限性,部分垃圾收集器其实已经停用,但是由于我们之前提到的那本书里都介绍了,所以这里我们也就简单提一下。你如果比较感兴趣,可以自己去了解一下后面三个垃圾收集器,它们是最主流的。