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

ops/2024/10/19 13:18:13/

通用内存快照裁剪压缩库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->bufferreader->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:       // 0x02handle_LOAD_CLASS(reader, writer);return 0;case HPROF_TAG_HEAP_DUMP:        // 0x0Ccase HPROF_TAG_HEAP_DUMP_SEGMENT:// 0x1Chandle_HEAP_DUMP_SEGMENT(reader, writer);return 0;case HPROF_TAG_HEAP_DUMP_END:    // 0x2Chandle_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:// 0x1Chandle_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); //读子tagswitch (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都移动5SEEK(reader, 4); //跳过u4 stack trace serial numberuint32_t count = INT4(reader, 0); //读u4 numbers of elementsuint8_t type = INT1(reader, 4); //读u1 element typeif (type == HPROF_BASIC_CHAR || type == HPROF_BASIC_BYTE) { //如果是char[]和byte[]类型MOVE(writer, reader, 5); //把count和type这5字节写入writerSEEK(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 = Nonetry:reader = open(source, 'rb')writer = open('.tailor', 'wb')  decompress(reader, writer) # 解压之前导出的mini.hprof到临时文件.tailorreader.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:  # STRINGdecode_STRING(reader, writer)  # 还原UTF8格式存储的字符串...elif tag == 0x0C:  # HEAP_DUMPdecode_HEAP_DUMP_SEGMENT(reader, writer)  # 还原子TAG: PRIMITIVE_ARRAY_DUMP...elif tag == 0x1C:  # HEAP_DUMP_SEGMENTdecode_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)  # 读取两字节的lengthreader.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_DUMPdecode_PRIMITIVE_ARRAY_DUMP(reader, writer)...else:breaksegment_stopped_index = writer.tell()if segment_started_index == segment_stopped_index:writer.seek(-9, 1)else:length = segment_stopped_index - segment_started_indexwriter.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长度的elementsdef decode_PRIMITIVE_ARRAY_ELEMENTS(reader, length, type, writer):...elif type == 5:   # charwriter.write(bytearray(2 * length))...elif type == 8:   # bytewriter.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


http://www.ppmy.cn/ops/110176.html

相关文章

java 继承的案例

java 继承的案例 以下是一个简单的Java类继承的例子。在这个例子中&#xff0c;我们定义了一个基类Vehicle&#xff0c;然后定义了两个派生类Car和Truck。 这个代码示例展示了面向对象编程中的继承和方法重写的基本用法。以下是对代码的详细分析&#xff1a; 类定义 基类 Vehi…

遇到僵尸进程,怎么处理---学习笔记

僵尸进程解释 当 iowait 升高时&#xff0c;进程很可能因为得不到硬件的响应&#xff0c;而长时间处于不可中断状态。从 ps 或者 top 命令的输出中&#xff0c;你可以发现它们都处于 D 状态&#xff0c;也就是不可中断状态&#xff08;Uninterruptible Sleep&#xff09;。既然…

边缘检测运用

文章目录 一、简介1.边缘检测的概念2.边缘检测的目的 二、代码实现三、边缘检测的方法1.1Canny边缘检测器1.2.Canny代码实现2.1Sobel边缘检测器2.2Sobel代码实现3.1Laplacian边缘检测器3.2Laplacian代码实现4.1Scharr边缘检测器4.2Scharr代码实现 四、边缘检测的应用 一、简介 …

影刀RPA实战:自动化批量生成条形码完整指南

今天我们聊聊使用影刀来实现批量生成条形码&#xff0c;条形码在零售行业运用非常广泛&#xff0c;主要作用表现在产品识别&#xff0c;库存管理&#xff0c;销售管理&#xff0c;防伪保护等&#xff0c;这些作用使其成为现代商业和工业环境中不可或缺的工具&#xff0c;它极大…

Qt进程通信,不推荐使用QSharedMemory和QLocalSocket,而是推荐ZMQ

一、据一位资深的网友说QLocalSocket有问题&#xff0c;共享内存QSharedMemory也有&#xff0c;比如存在多线程问题&#xff0c;不灵活&#xff0c;丢数据等问题都有&#xff0c;而且还占资源。血的教训。后来换成了zmqprotobuf。ZMQ进程内&#xff0c;进程间&#xff0c;机器间…

杜教筛入门

求 f f f 的前缀和&#xff08;不要求 f f f 为积&#xff09; 考虑 h f ∗ g hf*g hf∗g&#xff0c;若 h , g h,g h,g 前缀和都好求&#xff0c;那 f f f 的前缀和 s s s 是好求的 ∑ i 1 n h i ∑ i j ≤ n f i g j \sum_{i1}^n h_i\sum_{ij\le n}f_ig_j i1∑n​h…

灵活连接,无限可能—探索EtherCAT的拓扑艺术

EtherCAT技术具备快速响应和高效率的特点&#xff0c;在工业自动化领域显得至关重要&#xff0c;其灵活的拓扑结构是其核心优势&#xff0c;支持多样化的网络布局&#xff0c;无需交换机或集线器&#xff0c;简化布线&#xff0c;降低成本&#xff0c;提高系统可靠性和灵活性。…

C语言:刷题日志(3)

一.猴子选大王 一群猴子要选新猴王。新猴王的选择方法是&#xff1a;让N只候选猴子围成一圈&#xff0c;从某位置起顺序编号为1~N号。从第1号开始报数&#xff0c;每轮从1报到3&#xff0c;凡报到3的猴子即退出圈子&#xff0c;接着又从紧邻的下一只猴子开始同样的报数。如此不…