上章节中我们通过源码学习和分析了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])

这里是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

【参考】