速度优化:GC抑制
点击小卡片参与粉丝专属福利
我们知道,充分且合理地使用 CPU 资源是提升速度的本质因素之一。提升 CPU 利用率,除了前面提到的优化方案外,还有很多其他的方案,比如我们还可以通过分析 CPU 的使用情况寻找优化点。
Android 官方提供了完善的分析 CPU 使用的工具,如抓 Trace 或者 AndroidStudio 中自带的 Profile 工具,如果不熟悉使用的可以参考官方文档,讲解非常详细,这里就不过多介绍了。
「在通过 Profile 分析 CPU 使用时,」 「我们」 「经常会发现 HeapTaskDaemon」 「线程」 「占用了较高 CPU 时间,这个线程实际是虚拟机用来执行」 「GC」 「操作的。」 下图是 Demo 中的 CPU 使用分析,可以看到 HeapTaskDaemon 线程有大块处于 Running 状态的时间。
从 Android 5 开始,Dalvik 虚拟机被替换成了 ART 虚拟机,ART 虚拟机在进行 GC 的时候,虽然不再执行 Stop The World 逻辑来停止一切其他任务,但并不意味着 GC 操作便不会再导致卡顿。ART虚拟机上, GC 操作依然会导致卡顿,主要原因是该操作会抢占很多 CPU 资源,从而导致核心线程无法获得足够的 CPU 时间片而卡顿或者变慢。HeapTaskDaemon 线程除了抢占 CPU 时间片,还会因为有较多内存操作而持有内存相关的锁,其他任务无法得到锁自然就变慢了。
所以当我们执行核心场景,比如启动,打开页面或者滑动 List 时,如果能抑制 GC 的执行,就能让核心任务获得更多的 CPU 时间,表现出更好的性能。
这一章,我们就来学习「如何对 GC 进行抑制」。因为涉及比较多复杂的知识点,内容上会有一定的难度,希望通过今天的学习我们能一起弄懂它们,踏上进阶之路。
GC 执行的流程想要抑制 GC 执行,我们首先要熟悉 GC 的执行流程,然后从流程中寻找突破点,在前面学习通过“黑科技”手段优化虚拟内存时,我们也是这样的思路。既然 HeapTaskDaemon 线程抢占了较多的 CPU,我们就直接从 HeapTaskDaemon 这个线程来分析,看看这个线程到底是做什么的。
HeapTaskDaemon 线程的起源通过全局搜索 HeapTaskDaemon 关键字,发现它是在 Java 层创建的线程,并位于 Daemons.java 对象中。
分析源码可以发现,HeapTaskDaemon 继承自 Daemon 对象。Daemon 对象实际是一个 Runnale,并且内部会创建一个线程,用于执行当前这个 Daemon Runnable,这个内部线程的线程名就叫 HeapTaskDaemon。到这里,我们就知道了这个线程的起源。
privatestaticclassHeapTaskDaemonextendsDaemon{
privatestaticfinalHeapTaskDaemonINSTANCE=newHeapTaskDaemon();
HeapTaskDaemon(){
super("HeapTaskDaemon");
}
publicvoidrunInternal(){
……
VMRuntime.getRuntime().runHeapTasks();
}
}
privatestaticabstractclassDaemonimplementsRunnable{
@UnsupportedAppUsage
privateThreadthread;
privateStringname;
privatebooleanpostZygoteFork;
protectedDaemon(Stringname){
this.name=name;
}
@UnsupportedAppUsage
publicsynchronizedvoidstart(){
startInternal();
}
publicsynchronizedvoidstartPostZygoteFork(){
postZygoteFork=true;
startInternal();
}
//zygote进程启动就会启动当前线程
publicvoidstartInternal(){
if(thread!=null){
thrownewIllegalStateException("alreadyrunning");
}
thread=newThread(ThreadGroup.systemThreadGroup,this,name);
thread.setDaemon(true);
thread.setSystemDaemon(true);
thread.start();
}
publicfinalvoidrun(){
……
try{
runInternal();
}catch(Throwableex){
……
throw
}
}
publicabstractvoidrunInternal();
……
}
知道了 HeapTaskDaemon 线程的起源,我们接着看看它是干什么的。
HeapTaskDaemon 线程的作用HeapTaskDaemon 是一个守护线程,随着 Zygote 进程启动便会启动,该线程的 run 方法也比较简单,就是执行 runInternal 这个抽象函数,该抽象函数的实现方法中会执行 VMRuntime.getRuntime().runHeapTasks() 方法,runHeapTasks() 函数会执行 RunAllTasks 这个 Native 函数,它位于 task_processor.cc 这个类中。
staticvoidVMRuntime_runHeapTasks(JNIEnv*env,jobject){
Runtime::Current()-GetHeap()-GetTaskProcessor()-RunAllTasks(ThreadForEnv(env));
}
通过源码一路跟踪下来,可以看到 HeapTaskDaemon 线程的 run 方法中真正做的事情,实际只是在无限循环的调用 GetTask 函数获取 HeapTask 并执行。GetTask 中会不断从 tasks 集合中取出 HeapTask 来执行,并且对于需要延时的 HeapTask ,会阻塞到目标时间。
voidTaskProcessor::RunAllTasks(Thread*self){
while(true){
HeapTask*task=GetTask(self);
if(task!=nullptr){
task-Run(self);
task-Finalize();
}elseif(!IsRunning()){
break;
}
}
}
std::multisetHeapTask*,CompareByTargetRunTimetasks_
HeapTask*TaskProcessor::GetTask(Thread*self){
……
while(true){
if(tasks_.empty()){
//如果tasks集合为空,则休眠线程
cond_.Wait(self);
}else{
//如果task是集合不会空,则取出第一个HeapTask
constuint64_tcurrent_time=NanoTime();
HeapTask*task=*tasks_.begin();
uint64_ttarget_time=task-GetTargetRunTime();
if(!is_running_||target_time=current_time){
tasks_.erase(tasks_.begin());
returntask;
}
//对于延时执行的HeapTask,这里会进行等待,直到目标时间
constuint64_tdelta_time=target_time-current_time;
constuint64_tms_delta=NsToMs(delta_time);
constuint64_tns_delta=delta_time-MsToNs(ms_delta);
cond_.TimedWait(self,static_castint64_t(ms_delta),static_castint32_t(ns_delta));
}
}
UNREACHABLE();
}
到这里,抑制 GC 的思路其实已经出来,我们有 2 种做法:
「添加一个自己的 HeapTask 到 tasks 集合中,并且在我们自己的 HeapTask 中进行休眠,此时便会阻塞 HeapTaskDaemon」 「线程」 「,达到抑制该线程执行的目的」 「;」「获取到系统的 HeapTask,并让这个 HeapTask 休眠,同样能达到抑制 HeapTaskDaemon」 「线程」 「执行的目的。」HeapTask 分析这两种方案都需要 HeapTask 进行操作,为了让方案顺利实施,我们需要继续分析 HeapTask 是干什么的。
通过源码分析可以发现,HeapTask 实际上依次继承自 SelfDeletingTask 、Task 和 Closure 这三个类,Task 类定义了 Finalize 这个虚函数,Closure 类定义了 Run 这个虚函数。「什么是虚函数呢?我们可以先把它理解成 Java 的抽象函数,virtual 关键字就类似于 Java 的 abstract 关键字,虚函数在后面有很重要的作用,是实现 Hook 的关键之一,这里先有个印象。」 既然是抽象函数,就需要子类来实现,SelfDeletingTask 实现了 Finalize 这个虚函数,用于对象析构使用。Run 函数的实现,则会交给 HeapTask 的子类。
classHeapTask:publicSelfDeletingTask{
public:
explicitHeapTask(uint64_ttarget_run_time):target_run_time_(target_run_time){
}
uint64_tGetTargetRunTime()const{
returntarget_run_time_;
}
private:
voidSetTargetRunTime(uint64_tnew_target_run_time){//延时时间设置接口
target_run_time_=new_target_run_time;
}
uint64_ttarget_run_time_;
friendclassTaskProcessor;
};
classSelfDeletingTask:publicTask{
public:
virtual~SelfDeletingTask(){}
virtualvoidFinalize(){
deletethis;
}
};
classTask:publicClosure{
public:
//定义Finalize虚函数
virtualvoidFinalize(){}
};
classClosure{
public:
virtual~Closure(){}
//定义Run虚函数
virtualvoidRun(Thread*self)=0;
};
还是通过全局搜索,发现 Android 系统中继承自 HeapTask 的子类有下面这些。
下面大致介绍一下每一个 HeapTask 的作用。
ConcurrentGCTask:当 Java 内存到达阈值时,便会执行这个 Task,用于执行并发 GC。CollectorTransitionTask:前后台切换时,便会执行这个 Task,用于切换 GC 的类型,比如到后台时,便会切换成拷贝回收这种 GC 机制。HeapTrimTask:GC 完成之后,如果需要将堆中空闲的内存归还给内核,则会执行这个 Task 来处理。TriggerPostForkCCGcTask:Android8 开始,系统为了在启动时避免 GC 操作,会执行这个 Task,将 HeapTaskDaemon 线程阻塞 2 秒。ReduceTargetFootprintTask:和 TriggerPostForkCCGcTask 配合使用。ClearedReferenceTask:在对象回收时,会执行该 Task,Task 中调用 Java 层的ReferenceQueue.add 方法, 将被回收对象引用添加到 ReferenceQueue 队列中。LeakCanary 便是用 ReferenceQueue 队列来判断内存泄漏。NotifyStartupCompletedTask:启动完成后执行的一个 Task,用于校验使用。因为 Task 比较多,我们就不每一个都去分析它的实现了,这里仅以 ConcurrentGCTask 这一个 Task 为例子讲解它的原理和机制。
ConcurrentGCTask 分析在《Java 堆内存优化》中讲到过,当我们创建对象时,最终虚拟机会调用 AllocObjectWithAllocator 方法,到 Java 堆中为这个对象申请内存空间。申请空间的操作就不重复讲了,我们主要看触发 ConcurrentGCTask 的流程。
通过源码可以看到,如果判断是并发 GC,或者堆内存达到 concurrent_start_bytes_ (这个值是一个动态值,系统会根据当前条件,动态调整这个值的大小)阈值时,就会调用 RequestConcurrentGCAndSaveObject 方法。
inlinemirror::Object*Heap::AllocObjectWithAllocator(Thread*self,
ObjPtrmirror::Classklass,
size_tbyte_count,
AllocatorTypeallocator,
constPreFenceVisitorpre_fence_visitor){
……
boolneed_gc=false;
uint32_tstarting_gc_num;//o.w.GCnumberatwhichweobservedneedforGC.
{
……
if(bytes_tl_bulk_allocated0){
……
//如果是并发GC,或者达到了阈值,则need_gc为true
if(IsGcConcurrent()UNLIKELY(ShouldConcurrentGCForJava(new_num_bytes_allocated))){
need_gc=true;
}
……
}
}
……
if(need_gc){
//Dothisonlyoncethreadsuspensionisallowedagain,andwe'redonewithkInstrumented.
RequestConcurrentGCAndSaveObject(self,/*force_full=*/false,starting_gc_num,
}
……
returnobj.Ptr();
}
inlineboolHeap::ShouldConcurrentGCForJava(size_tnew_num_bytes_allocated){
returnnew_num_bytes_allocated=concurrent_start_bytes_;
}
RequestConcurrentGCAndSaveObject 方法中实际上就是创建 ConcurrentGCTask,并调用 task_processor_ 对象的 AddTask 方法,将该 Task 添加到 tasks 集合里去。ConcurrentGCTask 里面具体做的事情,就是执行并发 GC 了,这属于虚拟机模块的知识,就不展开讲了。
voidHeap::RequestConcurrentGCAndSaveObject(Thread*self,
boolforce_full,
uint32_tobserved_gc_num,
ObjPtrmirror::Object*obj){
RequestConcurrentGC(self,kGcCauseBackground,force_full,observed_gc_num);
}
boolHeap::RequestConcurrentGC(Thread*self,
GcCausecause,
boolforce_full,
uint32_tobserved_gc_num){
uint32_tmax_gc_requested=max_gc_requested_.load(std::memory_order_relaxed);
if(!GCNumberLt(observed_gc_num,max_gc_requested)){
if(CanAddHeapTask(self)){
if(max_gc_requested_.CompareAndSetStrongRelaxed(max_gc_requested,observed_gc_num+1)){
task_processor_-AddTask(self,newConcurrentGCTask(NanoTime(),//Startstraightaway.
cause,
force_full,
observed_gc_num+1));
}
……
returntrue;
}
returnfalse;
}
returntrue;
}
如果你对 GC 机制比较有兴趣,可以将其他的 HeapTask 都分析一下,这样能加深你对 ART GC 机制的了解。了解了 HeapTaskDaemon 线程以及相关的流程,下面我们进入实战,看看如何抑制 GC 的执行。
抑制 GC 执行的方案在上面的分析过程中,已经提到了 2 种方案:
「添加一个自己的 HeapTask 到 tasks 集合中,并且在我们自己的 HeapTask 中进行休眠,此时便会阻塞 HeapTaskDaemon 线程,达到抑制该线程的目的;」「获取到系统的 HeapTask,并让这个 HeapTask 休眠,同样能达到抑制 HeapTaskDaemon 线程执行的目的。」从 Android8 开始,应用启动时使用第 1 种方案,将 GC 延后 2 秒才执行。对于系统来说,这种方案非常简单,因为「系统能直接拿到 TaskProcessor 对象,往里面添加自定义 task 就行。」 但是对于应用来说,这种方案相对复杂,复杂的原因在后面会讲到,所以本章中介绍的是第二种方案,下面以系统的 ConcurrentGCTask 为例,我们看看如何让这个 Task 休眠 。
当我们想要调用某个方法时,需要在代码中持有方法的对象,然后才能进行方法的调用,当代码被编译时,编译器会将这个对象编译成内存中的一个地址。但是当我们在代码中拿不到目标对象时,就没法使用这个对象了,即使这个对象会被加载到进程的虚拟内存中。
如果我们想要在自己的 native 方法中,执行 libart 这个 so 库中 ConcurrentGCTask 对象的 Run 方法 ,常规手段办不到,因为我们拿不到 ConcurrentGCTask 对象,更别说执行对象里面的方法了 。
此时,只能使用非常规手段了。「libart.so 这个库实际上是已经加载进我们应用的虚拟内存中了,这个方法也被存放在应用用户空间的某一块内存地址上。这时,我们只需要找到这个 Run 方法的地址,就可以操作它了」。那怎么才能找到 Run 方法在内存中的地址呢?我们需要用到这个方法的符号,并通过符号在 libart 这个 so 库的内存范围中去寻找其对应的符号表,这样我们就能获取符号对应方法的内存地址了。那么什么是符号呢?
符号编译器在将 C++ 源代码编译成目标文件时,会将函数和变量的名字进行修饰,生成符号名,所以「符号是相应的函数和变量修饰后的名称」。编译器不同,生成的符号也不一样,比如通过 GCC 编译器来编译下面这几个函数,对应的符号则如下:
函数符号int func(int)_Z4funcifloat func(float)_Z4funcfint Test::func(int)_ZN4Test4funcEi以 int Test::func(int) 这个函数为例,GCC 在生成方法的符号时,都以 _Z 开头,对于嵌套的名字,后面紧跟 N,然后跟着各个名称空间和类的名称长度及名称,所以是 4Test4func,再以 E 结尾(非嵌套的方法名不需要 E ),最后跟着入参类型,那么这个函数的符号连起来就是 _ZN4Test4funcEi。我们不需要去熟悉这些规则,大致了解就行。
在《Native内存优化》这篇文章中,讲到了通过 dladdr 函数获取到的 dli_sname 和 dli_saddr 字段,就是方法的符号和这个符号对应的方法地址。下图中的 (Z16CaptureBacktracePPVM)(0x7032a1145c) 、(Z16printNativeStackV)(0X7032a11640) 等数据就是方法对应的符号,以及符号对应方法的地址。如果对内容记不清了,可以再回头看一下这章。
为了包体积和安全考虑,我们一般会将 so 去符号,这样我们在 dladdr 函数中就没法根据符号定位到方法名以及地址了,从上图也可以看到,去符号后的数据为 (null)(0x0)。
幸运的是,在 libart.so 中,很多对象和方法都是有符号的,之所以保留这些符号,可能是需要用于调试或者异常定位使用。通过符号,我们就能找到对应的函数地址了。话说回来,我们为什么不介绍第一种方案呢?也是因为 TaskProcessor 这个对象没有符号,我们无法拿到这个对象,但在第二种方案中,各种 HeapTask 的子类符号是有保留的,所以我们就能拿到这些 Task 的对象和函数的内存地址。有了地址,就有了操作的可行性。下面就来看一下要怎么做吧!
符号查找为了便于分析,我们先从 root 手机中拉取一份 libart.so 到本地,在设备的 shell 窗口中执行下面指令即可。libart 这个 so 库一般存放在 /system/lib/ 目录中。
cp/system/lib/libart.so/sdcard/libart.so
符号信息都是统一放在符号表(.symtab)中的,和 .bss,.text 这些段一样,符号表 .symtab 也属于 ELF 文件中的一个段。我们通过 readelf 工具的 -S 命令来读取 libart 库的段信息。可以看到 so 中是包含了 .symtab 这个段的。
aarch64-linux-android-readelf-Slibart.so
Thereare31sectionheaders,startingatoffset0x5978e8:
SectionHeaders:
[Nr]NameTypeAddrOffSizeESFlgLkInfAl
[0]NULL0000000000000000000000000
[1].note.android.ideNOTE0000c15400015400001800A004
[2].note.gnu.build-iNOTE0000c16c00016c00002000A004
[3].dynsymDYNSYM0000c18c00018c01a80010A414
[4].dynstrSTRTAB0002698c01a98c070a5600A001
[5].gnu.hashGNU_HASH000973e408b3e400c57004A304
[6].gnu.versionVERSYM000a395409795400350002A302
[7].gnu.version_dVERDEF000a6e5409ae5400001c00A414
[8].gnu.version_rVERNEED000a6e7009ae7000009000A434
[9].rel.dynLOOS+0x1000a6f0009af00002a8001A004
[10].rel.pltREL000a998009d980000bf808AI3114
[11].pltPROGBITS000aa57809e57800120800AX004
[12].textPROGBITS000ab80009f800374a2000AX00512
[13].ARM.exidxARM_EXIDX0042022041422000c2d808AL1204
[14].rodataPROGBITS0042c50042050002779400A0016
[15].ARM.extabPROGBITS00453c94447c9400085800A004
[16].eh_framePROGBITS004544ec4484ec0041c400A004
[17].eh_frame_hdrPROGBITS004586b044c6b00006fc00A004
[18].fini_arrayFINI_ARRAY0045a41044d41000000400WA004
[19].data.rel.roPROGBITS0045a42044d420006ab800WA0016
[20].init_arrayINIT_ARRAY00460ed8453ed800005800WA004
[21].dynamicDYNAMIC00460f30453f3000017008WA404
[22].gotPROGBITS004610a04540a0000f6000WA004
[23].dataPROGBITS0046200045500000129000WA0016
[24].bssNOBITS00463290456290001fe100WA0016
[25].commentPROGBITS0000000045629000006501MS001
[26].note.gnu.gold-veNOTE000000004562f800001c00004
[27].ARM.attributesARM_ATTRIBUTES0000000045631400004400001
[28].shstrtabSTRTAB0000000045635800014300001
[29].symtabSYMTAB0000000045649c066a901030194984
[30].strtabSTRTAB000000004bcf2c0da9bc00001
KeytoFlags:
W(write),A(alloc),X(execute),M(merge),S(strings),I(info),
L(linkorder),O(extraOSprocessingrequired),G(group),T(TLS),
C(compressed),x(unknown),o(OSspecific),E(exclude),
y(noread),p(processorspecific)
既然符号表是 so 库中的一个段,那么查找这个符号就不难了,和之前 plt hook 方案中查找 dynamic 段中 got 表的函数一样,也是 2 步。
「找到 so 库的首地址,并转换成 ELF 格式。」「找到 ELF 文件中的 .symtab 段,并遍历该段,找到我们想要的符号信息,并取出地址。」解析 maps 文件可以找到 so 库地址的方法我们就不再讲了,这里重点看看第 2 个步骤。
遍历 ELF 文件中的 Section 段,并寻找 symtab 段。unsignedlongsymbolAddr;
unsignedintsymbolSize;
//将so_addr强制转换成Elf_Ehdr格式
Elf_Ehdr*header=(Elf_Ehdr*)(so_addr);
//获取段头部表的地址
Elf_Shdr*seg_table=(Elf_Phdr*)(so_addr+header-e_shoff);
//段的数量
size_tseg_count=header-e_shnum;
//遍历段,寻找symtab段地址
for(inti=0;iseg_counti++){
seg_table+=header-e_shentsize
if(seg_table-sh_type==SHT_SYMTAB){
//so基地址加symtab段的偏移地址,就是symtab段的实际地址
symbolAddr=seg_table-sh_offset+so_addr;
symbolSize=seg_table-sh_size;
break;
}
}
遍历 symtab 段,寻找目标符号,并获取符号对应函数的地址。//确定symtab中符号的数量
size_tsymtab_num=(symbolSize/sizeof(Elf_Sym)
//将sybtal段地址强制转换成Elf_Sym结构体
Elf_Sym*symtab=(Elf_Sym*)symbolAddr;
//遍历sybtab中的符号,并进行对比
for(k=0;ksymtab_num;k++){
//如果和想要查找的符号名ratget一致,则返回符号对应函数的地址
if(strcmp(strtab+symtab-st_name,target)==0){
void*ret=so_addr+symtab-st_value;
return
}
//移动到下一个符号地址上
symtab++;
}
//Elf_Sym的数据结构如下
typedefstructelf_sym{
Elf32_Wordst_name;//符号名
Elf32_Addrst_value;//符号对应的值的偏移地址
Elf32_Wordst_size;//符号的大小
……
}Elf_Sym;
可以看到,通过符号寻找地址的逻辑并不复杂。我们也可以回头再看看《Native 内存优化:so 库申请的内存优化》这篇文章中 plt hook 的方案实现,会发现「寻找 .dynamic 段时的操作和这里寻找 .symtab 段是有区别的。plt hook 方案中,我们」 「遍历」 「的是 Program 段,这里遍历的是 Section 段。Program 实际只是按照 Section 的读写权限和属性特征,将 Section 重新组织了一次,然后加载进内存中,这样能节约更多的内存空间。」
我们可以通过 readelf -l 命令,查看 libart.so 按照 Program 段的组织方式,可以看到 Program Headers 只有 9 个,而 Section Headers 有 31 个,这 31 个 Section 会按照 Type 的区别,整合到这 9 个 Program 中。
aarch64-linux-android-readelf -l libart.so
Elf file type is DYN (Shared object file)
Entry point 0x0
There are 9 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x0000c034 0x0000c034 0x00120 0x00120 R 0x4
LOAD 0x000000 0x0000c000 0x0000c000 0x44cdac 0x44cdac R E 0x1000
LOAD 0x44d410 0x0045a410 0x0045a410 0x08e80 0x0ae61 RW 0x1000
DYNAMIC 0x453f30 0x00460f30 0x00460f30 0x00170 0x00170 RW 0x4
NOTE 0x000154 0x0000c154 0x0000c154 0x00038 0x00038 R 0x4
GNU_EH_FRAME 0x44c6b0 0x004586b0 0x004586b0 0x006fc 0x006fc R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x0
EXIDX 0x414220 0x00420220 0x00420220 0x0c2d8 0x0c2d8 R 0x4
GNU_RELRO 0x44d410 0x0045a410 0x0045a410 0x07bf0 0x07bf0 RW 0x10
Section to Segment mapping:
Segment Sections...
00
01 .note.android.ident .note.gnu.build-id .dynsym .dynstr .gnu.hash .gnu.version .gnu.version_d .gnu.version_r .rel.dyn .rel.plt .plt .text .ARM.exidx .rodata .ARM.extab .eh_frame .eh_frame_hdr
02 .fini_array .data.rel.ro .init_array .dynamic .got .data .bss
03 .dynamic
04 .note.android.ident .note.gnu.build-id
05 .eh_frame_hdr
06
07 .ARM.exidx
08 .fini_array .data.rel.ro .init_array .dynamic .got
None .comment .note.gnu.gold-version .ARM.attributes .shstrtab .symtab .strtab
不管是遍历 Section 段 ,还是遍历 Program 段,都能实现在 ELF 文件中查找数据的目的。通过这两种在 ELF 文件中查找数据的方案,可以让我们对 ELF 文件有一个更全面的了解。
虽然已经反复演示过了如何查找 ELF 文件中数据的操作,但是这里还是建议大家用成熟的开源工具来做这个事情,因为真正在线上应用中使用时,我们需要考虑到查找的性能,版本的兼容等各种因素,一不小心可能就出问题了。比如用 ndk_dlopen 这个开源库来实现 so 库和符号的查找就很简单,通过下面两个函数就能快速实现功能。
//打开so
ndk_dlopen()
//根据符号查找函数地址
ndk_dlsym()
当然, 除了 ndk_dlopen 这个开源库,你可以找一些其他的成熟的开源框架来完成上面的逻辑,GitHub 都有很多。
获取 ConcurrentGCTask 的 Run 函数地址了解了如何通过符号查找函数地址,我们再来看一下 ConcurrentGCTask 对象的 Run 函数的符号是什么。我们通过 readelf -s libart.so 指令来读取 libart 中所有的符号,可以看到 libart.so 的符号非常多,有 2 万多个。
Symboltable'.symtab'contains26281entries:
Num:ValueSizeTypeBindVisNdxName
0:000000000NOTYPELOCALDEFAULTUND
1:000000000FILELOCALDEFAULTABScrtbegin_so.c
2:000acf340NOTYPELOCALDEFAULT12$a
3:000acf500NOTYPELOCALDEFAULT12$d
4:0045a4100NOTYPELOCALDEFAULT18$d
5:004620000NOTYPELOCALDEFAULT23$d
……
16846:001b0ff136FUNCLOCALHIDDEN12_ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE
……
当我们稍微了解一下 libart 中符号的生成规则,就能找到 ConcurrentGCTask 对象的 Run 函数的符号,它位于 16846 行,即 「_ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE。」
有了 Run 函数的符号,我们就很容易拿到地址了,这里以 ndk_dlopen 开源工具做演示:
//初始化ndk_dlopen
ndk_init(env);
//以RTLD_NOW模式打开动态库libart.so,拿到句柄,RTLD_NOW即解析出每个未定义变量的地址
void*handle=ndk_dlopen("libart.so",RTLD_NOW);
//通过符号拿到地址
void*runAddress=ndk_dlsym(handle,"_ZN3art2gc4Heap16ConcurrentGCTask3RunEPNS_6ThreadE");
简单的两行代码,我们就成功拿到了 ConcurrentGCTask 的 Run 函数的地址,这个时候只需要插入我们自己的代码,修改这个函数让它休眠就能成功阻塞 HeapTaskDaemon 线程了。「修改这个函数可以用《Native内存优化》这篇文章中提到的 inline hook 方式」,我们直接使用文中提到的开源的 inline hook 工具即可,使用起来也很简单,这里就当做课后作业留给你自己去实现了。
inline hook 会直接修改汇编代码,不太稳定,所以这里介绍一种更简单稳定的方案:虚函数 Hook 。通过这种方案,我们能稳定且高效地实现对 Run 方法的 Hook 操作。
虚函数 HookC++ 中的虚函数和 Java 中的抽象函数在目的上是类似的,都是留给子类去扩展,实现多态的。虚函数和外部库函数一样都没法直接执行,需要在表中去查找函数的真实地址。当编译器将代码编译成目标代码时,如果发现代码逻辑中执行的是虚函数时,「编译器实际上会生成去虚函数表中寻找目标函数的地址的代码」,如果不生成这些代码,这个函数是无法执行的,这和我们调用外部库函数也是类似的道理。调用外部函数时,实际的代码逻辑会去 plt 和 got 表中寻找函数的真实地址。
voidTaskProcessor::RunAllTasks(Thread*self){
while(true){
HeapTask*task=GetTask(self);
if(task!=nullptr){
//编译器编译成目标代码是,会去虚函数表寻找这个Run函数的地址
task-Run(self);
//编译器编译成目标代码是,会去虚函数表寻找这个Finalize函数的地址
task-Finalize();
}elseif(!IsRunning()){
break;
}
}
}
我们在前面通过 ndk_dlsym 拿到的 Run 函数的地址,实际上已经直接拿到了该函数的真实地址了,但在 RunAllTasks 的汇编代码逻辑中,需要去虚函数表查找后才能拿到这个函数最终地址。那什么是虚函数表?
什么是虚函数表?一个类中如果存在虚函数,如 ConcurrentGCTask 有 Run 和 Finalize 两个虚函数,那么编译器就会为这个类生成一张虚函数表,并且将虚函数表的地址放在对象实例的首地址的内存中。同一个类的不同实例,都是共用一张虚函数表的。
这里只大致介绍虚函数和虚函数表的机制,关于虚函数更多的知识,就不再这里展开介绍了,有兴趣的可以自己查阅相关资料。
?关于c++ 虚函数更详细的资料,也可以参考这几篇文档
https://zhuanlan.zhihu.com/p/75172640
https://cloud.tencent.com/developer/article/1599283
?如何实现虚函数 Hook?可以看到,虚函数表和 plt got 表的功能其实类似。当我们执行函数时,都需要去表中查询目标函数的真实地址,既然 plt hook 可以修改 got 表中目标函数的地址来达到 hook 的目的,虚函数 hook 的方案同样可以修改虚函数表中目标函数的地址,跳转到我们自己的函数中,来实现 hook 的操作。
和 got 表不同的是,got 表是存在 dynamic 段中的,所以我们修改 got 表需要去遍历 dynamic 段,但是「虚函数表是存在对象头部的,我们直接在对象头部中就能拿到虚函数表了」,相比 plt hook 会简单很多。下面就看下实现步骤。
通过符号拿到对象的内存地址,这里是 ConcurrentGCTask 这个对象,它的符号是 _ZTVN3art2gc4Heap16ConcurrentGCTaskE。//通过符号拿到ConcurrentGCTask对象地址
void*taskAddress=ndk_dlsym(handle,"_ZTVN3art2gc4Heap16ConcurrentGCTaskE");
因为虚函数放在对象头部内存数据中,所以对象首地址中的数据就是虚函数表的地址。/*由于ConcurrentGCTask只有五个虚函数,所以我们只需要查询前五个地址即可。
但是在实际开发中,为了稳定性考虑,这里的k设置成(虚函数表size/sizeof(void*))最稳妥
*/
intk=5;
void**slot=nullptr;
for(size_ti=0;i5;i++){
/*对象头地址中的内容存放的就是是虚函数表的地址,所以这里是指针的指针,即是虚函数表地址
拿到虚函数表地址后,转换成数组,并遍历获取值
*/
void*vfunc=((void**)taskAddress)[i];
//如果虚函数表中的值是前面拿到的Run函数的地址,那么就找到了Run函数在虚函数表中的地址
if(vfunc==runAddress){
//这里需要注意的是,这里+i操作拿到的是地址,而不是值,因为这里的值是Run函数的真实地址
slot=(void**)taskAddress+
}
}
拿到 Run 函数在虚函数表中的地址后,将该地址里面的值替换成我们自己的函数就完成了 hook。在我们自己的函数中进行休眠操作就能抑制 GC 的执行,休眠完成后再调用真正的 Run 函数。//将虚函数表中的值替换成我们hook函数的地址
replaceFunc(mSlot,&hookRun)
replaceFunc(void**slot,void*func){
//将内存页面设置成为可写
void*page=(void*)((size_t)slot(~(size_t)(PAGE_SIZE-1)));
if(mprotect(page,PAGE_SIZE,PROT_READ|PROT_WRITE)!=0)returnfalse;
//将表中的值替换成我们自己的函数
*slot=func;
#ifdef__arm__
//刷新内存缓存,使虚函数表修改生效
cacheflush((long)page,(long)page+PAGE_SIZE,0);
#endif
//将内存页面设置成为只读
mprotect(page,PAGE_SIZE,PROT_READ);
returntrue;
}
//我们的hook函数
voidhookRun(void*thread){
//休眠2秒
sleep(2000);
//将虚函数表中的值还原成原函数,避免每次执行run函数时都会执行hook的方法
replaceFunc(mSlot,taskAddress);
//执行原来的Run方法
((void(*)(void*))taskAddress)(thread);
}
到这里,我们就成功抑制 HeapTaskDaemon 线程执行 GC 的逻辑了。但你可能会担心,「抑制了 GC 会不会导致 OOM 提升呢?实际上不会,我们不需要长时间的抑制 GC,只需要在启动的时候,List 滑动的时候,页面打开的时候,抑制 2 到 3 秒即可。并且,从Android8 开始,应用启动时系统自己也会抑制 GC 2 秒。」
抑制 GC 的方法有很多,比如我们可以一个个去分析 HeapTask 中 Run 函数所执行的逻辑,寻找这些逻辑中是否有回调方法,可以让我们直接进行休眠操作。以前面提到的 ClearedReferenceTask 为例,它会在 Run 函数中执行 ReferenceQueue.add 这个 Java 方法,那么我们能否在这个 add 方法中进行休眠操作来抑制 GC 呢?希望你能自己去想想,这一章只是为了抛砖引玉,讲了一个可行的实现方案,期待你自己能发现更多可行的方案。
小结这一章我们就讲到这里,你可以通过下面这张导图,以及导图中的几个问题,来自己回顾、总结一下本章的内容。
当我们掌握本章的知识点后,我们的优化手段就大大扩展了。除了 GC 线程,在开头的图片中,我们也可以看到 Jit thread pool 线程占有了较多的 CPU 时间,这个线程我们同样可以用本章学到的知识点来优化,并且本章的知识点在逆向、安全、外挂等领域都会被经常使用,希望你能掌握好。
到这里,你是不是觉得自己迈入了高手之路呢!切记不要眼高手低,只有当你理解、吸收本章的内容,并能基于它们举一反三,扩展出更多的优化方案时,你才真正迈进了高手之路!
如果文章对你有帮助的话欢迎「关注+点赞+收藏】
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线