在前面我们介绍过new运算符,这个操作实际上上包含了如下3个步骤:

  1. 调用operator new的标准库函数。此函数会分配一块内存空间以便函存储相应类型的实例;
  2. 调用相应类的构造函数;
  3. 返回一个指向该对象的指针。

在第一步中,其实我们可以自己写个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,根据需要有两个可选择性的字段,如下:

C++指针和地址偏移在HotSpot VM中的应用-LMLPHP

根据Method决定是否要多开辟内存来存储native_function和signature_handler,源代码如下:

// 源代码位置:openjdk/hotspot/src/share/vm/oops/method.cpp

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性能剖析与故障诊断等话题,欢迎加入。
C++指针和地址偏移在HotSpot VM中的应用-LMLPHP

09-23 17:14