一. 概述
1.什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?
1)Java虚拟机是一个可以执行Java字节码的虚拟机进程。
2)因为JVM识别的是字节码文件,只需将Java源文件编译成class字节码文件后即可被各个平台下的JVM所执行,做到“一次编译,处处运行”。
1.5. Java文件是如何被运行的
执行main方法的步骤如下:
- 编译好 App.java 后得到 App.class 后,执行 App.class,系统会启动一个 JVM 进程,从 classpath 路径中找到一个名为 App.class 的二进制文件,将 App 的类信息加载到运行时数据区的方法区内,这个过程叫做 App 类的加载
- JVM 找到 App 的主程序入口,执行main方法
这个main中的第一条语句为 Student student = new Student(“tellUrDream”) ,就是让 JVM 创建一个Student对象,但是这个时候方法区中是没有 Student 类的信息的,所以 JVM 马上加载 Student 类,把 Student 类的信息放到方法区中 - 加载完 Student 类后,JVM 在堆中为一个新的 Student 实例分配内存,然后调用构造函数初始化 Student 实例,这个 Student 实例持有 指向方法区中的 Student 类的类型信息 的引用
- 执行student.sayName();时,JVM 根据 student 的引用找到 student 对象,然后根据 student 对象持有的引用定位到方法区中 student 类的类型信息的方法表,获得 sayName() 的字节码地址。
- 执行sayName()
二. 内存管理
¥2.Java内存模型
- Java堆(Heap):存放对象实例,
- 元空间:也被叫做永久代,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。使用直接内存
- 运行时常量池。运行时常量池是元空间的一部分。
- 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器。
作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
- JVM栈(JVM Stacks):虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
- 本地方法栈(Native Method Stacks):虚拟机使用到的Native方法的栈。
- 直接内存
方法区和对是所有线程共享的内存区域;而java栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。
2.5 JVM堆和栈为什么要分开放
- 从软件设计的角度来看,栈代表了处理逻辑,而堆代表了数据,这样分离使得处理逻辑更为清晰。这种隔离、模块化的思想在软件设计的方方面面都有体现。
- 堆与栈的分离,使得堆中的内容可以被多个栈共享。这种共享有很多好处,一方面提供了一种有效的数据交互方式(如内存共享),另一方面,节省了内存空间。
- 栈因为运行时的需要(如保存系统运行的上下文),需要进行址段的划分。由于栈只能向上增长,因此会限制住栈存储内容的能力。而堆不同,堆的大小可以根据需要动态增长。因此,堆与栈的分离,使得动态增长成为可能,相应栈中只需要记录堆中的一个地址即可。
2.6 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致
OutOfMemoryError 异常出现,所以我们放到这里一起讲解。 在 JDK 1.4 中新加入了 NIO(New
Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native
函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java
堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置
-Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。
直接内存常用于NIO操作,用于数据缓冲区。
直接内存分配回收成本较高,但读写性能高。
直接内存不受JVM内存回收管理
直接内存详解:https://blog.csdn.net/weixin_51146329/article/details/128770701
3.Java对象创建过程
- JVM遇到一条新建对象的指令时首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用(是否被加载过),没有则加载这个类
- 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,线程安全的解决:“本地线程缓冲分配(TLAB)”或CAS+重试
- 将除对象头外的对象内存空间初始化为零值(这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用)
- 对对象头进行必要设置
例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行<init>方法
3.5 对象内存分配方法
分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
3.6 <init>方法和 <clinit>方法
<init>是对象构造器方法,在程序执行new一个对象调用该类的构造方法时会调用 <init>方法。<clinit>方法是类构造器方法,在JVM进行类加载的初始化阶段会调用<clinit>方法
4.Java对象结构
Java对象由三个部分组成:对象头、实例数据、对齐填充。
-
对象头:由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。
-
实例数据:用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)
-
对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)
4.5 对象的访问定位
- 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
- 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改(修改方便)。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
$5.如何判断对象可以被回收?
判断对象是否存活一般有两种方式:
- 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
- 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。
判断是否进行回收:
1.该对象没有与GC Roots相连
2.该对象没有重写finalize()方法或finalize()已经被执行过则直接回收(第一次标记)、否则将对象加入到F-Queue队列中(优先级很低的队列)在这里finalize()方法被执行,之后进行第二次标记,如果对象仍然应该被GC则GC,否则移除队列。 (在finalize方法中,对象很可能和其他 GC Roots中的某一个对象建立了关联,finalize方法只会被调用一次,且不推荐使用finalize方法)
5.5 有哪些是可以作为GCroots
虚拟机栈引用对象,类静态属性引用对象,常量引用对象,本地方法栈引用对象
5.6 记忆集(RemberSet)
跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用,如下图所示:

YGC时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费。因为跨代引用是极少的,为了找出那么一点点跨代引用,却得遍历整个老年代!
解决方案:记忆集(RemberSet)
记忆集就是用来记录跨代引用的表,通过引入记忆集避免遍历老年代。以YGC为例说明,要回收年轻代,只需要引用年轻代对象的GC ROOT+记忆集,就可以判断出Young区对象是否存活,不必再遍历老年代。
缺点:具有“滞后性”,浪费一定的空间;如下图所示,YGC时实际上对象E可以被回收,但是由于没发生FGC,老年代中的对象D仍存在对对象E的引用,导致E无法被回收。
G1是对每个region维护了一个rset,记忆集中维护了指向自己的region的指针,并且标记指针分别在那些卡页的范围之内。
虚拟机发现对引用数据类型进行写操作时,会产生一个中断,判断引用对象是否处于不同Region之中,如果是,则把相关引用信息记录到被引用对象所属Region的RememberSet之中。
5.7 OopMap
在进行初始标记时,对GC Roots 枚举的过程中,是需要暂停用户线程的,对栈进行扫描,找到哪些地方存储了对象的引用。然而,栈存储的数据不止是对象的引用,因此对整个栈进行全量扫描,显然是很耗费时间,影响性能的。
因此,在 HotSpot 中采取了空间换时间的方法,使用 OopMap 来存储栈上的对象引用的信息。在 GC Roots 枚举时,只需要遍历每个栈桢的 OopMap,通过 OopMap 存储的信息,快捷地找到 GC Roots。

6.方法区的回收
方法区回收价值很低,主要回收废弃的常量和无用的类。
如何判断无用的类:
- 该类所有实例都被回收(Java堆中没有该类的对象)
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方利用反射访问该类
¥7.垃圾收集算法及其特点
- 标记清除算法:标记出所有需要回收的对象,然后清除可回收的对象。效率较低,并且因为在清除后没有重新整理可用的内存空间,如果内存中可被回收的小对象居多,会引起内存碎片化问题。
- 复制算法:将可用内存分为区域1和区域2,将新生成的对象放在区域1,在区域1满后对区域1进行一次标记,将标记后仍然存活的对象复制到区域2,然后清除区域1。效率较高并且易于实现,解决了内存碎片化的问题,缺点是浪费了大量内存,同时在系统中存在长生命周期对象时会在两区域间来回复制影响系统效率。
- 标记整理算法:结合了标记清除算法和复制算法的优点,标记过程和标记清除算法一样,标记后将存活的对象移动到一端,清理另一端。
- 分代收集算法:根据对象不同类型把内存划分为不同区域,把堆划分为新生代和老年代。由于新生代的对象生命周期较短,主要采用复制算法。将新生代划分为一块较大的Eden区和两块较小的Survivor区,Servivor区又分为ServivorTo和ServivorFrom区。JVM在运行过程中主要使用Eden和SurvivorFrom区,进行垃圾回收时将这个两个区域存活的对象复制到SurvivorTo区并清除这两个区域。老年代主要存储长生命周期的大对象,因此采用标记清除或标记整理算法。
7.5 HotSpot 为什么要分为新生代和老年代?
在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
¥8.内存回收与回收策略
- 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
- 大对象直接进入老年代,避免在Eden区和两个Survivor区之间发生大量的内存拷贝。
- 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,年龄达到阀值对象进入老年区。
- 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC。
9.新生代的垃圾回收机制
新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下:
- 把在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区,如果某对象的年龄达到老年代的标准,则将其复制到老年代,同时把这些对象的年龄加1。如果ServivorTo区的内存空间不够,则也直接将其复制到老年代。如果对象属于大对象,则也直接复制到老年代。
- 清空Eden区和ServivorFrom区中的对象。
- 将ServivorFrom区和ServivorTo区互换,原来的ServivorTo区成为下一次GC时的ServivorFrom区。
10.老年代的垃圾回收机制
- 老年代主要存放有长生命周期的对象和大对象,老年代的GC叫MajorGC。
- 在老年代,对象比较稳定,MajorGC不会频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,过后仍然出现老年代空间不足或无法找到足够大的连续内存空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
- MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。因为要先扫描老年代的所有对象再回收,所以MajorGC的时间较长。容易产生内存碎片,在老年代没有内存空间可分配时,会出现内存溢出异常。
10.5 哪些情况会触发FullGC
- System.gc()方法的调用:此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。
- 疏散失败:在进行Young GC或Mixed GC时,G1需要将存活对象从一个Region复制到另一个空闲的Region时堆中没有足够的空闲Region
- 元空间空间不足
- 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间
- 堆中分配很大的对象:所谓大对象,是指需要大量连续内存空间的java对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC。
¥11. G1垃圾回收器
- 目标:尽可能在用户规定的停顿时间内尽可能回收垃圾多的region
- 内存结构:将整个堆划分为多个大小相等的独立区域,称为 Region。每个Region可以动态地扮演Eden区、Survivor区、Old区或Humongous区(专存大对象)。
11.1 G1垃圾回收器的Young GC
触发时机:当前分配的新生代Region(Eden区)耗尽,且应用程序需要分配新对象时,G1就会触发一次Young GC。
执行过程:
- G1通过记录在RSet(Remembered Set,记录其他Region指向本Region的引用,避免全堆扫描)中的信息,找到存活的对象。
- 将Eden区和Survivor区中的存活对象复制到新的Survivor区,或者晋升到Old区Region。
- 清空原来的Eden区Region,供后续分配使用。
特点:这个过程是STW的。G1会根据用户设定的期望停顿时间(-XX:MaxGCPauseMillis,默认200ms),智能选择回收哪些Region,尽量不超时。
11.2 G1垃圾回收器的全局并发标记周期
当老年代占用率达到一定阈值(默认45%,由-XX:InitiatingHeapOccupancyPercent控制)时,G1会触发全局并发标记,为后续的Mixed GC做准备。这个过程分为四个步骤:
- 初始标记(STW):负责标记GC Roots能直接关联到的对象。需要STW,但耗时极短。通常这个阶段会借用一次正在发生的Young GC来完成,没有额外单独的停顿。
- 并发标记(并发):从GC Roots开始对堆中的对象进行可达性分析,找出存活对象。与用户线程并发执行,不需要STW。这个阶段如果发现当前Region中没有任何存活对象,G1会在该阶段就将其回收(这种机制被称为“回收即清理” Live-Range Coalescing)。
- 最终标记(STW):处理并发标记阶段遗留的少量变更记录(由于并发执行,用户线程在标记期间产生了引用变更)。需要短暂的STW,使用SATB(Snapshot-At-The-Beginning)算法保证标记结果的正确性。
- 筛选回收(STW):对各个Region的回收价值(垃圾占比、回收耗时)进行排序。制定回收计划,将那些垃圾最多、回收收益最高的Region(包含老年代和新生代)加入到待回收集合中。
11.3 G1垃圾收集器的Mixed GC(混合回收)
全局并发标记周期结束后,G1并不会立刻全盘清理,而是通过Mixed GC逐步清理老年代。
阶段说明:Mixed GC不仅回收所有的年轻代Region,还会回收部分被标记为“高收益”的老年代Region。
执行过程:与Young GC类似,将存活对象复制到空的Region中。区别在于复制的对象不仅有年轻代的,还有老年代的存活对象。
执行次数:G1会连续进行多次Mixed GC,直到老年代的内存占用率降到安全水位以下,或者待回收的老年代Region被清理完毕。这就是G1被称为“Garbage-First”的原因:优先收集垃圾最多的Region。
11.4 G1垃圾收集器的 Full GC(退化/兜底机制)
在正常情况下,G1通过Mixed GC就能完成内存清理。但如果出现异常情况,G1会退化为传统的Serial单线程收集器进行Full GC,性能极差,需要极力避免。
触发条件:
- 在Mixed GC或Young GC时,由于存活对象过多,导致没有足够的空Region来存放复制的存活对象(发生Evacuation Failure疏散失败)。
- 巨型对象分配失败(Humongous Allocation Failure),无法在堆中找到连续的空闲Region存放巨型对象。
执行过程:停止所有用户线程,使用单线程对整个堆(新生代、老年代、元空间等)进行全面的标记、清理和压缩。此过程耗时极长,会导致应用长时间卡顿。
11.5 G1垃圾收集器如何确定每次回收哪些Region
- 计算回收价值: 在并发标记的最终标记阶段结束后,G1会统计数据,计算出每个Region的“回收价值”。回收价值通常可以理解为:(Region的大小 - 存活对象大小) / 预计回收耗时。简单来说,就是垃圾越多、存活对象越少、回收越快的Region,价值越高。
- 构建回收集合: G1会将所有Region按照回收价值从高到低进行排序,形成一个优先级列表。G1的“Garbage-First”之名正是来源于此:它总是优先去回收那些垃圾最多的Region。
- 记录历史耗时数据: 在每次GC执行的过程中,G1会详细记录各种操作的耗时统计数据,主要包括:
- 复制存活对象的耗时:G1会记录平均复制每个MB的存活对象需要多少时间。
- 预测与筛选机制: 当G1准备触发Mixed GC(混合回收)或Young GC时,它会根据上述历史数据来“估算”回收列表中每个Region需要花费的时间。
- G1从按价值排序的Region列表中,从高到低依次选取Region加入本次的“回收集合”。每加入一个Region,G1就会把预测的回收该Region的时间累加起来。一旦累加的预测时间即将达到或超过用户设定的期望停顿时间(如200ms),G1就会停止继续添加新的Region到回收集合中。
11.7 Safe Point
然而,在程序执行的过程中,对象之间的引用关系随时都会发生改变,这意味着对应的 OopMap 需要同步进行更新。如果每一条指令的执行,都生成(或更新)对应的OopMap,那么将会占用大量的内存空间,增加了 GC 的空间成本。
因此,针对这个问题,JVM 引入了 Safe Point 的概念,只有在 Safe Point 才会生成(或更新)对应的 OopMap。
Safe Point 就是一个安全点,可以理解为用户线程执行过程中的一些特殊位置。线程执行到 Safe Point 的时候,OopMap 保存了当前线程的上下文,当线程执行到这些位置的时候,说明线程当前的状态是确定的,线程有哪些对象、使用了哪些内存。
适合放置 Safe Point的地方:
- 所有的非计数循环的末尾 (防止循环体的执行时间太长,一直进入不了 Safe Point)
- 所有方法返回之前
- 每条 Java 编译后的字节码的边界
当所有线程都到达Safe Point,有两种方法中断线程:
- 抢占式中断(Preemptive Suspension) JVM会中断所有线程,然后依次检查每个线程中断的位置是否为Safe
Point,如果不是则恢复用户线程,让它执行至 Safe Point 再阻塞。- 主动式中断(Voluntary Suspension) 大部分 JVM
实现都是采用主动式中断,需要阻塞用户线程的时候,首先做一个标志,用户线程会主动轮询这个标志位,如果标志位处于就绪状态,就自行中断。
11.8 Safe Region
然而,实际情况中 Safe Point 仍然存在缺陷,例如:线程处于 Sleep 状态或者 Blocked 状态,那么线程就无法达到 Safe Point。
因此,针对这个问题,JVM 引入了 Safe Region 的概念。Safe Region 是一片区域,在这个区域的代码片段,引用关系不会发生变化,因此,在 Safe Region 中任意地方开始垃圾收集都是安全的。
可以理解为 Safe Region 就是 Safe Point 的扩展,点动成线。
线程执行到 Safe Region 时,首先标记线程已经进入 Safe Region,当线程将要离开 Safe Region 时,线程需要检查 JVM 是否已经完成 GC Roots 枚举。如果尚未完成,则需要一直等待,直到 GC Roots 枚举完成。
参考文献:https://zhuanlan.zhihu.com/p/441867302
12. 有什么办法可以主动通知虚拟机进行垃圾回收呢?
程序可以使用system.gc回收,但是回不回收看jvm
13.finalize()方法什么时候被调用?析构函数(finalization)的目的是什么?
调用时机:当垃圾回收器要宣告一个对象死亡时,至少要经过两次标记过程:如果对象在进行可达性分析后发现没有和GC Roots相连接的引用链,就会被第一次标记,并且判断是否执行finalizer( )方法,如果对象覆盖finalizer( )方法且未被虚拟机调用过,那么这个对象会被放置在F-Queue队列中,并在稍后由一个虚拟机自动建立的低优先级的Finalizer线程区执行触发finalizer( )方法,但不承诺等待其运行结束。
finalization的目的:对象逃脱死亡的最后一次机会。(只要重新与引用链上的任何一个对象建立关联即可。)但是不建议使用,运行代价高昂,不确定性大,且无法保证各个对象的调用顺序。可用try-finally或其他替代。
14.如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?
不会立即释放对象占用的内存。 如果对象的引用被置为null,只是断开了当前线程栈帧中对该对象的引用关系,而 垃圾收集器是运行在后台的线程,只有当用户线程运行到安全点(safe point)或者安全区域才会扫描对象引用关系,扫描到对象没有被引用则会标记对象,这时候仍然不会立即释放该对象内存,因为有些对象是可恢复的(在 finalize方法中恢复引用 )。只有确定了对象无法恢复引用的时候才会清除对象内存。
¥15. java中会存在内存泄漏吗,如何排查。
java中内存泄露只被该被回收的对象没有被正确回收
内存泄漏的场景:
- 静态集合类内部对象没被及时释放
- 外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持久外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
- 当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。
排查:jmap获得堆转储快照,再利用快照分析工具进行分析(如Visual VM)
¥16Java中4种引用类型?
- 强引用,最常见的引用类型,把一个对象指向一个引用变量时就是强引用。强引用的对象一定为可达性状态,所以不会被垃圾回收,是内存泄漏的主要原因。
- 软引用,通过SoftReference实现,如果一个对象只有软引用,当内存空间不足时将被回收。软引用可用来实现内存敏感的高速缓存。
- 弱引用,通过WeakReference实现,如果一个对象只有弱引用,在垃圾回收过程中一定会被回收。
- 虚引用,通过PhantomReference实现,虚引用和引用队列联合使用,主要用来跟踪对象的垃圾回收过程。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
16.5 虚引用与软引用和弱引用的区别
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
17.永久代与元空间
- 永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。
- 永久代和老年代、新生代不同,GC不会在程序运行期间对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会出现内存溢出异常,比如Tomcat引用jar文件过多导致JVM内存不足而无法启动。
- 在JDK1.8中,永久代已经被元数据区取代。元数据区的作用和永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。因此元空间的大小不受JVM内存的限制,只和操作系统的内存有关。
- 在JDK1.8中,JVM将类的元数据放入本地内存中,将常量池和类的静态常量放入Java堆中,这样JVM能够加载多少元数据信息就不再由JVM的最大可用内存空间决定,而由操作系统的实际可用内存空间决定。
17.5 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小。如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
-
元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
-
在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
17.6 JVM调优策略
- 根据需求选择合适的垃圾收集器
- 调整新生代和老年代的比值,将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。
- 合理调整Survivor区和Eden区的比值,避免Survivor区不够用。-XX:SurvivorRatio(幸存代)— 设置两个Survivor区和eden的比值
- 大对象进入老年代,大对象如果首次在新生代分配可能会出现空间不足导致很多年龄不够的小对象被分配的老年代,破坏新生代的对象结构,可能会出现频繁的 full gc。因此,对于大对象,可以设置直接进入老年代。-XX:PretenureSizeThreshold 可以设置直接进入老年代的对象大小。
- 合理设置进入老年代对象的年龄,-XX:MaxTenuringThreshold 设置对象进入老年代的年龄大小,减少老年代的内存占用,降低 full gc 发生的频率。
- 设置稳定的堆大小,堆大小设置有两个参数:-Xms 初始化堆大小,-Xmx 最大堆大小。开发过程中,通常会将 -Xms 与 -Xmx两个参数配置成相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。
- 合理设置栈大小,避免栈空间不足溢出。可以通过-Xss:调整每个线程栈空间的大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右
二. 执行子系统
¥18.Java类的加载过程(Java类的生命周期)
加载、连接(验证,准备,解析)、初始化、使用和卸载
- 加载,查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。
- 连接, 1)验证,文件格式、元数据、字节码、符号引用验证; 2)准备,为类的静态变量分配内存,并将其初始化为默认值; 3)解析,把类中的符号引用转换为直接引用
- 初始化,为类的静态变量赋予正确的初始值
- 使用,new出对象程序中使用
- 卸载,执行垃圾回收
19.哪些情况下类会初始化?哪些情况不会
初始化场景:①创建类的实例。②访问某个类或接口的静态变量,或对该静态变量赋值。③调用类的静态方法。④初始化一个类的子类时(初始化子类,父类必须先初始化)。⑤JVM启动时被标为启动类的类。⑥MethodHandle
不会初始化场景:除了上述都不会发生初始化,如①常量在编译时会存放在使用该常量的类的常量池,该过程不要调用常量所在的类,不会初始化。②子类引用父类的静态变量时,子类不会初始化,只有父类会初始化。③定义对象数组,不会触发该类的初始化。④在使用类名获取Class对象时不会触发类的初始化。⑤在使用Class.forName()加载指定的类时,可以通过initialize参数设置是否需要初始化。⑥在使用ClassLoader默认的loadClass方法加载类时不会触发该类的初始化。
¥20.类加载器
- 启动类加载器:Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库
- 扩展类加载器:Extension ClassLoader,它负责加载DK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库
- 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器
¥21.双亲委派机制,好处是什么
1)顶层的启动类加载器外其余的类加载器都应当有自己的父类加载器。一个类收到类加载请求后会层层找父类加载器去尝试加载,因此所有的加载请求最终都会被传送到顶层的启动类加载器,只有当父类加载器反馈自己无法完成加载时子加载器才会尝试自己去加载。
2)双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。
22. 如何自定义类加载器,如何打破双亲委派模型
- 自定义加载器的话,需要继承 ClassLoader 。
- 如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
- 但是,如果想打破双亲委派模型则需要重写 loadClass() 方法
双亲委派的源码,实际就是递归调用父类加载器去加载,加载不了再调用findClass找到类
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 同步上锁
synchronized (getClassLoadingLock(name)) {
// 先查看这个类是不是已经加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 递归,双亲委派的实现,先获取父类加载器,不为空则交给父类加载器
if (parent != null) {
c = parent.loadClass(name, false);
// 前面提到,bootstrap classloader的类加载器为null,通过find方法来获得
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
// 如果还是没有获得该类,调用findClass找到类
long t1 = System.nanoTime();
c = findClass(name);
// jvm统计
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
// 连接类
if (resolve) {
resolveClass(c);
}
return c;
}
}
23. 打破双亲委派的场景
- 双亲委派出现之前,ClassLoader只有loadClass() 方法方法,之后才加的findClass()方法
- JNDI服务。父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。
具体实现:通过线程上下文类加载器去进行类的加载(可以通过Thread类的setContextClassLoader()去设置,如果没有设置则默认为应用程序类加载器)。
- 代码热替换。每一个程序模块(Bundle)都有自己的类加载器,每当需要更换,就把Bundle连同类加载器一起换掉以实现代码的热替换。
24. JVM方法内联
就是把调用方函数代码"复制"到调用方函数中,如:
private int add2(int x1 , int x2 , int x3 , int x4) {
return add1(x1 , x2) + add1(x3,x4);
}
private int add1(int x1 , int x2) {
return x1 + x2;
}
经内联后:
private int add2(int x1 , int x2 , int x3 , int x4) {
//return add1(x1 , x2) + add1(x3,x4);
return x1 + x2 + x3 + x4;
}
JVM会自动的识别热点方法,并对它们使用方法内联优化。那么一段代码需要执行多少次才会触发JIT优化呢?通常这个值由-XX:CompileThreshold参数进行设置:
- 使用client编译器时,默认为1500;
- 使用server编译器时,默认为10000;
但是一个方法就算被JVM标注成为热点方法,JVM仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况:
- 如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联(可以通过 -XX:MaxFreqInlineSize=N来设置这个大小)
- 如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联(可以通过 -XX:MaxInlineSize=N 来设置这个大小)
我们可以通过增加这个大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。
三、问题排查
25、如何排查服务器中Java应用CPU飙高
当Java应用启动时CPU使用率很高,一段时间后下跌并恢复稳定,这通常是因为在启动阶段进行了大量的初始化工作,如类加载、JIT编译、数据库连接等。
具体的排查步骤:
- top 命令查询CPU占用率高的Java应用,获取应用的PID
- top -Hp {PID} 命令查看进程下的线程,获取CPU消耗高的线程pid
- jstack 跟踪线程的调用栈, jstack PID|grep 线程id,发现线程就一直处于 java.lang.Thread.State: RUNNABLE 执行状态,根据堆栈信息查询具体的代码
jstack 介绍
jstack 是 JDK 提供的一个命令行工具,用于生成 Java 进程的线程快照。
线程快照包含了 Java 进程中所有线程的状态信息,如线程的名称、线程的状态(RUNNABLE、WAITING、BLOCKED 等)以及线程的调用栈。
通过分析 jstack 生成的线程快照,可以帮助您诊断诸如死锁、线程阻塞、CPU 使用率过高等与线程相关的问题。

1466

被折叠的 条评论
为什么被折叠?



