Java 对象的内存密语:从字段偏移量计算到 Unsafe 访问的完整链路

在 Java 开发中,我们很少直接关心对象内部的字段是如何排列的------直到我们遇到内存对齐问题、伪共享性能瓶颈,或者使用 sun.misc.Unsafe 进行底层操作。实际上,JVM 在类加载的最后阶段,会精心计算每个字段在对象内存中的偏移量。这个偏移量一旦确定,Unsafe.getXxx(obj, offset) 就能像 C 语言指针一样高速访问数据。

本文将结合 OpenJDK 源码,完整展示一条逻辑链条:

class 字节流 → KlassFactoryClassFileParser → 字段布局 Best‑Fit 算法 → 偏移量编码 → 运行时 Unsafe 利用偏移量读写字段

通过这条路径,你不仅能理解 JVM 的内部优化策略,还能掌握 @Contended 注解消除伪共享的实现原理。


一、一切的源头:KlassFactory::create_from_stream

JVM 加载一个类(无论是从 .class 文件、JAR 还是网络)最终都会调用 KlassFactory::create_from_stream。这个函数负责将字节流转换成代表类元数据的 InstanceKlass 对象。

cpp

复制代码
// 源码位置:hotspot/share/classfile/klassFactory.cpp
InstanceKlass* KlassFactory::create_from_stream(ClassFileStream* stream,
                                                Symbol* name,
                                                ClassLoaderData* loader_data,
                                                const ClassLoadInfo& cl_info,
                                                TRAPS) {
  // ...
  // 1. 如果有 JVMTI 代理,可能先修改字节流(ClassFileLoadHook)
  if (!cl_info.is_hidden()) {
    stream = check_class_file_load_hook(stream, name, loader_data,
                                        cl_info.protection_domain(),
                                        &cached_class_file, CHECK_NULL);
  }

  // 2. 核心:创建 ClassFileParser 并解析字节流
  ClassFileParser parser(stream, name, loader_data, &cl_info,
                         ClassFileParser::BROADCAST, CHECK_NULL);

  // 3. 生成 InstanceKlass 实例
  InstanceKlass* result = parser.create_instance_klass(old_stream != stream,
                                                       *cl_inst_info,
                                                       CHECK_NULL);
  // 4. 缓存 JVMTI 可能修改过的字节码
  if (cached_class_file != NULL) {
    result->set_cached_class_file(cached_class_file);
  }
  return result;
}

关键点

  • ClassFileStream 封装了 .class 文件的字节流,包含魔数、版本、常量池等原始数据。

  • ClassFileParser 是整个解析工作的发动机。

  • create_instance_klass 会调用解析器内部已经计算好的各种布局信息(字段偏移、虚表大小等)来构造最终的 InstanceKlass


二、ClassFileParser 构造函数:解析与后处理

ClassFileParser 的构造函数接收字节流和各种加载上下文,依次完成:

  1. 解析字节流 :调用 parse_stream(stream, CHECK) 读取常量池、字段表、方法表、属性等,填充内部数据结构(如 _fields_methods_cp)。

  2. 后处理 :调用 post_process_parsed_stream,这是本文的核心。

cpp

复制代码
// 源码位置:hotspot/share/classfile/classFileParser.cpp
ClassFileParser::ClassFileParser(ClassFileStream* stream,
                                 Symbol* name,
                                 ClassLoaderData* loader_data,
                                 const ClassLoadInfo* cl_info,
                                 Publicity pub_level,
                                 TRAPS) :
  // ... 初始化成员变量(省略)
{
  // ... 设置验证标志等
  parse_stream(stream, CHECK);            // ① 解析字节码
  post_process_parsed_stream(stream, _cp, CHECK); // ② 后处理
}

post_process_parsed_stream 的任务包括:

  • 检查 java.lang.Object 不能实现接口。

  • 解析超类(如果尚未解析)。

  • 计算传递闭包接口列表(_transitive_interfaces)。

  • 对方法进行排序(影响虚表和 Miranda 方法)。

  • 计算 vtable 和 itable 大小。

  • 最重要的:执行字段布局(FieldLayoutBuilder

  • 记录当前类是强/软/弱/虚引用类型。

正是字段布局这一步,决定了每个字段在对象实例中的偏移量,而这个偏移量正是 Unsafe 访问的基础。


三、字段布局的核心:FieldLayoutBuilder::build_layout

FieldLayoutBuilder 负责将已经解析好的字段数组(_fields)转换成具体的偏移量分配方案。它支持两种布局风格:常规布局(当前代码所示)和紧凑布局(历史版本,本文略)。

cpp

复制代码
// 源码位置:hotspot/share/classfile/fieldLayoutBuilder.cpp
void FieldLayoutBuilder::build_layout() {
  compute_regular_layout();   // 只调用常规布局算法
}

void FieldLayoutBuilder::compute_regular_layout() {
  bool need_tail_padding = false;
  prologue();                    // 初始化字段分组(基本类型组、引用组、竞争组)
  regular_field_sorting();       // 对基本类型字段按大小降序排序(long/double 排最前)

  // 处理整个类被 @Contended 标记的情况
  if (_is_contended) {
    _layout->set_start(_layout->last_block());
    insert_contended_padding(_layout->start());  // 在类开头插入填充,使后续字段对齐到缓存行边界
    need_tail_padding = true;
  }

  // 将无竞争标记的普通字段加入布局
  _layout->add(_root_group->primitive_fields()); // 先加基本类型
  _layout->add(_root_group->oop_fields());       // 再加引用类型

  // 处理每个 @Contended 字段组(例如多个字段共用一个 contention group tag)
  if (!_contended_groups.is_empty()) {
    for (int i = 0; i < _contended_groups.length(); i++) {
      FieldGroup* cg = _contended_groups.at(i);
      LayoutRawBlock* start = _layout->last_block();
      insert_contended_padding(start);      // 组前置填充
      _layout->add(cg->primitive_fields(), start);
      _layout->add(cg->oop_fields(), start);
      need_tail_padding = true;
    }
  }

  // 末尾填充,保证竞争组后面的字段不会因意外共享缓存行
  if (need_tail_padding) {
    insert_contended_padding(_layout->last_block());
  }

  // 静态字段布局:先放引用字段,再放基本字段(顺序影响类静态区的排列)
  _static_layout->add_contiguously(this->_static_fields->oop_fields());
  _static_layout->add(this->_static_fields->primitive_fields());

  epilogue();  // 将最终计算的偏移量写入 FieldInfo
}

关键逻辑解读

  • regular_field_sorting :将基本类型字段(long, double, int, float, char, short, byte, boolean)按大小从大到小排序,这样大字段优先对齐,产生的内存空隙更容易被小字段填补。

  • @Contended 处理 :Java 8 引入的 @Contended 注解通过插入填充(padding)使字段独立于一个缓存行(通常 64 字节),避免多线程下的伪共享。JVM 会在标记字段前后各填充约 64 字节(实际大小取决于 -XX:ContendedPaddingWidth)。

  • 静态字段单独布局:静态字段存储在类元数据的静态区,不在对象实例中,但布局逻辑相似。


四、Best‑Fit 空隙填充算法:FieldLayout::add

FieldLayout::add 负责将一组字段块(LayoutRawBlock)插入到已有的布局链中,它使用一种改进的 Best‑Fit 策略:从后向前扫描空闲块,选择能容纳当前字段的最小空闲块。这样做既减少了内存碎片,又提高了缓存局部性(新字段尽可能放在靠近末尾的已有空隙中)。

cpp

复制代码
// 源码位置:hotspot/share/classfile/fieldLayout.cpp
void FieldLayout::add(GrowableArray<LayoutRawBlock*>* list, LayoutRawBlock* start) {
  if (list == NULL) return;
  if (start == NULL) start = this->_start;
  bool last_search_success = false;
  int last_size = 0;
  int last_alignment = 0;

  for (int i = 0; i < list->length(); i++) {
    LayoutRawBlock* b = list->at(i);
    LayoutRawBlock* candidate = NULL;

    // 情况1:起始块就是末尾块 → 直接追加
    if (start == last_block()) {
      candidate = last_block();
    }
    // 情况2:当前字段大小/对齐与上一个完全相同,且上次搜索失败 → 直接追加(避免重复无用扫描)
    else if (b->size() == last_size && b->alignment() == last_alignment && !last_search_success) {
      candidate = last_block();
    }
    else {
      last_size = b->size();
      last_alignment = b->alignment();
      LayoutRawBlock* cursor = last_block()->prev_block();
      last_search_success = true;
      // 从末尾向前扫描,寻找所有能容纳 b 的空闲块
      while (cursor != start) {
        if (cursor->kind() == LayoutRawBlock::EMPTY &&
            cursor->fit(b->size(), b->alignment())) {
          // 选择最小的空闲块(best‑fit)
          if (candidate == NULL || cursor->size() < candidate->size()) {
            candidate = cursor;
          }
        }
        cursor = cursor->prev_block();
      }
      if (candidate == NULL) {         // 没找到合适空隙
        candidate = last_block();      // 追加到末尾
        last_search_success = false;
      }
    }
    // 将字段块插入到选中的空闲块中
    insert_field_block(candidate, b);
  }
}

值得注意的优化

  • 记录上一次字段的尺寸和对齐,如果本次字段完全相同且上次搜索无果,则不再扫描(因为布局没有变化,结果必然相同)。

  • 扫描方向从后向前,因为新字段更有可能在末尾附近找到空隙,减少对前面已稳定布局的扰动。

  • 选用最小合适空闲块(best‑fit)而非最先找到的(first‑fit),能更有效地控制碎片。


五、对齐处理与偏移量写入:insert_field_block

当选定了空闲块 slot 来放置字段 block 后,insert_field_block 负责对齐调整和最终偏移量的赋值。

cpp

复制代码
LayoutRawBlock* FieldLayout::insert_field_block(LayoutRawBlock* slot, LayoutRawBlock* block) {
  assert(slot->kind() == LayoutRawBlock::EMPTY, "只能插入到空闲块中");

  // 检查 slot 的起始偏移是否满足 block 的对齐要求
  if (slot->offset() % block->alignment() != 0) {
    int adjustment = block->alignment() - (slot->offset() % block->alignment());
    // 创建一个小的填充块(EMPTY)来补齐对齐
    LayoutRawBlock* adj = new LayoutRawBlock(LayoutRawBlock::EMPTY, adjustment);
    insert(slot, adj);   // 先插入填充块
  }

  insert(slot, block);   // 再插入真正的字段块

  // 如果原空闲块被完全用尽,则将其从链表中移除
  if (slot->size() == 0) {
    remove(slot);
  }

  // 关键:将计算出的字段偏移量写回 FieldInfo 结构
  FieldInfo::from_field_array(_fields, block->field_index())->set_offset(block->offset());
  return block;
}

对齐逻辑

  • 假设 slot 起始偏移是 6,而 block 需要 8 字节对齐,则 adjustment = 8 - (6 % 8) = 2。JVM 会先创建一个 2 字节的空白填充块,使 slot 剩余部分的起始偏移变为 8,满足对齐。

  • 字段块的 offset() 方法会返回经过对齐调整后的最终偏移量。

  • 这个偏移量最终被写入 FieldInfo,供运行时访问。


六、偏移量的编码存储:FieldInfo::set_offset

FieldInfo 是 JVM 内部描述字段的结构,它存储在一个 Array<u2> 中(每个 u2 为 16 位)。为了在有限空间内保存偏移量(32 位)以及额外的标志位,JVM 采用了位打包技巧。

cpp

复制代码
// 源码位置:hotspot/share/oops/fieldInfo.hpp
static FieldInfo* from_field_array(Array<u2>* fields, int index) {
  // 每个 FieldInfo 占用 field_slots 个 u2 元素
  return ((FieldInfo*)fields->adr_at(index * field_slots));
}

void set_offset(u4 val) {
  // 左移 FIELDINFO_TAG_SIZE 位(通常为 2),为标记位留出空间
  val = val << FIELDINFO_TAG_SIZE;
  // 将低 16 位存入第一个 u2,并打上 TAG_OFFSET 标记
  _shorts[low_packed_offset] = extract_low_short_from_int(val) | FIELDINFO_TAG_OFFSET;
  // 高 16 位存入第二个 u2
  _shorts[high_packed_offset] = extract_high_short_from_int(val);
}

为什么需要左移 2 位?

  • 因为偏移量总是 4 字节对齐的(至少 JVM 保证对象内字段偏移按字长对齐),最低 2 位总是 0。

  • JVM 将这些空闲的比特位用来存储标记(比如 FIELDINFO_TAG_OFFSET 表示该槽位存的是偏移量,不是其他类型的数据)。

  • 这样在访问时,右移 2 位并清除标记即可恢复原始偏移量,节省了额外的存储空间。


七、运行时:Unsafe 如何利用偏移量访问字段

当 Java 代码通过 Unsafe.getChar(obj, offset) 读取字段时,实际上直接使用了解析阶段计算好的偏移量。

java

复制代码
// 源码位置:jdk/src/share/classes/sun/misc/Unsafe.java (实际在 Unsafe 实现类中)
public char getChar(Object obj) throws IllegalArgumentException {
    ensureObj(obj);
    return unsafe.getChar(obj, fieldOffset);
}

abstract class UnsafeFieldAccessorImpl {
    UnsafeFieldAccessorImpl(Field field) {
        this.field = field;
        if (Modifier.isStatic(field.getModifiers()))
            fieldOffset = unsafe.staticFieldOffset(field);  // 静态字段偏移
        else
            fieldOffset = unsafe.objectFieldOffset(field);  // 实例字段偏移
        isFinal = Modifier.isFinal(field.getModifiers());
    }
}

unsafe.objectFieldOffset(field) 是一个 native 方法,它最终会调用 objectFieldOffset0,从 Field 对象持有的 FieldInfo 中读取之前 set_offset 写入的偏移量。

java

复制代码
public long objectFieldOffset(Field f) {
    if (f == null) throw new NullPointerException();
    return objectFieldOffset0(f);
}
private native long objectFieldOffset0(Field f);

在 JVM 内部,objectFieldOffset0 会找到对应字段的 FieldInfo,读取其中的偏移量(右移 2 位去掉标记),然后返回给 Java 层。此后,Unsafe 的所有 get*put* 操作都直接基于这个数值,绕过了 Java 的可见性和访问控制检查,速度堪比 C 语言的内存读写。


八、总结:从字节码到直接内存访问的完整闭环

整条链路展示了 JVM 设计中的几个精妙之处:

  1. 解耦与延迟:字段偏移量的计算发生在类加载后处理阶段,与字节码解析解耦;偏移量的存储紧凑且带标记,节省内存。

  2. 优化第一:基本类型字段降序排列、best‑fit 空隙填充、对齐调整,都是为了减少对象大小并提高访问效率。

  3. 并发感知@Contended 注解的填充机制直接融合在布局算法中,体现了 JVM 对多核时代的适应。

  4. 底层访问友好Unsafe 直接消费这些偏移量,使得 JVM 内部和 JDK 底层库(如 AtomicLong)能够实现极高性能的字段访问。

当你下次使用 Unsafe 或者好奇某个 long 字段在内存中的位置时,不妨回想一下:这一切都源于 ClassFileParser 在加载那一刻的一次次扫描、比较和填充。理解这段代码,不仅有助于性能调优,更能让你对 Java 平台的底层魅力有更深刻的认知。

相关推荐
指令集梦境9 小时前
图解:单调栈算法模板(Java语言)
java·开发语言·算法
IronMurphy9 小时前
多线程问!
java·jvm·spring
小灰灰搞电子9 小时前
C++ boost::circular_buffer 详解:原理、用法与实战
开发语言·c++·boost
vx-Biye_Design9 小时前
springboot安阳地区研学旅游服务小程序-计算机毕业设计源码12785
java·vue.js·windows·spring boot·tomcat·maven·mybatis
whaledown10 小时前
Kafka 与 Java 消息队列入门:用订单场景理解核心机制
java·kafka·消息队列·springboot
Moshow郑锴10 小时前
Ubuntu用SDKMAN轻松管理多个Java 版本
java·ubuntu·sdkman
阿昌喜欢吃黄桃10 小时前
RocketMq事务消息原理
java·中间件·消息队列·rocketmq·mq
CoderYanger10 小时前
A.每日一题:2095. 删除链表的中间节点
java·数据结构·程序人生·leetcode·链表·面试·职场和发展
Hanniel10 小时前
Python描述符(下):内置机制揭秘
开发语言·python·机器学习
摇滚侠10 小时前
MyBatis+Spring+SpringMVC SSM 整合 179-185
java·spring·mybatis