Skip to content

04.3 - 对象访问定位

定位: Java 栈上的 reference 如何找到堆上的对象实例——两种访问机制对比 面试高频度: ⭐⭐⭐⭐ 考查方式: 句柄 vs 直接指针区别、HotSpot 选择直接指针的原因、GC 移动对象对引用的影响

一、这是什么?为什么需要它?

Java 程序通过栈上的 reference 变量来操作堆上的对象。但 reference 本身只是一个二进制值,JVM 需要一种机制来将它"翻译"成堆上的实际对象地址。

┌─────────┐                            ┌──────────────────┐
│ 栈帧     │                            │  堆               │
│          │                            │                  │
│ 局部变量表│     ??  怎么找到对象?       │  ┌────────────┐  │
│ ┌──────┐ │  ───────────────────────→  │  │ 对象实例   │  │
│ │ ref  │─┘                            │  │ ┌────────┐ │  │
│ └──────┘                              │  │ │数据...  │ │  │
│  (指向一个对象)                        │  │ └────────┘ │  │
└─────────────────────────────────────┘  └──────────────────┘

为什么需要讨论访问方式? 因为 GC 在回收对象时会移动对象(尤其是复制算法标记-压缩算法)。对象地址变了之后,所有指向它的 reference 都必须更新。不同的访问机制对"地址更新"的代价和频率有直接影响。

这个问题本质上是一个权衡:每次访问的代价 vs GC 时更新的代价。

二、原理拆解

2.1 句柄访问 (Handle Access)

句柄访问在堆中(或堆外)专门维护一个"句柄池"作为中间层:

栈 (Stack)                  句柄池 (Handle Pool)              堆 (Heap)
┌──────────┐              ┌────────────────────┐           ┌────────────────┐
│ reference│──→            │  ┌────────────────┐ │           │                │
│           │              │  │ 实例数据指针 ──────┼──→      │ 对象实例        │
│ 指向句柄池│              │  │ 类型数据指针 ──────┼──→      │ ┌────────────┐ │
│ 中的句柄  │              │  └────────────────┘ │           │ │ MarkWord   │ │
│           │              │  ┌────────────────┐ │           │ │ KlassPtr   │ │
│           │              │  │ ...             │ │           │ │ 实例数据    │ │
└──────────┘              │  └────────────────┘ │           │ └────────────┘ │
                           └────────────────────┘           └────────────────┘


                                                         ┌────────────────┐
                                                         │ 方法区          │
                                                         │ (类元数据)      │
                                                         └────────────────┘

访问路径reference → 句柄池 → 实例数据指针 → 对象实例

优势——GC 移动对象时的成本

GC 前:                           GC 后 (对象被压缩/复制到新地址):
reference                        reference
    │                                 │
    ▼                                 ▼
┌────────────┐                   ┌────────────┐
│ 句柄        │                   │ 句柄        │
│ 实例指针 ──→ 地址A (旧)         │ 实例指针 ──→ 地址B (新)
└────────────┘                   └────────────┘
       ↑                                 ↑
   只更新这里!                       只更新句柄!
   reference 不需要变               reference 依然指向同一句柄

Key insight:GC 移动对象后,只需要更新句柄池中的指针,栈上的 reference 值完全不变。这避免了遍历所有线程栈帧来更新引用的开销。

劣势——每次访问多一层间接

句柄访问:  一次访问 = 读取句柄指针 + 读取对象内容
                                  = 2 次内存读 (多一次间接)

直接指针:  一次访问 = 读取对象内容
                                  = 1 次内存读

2.2 直接指针访问 (Direct Pointer)

reference 直接存储对象在堆上的地址,通过对象头中的 Klass Pointer 找到类型信息:

栈 (Stack)                   堆 (Heap)                    方法区
┌──────────┐              ┌──────────────────┐          ┌────────────────┐
│ reference│──→            │ 对象实例          │          │ 类元数据        │
│           │              │ ┌──────────────┐ │          │ ┌────────────┐ │
│ 直接指向  │              │ │ MarkWord     │ │          │ │ 类的信息    │ │
│ 对象地址  │              │ │ KlassPtr ───────┼──→      │ │ 虚方法表    │ │
│           │              │ │ 实例数据      │ │          │ │ 字段偏移    │ │
└──────────┘              │ └──────────────┘ │          │ └────────────┘ │
                           └──────────────────┘          └────────────────┘

访问路径reference → 对象实例(通过 Klass Pointer 获取类型信息)

优势——速度:少一次指针间接,每次字段访问都更快。

劣势——GC 移动对象时要修改所有引用

GC 前:                           GC 后 (对象被移动):
reference → 地址A (旧)          reference → 地址B (新)

                                每个引用都要更新!

Key insight:GC 在移动对象后,必须扫描所有线程的栈帧、所有活跃对象的引用字段,把指向旧地址的 reference 全部替换为新地址。这个扫描过程是有开销的。

2.3 对比与权衡

┌─────────────────────────────────────────────────────────────────────┐
│              句柄访问                              直接指针访问         │
│                                                                     │
│   reference ─→  句柄池 ─→  对象              reference ─→  对象      │
│                    │                              │                  │
│                ┌───┴───┐                     ┌───┴───┐              │
│                │  实例  │                     │ 实例   │              │
│                │  类型  │                     │ Klass  │              │
│                └───────┘                     └───────┘              │
│                                                                     │
│  访问字段: 2 次内存访问                         访问字段: 1 次内存访问 │
│  GC 移动: 只改句柄池                            GC 移动: 改全部引用  │
│  实现: 需要句柄池管理                            实现: 简单直接       │
│  Oracle JRockit / IBM J9 (部分)               HotSpot / OpenJDK    │
└─────────────────────────────────────────────────────────────────────┘

2.4 HotSpot 的选择:直接指针 —— 为什么?

答案是:性能优先。

核心推理过程:

  1. HotSpot 面向的是"大部分时间程序在正常执行",而不是"大部分时间在做 GC"。
  2. 一次对象的字段访问可能执行几十万次(在循环中),而 GC 移动对象只需处理一次引用更新。
  3. 一次 extra 间接引用 × 每次字段访问 的成本,通常远大于 GC 时扫描引用 × 少数几次 GC 的成本。
场景分析: 一个对象字段被访问 100 万次, 中间经历 1 次 Young GC

句柄访问总成本:
  100万 × (2次内存读) + 1次GC × (更新句柄池 ≈ O(1))
  ≈ 200万内存读 + 1句柄更新

直接指针总成本:
  100万 × (1次内存读) + 1次GC × (遍历引用修改, 假设 N=1000 个引用)
  ≈ 100万内存读 + 1000引用更新

结论: 直接指针节省了 100万次 内存读, 代价是 1000次引用更新
      净收益 = 约 100万次 内存读

三、图解全景

┌─────────────────────────────────────────────────────────────────────────┐
│              两种对象访问方式对比                                        │
│                                                                         │
│  ┌─ 句柄访问 (Handle Access) ─────────────────────────────────────┐     │
│  │                                                                 │     │
│  │  Step 1: reference 读取句柄地址                                  │     │
│  │  Step 2: 从句柄读取实例数据指针                                  │     │
│  │  Step 3: 通过实例数据指针访问对象字段                            │     │
│  │           (还需要通过类型数据指针访问虚方法表实现多态)            │     │
│  │                                                                 │     │
│  │  栈                   句柄池                    堆               │     │
│  │  ┌────┐           ┌─────────────┐          ┌─────────────┐      │     │
│  │  │ref │──1──→     │  ┌─────────┐│──2──→   │ 对象实例    │      │     │
│  │  └────┘           │  │实例指针─┼┼──3──→   │ ┌─────────┐ │      │     │
│  │                   │  │类型指针 ││          │ │ 字段 A  │ │      │     │
│  │                   │  └─────────┘│     ┌→  │ └─────────┘ │      │     │
│  │                   │             │     │   └─────────────┘      │     │
│  │                   └─────────────┘     │   ┌─────────────┐      │     │
│  │                                       └── │ 方法区类元数 │      │     │
│  │                                           └─────────────┘      │     │
│  └─────────────────────────────────────────────────────────────────┘     │
│                                                                         │
│  ┌─ 直接指针访问 (Direct Pointer / HotSpot) ────────────────────────┐  │
│  │                                                                 │     │
│  │  Step 1: reference 直接读取对象首地址                              │     │
│  │  Step 2: 通过偏移量访问字段                                       │     │
│  │  Step 3: 通过 Klass Pointer (对象内偏移 8/12) 获取类型信息       │     │
│  │                                                                 │     │
│  │  栈                   堆                                         │     │
│  │  ┌────┐           ┌─────────────────────────┐                    │     │
│  │  │ref │──1,2──→   │ 偏移0: MarkWord          │                    │     │
│  │  └────┘           │ 偏移8/12: KlassPtr ────→│ 方法区              │     │
│  │                   │ 偏移12/16+: 实例数据     │                    │     │
│  │                   │ ┌──────────────────────┐│                    │     │
│  │                   │ │ 字段 A               ││                    │     │
│  │                   │ └──────────────────────┘│                    │     │
│  │                   └─────────────────────────┘                    │     │
│  └─────────────────────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────────────────────┘

关键字: 句柄 = 多一次间接但 GC 友好  |  直接指针 = 少一次间接但 GC 需改引用

四、实战验证

验证: 直接指针 vs 句柄模拟

虽然无法在 Java 层面直接切换访问模式(HotSpot 固定使用直接指针),但可以用代码模拟两种访问的性能差异:

java
// 模拟直接指针访问 vs 句柄访问的区别
public class AccessModeBenchmark {
    // 模拟数据
    private static final int ACCESS_COUNT = 100_000_000;
    private static final int OBJECT_COUNT = 1000;

    public static void main(String[] args) {
        // 直接指针模拟: 先拿到对象引用, 重复访问
        Object[] objects = new Object[OBJECT_COUNT];
        for (int i = 0; i < OBJECT_COUNT; i++) {
            objects[i] = new Object();
        }

        // 句柄模拟: 通过一个中间数组来 "间接" 访问
        int[] handleIndexes = new int[OBJECT_COUNT];
        for (int i = 0; i < OBJECT_COUNT; i++) {
            handleIndexes[i] = i;  // "句柄" 指向索引
        }

        // 测试直接访问
        long start1 = System.nanoTime();
        int sum1 = 0;
        for (int i = 0; i < ACCESS_COUNT; i++) {
            Object o = objects[i % OBJECT_COUNT];  // 直接读
            sum1 += o.hashCode();
        }
        long time1 = System.nanoTime() - start1;

        // 测试间接访问 (模拟句柄)
        long start2 = System.nanoTime();
        int sum2 = 0;
        for (int i = 0; i < ACCESS_COUNT; i++) {
            int idx = handleIndexes[i % OBJECT_COUNT];  // 句柄读取
            Object o = objects[idx];                     // 实例指针解引用
            sum2 += o.hashCode();
        }
        long time2 = System.nanoTime() - start2;

        System.out.println("直接访问: " + time1 / 1_000_000 + "ms");
        System.out.println("句柄模拟: " + time2 / 1_000_000 + "ms");
        System.out.println("差距: ~" + (time2 - time1) / 1_000_000 + "ms");
        // 防止 JIT 优化掉
        System.out.println(sum1 + ", " + sum2);
    }
}

这个示例只能说明"一次间接比零次间接慢"。但实际 HotSpot 的直接指针优势更大——因为 JIT 可以内联字段访问、重用地址,而句柄池的访问地址在编译器看来是不可预测的,更难优化。

JVM 参数(仅供参考,HotSpot 无法切换)

bash
# HotSpot 没有 "切换为句柄模式" 的参数
# 其他 JVM:
# IBM J9: -Xdump:handle  (句柄相关诊断)
# 但 HotSpot 始终使用直接指针

五、面试视角

追问答案要点
HotSpot 使用哪种对象访问方式?为什么?直接指针访问。因为大多数时间程序在执行字段访问而非 GC。少一次间接能显著提升字段访问性能,而 GC 时更新引用的成本相对较低(一次 GC 对应数十万次字段访问)。
句柄访问有什么优势?GC 移动对象时只需更新句柄池中的指针,所有 reference 保持不变。适用于 GC 频繁、大堆、引用多的场景——不过 HotSpot 判断这不如直接指针划算。
什么是句柄池?它存在哪里?句柄池是一块专门存储"指向对象的指针"的内存区域,通常位于堆中或堆外的独立区域。每个句柄包含实例数据指针和类型数据指针。
GC 移动对象后直接指针方式怎么处理?GC 在移动对象后,会遍历所有线程栈帧和活跃对象的引用字段,把旧地址替换为新地址。这个过程叫"指针更新"或"引用修正"。
IBM J9 为什么用句柄?J9 历史上使用了句柄方式,优势在于 GC 实现更简单(不需要遍历更新引用),在某些场景下对 GC 暂停时间更友好。但后来 J9 也引入了混合模式。
Java 的 reference 在栈上占多大?64 位 JVM 开启压缩指针时占 4 字节,关闭时占 8 字节。与对象访问方式无关。

📚 相关链接

  • **对象内存布局** — 直接指针访问依赖对象头中的 Klass Pointer
  • **Java虚拟机栈** — reference 变量存储在栈帧的局部变量表中
  • **垃圾收集器详解** — GC 的复制/压缩算法依赖指针更新机制
  • ← 返回 **对象分配索引**

Knowledge4J — Java 知识库