在前面我们介绍过new运算符,这个操作实际上上包含了如下3个步骤:
- 调用operator new的标准库函数。此函数会分配一块内存空间以便函存储相应类型的实例;
- 调用相应类的构造函数;
- 返回一个指向该对象的指针。
在第一步中,其实我们可以自己写个operator new函数对标准库函数进行重载,通常会根据类信息分配出需要的内存大小,但是分配内存的逻辑现在由我们自己控制,那我们就可以多分配一些内存,然后在多分配出来的内存上存储一些额外定义的信息。例如:
class Test {
private:
int a = 1;
public:
void *operator new(size_t requested_size) throw() {
return ::malloc(requested_size);
}
void *operator new(size_t requested_size, size_t length) throw() {
return ::malloc(requested_size + length);
}
long access_a_offset() {
return (size_t) ((intptr_t) &(((Test *) 16)->a) - 16);
}
u_char *start_b_address() {
return (u_char *) this + 4;
}
void set_b_value(long val) {
*start_b_address() = val;
}
long get_b_value() {
return (long) (*start_b_address());
}
};
我们重载了new运算符,第一个重载函数会分配类实例本来需要的内存大小,而第二个重载函数多分配了length个字节的大小。举个具体使用的例子,如下:
std::cout << sizeof(Test) << std::endl;
// 调用第一个operator new函数,分配的内存大小为8,用来存储变量
Test *t1 = new Test();
// 调用第二个operator new函数,分配的内存大小为16,用来存储变量外,还有空闲的8字节
Test *t2 = new (8) Test();
std::cout << t2->access_a_offset() << std::endl;
t2->set_b_value(10);
std::cout << t2->get_b_value() << std::endl;
最终打印的值为0 10
如上的例子在内存末尾多开辟了8字节用来存储long类型的数据,因为这个数据没有对应的属性用来直接存取,所以只能通过偏移来操作。
注意:我们通过this获取到当前实例内存的首地址时,必须要强制转换为u_char*类型,这样加4后才会移动4个字节,因为u_char占用一个字节,此时的指针指向u_char数据类型。其实还可以这样获取:
(u_char *) (this + 1)
this指向的是Test类型,加1后指针本身占用的内存大小的末尾,将其强制转换为指向u_char类型的指针即可。
在HotSpot VM中也有这样的操作,例如Method,根据需要有两个可选择性的字段,如下:
根据Method决定是否要多开辟内存来存储native_function和signature_handler,源代码如下:
int size = Method::size(access_flags.is_native());
return new (loader_data, size, false, MetaspaceObj::MethodType, THREAD) Method(cm, access_flags, size);
当为本地方法时,会为Method多开辟2 个指针大小的存储空间,然后使用new关键字创建对象。这里也重载了new运算符从指定的元数据区分配内存。
下面来看对这两个伪字段(不能通过类中的实例字段进行存取操作,但是又确实存在)的存取操作。
typedef u_char* address;
// 源代码位置:openjdk/hotspot/src/share/vm/oops/method.hpp
address* native_function_addr() const {
assert(is_native(), "must be native");
return (address*) (this+1);
}
address* signature_handler_addr() const {
return native_function_addr() + 1;
}
获取两个伪字段的地址。
注意这里的this+1,因为this的类型是Method实例,所以加1并不是加一个字节而是增加一个Method对应的字节数,即获取Method对应内存区域的下一个字节的地址;第二个native_function_addr() + 1,因为native_function_addr()返回的就是一个指针类型的数据,所以这里的加1是增加指针对应的字节数,64位下是8字节。
返回的类型为u_char**,也就是返回一个指向指针的指针。当我们要存储本地函数地址时,可如下操作:
// 读取操作
address current = *native_function;
// 存储操作
*native_function = function
其中的function的类型为address。
下面继续看偏移量的操作,HotSpot VM中经常做的操作就是计算某个变量的偏移量。例如定义的用来表示Java类的C++类Klass中有如下2个函数:
static ByteSize access_flags_offset(){
return in_ByteSize(offset_of(Klass, _access_flags));
}
其中的_access_flags属性就是定义在Klass中的,通过调用access_flags_offset()函数来计算这个属性在类中的偏移量。offset_of是一个宏,如下:
#define offset_of(klass,field) (size_t)((intx)&(((klass*)16)->field) - 16)
经过宏替换和格式调整后的方法如下:
static ByteSize access_flags_offset(){
return in_ByteSize((size_t)(
(intx)&( ((Klass*)16)->_access_flags) - 16
));
}
通过 (intx)&(((Klass*)16)->_access_flags) - 16 方式来计算出具体的偏移量。解释一下这种写法。
假如定义个变量Klass a; 我们都知道&a表示变量a的首地址,&(a._access_flags)表示变量_access_flags的地址,那么&(a._access_flags)减去&a就得到_access_flags的偏移量。
((Klass*)16)的地址为16,所以偏移量最终等于&( ((Klass*)16)->_access_flags)减去16。
当HotSpot VM要用一个成员变量的时候,它会根据对象的首地址加上成员的偏移量得到成员变量的地址。当对象的首地址为0时,得到的成员变量地址就是它的偏移量。
本人最近准备出一个手写Hotspot VM的课程,超级硬核,从0开始写HotSpot VM,将HotSpot VM所有核心的实现全部走一遍,如感兴趣,速速入群。
群里可讨论虚拟机和Java性能剖析与故障诊断等话题,欢迎加入。