通用内存快照裁剪压缩库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对象的裁剪。

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

cpp 复制代码
//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字节。

cpp 复制代码
//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的位置。

cpp 复制代码
//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编码的。

cpp 复制代码
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。

cpp 复制代码
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可以相同处理。

cpp 复制代码
//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;
        .....
        }
    }
}
cpp 复制代码
//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

python 复制代码
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)))

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

python 复制代码
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格式存储的字符串
python 复制代码
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。

python 复制代码
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

相关推荐
阿巴斯甜2 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker2 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95273 小时前
Andorid Google 登录接入文档
android
黄林晴5 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab17 小时前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿20 小时前
Android MediaPlayer 笔记
android
Jony_20 小时前
Android 启动优化方案
android
阿巴斯甜21 小时前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇21 小时前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android