对象一定是在堆中创建的吗?

image-20250902214557872

逃逸

在方法中创建对象之后,如果这个对象除了在方法体中还在其它地方被引用了,此时如果方法执行完毕,由于该对象有被引用,所以 GC 有可能是无法立即回收的,此时便成为 内存逃逸现象

逃逸状态:

一个对象有三种逃逸状态:

  1. 全局逃逸(GlobalEscape):即一个对象的作用范围逃出了当前方法或者当前线程,

    一般有以下几种场景:
    ① 对象是一个静态变量
    ② 对象是一个已经发生逃逸的对象
    ③ 对象作为当前方法的返回值

  2. 参数逃逸(ArgEscape):即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。

  3. 没有逃逸:即方法中的对象没有发生逃逸。

public class EscapeAnalysisTest {

public static Object globalVariableObject;

public Object instanceObject;

public void globalVariableEscape(){
globalVariableObject = new Object(); // 静态变量,外部线程可见,发生逃逸
}

public void instanceObjectEscape(){
instanceObject = new Object(); // 赋值给堆中实例字段,外部线程可见,发生逃逸
}

public Object returnObjectEscape(){
return new Object(); // 返回实例,外部线程可见,发生逃逸
}

public void noEscape(){
Object noEscape = new Object(); // 仅创建线程可见,对象无逃逸
}

}

标量替换

标量可以理解成一种不可分解的变量,如java内部的基本数据类型、引用类型等。 与之对应的聚合量是可以被拆解的,如对象。

当通过逃逸分析一个对象只会作用于方法内部,虚拟机可以通过使用标量替换来进行优化。

public String noEscape(){
Person person = new Person(26, "TomCoding noEscape");
return person.name;
}

比如上述noEscape()方法中person对象只会在方法内部,通过标量替换技术得到如下伪码:

public String noEscape(){
int age = 26;
String name = "TomCoding noEscape";
return name;
}

可以看到运行上面伪码之后,将不再从堆中分配内存创建Person对象,而是将成员变量分解作为栈帧中的局部变量。这样随着方法调用结束,栈帧也会销毁。有效的减少了堆中创建对象及gc次数

逃逸分析的优势

  • 同步消除(锁消除)

因为同步锁是非常消耗性能的,所以当编译器确定一个对象没有发生逃逸时,它便会移除该对象的同步锁。

  • 标量替换

首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。

  • 栈内存分配

栈内存分配很好理解,在上文中提过,就是将原本分配在堆内存上的对象转而分配在栈内存上,这样就可以减少堆内存的占用,从而减少 GC 的频次。

什么是程序计数器?

程序计数器是每个线程私有的(线程安全的),每个线程一份,内部保存的字节码的行号,用于记录正在执行的字节码指令的地址。

详细介绍Java堆

堆是线程共享的区域(线程不安全的):主要用来保存对象实例,数组等,内存不够则抛出OOM(内存溢出)异常。

java8及之后堆的组成是年轻代 + 老年代

  • 年轻代被划为三部分,Eden区和两个大小严格相同的Survivor区(幸存者区)
  • 老年代主要保存生命周期长的对象,一般是一些老的对象

根据JVM的策略,在经过几次垃圾收集后,任然存活于幸存者区的对象将被移动到老年代区

JDK1.7和JDK1.8的区别

  • 1.7中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
  • 1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出

以下为JVM内存结构图:

虚拟机栈

什么是虚拟机栈?

  • 每个线程运行时所需要的内存(线程安全的),称为虚拟机栈,先进后出

  • 每个栈由多个栈帧(frame)组成,对应每次方法调用时所占用的内存

  • 每个线程只能有一个活动栈,对应着当前正在执行的那个方法

    例如,此时线程调用方法一,那么此时活动栈就是栈一,接着在方法一中调用了方法二,此时栈二压栈,活动栈就变成了栈二

image-20250906165004278

垃圾回收是否涉及栈内存?

垃圾回收的对象主要指堆内存,而栈内存在栈帧弹出以后就会释放。

栈内存分配的越打越好吗?

并不是。默认的栈内存为1024k(1m),栈内存过大会导致线程数变少。例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半。

方法内的局部变量是否线程安全?

  • 如果方法内局部变量没有逃离方法的作用范围,他是线程安全的
  • 如果局部变量引用了对象,并逃离方法的作用范围,则需要考虑线程安全

image-20250906165622884

什么情况可能会导致栈内存溢出?

  • 栈帧过多:例如递归调用

image-20250906165807896

  • 栈帧过大

堆栈的区别是什么?

  • 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的。堆会GC垃圾回收,而栈不会。

  • 栈内存是线程私有的,而堆内存是线程共有的

  • 两者异常错误不同:

    栈空间不足:StackOverFlowError

    堆空间不足:OutOfMemoryError

介绍一下方法区(元空间)

  • 方法区是各个线程共享的内存区域
  • 主要存储类的信息、运行时常量池
  • 虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区中的内存无法满足分配需求,则会抛出OutOfMemoryError: Metaspace,有时也会提醒你方法区空间太小。

image-20250906171531789

image-20250906171603118

介绍一下运行时常量池

常量池

可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。

image-20250906171832087

运行时常量池

常量池是*.class文件中的,当该类被加载,它的常量信息就会被放入运行时常量池,并把里面的符号地址变为真实地址

image-20250906172030502

你听过直接内存吗?

Java文件拷贝

image-20250906173235417

常规IO的数据拷贝流程

image-20250906173316257

由于Java无法直接读取系统缓存区的数据,所以要先将数据拷贝一份到java缓冲区中,正是因为这种不必要的拷贝操作导致常规IO执行效率低。

NIO数据拷贝流程

image-20250906173544379

Java可以直接读取直接内存,执行效率大大提高。

总结:

  • 直接内存并不属于JVM中的内存结构,不由JVM进行管理,是虚拟机的系统内存。(也就是操作系统内存)
  • 常见于NIO操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受JVM内存回收管理

什么是类加载器、双亲委派机制?

类加载器

image-20250906180740358

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

类加载器种类

启动类加载器(加载对应目录下的类)、扩展类加载器(加载对应目录下的类)、应用类加载器(加载用户自己编写的Java类)、自定义加载器。

image-20250906181057344

双亲委派模型

加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。

image-20250906181417521

例如现在有一个用户编写的Student类,先在AppClassLoader加载,发现有上一级就先委托给上级类加载器加载,然后ExtClassLoader发现有上一级,就继续向上委派给BootStrapClassLoder,此时已经到顶开始正式加载,此时BootStrapClassLoder无法加载,ExtClassLoader开始尝试加载,发现也无法加载,最终回到AppClassLoader加载。

JVM为什么采用双亲委派机制?

  • 通过双亲委派机制可以避免同一个类被反复加载,当父类已经加载后则无需重复加载,保证唯一性。
  • 为了安全,保证类库API不会被修改

​ 例如:

image-20250906182116927

此时执行main函数会报错,在java.lang.String中找不到main方法。

由于双亲委派机制,java.lang.String在启动类加载器得到加载,但在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。

类装载的执行过程(重要)

image-20250906183637999

类装载过程分为以上7个阶段,其中验证、准备和解析这三个部分统称为链接(linking)。

加载

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型) (其实就是将类的信息存入到方法区)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

image-20250906184303141

验证

验证类是否符合JVM规范,安全性检查。

  • 文件格式验证

  • 元数据验证

  • 字节码验证

    以上这三点都是格式检查,例如:文件格式是否错误、语法是否错误、字节码是否错误。

  • 符号引用验证——Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法,检查他们是否存在

image-20250906184754566

上图“=”后面的就是所说的字符串。

准备

类变量(静态变量)分配内存并设置类变量初始值。

  • static变量,分配空间在准备阶段完成(设置默认值),赋值在初始阶段完成
  • static变量是final的基本类型以及字符串常量,值已确定,赋值在准备阶段完成
  • static变量是final的引用类型,那么赋值也会在初始化阶段完成

解析

把类中的符号引用转换为直接引用

例如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。

image-20250906195432931

image-20250906195448269

“#1”、“#2”等就可以看作是符号引用,而通过符号引用找到的java/io/PrintStreamprintln就是直接引用。

初始化

对类的静态变量、静态代码块执行初始化操作。

  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类
  • 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行

注意:子类访问父类静态变量,只触发父类初始化

使用

JVM开始从入口方法开始执行用户的程序代码

  • 调用静态类成员信息(比如:静态字段、静态方法)
  • 使用new关键字为其创建对象实例

卸载

当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象。

总结

  • 加载:查找和导入class文件
  • 验证:保证加载类的准确性
  • 准备:为类变量分配内存并设置类变量初始值
  • 解析:把类中的符号引用转换为直接引用
  • 初始化:对类的静态变量、静态代码块执行初始化操作
  • 使用:JVM开始从入口方法开始执行用户的程序代码
  • 卸载:当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象

对象什么时候可以被垃圾回收?

如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收。

定位垃圾的方式有两种:

  • 引用计数法(对象间循环引用会导致内存泄漏)

    image-20250907115115427

  • 可达性分析算法

image-20250907115228756

​ 扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到就表示可以回收。(上图中的X,Y两个节点是可以回收的)

哪些对象可以作为GC Root?

  • 虚拟机栈中引用的对象

    image-20250907115613415

  • 方法区中类静态属性引用的对象

    image-20250907115623467

  • 方法区中常量引用的对象

    image-20250907115636949

  • 本地方法中JNI(即Native方法)引用的对象

JVM垃圾回收算法有哪些?

  • 标记清除算法:垃圾回收分为两个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续。

    image-20250907120826906

  • 标记整理算法:和标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低。(一般老年代使用)

    image-20250907120843688

  • 复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾回收。无碎片,内存使用率低。(一般年轻代使用)

    image-20250907120854516

说一下JVM中的分代回收

一、堆的区域划分

  • 堆被分为了两份:新生代和老年代【1:2】

  • 对于新生代,内部又被分为了三个区域,Eden,幸存者区survivor(分成from和to)【8:1:1】

    image-20250907122715517

二、对象回收分代回收策略

  • 新创建的对象都会先分配到Eden区
  • 当Eden区内存不足,标记Eden与from的存活对象
  • 将存活对象采用复制算法复制到to中,复制完毕后,Eden和from内存都得到释放
  • 经过一段时间后Eden的内存又出现不足,标记Eden区和to区存活对象,将其复制到from区
  • 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存者区内存不足或者大对象会提前晋升)

MinorGC、MixedGC、FullGC的区别是什么?

  • MinorGC(youngGC)发生在新生代的垃圾回收、暂停时间(STW)短
  • MixedGC:新生代 + 老年代部分区域的垃圾回收,G1收集器持有
  • FullGC:新生代 + 老年代完整垃圾回收,暂停时间(STW)长,应尽力避免

STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收完成

说一下JVM有哪些垃圾回收器?

  • 串行垃圾收集器:Serial GC、Serial Old GC

    image-20250907125233051

  • 并行垃圾收集器:Parallel Old GC、ParNew GC(JDK8默认使用此垃圾回收器)

    image-20250907125250649

  • CMS(并发)垃圾收集器:CMS GC,作用在老年代

    image-20250907125305748

  • G1垃圾收集器,作用在新生代和老年代(JDK9之后默认使用G1)

详细聊一下G1垃圾回收器

  • 应用于新生代和老年代,在JDK9之后默认使用
  • 划分为多个区域,每个区域都可以充当Eden、Survivor、old、humongous,其中humongous专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段:新生代回收(stw)、并发标记(重新标记要stw)、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象的速度)会触发Full GC

Java内存泄漏的排查思路

内存泄漏通常是指堆内存,通常是指一些大对象不被回收的情况

  • 通过jmap或设置jvm参数获取堆内存快照dump
  • 通过工具VisualVM去分析dump分析,VisualVM可以加载离弦的dump文件
  • 通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
  • 找到对应的代码,通过阅读上下文的情况,进行修复即可