JVM对象模型详解:从底层结构到实战意义(完整版)

核心词:JVM对象模型、OOP-Klass、对象头、实例数据、对齐填充、Mark Word、指针压缩、对象创建流程、内存计算

一、开篇:我们每天都在new对象,但你真的知道对象在JVM里长什么样吗?

在Java开发中,User user = new User(); 这行代码几乎每天都会写,但很少有开发者深入思考其底层逻辑:

当JVM执行这行代码时,堆内存中到底会生成什么样的结构?为什么我们能通过user.getName()调用方法?为什么同样是对象,new Object()new User()的内存占用不一样?为什么GC能准确识别并回收无用对象?

这些问题的核心答案,都藏在JVM对象模型中。

JVM对象模型,本质是JVM对Java对象的底层内存表示规范,它定义了对象在堆内存中的存储布局、对象与类元数据的关联方式、对象访问机制,以及对象生命周期的管理逻辑。

它不仅是JVM内存分配、垃圾回收、锁优化的基础,更是理解Java高级特性的关键。掌握JVM对象模型,能帮你从根源上排查内存泄漏、优化内存占用、看懂GC日志,甚至应对面试中关于JVM的高频难点问题。

本文以主流的HotSpot虚拟机为核心,全面讲解JVM对象模型的底层实现,包括OOP-Klass模型细节、对象内存结构拆解、对象创建与销毁的完整流程、内存大小精准计算、实战优化技巧,以及与GC、锁机制的关联,全程补充细节和相关延伸知识,代码统一放入代码块,兼顾全面性与实用性。

二、核心概念:Java对象 ≠ JVM对象

2.1 逻辑对象与物理对象的本质区别

我们在代码中定义和使用的Java对象,与JVM在内存中实际管理的对象,是两个不同层面的概念,二者通过JVM对象模型完成映射:

  • Java逻辑对象:面向开发者的抽象概念,由class定义,包含属性、方法、构造器等语义信息,比如我们定义的User类及其实例,核心作用是承载业务逻辑。

  • JVM物理对象:JVM在堆内存中分配的内存块,是Java逻辑对象的底层具象化表示,包含对象头、实例数据、对齐填充三部分,核心作用是让JVM能够识别、管理和访问对象。

简单来说,JVM对象模型就是“翻译器”,将Java开发者编写的逻辑对象,翻译成JVM能够识别和管理的物理对象,实现“面向开发者的易用性”与“面向JVM的高效性”的平衡。

2.2 HotSpot核心:OOP-Klass模型

HotSpot虚拟机并没有直接将Java对象的实例数据和类元数据存储在一起,而是设计了OOP-Klass模型,将二者分离存储,这种设计的核心目的是节省内存、提升JVM管理效率——毕竟一个类可以创建多个实例,类元数据无需每个实例都存储一份,只需共享即可。

OOP-Klass模型的核心结构的是两个核心组件:OOP和Klass,二者通过指针关联,共同构成一个完整的JVM物理对象。

2.2.1 OOP

OOP是指向堆内存中对象实例的指针,其底层对应HotSpot虚拟机中的具体实现类,核心作用是存储Java对象的实例数据对象头,不存储类元数据——因为类元数据是所有实例共享的,无需每个OOP都存储。

HotSpot中常见的OOP实现类:

  • InstanceOopDesc:最常用的OOP实现,对应普通Java对象,存储对象头和实例变量。

  • ArrayOopDesc:对应数组对象,除了对象头和实例数据,还会额外存储数组的长度。

  • PrimitiveOopDesc:对应基本类型包装类对象,内部会存储对应的基本类型值,优化基本类型包装类的内存占用。

关键细节:每个Java实例对象,在堆内存中都会对应一个唯一的OOP实例,OOP是对象在堆内存中的“实际载体”,JVM对对象的所有操作,本质上都是通过OOP完成的。

2.2.2 Klass

Klass是Java类在JVM中的底层表示,核心作用是存储对象的类元数据,是所有该类实例对象的“共享模板”,每个Java类在被类加载器加载后,都会在方法区生成一个对应的Klass实例,所有该类的OOP实例,都会通过“元数据指针”指向这个Klass实例。

Klass中存储的核心类元数据:

  • 类的基本信息:类名、父类、接口、访问修饰符、类加载器引用。

  • 方法信息:方法的字节码、方法参数、返回值、异常表、方法访问修饰符。

  • 静态变量:类的静态属性,存储在方法区,由所有实例共享。

  • 常量池:类的常量、符号引用。

  • 字段信息:类的成员变量的名称、类型、访问修饰符、偏移量。

  • 其他信息:类的初始化状态、锁相关信息、GC相关标记等。

举个实际案例:当我们调用user.getName()时,JVM的执行流程:

  1. 通过user引用,找到堆内存中的OOP实例;

  2. 通过OOP实例中的“元数据指针”,找到方法区中的Klass实例;

  3. 在Klass实例中,根据方法名getName,找到对应的方法字节码;

  4. JVM执行该方法字节码,通过OOP实例获取当前对象的实例变量,返回结果。

2.2.3 OOP与Klass的关联关系

OOP与Klass的关联,核心依赖OOP实例中的“元数据指针”,这是二者关联的唯一桥梁,具体细节如下:

  • OOP实例的对象头中,专门预留了一块空间用于存储Klass Pointer,指向方法区中的Klass实例;

  • 一个Klass实例可以对应多个OOP实例,所有OOP实例共享同一个Klass实例的类元数据;

  • 当JVM需要判断一个对象的类型时,本质上就是通过OOP的Klass Pointer,判断其指向的Klass实例是否是对应类的Klass实例。

通俗比喻:OOP就像“每个人的具体信息”,Klass就像“人类的通用模板”;所有“人”都遵循“人类模板”,模板定义了“人”的共性,每个具体的人有自己的个性数据,且所有“人”共享同一个“模板”。

三、深入结构:一个Java对象在内存里的完整组成

无论是什么类型的Java对象,在堆内存中的存储结构都固定由三部分组成:对象头→ 实例数据→ 对齐填充

这三部分的大小、内容,直接决定了对象的内存占用,也是我们排查内存问题、优化内存占用的核心依据,下面逐一拆解每一部分的细节、底层实现和相关延伸知识。

3.1 对象头:对象的“身份证+状态标识”

对象头是对象的核心标识,存储了对象的关键状态信息和关联信息,占用内存大小固定,是JVM实现GC、锁优化、对象类型识别的核心依赖。

对象头的大小:

  • 32位JVM:固定8字节;

  • 64位JVM:

    • 开启指针压缩:12字节;

    • 未开启指针压缩:16字节;

补充知识:指针压缩的核心目的是节省内存——64位指针本身占用8字节,开启指针压缩后,可将指针压缩为4字节,大幅减少对象头和引用类型的内存占用,且不会影响访问效率。

对象头由三部分组成:Mark Word + Klass Pointer + 数组长度。

3.1.1 Mark Word:对象的“状态仪表盘”

Mark Word是对象头的核心,占用8字节,存储对象的各种状态信息,其结构是动态复用的——不同的对象状态,Mark Word存储的内容不同,目的是用有限的字节存储更多信息,节省内存。

64位JVM下,Mark Word的不同状态及存储内容:

对象状态 Mark Word存储内容 补充说明
无锁状态 25位:对象哈希码 + 31位:未使用 + 4位:GC分代年龄 + 2位:锁状态标识 hashCode是延迟计算的,首次调用时才会计算并存储,后续直接复用;GC分代年龄范围0~15,超过15则晋升到老年代。
偏向锁状态 54位:偏向线程ID + 2位:偏向锁标识 + 4位:GC分代年龄 + 2位:锁状态标识 偏向锁是JVM的锁优化机制,减少锁竞争的开销;偏向锁标识为1时,锁状态标识仍为01,区分于无锁状态。
轻量级锁状态 62位:指向栈帧中锁记录的指针 + 2位:锁状态标识 轻量级锁通过CAS操作实现锁竞争,无需阻塞线程,提升性能;锁记录存储在获取锁的线程栈帧中。
重量级锁状态 62位:指向重量级锁的指针 + 2位:锁状态标识 重量级锁会阻塞线程,性能开销较大;monitor是操作系统层面的锁,由操作系统管理线程的阻塞与唤醒。
GC标记状态 62位:未使用 + 2位:锁状态标识 GC进行标记时,会将对象的Mark Word设置为该状态,标识对象已被标记,避免重复标记。

延伸知识:Mark Word的动态复用,是JVM锁优化的核心基础——JVM通过修改Mark Word的锁状态标识,实现“偏向锁→轻量级锁→重量级锁”的锁升级,兼顾不同场景的性能需求。

3.1.2 Klass Pointer:对象的“类型导航器”

Klass Pointer是OOP实例指向Klass实例的指针,核心作用是让JVM识别对象的类型,找到对应的类元数据,其占用内存大小与是否开启指针压缩相关:

  • 64位JVM:开启指针压缩后占4字节,未开启则占8字节;

  • 32位JVM:固定占4字节。

关键细节:

  • Klass Pointer指向的是方法区中的Klass实例,而非Java代码中的Class对象;

  • 当我们通过user.getClass()获取Class对象时,JVM会通过Klass Pointer找到Klass实例,再通过Klass实例生成对应的Class对象并返回;

  • 如果关闭指针压缩,Klass Pointer会占用8字节,导致对象头大小增加,进而增加整个对象的内存占用。

3.1.3 数组长度

普通对象的对象头只有Mark Word和Klass Pointer两部分,但数组对象的对象头会额外增加4字节,用于存储数组的长度。

原因:数组的长度是动态的,无法通过Klass实例获取,因此需要在对象头中单独存储,让JVM能快速获取数组长度。

补充:数组对象的对象头大小= Mark Word + Klass Pointer + 数组长度 = 16字节;普通对象的对象头大小为12字节。

3.2 实例数据:对象的“核心数据载体”

实例数据是对象的核心内容,存储Java对象的所有实例变量,其内存占用大小由实例变量的类型和数量决定。

关键细节:

3.2.1 实例变量的存储顺序

JVM并不是按照我们在代码中定义的实例变量顺序存储,而是按照“字段类型大小”从大到小排序存储,目的是减少内存空洞,提升内存利用率,具体排序规则:

long/double→ int/float→ char/short→ boolean/byte→ 引用类型

示例:我们在代码中定义的User类:


class User {
    String name;
    long id;
    int age;
    boolean sex;
}
    

JVM实际的存储顺序:id→ name→ age→ sex,而非代码中定义的name→id→age→sex。

延伸知识:JVM的这种排序方式,称为“字段重排”,是JVM的内存优化手段之一,通过减少内存空洞,提升内存利用率。

3.2.2 父类与子类实例变量的存储顺序

当一个类继承自另一个类时,实例变量的存储顺序遵循“父类在前,子类在后”的规则,即:父类的实例变量先存储,子类的实例变量再存储,且父类和子类的实例变量,各自遵循“从大到小”的排序规则。

示例:


// 父类
class Person {
    long id;
    String name;
}

// 子类
class User extends Person {
    int age;
    boolean sex;
}

JVM实际的存储顺序:父类id→ 父类name→ 子类age→ 子类sex。

关键:子类不会继承父类的静态变量,静态变量存储在方法区,由所有实例共享,不占用对象的实例数据内存。

3.2.3 内存对齐

实例数据中的每个实例变量,其存储地址必须对齐到其自身类型大小的整数倍,这是JVM的内存对齐规则,目的是提升CPU的访问效率——CPU访问内存时,是按“固定大小的块”读取的,如果变量的存储地址未对齐,CPU需要多次读取才能获取完整的变量数据,降低访问效率。

不同类型变量的对齐要求:

  • long/double:存储地址必须是8的整数倍;

  • int/float:存储地址必须是4的整数倍;

  • char/short:存储地址必须是2的整数倍;

  • boolean/byte:无对齐要求;

  • 引用类型:存储地址必须是4的整数倍或8的整数倍。

示例:如果一个int类型变量的存储地址是10,JVM会自动填充2字节,将其存储地址调整为12,确保内存对齐。

3.2.4 实例变量的默认值初始化

当JVM为对象分配内存后,会自动将实例数据中的所有实例变量初始化为默认值,这也是Java中实例变量无需赋值就能直接使用的原因,不同类型的默认值:

  • 基本类型:long/double→0,int→0,float→0.0f,char→’u0000’,short→0,byte→0,boolean→false;

  • 引用类型:null。

补充:构造方法的执行,是在实例变量默认值初始化之后,目的是覆盖默认值,设置开发者自定义的初始值。

3.3 对齐填充:对象的“内存补位器”

对齐填充是JVM的内存对齐机制,无实际业务意义,仅用于补齐内存,确保整个对象的总内存大小是8字节的整数倍、4字节的整数倍。

核心目的:提升CPU访问效率——CPU访问内存时,是按8字节为一个块读取的,如果对象的总内存大小不是8字节的整数倍,CPU需要多次读取才能获取完整的对象数据,增加访问开销;对齐填充能让对象的总内存大小满足8字节整数倍,让CPU一次读取完整对象,提升访问效率。

示例:

  • 场景1:普通对象,对象头+ 实例数据= 17字节;17不是8的整数倍,需要填充7字节,总大小变为24字节;

  • 场景2:普通对象,对象头+ 实例数据= 28字节;28不是8的整数倍,需要填充4字节,总大小变为32字节;

  • 场景3:数组对象,对象头+ 实例数据= 56字节;56是8的整数倍,无需填充,总大小为56字节。

补充知识:对齐填充的大小是动态变化的,取决于对象头+实例数据的总大小,只要最终总大小是8字节的整数倍即可,填充的字节内容无意义。

四、对象的生命周期:从new到销毁的完整流程

一个Java对象的生命周期,从new关键字执行开始,到被GC回收销毁结束,整个过程分为5个核心步骤,每一步都与OOP-Klass模型、对象内存结构紧密相关,下面详细拆解每一步的底层逻辑和细节。

完整流程:类加载检查 → 堆内存分配 → 零值初始化 → 对象头设置 → 构造方法执行 → 对象使用 → GC标记回收

4.1 第一步:类加载检查

当JVM执行new User()时,首先会进行类加载检查,核心目的是验证User类是否已被类加载器加载、链接、初始化,是否存在对应的Klass实例:

  1. JVM会先检查方法区中,是否存在User类的Klass实例;

  2. 如果不存在,则执行类加载流程:加载→ 链接→ 初始化;

    • 加载:类加载器读取User.class文件,将其转化为二进制字节流,生成对应的Klass实例,存储到方法区;

    • 链接:分为验证、准备、解析;

    • 初始化:执行类的静态代码块、静态变量赋值语句,完成Klass实例的初始化。

  3. 如果已存在,则直接进入下一步,无需重复加载。

关键:类加载检查的核心是确保Klass实例存在,因为后续创建OOP实例时,需要通过Klass Pointer关联到该Klass实例。

4.2 第二步:堆内存分配

类加载检查通过后,JVM会在堆内存中为User对象分配内存,内存大小 = 对象头大小 + 实例数据大小 + 对齐填充大小,分配方式有两种:

4.2.1 内存分配方式

  • 指针碰撞:适用于堆内存连续、无内存碎片的场景,JVM维护一个指针,指向堆内存的空闲区域,分配内存时,直接将指针向后移动对应大小的内存空间,效率极高;

  • 空闲列表:适用于堆内存不连续、存在内存碎片的场景,JVM维护一个空闲列表,记录堆内存中的空闲区域,分配内存时,从空闲列表中找到一块足够大的空闲区域,分配给对象,效率略低于指针碰撞。

补充:JDK8默认使用Parallel GC,堆内存采用指针碰撞方式分配内存;如果使用CMS GC,则采用空闲列表方式。

4.2.2 内存分配的线程安全问题

多线程并发创建对象时,可能会出现“指针碰撞冲突”,JVM提供两种解决方案:

  • CAS + 失败重试:对指针碰撞操作进行CAS原子操作,确保同一时间只有一个线程能成功分配内存,失败的线程重试;

  • 本地线程分配缓冲:JVM为每个线程分配一块独立的本地内存缓冲,线程创建对象时,优先在自己的TLAB中分配内存,无需竞争,只有当TLAB用完时,才会使用CAS方式在堆内存中分配,这是JDK8默认的解决方案,大幅提升多线程并发创建对象的效率。

内存分配完成后,JVM会创建一个OOP实例,并将其存储在分配的堆内存区域,此时OOP实例的内存区域还未初始化。

4.3 第三步:零值初始化

内存分配完成后,JVM会自动将OOP实例中的实例数据初始化为默认值,这一步的目的是确保对象在构造方法执行前,所有实例变量都有合法的初始值,避免出现未初始化的异常。

关键细节:零值初始化仅针对实例变量,静态变量的默认值初始化是在类加载的“准备阶段”完成的,与对象创建无关;零值初始化完成后,对象的实例变量还未设置开发者自定义的初始值。

4.4 第四步:设置对象头

零值初始化完成后,JVM会设置OOP实例的对象头,核心是完成OOP与Klass的关联,同时设置对象的初始状态,具体操作:

  • 设置Mark Word:初始状态为“无锁状态”,GC分代年龄设为0,hashCode暂不计算;

  • 设置Klass Pointer:指向方法区中User类的Klass实例,建立OOP与Klass的关联;

  • 如果是数组对象,还会设置对象头中的“数组长度”,赋值为数组的实际长度。

这一步完成后,JVM层面的物理对象已经创建完成,但Java逻辑对象的初始化还未完成。

4.5 第五步:执行构造方法

对象头设置完成后,JVM会调用User类的构造方法,执行开发者编写的初始化逻辑,包括:

  • 实例变量的自定义赋值;

  • 构造方法中的业务逻辑;

  • 如果有父类,会先执行父类的构造方法,再执行子类的构造方法。

构造方法执行完成后,一个完整的Java对象才算创建完成,此时user引用会指向堆内存中的OOP实例,开发者可以通过user引用访问对象的属性、调用方法。

4.6 补充:对象的使用与销毁

对象创建完成后,进入使用阶段,开发者通过引用访问对象,JVM通过OOP-Klass模型管理对象的方法调用、属性访问;当对象不再被任何引用指向时,会被GC回收销毁,销毁流程:

  1. GC标记阶段:通过可达性分析法,标记出堆内存中的无用对象;

  2. GC回收阶段:回收无用对象占用的堆内存,释放内存空间;

  3. 对象销毁:OOP实例被回收,其关联的Klass实例不会被回收。

延伸知识:对象头中的GC分代年龄,会在每次Minor GC时加1,当年龄达到15时,对象会被晋升到老年代,老年代的对象会在Full GC时被回收。

五、实战:对象内存大小精准计算

掌握对象内存结构后,我们可以精准计算任意Java对象的内存大小,核心公式:

对象总大小 = 对象头大小 + 实例数据大小 + 对齐填充大小

下面结合普通对象、数组对象、继承场景的案例,详细讲解计算过程,并介绍实战中验证内存大小的工具。

5.1 普通对象内存计算

案例1:简单User类


class User {
    long id;
    String name;
    int age;
    boolean sex;
}
    

计算步骤:

  1. 对象头大小:普通对象,开启指针压缩,对象头 = Mark Word + Klass Pointer = 12字节;

  2. 实例数据大小:按“从大到小”排序,id + name + age + sex = 17字节;

    • 注意:sex无需对齐,因此实例数据总大小为17字节;
  3. 对齐填充大小:对象头+ 实例数据= 29字节;29不是8的整数倍,需要填充3字节;

  4. 对象总大小:12 + 17 + 3 = 32字节。

结论:一个User实例对象,内存占用为32字节。

案例2:空对象


class EmptyObject extends Object {
    // 无任何实例变量
}
    

计算步骤:

  1. 对象头大小:12字节;

  2. 实例数据大小:无实例变量,0字节;

  3. 对齐填充大小:12 + 0 = 12字节,12不是8的整数倍,填充4字节;

  4. 对象总大小:12 + 0 + 4 = 16字节。

结论:空对象的内存占用为16字节,核心是对象头的占用,对齐填充补齐到8的整数倍。

5.2 数组对象内存计算

案例:int[] arr = new int[10];

计算步骤:

  1. 对象头大小:数组对象,开启指针压缩,对象头 = Mark Word + Klass Pointer + 数组长度 = 16字节;

  2. 实例数据大小:int类型占4字节,10个int元素 = 10 × 4 = 40字节;

  3. 对齐填充大小:16 + 40 = 56字节,56是8的整数倍,无需填充;

  4. 对象总大小:16 + 40 + 0 = 56字节。

结论:一个长度为10的int数组,内存占用为56字节。

5.3 继承场景对象内存计算

案例:子类User继承父类Person,包含父类和子类的实例变量


// 父类
class Person {
    long id;
    String name;
}

// 子类
class User extends Person {
    int age;
    boolean sex;
}
    

计算步骤:

  1. 对象头大小:12字节;

  2. 实例数据大小:父类在前,子类在后,合计 8+4+4+1 = 17字节;

  3. 对齐填充大小:12 + 17 = 29字节,填充3字节,总大小32字节;

  4. 对象总大小:12 + 17 + 3 = 32字节。

结论:子类User实例的内存占用为32字节,与无继承的User实例大小一致。

5.4 实战工具:验证对象内存大小

上述计算结果可以通过工具验证,确保准确性,推荐两种常用工具:

5.4.1 JOL工具

JOL是OpenJDK提供的工具,专门用于查看Java对象的内存布局和大小,使用简单,精准度高,Maven依赖:


<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.16</version>
</dependency>
   

使用代码:


public class ObjectSizeTest {
    public static void main(String[] args) {
        User user = new User();
        // 打印对象的内存布局和大小
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

class User {
    long id;
    String name;
    int age;
    boolean sex;
}
    

输出结果中,会明确显示对象的内存布局和总大小,可直接验证我们的计算结果。

5.4.2 VisualVM工具

VisualVM是JDK自带的可视化工具,可用于查看JVM内存、对象实例、内存占用等信息,操作步骤:

  1. 启动VisualVM;

  2. 连接需要监控的Java进程;

  3. 进入“抽样器”→“内存”,启动抽样,创建User对象后,可查看User对象的内存占用大小。

六、对象模型与GC、锁、内存优化的深度关联

JVM对象模型不仅是对象存储的基础,更是GC、锁优化、内存优化的核心依赖,理解它们之间的关联,能帮你更好地排查问题、优化系统性能。

6.1 与GC的关联

GC的核心任务是回收无用对象,而GC能准确识别对象、管理对象生命周期,完全依赖JVM对象模型:

  • 对象存活判定:GC通过“可达性分析法”判定对象是否存活,而可达性分析的核心是通过对象的引用,遍历对象之间的关联关系,本质是遍历OOP实例之间的引用链路;

  • 对象分代管理:对象头中的GC分代年龄,决定了对象属于新生代还是老年代,GC会根据分代策略对不同代的对象进行回收,减少GC开销;

  • 对象回收效率:OOP-Klass模型分离了实例数据和类元数据,GC回收时,只需回收堆内存中的OOP实例,无需回收方法区中的Klass实例,提升回收效率;

  • 大对象处理:JVM会根据对象的内存大小,判定是否为大对象,大对象会直接进入老年代,避免频繁Minor GC。

6.2 与锁优化的关联

JVM的锁优化,核心是通过修改对象头中的Mark Word实现的,对象模型是锁优化的基础:

  • 锁状态存储:Mark Word中专门预留了2位用于存储锁状态标识,不同的锁状态对应不同的Mark Word结构;

  • 锁升级机制:JVM会根据线程竞争情况,修改Mark Word的锁状态,实现“偏向锁→轻量级锁→重量级锁”的升级,兼顾不同场景的性能需求;

  • 锁释放机制:锁释放时,JVM会将Mark Word恢复为对应状态,确保其他线程能正常获取锁。

示例:当一个线程获取偏向锁时,JVM会将Mark Word中的偏向线程ID设为当前线程ID,偏向锁标识设为1,后续该线程再次获取锁时,无需CAS操作,直接通过Mark Word判断即可,大幅提升性能。

6.3 与内存优化的关联

理解JVM对象模型后,我们可以针对性地优化对象内存占用,减少内存浪费,提升系统性能,常见的优化技巧:

  • 合理排列实例变量顺序:按照“从大到小”的顺序定义实例变量,减少内存空洞;

  • 避免使用过多的引用类型:引用类型,如果可以用基本类型替代,可减少内存占用;

  • 开启指针压缩:JDK8默认开启,无需手动配置,可减少对象头和引用类型的内存占用;

  • 避免创建过多的小对象:小对象的内存占用主要来自对象头,过多的小对象会浪费大量内存,可通过对象池复用

 

Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注