1,JVM 运行时数据区(内存结构)
2,什么情况下会内存溢出?
3,JVM 有哪些垃圾回收算法?
4,GC如何判断对象可以被回收?
5,典型垃圾回收器
6,类加载器和双亲委派机制
7,JVM中有哪些引用?
8,类加载过程
9,JVM类初始化顺序
10,对象的创建过程
11,对象头中有哪些信息?
12,JVM内存参数
13,GC 的回收机制和原理
一、JVM 运行时数据区(内存结构)
-
程序计数器(PC Register)
- 基本概念:
- 程序计数器是一块很小的内存空间,它可以被看作是当前线程所执行字节码的行号指示器。字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,比如在循环、分支、跳转、异常处理等流程控制场景下发挥关键作用。
- 线程私有特性:
- 程序计数器是线程私有的,每个线程都有自己独立的程序计数器。这是因为多线程环境下,各个线程需要独立地记录自己当前执行的字节码位置,以保证每个线程能够正确地恢复执行。例如,在一个多线程的 Web 应用中,每个线程处理不同用户的请求,它们的执行进度(字节码指令位置)是不同的,通过线程私有的程序计数器来分别记录。
- 内存占用与异常情况:
- 它在 JVM 内存结构中占用的空间非常小,并且是 Java 虚拟机规范中唯一一个不会出现
OutOfMemoryError
情况的区域。这是因为它只需要记录一个字节码指令的行号,所需空间有限,并且其功能相对简单,不会出现内存耗尽的情况。
- 它在 JVM 内存结构中占用的空间非常小,并且是 Java 虚拟机规范中唯一一个不会出现
- 基本概念:
-
Java 虚拟机栈(JVM Stacks)
- 栈帧结构与方法调用:
- 虚拟机栈是线程私有的,它的生命周期与线程相同。每个方法在执行时都会创建一个栈帧,栈帧用于存储局部变量表、操作数栈、动态连接和方法出口等信息。当一个方法被调用时,对应的栈帧就会被压入虚拟机栈;当方法执行完成后,栈帧就会出栈。例如,在一个递归调用的方法中,每次递归调用都会创建一个新的栈帧并压入栈中。
- 局部变量表:
- 局部变量表用于存储方法参数和方法内部定义的局部变量。其大小在编译期就已经确定,并且在方法运行期间保持不变。例如,在
public int add(int a, int b)
方法中,参数a
和b
就存储在局部变量表中。局部变量表的槽(Slot)可以存储基本数据类型(如int
、float
等)、对象引用(reference
类型)或者returnAddress
类型的值。
- 局部变量表用于存储方法参数和方法内部定义的局部变量。其大小在编译期就已经确定,并且在方法运行期间保持不变。例如,在
- 操作数栈:
- 操作数栈是一个后入先出(LIFO)的栈,用于在方法执行过程中进行操作数的运算。字节码指令会从局部变量表或者操作数栈中获取操作数,并将运算结果压入操作数栈。例如,在执行
iadd
(整数加法)指令时,会从操作数栈中弹出两个整数,相加后将结果再压入操作数栈。
- 操作数栈是一个后入先出(LIFO)的栈,用于在方法执行过程中进行操作数的运算。字节码指令会从局部变量表或者操作数栈中获取操作数,并将运算结果压入操作数栈。例如,在执行
- 动态连接与方法出口:
- 动态连接用于在运行时确定方法的调用版本。在 Java 中有虚方法(如被
override
的方法),在调用这些方法时,需要通过动态连接来确定具体要调用的方法实现。方法出口则存储了方法执行完成后返回的位置信息,以便在方法执行完毕后能够正确地返回到调用者方法。
- 动态连接用于在运行时确定方法的调用版本。在 Java 中有虚方法(如被
- 异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,就会抛出
StackOverflowError
。例如,在一个无限递归的方法中,由于栈帧不断地被压入虚拟机栈,最终会导致栈溢出。另外,如果虚拟机栈可以动态扩展,但是在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError
。
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,就会抛出
- 栈帧结构与方法调用:
-
本地方法栈(Native Method Stacks)
- 与虚拟机栈的对比:
- 本地方法栈和 Java 虚拟机栈类似,主要的区别在于 Java 虚拟机栈是为 Java 方法(字节码方法)服务的,而本地方法栈是为本地方法(一般是用 C 或 C++ 等语言编写的方法)服务的。当本地方法被执行时,会在本地方法栈中创建栈帧,用于存储本地方法执行过程中的信息。
- 异常情况:
- 本地方法栈同样会出现
StackOverflowError
和OutOfMemoryError
这两种异常情况,原理和 Java 虚拟机栈类似。例如,在一个本地方法中如果出现了无限递归或者本地方法栈内存耗尽的情况,就会抛出相应的异常。
- 本地方法栈同样会出现
- 与虚拟机栈的对比:
-
Java 堆(Java Heap)
- 对象存储与内存分配:
- Java 堆是 JVM 管理的内存中最大的一块共享内存区域,几乎所有的对象实例和数组都在 Java 堆中分配内存。例如,当使用
new
关键字创建一个对象时,对象的内存空间通常是在 Java 堆中分配的。它是垃圾收集器主要的工作区域,因为在程序运行过程中,对象的创建和销毁是动态的,需要垃圾收集器来回收不再使用的对象所占用的内存。
- Java 堆是 JVM 管理的内存中最大的一块共享内存区域,几乎所有的对象实例和数组都在 Java 堆中分配内存。例如,当使用
- 分代收集算法与堆的细分:
- 由于垃圾回收机制的存在,Java 堆一般会根据对象的存活特性采用分代收集算法,将堆分为新生代和老年代。新生代又可以进一步细分为 Eden 空间、From Survivor 空间和 To Survivor 空间。新创建的对象一般首先分配在 Eden 空间,经过一次垃圾回收后,存活的对象会被移动到 Survivor 空间,在多次垃圾回收后仍然存活的对象会被移动到老年代。不同的代采用不同的垃圾回收策略,以提高垃圾回收的效率。
- 内存溢出异常:
- 当在 Java 堆中没有足够的内存来完成对象实例分配,并且堆无法再扩展时(堆的大小可以通过
-Xmx
和-Xms
等参数来设置),就会抛出OutOfMemoryError
。例如,在一个处理大量数据的程序中,如果不断地创建新的对象而不及时释放,就可能导致堆内存耗尽。
- 当在 Java 堆中没有足够的内存来完成对象实例分配,并且堆无法再扩展时(堆的大小可以通过
- 对象存储与内存分配:
-
方法区(Method Area)
- 存储内容概述:
- 方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息(包括类的版本、字段、方法、接口等)、常量、静态变量、即时编译器编译后的代码等。例如,一个类的静态方法和静态变量就存储在方法区中,所有该类的实例都共享这些静态资源。
- 运行时常量池:
- 运行时常量池是方法区的一部分,它是类文件中的常量池表的运行时表示形式。在编译期生成的各种字面量(如字符串字面量)和符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)等会存储在运行时常量池中,并且在运行期间可以动态地添加新的常量。例如,
String s = "hello"
中的"hello"
这个字符串字面量就存储在运行时常量池中。
- 运行时常量池是方法区的一部分,它是类文件中的常量池表的运行时表示形式。在编译期生成的各种字面量(如字符串字面量)和符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)等会存储在运行时常量池中,并且在运行期间可以动态地添加新的常量。例如,
- 内存溢出异常:
- 当方法区无法满足内存分配需求时,也会抛出
OutOfMemoryError
。在 Java 8 之后,由于移除了永久代,将字符串常量池等移到了堆中,方法区的内存管理方式发生了变化,其内存溢出的情况也有所不同。例如,在使用大量的动态代理或者加载大量类的情况下,可能会导致方法区内存不足。
- 当方法区无法满足内存分配需求时,也会抛出
- 存储内容概述:
jdk 1.8 之后的
- 元空间(Metaspace)取代永久代(Permanent Generation)
- 背景和原因:
- 在 JDK 1.8 之前,方法区的实现是通过永久代来完成的。永久代有一些局限性,例如容易出现内存溢出(
OutOfMemoryError
),尤其是在加载大量类或者使用动态代理等技术时。而且,它的大小是固定的(通过-XX:MaxPermSize
参数设置),在某些复杂的应用场景下可能无法满足需求。JDK 1.8 之后,采用元空间来替代永久代,元空间直接使用本地内存,而不是虚拟机内存。
- 在 JDK 1.8 之前,方法区的实现是通过永久代来完成的。永久代有一些局限性,例如容易出现内存溢出(
- 存储内容变化:
- 元空间存储的主要是类的元数据信息,如类的结构信息(包括类的方法、字段等)、常量池等。与永久代不同的是,字符串常量池在 JDK 1.8 后被移到了堆(Java Heap)中。这样的改变使得内存管理更加灵活,因为本地内存的空间相对较大,并且元空间的大小不再受限于固定的虚拟机参数设置,而是由系统的本地内存和应用的实际需求动态调整。
- 内存管理和性能影响:
- 元空间的内存管理更加灵活高效。它可以根据应用程序的实际需求自动扩展和收缩,减少了因永久代内存不足导致的
OutOfMemoryError
的发生概率。不过,如果应用程序中存在大量的类加载或者动态生成类的情况,元空间的内存占用可能会迅速增加。在这种情况下,需要合理地监控和调整内存使用,避免本地内存耗尽。
- 元空间的内存管理更加灵活高效。它可以根据应用程序的实际需求自动扩展和收缩,减少了因永久代内存不足导致的
- 背景和原因:
- Java 堆的优化
- G1 垃圾收集器的广泛应用:
- G1(Garbage - First)垃圾收集器在 JDK 1.8 之后得到了更广泛的应用。与之前的垃圾收集器(如 Parallel Old、CMS 等)相比,G1 有很多优势。它是一款面向服务器的垃圾收集器,主要用于处理大容量的堆内存。G1 采用了分区(Region)的概念,将堆内存划分为多个大小相等的 Region,这些 Region 在逻辑上又分为新生代和老年代。
- 在垃圾收集过程中,G1 会优先收集垃圾最多的 Region,从而提高垃圾收集的效率。例如,在一个大型的企业级应用中,内存中有大量的对象,G1 能够更快地回收那些垃圾较多的区域,减少垃圾收集对应用程序的暂停时间。而且,G1 能够在后台进行并发标记,进一步减少了应用程序的停顿时间,提供了更好的用户体验。
- 对堆内存参数的调整建议:
- JDK 1.8 之后,对于堆内存参数的调整也有了一些新的考虑。例如,由于 G1 垃圾收集器的特性,
-Xmx
(最大堆内存)和-Xms
(最小堆内存)的设置可以更加灵活。可以将它们设置为相同的值,以避免堆内存的动态扩展和收缩带来的性能开销。同时,对于 G1 的一些特定参数,如-XX:MaxGCPauseMillis
(设置最大垃圾收集暂停时间目标)等,可以根据应用程序的性能要求进行精细调整,以平衡垃圾收集效率和应用程序的响应速度。
- JDK 1.8 之后,对于堆内存参数的调整也有了一些新的考虑。例如,由于 G1 垃圾收集器的特性,
- G1 垃圾收集器的广泛应用:
- Java 虚拟机栈和本地方法栈的改进
- 对栈内存溢出检测的优化:
- JDK 1.8 在检测栈内存溢出(
StackOverflowError
)方面可能会有一些性能优化。当线程的栈深度超过虚拟机所允许的深度时,会抛出StackOverflowError
。JDK 1.8 通过改进栈内存的管理和溢出检测机制,能够更快地发现和处理这种异常情况。例如,在递归调用层数较深的方法时,JDK 1.8 能够更及时地抛出异常,避免浪费过多的系统资源。
- JDK 1.8 在检测栈内存溢出(
- 本地方法栈与安全机制的强化:
- 在本地方法栈方面,JDK 1.8 加强了与本地方法相关的安全机制。由于本地方法通常涉及到与底层系统(如操作系统的本地库)的交互,存在一定的安全风险。JDK 1.8 通过改进本地方法栈的访问控制和安全检查机制,减少了因本地方法调用而导致的安全漏洞。例如,在调用一些涉及文件系统或者网络访问的本地方法时,会进行更严格的权限检查。
- 对栈内存溢出检测的优化:
二、什么情况下会内存溢出?
- Java 堆内存溢出(
OutOfMemoryError: Java heap space
)- 对象过度创建且长期存活:
- 在程序中不断地创建新的对象,并且这些对象在很长时间内都不会被垃圾回收器回收,就会导致堆内存被逐渐占满。例如,在一个处理大量数据的循环中,每次循环都创建新的大型数据对象(如包含大量元素的数组或复杂的自定义对象),而没有及时释放这些对象占用的内存。
- 以一个简单的示例来说,假设有一个程序不断地读取文件内容并将每行内容存储为一个
String
对象放入一个List
中,如果读取的文件非常大,并且这个List
对象一直被引用而没有机会被回收,就可能导致堆内存溢出。
- 缓存数据无限制增长:
- 当应用程序使用缓存来存储大量数据时,如果没有对缓存的大小进行限制或者没有合理的缓存过期策略,缓存中的对象会不断积累,最终耗尽堆内存。例如,一个 Web 应用在内存中缓存了所有用户的会话信息,随着用户数量的增加和会话时间的延长,缓存数据占用的堆内存会越来越大。
- 大对象直接分配:
- 如果在程序中频繁地创建非常大的对象(如超大的数组或巨大的位图对象),并且这些大对象的数量较多,也容易导致堆内存溢出。例如,在一个图像处理应用中,需要频繁地加载高分辨率的图像数据到内存中,如果同时加载的图像数量过多,就可能超出堆内存的承载能力。
- 对象过度创建且长期存活:
- 方法区内存溢出(
OutOfMemoryError: PermGen space
- JDK 1.7 及以前,OutOfMemoryError: Metaspace
- JDK 1.8 及以后)- 大量类加载:
- 在 JDK 1.7 及以前的永久代中,或者 JDK 1.8 及以后的元空间中,如果应用程序动态加载了大量的类,就可能导致内存溢出。例如,在一些使用动态代理技术的框架中,会在运行时动态生成大量的代理类。如果这些代理类没有被及时卸载,并且数量不断增加,就会消耗方法区的内存。
- 另外,在一些具有插件系统的应用中,频繁地加载和卸载插件(每个插件都包含大量的类),可能会因为方法区无法及时回收之前加载插件的类信息而导致内存溢出。
- 运行时常量池溢出:
- 运行时常量池存储了编译期生成的各种字面量和符号引用,并且在运行期间可以动态添加新的常量。如果在程序中大量使用
String.intern()
方法(在 JDK 1.7 及以前,这个方法会将字符串常量放入永久代的字符串常量池;在 JDK 1.8 及以后,放入堆中,但如果堆中的字符串常量池过度膨胀也可能引发问题),或者有大量的动态生成的常量(如通过反射机制生成的大量符号引用),可能会导致运行时常量池所在的方法区内存溢出。
- 运行时常量池存储了编译期生成的各种字面量和符号引用,并且在运行期间可以动态添加新的常量。如果在程序中大量使用
- 大量类加载:
- Java 虚拟机栈和本地方法栈内存溢出
- 栈帧过多导致栈溢出(
StackOverflowError
):- 当一个方法递归调用自身,并且没有正确的终止条件时,会不断地创建新的栈帧并压入虚拟机栈或本地方法栈,最终导致栈溢出。例如,一个简单的递归计算阶乘的方法,如果没有对输入参数进行合理的限制,当输入的数值较大时,会导致栈深度过深而溢出。
- 除了递归调用,方法之间的循环调用(如方法 A 调用方法 B,方法 B 又调用方法 A)也可能导致栈帧过多而溢出。
- 栈内存空间不足(
OutOfMemoryError
):- 如果虚拟机栈或本地方法栈的内存空间设置过小,并且在方法执行过程中需要较多的栈空间(例如,方法中有大量的局部变量或者复杂的操作数栈操作),可能会导致栈内存空间不足而抛出
OutOfMemoryError
。不过,这种情况相对较少,因为一般情况下虚拟机栈和本地方法栈的默认大小在大多数应用场景下是足够的。
- 如果虚拟机栈或本地方法栈的内存空间设置过小,并且在方法执行过程中需要较多的栈空间(例如,方法中有大量的局部变量或者复杂的操作数栈操作),可能会导致栈内存空间不足而抛出
- 栈帧过多导致栈溢出(
三、JVM 有哪些垃圾回收算法?
- 标记 - 清除算法(Mark - Sweep)
- 原理:
- 标记 - 清除算法是最基础的垃圾回收算法。它分为两个阶段,首先是标记阶段,垃圾收集器会从根对象(如虚拟机栈中的局部变量表、方法区中的静态变量等引用的对象)开始,通过深度优先搜索或广度优先搜索的方式,标记所有从根对象可达的对象,这些被标记的对象被认为是存活的。然后是清除阶段,遍历整个堆内存,将未被标记的对象回收,即将这些对象占用的内存空间释放掉,以便后续重新分配给新的对象使用。
- 优点:
- 实现简单,容易理解。对于存活对象较多的场景,标记阶段的开销相对较小,因为只需要标记少量的垃圾对象。
- 缺点:
- 效率问题:标记和清除两个过程的效率都不高。标记阶段需要遍历整个堆内存中的对象来确定存活对象,清除阶段也需要遍历整个堆来回收垃圾对象,这两个过程都比较耗时。
- 空间碎片化:清除后的内存空间是不连续的,会产生大量的内存碎片。当需要分配一个较大的对象时,可能由于没有足够大的连续内存空间而导致分配失败,尽管此时堆内存的空闲空间总和可能是足够的。
- 原理:
- 复制算法(Copying)
- 原理:
- 复制算法将可用内存划分为两个大小相等的区域,通常称为
From
区和To
区。在对象分配内存时,只使用From
区。当进行垃圾回收时,会将From
区中存活的对象复制到To
区,然后清空From
区。此时,To
区就成为了新的From
区,原来的From
区就成为了新的To
区,用于下一次垃圾回收。
- 复制算法将可用内存划分为两个大小相等的区域,通常称为
- 优点:
- 简单高效:复制算法的实现相对简单,并且在复制存活对象时是顺序复制,效率较高。同时,它不会产生内存碎片,因为每次回收后,
To
区都是连续的空闲空间,新对象的分配可以在连续的空间中进行。
- 简单高效:复制算法的实现相对简单,并且在复制存活对象时是顺序复制,效率较高。同时,它不会产生内存碎片,因为每次回收后,
- 缺点:
- 内存利用率低:由于需要将内存划分为两个相等的区域,在实际使用中,只有一半的内存空间可用于对象分配,另一半用于备份存活对象,这使得内存利用率较低。对于存活对象较多的场景,复制存活对象的开销也会较大。
- 原理:
- 标记 - 整理算法(Mark - Compact)
- 原理:
- 标记 - 整理算法结合了标记 - 清除算法和复制算法的优点。它也分为两个阶段,首先是标记阶段,与标记 - 清除算法一样,从根对象开始标记所有存活的对象。然后是整理阶段,将所有存活的对象向一端移动,使得存活对象在内存空间的一端排列,然后将存活对象边界以外的内存空间全部回收,这样就得到了一个连续的空闲空间,方便后续新对象的分配。
- 优点:
- 解决了标记 - 清除算法的空间碎片化问题,经过整理后,内存空间是连续的,不会出现因碎片导致无法分配大对象的情况。同时,它不需要像复制算法那样浪费一半的内存空间用于备份存活对象,提高了内存利用率。
- 缺点:
- 整理阶段的移动存活对象操作会有一定的性能开销。在移动对象时,需要更新所有引用这些对象的地方,这可能会涉及到较多的指针操作,效率相对较低。
- 原理:
- 分代收集算法(Generational Collection)
- 原理:
- 分代收集算法是目前大多数 JVM 垃圾收集器采用的算法。它基于这样一个观察结果:在 Java 程序中,大部分对象的生命周期都很短,只有少部分对象会存活较长时间。因此,它将堆内存划分为不同的代,通常是新生代和老年代。新生代又可以细分为 Eden 区和两个 Survivor 区(
From
Survivor 和To
Survivor)。新创建的对象一般分配在 Eden 区,经过一次垃圾回收后,存活的对象会被移动到 Survivor 区,经过多次回收后仍然存活的对象会被移动到老年代。 - 不同代采用不同的垃圾回收算法。在新生代,由于对象大部分是朝生暮死的,通常采用复制算法,因为复制算法对于存活对象较少的场景效率较高。在老年代,由于对象存活时间较长,且对象数量相对较少,通常采用标记 - 清除算法或者标记 - 整理算法。
- 分代收集算法是目前大多数 JVM 垃圾收集器采用的算法。它基于这样一个观察结果:在 Java 程序中,大部分对象的生命周期都很短,只有少部分对象会存活较长时间。因此,它将堆内存划分为不同的代,通常是新生代和老年代。新生代又可以细分为 Eden 区和两个 Survivor 区(
- 优点:
- 根据对象的生命周期特点采用不同的回收策略,提高了垃圾回收的效率。对于新生代的大量短生命周期对象可以快速有效地回收,对于老年代的长生命周期对象也能进行合理的管理。
- 缺点:
- 实现复杂,需要维护不同代之间的对象转移和回收机制。并且,分代的边界可能不是绝对的,对于一些特殊的对象(如大对象可能直接进入老年代),需要特殊的处理,增加了垃圾回收器的复杂性。
- 原理:
四、GC如何判断对象可以被回收
1,引用计数法(Reference Counting)
-
原理:
- 引用计数法是一种比较直观的判断对象是否可以被回收的方法。在这种方法中,为每个对象添加一个引用计数器。当有一个地方引用这个对象时,计数器就加 1;当引用失效(例如,引用变量超出作用域或者被重新赋值)时,计数器就减 1。当对象的引用计数器的值为 0 时,就表示这个对象可以被回收。
-
示例:
-
假设有一个简单的 Java 类
Person
,在代码中创建了Person
对象并进行引用计数。
class Person { } public class Main { public static void main(String[] args) { Person p1 = new Person(); // p1引用对象,计数器为1 Person p2 = p1; // p2也引用对象,计数器为2 p1 = null; // p1引用失效,计数器为1 p2 = null; // p2引用失效,计数器为0,对象可回收 } }
-
-
缺点:
- 无法解决循环引用的问题。例如,有两个对象
A
和B
,A
引用B
,B
也引用A
,它们的引用计数器都不会变为 0,即使没有其他对象引用A
和B
,这两个对象也不会被回收,这可能会导致内存泄漏。
- 无法解决循环引用的问题。例如,有两个对象
2,可达性分析算法(Reachability Analysis)
JVM 主要采用可达性分析算法来判断对象是否可以被回收。
- 原因
- 准确性和高效性的平衡:
- 可达性分析算法能够准确地判断对象是否真正不可达,避免了引用计数法中循环引用导致对象无法回收的问题。同时,它的实现效率在实际应用中是可以接受的。JVM 在进行垃圾回收时,通过从 GC Roots 开始的深度优先搜索或者广度优先搜索等方式,可以在合理的时间内确定对象的可达性。
- 与 Java 语言特性相适配:
- Java 语言有丰富的对象引用关系,包括强引用、软引用、弱引用和虚引用等。可达性分析算法可以很好地结合这些引用类型来判断对象的生命周期。例如,对于强引用对象,只要从 GC Roots 可达,就不会被回收;而对于软引用对象,在内存不足时可能会被回收,这可以通过在可达性分析的基础上结合内存状态来判断。
- 准确性和高效性的平衡:
- 实现细节
- GC Roots 的枚举:
- JVM 在进行可达性分析时,首先需要枚举 GC Roots。这个过程需要暂停所有用户线程(Stop - The - World),以确保在枚举过程中对象引用关系不会发生变化。JVM 通过遍历虚拟机栈中的栈帧(获取本地变量表中的引用对象)、方法区中的静态变量和常量等来确定 GC Roots。
- 引用链的搜索:
- 从 GC Roots 开始,JVM 会通过对象之间的引用关系进行搜索。这个搜索过程可以使用深度优先搜索(DFS)或者广度优先搜索(BFS)算法。例如,对于一个树形的对象引用结构,深度优先搜索会沿着一条路径一直深入到叶子节点,然后再回溯搜索其他路径;而广度优先搜索则是一层一层地向外扩展搜索。在搜索过程中,JVM 会标记所有可达的对象,未被标记的对象就可以被认为是可以回收的。
- GC Roots 的枚举:
- 与其他机制的配合
- 垃圾回收算法的结合:
- 可达性分析算法与 JVM 采用的垃圾回收算法紧密配合。例如,在分代收集算法中,通过可达性分析确定新生代和老年代中的不可达对象。对于新生代中大量的短生命周期对象,在确定为不可达后,通常采用复制算法进行回收;对于老年代中的不可达对象,可能采用标记 - 清除或标记 - 整理算法进行回收。
- 内存分配策略的关联:
- 它也与内存分配策略有关。当新对象需要分配内存时,JVM 会先通过可达性分析等手段确定内存空间是否足够。如果有足够的空间,就可以直接分配;如果空间不足,就会触发垃圾回收,通过可达性分析找出可以回收的对象来释放空间,然后再进行分配。
- 垃圾回收算法的结合:
五、典型垃圾回收器
- Serial 垃圾回收器
- 基本原理:
- Serial 垃圾回收器是最基本的单线程垃圾回收器。在进行垃圾回收时,它会暂停所有用户线程(Stop - The - World),然后采用标记 - 复制算法(新生代)或标记 - 清除算法(老年代)来回收垃圾。例如,在新生代中,它会将 Eden 区和 From Survivor 区中存活的对象复制到 To Survivor 区;在老年代中,它会标记并清除不可达的对象。
- 适用场景:
- 适用于单核 CPU 的小型应用程序,因为其实现简单,在简单环境下可以有效回收垃圾。对于内存占用较小、对暂停时间不太敏感的应用,如简单的命令行工具等,Serial 垃圾回收器是一个不错的选择。
- 性能特点:
- 由于是单线程回收,在垃圾回收过程中会导致较长时间的停顿。但是,因为其简单的实现,在资源受限的环境下,它的内存占用较小,并且在回收小内存区域时效率尚可。
- 基本原理:
- Parallel 垃圾回收器(Parallel Scavenge + Parallel Old)
- 基本原理:
- Parallel Scavenge 是用于新生代的垃圾回收器,它采用复制算法。它是多线程的,能够利用多个 CPU 核心同时进行垃圾回收,从而提高垃圾回收的效率。Parallel Old 是用于老年代的垃圾回收器,采用标记 - 整理算法,同样是多线程工作。它们配合使用,能够在整个堆内存范围内高效地回收垃圾。
- 适用场景:
- 适合对吞吐量要求较高的应用,如批处理程序、科学计算等。这些应用通常更关注整体的执行效率,能够接受一定时间的停顿来换取更高的垃圾回收效率和系统吞吐量。
- 性能特点:
- 相比 Serial 垃圾回收器,Parallel 垃圾回收器能够显著提高垃圾回收的速度,减少垃圾回收时间占总运行时间的比例,从而提高系统的吞吐量。但是,它在垃圾回收过程中仍然会产生较长时间的停顿,因为它需要暂停所有用户线程来进行回收操作。
- 基本原理:
- CMS(Concurrent Mark Sweep)垃圾回收器
- 基本原理:
- CMS 主要用于老年代的垃圾回收。它的垃圾回收过程分为四个阶段:初始标记(Initial Mark)、并发标记(Concurrent Mark)、重新标记(Remarking)和并发清除(Concurrent Sweep)。初始标记阶段会暂停所有用户线程,标记出直接与 GC Roots 相连的对象;并发标记阶段,垃圾回收器与用户线程同时运行,标记所有可达对象;重新标记阶段会再次暂停用户线程,处理并发标记阶段中因为用户线程运行而产生的标记变动;最后,并发清除阶段与用户线程同时运行,清除不可达的对象。
- 适用场景:
- 适用于对响应时间敏感的应用,如 Web 应用、交互式应用等。这些应用要求垃圾回收过程尽量减少对用户体验的影响,不能出现长时间的停顿。
- 性能特点:
- CMS 垃圾回收器的最大优点是能够在垃圾回收过程中尽量减少停顿时间。但是,它也有一些缺点,比如在并发标记和并发清除阶段,会占用一部分 CPU 资源,可能会对应用程序的性能产生一定的影响。而且,它采用的标记 - 清除算法会产生内存碎片,可能会影响后续的内存分配效率。
- 基本原理:
- G1(Garbage - First)垃圾回收器
- 基本原理:
- G1 垃圾回收器将堆内存划分为多个大小相等的区域(Region),这些区域在逻辑上又分为新生代和老年代。它在回收时会优先回收垃圾最多的区域,采用复制算法(新生代)和标记 - 整理算法(老年代)相结合的方式。在垃圾回收过程中,G1 也分为多个阶段,包括初始标记、并发标记、最终标记、筛选回收等,部分阶段可以与用户线程并发执行。
- 适用场景:
- 适用于大容量的堆内存环境,如大型服务器应用、分布式系统等。它能够有效地处理大内存中的垃圾回收问题,并且能够较好地控制停顿时间。
- 性能特点:
- G1 垃圾回收器的优点是能够灵活地控制停顿时间,通过设置参数可以让停顿时间保持在一个可接受的范围内。同时,它对内存的利用效率较高,不会像 CMS 那样产生大量的内存碎片。不过,它的实现相对复杂,在小内存环境下或者垃圾回收压力较小的情况下,性能优势可能不明显。
- 基本原理:
六、类加载器和双亲委派机制
-
类加载器(ClassLoader)概述
- 定义与作用:
- 类加载器是 Java 虚拟机(JVM)中负责加载类文件的组件。它的主要功能是将字节码文件(.class 文件)加载到内存中,并将其转换为可以被 JVM 直接使用的 Java 类。例如,当程序中使用
new
关键字创建一个对象时,首先需要通过类加载器将对应的类加载到内存中。
- 类加载器是 Java 虚拟机(JVM)中负责加载类文件的组件。它的主要功能是将字节码文件(.class 文件)加载到内存中,并将其转换为可以被 JVM 直接使用的 Java 类。例如,当程序中使用
- 分类:
- 启动类加载器(Bootstrap ClassLoader):它是最顶层的类加载器,主要负责加载 Java 核心库,如
java.lang
包中的类(像Object
、String
等)。它是由 C++ 实现的,是 JVM 的一部分,在 Java 中无法直接获取它的引用。 - 扩展类加载器(Extension ClassLoader):它的父加载器是启动类加载器,主要负责加载 Java 的扩展库,通常是
jre/lib/ext
目录下的类库。它是由 Java 语言实现的。 - 应用程序类加载器(Application ClassLoader):也称为系统类加载器,它的父加载器是扩展类加载器。主要负责加载应用程序的类路径(
classpath
)下的类,这是我们在日常开发中最常接触到的类加载器,它会加载我们自己编写的 Java 类。
- 启动类加载器(Bootstrap ClassLoader):它是最顶层的类加载器,主要负责加载 Java 核心库,如
- 定义与作用:
-
双亲委派机制(Parents Delegation Model)原理
- 工作流程:
- 当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把这个请求委派给父加载器。只有当父加载器无法完成加载任务(即找不到对应的类)时,子加载器才会尝试自己加载。例如,当应用程序类加载器收到加载一个类的请求时,它会先委派给扩展类加载器,扩展类加载器再委派给启动类加载器。如果启动类加载器无法加载这个类,才会由扩展类加载器尝试加载,若扩展类加载器也无法加载,最后才由应用程序类加载器自己加载。
- 代码示例说明:
- 假设我们有一个自定义的
java.lang.String
类(只是为了演示,实际开发中不建议这样做,因为这违反了 Java 的规范),当我们在程序中使用这个类时,应用程序类加载器会先把加载请求委派给扩展类加载器,扩展类加载器再委派给启动类加载器。启动类加载器会加载它所负责的java.lang
包中的String
类(真正的 Java 核心库中的String
类),而不会加载我们自定义的这个类。
- 假设我们有一个自定义的
- 优势:
- 保证类的一致性:通过双亲委派机制,确保了 Java 核心库中的类(如
java.lang
包中的类)由启动类加载器加载,这样就保证了在整个 Java 应用中,这些核心类的唯一性和一致性。例如,无论在哪个应用程序或者模块中,java.lang.Object
类都是由启动类加载器加载的同一个类,避免了不同版本或者不同实现的混乱。 - 安全性:可以防止恶意代码自定义一些与核心库同名的类来破坏系统。因为核心类总是由顶层的启动类加载器加载,恶意代码自定义的类无法覆盖核心类的加载路径,从而保障了系统的安全性。
- 保证类的一致性:通过双亲委派机制,确保了 Java 核心库中的类(如
- 工作流程:
-
双亲委派机制的实现细节
- 在类加载器中的代码体现:
- 在 Java 的类加载器实现中,
loadClass()
方法体现了双亲委派机制。以下是一个简化的类加载器加载类的过程(不考虑异常情况):
- 在 Java 的类加载器实现中,
public Class loadClass(String name) throws ClassNotFoundException { Class c = findLoadedClass(name); if (c == null) { try { if (parent!= null) { c = parent.loadClass(name); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父加载器无法加载,自己尝试加载 c = findClass(name); } } return c; }
- 在类加载器中的代码体现:
- 首先会检查这个类是否已经被加载过(
findLoadedClass
),如果没有加载,会先尝试让父加载器加载(parent.loadClass
),如果父加载器为null
(如启动类加载器),会尝试通过特定方式加载核心类(findBootstrapClassOrNull
)。只有当父加载器无法加载时,才会调用自己的findClass
方法来加载。 - 打破双亲委派机制:
- 在某些特殊情况下,可能需要打破双亲委派机制。例如,在一些复杂的框架中,需要在运行时动态加载一些类,并且这些类的加载方式不符合双亲委派机制的常规流程。可以通过重写类加载器的
loadClass
方法来实现。不过,这种做法需要谨慎使用,因为它可能会破坏 Java 类加载的稳定性和安全性。
- 在某些特殊情况下,可能需要打破双亲委派机制。例如,在一些复杂的框架中,需要在运行时动态加载一些类,并且这些类的加载方式不符合双亲委派机制的常规流程。可以通过重写类加载器的
七、JVM中有哪些引用?
1,强引用(Strong Reference)
-
定义与特点:
- 强引用是最常见的引用类型。在 Java 代码中,通过
new
关键字创建的对象引用就是强引用。例如,Object obj = new Object();
中的obj
就是对新创建的Object
对象的强引用。只要存在强引用,垃圾回收器在进行内存回收时,就不会回收被引用的对象,即使内存空间不足也不会回收。强引用对象只有在所有指向它的强引用都被释放(如引用变量超出作用域或者被显式地赋值为null
)之后,才可能被垃圾回收。
- 强引用是最常见的引用类型。在 Java 代码中,通过
-
示例与影响:
- 假设在一个方法中有如下代码:
public void method() { ArrayList list = new ArrayList<>(); list.add(1); list.add(2); // 在这里,list是对ArrayList对象的强引用, // 只要method方法没有结束,list所引用的ArrayList对象就不会被回收 }
-
这种引用类型保证了对象在需要时能够稳定地存在于内存中,但如果使用不当,可能会导致内存泄漏。例如,在一个长期运行的系统中,如果有大量的强引用对象一直被引用,即使它们已经不再使用,也不会被回收,从而占用大量的内存资源。
2,软引用(Soft Reference)
-
定义与特点:
- 软引用是一种相对较弱的引用。当内存空间足够时,软引用所指向的对象不会被垃圾回收;但是当内存空间不足时,垃圾回收器会回收软引用所指向的对象。软引用通常用于实现缓存。例如,可以将一些不经常使用但又可能会再次使用的对象用软引用包装起来存储在缓存中。这样,在内存充足的情况下,这些对象可以保留在缓存中以便快速访问;当内存紧张时,这些对象可以被回收以释放内存空间。
-
示例与应用场景:
- 以下是一个简单的软引用示例:
import java.lang.ref.SoftReference; public class SoftReferenceExample { public static void main(String[] args) { SoftReference softRef = new SoftReference<>("Hello"); String str = softRef.get(); if (str!= null) { System.out.println("对象还未被回收,值为: " + str); } else { System.out.println("对象已被回收"); } } }
-
在这个示例中,创建了一个软引用
softRef
指向字符串"Hello"
。在内存充足的情况下,通过softRef.get()
可以获取到这个字符串。软引用常用于缓存图片、文件内容等场景。例如,在一个图片浏览器应用中,对于已经浏览过的图片,可以用软引用来缓存图片对象,当内存不足时,这些图片对象可以被回收,而在内存允许的情况下,用户再次查看图片时可以直接从缓存中获取,提高访问速度。
3,弱引用(Weak Reference)
-
定义与特点:
- 弱引用比软引用更弱。只要垃圾回收器运行,一旦发现弱引用所指向的对象只有弱引用关联它(即没有任何强引用指向这个对象),就会回收这个对象。弱引用的主要作用是用于解决一些对象生命周期管理的特殊情况,例如在一些容器类中,当对象不再被其他地方强引用时,希望能够及时清理这些对象,同时又不影响正常的对象访问逻辑。
-
示例与应用场景:
- 以下是一个弱引用示例:
import java.lang.ref.WeakReference; public class WeakReferenceExample { public static void main(String[] args) { WeakReference weakRef = new WeakReference<>("Weak"); System.gc(); // 手动触发垃圾回收 String str = weakRef.get(); if (str!= null) { System.out.println("对象还未被回收,值为: " + str); } else { System.out.println("对象已被回收"); } } }
-
在这个示例中,创建了一个弱引用
weakRef
指向字符串"Weak"
。当手动触发垃圾回收(System.gc()
)后,由于只有弱引用指向这个字符串,所以这个字符串很可能会被回收。弱引用常用于一些特殊的集合类,如WeakHashMap
。在WeakHashMap
中,键是弱引用,当键对象没有其他强引用时,键 - 值对会被自动回收,这在一些场景下可以避免内存泄漏。
4,虚引用(Phantom Reference)
-
定义与特点:
- 虚引用是最弱的一种引用。虚引用的主要目的不是为了访问对象,而是在对象被回收时收到一个系统通知。虚引用对象必须和引用队列(Reference Queue)一起使用。当垃圾回收器准备回收一个对象时,如果发现它有虚引用,就会在回收对象之前,把这个虚引用加入到引用队列中。通过检查引用队列,可以得知对象是否已经被回收。
-
示例与应用场景:
- 以下是一个虚引用示例:
import java.lang.ref.PhantomReference; import java.lang.ref.ReferenceQueue; public class PhantomReferenceExample { public static void main(String[] args) { ReferenceQueue queue = new ReferenceQueue<>(); PhantomReference phantomRef = new PhantomReference<>(new Object(), queue); System.gc(); Object removed = queue.poll(); if (removed!= null) { System.out.println("对象已被回收"); } else { System.out.println("对象尚未被回收"); } } }
-
在这个示例中,创建了一个虚引用
phantomRef
指向一个新创建的对象,并将其与一个引用队列queue
关联。当手动触发垃圾回收(System.gc()
)后,检查引用队列queue
,如果队首元素不为空,说明对象已经被回收。虚引用通常用于一些比较复杂的资源回收场景,如在直接内存(Direct Memory)管理中,当直接内存被回收时,可以通过虚引用来进行一些后续的清理工作。 -
加载(Loading)阶段
- 字节码文件的获取:
- 类加载器首先要获取类的字节码文件(.class 文件)。获取方式有多种,对于本地编译的 Java 程序,字节码文件通常从本地文件系统中读取,这个路径可以是通过
-classpath
或者-cp
参数指定的目录或者 JAR 文件。例如,如果有一个名为com.example.MyClass
的类,类加载器会在classpath
指定的路径下寻找com/example/MyClass.class
文件。此外,字节码文件也可以从网络中获取,比如在一些动态加载的场景下,通过网络下载字节码文件进行加载。
- 类加载器首先要获取类的字节码文件(.class 文件)。获取方式有多种,对于本地编译的 Java 程序,字节码文件通常从本地文件系统中读取,这个路径可以是通过
- 将字节码文件转换为方法区中的运行时数据结构:
- 类加载器把获取到的字节码文件内容加载到内存中的方法区,同时将字节码文件中的数据转换为方法区中的运行时数据结构。这些数据结构包括类的信息(如类名、父类名、接口信息等)、常量池信息(存储各种字面量和符号引用)、字段信息(包括字段的名称、类型、修饰符等)和方法信息(包括方法的签名、字节码指令等)。例如,对于一个包含
int
类型字段和void
类型方法的类,其字段和方法的相关信息都会被解析并存储到方法区的运行时数据结构中。
- 类加载器把获取到的字节码文件内容加载到内存中的方法区,同时将字节码文件中的数据转换为方法区中的运行时数据结构。这些数据结构包括类的信息(如类名、父类名、接口信息等)、常量池信息(存储各种字面量和符号引用)、字段信息(包括字段的名称、类型、修饰符等)和方法信息(包括方法的签名、字节码指令等)。例如,对于一个包含
- 在内存中生成一个代表这个类的
java.lang.Class
对象:- 加载阶段的最后一步是在堆内存中生成一个
java.lang.Class
对象,这个对象是 Java 程序访问和操作类的入口。它包含了类的各种元数据信息,并且可以通过反射机制来访问。例如,通过Class.forName("com.example.MyClass")
获取到的Class
对象,就可以用来创建该类的实例、调用类的方法、访问类的字段等操作。
- 加载阶段的最后一步是在堆内存中生成一个
- 字节码文件的获取:
-
验证(Verification)阶段
- 文件格式验证:
- 验证字节码文件的格式是否符合 Java 虚拟机规范。这包括检查字节码文件是否以正确的魔数(
0xCAFEBABE
)开头,主版本号和次版本号是否在 JVM 支持的范围内等。例如,如果字节码文件的魔数错误或者版本号不被 JVM 识别,那么这个字节码文件就无法通过验证。
- 验证字节码文件的格式是否符合 Java 虚拟机规范。这包括检查字节码文件是否以正确的魔数(
- 元数据验证:
- 对类的元数据进行验证,确保类的继承关系、接口实现等符合 Java 语言的规则。例如,要验证类是否继承自
java.lang.Object
(除了java.lang.Object
本身),是否实现了接口中定义的所有方法等。如果一个类声称实现了某个接口,但没有实现接口规定的所有方法,就无法通过验证。
- 对类的元数据进行验证,确保类的继承关系、接口实现等符合 Java 语言的规则。例如,要验证类是否继承自
- 字节码验证:
- 验证字节码指令的合法性。这是一个比较复杂的过程,包括检查字节码指令的操作数类型是否正确、跳转指令是否指向合法的位置、方法调用的参数和返回值类型是否正确等。例如,在字节码指令中,如果一个加法指令的操作数类型应该是
int
,但实际是Object
类型,就会导致验证失败。
- 验证字节码指令的合法性。这是一个比较复杂的过程,包括检查字节码指令的操作数类型是否正确、跳转指令是否指向合法的位置、方法调用的参数和返回值类型是否正确等。例如,在字节码指令中,如果一个加法指令的操作数类型应该是
- 符号引用验证:
- 对类的符号引用进行验证。符号引用是在编译期生成的,用于在运行时定位类、方法和字段等。验证过程包括检查符号引用所指向的类、方法和字段是否存在,以及访问权限是否符合要求等。例如,如果一个类引用了另一个不存在的类的方法,符号引用验证就会失败。
- 文件格式验证:
-
准备(Preparation)阶段
- 为类变量分配内存空间:
- 在方法区中为类的静态变量(类变量)分配内存空间。这些内存空间的分配是在类加载的准备阶段进行的,并且仅针对类变量,不包括实例变量。例如,对于一个包含
static int count = 0;
的类,在准备阶段会在方法区为count
分配内存空间。
- 在方法区中为类的静态变量(类变量)分配内存空间。这些内存空间的分配是在类加载的准备阶段进行的,并且仅针对类变量,不包括实例变量。例如,对于一个包含
- 设置类变量的初始值:
- 为类变量设置初始值,但这个初始值通常是数据类型对应的零值。对于基本数据类型,如
int
的初始值是0
,boolean
的初始值是false
;对于引用类型,初始值是null
。继续上面的例子,在准备阶段,count
的值会被设置为0
,而不是0
这个初始值的赋值操作(count = 0;
)是在初始化阶段完成的。
- 为类变量设置初始值,但这个初始值通常是数据类型对应的零值。对于基本数据类型,如
- 为类变量分配内存空间:
-
初始化(Initialization)阶段
- 执行类构造器
<clinit>
方法:- 初始化阶段是类加载过程的最后一步,它执行类的构造器
<clinit>
方法。这个方法是由编译器自动收集类中的所有静态变量的赋值语句和静态代码块合并而成的。例如,对于一个有静态变量赋值和静态代码块的类:
- 初始化阶段是类加载过程的最后一步,它执行类的构造器
public class MyClass { static int a = 10; static { System.out.println("静态代码块"); } }
- 执行类构造器
- 编译器会将
a = 10;
和静态代码块中的内容合并到<clinit>
方法中。在初始化阶段,JVM 会执行这个<clinit>
方法,按照代码的顺序依次执行静态变量赋值和静态代码块中的内容。 - 类的初始化时机:
- 类的初始化是有条件的,只有在以下几种情况下才会触发:
- 当创建类的第一个实例时,例如,
new MyClass()
会触发MyClass
的初始化。 - 当访问类的静态变量或者静态方法时,例如,
MyClass.a
或者MyClass.staticMethod()
(假设MyClass
有静态方法staticMethod
)会触发MyClass
的初始化。 - 当使用
java.lang.reflect
包中的方法对类进行反射调用时,如Class.forName("MyClass").newInstance()
会触发MyClass
的初始化。 - 当子类初始化时,如果父类还没有初始化,会先触发父类的初始化。例如,
class ChildClass extends MyClass {}
,当创建ChildClass
的实例或者访问ChildClass
的静态变量 / 方法时,会先触发MyClass
的初始化。
- 当创建类的第一个实例时,例如,
- 类的初始化是有条件的,只有在以下几种情况下才会触发:
-
静态变量初始化:
- 在类初始化阶段,首先会对父类的静态变量进行初始化。静态变量的初始化是按照它们在类中定义的顺序进行的。例如,对于一个父类
Parent
有两个静态变量static int a = 10;
和static int b = 20;
,会先将a
初始化为 10,然后将b
初始化为 20。这些初始值是在编译期确定的赋值操作,在类加载的准备阶段,这些变量会被赋予默认的零值(如int
类型为 0),而真正的赋值操作是在初始化阶段。
- 在类初始化阶段,首先会对父类的静态变量进行初始化。静态变量的初始化是按照它们在类中定义的顺序进行的。例如,对于一个父类
-
静态代码块执行:
- 父类的静态代码块会在静态变量初始化之后执行。静态代码块用于在类加载时执行一些一次性的初始化操作。例如,以下父类中的静态代码块:
public class Parent { static { System.out.println("父类静态代码块"); } }
-
这个静态代码块会在所有父类静态变量初始化完成后执行,并且只会执行一次,用于在类初始化时完成一些初始化逻辑,如初始化一些静态资源或者加载配置文件等。
-
静态变量初始化:
- 类似父类,子类的静态变量也会按照定义的顺序进行初始化。如果子类继承了父类,在子类静态变量初始化之前,父类的静态部分(静态变量和静态代码块)必须已经完成初始化。例如,子类
Child
继承自Parent
,Child
有静态变量static int c = 30;
,在Child
类初始化时,会先完成Parent
的静态初始化,然后再初始化c
为 30。
- 类似父类,子类的静态变量也会按照定义的顺序进行初始化。如果子类继承了父类,在子类静态变量初始化之前,父类的静态部分(静态变量和静态代码块)必须已经完成初始化。例如,子类
-
静态代码块执行:
- 子类的静态代码块会在子类静态变量初始化之后执行。例如,对于子类
Child
中的静态代码块:
public class Child { static { System.out.println("子类静态代码块"); } }
- 子类的静态代码块会在子类静态变量初始化之后执行。例如,对于子类
-
它会在
Child
类的静态变量初始化完成后执行,用于完成子类特有的静态初始化逻辑,如注册子类相关的资源或者初始化子类相关的工具类等。 -
实例变量初始化:
- 当创建子类的实例时,在完成子类和父类的静态部分初始化后,会开始初始化父类的实例变量。实例变量的初始化也是按照它们在类中定义的顺序进行。例如,父类
Parent
有实例变量int d = 40;
,在创建Child
类的实例时,会先初始化d
为 40。
- 当创建子类的实例时,在完成子类和父类的静态部分初始化后,会开始初始化父类的实例变量。实例变量的初始化也是按照它们在类中定义的顺序进行。例如,父类
-
实例代码块执行:
- 父类的实例代码块会在父类实例变量初始化之后执行。实例代码块用于在每次创建对象时执行一些初始化操作。例如,以下父类中的实例代码块:
public class Parent { int d = 40; { System.out.println("父类实例代码块"); } }
-
当创建
Child
类的实例时,在d
初始化后会执行这个实例代码块,用于完成每次对象创建时需要执行的逻辑,如初始化对象的一些临时属性或者验证对象状态等。 -
父类的构造方法会在父类实例变量和实例代码块之后执行。构造方法用于完成对象的最终初始化。例如,父类
Parent
的构造方法:public Parent() { System.out.println("父类构造方法"); }
-
它会在父类实例代码块执行后被调用,用于完成父类对象的初始化,如初始化一些复杂的属性或者建立对象的内部状态等。
-
实例变量初始化:
- 类似父类,子类的实例变量会按照定义的顺序进行初始化。例如,子类
Child
有实例变量int e = 50;
,在父类构造方法执行后,会初始化e
为 50。
- 类似父类,子类的实例变量会按照定义的顺序进行初始化。例如,子类
-
实例代码块执行:
- 子类的实例代码块会在子类实例变量初始化之后执行。例如,对于子类
Child
中的实例代码块:
public class Child { int e = 50; { System.out.println("子类实例代码块"); } }
- 子类的实例代码块会在子类实例变量初始化之后执行。例如,对于子类
-
它会在
e
初始化后执行,用于完成子类对象特有的初始化逻辑,如根据父类对象的状态初始化子类对象的一些属性等。 -
最后执行子类的构造方法。子类构造方法用于完成子类对象的最终初始化,并且在构造方法的第一行默认会调用父类的构造方法(如果没有显式调用,编译器会自动添加一个对父类默认构造方法的调用)。例如,子类
Child
的构造方法:public Child() { System.out.println("子类构造方法"); }
-
它会在子类实例代码块执行后被调用,用于完成子类对象的最终初始化,如初始化子类对象特有的复杂属性或者建立子类对象的特殊状态等。
-
类加载检查
- 确认类是否已加载:
- 当使用
new
关键字创建一个对象时,JVM 首先会检查要创建对象的类是否已经被加载。如果类尚未加载,就会触发类加载过程。这个过程遵循双亲委派机制,从启动类加载器开始,依次尝试加载类。例如,若要创建一个自定义类com.example.MyClass
的对象,JVM 会先查看这个类是否已经在内存中,如果没有,就会通过类加载器寻找com/example/MyClass.class
文件并加载。
- 当使用
- 加载类的过程回顾:
- 类加载过程包括加载、验证、准备和初始化阶段。在加载阶段,获取字节码文件并转换为方法区中的运行时数据结构,同时在堆中生成一个
java.lang.Class
对象;验证阶段检查字节码文件的格式、元数据、字节码指令和符号引用等是否合法;准备阶段为类变量分配内存并设置初始值;初始化阶段执行类构造器<clinit>
方法,完成类的初始化。
- 类加载过程包括加载、验证、准备和初始化阶段。在加载阶段,获取字节码文件并转换为方法区中的运行时数据结构,同时在堆中生成一个
- 确认类是否已加载:
-
分配内存
- 选择分配方式:
- 如果类已经加载,接下来就是为对象分配内存空间。内存分配有两种方式,一种是指针碰撞(Bump the Pointer),另一种是空闲列表(Free List)。如果 Java 堆内存是规整的(即所有用过的内存放在一边,空闲的内存放在另一边,中间有一个分界指针),就可以使用指针碰撞方式。当为对象分配内存时,只需要将这个指针向空闲空间那边移动对象大小的距离即可。例如,在一个简单的内存模型中,假设已经使用的内存在左边,空闲内存在右边,指针初始位置在中间,当要为一个大小为
n
字节的对象分配内存时,就将指针向右移动n
字节。 - 如果 Java 堆内存不是规整的,就需要使用空闲列表方式。这种情况下,JVM 会维护一个记录空闲内存块的列表。当为对象分配内存时,从这个列表中找到一块足够大的空闲内存块来分配给对象。例如,在一个复杂的内存模型中,内存块被频繁地分配和回收,导致内存空间不规整,此时就需要通过空闲列表来查找合适的空闲内存块。
- 如果类已经加载,接下来就是为对象分配内存空间。内存分配有两种方式,一种是指针碰撞(Bump the Pointer),另一种是空闲列表(Free List)。如果 Java 堆内存是规整的(即所有用过的内存放在一边,空闲的内存放在另一边,中间有一个分界指针),就可以使用指针碰撞方式。当为对象分配内存时,只需要将这个指针向空闲空间那边移动对象大小的距离即可。例如,在一个简单的内存模型中,假设已经使用的内存在左边,空闲内存在右边,指针初始位置在中间,当要为一个大小为
- 处理并发安全问题:
- 在多线程环境下,内存分配可能会出现并发安全问题。例如,两个线程同时为对象分配内存,可能会导致内存分配错误。为了解决这个问题,JVM 有两种常见的方案。一种是采用 CAS(Compare - And - Swap)机制,通过原子操作来保证内存分配的安全性;另一种是通过 TLAB(Thread - Local Allocation Buffer)来实现。TLAB 是每个线程独有的一块内存区域,线程在自己的 TLAB 中分配内存时,不需要考虑并发问题,只有当 TLAB 的内存不够时,才会涉及到共享内存的分配,这样可以在一定程度上提高内存分配的效率。
- 选择分配方式:
-
对象初始化零值
- 基本数据类型零值初始化:
- 在分配完内存后,JVM 会将对象的实例变量(非静态变量)初始化为默认的零值。对于基本数据类型,
int
初始化为 0,long
初始化为 0L,float
初始化为 0.0f,double
初始化为 0.0d,boolean
初始化为false
,char
初始化为'\u0000'
。例如,创建一个包含int
和boolean
实例变量的类的对象,这些变量在这一步会分别被初始化为 0 和false
。
- 在分配完内存后,JVM 会将对象的实例变量(非静态变量)初始化为默认的零值。对于基本数据类型,
- 引用类型零值初始化:
- 对于引用类型的实例变量,初始值为
null
。例如,一个类中有一个String
类型的实例变量,在这一步它会被初始化为null
。这一步的零值初始化是为了保证对象在被真正初始化之前,其成员变量有一个确定的初始状态。
- 对于引用类型的实例变量,初始值为
- 基本数据类型零值初始化:
-
设置对象头
- 存储对象的运行时数据:
- 对象头(Object Header)用于存储对象的一些运行时数据。其中包括对象的哈希码(如果对象已经计算过哈希码)、对象的分代年龄(在分代垃圾回收机制中使用)、锁状态标志(用于表示对象是否被锁定以及锁定的状态,如偏向锁、轻量级锁、重量级锁等)等信息。例如,当一个对象被用于作为
HashMap
的键时,对象头可能会存储其哈希码,以便在HashMap
进行查找等操作时使用。
- 对象头(Object Header)用于存储对象的一些运行时数据。其中包括对象的哈希码(如果对象已经计算过哈希码)、对象的分代年龄(在分代垃圾回收机制中使用)、锁状态标志(用于表示对象是否被锁定以及锁定的状态,如偏向锁、轻量级锁、重量级锁等)等信息。例如,当一个对象被用于作为
- 对象头的大小和内容因 JVM 实现而异:
- 不同的 JVM 实现可能会有不同的对象头大小和内容。在 32 位的 JVM 中,对象头可能占用 8 个字节,而在 64 位的 JVM 中,对象头可能占用 12 个字节或者更多。对象头的具体内容也会根据 JVM 是否启用了某些特性(如偏向锁)而有所不同。
- 存储对象的运行时数据:
-
执行
<init>
构造方法- 完成对象的初始化:
- 最后一步是执行对象所属类的
<init>
构造方法。这个构造方法是由编译器根据类中的实例变量初始化语句和构造代码块组合而成的。例如,对于一个类有如下代码:
- 最后一步是执行对象所属类的
public class MyClass { int num; { num = 10; } public MyClass() { System.out.println("对象创建完成"); } }
- 完成对象的初始化:
- 在执行
<init>
构造方法时,首先会执行实例变量初始化语句(这里是num = 10;
),然后执行构造方法中的代码(这里是打印语句),通过这个过程完成对象的最终初始化,使得对象处于可用状态。 - 哈希码(HashCode)
- 作用:
- 哈希码主要用于在一些基于哈希的数据结构中,如
HashMap
、HashSet
等。当一个对象作为这些数据结构中的键(Key)时,需要通过哈希码来确定其在哈希表中的存储位置。哈希码是一个对象的数字标识,通过Object.hashCode()
方法获取。例如,在HashMap
中,根据对象的哈希码来快速定位该对象对应的键 - 值对在数组中的桶(Bucket)位置,这样可以提高数据存储和查找的效率。
- 哈希码主要用于在一些基于哈希的数据结构中,如
- 存储方式:
- 如果对象的哈希码已经被计算过,那么这个哈希码的值会存储在对象头中。在一些 JVM 实现中,会预留一定的位来存储哈希码。不过,不是所有的对象都会立即计算哈希码。只有当对象需要参与哈希相关操作时,才会计算并存储哈希码。例如,当一个对象第一次被放入
HashSet
中时,会计算其哈希码并存储在对象头中,方便后续的查找和比较操作。
- 如果对象的哈希码已经被计算过,那么这个哈希码的值会存储在对象头中。在一些 JVM 实现中,会预留一定的位来存储哈希码。不过,不是所有的对象都会立即计算哈希码。只有当对象需要参与哈希相关操作时,才会计算并存储哈希码。例如,当一个对象第一次被放入
- 作用:
- 分代年龄(Generational Age)
- 作用:
- 在 JVM 的分代垃圾回收机制中,对象头中的分代年龄用于标记对象在新生代中的存活次数。新生代采用复制算法进行垃圾回收,新创建的对象一般在 Eden 区,经过一次垃圾回收后,如果对象仍然存活,就会被移动到 Survivor 区,并且分代年龄会加 1。当分代年龄达到一定阈值(通常是 15,不同 JVM 可能有所不同)时,对象会被移动到老年代。例如,一个新创建的对象在经过几次新生代的垃圾回收后仍然存活,其分代年龄会不断增加,直到达到阈值后被移到老年代进行管理。
- 存储方式:
- 分代年龄通常使用对象头中的几位来存储。这些位的具体数量和位置因 JVM 实现而异。通过这些位可以记录对象在新生代中的存活次数,为垃圾回收器提供了对象生命周期的重要信息,以便采取合适的垃圾回收策略。
- 作用:
- 锁状态标志(Lock State Flag)
- 偏向锁(Biased Locking)相关信息:
- 偏向锁是一种优化的锁机制,用于在单线程访问共享资源时提高性能。当一个对象第一次被线程获取锁时,对象头中会记录这个线程的 ID,表示这个锁偏向于这个线程。在对象头中,会有相应的位来标识锁是否为偏向锁,以及偏向的线程 ID 等信息。例如,在一个单线程频繁访问某个对象的场景下,偏向锁可以减少锁获取和释放的开销,提高程序的执行效率。
- 轻量级锁(Lightweight Locking)相关信息:
- 当出现多个线程竞争偏向锁时,偏向锁会升级为轻量级锁。轻量级锁主要通过在对象头中存储线程栈帧中的锁记录(Lock Record)指针来实现。这个指针用于在多线程竞争锁时,快速判断锁的状态和归属。例如,在多线程交替访问共享资源的情况下,轻量级锁可以通过自旋(Spinning)的方式等待锁的释放,而不是立即升级为重量级锁,从而减少线程上下文切换的开销。
- 重量级锁(Heavyweight Locking)相关信息:
- 如果轻量级锁自旋一定次数后,仍然无法获取锁,就会升级为重量级锁。在对象头中,会有相应的标志位来表示对象处于重量级锁状态。重量级锁涉及到操作系统的互斥量(Mutex),会导致线程的阻塞和唤醒,开销较大。当对象处于重量级锁状态时,对象头中可能会记录一些与阻塞线程相关的信息,如等待队列等。
- 偏向锁(Biased Locking)相关信息:
- 堆内存参数
- -Xms(初始堆内存大小)
- 含义:指定 JVM 启动时分配的初始堆内存大小。例如,设置
-Xms2g
表示 JVM 启动时堆内存初始大小为 2GB。这个参数可以让 JVM 在启动时就分配足够的内存,避免在运行过程中频繁地扩展堆内存,因为堆内存的动态扩展可能会导致性能下降。 - 应用场景和建议:对于那些启动后就需要处理大量数据或者加载大量资源的应用程序,合理设置
-Xms
参数非常重要。如果设置的值过小,可能会导致频繁的垃圾回收和内存扩展,影响应用程序的性能。一般根据应用程序的实际需求和服务器的物理内存情况来确定,通常可以将其设置为与-Xmx
(最大堆内存)相同的值,以避免动态扩展。
- 含义:指定 JVM 启动时分配的初始堆内存大小。例如,设置
- -Xmx(最大堆内存大小)
- 含义:规定 JVM 堆内存可以扩展到的最大值。例如,
-Xmx4g
表示 JVM 堆内存最大可以达到 4GB。当应用程序在运行过程中需要更多的内存来创建对象等操作时,堆内存会在-Xms
和-Xmx
之间动态扩展,直到达到-Xmx
所设定的上限。 - 应用场景和建议:这个参数的设置需要综合考虑应用程序的内存需求和服务器的物理内存。如果设置得过大,可能会导致系统内存不足,影响其他应用程序的运行;如果设置得过小,可能会出现
OutOfMemoryError
(内存溢出)的情况。在生产环境中,需要通过性能测试等手段来确定合适的-Xmx
值,同时要预留一定的系统内存给操作系统和其他进程。
- 含义:规定 JVM 堆内存可以扩展到的最大值。例如,
- -Xmn(新生代大小)
- 含义:用于设置新生代(Young Generation)的大小。例如,
-Xmn512m
表示新生代的大小为 512MB。在分代垃圾回收算法中,新生代是对象创建和回收比较频繁的区域,通过设置-Xmn
可以控制新生代的大小,从而影响垃圾回收的频率和效率。 - 应用场景和建议:对于那些短生命周期对象较多的应用程序,合理设置
-Xmn
参数可以提高垃圾回收的效率。如果新生代设置得太小,可能会导致频繁的新生代垃圾回收,增加系统的开销;如果设置得太大,可能会减少老年代的空间,影响老年代对象的存储和垃圾回收。一般可以根据应用程序的对象生命周期特点和性能测试结果来设置,通常新生代大小可以占堆内存的 1/3 到 1/4 左右。
- 含义:用于设置新生代(Young Generation)的大小。例如,
- -Xms(初始堆内存大小)
- 非堆内存参数
- -XX:MetaspaceSize(元空间初始大小)
- 含义:在 JDK 1.8 及以后,用于指定元空间(Metaspace)的初始大小。元空间主要用于存储类的元数据信息。例如,设置
-XX:MetaspaceSize=256m
表示元空间的初始大小为 256MB。当加载的类信息等元数据超过这个初始大小时,元空间会自动扩展。 - 应用场景和建议:如果应用程序会加载大量的类或者动态生成类,需要合理设置元空间的大小。设置过小可能会导致
OutOfMemoryError
(元空间内存溢出),而设置过大可能会浪费内存。在实际应用中,可以根据应用程序的类加载情况和服务器内存进行调整,并且可以通过监控工具来观察元空间的使用情况。
- 含义:在 JDK 1.8 及以后,用于指定元空间(Metaspace)的初始大小。元空间主要用于存储类的元数据信息。例如,设置
- -XX:MaxMetaspaceSize(元空间最大大小)
- 含义:设定元空间可以扩展到的最大大小。例如,
-XX:MaxMetaspaceSize=1g
表示元空间最大为 1GB。当元空间的大小达到这个最大值后,如果还需要加载更多的类元数据,就会抛出OutOfMemoryError
。 - 应用场景和建议:与
-XX:MetaspaceSize
类似,这个参数也是为了控制元空间的内存使用。在设置时要考虑应用程序的类加载规模和服务器的内存资源,避免因元空间内存耗尽而导致应用程序崩溃。一般情况下,可以根据性能测试和实际运行情况,适当设置一个合理的最大值。
- 含义:设定元空间可以扩展到的最大大小。例如,
- -XX:PermGenSize(永久代大小 - JDK 1.7 及以前)
- 含义:在 JDK 1.7 及以前,用于设置永久代(Permanent Generation)的大小。永久代主要用于存储类的元数据、常量池等信息。例如,设置
-XX:PermGenSize=128m
表示永久代大小为 128MB。 - 应用场景和建议:由于永久代容易出现内存溢出问题,并且在 JDK 1.8 已经被元空间取代,在 JDK 1.7 及以前的应用中,需要谨慎设置这个参数。对于那些大量使用动态代理、加载大量类或者频繁进行字符串操作(因为字符串常量池在永久代)的应用程序,需要适当增大永久代的大小,但也要注意避免设置过大导致内存浪费。
- 含义:在 JDK 1.7 及以前,用于设置永久代(Permanent Generation)的大小。永久代主要用于存储类的元数据、常量池等信息。例如,设置
- -XX:MetaspaceSize(元空间初始大小)
- 栈内存参数
- -Xss(线程栈大小)
- 含义:用于设置每个线程的栈内存大小。例如,设置
-Xss256k
表示每个线程的栈大小为 256KB。线程栈用于存储线程执行方法时的栈帧,包括局部变量表、操作数栈等信息。 - 应用场景和建议:如果线程栈设置得过小,可能会导致
StackOverflowError
(栈溢出),特别是在方法调用层次较深或者局部变量较多的情况下。如果设置得过大,会浪费内存,因为每个线程都会占用一定的栈内存空间。对于递归调用较多的应用程序,可能需要适当增大-Xss
的值;而对于线程数量较多的应用程序,则需要考虑减小-Xss
的值以避免过度占用内存。
- 含义:用于设置每个线程的栈内存大小。例如,设置
- -Xss(线程栈大小)
- 回收机制概述
- 自动内存管理:
- GC(Garbage Collection)即垃圾回收,是 Java 虚拟机(JVM)提供的一种自动内存管理机制。它的主要目的是回收那些不再被程序使用的对象所占用的内存空间,使得程序员不需要手动释放内存,从而减少内存泄漏和悬空指针等问题,提高程序的可靠性和开发效率。
- 触发时机:
- GC 的触发时机有多种。一种是当堆内存中的对象占用空间达到一定阈值时,例如,新生代(Young Generation)中的 Eden 区被填满时,就会触发新生代的垃圾回收。另一种情况是当应用程序主动请求垃圾回收时,比如通过
System.gc()
方法(不过这个方法只是建议 JVM 进行垃圾回收,JVM 不一定会立即执行)。此外,在老年代(Old Generation)空间不足或者元空间(Metaspace)空间不足等情况下,也会触发相应区域的垃圾回收。
- GC 的触发时机有多种。一种是当堆内存中的对象占用空间达到一定阈值时,例如,新生代(Young Generation)中的 Eden 区被填满时,就会触发新生代的垃圾回收。另一种情况是当应用程序主动请求垃圾回收时,比如通过
- 自动内存管理:
- 标记 - 清除(Mark - Sweep)原理
- 标记阶段:
- 从根对象(GC Roots)开始,通过深度优先搜索(DFS)或者广度优先搜索(BFS)等方式遍历对象图。GC Roots 包括虚拟机栈(栈帧中的本地变量表)中的引用对象、方法区中类静态属性引用的对象、方法区中常量引用的对象以及本地方法栈中 JNI(Java Native Interface)引用的对象。在这个过程中,会标记所有从根对象可达的对象,这些对象被认为是存活的。例如,在一个简单的 Java 程序中,一个方法中的局部变量引用的对象就是从 GC Roots 可达的,会被标记为存活。
- 清除阶段:
- 遍历整个堆内存,回收那些没有被标记的对象所占用的内存空间。这些对象被视为垃圾,因为它们无法从 GC Roots 通过引用链到达。清除后的内存空间是不连续的,会产生内存碎片。例如,如果有一系列大小不同的对象被回收,留下的空闲空间可能是分散的小块,这可能会导致后续分配大对象时出现问题,因为可能没有足够大的连续内存空间。
- 标记阶段:
- 复制(Copying)原理
- 内存划分:
- 复制算法将堆内存划分为两个大小相等的区域,通常称为
From
区和To
区。在对象分配时,只使用From
区。例如,在新生代的垃圾回收中,Eden 区和其中一个 Survivor 区(假设为From
Survivor)可以看作是用于对象分配的区域。
- 复制算法将堆内存划分为两个大小相等的区域,通常称为
- 复制存活对象:
- 当进行垃圾回收时,会将
From
区中存活的对象复制到To
区。在复制过程中,是顺序复制,效率较高。然后清空From
区,此时To
区就成为了新的From
区,原来的From
区就成为了新的To
区,用于下一次垃圾回收。由于只复制存活对象,并且To
区在回收后是连续的空闲空间,所以不会产生内存碎片,新对象的分配可以在连续的空间中进行。不过,这种算法的缺点是内存利用率较低,因为只有一半的内存空间可用于对象分配。
- 当进行垃圾回收时,会将
- 内存划分:
- 标记 - 整理(Mark - Compact)原理
- 标记阶段:
- 与标记 - 清除算法类似,首先从 GC Roots 开始标记所有存活的对象。这个过程通过对象之间的引用关系进行遍历,确定哪些对象是存活的,哪些是垃圾。例如,在一个复杂的对象关系图中,通过深度优先搜索从根对象出发,标记出所有可以到达的对象。
- 整理阶段:
- 将所有存活的对象向一端移动,使得存活对象在内存空间的一端排列。这个过程可以通过移动对象并更新对象的引用关系来实现。例如,将所有存活对象向内存空间的低地址端移动,然后将存活对象边界以外的内存空间全部回收,这样就得到了一个连续的空闲空间,方便后续新对象的分配,解决了标记 - 清除算法的内存碎片问题。不过,移动对象会有一定的性能开销,因为需要更新所有引用这些对象的地方。
- 标记阶段:
- 分代收集(Generational Collection)原理
- 分代假设:
- 分代收集算法基于一个观察结果:在 Java 程序中,大部分对象的生命周期都很短,只有少部分对象会存活较长时间。所以将堆内存划分为不同的代,通常是新生代和老年代。新生代又可以细分为 Eden 区和两个 Survivor 区(
From
Survivor 和To
Survivor)。
- 分代收集算法基于一个观察结果:在 Java 程序中,大部分对象的生命周期都很短,只有少部分对象会存活较长时间。所以将堆内存划分为不同的代,通常是新生代和老年代。新生代又可以细分为 Eden 区和两个 Survivor 区(
- 不同代的回收策略:
- 新生代回收:在新生代,由于对象大部分是朝生暮死的,通常采用复制算法。新创建的对象一般分配在 Eden 区,经过一次垃圾回收后,存活的对象会被移动到 Survivor 区,经过多次回收后仍然存活的对象会被移动到老年代。
- 老年代回收:老年代中的对象存活时间较长,且对象数量相对较少。通常采用标记 - 清除或标记 - 整理算法。当老年代空间不足或者达到一定的回收阈值时,会触发老年代的垃圾回收。这种根据对象生命周期特点采用不同回收策略的方式,可以提高垃圾回收的效率。
- 分代假设:
八、类加载过程
九、JVM类初始化顺序
1,父类静态变量和静态代码块
2,子类静态变量和静态代码块
3,父类实例变量和实例代码块
4,父类构造方法
5,子类实例变量和实例代码块
6,子类构造方法