在 Java 开发中,我们很少直接关心对象内部的字段是如何排列的------直到我们遇到内存对齐问题、伪共享性能瓶颈,或者使用 sun.misc.Unsafe 进行底层操作。实际上,JVM 在类加载的最后阶段,会精心计算每个字段在对象内存中的偏移量。这个偏移量一旦确定,Unsafe.getXxx(obj, offset) 就能像 C 语言指针一样高速访问数据。
本文将结合 OpenJDK 源码,完整展示一条逻辑链条:
class 字节流 →
KlassFactory→ClassFileParser→ 字段布局 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 的构造函数接收字节流和各种加载上下文,依次完成:
-
解析字节流 :调用
parse_stream(stream, CHECK)读取常量池、字段表、方法表、属性等,填充内部数据结构(如_fields、_methods、_cp)。 -
后处理 :调用
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 设计中的几个精妙之处:
-
解耦与延迟:字段偏移量的计算发生在类加载后处理阶段,与字节码解析解耦;偏移量的存储紧凑且带标记,节省内存。
-
优化第一:基本类型字段降序排列、best‑fit 空隙填充、对齐调整,都是为了减少对象大小并提高访问效率。
-
并发感知 :
@Contended注解的填充机制直接融合在布局算法中,体现了 JVM 对多核时代的适应。 -
底层访问友好 :
Unsafe直接消费这些偏移量,使得 JVM 内部和 JDK 底层库(如AtomicLong)能够实现极高性能的字段访问。
当你下次使用 Unsafe 或者好奇某个 long 字段在内存中的位置时,不妨回想一下:这一切都源于 ClassFileParser 在加载那一刻的一次次扫描、比较和填充。理解这段代码,不仅有助于性能调优,更能让你对 Java 平台的底层魅力有更深刻的认知。