JVM复习笔记(上)

JVM

一、基础概念

1.1 什么是JVM?

java文件--(编译)-->二进制的.class文件--(JVM解释)-->机器码指令。这也就是java半编译半解释的原因

JVM,也就是 Java 虚拟机,它是 Java 实现跨平台的基石。

程序运行之前,需要先通过编译器将 Java 源代码文件编译成 Java 字节码文件;

程序运行时,JVM 会对字节码文件进行逐行解释,翻译成机器码指令,并交给对应的操作系统去执行。

这样就实现了 Java 一次编译,处处运行的特性。

(1)如果我们要执行hello world,那虚拟机干了什么呢?谁把字节码翻译成机器码,操作时机是什么?Java虚拟机是一个执行单元吗?

当我们写好一个 HelloWorld 程序,编译成 .class 文件后,执行 java HelloWorld 这条命令时,**操作系统会创建一个 JVM 进程,JVM 进程启动起来后,会先初始化运行环境,包括创建类加载器、初始化内存空间、启动垃圾回收线程等等。**接下来,JVM 的类加载器会去找 HelloWorld.class 这个文件,读取它的字节码内容。

类加载器去读.class字节码内容,JVM执行引擎将字节码翻译成机器码

JVM 的执行引擎会将这些字节码翻译成机器码,有两种方式:

  • 第一种解释执行,JVM解释器逐行读取字节码指令,然后把它翻译成机器码执行。这个过程是动态的,每次执行都要翻译一遍。所以解释执行的速度相对较慢。
  • 第二种即时编译JIT ,类似缓存的设计思想,他发现某段代码被频繁执行 (比如循环里的代码或者热点方法hotspot)时,JIT 编译器就会把这段字节码直接编译成本地机器码并缓存到 codeCache 中,下次执行的时候不用再一行一行的解释,而是直接执行缓存后的机器码,执行效率会大幅提高。

执行实际,解释执行是立即的,当JVM启动就开始对字节码进行解释。

JIT则是比较延后的因为要先监控判断那些代码是热点代码,然后才启动编译。

在操作系统层面,进程是程序执行的最小单位,因此JVM进程是一个执行单元,由操作系统调度执行。

(2)说说 JVM 的其他特性?

JVM还有3大特性。自动内存管理、JIT、JVM可以运行任何可以通过java编译的语言。

  1. JVM 可以自动管理内存,通过垃圾回收器回收不再使用的对象并释放内存空间,不会像c使用molloc这些。
  2. JVM 包含一个即时编译器 JIT,它可以在运行时将热点代码缓存到 codeCache 中,下次执行的时候不用再一行一行的解释,而是直接执行缓存后的机器码,执行效率会大幅提高。

3.任何可以通过 Java 编译的语言,比如说 Groovy、Kotlin、Scala 等,都可以在 JVM 上运行。

(3)为什么要学习 JVM?

更好的学习Java,理解JVM底层原理,帮助我们优化程序,提升程序的性能,优化内存分配。

1.更好地优化程序性能、避免内存问题。

2.了解 JVM 的内存模型和垃圾回收机制,可以帮助我们更合理地配置内存、减少 GC 停顿

3.掌握 JVM 的类加载机制可以帮助我们排查类加载冲突或异常

4.JVM 还提供了很多调试和监控工具,可以帮助我们分析内存和线程的使用情况,从而解决内存溢出内存泄露等问题

1.2 说说JVM的架构

JVM大致分为3部分:类加载器、运行时数据区、字节码执行引擎。

  • 类加载器 ,负责加载这个.class,负责从文件系统、网络或其他来源加载 Class 文件,将 Class 文件中的二进制数据读入到内存当中。ClassLoader

  • 运行时数据区,分为方法区、堆区、虚拟机栈区、本地方法区、程序计数器,JVM执行Java程序时,从内存中分配空间处理各种数据。

  • 字节码执行引擎,包含虚拟处理器解释器、JIT编译器、垃圾回收器。也是 JVM 的心脏,负责执行字节码。

(1)JVM是什么东西

JVM本质是一个应用程序,一个进程。进程中运行着虚拟机 ,当我们执行 java -jar application.jar 命令时,操作系统会创建一个名为 JVM 的进程。这个进程在内存里运行着一个虚拟机,这个虚拟机有自己的指令集、内存模型、执行引擎等等。

(2)Java虚拟机和操作系统的关系到底什么,假如我是个完全不懂技术的人,举例说明让我明白

JVM在操作系统上面运行这的,Java虚拟机(JVM)像是一个中间层,它向下讨好操作系统(找管家要资源),向上服务Java程序(让菜谱在任何地方都能跑通)。

  • JVM在操作系统中之上
  • JVM屏蔽了操作系统的复杂性,也是Java可以跨平台运行的原因
  • 操作系统是JVM的上司,给JVM提供资源。
  • JVM 是运行在操作系统上的一个应用程序。JVM 本身是一个进程,也就是说,从操作系统的角度看,JVM 就是一个普通的应用程序,并没有什么特别的。操作系统不知道也不关心 JVM 里面运行的是什么,它只是按照进程管理的规则来管理 JVM 这个进程。

示例:

你想写一套**"做红烧肉的秘籍"(这就是Java程序**),希望这套秘籍在全世界的厨房里都能用。

  • 操作系统(OS)是"厨房管家":

    每个国家的厨房规则不一样。美国厨房(Windows)用的是煤气灶,开关是旋钮的;英国厨房(Mac)用的是电磁炉,开关是触摸的;德国厨房(Linux)可能用的是老式柴火灶。

    作为外行,你直接去用这些灶台,可能连火都打不着。这时候,每个厨房都有一个管家,他控制着火的大小、水的供应和锅的使用权。

  • Java虚拟机(JVM)是"随身翻译官":

    你拿着你的"红烧肉秘籍"来到美国厨房。这时候,你身边带了一个**"懂美国厨房的翻译官"**(Windows版的JVM)。

    你对翻译官说:"秘籍上第一步是开火。"

    翻译官转头用英语告诉美国管家:"请拧开左边第二个旋钮。"

第二天你去了德国厨房。你带了另一个**"懂德国厨房的翻译官"**(Linux版的JVM)。

你还是对翻译官说:"秘籍上第一步是开火。"

翻译官转头用德语告诉德国管家:"请往灶台里加两块木柴并点火。"

(3)一个操作系统有两个Java程序的话,有几个虚拟机?有没有单独的JVM进程存在?启动一个hello world编译的时候,有几个进程

  • 一个Java程序独占一个JVM进程,每一个 Java 程序都需要一个独立的 JVM 进程来运行,两个 Java 程序就需要两个 JVM。这两个 JVM 进程是完全独立的,互不干扰。

  • **如果只是编译,不运行,至少有两个进程。**一个是 javac 的编译器进程。当执行 javac HelloWorld.java 时,操作系统会创建一个 javac 进程,这个进程会读取 .java 源文件,生成 .class 字节码文件。完成后 javac 进程就退出了。另一个是当前的 shell 进程,运行 javac 进程的终端,这是一直存在的。(javac编译进程+shell运行javac进程),要运行就加上JVM进程

(4)JVM什么时候启动 比如执行一条Java命令的时候对应一个进程,然后这个JVM虚拟机到底是不是在这个进程里面,还是说要先启动一个JVM虚拟机的进程

没有先启动JVM的说法,就OS创建JVM进程之后紧接着就开始运行Java程序。

二、内存管理

2.1 能说一下 JVM 的内存区域吗?【*】

就是讲运行时数据区,JVM内存区主要分成线程共享的堆区、方法区,然后线程独享的虚拟机栈区、本地方法区、程序计数器

其中方法区是线程共享的,虚拟机栈本地方法栈程序计数器是线程私有的。

(1)介绍一下程序计数器?

就是用来指示执行到哪里了,计算机组成原理也有,指示当前执行的字节码行号,程序计数器也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码行号指示器。

(2)介绍一下 Java 虚拟机栈?

生命周期:Java 虚拟机栈的生命周期和线程的周期是相同的。

栈帧 :栈帧就是java程序中的方法,当线程执行一个方法时,会创建一个对应的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,然后栈帧会被压入虚拟机栈中。当方法执行完毕后,栈帧会从虚拟机栈中移除。

(3)一个什么都没有的空方法,空的参数都没有,那局部变量表里有没有变量?

  • 对于静态方法:局部变量表不会有变量,因此在局部变量表中不会有任何变量。
  • 对于非静态方法:局部变量表有1个变量,局部变量表中也会有一个用于存储 this 引用的变量。this 引用指向当前实例对象,在方法调用时被隐式传入。

示例:

比如说有这样一段代码:

复制代码
public class VarDemo1 {
public void emptyMethod() {
  // 什么都没有
}

public static void staticEmptyMethod() {
  // 什么都没有
}
}

javap -v VarDemo1 命令查看编译后的字节码,就可以在 emptyMethod 中看到这样的内容:

这里的 locals=1 表示局部变量表有一个变量,即 this,Slot 0 位置存储了 this 引用。

而在静态方法 staticEmptyMethod 中,你会看到这样的内容:


这里的 locals=0 表示局部变量表为空,因为静态方法属于类级别方法,不需要 this 引用,也就没有局部变量。

(4)介绍一下本地方法栈?

本地方法栈和虚拟机栈是类似的,虚拟机栈是为了JVM执行java编写方法而进行服务,先进后出栈帧的,递归方法STackoverflow,本地方法栈就是服务于Java带哦用native方法的时候,native方法主要由c/c++来编写。

在本地方法栈中,主要存放了 native 方法的局部变量、动态链接和方法出口等信息。当一个 Java 程序调用一个 native 方法时,JVM 会切换到本地方法栈来执行这个方法。 比如System.copyArray,一般方法比较高效。

(5)介绍一下本地方法栈的运行场景?

本地方法栈运行也就上java调用native方法的时候。当 Java 应用需要与操作系统底层或硬件交互时,通常会用到本地方法栈。

比如调用操作系统的特定功能,如内存管理、文件操作、系统时间、系统调用等。

细说明一下:

比如说获取系统时间的 System.currentTimeMillis() 方法就是调用本地方法,来获取操作系统当前时间的。


还有就是JVM自身的一些底层功能:Object类hashCode()、clone()

(5)native 方法解释一下?

通过关键字native进行声明的,用于调用非java语言,比如。可用通过JNI进行交互,java native interfere与底层系统、硬件设备进行交互。

native 方法是在 Java 中通过 native 关键字声明的,用于调用非 Java 语言,如 C/C++ 编写的代码。Java 可以通过 JNI,也就是 Java Native Interface 与底层系统、硬件设备、或者本地库进行交互。

(6)介绍一下 Java 堆?

堆区是现场共享的区域,堆用来对象等数据的存储,堆管存储,栈管运行,存储new出来的对象。

Java 中"几乎"所有的对象都会在堆中分配,堆也是垃圾收集器管理的目标区域。

GC角度:从内存回收的角度来看,由于垃圾收集器大部分都是基于分代收集理论设计 的,所以堆又被细分为新生代老年代Eden空间From Survivor空间To Survivor空间等。

随着 JIT 编译器的发展和逃逸技术的逐渐成熟,"所有的对象都会分配到堆上"就不再那么绝对了。

从 JDK 7 开始,JVM 默认开启了逃逸分析,意味着如果某些方法中的对象引用没有被返回或者没有在方法体外使用,也就是未逃逸出去,那么对象可以直接在栈上分配内存。

(7)堆和栈的区别是什么?

  • 堆区:堆区主要存储new出来的对象,字符常量池等,是线程共享的区域,他的生命周期不是由单个方法决定的,在一个方法调用之后依然存在,只有当没有变量对其进行引用的时候,最后区域被垃圾回收。
  • 栈区:栈区是进程私有的区域,主要存储局部变量、对象引用、方法参数,生命周期就是随着方法的调用而结束,不需要垃圾回收

(8)介绍一下方法区?

方法区不真实存在,是JVM虚拟机的逻辑概念,功能就是主要用于存储JVM加载的类信息、常量、静态变量、JIT编译的代码缓存。

在 HotSpot 虚拟机中,方法区的实现称为永久代 PermGen,但在 Java 8 及之后的版本中,已经被元空间 Metaspace 所替代。

HotSpot与JVM什么关系?

JVM 是一个"标准"或"规范",而 HotSpot 是这个标准下最流行的一个"具体产品"。

如果面试官问你:"JVM 和 HotSpot 什么关系?"

你按照这个逻辑说,绝对加分:

  1. 分清定义:JVM 是 Java 虚拟机规范,它定义了 Java 程序如何运行的准则;而 HotSpot 是该规范的一个具体实现。
  2. 强调地位:HotSpot 是目前 Oracle JDK 和 OpenJDK 中默认的虚拟机,也是生产环境应用最广泛的虚拟机。
  3. 点出核心技术 :HotSpot 之所以叫 HotSpot,是因为它通过热点代码探测 技术,配合 JIT(即时编译),能够将高频执行的代码编译成底层机器码,极大地提高了运行效率。
  4. 展示横向视野:除了 HotSpot,业界还有 IBM 的 J9、阿里巴巴的 Dragonwell 等其他实现,它们在特定场景(如冷启动速度、超大内存管理)下各有优势。

分代收集理论:

分代收集理论就是:通过把对象按寿命长短分开存放,让 JVM 能把有限的精力花在垃圾最多的地方,从而提高程序的运行效率。

主要分为新生代和老年代

新生代:存放'新诞生'或'寿命短'的对象;动作快,回收频繁。因为大部分对象是垃圾,用'复制算法'很快就能腾出大片空间。

老年代:存放'熬过多次 GC'或'体积特别大'的长寿对象。"动作慢,频率低。因为它里面的对象都是'硬骨头',不轻易死掉。

A. 新生代(Young Generation)------ "朝生夕灭的试验场"

  • 组成部分 :它被进一步细分为三个部分:Eden(伊甸园) 、**Survivor 0(S0)**和 Survivor 1(S1) ,默认比例是 8:1:1
  • 运行逻辑
  1. 绝大多数新对象在 Eden 区出生。
  2. 当 Eden 区满了,触发 Minor GC 。活下来的对象会被移动到 S0
  3. 下次 GC 时,Eden 和 S0 里的幸存者被移动到 S1,然后清空 Eden 和 S0。
  4. 循环往复:S0 和 S1 就像两个轮换的"中转站",始终保持一个是空的。
  • 回收算法 :使用复制算法,因为新生代垃圾多、存活少,移动少量存活对象比清理大量垃圾快得多。

B. 老年代(Old Generation)------ "沉稳稳重的养老院"

  • 对象来源
  1. 熬过 15 次 GC 的对象(默认阈值是 15)。
  2. 大对象(比如一个巨长的数组),新生代放不下,直接"保送"老年代。
  3. 动态年龄判断:如果 Survivor 区里同龄对象总和超过一半,也会提前晋升。
  • 运行逻辑 :老年代空间大,垃圾产生的频率低。当它快满时,会触发 Major GCFull GC
  • 回收算法 :使用**标记-整理(Mark-Compact)*或*标记-清除。因为这里对象搬动成本太高,所以更倾向于原地清理。

(9)变量存在堆栈的什么位置?

分局部变量和静态变量:

局部变量:存储与当前虚拟机栈中的栈帧中的局部变量表中。当方法执行完毕,栈帧被回收,局部变量也会被释放。

java 复制代码
public void method() {
    int localVar = 100;  // 局部变量,存储在栈帧中的局部变量表里
}

静态变量:存储在堆区的方法区中,java7的永久代。java8的新生代

java 复制代码
public class StaticVarDemo {
    public static int staticVar = 100;  // 静态变量,存储在方法区中
}

2.2 说一下 JDK 1.6、1.7、1.8 内存区域的变化?

主要就是堆区在变化,方法区的永久代与元空间的实现,字符串常量池、类常量池、运行时常量池的存放不同位置

  • jdk1.6:JDK 1.6 使用永久代来实现方法区
  • jdk1.7:有略微的变化就是把永久代中的字符串常量池放在读取
  • jdk1.8:使用元空间的方式实现方法区,就不在JVM内存中,从内存中单独划出一块区域做元空间

2.3 为什么使用元空间替代永久代?

客观上,永久代会导致 Java 应用程序更容易出现内存溢出的问题,因为它要受到 JVM 内存大小的限制。

JDK 8 就终于完成了这项移出工作,这样的好处就是,元空间的大小不再受到 JVM 内存的限制,而是可以像 J9 和 JRockit 那样,只要系统内存足够,就可以一直用。

"为什么要废除永久代,改用元空间?

  1. 为了防止 OOM: 以前程序员很难预测一个程序到底会加载多少个类,永久代设小了会崩溃,设大了浪费内存。元空间解决了这个难题。
  2. 简化 GC(垃圾回收): 永久代在堆里,垃圾回收器每次都要去扫描它,效率低。挪出来后,管理更简单,性能更好。
  3. 融合 HotSpot 和 JRockit: 当时 Oracle 收购了另外一家虚拟机 JRockit,JRockit 本来就没有永久代。为了合并两者的技术优势,Oracle 干脆在 HotSpot 里也把永久代去掉了。

2.4 对象创建的过程了解吗?【*】

new对象--》类加载(如果已经加载就不用)-->分配内存-->对象内存初始化--》设置对象头--》执行init方法

  1. 当我们使用 new 关键字创建一个对象时,JVM 首先会检查 new 指令的参数是否能在常量池中定位到类的符号引用,然后检查这个符号引用代表的类是否已被加载、解析和初始化。
  2. 如果没有,就先执行类加载。
  3. 如果已经加载,JVM 会为对象分配内存完成初始化,比如数值类型的成员变量初始值是 0,布尔类型是 false,对象类型是 null。
  4. 接下来会设置对象头,里面包含了对象是哪个类的实例、对象的哈希码、对象的 GC 分代年龄等信息
  5. 最后,JVM 会执行构造方法 <init> 完成赋值操作,将成员变量赋值为预期的值 ,比如 int age = 18,这样一个对象就创建完成了。

(1)对象销毁的过程又是怎么样?

对象不再被任何引用指向时,就会变成垃圾

  1. 垃圾收集器会通过可达性分析算法判断对象是否存活,如果对象不可达,就会被回收。

  2. 垃圾收集器通过标记清除、标记复制、标记整理等算法来回收内存,将对象占用的内存空间释放出来。

2.5 堆内存是如何分配的?

堆内存分配空间"指针碰撞和空闲列表

指针碰撞适用于管理简单、碎片化较少(连续内存多)的内存区域 ,如年轻代;而空闲列表适用于内存碎片化较严重或对象大小差异较大的场景如老年代。

(1)什么是指针碰撞?

假设堆内存是一个连续的空间,分为两个部分,一部分是已经被使用的内存,另一部分是未被使用的内存。

分配内存时,jvm会维护一个指针,指向下一个可用的内存地址,每次分配内存就向后移动,如果指针没有碰撞就正常分配内存

(2)什么是空闲列表?

用列表记录空闲内存,JVM 维护一个列表,记录堆中所有未占用的内存块,每个内存块都记录有大小和地址信息。

当有新的对象请求内存时,JVM 会遍历空闲列表,寻找足够大的空间来存放新对象。

分配后,如果选中的内存块未被完全利用,剩余的部分会作为一个新的内存块加入到空闲列表中

2.6 new 对象时,堆会发生抢占吗?

会,堆区线程共享,两个线程并发创建的时候就会发生抢占。

new 对象时,指针会向右移动一个对象大小的距离,假如一个线程 A 正在给字符串对象 s 分配内存,另外一个线程 B 同时为 ArrayList 对象 l 分配内存,两个线程就发生了抢占。

(1)JVM怎么解决堆内存分配的竞争问题

对JVM线程预留内存缓冲区--TLAB(线程本地分配缓存区),TLAB用完才会使用全局分配指针

为了解决堆内存分配的竞争问题,JVM 为每个线程保留了一小块内存空间,被称为 TLAB,也就是线程本地分配缓冲区,用于存放该线程分配的对象。

当线程需要分配对象时,直接从 TLAB 中分配。只有当 TLAB 用尽或对象太大需要直接在堆中分配时,才会使用全局分配指针。

测试:

以通过 java -XX:+PrintFlagsFinal -version | grep TLAB 命令查看当前 JVM 是否开启了 TLAB。

2.7 能说一下对象的内存布局吗?【*】

不同JVM规范实现的细节各有不同,如 HotSpot 和 OpenJ9 就不一样。

就拿我们常用的 HotSpot 来说吧。对象在内存中包括三部分:对象头、实例数据和对齐填充。

"Java 对象在内存中分为三块区域:

  1. 首先是对象头 :它包含 Mark Word类型指针。Mark Word 是最核心的,它存储了对象的 HashCode、GC 年龄以及锁状态。这也是 synchronized 实现锁升级的关键支撑。如果是数组,还会多出一块记录数组长度的区域。
  2. 其次是实例数据:这里存储的是对象真正的有效信息,即我们在类中定义的各种成员变量。
  3. 最后是对齐填充:因为 JVM 要求对象占用的字节数必须是 8 的倍数,所以不足时会进行填充。(这个比较像计算机网络)

(1)说说对象头的作用?

对象头是对象存储在内存中的元信息,包含了Mark Word8、类型指针等信息。

  • Mark Word:Mark Word 存储了对象的运行时状态信息,包括锁、哈希值、GC 标记等。在 64 位操作系统下占 8 个字节,32 位操作系统下占 4 个字节。
  • 类型指针:类型指针指向对象所属类的元数据,也就是 Class 对象,用来支持多态、方法调用等功能。
  • 数组长度:如果对象是数组类型,还会有一个额外的数组长度字段。占 4 个字节。

(2)类型指针会被压缩吗?

会的,类型执政被压缩可以节省空间。类型指针可能会被压缩,以节省内存空间。比如说在开启压缩指针的情况下占 4 个字节,否则占 8 个字节。在 JDK 8 中,压缩指针默认是开启的。

可以通过 java -XX:+PrintFlagsFinal -version | grep UseCompressedOops 命令来查看 JVM 是否开启了压缩指针。

(3)实例数据了解吗?

实例数据是对象实际的字段值,成员变量的值,按照字段在类中声明的顺序存储。

存储的是对象真正的有效信息,即我们在类中定义的各种成员变量。

(4)对齐填充了解吗?

就是需要对象的长度是8字节的倍数,如果对象头+实例数据没有达到8字节的倍数,就进行和填充。

由于 JVM 的内存模型要求对象的起始地址是 8 字节对齐(64 位 JVM 中),因此对象的总大小必须是 8 字节的倍数。

如果对象头和实例数据的总长度不是 8 的倍数,JVM 会通过填充额外的字节来对齐。

比如说,如果对象头 + 实例数据 = 14 字节,则需要填充 2 个字节,使总长度变为 16 字节。

(5)为什么非要进行 8 字节对齐呢?

因为 CPU 进行内存访问时,一次寻址的指针大小是 8 字节,正好是 L1 缓存行的大小。如果不进行内存对齐,则可能出现跨缓存行访问,导致额外的缓存行加载,CPU 的访问效率就会降低。

比如说上图中 obj1 占 6 个字节,由于没有对齐,导致这一行缓存中多了 2 个字节 obj2 的数据,当 CPU 访问 obj2 的时候,就会导致缓存行刷新。

也就说,8 字节对齐,是为了效率的提高,以空间换时间的一种方案。

(6)new Object() 对象的内存大小是多少?

一般来说,目前的操作系统都是 64 位的,并且 JDK 8 中的压缩指针是默认开启的,因此在 64 位的 JVM 上,new Object()的大小是 16 字节(12 字节的对象头 + 4 字节的对齐填充)。

对象头的大小是固定的,在 32 位 JVM 上是 8 字节,在 64 位 JVM 上是 16 字节;如果开启了压缩指针,就是 12 字节。

实例数据的大小 取决于对象的成员变量和它们的类型。对于new Object()来说,由于默认没有成员变量,因此我们可以认为此时的实例数据大小是 0。

假如 MyObject 对象有三个成员变量,分别是 int、long 和 byte 类型,那么它们占用的内存大小分别是 4 字节、8 字节和 1 字节。

java 复制代码
class MyObject {
    int a;        // 4 字节
    long b;       // 8 字节
    byte c;       // 1 字节
}

考虑到对齐填充,MyObject 对象的总大小为 12(对象头) + 4(a) + 8(b) + 1(c) + 7(填充) = 32 字节。

(7)用过 JOL 查看对象的内存布局吗?

JOL 是一款分析 JVM 对象布局的工具。

第一步,在 pom.xml 中引入 JOL 依赖:

xml 复制代码
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

第二步,使用 JOL 编写代码示例:

java 复制代码
public class JOLSample {
    public static void main(String[] args) {
        // 打印JVM详细信息(可选)
        System.out.println(VM.current().details());

        // 创建Object实例
        Object obj = new Object();

        // 打印Object实例的内存布局
        String layout = ClassLayout.parseInstance(obj).toPrintable();
        System.out.println(layout);
    }
}

第三步,运行代码,查看输出结果:

可以看到有 OFFSET、SIZE、TYPE DESCRIPTION、VALUE 这几个信息。

  • OFFSET:偏移地址,单位字节;
  • SIZE:占用的内存大小,单位字节;
  • TYPE DESCRIPTION:类型描述,其中 object header 为对象头;
  • VALUE:对应内存中当前存储的值,二进制 32 位;

从上面的结果能看到,对象头是 12 个字节,还有 4 个字节的 padding,new Object() 一共 16 个字节。

(8)对象的引用大小了解吗?

在 64 位 JVM 上,未开启压缩指针时,对象引用占用 8 字节;开启压缩指针时,对象引用会被压缩到 4 字节。HotSpot 虚拟机默认是开启压缩指针的。

验证以下,对象引用,对象类型的引用,不同于和基础,感觉和类型指针是一样的

java 复制代码
class ReferenceSizeExample {
    private static class ReferenceHolder {
        Object reference;
    }

    public static void main(String[] args) {
        System.out.println(VM.current().details());
        System.out.println(ClassLayout.parseClass(ReferenceHolder.class).toPrintable());
    }
}

结果:

2.8 JVM 怎么访问对象的?

JVM访问对象就是栈区里的对象引用怎么访问到对象,主要通过两种方式:句柄和直接指针

句柄:通过一个中间的句柄表来定位对象。

直接指针:通过引用直接指向对象的内存地址。

优点是,对象被移动时只需要修改句柄表中的指针,而不需要修改对象引用本身。

在直接指针访问中,引用直接存储对象的内存地址;对象的实例数据和类型信息都存储在堆中固定的内存区域。

优点是访问速度更快,因为少了一次句柄的寻址操作。缺点是如果对象在内存中移动,引用需要更新为新的地址。

2.9 说一下对象有哪几种引用?

主要分为4种:强引用,软引用,弱引用,虚引用。回收的时机

1.强引用是 Java 中最常见的引用类型。使用 new 关键字赋值的引用就是强引用,只要强引用关联着对象,垃圾收集器就不会回收这部分对象,即使内存不足。不可达就回收

java 复制代码
// str 就是一个强引用
String str = new String("沉默王二");

2.软引用于描述一些非必须对象,通过 SoftReference 类实现。软引用的对象在内存不足时会被回收

java 复制代码
// softRef 就是一个软引用
SoftReference<String> softRef = new SoftReference<>(new String("沉默王二"));

3.弱引用用于描述一些短生命周期的非必须对象 ,如 ThreadLocal 中的 Entry,就是通过 WeakReference 类实现的。弱引用的对象会在下一次垃圾回收时会被回收,不论内存是否充足。

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    //节点类
    Entry(ThreadLocal<?> k, Object v) {
        //key赋值
        super(k);
        //value赋值
        value = v;
    }
}

4.虚引用主要用来跟踪对象被垃圾回收的过程,通过 PhantomReference 类实现。虚引用的对象在任何时候都可能被回收。

java 复制代码
// phantomRef 就是一个虚引用
PhantomReference<String> phantomRef = new PhantomReference<>(new String("沉默王二"), new ReferenceQueue<>());

2.10 Java 堆的内存分区了解吗?

Java根据垃圾回收将堆区分为两个大区:新生代和老年代

新生代分为3个区域:1个Eden,2个survivor(from/to)

先创建对象分到Eden.当 Eden 区填满时,会触发一次 Minor GC,清除不再使用的对象。存活下来的对象会从 Eden 区移动到 Survivor 区。

然后在下次GC还有存活就在s区反复横跳

对象在新生代中经历多次 GC 后(15次),如果仍然存活,会被移动到老年代。当老年代内存不足时,会触发 Major GC,对整个堆进行垃圾回收。

A. 新生代(Young Generation)------ "朝生夕灭的试验场"

  • 组成部分 :它被进一步细分为三个部分:Eden(伊甸园) 、**Survivor 0(S0)**和 Survivor 1(S1) ,默认比例是 8:1:1
  • 运行逻辑
  1. 绝大多数新对象在 Eden 区出生。
  2. 当 Eden 区满了,触发 Minor GC 。活下来的对象会被移动到 S0
  3. 下次 GC 时,Eden 和 S0 里的幸存者被移动到 S1,然后清空 Eden 和 S0。
  4. 循环往复:S0 和 S1 就像两个轮换的"中转站",始终保持一个是空的。
  • 回收算法 :使用复制算法,因为新生代垃圾多、存活少,移动少量存活对象比清理大量垃圾快得多。

B. 老年代(Old Generation)------ "沉稳稳重的养老院"

  • 对象来源
  1. 熬过 15 次 GC 的对象(默认阈值是 15)。
  2. 大对象(比如一个巨长的数组),新生代放不下,直接"保送"老年代。
  3. 动态年龄判断:如果 Survivor 区里同龄对象总和超过一半,也会提前晋升。
  • 运行逻辑 :老年代空间大,垃圾产生的频率低。当它快满时,会触发 Major GCFull GC
  • 回收算法 :使用**标记-整理(Mark-Compact)*或*标记-清除。因为这里对象搬动成本太高,所以更倾向于原地清理。

怎么来区分对象是属于哪个代的?

可以通过对象的对象头种mark word里有GC分代年龄字段

作为开发者无法直接在代码里写obj.getGeneration()

  1. 从理论推断
  • 如果是方法内部定义的临时局部变量 ,大概率在新生代
  • 如果是静态变量缓存对象Spring 管理的单例 Bean ,它们长期存活,最终一定在老年代
  1. 从工具观察
  • 使用 jstat -gc 命令观察堆内存各区的变化。
  • 使用 VisualVMArthas 实时查看各代内存占用情况。

2.11 说一下新生代的区域划分?

新生代垃圾回收主要靠标记-复制法,因为新生代的存活对象比较少,每次复制少量的存活对象效率比较高。

基于这种算法,虚拟机将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。默认 Eden 和 Survivor 的大小比例是 8∶1。

2.12 对象什么时候会进入老年代?【*】

对象一开始在年轻代中进行分配,随着GC和时间推进,满足条件的对象就会进入老年代,主要是三种对象:长期存活的对象、大对象、动态年龄判断。

(1)长期存活对象的判断

JVM会在对象的对象头的mark Word中维护一个GC年龄计数器。记录对象在新生代中经历minor GC的次数。每一次GC未被回收的对象,其年龄就+1.

达到一定阈值,默认15次,就是老对象,进入老年代。这个年龄阈值可以通过 JVM 参数-XX:MaxTenuringThreshold来设置。

  1. 如果应用中的对象存活时间较短,可以适当调大这个值,让对象在新生代多待一会儿
  2. 如果对象存活时间较长,可以适当调小这个值,让对象更快进入老年代,减少在新生代的复制次数

(2)大对象如何判断

大对象是指占用内存较大的对象,如大数组、长字符串等。

java 复制代码
int[] array = new int[1000000];
String str = new String(new char[1000000]);

其大小由 JVM 参数 -XX:PretenureSizeThreshold 控制,在 JDK 8 中,默认值为 0,也就是说默认情况下,对象仅根据 GC 存活的次数来判断是否进入老年代。

G1 垃圾收集器 中,大对象会直接分配到 HUMONGOUS 区域。当对象大小超过一个 Region 容量的 50% 时**,会被认为是大对象。** 下图就是G1垃圾收集器,其中HUMONGOUS是巨型对象,Region 就是方格

Region 的大小可以通过 JVM 参数 -XX:G1HeapRegionSize 来设置,默认情况下从 1MB 到 32MB 不等,会根据堆内存大小动态调整。

什么是G1垃圾收集器?

  1. 分块化管理 :G1 不再物理隔离新生代和老年代,而是把堆内存划分为多个大小相等的 Region
  2. 垃圾优先:它会跟踪每个 Region 的回收价值,优先回收垃圾最多的区域,从而在有限的时间内获得最高的回收效率。
  3. 可预测停顿:通过 -XX:MaxGCPauseMillis 参数,用户可以设定预期的停顿时间,G1 会尽量在这个时间内完成任务。
  4. 处理大对象 :引入了 Humongous 区域,专门存储大对象,避免了它们在年轻代和老年代之间频繁移动。
  5. 总结地位:它是为了取代 CMS 而设计的,适合大内存、多核 CPU 的服务器环境。

(3)动态年龄如何判断

如果 Survivor 区中所有对象的总大小超过了一定比例,通常是 Survivor 区的一半,那么年龄较小的对象也可能会被提前晋升到老年代。( Survivor区被占太多也会将年龄小的对象晋级到老年代)

这是因为如果年龄较小的对象在 Survivor 区中占用了较大的空间,会导致 Survivor 区中的对象复制次数增多,影响垃圾回收的效率。

2.13 STW了解吗?

STW是暂停所有的用户线程stop the world,为了保证GC时对象移动过程中保证对象引用中不被修改。

JVM 进行垃圾回收的过程中,会涉及到对象的移动,为了保证对象引用在移动过程中不被修改,必须暂停所有的用户线程,像这样的停顿,我们称之为Stop The World。简称 STW。

(1)如何暂停线程?

会使用到安全点,JVM 会使用一个名为安全点(Safe Point)的机制来确保线程能够被安全地暂停,其过程包括四个步骤:

  1. JVM 发出暂停信号;
  2. 线程执行到安全点后,挂起自身并等待垃圾收集完成;
  3. 垃圾回收器完成 GC 操作;
  4. 线程恢复执行。

(2)什么是安全点?

安全点是 JVM 的一种机制,常用于垃圾回收的 STW 操作,用于让线程在执行到某些特定位置时,可以被安全地暂停。

通常位于方法调用、循环跳转、异常处理等位置,以保证线程暂停时数据的一致性。

用个通俗的比喻,老王去拉车,车上的东西很重,老王累的汗流浃背,但是老王不能在上坡或者下坡时休息,只能在平地上停下来擦擦汗,喝口水。

安全点就是你设定那一段平路

2.14 对象一定分配在堆中吗?

不一定。默认情况下是堆中,但 JVM 会进行逃逸分析,来判断对象的生命周期是否只在方法内部**,如果是只在方法内部的话,这个对象可以在栈上分配。**

示例:

举例来说,下面的代码中,对象 new Person() 的生命周期只在 testStackAllocation 方法内部,因此 JVM 会将这个对象分配在栈上。

复制代码
public void testStackAllocation() {
Person p = new Person();  // 对象可能分配在栈上
p.name = "沉默王二是只狗";
p.age = 18;
System.out.println(p.name);
}

(1)什么是逃逸分析?

JVM用来分析对象作用域和生命周期的一个JVM优化技术,判断对象是否逃逸出方法或线程。

可以通过 java -XX:+PrintFlagsFinal -version | grep DoEscapeAnalysis 来确认 JVM 是否开启了逃逸分析。

可以通过分析对象的引用流向,判断对象是否被方法返回、赋值到全局变量、传递到其他线程等,来确定对象是否逃逸。

如果对象没有逃逸 ,就可以进行栈上分配、同步消除、标量替换等优化,以提高程序的性能。

(2)逃逸具体是指什么?

根据逃逸的范围,具体分为方法逃逸和线程逃逸。

未逃逸在栈中分配,逃逸就在堆中分配。

当对象被方法外部的代码引用,生命周期超出了方法的范围,那么对象就必须分配在堆中,由垃圾收集器管理。

java 复制代码
public Person createPerson() {
    return new Person(); // 对象逃逸出方法
}

比如说 new Person() 创建的对象被返回,那么这个对象就逃逸出当前方法了。

方法逃逸:

线程逃逸:

当对象被另一线程引用,生命周期超出了当前线程,那么对象就必须分配在堆中,并且线程之间需要同步。

java 复制代码
public void threadEscapeExample() {
    Person p = new Person(); // 对象逃逸到另一个线程
    new Thread(() -> {
        System.out.println(p);
    }).start();
}

对象 new Person() 被另外一个线程引用了,发生了线程逃逸。

(3)逃逸分析会有什么好处?

  • 第一,减轻GC的压力,如果确定一个对象不会逃逸,那么就可以考虑栈上分配,对象占用的内存随着栈帧出栈后销毁,这样一来,垃圾收集的压力就降低很多。
  • 第二,判断加锁的必要性线程同步需要加锁,加锁就要占用系统资源,如果逃逸分析能够确定一个对象不会逃逸出线程,那么这个对象就不用加锁,从而减少线程同步的开销。
  • 第三,可以进行判断,分解对象未标量变量,如果对象的字段在方法中独立使用,JVM 可以将对象分解为标量变量,避免对象分配。
java 复制代码
public void scalarReplacementExample() {
Point p = new Point(1, 2);
System.out.println(p.getX() + p.getY());
}

如果 Point 对象未逃逸,JVM 可以优化为:

java 复制代码
int x = 1;
int y = 2;
System.out.println(x + y);

2.15 内存溢出和内存泄漏了解吗?

(1)内存溢出

内存溢出,out of mermory(OOM),是指当程序请求分配内存时,由于没有足够的内存空间,从而抛出 OutOfMemoryError。等着用内存,但是没有内存。

java 复制代码
List<String> list = new ArrayList<>();
while (true) {
    list.add("OutOfMemory".repeat(1000)); // 无限增加内存
}

可能是因为堆、元空间、栈或直接内存不足导致的。可以通过优化内存配置、减少对象分配来解决。

(2)内存泄漏

内存泄漏是没有即使释放空闲内存,内存泄漏是指程序在使用完内存后,未能及时释放,导致占用的内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终导致内存溢出。

内存泄漏通常是因为长期存活的对象持有短期存活对象的引用,又没有及时释放,从而导致短期存活对象无法被回收而导致的。

java 复制代码
// 情况1:静态集合
class MemoryLeakExample {
    // static 关键字意味着:只要程序不关,这个 List 就永远活着!
    private static List<Object> staticList = new ArrayList<>(); 

    public void addObect() {
        // 每调用一次,List 里就多一个对象
        staticList.add(new Object()); 
        // 报错点:你只管往里加,却从来没写过 staticList.remove(...) 
        // 或者是 staticList.clear()
    }
}

// 情况2:资源未释放)
public void readFile() {
    FileInputStream fis = new FileInputStream("big_file.txt");
    // ... 读取文件的代码 ...
    
    // 报错点:漏写了 fis.close(); 
}

// 情况3:线程中的大对象
public void useThreadLocal() {
    ThreadLocal<User> threadLocal = new ThreadLocal<>();
    threadLocal.set(new User()); // 给当前线程塞入一个大对象
    
    // 报错点:没写 threadLocal.remove();
    // 现在的服务器(如 Tomcat)都是使用线程池的。一个线程干完活不会死,而是回池子里待命。如果你不手动 remove(),存进去的对象就会一直长在那个线程身上。
}

用一个比较有味道的比喻来形容就是,内存溢出是排队去蹲坑,发现没坑了;内存泄漏,就是有人占着茅坑不拉屎,导致坑位不够用。

内存泄漏就是有些情况下

2.16 能手写内存溢出的例子吗?

代码内存溢出的例子:

我就拿最常见的堆内存溢出来完成吧,堆内存溢出通常是因为创建了大量的对象,且长时间无法被垃圾收集器回收,导致的。

java 复制代码
class HeapSpaceErrorGenerator {
    public static void main(String[] args) {
        // 第一步,创建一个大的容器
        List<byte[]> bigObjects = new ArrayList<>();
        try {
            // 第二步,循环写入数据
            while (true) {
                // 第三步,创建一个大对象,一个大约 10M 的数组
                byte[] bigObject = new byte[10 * 1024 * 1024];
                // 第四步,将大对象添加到容器中
                bigObjects.add(bigObject);
            }
        } catch (OutOfMemoryError e) {
            System.out.println("OutOfMemoryError 发生在 " + bigObjects.size() + " 对象后");
            throw e;
        }
    }
}

那些区域会OOM:

只有程序计数器不会发生内存溢出。

内存区域 报错类型 核心原因
堆 (Heap) Java heap space 对象太多,GC 回收不了
元空间 (Metaspace) Metaspace 加载的类信息过多(动态生成的类)
栈 (Stack) unable to create new native thread 线程开得太多,系统内存耗尽
栈 (Stack) StackOverflowError 递归太深,单个线程的栈帧用完了
直接内存 (Direct) Direct buffer memory NIO 申请的堆外内存过多
程序计数器 它是唯一不报 OOM 的区域(只是个行号)

2.17 内存泄漏可能由哪些原因导致呢?

长生命周期的对象引用短生命周期的对象,导致

连接资源没有及时释放

线程没有及时释放

(1)静态集合中添加的对象越来越多。没有GC清理,静态变量的生命周期与应用程序相同,如果静态变量持有对象的引用,这些对象将无法被 GC 回收。

java 复制代码
class OOM {
 static List list = new ArrayList(); // 静态集合

 public void oomTests(){
   Object obj = new Object();

   list.add(obj); // 只add不remove
  }
}

(2)单例模式下对象持有外部引用无法及时释放。单例对象在整个应用程序的生命周期中存活,如果单例对象持有其他对象的引用,这些对象将无法被回收。

java 复制代码
class Singleton {
    private static final Singleton INSTANCE = new Singleton(); 
    private List<Object> objects = new ArrayList<>();

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

(3)数据库、IO、Socket 等连接资源没有及时关闭;

java 复制代码
try {
    Connection conn = null;
    Class.forName("com.mysql.jdbc.Driver");
    conn = DriverManager.getConnection("url", "", "");
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("....");
  } catch (Exception e) {

  }finally {
    //不关闭连接
  }

(4) ThreadLocal 的引用未被清理,线程退出后仍然持有对象引用;**在线程执行完后,要调用 ThreadLocal 的 remove 方法进行清理。**线程退出但是不会被清除,而是会待命。

java 复制代码
ThreadLocal<Object> threadLocal = new ThreadLocal<>();
threadLocal.set(new Object()); // 未remove清理

2.18 有没有处理过内存泄漏问题?

这个偏实战

2.19 有没有处理过内存溢出问题?

这个偏实战

2.20 什么情况下会发生栈溢出?

栈溢出一般就是方法递归太深。

栈溢出发生在程序调用栈的深度超过 JVM 允许的最大深度时。

栈溢出的本质是因为线程的栈空间不足,导致无法再为新的栈帧分配内存。

当一个方法被调用时,JVM 会在栈中分配一个栈帧,用于存储该方法的执行信息。如果方法调用嵌套太深,栈帧不断压入栈中,最终会导致栈空间耗尽,抛出 StackOverflowError。

最常见的栈溢出场景就是递归调用,尤其是没有正确的终止条件下,会导致递归无限进行。

java 复制代码
class StackOverflowExample {
    public static void recursiveMethod() {
        // 没有终止条件的递归调用
        recursiveMethod();
    }

    public static void main(String[] args) {
        recursiveMethod();  // 导致栈溢出
    }
}

另外,如果方法中定义了特别大的局部变量,栈帧会变得很大,导致栈空间更容易耗尽。

java 复制代码
public class LargeLocalVariables {
    public static void method() {
        int[] largeArray = new int[1000000];  // 大量局部变量
        method();  // 递归调用
    }

    public static void main(String[] args) {
        method();  // 导致栈溢出
    }
}
相关推荐
Serene_Dream2 小时前
NIO 的底层机理
java·jvm·nio·mmap
skywalker_112 小时前
多线程&JUC
java·开发语言·jvm·线程池
u0109272714 小时前
RESTful API设计最佳实践(Python版)
jvm·数据库·python
qq_1927798710 小时前
高级爬虫技巧:处理JavaScript渲染(Selenium)
jvm·数据库·python
超级大只老咪11 小时前
快速进制转换
笔记·算法
u01092727111 小时前
使用Plotly创建交互式图表
jvm·数据库·python
爱学习的阿磊11 小时前
Python GUI开发:Tkinter入门教程
jvm·数据库·python
tudficdew11 小时前
实战:用Python分析某电商销售数据
jvm·数据库·python
sjjhd65212 小时前
Python日志记录(Logging)最佳实践
jvm·数据库·python