JVM自动内存管理

内存管理

运行时数据区

运行时数据区包括:方法区、虚拟机栈、本地方法栈、堆、和程序计数器。

image-20250901011910374

程序计数器

程序计数器(PC)是 JVM 线程私有的寄存器,用于记录当前线程执行的字节码指令地址。为了让线程切换时,下一线程能够准确回到正确的执行位置,每个线程都有一个独立的程序计数器。

场景 PC 的值 原因
执行 Java 字节码 指向当前字节码指令地址 JVM 正常执行 Java 代码时,PC 有效。
执行 JNI 本地代码 可能为 NULL 当调用 JNI 本地方法时,执行权会从 JVM 托管的代码切换到操作系统直接执行的本地机器码(通常是 C/C++ 编译的二进制代码)。此时,程序计数器会转而记录本地机器码的指令地址,而非 Java 代码的行号。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈

当每个方法被执行时,虚拟机都会创建一个栈帧(Stack Frame),记录当前方法的局部变量表、操作数栈、动态连接、方法出口等信息。

组成部分 说明
局部变量表(Local Variable Table) 存储方法参数和局部变量,按索引访问(从 0 开始)。
操作数栈(Operand Stack) 用于字节码指令计算(如 iadd, invokevirtual)。
动态链接(Dynamic Linking) 支持方法调用时的动态绑定(如虚方法调用)。
返回地址(Return Address) 方法执行完成后返回的地址(用于 return 或异常处理)。

操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

动态连接:主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

Java虚拟机栈可能出现两种错误:

StackOverFlowError 如果栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

OutOfMemoryError 如果栈的内存大小可以动态扩展, 那么当虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

本地方法栈

本地方法栈(Native M ethod Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务,即JNI方法。

JDK7

image-20250902030014222

JDK8+

image-20250902030031209

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

堆是GC管理的主要工作区域,堆可以被分为:新生代和老年代;细分为Eden、Survivor、Old等。不同的JDK和JVM版本对于堆的管理有不同的实现。

JDK7及之前分为:1.新生代,2.老年代,3.永久代

JDK8机之后的永久代被元空间(MetaSpace)取代

image-20250902021536807

新生代

新生代一般有两个部分 Eden和Survivor包括To Survivor和From Survivor,其中:

Eden占约80%,所有新创建的对象都会放在Eden区,当Eden区满时触发Minor GC

Survivor分为两个部分To Survivor和From Survivor,在部分JVM也被称为S0和S1。当对象经过Minor GC依旧存活时,其进入Survivor区,其中From Survivor和To Survivo每次总有一块是空的,意味着每次GC这两者的关系都会转换:

  • From Survivor:存放上一次GC后存活下来的对象。

  • To Survivor空的,准备接收本次GC后存活的对象。

工作流程:

1.对象首次在Eden区创建

2.Eden区满时触发Minor GC

3.存活对象被复制到空的Survivor区(To)

4.对象年龄+1(每经历一次GC年龄+1)

5.年龄达到阈值(默认15)的对象晋升到老年代

6.清空Eden区和之前的From区

7.From和To角色互换

算法:标记复制算法,

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:

1
MaxTenuringThreshold of 20 is invalid; must be between 0 and 15

为什么年龄是15:因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。

在HotSpot虚拟机中,其中,对象头包括两部分:标记字段(Mark Word)和类型指针(Klass Word)。其中,对象头包括两部分:标记字段(Mark Word)和类型指针(Klass Word)。

动态年龄:Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累加,当累加到某个年龄时,所累加的大小超过了 Survivor 区的一半,则取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。

老年代

存储对象:1.从新生代晋升的长期存活对象,2.大对象

GC机制:Major GC(通常伴随Full GC),GC频率低但是耗时长

算法:标记-清除或者标记-整理算法

永久代/元空间

  • Java 7及之前:永久代(方法区实现)•存储类元数据、常量池、静态变量等•大小通过-XX:PermSize-XX:MaxPermSize设置
  • Java 8+:元空间(Metaspace)•移出堆内存,使用本地内存•大小通过-XX:MetaspaceSize-XX:MaxMetaspaceSize设置•动态调整,减少OOM风险

线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)

线程私有的分配缓冲区(TLAB) 是 Java 虚拟机(JVM)在堆内存中为每个线程分配的一小块私有内存区域

  • 目的:它的唯一目的就是让线程在创建新对象时,可以在自己的这块“自留地”里进行分配,从而避免多个线程同时在同一块堆内存上分配对象而引发的性能开销和竞争
    • 1.锁竞争(Lock Contention):JVM 必须通过加锁(如使用全局锁**)来保证同一时刻只有一个线程可以操作堆内存,进行内存分配。频繁的加锁、解锁操作会严重降低性能。
    • 2.缓存失效(Cache Thrashing):多个线程在内存的不同地址上分配对象,会导致 CPU 缓存(Cache)中的数据频繁失效和刷新,降低缓存命中率。
  • 1.初始化:当线程启动时,JVM 会为其在 Eden 区分配一个新的 TLAB。
  • 2.分配对象:线程要分配新对象(如 new Object()),首先检查对象大小是否小于当前 TLAB 的剩余空间。如果够用,就在当前 TLAB 的顶部移动指针,完成分配。此操作无锁,速度极快。
  • 3.TLAB 耗尽:如果当前 TLAB 剩余空间不足,线程会丢弃当前的 TLAB。向 JVM 申请一个新的 TLAB(这个过程需要同步锁)。在新的 TLAB 中重新尝试分配对象。
  • 4.垃圾回收:发生 Minor GC 时,Eden 区(包括所有的 TLAB)会被清空。存活的对象被复制到 Survivor 区或老年代。GC 之后,线程会获得新的 TLAB。

方法区(非堆)

方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

方法区是一个抽象的规定,但是具体到JVM有不同的方法区的实现,在JDK7及之前是永久代,在JDK8及之后是元空间。

image-20250902025124256

为什么用元空间取代永久代

1.解决永久代的内存溢出问题:

  • 永久代的固定大小限制:永久代是堆内存的一部分,其大小在 JVM 启动时通过 -XX:MaxPermSize 参数设定。如果加载的类过多、常量池过大或 JIT 编译的代码太多,很容易遇到臭名昭著的 java.lang.OutOfMemoryError: PermGen space 错误。这在频繁进行热部署的应用服务器(如 Tomcat)中尤其常见,因为每次重新部署都会生成新的类加载器并加载新的类,而旧的类又可能因为引用问题无法被完全卸载,导致永久代被“撑爆”。
  • 元空间的动态扩展:元空间不再使用 Java 堆内存,而是使用本地内存(Native Memory)。这意味着它的理论上限是可用的系统内存总量(取决于操作系统),而不再是固定的 JVM 参数。元空间默认只受本地内存大小限制,虽然也可以通过 -XX:MaxMetaspaceSize 来设置上限以防止无限膨胀,但相比永久代,其默认行为大大降低了 OOM 的风险。

2.提升元数据的管理灵活性和性能

  • 简化内存管理:永久代的内存管理由 JVM 的垃圾收集器负责,这与管理 Java 对象堆的逻辑混杂在一起,实现复杂且效率不高。
  • 元空间的自主管理:元空间的内存管理改由 Java 虚拟机自行在本地内存中完成。它根据需要从操作系统申请内存,并由一套专门的、更高效的算法进行管理。类元数据的内存分配和释放都变得更加高效。
  • 高效的垃圾收集:对于元空间的垃圾收集,其触发条件也得到了优化。当某个类加载器被卸载时(这是垃圾收集的基本单位),元空间会一次性回收该加载器对应的所有类元数据,这个操作非常高效。这避免了永久代中复杂的、容易产生碎片的垃圾回收过程。

3.为函数式编程做准备

Java 8 引入了 Lambda 表达式和方法引用等新特性。这些特性在底层大量使用了动态生成类(如 Lambda 表达式会生成实现函数式接口的匿名类)和字节码技术。

  • 动态类生成的挑战:如果仍然使用永久代,这种动态、大量生成类的行为会极大地加剧永久代的内存压力和碎片化,导致 PermGen OOM 变得更加频繁。
  • 元空间的天然优势:元空间的动态扩展能力和更高效的内存管理机制,正好为这些动态生成类的语言特性提供了理想的“土壤”,使得 JVM 能够更顺畅地支持现代编程范式。

运行时常量池

常量池的内容就两大类,分别是常量(对应.class文件常量池的字面量)和符号引用。

符号引用:类或接口的符号引用,类字段的符号引用,类或接口方法的符号引用,还有method handle符号引用,method type符号引用,*dynamically-computed constant符号引用,dynamically-computed call site符号引用。*包括:

  • 类和接⼝的全限定名
  • 字段的名称和描述符
  • ⽅法的名称和描述符

字面量: String,被final修饰的字段对应的值,包括int,long,float,double类型。

其中String不管有没有final修饰的,都会被放到字符串常量池(String的不可变性),其它得被final修饰值才会进入常量池。

字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

1
2
3
4
5
6
// 在字符串常量池中创建字符串对象 ”ab“
// 将字符串对象 ”ab“ 的引用赋值给给 aa
String aa = "ab";
// 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb
String bb = "ab";
System.out.println(aa==bb); // true

在HotSpot VM⾥实现的string pool功能的是⼀个StringTable类,它是⼀个哈希表,⾥⾯存的是驻留字符串(也就是我们常说的 ⽤双引号括起来的)的引⽤(⽽不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引⽤之后就 等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有⼀份,被所有的类共享

直接内存

它是堆外内存的一个子集,特指通过 Java.nio 包中的 ByteBuffer.allocateDirect() 方法所分配的内存。

  • 避免复制:在传统的 Java I/O 中,数据从磁盘读到内核空间的缓冲区,然后需要复制用户空间的 Java 堆内的一个字节数组中,然后 Java 程序才能处理。处理完后,如果要写回磁盘,又需要从堆内复制到内核缓冲区。这个过程有两次不必要的复制。
  • 零拷贝:使用直接内存(DirectByteBuffer),Java 程序可以直接操作内核空间的缓冲区(或者由 JVM 在用户空间分配的、与内核缓冲区格式对齐的内存),省去了来回复制的开销,极大提升了 I/O 效率。这对于网络通信、文件处理等场景性能提升巨大。

传统的BIO(阻塞式IO):

  1. 磁盘 -> 内核缓冲区 (DMA Copy): 应用程序发起read()调用,导致一次用户态->内核态的上下文切换。DMA引擎将数据从磁盘读取到内核空间的PageCache中。
  2. 内核缓冲区 -> JVM堆 (CPU Copy): 数据从内核缓冲区完整地拷贝到用户空间(即JVM堆内的byte[]数组)。这导致一次内核态->用户态的上下文切换。
  3. JVM堆 -> Socket缓冲区 (CPU Copy): 应用程序发起write()调用,导致又一次用户态->内核态的上下文切换。数据从JVM堆内的byte[]数组再次拷贝到内核空间的Socket缓冲区。
  4. Socket缓冲区 -> 网卡 (DMA Copy): 数据从Socket缓冲区由DMA引擎拷贝到网卡,进行网络传输。完成后,又一次内核态->用户态的上下文切换。

JDK1.4之后的NIO(非阻塞IO):

  1. 磁盘 -> 内核缓冲区 (DMA Copy): 应用程序调用transferTo(),导致一次上下文切换。DMA引擎将数据从磁盘读取到内核缓冲区(PageCache)。
  2. 内核缓冲区 -> 网卡 (DMA Copy): 这是最关键的一步。数据不再拷贝到用户空间,而是直接从内核缓冲区(PageCache)DMA拷贝到目标Socket缓冲区(甚至在某些优化下,如scatter/gather,只需要传递一个文件描述符和长度信息即可)。

堆外内存:包括

  • 元空间 (Metaspace):存储类的元信息。

  • 代码缓存 (Code Cache):存储 JIT 编译器编译后的本地代码。

  • 线程栈 (Thread Stacks):每个线程私有的栈内存。

  • GC 相关数据:垃圾收集器本身需要的一些数据结构(如卡表 Card Table)。

  • 直接内存 (Direct Memory):就是我们下面要讲的部分。

  • 内存映射文件 (Memory-Mapped Files):通过 FileChannel.map 映射到虚拟地址空间的文件内容。

-Xmx设置了JVM的最大内存,这是JVM的上限,如-Xmx8g

-Xms设置了JVM的初始内存,JVM启动时立即分配的内存大小,如-Xms8g

一般将Xms和Xmx设置为相同的值,能够避免内存的频繁扩容,Xmx不应当超过物理内存的大小,并为系统和其他进程预留足够的内存,不然操作系统会使用交换(Swap Space,虚拟内存)来保证内存地址可分配,这会导致磁盘抖动,频繁的IO和缺页等因素会拖累应用程序。

HOTSPOT的对象管理

对象的生命周期

1.类加载检查

当JVM遇到new指令或者类的静态方法时,首先会去检查这个参数的指令的符号引用是否已经在运行时常量池中出现,并且检查这个符号引用代表的类是否已经被加载过、解析过和初始化过。如果没有则先进行类加载过程。


2.分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

  • 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
  • 空闲列表:但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

采用有标记-整理的GC收集器时可以采用指针碰撞,如Serial、ParNew;而CMS基于标记-清除的算法会导致已分配的内存和未分配的内存交织,就必须采用空闲列表。

内存分配的并发问题:在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。


3.初始化

内存分配完成后,JVM将除对象头之外的内存空间都赋值为零值,这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。


4.对象设置

Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。


5.执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,即在字节码中new指令后面会跟随invokespecial指令码,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头

对象头包括两个部分:标记字段和类型指针

  1. 标记字段:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。
  2. 类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

image-20250903030305124

1
2
3
4
5
6
7
8
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

偏向锁[1]如果一把锁在大多数情况下总是被同一个线程所获得,那么就可以消除这个线程后续加锁和解锁的开销。当第一个线程第一次访问同步块时,JVM 会将对象头中的标记设置为“偏向模式”,并记录下这个线程的 ID。此时,锁就“偏向”了这个线程。以后同一个线程再次进入这个同步块时,无需执行任何昂贵的原子操作(如CAS),只需要检查对象头中的线程ID是否是自己即可,速度极快。目的:减少同一线程重入锁的开销,提升单线程重复访问同步块的性能。

偏向线程ID:一个具体的数据,存储在 Java 对象头(Mark Word)的特定字段中。当偏向锁启用时,这个字段用于记录当前持有偏向锁的线程ID。当一个线程第一次获得偏向锁时,JVM 会将它的线程 ID 写入对象头的“偏向线程ID”字段。后续任何线程尝试获取锁时,都会先比较对象头中的线程ID是否与自己的ID一致。如果一致,直接顺利进入,开销极小。如果不一致,说明发生了竞争,需要升级为更高级的锁(轻量级锁或重量级锁)。

偏向时间戳:用于批量重偏向(Bulk Rebiasing)和批量撤销(Bulk Revocation)的优化,解决“偏向锁不适合竞争场景”的问题。假设一个对象被线程A创建并偏向,但后来被多个其他线程(B, C, D)竞争访问。每次竞争都会导致撤销偏向锁(Revoke Bias),这个操作成本很高。如果有一类对象(比如某个Class的所有实例)都被频繁地撤销偏向,说明它们的模式不适合偏向锁。JVM 为每个类维护一个 “偏向时间戳 epoch”。当一个对象被偏向时,对象头中也会记录当前类的 epoch 值。当JVM检测到某个类的对象发生大量撤销时,它会递增该类的 epoch,并执行批量重偏向。所有仍然存活的、属于这个类的对象,在下次被访问时,会检查自己的 epoch 是否与当前类的 epoch 一致。如果不一致,说明这个对象的偏向状态是“过时”的,可以很廉价地将其重偏向给当前正在竞争的线程,而不是直接升级为更重的锁。

实例数据

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。这部分的数据受到虚拟机分配策略参数(-XX FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

对齐填充部分

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

句柄

句柄实际上是资源的抽象标识符/索引,与指针类似但不同于指针,它本身不是一个直接的内存地址,而是一个中间层的、不透明的令牌(Token),系统通过这个令牌可以安全地找到和管理真正的资源(如内存块、文件、窗口等)

特性 指针 (Pointer) 句柄 (Handle)
本质 直接内存地址 资源的抽象标识符/索引
安全性 低。程序可以直接通过指针修改内存,非常危险,容易导致崩溃或安全漏洞。 高。程序无法通过句柄直接操作资源,必须通过系统API,系统可以进行安全检查。
灵活性 低。资源地址固定后,指针就固定了。如果系统移动了资源(如垃圾回收),所有指向它的指针都会失效(成为“野指针”)。 极高。系统可以在不改变句柄值的情况下,在背后自由地移动、管理、压缩资源。只需要更新句柄表即可。
抽象层级 底层,接近硬件。 高层,是操作系统或运行时环境提供的一种服务。

如果使用句柄访问对象,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

image-20250903031022918

直接指针

引用(Reference):Java 使用引用来代替指针。引用可以看作是对象的句柄,它指向对象在堆中的位置,但你不能对它进行任何算术运算(比如 myObject + 1),也无法通过引用直接访问对象的底层内存地址。这有效地避免了大多数与指针相关的安全漏洞。

垃圾回收(Garbage Collection):JVM 自动管理内存的分配和释放,当一个对象不再被任何引用指向时,垃圾回收器会自动回收其占用的内存。开发者无需手动管理内存,从而避免了内存泄漏的风险。

java中并没有指针的设计,因此该处的指针是JVM中的对象访问设计方式。如果JVM设计为使用直接指针访问对象,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

image-20250903031342961

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用的就是直接指针的方式来进行对象访问。

  1. 偏向锁在JDK15之后逐渐被废弃(JEP374),主要在于1)复杂性增加:偏向锁的实现非常复杂,它引入了额外的代码路径,使得 JVM 的代码库变得臃肿且难以维护;2)启动时性能开销:在应用程序启动初期,许多对象都会经历偏向锁的获取、撤销和升级过程。这个过程本身会引入一定的性能开销,抵消了偏向锁本应带来的好处;3)现代 JVM 的优化:现代 JVM(如 HotSpot)在处理轻量级锁(Lightweight Locking)和自旋锁(Spin Locking)时已经非常高效。这些优化在大多数情况下足以应对并发场景,使得偏向锁的必要性大大降低。

JVM自动内存管理
https://yicizhang00.github.io/posts/编程语言/Java/JVM/JVM自动内存管理/
作者
Yici Zhang
发布于
2025年8月12日
许可协议