这里是有关JVM的八股
1.引言
1.JVM的特性
对字节码文件中的指令,实时解释成机器码,让机器执行
最重要的特性还是跨平台,以下是其它特性
①、垃圾回收:JVM 可以自动管理内存,通过垃圾回收机制(Garbage Collection)释放不再使用的对象所占用的内存。
②、JIT:JVM 包含一个即时编译器(JIT Compiler),它在运行时将热点代码缓存到 codeCache 中,下次执行的时候不用再一行一行解释,而是直接执行缓存后的机器码,执行效率会提高很多。
③、多语言支持:任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。
所以主要关注四个部分,字节码文件,类加载器,运行时数据区,垃圾回收器
2.JVM的组成
3.字节码文件的结构
该部分内容引自美团技术团队文章——字节码增强技术探索
.java文件通过javac编译后将得到一个.class文件
编译后生成.class文件,打开后是一堆十六进制数,JVM规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构如图3所示。接下来我们将一一介绍这十部分:
魔数(Magic Number)
固定 4 字节:
0xCAFEBABE
,用来标识这是一个合法的 Java 类文件。版本号
版本号为魔数之后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。 用来标明该字节码所兼容的最低 JVM 版本
常量池
大小可变,是整个类文件中最“重”的部分。 常量池中存储两类常量:字面量(字符串、整数等) 与符号引用(类名、字段名、方法名及描述符)。
访问标志
描述该Class是类还是接口,以及是否被Public、Abstract、Final等修饰符修饰。
当前类名
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
父类名称
当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
接口信息
列出本类实现的所有接口
字段表
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。
方法表
方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性
附加属性表
类级的额外信息。
4.从底层看 i++和++i
1 | int i = 0; |
这个代码的执行结果是什么?
先来分析字节码指令的执行过程
答案是0,通过分析字节码指令发现,i++
先把0取出来放入临时的操作数栈中,接下来对i进行加1,i变成了1,最后再将之前保存的临时值0放入i,最后i就变成了0。
而++i的执行结果则不一样
PS:这里iload_1写错了,应该是将1复制一份到操作数栈中,
然后istore_1是将操作数栈中的1对局部变量表中的1进行一个覆盖
所以,如果是
1 | int i = 0; |
执行结果则是1
总结:关键的是++
这个命令是直接对局部变量表上的值进行增加。而i++
和++i
有不同的执行顺序。前者是先将原值拷贝一份,然后对变量表中的进行操作,最后将拷贝的那份进行了一个覆盖,所以不管怎么操作,i的值仍然不变。而后者是先操作,再拷贝,最后覆盖的仍然是操作后的数据,因此就可以实现变化。
2.内存管理
1.JVM的内存区域
JVM 的内存区域,有时叫 JVM 的内存结构,有时也叫 JVM 运行时数据区
主要包括
- 程序计数器(Program Counter Register)
- Java 虚拟机栈(Java Virtual Machine Stacks)
- 本地方法栈(Native Method Stack)
- 堆(Heap)
- 方法区(Method Area)其中
线程私有的:程序计数器,虚拟机栈,本地方法栈
线程共享的:堆,方法区,直接内存 (也即本地内存)
下面依次介绍这些区域:
2.程序计数器
程序计数器(Program Counter Register)也被称为 PC 寄存器,是一块较小的内存空间。
线程私有的,每个线程一份,内部保存的字节码的行号。可以看作是当前线程所执行的字节码的行号指示器。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
从上面的介绍中我们知道了程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
3.虚拟机栈
Java 虚拟机栈(JVM 栈)中是一个个栈帧,每个栈帧对应一个被调用的方法。当线程执行一个方法时,会创建一个对应的栈帧,并将栈帧压入栈中。当方法执行完毕后,将栈帧从栈中移除。
每个线程有一个私有的栈,生命周期和线程一样。
栈帧:用于存储局部变量表、操作数栈、动态链接、方法出口等信息
局部变量表:局部变量表的作用是在方法执行过程中存放所有的局部变量。局部变量表分为两种,一种是字节码文件中的,另外一种是栈帧中的也就是保存在内存中。栈帧中的局部变量表是根据字节码文件中的内容生成的。
操作数栈:操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域。
动态链接:当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
方法出口:方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
可能出现的问题:
- StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出
StackOverFlowError
错误。 - OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出
OutOfMemoryError
异常。
垃圾回收是否涉及栈内存?
垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放
栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k,栈过大会导致线程数变少方法内的局部变量是否线程安全?
如果方法内局部变量没有逃离方法的作用范围,它是线程安全的如果是局部变量引用了对象(传入有参数),并逃离方法的作用范围(有返回值),需要考虑线程安全
什么情况下会导致栈内存溢出?
栈帧过多导致栈内存溢出,典型问题:递归调用
栈帧过大导致栈内存溢出堆栈的区别是什么?
堆属于线程共享的内存区域,几乎所有的对象都在堆上分配,生命周期不由单个方法调用所决定,可以在方法调用结束后继续存在,直到不再被任何变量引用,然后被垃圾收集器回收。
栈属于线程私有的内存区域,主要存储局部变量、方法参数、对象引用等,通常随着方法调用的结束而自动释放,不需要垃圾收集器处理。
4.本地方法栈
与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的;
在本地方法栈中,主要存放了 native 方法的局部变量表、操作数栈、动态链接、出口信息。当一个 Java 程序调用一个 native 方法时,JVM 会切换到本地方法栈来执行这个方法。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
5.堆
堆(heap)是 JVM 中最大的一块内存区域,被所有线程共享,在 JVM 启动时创建,主要用来存储对象的。
从内存回收的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计的,所以堆也会被划分为新生代
、老年代。
新生代又会划分为Eden空间
、From Survivor空间
、To Survivor空间
简单介绍一下JIT和逃逸分析:
常见的编译型语言如 C++,通常会把代码直接编译成 CPU 所能理解的机器码来运行。而 Java 为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由 javac 编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java 可能会干不过 C++ 这类编译型语言。
为了优化 Java 的性能 ,JVM 在解释器之外引入了 JIT 编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。
逃逸分析(Escape Analysis)是一种编译器优化技术,用于判断对象的作用域和生命周期。如果编译器确定一个对象不会逃逸出方法或线程的范围,它可以选择在栈上分配这个对象,而不是在堆上。这样做可以减少垃圾回收的压力,并提高性能。
堆最容易出现OOM问题:
OutOfMemoryError: GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生该错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发该错误。和本机的物理内存无关,和我们配置的虚拟机内存大小有关!
6.方法区
方法区并不真实存在,属于 Java 虚拟机规范中的一个逻辑概念。每个JVM 只有一个方法区,它是一个共享的资源。
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。
方法区是存放基础信息的位置,线程共享,主要包含三部分内容:
- 类的元信息,保存了所有类的基本信息
- 运行时常量池,保存了字节码文件中的常量池内容
- 字符串常量池,保存了字符串常量
类的元信息
方法区是用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息。
运行时常量池
方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容。
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
字符串常量池
方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池(StringTable)。
字符串常量池存储在代码中定义的常量字符串内容。比如“123” 这个123就会被放入字符串常量池。
字符串常量池和运行时常量池有什么关系?
早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分。
静态变量存储在哪里呢?
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理。
全局变量(可以认为是static修饰的变量)就是放在方法区中。成员变量放在堆中,局部变量放在栈中。
方法区和永久代以及元空间是什么关系呢? 方法区和永久代以及元空间的关系很像 Java 中 接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间, 接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。 并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。 元空间使用的是本地内存
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
- 直接内存(不属于JVM的内存结构
操作系统划分的一块用于NIO(new io)的数据缓冲区,可以提高读写性能,不属于JVM的内存结构,不受JVM内存回收管理
8.JDK1.6、1.7、1.8内存区域的变化
两方面:
1.方法区的实现
2.字符串常量池的位置
JDK1.6 使用永久代实现方法区:
此时的常量池包括运行时常量池和字符串常量池,这两个还没有分开
- JDK1.7 时发生了一些变化,将字符串常量池、静态变量,存放在堆上,而运行时常量池还在永久代中
- 在 JDK1.8 时彻底干掉了永久代,而在直接内存中划出一块区域作为元空间,运行时常量池、类常量池都移动到元空间。
9.四种引用类型
四种,分别是强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。
- 强引用:是 Java 中最常见的引用类型。使用 new 关键字赋值的引用就是强引用,发生 gc 的时候不会被回收。
- 软引用:是一种相对较弱的引用类型,可以通过 SoftReference 类实现。 有用但不是必须的对象,在发生内存溢出之前会被回收。
- 弱引用:可以通过 WeakReference 类实现。他的强度比软引用更低一点, 有用但不是必须的对象,在下一次GC时会被回收,而不管内存是否足够。
- 虚引用:是最弱的引用关系, 可以通过 PhantomReference 类实现。虚引用对象在任何时候都可能被回收。主要用于跟踪对象被垃圾回收的状态,可以用于管理直接内存。 虚引用的用途是在 gc 时返回一个通知。
引用关系从强到弱:强引用 > 软引用 > 弱引用 > 虚引用
10.内存溢出和内存泄露
内存溢出(Out of Memory,俗称 OOM)和内存泄漏(Memory Leak)是两个不同的概念,但它们都与内存管理有关。
①、内存溢出:是指当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。在 Java 中,这种情况会抛出 OutOfMemoryError,OOM
。
内存溢出可能是由于内存泄漏导致的,也可能是因为程序一次性尝试分配大量内存,内存直接就干崩溃了导致的,比如堆内存不足以存放新创建的对象时 。
②、内存泄漏:是指程序在使用完内存后,未能释放已分配的内存空间,导致这部分内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终可能导致内存溢出。
在 Java 中,内存泄漏通常发生在长期存活的对象持有短期存活对象的引用,而长期存活的对象又没有及时释放对短期存活对象的引用,从而导致短期存活对象无法被回收。
用一个比较有味道的比喻来形容就是,内存溢出是排队去蹲坑,发现没坑了;内存泄漏,就是有人占着茅坑不拉屎,占着茅坑不拉屎的多了可能会导致坑位不够用。
在JVM运行时数据区中,除了程序计数器不会发生内存溢出,其余的堆,栈,方法区都会发生内存溢出
对象什么时候进入老年代?(TODO)
对象通常会先在年轻代中分配,然后随着时间的推移和垃圾收集的处理,某些对象会进入到老年代中。
①、长期存活的对象将进入老年代
对象在年轻代中存活足够长的时间(即经过足够多的垃圾回收周期)后,会晋升到老年代。
每次 GC 未被回收的对象,其年龄会增加。当对象的年龄超过一个特定阈值(默认通常是 15),它就会被移动到老年代。这个年龄阈值可以通过 JVM 参数-XX:MaxTenuringThreshold
来设置。
②、大对象直接进入老年代
为了避免在年轻代中频繁复制大对象,JVM 提供了一种策略,允许大对象直接在老年代中分配。
这些是所谓的“大对象”,其大小超过了预设的阈值(由 JVM 参数-XX:PretenureSizeThreshold
控制)。直接在老年代分配可以减少在年轻代和老年代之间的数据复制。
③、动态对象年龄判定
除了固定的年龄阈值,还会根据各个年龄段对象的存活大小和内存空间等因素动态调整对象的晋升策略。
比如说,在 Survivor 空间中相同年龄的所有对象大小总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代。
2.类加载器
1.对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括两部分信息:
- 标记字段(Mark Word):存储对象自身运行时的数据:HashCode、GC 状态(年龄、标记位)、锁状态(锁记录/偏向锁信息)等。 Mark Word 的低 3 位作为锁状态标识(00 = 无锁,01 = 偏向锁,10 = 轻量级锁,11 = 重量级锁)(在并发那里需要了解这个)
- 类型指针(Klass pointer):指向对象元数据(Class 元信息)的指针。 虚拟机通过这个指针来确定这个对象是哪个类的实例。
Java 数组对象在头部还多一个 length
字段(4 字节)
对象头 Mark Word 的多种状态 :无锁态、偏向锁态、轻量级锁态、重量级锁态、GC 标记态 等,由 JVM 动态切换。
HotSpot 特殊优化
- TLAB(线程本地分配缓冲),小对象由线程本地分配,减少竞争。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
2.对象创建的过程
- 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 这一步保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用, 程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置, 例如这个对象是哪个类的实例、如何才能找到类的元数据信息、 对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看, 一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始——构造函数, 即class文件中的方法还没有执行,所有的字段都还为零, 对象需要的其他资源和状态信息还没有按照预定的意图构造好。 所以一般来说,执行 new 指令之后会接着执行方法, 把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
3.为对象分配内存(堆内存如何分配)
类加载完成后,接着会在Java堆中划分一块内存分配给对象。内存分配根据Java堆是否规整,有两种方式:
指针碰撞
原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
空闲列表:
原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
选择哪种分配方式是由 Java 堆是否规整来决定的,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
指针碰撞适用于管理简单、碎片化较少的内存区域(如年轻代),而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景(如老年代)。
4.处理并发安全问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
- 对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性);
- 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配, 当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
5.JVM如何访问对象(对象的访问定位)
Java 程序通过栈上的 reference 数据来操作堆上的具体对象。 对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针
如果使用直接指针访问,reference 中存储的直接就是对象的地址。
- 句柄的好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 直接指针的好处是速度快,它节省了一次指针定位的时间开销。
6.类加载的过程(类的生命周期)
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:
连接或作链接,都一样的。
1.加载:通过类的全限定名获取字节码文件,并将其转换为方法区内的运行时数据结构。
通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口
2.验证:对字节码进行校验,确保符合Java虚拟机规范。
确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
3.准备:为类的静态变量分配内存,并设置默认初始值。
为类中的静态字段分配内存,并设置默认的初始值(比如int的默认值就是0),但如果这个字段还被final修饰,会直接设置为我们设置的初始值,比如public static final a = 1
,就会设置为1。
被final修饰的static字段不会设置,因为final在编译的时候就分配了
也就是说,final分配内存更早!在编译阶段,static分配内存在准备阶段!
4.解析:将符号引用转换为直接引用,即将类、方法、字段等解析为具体的内存地址。
符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、 相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
5.初始化:执行类的初始化代码,包括静态变量赋值和静态代码块的执行。
6.类卸载
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
拓展一下:
JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。 只有两者都相同的情况,才认为两个类是相同的。
7.什么是类加载器,类加载器有哪些
简而言之,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中,详细如下:
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的
ClassLoader
。
- 每个 Java 类都有一个引用指向加载它的
- 数组类不是通过
ClassLoader
创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
以下是常见的类加载器
- 启动类加载器(Bootstrap ClassLoader):用来加载java核心类库,无法被java程序直接引用。负责加载 JAVA_HOME/jre/lib 目录下的jar包和类,(如 String、System等)
- 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。它负责加载JRE的扩展目录( %JAVA_HOME%/jre/lib/ext )中jar包的类
- 应用程序类加载器(application class loader):负责加载用户类路径(ClassPath)上的指定类库 ,是我们平时编写Java程序时默认使用的类加载器。 它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。
- 用户自定义类加载器:开发者可以根据需求定制类的加载方式 ,通过继承 java.lang.ClassLoader类的方式实现。
层级关系:启动类加载器——>扩展类加载器——>应用程序类加载器——>自定义类加载器
加载流程:自底向上判断是否加载过,自顶向下尝试加载类
8.什么是双亲委派机制?为什么采用双亲委派机制
双亲委派机制是指类加载器在加载类时,首先不会自己去尝试加载这个类 ,而是将加载请求委托给父类加载器,只有当父类加载器无法加载时(每种类加载器有自己的加载目录),才自己尝试加载。从而确保类的加载安全和防止类的重复加载。
- 保证类的唯一性 :通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
- 保证安全性: 为了安全,保证类库API不会被修改,避免恶意代码替换JDK中的核心类库
9.类加载和对象创建的过程中,变量的赋值变化
首先,在类的准备阶段,为静态变量设置零值(默认值)
其次,在类的初始化阶段,执行类的初始化代码<clnint>
,为静态变量赋值(人为设置的初始值)和执行静态代码块
然后,在对象创建的初始化阶段,为非静态变量,也就是类的实例字段设置零值(也就是八大类型的默认值)
最后,在对象创建的执行构造器阶段,才是为类的实例字段赋值(人为设置的值)
3.垃圾收集
垃圾回收就是对内存堆中已经死亡的或者长时间没有使用的对象进行清除或回收。
JVM 在做 GC 之前,会先搞清楚什么是垃圾,什么不是垃圾,通常会通过可达性分析算法来判断对象是否存活。
在确定了哪些垃圾可以被回收后,垃圾收集器(如 CMS、G1、ZGC)要做的事情就是进行垃圾回收,可以采用标记清除算法、复制算法、标记整理算法、分代收集算法等。
1.判断垃圾的方法
也可以问:如何判断对象仍然存活?对象什么时候可以被垃圾器回收?
如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
通常有两种方式:引用计数算法(reference counting)和可达性分析算法。
引用计数法
每个对象有一个引用计数器,记录引用它的次数。当计数器为零时,对象可以被回收。
但无法解决循环引用问题。例如,两个对象互相引用,但不再被其他对象引用,它们的引用计数都不为零,因此不会被回收。
可达性分析算法
通过一组名为 “GC Roots” 的根对象,进行递归扫描。那些无法从根对象到达的对象是不可达的,可以被回收;反之,是可达的,不会被回收。
2.哪些对象可以作为GC Root
**GC Roots就是对象,而且是JVM确定当前绝对不能被回收的对象(如方法区中类静态属性引用的对象 )**。只有找到这种对象,后面的搜寻过程才有意义,不能被回收的对象所依赖的其他对象肯定也不能回收嘛。
- 方法区中类静态属性引用的对象:全局对象的一种,Class对象本身很难被回收,回收的条件非常苛刻,只要Class对象不被回收,静态成员就不能被回收
- 方法区中常量引用的对象:也属于全局对象,例如字符串常量池,常量本身初始化后不会再改变,因此作为GC Roots也是合理的。
- 虚拟机栈(栈帧中的局部变量表) 中正在引用的对象:属于执行上下文中的对象,线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量会存放到栈帧的本地变量表中。只要方法还在运行,还没出栈,就意味这本地变量表的对象还会被访问,GC就不应该回收,所以这一类对象也可作为GC Roots
- 本地方法栈中正在引用的对象:和上一条本质相同,无非是一个是Java方法栈中的变量引用,一个是native方法(C、C++)方法栈中的变量引用
- 被同步锁持有的对象:被synchronized锁住的对象也是绝对不能回收的,当前有线程持有对象锁呢,GC如果回收了对象,锁不就失效了嘛
3.finalize()方法的作用
对象可以被回收,就代表一定会被回收吗? 不一定
用一个不太贴切的比喻,垃圾回收就是古代的秋后问斩,finalize()就是刀下留人,在人犯被处决之前,还要做最后一次审计,青天大老爷看看有没有什么冤情,需不需要刀下留人。
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。如果对象在在 finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它就”逃过一劫“;但是如果没有抓住这个机会,那么对象就真的要被回收了。
4.垃圾收集算法
标记-清除算法: 标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象, 然后统一回收所有被标记的对象。
缺点:一个是效率问题,标记和清除的过程效率都不高 ;另一个就是,清除结束后会造成大量不连续的碎片空间。
复制算法:为了解决碎片空间的问题,出现了“复制算法”。 它把内存空间划为两个相等的区域,每次只使用其中一个区域(from区)。垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域(to区)中,最后将当前区域(from)清空。 然后将两块区间的名字互换,即from区变to区,to区变from区,之后继续在from区创建对象
缺点:因为每次在申请内存时,都只能使用一半的内存空间。内存利用率严重不足。
标记-整理算法: 复制算法在 GC 之后存活对象较少的情况下效率比较高,但如果存活对象比较多时, 会执行较多的复制操作,效率就会下降。 标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。 而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
缺点:由于多了整理这一步,因此效率也不高
分代回收算法: 年轻代通常使用复制算法,老年代使用标记-清除或标记-整理算法。
- 分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。 eden[首次]满时,会触发垃圾回收,根据可达性判断存活的对象,将存活的对象复制到某个幸存区(假设为From区),同时Eden区清空。
- 当后续eden再满时,就会触发年轻代的GC,称为Minor GC或者Young GC。 Minor GC会把eden中和From需要回收的对象回收,把没有回收的对象放入To区。 然后交换from区和to区的名字,下次edan区满了还是放入from区,这个就是复制算法的“换名”
- 如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
- 当老年代中空间不足,无法放入新的对象时,先尝试minor gc。如果还是不足,就会触发Full GC,涉及整个 Java 堆和方法区(或元空间)。它是最耗时的 GC,通常在 JVM 压力很大时发生。
5.为什么要分为新生代和老年代?
正是利用了“对象大多很快就死掉,少量长期存活”的规律,让垃圾收集在高频快速回收短命对象的同时,又能高效地管理持久对象,从而在吞吐量、暂停时间和内存利用之间取得最佳平衡。
对象的生命周期差异
- 大多数对象都是“朝生暮死”——它们刚创建没多久就不再被引用(比如临时变量、短期缓存、局部计算中间值等)。
- 少量对象会被长期持有(如单例、全局缓冲区、长连接管理器等)。
- 如果把所有对象都用同一种算法来回收,就要不停地遍历大量很快就死掉的对象,也要频繁地处理少量存活对象,效率低下。
针对新生代使用“复制算法”
- 新生代采用“复制”策略(Copying GC)——把 Eden 区里存活对象搬到 Survivor 区,再把 Survivor 存活对象搬到老年代。
- 优点:
- 只扫描 Eden(不用碰老年代的大多数对象),节省标记时间。
- 天生压缩,无碎片;
- 绝大多数对象在 Eden 区就被回收,Minor GC 快而频繁。
针对老年代使用“标记‑清除/整理”
- 老年代对象存活时间长,复制成本不断累加会很大,所以改用 Mark‑Sweep 或 Mark‑Compact:
- 标记‑清除:快速回收垃圾,但会留下碎片。
- 标记‑整理:回收后压缩堆,但需要移动存活对象。
- 因为老年代 GC 发生得少(Major/Full GC),即便停顿时间稍长,也在可接受范围内。
分代收集的综合收益
- 缩短常态停顿:Minor GC 只作用于新生代,且非常快。
- 降低全堆扫描频率:只有在老年代空间快满或触发 Full GC 时,才要遍历整个堆。
- 更灵活的算法组合:根据不同代的存活率和访问特征,分别采用最适合的回收策略。
6.CMS垃圾收集器
CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间。
以获取最短GC回收停顿时间为目标的收集器,具有高并发、低停顿的特点,采取标记——清除算法,回收老年代
工作步骤
初始标记: 短暂停顿(Stop-The-World,STW),标记直接与 GCRoots 相连的对象(根对象),该阶段需要STW,由于只标记直接引用的对象,这一阶段通常很快。
并发标记: 指的是对「初始标记阶段」标记的对 象进行整个引用链的扫描,该阶段与用户线程同时运行, 不需要STW 。因为是并发进行,应用线程可能修改对象图,导致标记可能不完全准确。 即:
并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该回收的垃圾没有被回收)和多标(本不应该回收的垃圾被回收)了。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短,需要STW
并发清除:指的是将标记为垃圾的对象进行清 除,该阶段与用户线程同时运行,不需要STW
缺点:
- 无法处理在并发清理过程中产生的“浮动垃圾”, 不能做到完全的垃圾回收
- 它使用的回收算法“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
- 如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代。
7.G1收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。 同时注重吞吐量(Throughput)和低延迟
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小。CMS关注暂停时间,但是吞吐量方面会下降。而G1设计目标就是将上述两种垃圾回收器的优点融合
核心设计理念:
- 可预测的停顿时间模型: G1 的核心目标之一是能够设定一个期望的最大停顿时间目标(通过
-XX:MaxGCPauseMillis
指定,默认 200ms)。它会努力在每次回收时,将停顿时间控制在这个目标值附近。 - 高吞吐量: 在保证低停顿的同时,也追求整体应用的吞吐量不能太低。
- 大堆内存管理: 能高效地管理非常大的堆(几十GB甚至上百GB),并且避免像 CMS 那样因内存碎片导致 Full GC 的问题。
- 并发与并行: 充分利用多核 CPU 资源,在应用线程运行的同时进行大部分的垃圾回收工作,尽量减少 STW (Stop-The-World) 停顿时间。
G1出现之前的垃圾回收器,年轻代和老年代一般是连续的,比如在堆那里的图那样
G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。 Region默认约2048个,每个 Region 在逻辑上被标记为 Eden、Survivor、Old 或 Humongous(超大对象区)。 大对象的判定规则是,如果一个大对象超过了一个 Region 大小的 50%,比如每个 Region 是 2M,只要一个对象超过了 1M,就会被放入 Humongous 中。
G1垃圾回收有两种方式:
1、年轻代回收(Young GC)
2、混合回收(Mixed GC)
1.年轻代回收
- 触发条件:Eden区满时
- 过程:
- 根枚举(STW开始)
- 标记存活对象
- 复制存活对象到Survivor区(或直接晋升到Old区)
- 清空Eden区
工作步骤:
- 初始标记: 短暂停顿,标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
- 并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
- 最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
- 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度。
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。
特点:
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 停顿时间可控: G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间避免应用雪崩现象。
CMS和G1的区别
6.Minor GC/Young GC、Major GC/Old GC、Mixed GC、Full GC 都是什么意思?
Minor GC 也称为 Young GC,是指发生在年轻代(Young Generation)的垃圾收集。由于java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快。 一般采用复制算法回收垃圾
触发条件:当Eden区空间不足时,JVM会触发一次Minor GC, 将Eden区和一个Survivor区中的存活对象移动到另一个Survivor区或老年代(Old Generation)。
Major GC 也称为 Old GC,主要指的是发生在老年代的垃圾收集。CMS 收集器的特有行为。Major GC的速度要比Minor GC慢的多。(可采用标记清楚法和标记整理法)
触发条件:当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快 ,可能会触发Major GC。
Mixed GC 是 G1 垃圾收集器特有的一种 GC 类型,它在一次 GC 中同时清理年轻代和部分老年代。
Full GC 是最彻底的垃圾收集,涉及整个 Java 堆和方法区(或元空间)。它是最耗时的 GC,通常在 JVM 压力很大时发生。
触发条件:
系统调用
System.gc()
(多数 HotSpot 默认会触发 Full GC);CMS 并发清理后发现碎片导致老年代空间严重不足;
元空间(Metaspace)或方法区需要回收;
触发非并行收集器的显式 Full GC 请求。
2.3.3、简述分代回收器怎么工作的?(分代收集算法)
分代回收器有两个分区:老生代和新生代,新生代和老生代空间占比是1:2
新生代使用的是复制算法,新生代里有 3 个分区:Eden(一den)、To 、From ,它们的默认占比是 8:1:1,它的执行流程如下:
- 把 Eden + From 存活的对象放入 To 区;
- 清空 Eden 和 From 分区;
- From 和 To 分区交换,From 变 To ,To 变 From 。
每次在 From 到 To 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老生代。大对象也会直接进入老生代。
老生代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循环往复就构成了整个分代垃圾回收的整体执行流程。