JVM 常见面试题
JVM基础
什么是字节码?采用字节码的好处是什么?
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class
的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以, Java 程序运行时相对来说还是高效的(不过,和 C、 C++,Rust,Go 等语言还是有一定差距的),而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行。
Java 程序从源代码到运行的过程如下图所示:
我们需要格外注意的是 .class->机器码
这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT(Just in Time Compilation) 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言 。
对象头了解吗?
Java 内存中的对象由以下三部分组成:对象头、实例数据和对齐填充字节。
在Java中对象的结构是在Java对象模型(Java Object Model)的基础上设计和实现的。一个Java对象在JVM中的基本结构包括以下几个组成部分:
对象头(Object Header):
- Mark Word:
- 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。
- Mark Word是动态定义的,随着对象状态的变化而改变。
- Class Pointer(类型指针):
- 指向对象的类元数据(Class Metadata),JVM通过它来确定该对象是哪个类的实例,并据此找到该类的方法区。
- 数组长度(仅数组对象):
- 如果对象是数组,还需要一个额外的空间来存储数组的长度。
实例数据(Instance Data):
- 存储实体类的实例字段内容,包括从父类继承下来的字段和自身定义的字段。
- 数据存储的顺序受字段声明顺序、字段类型、虚拟机分配策略(如内存对齐)影响。
对齐填充(Padding):
- JVM要求对象的起始地址是8字节的整数倍(对齐),因此可能在对象结尾填充对齐字节。
- 对齐填充对于64位虚拟机(如HotSpot JVM)是在开启压缩指针(Compressed Oops)时对未对齐的对象添加的。
内存对齐的主要作用是:
- 平台原因:不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
- 性能原因:经过内存对齐后,CPU的内存访问速度大大提升。
对象的大小如何计算
在Java中,对象的大小通常是由对象的对象头、实例数据、和内部填充(8字节的倍数)组成的。对象的大小计算方法可以简化为以下几个步骤:
- 计算对象头大小:对象头包含了一些关于对象的元信息,如对象的哈希码、锁状态、垃圾回收信息等。对象头的大小在不同的JVM实现和配置下会有所不同。通常,对象头的大小在64位JVM是12~16个字节,在32位JVM是4个字节。
- jdk1.8, 64位机器:对象头: markworld (8字节),类型指针(通常会压缩 4字节) , 如果对象是数组还有4字节
- 计算实例变量大小:对象的实例变量是对象的数据部分,它们占用的内存空间由它们的数据类型和数量决定。例如,一个整数类型的实例变量在64位JVM是8个字节,在32位JVM是4个字节。
- 计算内部填充大小:为了对齐数据,Java虚拟机通常在实例变量之间插入一些内部填充。填充的大小取决于虚拟机和操作系统的要求,通常是8字节的倍数。这样可以提高内存的访问效率。
综上,对象总大小等于对象头大小加上实例变量大小和内部填充的大小。
Java对象的定位方式?
Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:
- 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 直接指针。reference 中存储的直接就是对象的地址。对象包含到对象类型数据的指针,通过这个指针可以访问对象类型数据。使用直接指针访问方式最大的好处就是访问对象速度快,它节省了一次指针定位的时间开销,虚拟机hotspot主要是使用直接指针来访问对象。
内存泄漏和内存溢出的区别
内存泄漏和内存溢出是两个常见的内存问题,它们的性质和原因不同,但都可能对应用程序的性能和稳定性产生严重影响。
内存泄漏(Memory Leak) 指的是程序未能释放已不再使用的对象或者资源,从而导致内存的浪费。在Java中,内存泄漏通常是由于对象的引用没有被正确清除,使得垃圾回收无法回收这些对象所占用的内存。 一般不会立即导致程序崩溃,但会缓慢地消耗内存,最终可能导致内存不足。
内存溢出(Out of Memory) 内存溢出是指程序在申请内存时,没有足够的内存可用,导致无法正常分配,从而抛出OutOfMemoryError。这是由于程序已使用的内存超过了JVM为其分配的最大内存限制。 通常会导致程序崩溃或终止,因为未能限制内存使用而直接超过JVM配置的内存上限。
讲一下JVM内存结构?
详细内容可以查看:JVM内存结构
JVM内存结构分为5大区域,程序计数器、虚拟机栈、本地方法栈、堆、方法区。
程序计数器
特点:
- 线程私有
- CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
- 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
- 不存在内存溢出
程序计数器的作用?
线程私有的,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。
当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,当线程被切换回来的时候能够知道它上次执行的位置。
程序计数器会出现OOM吗?
程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡
虚拟机栈
定义:每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
特点:
每个线程运行需要的内存空间,称为虚拟机栈。是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡
Java 虚拟机栈是由一个个栈帧组成,对应着每次调用方法时所占用的内存。每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出。两种返回函数的方式,不管用哪种方式,都会导致栈帧被弹出
正常的函数返回,使用 return 指令
抛出异常
每个线程只能有一个活动栈帧,栈顶存放当前当前正在执行的方法
虚拟机栈里有什么?
每个栈帧中都存储着:
局部变量表(Local Variables)
操作数栈(Operand Stack)(或称为表达式栈)
动态链接(Dynamic Linking):指向运行时常量池的方法引用
方法返回地址(Return Address):方法正常退出或异常退出的地址
虚拟机栈会发生stackOverflowError吗?
stackOverflowError发生原因
虚拟机栈中,栈帧过多(无限递归)
每个栈帧所占用内存过大
虚拟机栈会发生OutOfMemoryError吗?
OutOfMemoryError发生原因:
在单线程程序中,无法出现OOM异常;但是通过循环创建线程(线程体调用方法),可以产生OOM异常。此时OOM异常产生的原因与栈空间是否足够大无关。
线程动态扩展,没有足够的内存供申请时会产生OOM
垃圾回收是否涉及栈内存?
不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
栈内存的分配越大越好吗?
不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
方法内的局部变量是否是线程安全的?
如果方法内局部变量没有逃离方法的作用范围,则是线程安全的;如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题
本地方法栈
也是线程私有的
虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Native 方法一般是用其它语言(C、C++等)编写的。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
为什么需要本地方法?
一些带有native关键字的方法就是需要JAVA去调用C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法
Native Method Stack:它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies
Native Interface本地接口:本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C程序, Java在诞生的时候是C/C横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了块区域处理标记为native的代码,它的具体做法是在Native Method Stack 中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等
在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一
堆
通过new关键字创建的对象都会被放在堆内存
所有线程共享,堆内存中的对象都需要考虑线程安全问题
有垃圾回收机制
堆中的区域:新生代( Eden 空间、 From Survivor 、 To Survivor 空间)和老年代。
说一下堆栈的区别?
- 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
- 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
- 堆是线程共享的;栈是线程私有的。
如何设置堆内存大小
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定
-Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
-Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize
如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。
为什么通常会将 -Xmx 和 -Xms 两个参数配置为相同的值?
目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。
如果 -Xms
和 -Xmx
设置为不同的值,JVM 在运行时可能会根据内存使用情况不断调整堆的大小。这种动态调整需要进行内存分配和垃圾收集,可能会增加系统的开销和延迟。而将这两个参数设置为相同的值,JVM 在启动时就分配好固定量的堆内存,从而避免了内存重新分配的开销。
了解TLAB吗?
TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间(包含在 Eden 空间内),只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
为什么要有TLAB?
堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
当然了,不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。
默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。
一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存
对象一定分配在堆中吗?
在Java中,传统上我们认为对象是在堆上分配内存的。但是,随着JVM优化技术的发展,尤其是在引入即时编译器(JIT)和逃逸分析(Escape Analysis)技术后,并非所有对象都一定在堆上分配内存。这有以下一些细节:
- 逃逸分析(Escape Analysis): 逃逸分析是一种优化技术,用于确定对象的作用范围。如果JVM通过逃逸分析确定一个对象不会被方法之外的代码访问(即对象不会逃逸出方法),那么JVM可能会选择在栈上分配该对象。
- 栈上分配(Stack Allocation): 如果对象可以被确定为不会逃逸出其方法,则JVM可以在栈上为该对象分配内存。这减少了垃圾回收的压力,因为栈上的内存在方法执行结束后自动释放。
- 标量替换(Scalar Replacement): 如果对象的所有属性都可以独立处理,JVM可能会对对象进行标量替换,将对象分解为其基本类型的成员变量进行优化。这种情况下,原始的对象概念被消除,更谈不上在堆或栈上分配。
- 寄存器分配(Registers Allocation): 在某些情况下,JIT编译器甚至可能将某些对象的内容存放在CPU寄存器中,以提高访问速度。
栈上分配的条件?
作用域不会逃逸出方法的对象
小对象(一般几十个byte);大对象无法在栈上分配
标量替换:若逃逸分析证明一个对象不会逃逸出方法,不会被外部访问,并且这个对象是可以被分解的,那程序在真正执行的时候可能不创建这个对象,而是直接创建这个对象分解后的标量来代替。这样就无需在对对象分配空间了,只在栈上为分解出的变量分配内存即可。
什么是逃逸分析?
逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
一个对象在方法中被定义后,对象如果只在方法内部使用,则认为没有发生逃逸;(没有发生逃逸的对象,会在栈上分配)
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生了逃逸。
如何快速的判断是否发生了逃逸分析?
看new的对象实体是否有可能在方法外被调用。注意是看new 出来的实体,而不是那个引用变量。
通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。
逃逸分析的好处
栈上分配,可以降低垃圾收集器运行的频率。
同步消除,如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作不需要同步。
标量替换,把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有
减少内存使用,因为不用生成对象头。
程序内存回收效率高,并且GC频率也会减少。
逃逸分析一定好吗?
关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
方法区
结构
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载。
方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代
方法区是 JVM 的规范,而永久代 PermGen 是方法区的一种实现方式,并且只有 HotSpot 有永久代。对于其他类型的虚拟机,如 JRockit 没有永久代。由于方法区主要存储类的相关信息,所以对于动态生成类的场景比较容易出现永久代的内存溢出。
永久区是常驻内存的,是用来存放JDK自身携带的Class对象和interface元数据。这样这些数据就不会占用空间。用于存储java运行时环境。
- 在JDK1.7前,字符串存放在方法区之中
- 在JDK1.7后字符串被放在了堆
- 在Java8,取消了方法区,改用了直接使用直接内存的的元空间。即元空间逻辑上属于堆,但在物理内存上,元空间的内存并不由堆空间内存分配
- 元空间
JDK 1.8 的时候, HotSpot 的永久代被彻底移除了,使用元空间替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。两者最大的区别在于:元空间并不在虚拟机中,而是使用直接内存。
为什么要将永久代替换为元空间呢?
永久代内存受限于 JVM 可用内存,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是相比永久代内存溢出的概率更小。
可以减少 Full GC 问题:元空间独立于堆内存,大大减少了永久代相关的 Full GC 次数,因此在运行时减少了长时间的中断。
运行时常量池
运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引号放到运行时常量池。在运行期间动态生成的常量,如 String 类的 intern()方法,也会被放入运行时常量池。
Class常量池和运行时常量池的区别
Class 常量池(Class Constant Pool) Class 常量池是Java类文件的一部分,它由编译器在编译Java源文件时生成,存储在.class文件中。它包含了类或接口的字面量(如字符串、整数常量等)以及符号引用(如类和接口的名字、字段和方法的名字及描述符)。
运行时常量池(Runtime Constant Pool) 是Class常量池在类加载到JVM后的一种表现形式。它是类加载过程的一部分,在类或接口被载入JVM时,Class常量池的信息被载入运行时常量池。 它在类加载时被创建,是方法区的一部分(在Java 8后部分实现为元空间的一部分)。
运行时常量池是动态的,可以在运行时扩展,因为它不仅包含Class常量池的映射数据,还允许在运行时添加新的常量,例如通过字符串interning。
Class常量池和运行时常量池的关系
来源与转换
- Class常量池是从Java编译器生成的静态数据结构,是.class文件的一部分。
- 运行时常量池是JVM执行环境的一部分,是Class常量池在类加载时被解析、验证后存储的方法区中的数据结构。
作用域与用途:
- Class常量池是在磁盘上文件级别的数据结构,定义了类的编译时依赖和信息。
- 运行时常量池存在于内存中,在类加载期间被JVM转化和使用,维护符号引用的解析,动态链接和跨越生命周期的优化。
使用与管理:
- 编译器生成Class常量池,它是只读的。
- 运行时常量池在运行期间可以被动态更新,允许JVM对类执行管理和优化。
总结来说,Class常量池和运行时常量池在Java的编译和执行阶段分别扮演着不同的角色。Class常量池是类文件的组成部分,而运行时常量池是JVM的执行环境结构,它将Class常量池的数据转换成JVM可以理解和使用的形式,并对其进行动态管理。
运行时常量池和字符串常量池的区别
- 运行时常量池:用于存储类常量、方法和字段的引用,以及字符串字面量等。
- 字符串常量池:专注于优化字符串使用,通过在堆中存储唯一的字符串实例来减少内存消耗。 字符串常量池可以被视为是运行时常量池的一个特殊部分,专门用于字符串字面量的存储和重用。
- 运行时常量池位于方法区/元空间。 字符串常量池位于堆内存中。
直接内存(堆外内存)
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,也称之为堆外内存。
但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
直接内存的读写操作比堆内存快,可以提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到直接内存。
堆外内存的特点
- 性能优势:
- 堆外内存可以减少垃圾回收(Garbage Collection)带来的停顿时间,因为它不参与普通的JVM垃圾回收过程。
- 提供更好的内存管理和减少了GC造成的性能波动。
- 使用局限:
- 手动管理导致更大复杂性:开发者需要显式释放堆外内存,避免内存泄露。
- 不支持GC,所以必须非常小心管理生命周期。需要手动清理垃圾,增加代码开发的复杂性;不参与jvm垃圾回收,因此可以减少gc带来的停顿时间;
- 应用场景:
- 大数据和分布式系统中需要处理大量数据时。
- 需要高性能、低延迟的应用程序,如游戏服务器或金融系统。
- 缓存系统,如Memcached、Redis等。
如何使用堆外内存
NIO的Buffer提供了DirectBuffer ,可以直接访问系统物理内存,避免堆内内存到堆外内存的数据拷贝操作,提高效率。DirectBuffer 直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制,不受最大堆内存的限制。
类加载机制
详细内容可以查看:类加载机制
Java 创建对象的生命周期,JVM 的变化,说说你的理解
Java对象在JVM中的生命周期可以分为以下几个阶段:创建阶段、应用阶段、不可见阶段、不可达阶段、收集阶段和终结阶段。
- 创建阶段:当虚拟机遇到一条new指令时,首先检查指令的参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。然后,虚拟机为新生对象分配内存,并将分配到的空间初始化为默认值。接下来,虚拟机对对象进行必要的设置,如对象的类信息、哈希码、GC分代年龄等,并执行
<init>
方法进行实例初始化。 - 应用阶段:对象创建后,只要这个对象被引用变量引用,它就会一直驻留在内存中,执行其功能。
- 不可见阶段:当对象对其引用变量不再可见时,对象进入不可见阶段。
- 不可达阶段:如果一个对象没有任何引用指向它,那么这个对象就进入了不可达阶段。此时,对象虽然还存在,但是已经无法通过正常的途径访问。
- 收集阶段:垃圾回收器会定期检查内存中的对象,如果发现不可达的对象,就会进行回收,释放其占用的内存。
- 终结阶段:在收集阶段之后,如果对象还被引用但不再需要,就会进入终结阶段,此时对象会被彻底删除。
在创建对象的过程中,JVM会经历以下变化:
- 内存分配:为对象分配内存空间。
- 初始化:将分配的内存初始化为默认值。
- 设置对象信息:包括对象的类信息、哈希码、GC分代年龄等。
- 执行构造方法:执行对象的构造方法进行初始化
什么是类加载?类加载的过程?
虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。类加载过程包括了加载、验证、准备、解析、初始化五个阶段
加载
查找并加载类的二进制数据
在加载阶段,虚拟机需要完成以下三件事情:
通过一个类的全限定名来获取其定义的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
这个阶段既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
验证
确保Class文件的字节流中包含的信息符合JVM规范,保证在运行后不会危害虚拟机自身的安全。即安全性检查,主要包括四种验证:
文件格式验证: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
元数据验证:: 对字节码描述的信息进行语义分析(注意: 对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object之外。
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
符号引用验证:确保解析动作能正确执行
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
准备阶段是正式为static 变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成。也就是说这里给类变量设置初始值,设置的是数据类型默认的零值(如0、0L、null、false等)
如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
解析
将常量池内的符号引用替换为直接引用的过程。符号引用用于描述目标,直接引用直接指向目标的地址。
未解析时,常量池中的看到的对象仅是符号,未真正的存在于内存中
解析以后,会将常量池中的符号引用解析为直接引用
初始化
初始化阶段会执行cinit方法来为 类变量static变量 赋上定义的值并执行类中的静态代码块
在Java中对类变量进行初始值设定有两种方式:
声明类变量是指定初始值
使用静态代码块为类变量指定初始值
何时进行类加载?
定义了main的类,启动main方法时该类会被加载
创建类的实例,即new对象的时候
访问类的静态方法
访问类的静态变量
反射 Class.forName()
JVM初始化步骤?
假如这个类还没有被加载和连接,则程序先加载并连接该类
假如该类的直接父类还没有被初始化,则先初始化其直接父类
假如类中有初始化语句,则系统依次执行这些初始化语句
初始化发生的时机?
概括得说,类初始化是【懒惰的】,只有当对类的主动使用的时候才会导致类的初始化
main 方法所在的类,总会被首先初始化
首次访问这个类的静态变量或静态方法时
子类初始化,如果父类还没初始化,会引发父类初始化
子类访问父类的静态变量,只会触发父类的初始化
Class.forName new 会导致初始化
不会导致类初始化的情况?
访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
类对象.class 不会触发初始化
创建该类的数组不会触发初始化
类加载器的 loadClass 方法
Class.forName 的参数 2 为 false 时
cinit方法如果执行失败了怎么办,这个类还能用吗?
在Java类加载的过程中,cinit 方法实际上指的是类的静态初始化方法,也就是类的静态代码块或者静态变量的初始化代码。如果类的静态初始化方法执行失败,通常会导致类的初始化失败,这意味着这个类不能被正常使用。会抛出异常,如 ExceptionInInitializerError
在Java中,类的静态初始化方法只会执行一次,无论类被加载多少次,静态初始化方法只会在首次加载类的时候执行。因此,cinit 方法不会多次执行。一旦类的静态初始化方法执行过,后续对同一个类的加载都不会再次触发静态初始化方法的执行。这种机制确保了类的静态初始化只会在需要的时候执行一次,避免了不必要的开销和重复操作。
分配内存
在类加载后,接下来虚拟机将为新⽣对象分配内存。
分配在哪?
主要就是根据JVM的分配机制:对象优先分配Eden
- 先TLAB分配
- 再通过CAS在Eden区分配
- 大对象直接分配到老年代
TLAB:线程本地分配缓冲区,为每⼀个线程预先在 Eden 区分配⼀块⼉私有的缓存区域,JVM 在给线程中的对象分配内存时,⾸先在 TLAB 分配,当对象⼤于 TLAB 中的剩余内存或 TLAB 的内存已⽤尽时(或者未开启TLAB),再采⽤上述的 CAS 进⾏内存分配。默认情况TLAB仅占每个Eden区域的1%。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。
为什么要CAS分配内存?
多个并发执行的线程需要创建对象、申请分配内存的时候,有可能在 Java 堆的同一个位置申请,这时就需要对拟分配的内存区域进行加锁或者采用 CAS 等操作,保证这个区域只能分配给一个线程。
JVM对象分配内存如何保证线程安全
在JVM中,为对象分配内存的过程需要确保线程安全,因为在多线程环境下,多个线程可能会同时尝试创建对象。为了保证内存分配的线程安全性,JVM采用了以下几种机制和技术:
TLAB(Thread Local Allocation Buffer):
- 当一个线程需要分配对象时,首先会尝试在TLAB中进行分配。如果TLAB有足够的空间,分配过程就是线程安全的,因为没有其他线程访问这个内存块。
- 不足:当TLAB空间不足时,线程需要请求一个新的TLAB或者直接从共享堆中分配,这个过程需要一定的同步机制。
CAS(Compare-And-Swap)机制: 当TLAB耗尽或在涉及到跨线程的堆内存分配时,CAS有效避免了竞争条件。
分代收集: 虽然不是直接用于线程安全,但分代收集(年轻代、老年代、永久代/元空间)使得内存管理更高效,减少了直接竞争的机会。
结合:TLAB一般对年轻代的内存分配进行优化,更加局部化的内存管理有助于线程安全。
通过运用这些机制,JVM能够在多线程环境下高效而安全地进行内存分配,并最大限度地减少同步操作带来的性能损耗。这样设计不仅提升了性能,也保证了对象内存分配的安全性和一致性。
说说对象分配规则
在Java中,对象分配规则是关于如何为新对象分配内存的一套规则,以确保内存的有效使用和对象的正确初始化。以下是关于对象分配的主要规则:
- 内存分配:新对象通常在堆内存中分配内存空间。
- 对象头:在为对象分配内存空间后,Java虚拟机会为对象分配一个对象头。对象头包含了一些关于对象的元信息,如对象的哈希码、锁状态、垃圾回收信息等。
- 零值初始化:在对象内存分配后,所有的成员变量会被初始化为零值。具体的零值取决于变量的数据类型。例如,整数类型会初始化为0,布尔类型会初始化为false,对象引用会初始化为null。
- 构造函数调用:一旦对象内存分配和零值初始化完成,Java虚拟机会调用对象的构造函数。
- 对象引用:最后,new 关键字会返回对象的引用,将这个引用分配给一个变量,以便后续可以通过该变量访问对象的属性和方法。
- 垃圾回收管理:Java虚拟机会自动管理对象的内存。如果对象不再被引用,它会被标记为垃圾,并在适当的时机由垃圾回收器回收,释放占用的内存。
这些规则确保了对象在创建时的正确初始化和内存管理。对于程序员来说,最重要的是编写好构造函数以确保对象在创建后具有合适的初始状态,并且不忘记在不再需要对象时将引用置为null,以便垃圾回收器能够回收不再使用的对象。
何时进行类卸载?
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。
Java虚拟机将结束生命周期的几种情况?(什么情况会导致JVM退出)
- 正常程序终止: 当程序执行完main方法,包括所有非守护线程都终止时,JVM将正常退出。
- 调用System.exit(int status): 显式调用System.exit()方法,以指定的状态码终止当前运行的Java虚拟机。
- 未捕获的异常或错误: 如果某个线程抛出的异常没有被捕获,并且此异常传播到了主线程,JVM可能会终止。
- Runtime.halt(int)或崩溃:
- 直接调用Runtime.halt()会立即停止Java进程,类似于突然终止程序而不调用任何钩子。
- JVM的致命错误(如内存访问违规)也可能导致崩溃并退出。
- 外部命令强制关闭: 例如通过操作系统的任务管理器或者控制台命令,如kill命令。或者操作系统出现错误而导致Java虚拟机进程终止
什么是类加载器,类加载器有哪些?
实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。
主要有一下四种类加载器:
- 启动类加载器:用来加载 Java 核心类库,无法被 Java 程序直接引用。
- 扩展类加载器:它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
- 系统类加载器:它根据应用的类路径来加载 Java 类。可通过ClassLoader.getSystemClassLoader() 获取它。
- 自定义类加载器:通过继承java.lang.ClassLoader 类的方式实现。
JVM类加载机制?
Java 的类加载器机制与双亲委派模型是 Java 虚拟机(JVM)加载类文件时采用的一种体系结构。它用于确保 Java 应用程序中类的单一性、安全性和加载顺序。
全盘负责:当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
双亲委派机制:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
什么是双亲委派机制?
一个类加载器收到一个类的加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
双亲委派模型的具体实现代码在 java.lang.ClassLoader 中,此类的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出ClassNotFoundException ,此时尝试自己去加载。
双亲委派模型目的?
可以防止内存中出现多份同样的字节码。如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的 Object 类,那么类之间的比较结果及类的唯一性将无法保证。
什么时候需要打破双亲委派模型?
比如类A已经有一个classA,恰好类B也有一个clasA 但是两者内容不一致,如果不打破双亲委派模型,那么类A只会加载一次
只要在加载类的时候,不按照UserCLASSlOADER->Application ClassLoader->Extension ClassLoader->Bootstrap ClassLoader的顺序来加载就算打破打破双亲委派模型了。比如自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。
打破双亲委派模型的方式?
有两种方式:
自定义一个类加载器的类,并覆盖抽象类java.lang.ClassL oader中loadClass..)方法,不再优先委派“父”加载器进行类加载。(比如Tomcat)
主动违背类加载器的依赖传递原则
例如在一个BootstrapClassLoader加载的类中,又通过APPClassLoader来加载所依赖的其它类,这就打破了“双亲委派模型”中的层次结构,逆转了类之间的可见性。
典型的是Java SPI机制,它在类ServiceLoader中,会使用线程上下文类加载器来逆向加载classpath中的第三方厂商提供的Service Provider类。(比如JDBC)
什么是依赖传递原则?
如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。
Tomcat是如何打破双亲委派模型的?
在Tomcat部署项目时,是把war包放到tomcat的webapp下,这就意味着一个tomcat可以运行多个Web应用程序。
假设现在有两个Web应用程序,它们都有一个类,叫User,并且它们的类全限定名都一样,比如都是com.yyy.User,但是他们的具体实现是不一样的。那么Tomcat如何保证它们不会冲突呢?
Tomcat给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找,这样就做到了Web应用层级的隔离。
但是并不是Web应用程序的所有依赖都需要隔离的,比如要用到Redis的话,Redis就可以再Web应用程序之间贡献,没必要每个Web应用程序每个都独自加载一份。因此Tomcat就在WebAppClassLoader上加个父加载器ShareClassLoader,如果WebAppClassLoader没有加载到这个类,就委托给ShareClassLoader去加载。(意思就类似于将需要共享的类放到一个共享目录下)
Web应用程序有类,但是Tomcat本身也有自己的类,为了隔绝这两个类,就用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类
Tomcat与Web应用程序还有类需要共享,那就再用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器,来加载他们之间的共享类
Tomcat加载结构图如下:
JDBC 是如何打破双亲委派模型的?
实际上JDBC定义了接口,具体的实现类是由各个厂商进行实现的(比如MySQL)
类加载有个规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。
而在用JDBC的时候,是使用DriverManager获取Connection的,DriverManager是在java.sql包下的,显然是由BootStrap类加载器进行装载的。当使用DriverManager.getConnection ()时,需要得到的一定是对应厂商(如Mysql)实现的类。这里在去获取Connection的时候,是使用「线程上下文加载器」去加载Connection的,线程上下文加载器会直接指定对应的加载器去加载。也就是说,在BootStrap类加载器利用「线程上下文加载器」指定了对应的类的加载器去加载
什么线程上下文加载器?
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC 。
这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器来加载的;SPI的实现类是由系统类加载器来加载的。启动类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能委派给系统类加载器,因为它是系统类加载器的祖先类加载器。
线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到。
线程上下文加载器的一般使用模式(获取 - 使用 - 还原)
ClassLoader calssLoader = Thread.currentThread().getContextClassLoader();
try {
//设置线程上下文类加载器为自定义的加载器
Thread.currentThread.setContextClassLoader(targetTccl);
myMethod(); //执行自定义的方法
} finally {
//还原线程上下文类加载器
Thread.currentThread().setContextClassLoader(classLoader);
}
能自定义类加载器加载 java.lang.String吗?
详细内容可以查看:类加载机制
GC
常用的JVM启动参数有哪些?
记住前两个,其它的使用时再查就行
JVM(Java虚拟机)的启动参数用于配置和调整Java应用程序的运行时行为。以下是一些常用的JVM启动参数:
- -Xmx:指定Java堆内存的最大限制。例如,-Xmx512m 表示最大堆内存为512兆字节。
- -Xms:指定Java堆内存的初始大小。例如,-Xms256m 表示初始堆内存为256兆字节。
- -Xss:指定每个线程的堆栈大小。例如,-Xss256k 表示每个线程的堆栈大小为256千字节。
- -XX:MaxPermSize(对于Java 7及之前的版本)或 -XX:MaxMetaspaceSize(对于Java 8及以后的版本):指定永久代(Java 7及之前)或元空间(Java 8及以后)的最大大小。
- -XX:PermSize(对于Java 7及之前的版本)或 -XX:MetaspaceSize(对于Java 8及以后的版本):指定永久代(Java 7及之前)或元空间(Java 8及以后)的初始大小。
- -Xmn:指定年轻代的大小。例如,-Xmn256m 表示年轻代大小为256兆字节。
- -XX:SurvivorRatio:指定年轻代中Eden区与Survivor区的大小比例。例如,-XX:SurvivorRatio=8 表示Eden区与每个Survivor区的大小比例为8:1。
- -XX:NewRatio:指定年轻代与老年代的大小比例。例如,-XX:NewRatio=2 表示年轻代和老年代的比例为1:2。
- -XX:MaxGCPauseMillis:设置垃圾回收的最大暂停时间目标。例如,-XX:MaxGCPauseMillis=100 表示垃圾回收的最大暂停时间目标为100毫秒。
- -XX:ParallelGCThreads:指定并行垃圾回收线程的数量。例如,-XX:ParallelGCThreads=4 表示使用4个线程进行并行垃圾回收。
- -XX:+UseConcMarkSweepGC:启用并发标记清除垃圾回收器。
- -XX:+UseG1GC:启用G1(Garbage First)垃圾回收器。
- -Dproperty=value:设置Java系统属性,可以在应用程序中使用 System.getProperty("property") 来获取这些属性的值。
这些是一些常见的JVM启动参数,可以根据应用程序的需求和性能调优的目标进行调整。JVM启动参数的使用可以显著影响应用程序的性能和行为,因此在设置这些参数时需要谨慎。同时,JVM支持的启动参数因不同的JVM版本和供应商而有所不同,建议查阅相关文档以获取更详细的信息。
如何判断一个对象是否存活?
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
优点:可即刻回收垃圾,当对象计数为0时,会立刻回收;
弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。JVM不用这种算法
可达性分析算法
通过 GC Root 对象为起点,从这些节点向下搜索,搜索所走过的路径叫引用链,当一个对象到 GC Root没有任何的引用链相连时,说明这个对象是不可用的。
JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
GC Root的对象有哪些?
虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法栈用到的参数、局部变量或者临时变量等。
方法区中类静态属性引用的对象或者说Java类中的引用类型的静态变量。
方法区中常量引用的对象或者运行时常量池中的引用类型变量。
本地方法栈中JNI(即一般说的Native方法)引用的对象
JVM内部的内存数据结构的一些引用、同步的监控对象(被修饰同步锁)。
finalize()方法的作用?
finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。(Java 9中已弃用)
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。
Java种有哪些引用类型?有什么特点?
强引用:gc时不会回收
软引用:只有在内存不够用时,gc才会回收
弱引用:只要gc就会回收;弱引用对象非常适合于实现 Map 的缓存(weakHashMap),当对象只通过弱引用可达时,可以快速释放内存。
虚引用:是否回收都找不到引用的对象,仅用于管理直接内存
GC是什么?为什么要GC?
GC( Garbage Collection ),垃圾回收,是Java与C++的主要区别之一。作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码。这是因为在Java虚拟机中,存在自动内存管理和垃圾清理机制。对JVM中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,保证JVM中的内存空间,防止出现内存泄露和溢出问题。
GC是任意时候都能进行的吗
GC垃圾收集只能在安全点才能进行。在Java虚拟机(JVM)中,安全点(Safe Point)是程序执行的某些特定位置。JVM只能在安全点安全地暂停执行,从而进行垃圾回收(GC)等操作。安全点的设定确保了当线程暂停时,程序的状态是可知和一致的。
什么时候需要安全点?安全点的触发条件?
安全点的作用:
- 垃圾收集: 在进行垃圾收集时,JVM需要暂停所有应用程序线程(GC暂停),以确保不会有线程在操作内存。同时,状态的快照是确定的,以便于GC工作。
- 堆栈遍历: 在执行如线程转储(Thread Dump)等操作时,JVM需要安全地遍历线程栈,这时也需要安全点。
- 性能损耗最小化: 通过在最可能长时间运行的指令设置安全点(例如循环的末端、方法的调用与返回),JVM可以减少程序暂停的频率,从而降低性能损耗。
安全点的触发条件:
- 方法调用:每次方法调用都是一个潜在的安全点。
- 循环回跳:长时间循环中间会插入安全点检查。
- 异常处理:处理异常时,也会检查是否到达安全点。
Minor GC 和 Full GC的区别?
- Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Full GC:回收老年代和新生代,老年代的对象存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
对象在堆中的生命周期?
在 JVM 内存模型的堆中,堆被划分为新生代和老年代
- 新生代又被进一步划分为 Eden区Survivor区From SurvivorTo Survivor
当创建一个对象时,对象会被优先分配到新生代的 Eden 区
- 此时 JVM 会给对象定义一个对象年轻计数器 -XX:MaxTenuringThreshold
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
如果分配的对象超过了 -XX:PetenureSizeThreshold 直接被分配到老年代
内存的分配策略?
对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC
大对象直接进入老年代: 当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代,最典型的大对象有长字符串和大数组。可以设置JVM参数 -XX:PretenureSizeThreshold ,大于此值的对象直接在老年代分配。
长期存活的对象进入老年代: 通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在 Survivor 区每经过一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。
动态对象年龄判定: 并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。
空间分配担保: 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次Minor GC是有风险的;(也就是说,会把原先新生代的对象挪到老年代中) ;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC 。
空间分配担保时的 “冒险”是冒了什么风险?
新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。但前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
Full GC 的触发条件?
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:•
用 System.gc(): 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
老年代空间不足: 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组、注意编码规范避免内存泄露。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
空间分配担保失败: 当程序创建一个大对象时,Eden区域放不下大对象,使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
JDK 1.7 及以前的永久代空间不足: 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError 。(JDK 8以后元空间不足)
Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
垃圾回收算法有哪些?
垃圾回收算法有四种,分别是标记清除法、标记整理法、复制算法、分代收集算法。
- 标记清除算法:首先利用可达性去遍历内存,把存活对象和垃圾对象进行标记。标记结束后统一将所有标记的对象回收掉。这种垃圾回收算法效率较低,并且会产生大量不连续的空间碎片。
- 复制清除算法:半区复制,用于新生代垃圾回收。将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。
特点:实现简单,运行高效,但可用内存缩小为了原来的一半,浪费空间。 - 标记整理算法:根据老年代的特点提出的一种标记算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
- 分类收集算法:根据各个年代的特点采用最适当的收集算法。
一般将堆分为新生代和老年代。新生代使用复制算法,老年代使用标记清除算法或者标记整理算法。
在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,使用复制算法比较合适,只需要付出少量存活对象的复制成本就可以完成收集。老年代对象存活率高,适合使用标记-清理或者标记-整理算法进行垃圾回收。
什么是指针碰撞
在Java中,指针碰撞是一种垃圾收集算法中用于分配内存的一种方式。它通常用于实现停顿时间较短的垃圾收集器,如复制算法和标记整理算法。
指针碰撞的基本思想是将堆内存分为两个区域:一个是已分配的对象区域,另一个是未分配的空闲区域。通过一个指针来分隔这两个区域。当需要分配对象时,垃圾收集器将对象的大小与空闲区域的大小进行比较,如果空闲区域足够容纳对象,则将指针碰撞指针向前移动对象的大小,并返回指针碰撞指针的旧值作为对象的起始地址。如果空闲区域不足以容纳对象,则进行垃圾回收操作,释放一些内存后再进行分配。
指针碰撞的优点是分配内存的速度很快,只需简单地移动一个指针即可完成。而且由于已分配的对象区域和未分配的空闲区域是连续的,所以内存的利用率也比较高。
然而,指针碰撞算法的缺点是需要保证堆内存的连续性,即堆内存必须是一块连续的内存空间。这对于某些情况下的内存分配来说可能是一个限制,因为连续的内存空间可能会受到碎片化的影响,导致无法分配足够大的对象。因此,在实际应用中,指针碰撞算法通常与其他内存分配算法结合使用,以克服其局限性。
有哪些垃圾回收器?
详细查看:垃圾收集器
G1收集器可以查看:G1收集器详解
什么是三色标记算法
三色标记法是一种用于垃圾回收算法中的对象标记方法,特别用于标记清除型垃圾回收器。这种方法通过使用三种颜色(白色、灰色和黑色)来跟踪对象的可达性和垃圾回收状态,以避免对象的重复回收和丢失。
三色标记的基本概念:
- 白色:表示对象尚未被检查。白色对象可能是垃圾,直到证明它们是可达的。
- 灰色:表示对象被检查过,并且其本身是可达的,但其引用的对象还未全部检查。
- 黑色:表示对象和它所有引用的对象都已检查且是可达的。
三色标记步骤:
- 初始化:所有对象开始时都是白色。
- 标记开始:从GC Roots开始,根对象标记为灰色。
- 扫描灰色对象:
- 将灰色对象引用的所有白色对象标记为灰色。
- 然后将该灰色对象标记为黑色。
- 重复步骤3直到没有更多的灰色对象。
- 清除:未标记为黑色的对象为白色,即垃圾,可被回收。
G1 收集器的最大特点
G1 最大的特点是引入分区的思路,弱化了分代的概念。
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,不会产生空间碎片;从局部上来看是基于“标记-复制”算法实现的。
可预测的停顿:G1垃圾回收器设定了用户可控的停顿时间目标,开发者可以通过设置参数来指定允许的最大垃圾回收停顿时间。G1会根据这个目标来动态调整回收策略,尽可能地减少长时间的垃圾回收停顿。
G1如何完成可预测的停顿?
G1根据历史数据来预测本次回收需要的堆分区数量,也就是选择回收哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去10次一共收集了10GB的内存,花费了1s。那么在200ms的时间下,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。
CMS 和 G1 的区别
CMS 中,堆被分为 PermGen,YoungGen,OldGen ;而 YoungGen 又分了两个 survivo 区域。在 G1 中,堆被平均分成几个区域 (region) ,在每个区域中,虽然也保留了新老代的概念,但是收集器是以整个区域为单位收集的。
G1 在回收内存后,会立即同时做合并空闲内存的工作;而 CMS ,则默认是在 STW(stop the world)的时候做。
G1 会在 Young GC 中使用;而 CMS 只能在 Old 区使用
G1如何解决漏标问题吗?为什么G1采用SATB而不用incremental update?
**SATB算法:**是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到satb_mark_queue中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。
因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。
也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成。
G1一定不会产生内存碎片吗?
堆内存的动态变化、分配模式以及回收行为等因素影响下,仍然可能出现一些碎片问题。当某些Region中存在多个不连续的小块空闲内存,无法完全满足某些大对象的内存需求时,仍然可以称之为碎片问题。
- 分配模式不规律: 如果应用程序的内存分配模式不规律,频繁地分配和释放不同大小的对象,可能会导致一些小的空闲内存碎片在堆中产生。
- 大对象分配: G1回收器的区域被划分为不同大小的Region,当一个大对象无法在单个Region中分配时,G1可能会在多个Region中分配这个大对象,这可能导致跨多个Region的碎片。
- 并发情况下的内存变化: G1回收器会在后台进行并发的垃圾回收,如果在回收过程中发生了内存变化,如某个区域中的对象被回收,留下一些零散的空闲空间,也有可能会导致内存碎片。
- 频繁的Full GC: 尽管G1垃圾回收器的设计可以减少Full GC(全局垃圾回收)的频率,但如果频繁发生Full GC,可能会导致内存布局的重组,产生一些碎片。
工作中常见的6种OOM问题
堆内存OOM
堆内存OOM是最常见的OOM了。
出现堆内存OOM问题的异常信息如下:
java.lang.OutOfMemoryError: Java heap space
此OOM是由于JVM中heap的最大值,已经不能满足需求了。
举个例子:
@Test public void test01() {
List list = Lists.newArrayList();
while (true) {
list.add(new OOMTests());
}
}
这里创建了一个list集合,在一个死循环中不停往里面添加对象。
执行结果:
出现了java.lang.OutOfMemoryError: Java heap space的堆内存溢出。
很多时候,excel一次导出大量的数据,获取在程序中一次性查询的数据太多,都可能会出现这种OOM问题。
我们在日常工作中一定要避免这种情况。
栈内存OOM
有时候,我们的业务系统创建了太多的线程,可能会导致栈内存OOM。
出现堆内存OOM问题的异常信息如下:
java.lang.OutOfMemoryError: unable to create new native thread
举个例子
public class StackOOMTest {
public static void main(String[] args) {
while (true) {
new Thread().start();
}
}
}
使用一个死循环不停创建线程,导致系统产生了大量的线程。
如果实际工作中,出现这个问题,一般是由于创建的线程太多,或者设置的单个线程占用内存空间太大导致的。
建议在日常工作中,多用线程池,少自己创建线程,防止出现这个OOM。
栈内存溢出
我们在业务代码中可能会经常写一些 递归调用,如果递归的深度超过了JVM允许的最大深度,可能会出现栈内存溢出问 题。
出现栈内存溢出问题的异常信息如下:
java.lang.StackOverflowError
举个例子
@Test
public void test03() {
recursiveMethod();
}
public static void recursiveMethod() {
// 递归调用自身
recursiveMethod();
}
出现了java.lang.StackOverflowError栈溢出的错误。
我们在写递归代码时,一定要考虑递归深度。即使是使用 parentId一层层往上找的逻辑,也最好加一个参数控制递归 深度。防止因为数据问题导致无限递归的情况,比如:id和 parentId的值相等。
直接内存OOM
直接内存不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。
它来源于 NIO ,通过存在堆中的 DirectByteBuffer 操作 Native内存,是属于堆外内存 ,可以直接向系统申请的内存空间。 出现直接内存OOM问题时异常信息如下:
java.lang.OutOfMemoryError: Direct buffer memory
例如:
private static final int BUFFER = 1024 * 1024 * 20;
@Test
public void test04() {
ArrayList<ByteBuffer> list = new ArrayList<>();
int count = 0;
try {
while (true) {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
list.add(byteBuffer);
count++;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally {
System.out.println(count);
}
}
会看到报出来java.lang.OutOfMemoryError: Direct buffer memory直接内存空间不足的异常。
GC OOM
GC OOM 是由于JVM在GC时,对象过多,导致内存溢出,建 议调整GC的策略。 出现GC OOM问题时异常信息如下:
java.lang.OutOfMemoryError: GC overhead limit exceeded
为了方便测试,我先将idea中的最大和最小堆大小都设置成 10M,例如下面这个例子:
public class GCOverheadOOM {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < Integer.MAX_VALUE; i++) {
executor.execute(() -> {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
}
});
}
}
}
出现这个问题是由于JVM在GC的时候,对象太多,就会报这 个错误。 我们需要改变GC的策略。 在老代80%时就是开始GC,并且将-XX:SurvivorRatio(- XX:SurvivorRatio=8)和-XX:NewRatio(- XX:NewRatio=4)设置的更合理。
元空间OOM
JDK8 之后使用 Metaspace 来代替 永久代 ,Metaspace是方 法区在 HotSpot 中的实现。
Metaspace不在虚拟机内存中,而是使用本地内存也就是在 JDK8中的 ClassMetadata ,被存储在叫做Metaspace的 native memory。
出现元空间OOM问题时异常信息如下:
java.lang.OutOfMemoryError: Metaspace
为了方便测试,我们修改一下idea中的JVM参数,增加下面的配 置:
-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
指定了元空间和最大元空间都是10M。 接下来,看看下面这个例子:
public class MetaspaceOOMTest {
static class OOM {
}
public static void main(String[] args) {
int i = 0;
try {
while (true) {
i++;
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[]
return methodProxy.invokeSuper(o, args);
}
});
enhancer.create();
}
} catch (Throwable e) {
e.printStackTrace();
}
}
}
程序最后会报java.lang.OutOfMemoryError: Metaspace的 元空间OOM。 这个问题一般是由于加载到内存中的类太多,或者类的体积太 大导致的。
OOM一定会导致JVM退出吗
在Java中,发生了OutOfMemoryError(OOM)不一定会导致整个JVM退出。是否退出取决于发生OOM错误的线程和错误处理逻辑。这是一个复杂的问题,具体行为会因应用程序实现方式、错误发生的情境以及错误处理策略而异。
- 主线程中未处理的OOM: 如果在主线程中发生OOM且没有被捕获,JVM通常会终止程序并退出。这是因为JVM中没有其他存活的非守护线程来保持程序运行。
- 子线程中未处理的OOM: 在非主线程中,如果OOM发生且未被捕获,该线程会停止执行。但如果其他非守护线程仍在运行,JVM不会退出。
- 捕获并处理OOM: 如果在代码中捕获并正确处理了OOM错误,JVM则可以继续执行其余的程序代码。合适的错误处理可能包括释放内存资源或提示用户进行适当的操作。
注意:
- 不建议频繁捕获OOM并继续执行程序,因为这样可能表明程序有严重的内存管理问题,应尽量优化内存使用。
- 在关键路径中发生OOM时,通常应记录日志并考虑安全停机,因为无法保证系统在内存压力下的正确性。