通用内存快照裁剪压缩库Tailor介绍及源码分析(二)

通用内存快照裁剪压缩库Tailor介绍及源码分析(一)

上章节中我们通过源码学习和分析了dump内存快照的hook,本章节的重点则是分析裁剪和还原的实现。

裁剪压缩hprof

如何裁剪掉无用信息,我们需要对hprof文件格式有所了解。

认识hprof文件格式

hprof文件是二进制文件格式,其数据组织形式比较简单,整体可分为 Header和 Record 数组两部分,相关数据组织定义如下:

Header: "JAVA PROFILE 1.0.2" + size of identifiers + timestamp

(19byte + 4byte + 8byte, 总共31字节)

u1、u4等表示的是字节数,1字节、4字节等;这个实现中的ID是u4,但是ID的大小实际上是由Header中的“size of indentifiers”字段决定的。

文件以Header开始,JAVA PROFILE 1.0.2之后还有字符串结束符\0(0x00),算上的话是19字节。后面四个字节0x00000004是size of identifiers,接着是8字节的时间。

Header后面是Record数组

  Record:tag + time + length + body(1byte + 4byte + 4byte + byte[$length])

【查看支持的TAGs】

这里是tag为STRING IN UTF8和LOAD CLASS的body的结构:

在上面的样例hprof文件,第一个记录的tag是01, 时间是从报头中的时间戳开始的微秒数0x00000000,长度是0x0000000e,也就是说后面是14字节的body, body前四字节0x00 40 44 84为string ID,字符串的UTF8字符为0x24 24 49 4E 53 54 41 4E 43 45, 对应为$$INSTANCE。

Android 上 dump 出的 hprof 文件虽然也遵循 hprof 格式,但也有所不同,典型的是其一级TAG只有:STRING、LOAD_CLASS、HPROF_TAG_STACK_TRACE、HEAP_DUMP_SEGMENT、HEAP_DUMP_END。HEAP_DUMP_SEGMENT 又分了很多二级 TAG ,这些二级 TAG 中既有标准 hprof 定义的,也有 Android 自定义的 TAG。跟裁剪关系比较紧密的二级 TAG 是 PRIMITIVE_ARRAY_DUMP,存放的是诸如 byte[] 、char[] 、int[]等类型的数据,其格式如图所示:

通过 hprof 格式定义可以发现,直接裁剪掉所有的 byte[]和 char[]就可以实现对 Bitmap/String 对象的裁剪。同时其数据格式定义中还存在大量的无用数据,比如 timestamp、class-serial-number、stack-serial-number、reserved 数据等等,4byte 的 length/number 等也可以压缩成 3byte 或者 2byte 等等。

前面一节我们已经成功的hook write函数替换为自己的write_proxy了,接下来就是对string和bitmap对象的裁剪。

这里有两个重要的结构体:

//stream.hpp
struct Reader {
    virtual ~Reader() {}
    virtual bool isAvailable() = 0;

    char  *buffer;
    size_t length;
    size_t offset; //当前读数据位置
};

struct Writer {
    virtual ~Writer() {}
    virtual  int proxy(int flags, mode_t mode) = 0;
    virtual void flush(char *buff, size_t bytes, bool isEof) = 0;

    const char *name;
    int wrap;

    FILE  *target;
    char   buffer[MAX_BUFFER_SIZE];
    size_t offset; //当前写数据位置
};

对应的具体类是ByteReader和LibzWriter或不需要gzip的FileWriter。

Dump hprof 数据的时候,Reader对象用做接收数据的buffer缓存,然后将需要保留的字节copy或fill到Writer对象,通过writer写到target文件。

启用裁剪, fill(writer, const_cast<char *>(VERSION), 18); 先往writer->buffer写入18字节的Header,没有原Header中的 size of identifiers + timestamp共12字节。

//xloader.cpp
const char *VERSION = "JAVA PROFILE 6.0.1";
void Tailor_nOpenProxy(JNIEnv* env, jobject obj, jstring name, jboolean gzip) {
    target = -1;
    reader = new ByteReader();
    writer = createWriter(env->GetStringUTFChars(name, 0), gzip);
    fill(writer, const_cast<char *>(VERSION), 18);
    LOGGER(">>> open %s", ((0 == hook()) ? "success" : "failure"));
}


ssize_t write_proxy(int fd, const char *buffer, size_t count) {
    if (target == fd) {
        return handle(buffer, count);
    } else {
        return write(fd, buffer, count);
    }
}

inline ssize_t handle(const char *buffer, size_t count) {
//将本次write操作的字节流暂存在 reader->buffer
    reader->buffer = const_cast<char *>(buffer);
    reader->length = count; 
    reader->offset = 0;

    int result = 0;
    while (reader->isAvailable() && (result = handle(reader, writer)) == 0);
    if (result == 1) {
        target = -1;
    }

    return count;
}

 INT1、INT2、INT4宏表示从reader当前offset + N的位置读取1字节、2字节和4字节。

SEEK 是将reader的offset移动到+N的位置。

//Tailor.cpp
int handle(Reader *reader, Writer *writer) {
    uint8_t tag = INT1(reader, 0);
    switch (tag) {
    case 0x4A:
        SEEK(reader, 31);            //"JAVA PROFILE 1.0.X";  移动offset+=31,也就是跳过Header数据
        return 0;
    case HPROF_TAG_STRING:           // 0x01   字符串处理
        handle_STRING(reader, writer);
        return 0;
    case HPROF_TAG_LOAD_CLASS:       // 0x02
        handle_LOAD_CLASS(reader, writer);
        return 0;
    case HPROF_TAG_HEAP_DUMP:        // 0x0C
    case HPROF_TAG_HEAP_DUMP_SEGMENT:// 0x1C
        handle_HEAP_DUMP_SEGMENT(reader, writer);
        return 0;
    case HPROF_TAG_HEAP_DUMP_END:    // 0x2C
        handle_HEAP_DUMP_END(reader, writer);
        return 1;
    default:                         // unsupported tag, 直接跳过(裁剪)
        SEEK(reader, 9 + INT4(reader, 5));
        return 0;
    }
}

裁剪UTF8格式存储的字符串

前面介绍hprof文件格式提到记录的格式如下:

Record:tag + time + length + body(1byte + 4byte + 4byte + byte[$length]) 

在Header之后紧挨着的是string table:包含所有用到的字符串名称,包括类名、方法名、常量名等,每条记录的tag = 0x01。

STRING IN UTF8 tag在HPROF文件中用来表示字符串对象的内容,这些内容是以UTF-8编码的。

void handle_STRING(Reader *reader, Writer *writer) {
    FILL(writer, 0x01); //tag要写入writer保留下来, 
    SEEK(reader, 7); // reader.offset += 7,跳过(裁剪)7个字节,offset移动到length第3字节
    COPY(writer, reader, 2 + INT2(reader, 0));  //将剩下的两字节length+length长度的body copy到writer,reader->offset增加对应的count
}

inline void fill(Writer *writer, char value) {
    if (writer->offset + 1 > MAX_BUFFER_SIZE) {
        writer->flush(writer->buffer, writer->offset, false);
        writer->offset = 0;
    }

    writer->buffer[writer->offset++] = value;
}

inline void copy(Writer *writer, Reader *reader, size_t count) {
    if (writer->offset > 0) {
        writer->flush(writer->buffer, writer->offset, false);
        writer->offset = 0;
    }

    writer->flush(reader->buffer + reader->offset, count, false);
    reader->offset += count;
}

fill和copy最终都会写入target,区别是copy直接写入target, 而fill先缓存在writer->buffer,存不下了才写到target。

经过handle_STRING的处理,每条记录就裁剪掉了7字节,Java应用会有很多这类记录,有可观的缩减效果。

裁剪char[]和byte[]

还有一种字符串是char[]形式存在的,在hprof文件中tag为PRIMITIVE ARRAY DUMP,属于HEAP DUMP 或 HEAP DUMP SEGMENT 子tag。

case HPROF_TAG_HEAP_DUMP:        // 0x0C
case HPROF_TAG_HEAP_DUMP_SEGMENT:// 0x1C
    handle_HEAP_DUMP_SEGMENT(reader, writer);
    return 0;

堆转储的hprof文件格式中,原本是使用4字节32位存储堆对象的 “HEAP DUMP” (0x0C)的区块长度,但同时也就限制了HEAP DUMP的大小必须在4GB以内。在出现这个问题的情况下,在HPROF文件中新增了”HEAP DUMP SEGMENT” (0x1C)的格式,用来将超过4GB的JVM堆对象信息分别存储到文件的多个区块中。

 HPROF_TAG_HEAP_DUMP和HPROF_TAG_HEAP_DUMP_SEGMENT可以相同处理。

//0x2C
void handle_HEAP_DUMP_SEGMENT(Reader *reader, Writer *writer) {
    FILL(writer, 0x1C);  //合并为 tag HEAP DUMP,裁剪后hprof文件会很小,不需要HEAP DUMP SEGMENT 分多个区块。
    SEEK(reader, 9); // reader.offset += 9,跳过 tag + time + length, offset移动到body开始位置,裁剪了time + length(8字节)

    while (reader->isAvailable()) {
        uint8_t tag = INT1(reader, 0); //读子tag
        switch (tag) {
        .....
        case HPROF_PRIMITIVE_ARRAY_DUMP:       // 0x23 基本类型数组
            handle_PRIMITIVE_ARRAY_DUMP(reader, writer);
            break;
        .....
        }
    }
}

//0x23
void handle_PRIMITIVE_ARRAY_DUMP(Reader *reader, Writer *writer) {
    MOVE(writer, reader, 5); //move 也是 fill操作,从reader读取5字节( 子tag + ID)写入writer,两个offset都移动5
    SEEK(reader, 4); //跳过u4 stack trace serial number

    uint32_t count = INT4(reader, 0); //读u4 numbers of elements
    uint8_t type = INT1(reader, 4); //读u1 element type
    if (type == HPROF_BASIC_CHAR || type == HPROF_BASIC_BYTE) { //如果是char[]和byte[]类型
        MOVE(writer, reader, 5); //把count和type这5字节写入writer
        SEEK(reader, count * bytes(type)); //reader.offset += count * bytes(type),跳过(裁剪)elements数据部分
    } else {
        COPY(writer, reader, 5 + count * bytes(type));
    }
}

//Tailor.h
#define MOVE(writer, reader, s) fill(writer, reader, s)

//stream.hpp
inline void fill(Writer *writer, Reader *reader, size_t count) {
    if (writer->offset + count > MAX_BUFFER_SIZE) {
        writer->flush(writer->buffer, writer->offset, false);
        writer->offset = 0;
    }

    for (int i = 0; i < count; i++) {
        writer->buffer[writer->offset++] = reader->buffer[reader->offset++];
    }
}

其他tag的裁剪可以看Tailor.cpp的int handle(Reader *reader, Writer *writer) , 经过上面的char[]和byte[]的裁剪处理,已经可以大幅缩减最终hprof文件的大小了。

还原hprof

裁剪压缩后的mini.hprof不能直接用于其他分析工具,我们需要按照裁剪过程做对应的还原处理,得到target.hprof可通过 Android Studio 分析,通过 MAT 还需要 hprof-conv 转换。

Tailor库提供了python还原脚本:

$ python3 library/src/main/python/decode.py -i mini.hprof -o target.hprof

def process(source, target):
    reader = None

    try:
        reader = open(source, 'rb')
        writer = open('.tailor', 'wb')  
        decompress(reader, writer) # 解压之前导出的mini.hprof到临时文件.tailor
        reader.close()
        writer.close()
    except Exception as e:
        raise Exception('decompress failed at %d/%d: %s' % (reader.tell(), os.path.getsize(reader.name), str(e)))

    try:
        reader = open('.tailor', 'rb')
        writer = open(target, 'wb')
        # 读取.tailor前18字节,判断一下是否是我们生成mini.hprof时的版本
        if reader.read(18).decode('ascii') == 'JAVA PROFILE 6.0.1':
            decode(reader, writer) #还原裁剪掉的字节
        else:
            raise Exception('unknown file format!')
        reader.close()
        writer.close()
    except Exception as e:
        raise Exception('decode failed at %d/%d: %s' % (reader.tell(), os.path.getsize(reader.name), str(e)))

这里我们只看一下针对前面裁剪处理的还原代码。

def decode(reader, writer):
    '''
    首先还原header到target.hprof
    裁剪步骤中,我们改成了JAVA PROFILE 6.0.1,后面的字符串结束符 + size of identifiers + timestamp (1byte + 4byte + 8byte, 总共13字节) 被删除了
    这里补回,并且size of identifiers值给0x04,0x00字节处的真实数据不影响对快照的分析
    '''
    writer.write(bytearray([ord(c) for c in 'JAVA PROFILE 1.0.3']))   # 真实版本
    writer.write(bytearray([0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))  # 补上裁剪掉的13字节
    length = os.path.getsize(reader.name)
    while reader.tell() < length:
        tag = int.from_bytes(reader.read(1), byteorder='big', signed=False)  # 读1字节的tag 
        if tag == 0x01:  # STRING
            decode_STRING(reader, writer)  # 还原UTF8格式存储的字符串
        ...
        elif tag == 0x0C:  # HEAP_DUMP
            decode_HEAP_DUMP_SEGMENT(reader, writer)  # 还原子TAG: PRIMITIVE_ARRAY_DUMP
        ...
        elif tag == 0x1C:  # HEAP_DUMP_SEGMENT
            decode_HEAP_DUMP_SEGMENT(reader, writer) 
        ...

还原UTF8格式存储的字符串
def decode_STRING(reader, writer):
    COUNTER('STRING')
    '''
   Record:tag + time + length + body(1byte + 4byte + 4byte + byte[$length])
   裁剪步骤中,tag之后的数据裁剪了4字节time + length的高两字节
   这里补回 tag + time + length的高两字节,0x00字节处的真实数据不影响对快照的分析
    '''
    writer.write(bytearray([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
	
    length = int.from_bytes(reader.read(2), byteorder='big', signed=False)  # 读取两字节的length
    reader.seek(-2, 1)   # 从当前位置移动读取指针到前两个字节处,即前面读取两字节length开始处

    writer.write(bytearray(reader.read(2 + length)))  # 接着将低两字节的length+body写到target.hprof,此时是完整的Record了

还原char[]和byte[]

对应的数据tag为PRIMITIVE_ARRAY_DUMP,属于HEAP_DUMP或HEAP_DUMP_SEGMENT的子tag,所以需要先还原HEAP_DUMP或HEAP_DUMP_SEGMENT。

def decode_HEAP_DUMP_SEGMENT(reader, writer):
    COUNTER('HEAP_DUMP_SEGMENT')
    '''
   裁剪步骤中,HEAP_DUMP_SEGMENT tag之后的数据裁剪了 time + length 8字节
   这里补回到target.hprof,0x1C HEAP_DUMP_SEGMENT的数据就完整了
    '''
    writer.write(bytearray([0x1C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))

    segment_started_index = writer.tell()
    while True:
        tag = int.from_bytes(reader.read(1), byteorder='big', signed=False)
        reader.seek(-1, 1)  # 读出tag后读指针重新移动到tag位置
        ...
        elif tag == 0x23:  # PRIMITIVE_ARRAY_DUMP
            decode_PRIMITIVE_ARRAY_DUMP(reader, writer)
        ...
        else:
            break

    segment_stopped_index = writer.tell()
    if segment_started_index == segment_stopped_index:
        writer.seek(-9, 1)
    else:
        length = segment_stopped_index - segment_started_index
        writer.seek(-4 - length, 1)
        writer.write(bytearray([(length & 0XFF000000) >> 24, (length & 0X00FF0000) >> 16, (length & 0X0000FF00) >> 8, length & 0X000000FF]))
        writer.seek(segment_stopped_index, 0)


def decode_PRIMITIVE_ARRAY_DUMP(reader, writer):
    COUNTER('PRIMITIVE_ARRAY_DUMP')
    '''
    Sub Record: tag + array object ID + stack trace serial number + number of elements + element type + elements
    (1byte + 4byte + 4byte + 1byte + byte[$length])
      裁剪步骤中,tag之后的数据被裁剪了4字节的stack trace serial number和elements部分
     这里先读出5字节,接着补回4字节到target.hprof
    '''
    writer.write(bytearray(reader.read(5)))

    writer.write(bytearray(4))
   
    length = int.from_bytes(reader.read(4), byteorder='big', signed=False)
    type = int.from_bytes(reader.read(1), byteorder='big', signed=False)

    reader.seek(-5, 1)
    writer.write(bytearray(reader.read(5)))

    decode_PRIMITIVE_ARRAY_ELEMENTS(reader, length, type, writer)  # 补length长度的elements


def decode_PRIMITIVE_ARRAY_ELEMENTS(reader, length, type, writer):
    ...
    elif type == 5:   # char
        writer.write(bytearray(2 * length))
    ...
    elif type == 8:   # byte
        writer.write(bytearray(1 * length))
    ...
    else:
        raise Exception('decode_PRIMITIVE_ARRAY_ELEMENTS() not supported type ' % type)

其他tag的还原也是一样的原理,补齐裁剪掉的字节,默认值为0即可。

裁剪压缩效果 

实际的裁剪效果取决于具体现场,OOM 现场的快照通常比较大(LargeHeap/非 LargeHeap 的差异也很大),非 OOM 的则要小很多,西瓜视频(LargeHeap)提到根据他们的实践经验得出以下数据:

体积
    OOM:约 50%可以裁剪压缩到 10M 以内。

    非 OOM:约 60%可以裁剪压缩到 5M 以内,约 90%可以裁剪压缩到 10M 以内。

耗时
    同原生 dump 耗时相差很小:dump 过程的耗时主要集中在两次 ProcessHeap 调用和文件写入上。

稳定性
     基本没有稳定性问题:此开源版本已运行半年以上,未发现有 Tailor 相关的 crash。

这里我们以一份Android dump出来的完整的memory-20240527T184209_source.hprof为例,使用python版的裁剪压缩脚本展示一下效果。

$ python3 library/src/main/python/encode.py -i memory-20240527T184209_source.hprof   -o mini.hprof

{'STRING': 142711, 'LOAD_CLASS': 28085, 'STACK_TRACE': 1, 'HEAP_DUMP_SEGMENT': 20609, 'ROOT_THREAD_OBJECT': 95, 'ROOT_JNI_LOCAL': 81, 'ROOT_JAVA_FRAME': 782, 'ROOT_NATIVE_STACK': 11, 'ROOT_JNI_GLOBAL': 615, 'ROOT_UNKNOWN': 295991, 'ROOT_STICKY_CLASS': 24509, 'INSTANCE_DUMP': 881970, 'PRIMITIVE_ARRAY_DUMP': 370163, 'OBJECT_ARRAY_DUMP': 118737, 'CLASS_DUMP': 28085}

COMPLETE: 145296248/145296248 -> 68047945

【参考】

通用内存快照裁剪压缩工具Tailor

HPROF 协议

xHook 

虚拟内存研究

GNU Hash ELF Sections

  • 16
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值