前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >你想要的Android性能优化系列:内存优化 !

你想要的Android性能优化系列:内存优化 !

作者头像
胡飞洋
发布2020-07-31 10:03:34
1.3K0
发布2020-07-31 10:03:34
举报
文章被收录于专栏:胡飞洋的Android进阶

本篇来自我的同事 梅贤斌 的投稿,分享了内存优化的知识,也是在我们项目组内进行技术分享的原稿。

一、类加载机制1. ClassLoader的类型2. ClassLoader的加载过程二、Java虚拟机的运行时内存模型三、垃圾标记算法1、引用计数算法:2、根搜索算法3、Java中的引用类型四、垃圾收集算法1. 标记-清除算法2. 复制算法3. 标记压缩算法4. 分代收集算法五、Android 虚拟机1. Android使用的虚拟机2. 引起GC的原因3.垃圾收集六、常见的内存问题七、常见的内存泄漏场景

在Android系统中,系统为每个App分配的内存都是有限的,如果不合理的使用,就有可能造成一系列的内存问题,如:内存泄漏,内存溢出和内存抖动,这些问题在App中的体现为应用卡顿,不流畅,严重的时候还会导致App的崩溃,这样就严重影响了用户的体验。所以对App内存的治理,就显得尤为重要。要想很好的解决App的内存问题,就需要先弄懂Android的内存管理机制,知其然知其所以然,明白原理之后,在结合相关的工具,去定位并解决问题。

机型

系统版本

默认分配的最大内存:heapgrowthlimit

开启largeHeap后,分配的最大内存:dalvik.vm.heapsize

三星 Galaxy A9

Android 9.0

256M

512M

模拟器

Android 6.0

192M

512M

一、类加载机制

在介绍Java运行时内存之前,先来看看类是如何被加载到内存中来的。类的加载时通过ClassLoader加载到内存中的。下面就先了解一下Android中,ClassLoader有哪些类型。

1. ClassLoader的类型

Android中的ClassLoader类型分为两种类型,分别是系统类加载器和自定义类加载器。其中系统类加载器主要包括3种,分别是:BootClassLoader、PathClassLoader和DexClassLoader。

  • BootClassLoader:继承与ClassLoader类,主要用来加载 framework的class文件。它在Zygote进程启动开始时,在ZygoteInit.main()方法中执行资源预加载,此时会单例创建BootClassLoader对象,它在loadClass中直接调用findClass(),而findClass()中调用Class.classForName(name, false, null)查找类; 预加载的类:https://android.googlesource.com/platform/frameworks/base/+/b0d93ee4f84643e1a7dbddc95f693faa88e87228/preloaded-classes
  • BaseDexClassLoader:PathClassLoader 和 DexClassLoader的父类,主要的执行逻辑和文件的处理都在其中(后面会分析它的源码);
  • DexClassLoader:可以加载dex文件以及包含dex的压缩文件(apk和jar文件),不管加载哪种文件,最终都是加载dex文件。
  • PathClassLoader:负责加载系统和apk中的类,context.getClassLoader获取到的就是PathClassLoader实例,App就是默认用PathClassLoader来加载类的。

App中打印类的继承关系:

代码语言:javascript
复制
ClassLoader classLoader = getClassLoader();
while (classLoader != null) {
    Log.i(TAG, "getClassLoaderInfo: " + classLoader.toString());
    classLoader = classLoader.getParent();
}

打印的信息如下:

getClassLoaderInfo: dalvik.system.PathClassLoader[ DexPathList[ [zip file "/data/app/com.mei.memoryoptmjnAExPuozZ0Se4OXdpW0A==/base.apk"], nativeLibraryDirectories=[/data/app/com.mei.memoryopt-mjnAExPuozZ0Se4OXdpW0A==/lib/arm, /system/lib, /vendor/lib] ] ] getClassLoaderInfo: java.lang.BootClassLoader@6b71756

从打印信息可以看出:

  • PathClassLoader加载的dex文件路径是:"/data/app/com.mei.memoryopt-mjnAExPuozZ0Se4OXdpW0A==/base.apk"
  • PathClassLoader的父类是BootClassLoader,是在构造函数中传入的。

继承关系类图如下:

WeChatWorkScreenshot_5d8b3068-20d0-490c-af15-07eaac83c50f

2. ClassLoader的加载过程

类是通过ClassLoader的loadClass方法加载到内存的,loadClass()被定义在抽象ClassLoader中,如下所示:

代码语言:javascript
复制
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 1. 检查类是否已经加载过,已经加载,则直接返回
    Class<?> c = findLoadedClass(name);
    if (c == null) {// 没有加载
      try {
        // 2. 父类不为空,则调用父类的加载方法
        if (parent != null) {
          c = parent.loadClass(name, false);
        } else {
          c = findBootstrapClassOrNull(name);// 这个方法直接返回null
        }
      } catch (ClassNotFoundException e) {
        // ClassNotFoundException thrown if class not found
        // from the non-null parent class loader
      }

      // 3. 如果父类加载器加载失败,则调用自己的加载方法,findClass方法由各个子类自己实现
      if (c == null) {
        // If still not found, then invoke findClass in order
        // to find the class.
        c = findClass(name);
      }
    }
    return c;
}

在loadClass方法中,采用了一个双亲委托的模式去加载。

双亲委托:首先检查类是否被加载过,如果没有则先委托给父加载器进行查找,这样依次进行递归,直到委托到最顶层的ClassLoader,如果最顶层ClassLoader找到了,就直接返回,如果没有找到,则继续依次向下调用自身的findClass()方法查找,如果找到了,则返回,没有则最终交由调用者自身去查找。流程如下图:

image-20200722155307346

采用双亲委托模式的好处:

  • 避免重复加载,如果已经加载过一次class,就不需要再次加载,而是直接读取已经加载的Class
  • 更加安全,如果不使用双亲委托模式,就可以自定义一个String 类来替代系统的String类,这显然会造成安全隐患,采用双亲委托模式会使得系统的String类在Java虚拟机启动时就被加载,也就无法自定义String类来替代系统的String类,除非我们修改类加载器搜索类的默认算法。还有一点,只有两个类名一致并且被同一个类加载器加载的类,Java虚拟机才会认为它们是同一个类,想要骗过Java虚拟机显然不会那么容易。

当通过双亲委托模式,没有找到指定的类,则会调用自身的findClass方法去找,findClass方法在基类ClassLoader中,是一个抽象方法,在其子类BaseDexClassLoader中有具体实现,PathClassLoader和DexClassLoader继承于BaseDexClassLoader,下面看看BaseDexClassLoader中的具体实现:

代码语言:javascript
复制
 public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(parent);
   // 创建DexPathList
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);

        if (reporter != null) {
            reportClassLoaderChain();
        }
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
  // 调用DexPathList的findClass方法加载类
    Class c = pathList.findClass(name, suppressedExceptions);
    if (c == null) {
        ClassNotFoundException cnfe = new ClassNotFoundException(
                "Didn't find class \"" + name + "\" on path: " + pathList);
        for (Throwable t : suppressedExceptions) {
            cnfe.addSuppressed(t);
        }
        throw cnfe;
    }
    return c;
}

我们看到,在BaseDexClassLoader的findClass方法又调用了DexPathList的findClass方法:

代码语言:javascript
复制
/**
 * Finds the named class in one of the dex files pointed at by
 * this instance. This will find the one in the earliest listed
 * path element. If the class is found but has not yet been
 * defined, then this method will define it in the defining
 * context that this instance was constructed with.
 *
 * @param name of class to find
 * @param suppressed exceptions encountered whilst finding the class
 * @return the named class or {@code null} if the class is not
 * found in any of the dex files
 */
public Class<?> findClass(String name, List<Throwable> suppressed) {
  // 这里每一个Element对象,都代表一个dex文件
  // 这里就会循环去遍历每一个dex文件,是否有指定类名:name的类存在,只要在一个dex文件中找到了,就返回
    for (Element element : dexElements) {
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        if (clazz != null) {
            return clazz;
        }
    }

    if (dexElementsSuppressedExceptions != null) {
        suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
    }
    return null;
}

DexPathList中保存有代表dex文件的Element数组,这里会循环去遍历这个数组,并调用Element的findClass方法,Element是DexPathList的静态内部类,里面封装了DexFile,即dex文件,

image-20200723155936029

代码语言:javascript
复制
static class Element {
/**
 * A file denoting a zip file (in case of a resource jar or a dex jar), or a directory
 * (only when dexFile is null).
 */
private final File path;

private final DexFile dexFile;

/**
 * Element encapsulates a dex file. This may be a plain dex file (in which case dexZipPath
 * should be null), or a jar (in which case dexZipPath should denote the zip file).
 */
public Element(DexFile dexFile, File dexZipPath) {
    this.dexFile = dexFile;
    this.path = dexZipPath;
}

public Class<?> findClass(String name, ClassLoader definingContext,
        List<Throwable> suppressed) {
  // 这里调用了DexFile的loadClassBinaryName方法去加载类
    return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed)
            : null;
}

DexFile就是一个dex文件,里面就有指向dex文件的file路径:

代码语言:javascript
复制
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
    return defineClass(name, loader, mCookie, this, suppressed);
}

private static Class defineClass(String name, ClassLoader loader, Object cookie,
                                 DexFile dexFile, List<Throwable> suppressed) {
    Class result = null;
    try {
          // 这里就是调用一个native方法,去查找类
        result = defineClassNative(name, loader, cookie, dexFile);
    } catch (NoClassDefFoundError e) {
        if (suppressed != null) {
            suppressed.add(e);
        }
    } catch (ClassNotFoundException e) {
        if (suppressed != null) {
            suppressed.add(e);
        }
    }
    return result;
}

// 这是一个native方法
private static native Class defineClassNative(String name, ClassLoader loader, Object cookie, DexFile dexFile)

在DexFile中,最终就调defineClassNative方法了,这是一个Native方法,源码在这里就不看了。到此,类加载的流程就走完了。

二、Java虚拟机的运行时内存模型

Android原生开发以java为主,在java中,Java内存模型,往往是指Java程序在运行时内存的模型,而Java代码是运行在Java虚拟机之上的,所以Java内存模型,也就是指Java虚拟机的运行时内存模型。Java中内存全权交给虚拟机去管理,那虚拟机的运行时内存是如何构成的?

image-20200718234529087

从上图可以看出Java虚拟机 运行时内存划分为:方法区、堆、程序计数器pc、虚拟机栈、本地方法栈等模块。

其中:程序计数器pc、虚拟机栈和本地方法栈,是线程私有的;方法区和堆内存是线程共享的。下面对他们进行详细的介绍:

1. 程序计数器PC

作用:记录线程代码执行到那个位置,即保存正在执行的字节码指令地址。如果是native方法,则值为空

范围:线程私有

异常:程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况的数据区域。

2. 虚拟机栈

作用:存储线程中Java方法调用的状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java虚拟机栈包含了多个栈帧,一个栈帧用来存储:局部变量表、操作数栈、动态链接、方法出口等信息。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java虚拟机栈中,在该方法执行完成后,这个栈帧就从Java虚拟机栈中弹出。我们平常所说的栈内存(Stack)指的就是Java虚拟机栈。

范围:线程私有,生命周期与线程相同,与线程是同时创建。

异常:

StackOverFlowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出 (递归函数);

OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够内存时抛出 (OOM)

3. 本地方法栈

作用:作用同虚拟机栈,只不过本地方法栈是为native方法服务的。

范围:线程私有

异常:StackOverflowError和OutOfMemoryError异常

4. 方法区

作用:存储已经被Java虚拟机加载的类的结构信息,包括运行时常量池、字段和方法信息、静态变量等数据。

范围:被所有线程共享

异常:OutOfMemoryError异常,在方法区的内存空间不满足内存分配需求时,会抛出。

5. 常量池:

作用:用于存放编译器生成的各种字面量和符号引用。运行时常量池除了编译期产生的Class文件的常量池,还可以在运行期间,将新的常量加入常量池,比较String类的intern()方法。

范围:运行时常量池是方法区的一部分

异常:OutOfMemoryError异常

6. Java堆

作用:Java堆用来存放对象实例,几乎所有的对象实例都在这里分配内存。而在虚拟机栈中分配的只是引用,这些引用会指向堆中真正存储的对象。

范围:被所有线程共享

异常:OutOfMemoryError异常,在堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展时,则会抛出。

局部变量的基本数据类型和引用存储于栈中,引用的对象实体存储于堆中。因为它们属于方法中的变量,生命周期随方法而结束。

成员变量全部存储与堆中(包括基本数据类型,引用和引用的对象实体)。因为它们属于类,类对象终究是要被new出来使用的。

我们知道,内存的释放是由垃圾收集器(Garbage Collection,GC)完成的,而GC主要就是针对Java堆内存进行管理和回收,但GC只能回收无用并且不再被其它对象引用的那些对象所占用的堆空间,并且内存泄漏也都是发生在这个区域。

堆中几乎存放着Java世界中所有的对象实例,垃圾收集器在对堆回收之前,第一件事情就是要确定这些对象哪些还“存活”着,哪些对象已经“死去”(即不可能再被任何途径使用的对象),确定对像是否存活这就需要了解:垃圾标记算法。

三、垃圾标记算法

垃圾收集器(Garbage Collection),通常被称作GC。GC主要做了两个工作:

  • 内存的划分和分配,
  • 对垃圾进行回收。

关于内存的划分和分配,目前Java虚拟机内存的划分是依赖于GC设计的,比如现在GC都是采用了分代收集算法来回收垃圾的,Java堆作为GC主要管理的区域,被细分为新生代和老年代,再细致一点新生代又可以划分为Eden空间、From Survivor空间、To Survivor空间等,这样划分是为了更快地进行内存分配和回收。空间划分后,GC就可以为新对象分配内存空间。关于对垃圾进行回收,被引用的对象是存活的对象,而不被引用的对象是死亡的对象(也就是垃圾),GC要区分出存活的对象和死亡的对象(也就是垃圾标记),并对垃圾进行回收。

在对垃圾进行回收前,GC要先标记出垃圾,那么如何标记呢?目前有两种垃圾标记算法:

  • 分别是引用计数算法
  • 根搜索算法
1、引用计数算法:

引用计数算法的基本思想就是每个对象都有一个引用计数器,当对象在某处被引用的时候,它的引用计数器就加1,引用失效时就减1。当引用计数器中的值变为0,则该对象就不能被使用,变成了垃圾。

目前主流的Java虚拟机没有选择引用计数算法来为垃圾标记,主要原因是引用计数算法没有解决对象之间相互循环引用的问题。举个例子,在下面代码的注释1和注释2处,tom和mike相互引用,除此之外这两个对象无任何其他引用,实际上这两个对象已经死亡,应该作为垃圾被回收,但是由于这两个对象互相引用,引用计数就不会为0,如果Java虚拟机采用了引用计数算法,垃圾收集器就无法回收它们。

代码语言:javascript
复制
class Student {
    Student friend;
}
//
Student s1 = new Student();
Student s2 = new Student();
s1.friend = s2;// 1
s2.friend = s1;// 2

s1 = null;
s2 = null;

优点:

  引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:

  无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

2、根搜索算法

这个算法的基本思想就是选定一些对象作为GC Roots,并组成根对象集合,然后以这些GCRoots的对象作为起始点,向下搜索,如果目标对象到GC Roots是连接着的,我们则称该目标对象是可达的,如果目标对象不可达则说明目标对象是可以被回收的对象,如图下图所示。

image-20200719141326799

从上图可以看出,ObjF、ObjD和ObjE都是不可达的对象,其中ObjD和ObjE虽然互相引用,但是因为它们到GC Roots是不可达的,所以它们仍旧被判定为可回收的对象,这样根搜索算法就解决了引用计数算法无法解决的问题:已经死亡的对象因为相互引用而不能被回收。

在Java中,可以作为GC Roots的对象主要有以下几种:

  • 虚拟机栈(本地变量表)中正在运行使用的引用
  • 本地方法栈中JNI引用的对象。
  • 方法区中运行时常量池引用的对象。
  • 方法区中静态属性引用的对象。
  • 运行中的线程。
  • 由引导类加载器加载的对象。
  • GC控制的对象。

虽然根搜索算法解决了引用计数算法因对象相互引用而无法释放的问题,但根搜索算法也会带来一个新的问题,即内存泄漏。

内存泄漏:堆内存中的长生命周期的对象(gc root)持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的根本原因。总结一句话就是不需要了该回收因为引用问题导致不能回收。

内存泄漏会导致可用内存慢慢变少,让程序慢慢变卡。最终还会导致臭名昭著的oom ,即内存溢出

但是即使在可达性分析算法中不可达的对象,也并非一定要死。当gc第一次扫过这些对象的时候,他们处于“死缓”的阶段。要真正执行死刑,至少需要经过两次标记过程。如果对象经过可达性分析之后发现没有与GC Roots相关联的引用链,那他会被第一次标记,并经历一次筛选。这个对象的finalize()方法会被执行。如果对象没有覆盖finalize或者已经被执行过了。虚拟机也不会去执行finalize方法。Finalize是对象逃狱的最后一次机会。验证代码如下:

代码语言:javascript
复制
package com.mei.lib;

/**
 * @author mxb
 * @date 2020/7/19
 * @desc 通过finalize()方法躲避GC
 * @desired
 */
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE = null;

    public String name;

    public void isAlive() {
        System.out.println(name + "还想活500年!嘿嘿,我自救成功了!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println(name + "要死了吗?不,我要自救。");
        // 不想死把自己存起来
        SAVE = this;
    }

    public static void main(String[] args) throws InterruptedException {
        FinalizeEscapeGC finalizeEscapeGC = new FinalizeEscapeGC();
        finalizeEscapeGC.name = "小强";
        // 引用置空,等待被GC回收
        finalizeEscapeGC = null;

        System.gc();
        Thread.sleep(2000);
        // 是否获救
        if (SAVE != null) {
            SAVE.isAlive();
        } else {
            System.out.println("小强已经死翘翘,第一次");
        }

        // 又被抓住了
        SAVE = null;
        System.gc();
        Thread.sleep(2000);
        if (SAVE != null) {
            SAVE.isAlive();
        } else {
            System.out.println("小强已经死翘翘,第二次");
        }
    }
}

输出结果如下:

Task :lib:FinalizeEscapeGC.main() 小强要死了吗?不,我要自救。 小强还想活500年!嘿嘿,我自救成功了! 小强已经死翘翘,第二次

在例子中,对象第一次被执行了finalize方法,但是把自己交给别的引用,但再次被置空的时候,GC又一次发现该对象到GC Root 不可达,这个时候就没办法自救了,只有被回收。

总结:

  • 对象第一次被GC 判断到gc root不可达时,会调用finalize()方法,第二次还是不可达时,直接回收
  • 对象被回收,至少需要经过两次标记。
  • 内存泄漏:堆内存中的长生命周期的对象持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的根本原因。总结一句话就是不需要的对象因为GC Root的引用导致不能回收。
  • 内存泄漏会导致内存溢出。

在上面的分析中,一直都提到一个关键词:引用。GC过程与对象的引用类型是密切相关的,下面就介绍一下Java中的引用类型。

3、Java中的引用类型

Java的引用类型有四种:强引用,软引用,弱引用和虚引用。

  • 强引用: 当我们新建一个对象时就创建了一个具有强引用的对象,如果一个对象具有强引用,GC在回收内存的时候不会回收它。Java虚拟机宁愿抛出OutOfMemoryError异常,使程序异常终止,也不会回收具有强引用的对象来解决内存不足的问题。
  • 软引用: 如果一个对象只具有软引用,当内存不够时,会回收这些对象的内存,回收后如果还是没有足够的内存,就会抛出OutOfMemoryError异常。Java提供了SoftReference类来实现软引用。
  • 弱引用: 弱引用比起软引用具有更短的生命周期,垃圾收集器一旦发现了只具有弱引用的对象,不管当前内存是否足够,都会回收它的内存。Java提供了WeakReference类来实现弱引用。
  • 虚引用: 虚引用并不会决定对象的生命周期,如果一个对象仅持有虚引用,这就和没有任何引用一样,在任何时候都可能被垃圾收集器回收。一个只具有虚引用的对象,被垃圾收集器回收时会收到一个系统通知,这也是虚引用的主要作用。Java提供了PhantomReference类来实现虚引用。

下面通过代码体会一下:

代码语言:javascript
复制
public static void main(String[] args) throws InterruptedException {
        // 1.强引用
        Object student = new Object();

        // 2.软引用
        System.out.println("\n软引用------------");
        ReferenceQueue<Object> softReferenceQueue = new ReferenceQueue();// 引用队列
        SoftReference<Object> softReference = new SoftReference<>(student, softReferenceQueue);
        System.out.println("soft:" + softReference.get());
        System.out.println("soft queue:" + softReferenceQueue.poll());
        //请求gc
        student = null;
        System.gc();
        Thread.sleep(2_000);
        System.out.println("gc 之后的对象存活状态");
        System.out.println("soft:" + softReference.get());
        System.out.println("soft queue:" + softReferenceQueue.poll());

        // 3.弱引用
        System.out.println("\n弱引用------------");
        Object weakStudent = new Object();
        ReferenceQueue<Object> weakReferenceQueue = new ReferenceQueue();// 引用队列,
        WeakReference<Object> weakReference = new WeakReference<>(weakStudent, weakReferenceQueue);
        System.out.println("weak:" + weakReference.get());
        System.out.println("weak queue:" + weakReferenceQueue.poll());
        //请求gc
        weakStudent = null;
        System.gc();
        Thread.sleep(2_000);
        System.out.println("gc 之后的对象存活状态");
        System.out.println("weak:" + weakReference.get());
        System.out.println("weak queue:" + weakReferenceQueue.poll());

        // 4.虚引用
        System.out.println("\n虚引用------------");
        Object phantomObj = new Object();
        ReferenceQueue<Object> phantomReferenceQueue = new ReferenceQueue();
        PhantomReference<Object> phantomReference = new PhantomReference<>(phantomObj,
                phantomReferenceQueue);
        System.out.println("phantom:" + phantomReference.get());
        System.out.println("phantom queue:" + phantomReferenceQueue.poll());
        //请求gc
        phantomObj = null;
        System.gc();
        Thread.sleep(2_000);
        System.out.println("gc 之后的对象存活状态");
        System.out.println("phantom:" + phantomReference.get());
        System.out.println("phantom queue:" + phantomReferenceQueue.poll());
  }

执行结果:

Task :lib:TestReference.main() 软引用------------ soft:java.lang.Object@6d06d69c soft queue:null gc 之后的对象存活状态 soft:java.lang.Object@6d06d69c soft queue:null 弱引用------------ weak:java.lang.Object@7852e922 weak queue:null [GC (System.gc()) [PSYoungGen: 1310K->128K(76288K)] 1582K->399K(251392K), 0.0003742 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (System.gc()) [PSYoungGen: 128K->0K(76288K)] [ParOldGen: 271K->270K(175104K)] 399K->270K(251392K), [Metaspace: 2621K->2621K(1056768K)], 0.0037219 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] gc 之后的对象存活状态 weak:null weak queue:java.lang.ref.WeakReference@4e25154f 虚引用------------ phantom:null phantom queue:null gc 之后的对象存活状态 phantom:null phantom queue:java.lang.ref.PhantomReference@70dea4e

从上面的执行结果可以看出,在内存充足的情况下,在运行GC的时候,软引用是不会被GC回收的,而弱引用被GC回收了,并且把对象放到了:引用队列当中。

引用队列:ReferenceQueue(引用队列):

当gc(垃圾回收线程)准备回收一个对象时,如果发现它只有软引用(或弱引用,或虚引用)指向它,就会在回收该对象之前,把这个软引用(或弱引用,或虚引用)加入到与之关联的引用队列(ReferenceQueue)中。如果一个软引用(或弱引用,或虚引用)对象本身在引用队列中,就说明该引用对象所指向的对象被回收了。

当软引用(或弱引用,或虚引用)对象所指向的对象被回收了,那么这个引用对象本身就没有价值了,如果程序中存在大量的这类对象(注意,我们创建的软引用、弱引用、虚引用对象本身是个强引用,不会自动被gc回收),就会浪费内存。因此我们这就可以手动回收位于引用队列中的引用对象本身。

代码语言:javascript
复制
if (weakReferenceQueue.poll() != null) {// 引用队列中有对象,说明该引用对象所指向的对象被回收了,
    weakReference = null;// 这时我们可以把引用对象weakReference置空,以便引用对象被GC回收。
}

在Android应用的开发中,为了防止内存溢出,在处理一些占用内存大而且生命周期较长的对象时候,可以尽量应用软引用弱引用技术。

对于软引用和弱引用的选择:

  • 如果只是想避免OutOfMemory异常的发生,则可以使用软引用。
  • 如果对于应用的性能更在意,想尽快回收一些占用内存比较大的对象,则可以使用弱引用。
  • 另外可以根据对象是否经常使用来判断选择软引用还是弱引用。如果该对象可能会经常使用的,就尽量用软引用。如果该对象不被使用的可能性更大些,就可以用弱引用。

四、垃圾收集算法

在对对象进行回收前需要对垃圾进行采集,不同的虚拟机实现可能使用不同的垃圾收集算法,不同的收集算法的实现也不尽相同。不同的算法各有各的优劣势。常用的收集算法有:

  • 标记-清除算法 Mark-Sweep
  • 复制算法 Copying
  • 标记压缩算法 Mark-Compact
  • 分代收集算法

下面就对他们进行一一的介绍

1. 标记-清除算法

实现原理:标记—清除算法(Mark-Sweep)是一种常见的基础垃圾收集算法,它将垃圾收集分为两个阶段。

  • 标记阶段:标记出可以回收的对象。
  • 清除阶段:回收被标记的对象所占用的空间。

标记—清除算法之所以是基础的,是因为后面讲到的垃圾收集算法都是在此算法的基础上进行改进的。标记—清除算法的执行过程如下图所示。

image-20200719160047507

如上图所示。标记-清除算法不会进行对象的移动,直接回收不存活的对象,因此会造成内存碎片。

特点

  • 一个是标记和清除的效率都不高
  • 再者就是容易产生大量不连续的内存碎片。

比如我们回收后,如果我需要创建一个占了10个格子的内存大小的对象,这种情况,就会触发新的一次垃圾收集回收,如果回收后的内存还是不够,就内存溢出了。因为虽然我们现在有这么大的内存可以使用,但是没有连续的这么大的内存。

2.复制算法

为了解决标记—清除算法的效率不高的问题,产生了复制算法。它把内存空间划为两个相等的区域,每次只使用其中一个区域。在垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用的区域的可回收的对象进行回收。简单来说就是:

  • 将内存划分为大小相等的两块。
  • 一块内存用完之后复制存活对象至另一块。
  • 清理另一块内存。

复制算法的执行过程如下图所示。

image-20200719161045458

这种算法每次都对整个半区进行内存回收,不需要考虑内存碎片的问题,代价就是使用内存为原来的一半。复制算法的效率与存活对象的数目多少有很大的关系,如果存活对象很少,复制算法的效率就会很高。由于绝大多数对象的生命周期很短,并且这些生命周期很短的对象都存于新生代中,所以复制算法被广泛应用于新生代中,关于新生代中复制算法的应用,会在后面的分代收集算法中详细介绍。

特点:

  • 实现简单,运行高效。
  • 浪费一半空间,代价大。
  • 对象存活率较高时会进行较多的复制操作,效率会变低。
3. 标记压缩算法

标记过程仍然与“标记-清除”算法一样,与标记—清除算法不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使它们紧凑地排列在一起,然后对边界以外的内存进行回收,回收后,已用和未用的内存都各自一边。即:

  • 标记过程与 ”标记-清除“ 算法一样。
  • 存活对象往一端进行移动。
  • 清理其余内存。

标记—压缩算法的执行过程如下图所示。

image-20200719161542782

标记—压缩算法解决了标记—清除算法效率低和容易产生大量内存碎片的问题,它被广泛应用于老年代中。

标记-压缩算法虽然缓解的内存碎片问题,但是它也引用了额外的开销,比如说额外的空间来保存迁移地址,需要遍历多次堆内存等。

特点

  • 避免 ”标记-清除” 算法导致的内存碎片。
  • 避免复制算法的空间浪费。
4. 分代收集算法

在Java中,各种对象的生命周期会有着较大的差别,大部分对象生命周期很短暂,少部分对象生命周期很长,有的甚至与应用程序以及Java虚拟机的运行周期一样长。因此把Java堆区的空间根据对象的生命周期长短进行划分,并在不同的内存区域中采用不同的收集算法,这就是分代的概念。

现在主流的虚拟机的垃圾收集器都采用分代收集算法。Java 堆区基于分代的概念,分为新生代(Young Generation)和老年代(Tenured Generation),其中新生代再细分为Eden空间、From Survivor空间和To Survivor空间。因为Eden空间中的大多数对象生命周期很短,所以新生代的空间划分并不是均分的,HotSpot虚拟机默认Eden空间和两个Survivor空间的所占的比例为8:1。Java堆区的空间划分如下图所示。

根据Java堆区的空间划分,垃圾收集的类型分为两种,它们分别如下。

  • Minor Collection:新生代垃圾收集。
  • Full Collection:对老年代进行收集,又可以称作Major Collection,Full Collection通常情况下会伴随至少一次的Minor Collection,它的收集频率较低,耗时较长。

(1)新生代

功能:新建的对象都是用新生代分配内存。

GC过程:当执行一次新生代垃圾收集时,把Eden空间的存活对象复制到To Survivor空间,把From Survivor空间存活的且仍年轻的对象也会复制到To Survivor空间,即把存活的对象都复制到To Survior空间,并把对象的年龄加1;接着就可以把Eden空间和From Survior空间的内存都会被回收掉。

有两种情况Eden空间和From Survivor空间存活的对象不会复制到To Survivor 空间,而是晋升到老年代

  • 一种是存活的对象的分代年龄超过-XX:MaxTenuringThreshold(用于控制对象经历多少次Minor GC才晋升到老年代)所指定的阈值,最大是15。
  • 另一种是To Survivor空间容量达到阈值。当所有存活的对象被复制到To Survivor空间,或者晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收对象。

当Eden空间和From Survivor空间存活的对象都复制到To Survivor空间后,Eden空间和From Survivor空间都会被清空。然后就将From Survivor空间和To Survivor空间互换位置,也就是此前的From Survivor 空间成为了现在的To Survivor 空间,每次Survivor空间互换都要保证To Survivor空间是空的,这就是复制算法在新生代中的应用。在老年代则会采用标记—压缩算法或者标记—清除算法。

(2)老年代

用于存放新生代中经过N次垃圾回收仍然存活的对象。

老年代的垃圾回收称为Major GC。整堆包括新生代与老年代的垃圾回收称之为Full GC。

一句话描述:

一般来说,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以一般选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就需要使用“标记-压缩”算法来进行回收。

特点

  • 结合多种收集算法的优势。
  • 新生代对象存活率低 => “复制” 算法(注意这里每一次的复制比例都是可以调整的,如一次仅复制 30% 的存活对象)。
  • 老年代对象存活率高 => “标记-整理” 算法。

五、Android 虚拟机

1. Android使用的虚拟机

相比于传统的Java虚拟机,Android使用的虚拟机有很大的差异。由于 Androd 运行在移动设备上,内存以及电量等诸多方面跟一般的 PC 设备都有本质的区别 ,一般的 JVM 没法满足移动设备的要求,所以Android根据Java 虚拟机规范开发符合自己需求的虚拟机。

Android经过这么多年的发展,一共使用过两种虚拟机:Dalvik虚拟机和ART 虚拟机。

  • Dalvik虚拟机 Dalvik虚拟机,简称DVM。在Android 5.0之前,默认使用的就是此虚拟机。
  • ART虚拟机: ART 是在 Android 4.4 中引入的,在Android4.4默认使用的是Dalvik虚拟机,而在Android 5.0 及更高版本的默认使用ART虚拟机。至此google已不再继续维护和提供 Dalvik 运行时,现在 ART 采用了其字节码格式。ART 有多个不同的 GC 方案,这些方案包括运行不同垃圾回收器。。

Dalvik虚拟机和 ART虚拟机对比

  • 字节码编译成机器码的时机不同。Dalvik通过JIT编译器把字节码编译成机器码,及时编译。而ART在App安装的时候,会进行一次预编译(AOT),并把编译后的机器码存储在本地。预编译有两个问题:
  • 导致App安装时间变长
  • App会占用更多的手机存储空间 在Android 7.0版本中,ART加入了即时编译器JIT,在App安装时,并不会讲字节码全部编译成机器码,而是在运行中,将热点代码编译成机器码,并存储在本地,从而缩短了App的安装时间和节省了存储空间。
  • Dalvik时32位CPU设计的,而ART支持64位并兼容32位的CPU。
  • ART对垃圾回收机制进行了改进,比如更频繁地执行并行垃圾收集,将GC暂停两次减少为1次等。
  • ART的运行时堆空间划分与Dalvik不同。
2. 引起GC的原因

Dalvik虚拟机引发GC的原因:

  • GC_CONCURRENT:表示是在已分配内存达到一定量之后触发的GC。
  • GC_FOR_MALLOC:当堆内存已满时,App尝试分配内存而引起的GC,系统必须停止App并回收内存。
  • GC_HPROF_DUMP_HEAP:当你请求创建HPROF文件来分析堆内存时出现的GC。
  • GC_EXPLICIT:显式的GC,例如调用System.gc()(应该避免调用显式的GC,信任GC会在需要时运行)。
  • GC_EXTERNAL_ALLOC:仅适用于API级别小于等于10,且用于外部分配内存的GC。

ART虚拟机引发GC的原因,比Dalvik要多一些:

  • Concurrent:并发GC,不会使App的线程暂停,该GC是在后台线程运行的,并不会阻止内存分配。
  • Alloc:当堆内存已满时,App尝试分配内存而引起的GC,这个GC会发生在正在分配内存的线程中。(如果是主线程,会占用)
  • Explicit:App显示的请求垃圾收集,例如调用System.gc()。与DVM一样,最佳做法是应该信任GC并避免显式地请求GC,显式地请求GC会阻止分配线程并不必要地浪费CPU周期。如果显式地请求GC导致其他线程被抢占,那么有可能会导致jank (App同一帧画了多次)。
  • NativeAlloc:Native内存分配时,比如为Bitmaps或者RenderScript分配对象,这会导致Native内存压力,从而触发GC。
  • CollectorTransition:由堆转换引起的回收,这是运行时切换GC 而引起的。收集器转换包括将所有对象从空闲列表空间复制到碰撞指针空间(反之亦然)。当前,收集器转换仅在以下情况下出现:在内存较小的设备上,App将进程状态从可察觉的暂停状态变更为可察觉的非暂停状态(反之亦然)。
  • HomogeneousSpaceCompact:齐性空间压缩是指空闲列表到压缩的空闲列表空间,通常发生在当App已经移动到可察觉的暂停进程状态时。这样做的主要原因是减少了内存使用并对堆内存进行碎片整理。
  • DisableMovingGc:不是真正触发GC 的原因,发生并发堆压缩时,由于使用了GetPrimitiveArrayCritical,收集会被阻塞。在一般情况下,强烈建议不要使用GetPrimitiveArrayCritical,因为它在移动收集器方面具有限制。
  • HeapTrim:不是触发GC的原因,但是请注意,收集会一直被阻塞,直到堆内存整理完毕。
3.垃圾收集

3.1 Dalvik的垃圾收集

Dalvik虚拟机垃圾收集主要是通过mark-sweep(标记-清除)算法实现的。简单点说,dalvik虚拟机的mark-sweep算法就分为两个阶段,即mark阶段和sweep阶段。

标记阶段:在标记阶段,会两次暂停GC线程之外的所有线程,即终止App的运行:

  • 第一次遍历堆地址空间,标记不可达对象
  • 在标记第二次标记结束之后,再次禁止GC线程之外的其它线程执行,以便GC线程再次根据Card Table记录的信息对被修改过的对象引用的其它对象进行重新标记

清除阶段:根据标记出垃圾,回收对应对象的内存。

Dalvik虚拟机会造成两次停顿,这就有可能造成App的卡顿。

3.2 ART 垃圾收集

ART虚拟机的垃圾收集正对不同的区域,采用不同的垃圾收集算法,在新生代中,使用的就是复制算法,在老年代中使用的就是标记清除或者标记压缩算法。

标记阶段:相对于Dalvik虚拟机的垃圾收集器的标记过程,ART虚拟机在标记的时候,只会暂停一次App线程,提高了效率。

清除阶段:根据标记出垃圾,回收对应对象的内存。

通过对垃圾收集的了解,我们知道在垃圾回收的过程中,不可避免的会暂停其它的线程,如果某个时间内频繁的进行内存的分配,造成内存不足并引发频繁的GC,就会造成内存的抖动,也就会造成UI的卡顿。

官网:https://source.android.com/devices/tech/dalvik/gc-debug?hl=zh-cn

垃圾收集器介绍:https://juejin.im/post/5b6b986c6fb9a04fd1603f4a

六、常见的内存问题

我们知道Android应用的进程都是从Zygote的进程fork出来的。并且每个应用android会对其进行内存限制。我们可以查看 /system/build.prop中的对应字段,来查看app的最大允许申请内存。

  • -dalvik.vm.heapstartsize :堆分配的初始大小
  • -dalvik.vm.heapgrowthlimit :正常情况下dvm heap的大小是不会超过dalvik.vm.heapgrowthlimit的值。
  • -dalvik.vm.heapsize:manifest中指定android:largeHeap为true的极限堆大小,这个就是堆的最大值。

这些属性,在ActivityManager类中,都有对应的获取方法:

代码语言:javascript
复制
public int getMemoryClass() {
    return staticGetMemoryClass();
}

/** @hide */
static public int staticGetMemoryClass() {
    // Really brain dead right now -- just take this from the configured
    // vm heap size, and assume it is in megabytes and thus ends with "m".
    String vmHeapSize = SystemProperties.get("dalvik.vm.heapgrowthlimit", "");
    if (vmHeapSize != null && !"".equals(vmHeapSize)) {
        return Integer.parseInt(vmHeapSize.substring(0, vmHeapSize.length()-1));
    }
    return staticGetLargeMemoryClass();
}

public int getLargeMemoryClass() {
        return staticGetLargeMemoryClass();
    }

    /** @hide */
    static public int staticGetLargeMemoryClass() {
        // Really brain dead right now -- just take this from the configured
        // vm heap size, and assume it is in megabytes and thus ends with "m".
        String vmHeapSize = SystemProperties.get("dalvik.vm.heapsize", "16m");
        return Integer.parseInt(vmHeapSize.substring(0, vmHeapSize.length() - 1));
    }

正是因为Android系统为每个App分配的内存都是有限的,所以如果不合理的使用内存的话,就有可能造成一系列的内存问题,如:内存泄漏、内存溢出和内存抖动。

  1. 内存泄漏: 在垃圾标记算法中说过,堆内存中的长生命周期的对象(gc root)持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的根本原因。总结一句话就是不需要了该回收因为引用问题导致不能回收。
  2. 内存抖动: 在短时间内有大量的对象被创建或者被回收的现象,伴随着频繁的GC。通常存在内存抖动时,我们可以在Android StudioMonitors中看到如下场景:
代码语言:javascript
复制
image-20200719210219968举例说明,实现一个图片加载列表,代码如下:
public class MainActivity extends AppCompatActivity {

 private static final String TAG = "MainActivity";

 private RecyclerView mRecyclerView;

 @Override
 protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);
       mImageView = findViewById(R.id.icon_group);
       mRecyclerView = findViewById(R.id.recycler_view);
       mRecyclerView.setLayoutManager(new GridLayoutManager(this, 2));
       mRecyclerView.setAdapter(new Adapter());
   }

 private void getMemory() {
       ActivityManager activityManager = (ActivityManager) getSystemService(ACTIVITY_SERVICE);
 int heapGrowthLimit = activityManager.getMemoryClass();
       Log.i(TAG, "heapGrowthLimit: " + heapGrowthLimit);
 int largeMemoryClass = activityManager.getLargeMemoryClass();
       Log.i(TAG, "largeMemoryClass: " + largeMemoryClass);
   }

 private class Adapter extends RecyclerView.Adapter<Adapter.ViewHolder> { @NonNull
 @Override
 public Adapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
 return new ViewHolder(LayoutInflater.from(parent.getContext())
              .inflate(R.layout.item_image_list, parent, false));
  }

 @Override
 public void onBindViewHolder(@NonNull Adapter.ViewHolder holder, int position) {
      Bitmap bitmap = BitmapFactory
          .decodeResource(getResources(),R.drawable.ic_illustration_coupon_get);
      holder.mImageView.setImageBitmap(bitmap);
  }

 @Override
 public int getItemCount() {
 return Integer.MAX_VALUE;
  }

 public class ViewHolder extends RecyclerView.ViewHolder {

 public ImageView mImageView;

 public ViewHolder(@NonNull View itemView) {
 super(itemView);
          mImageView = (ImageView) itemView;
      }
  }

  }
 }

通过Android Studio捕捉到的内存图如下:

image-20200720173654899可以看到,在滑动图片列表的时候,发生GC的频率特别频繁,App的直观感受就是卡顿。 内存抖动避免:

  • 尽量避免在循环体内创建对象,应该把对象创建移到循环体外。
  • 注意自定义View的onDraw()方法会被频繁调用,所以在这里面不应该频繁的创建对象。
  • 当需要大量使用Bitmap的时候,试着把它们缓存在数组中实现复用。
  • 对于能够复用的对象,同理可以使用对象池将它们缓存起来。
  • 大量的字符串拼接,使用StringBuilder或者StringBuffer。
  1. 内存溢出(OOM):OOM就是申请的内存超过了Heap的最大值。 内存泄漏和内存抖动都会导致内存溢出异常。
  • 内存泄漏导致该被回收的对象无法回收,导致能使用的内存越来越少。
  • 内存抖动在短时间内频繁的申请和释放内存,导致内存碎片,无法申请到可用的连续内存,即oom。 内存溢出(OOM)可分为以下两种场景:
  • 内存真正不足:例如 APP 当前进程最大内存上限为 512 MB,当超过这个值就表明内存真正不足了。
  • 可用内存不足:手机系统内存极度紧张,就算 APP 当前进程最大内存上限为 512 MB,我们只分配了 200 MB,也会产生内存溢出,因为系统的可用内存不足了。

七、常见的内存泄漏场景

对于内存泄漏,其本质可理解为无法回收无用的对象。这里我总结了我在项目中遇到的一些常见的内存泄漏案例(包含解决方案)。

  1. 资源性对象未关闭 对于资源性对象不再使用时,应该立即调用它的close()函数,将其关闭,然后再置为null。例如Bitmap等资源未关闭会造成内存泄漏,此时我们应该在Activity销毁时及时关闭。
  2. 注册对象未注销 例如BraodcastReceiver、EventBus未注销造成的内存泄漏,我们应该在Activity销毁时及时注销。
  3. 类的静态变量持有大数据对象 尽量避免使用静态变量存储数据,特别是大数据对象,建议使用数据库存储。
  4. 单例造成的内存泄漏 优先使用Application的Context,如需使用Activity的Context,可以在传入Context时使用弱引用进行封装,然后,在使用到的地方从弱引用中获取Context,如果获取不到,则直接return即可。
  5. 非静态内部类的静态实例 该实例的生命周期和应用一样长,这就导致该静态实例一直持有该Activity的引用,Activity的内存资源不能正常回收。此时,我们可以将该内部类设为静态内部类或将该内部类抽取出来封装成一个单例,如果需要使用Context,尽量使用Application Context,如果需要使用Activity Context,就记得用完后置空让GC可以回收,否则还是会内存泄漏。
  6. Handler临时性内存泄漏 Message发出之后存储在MessageQueue中,在Message中存在一个target,它是Handler的一个引用,Message在Queue中存在的时间过长,就会导致Handler无法被回收。如果Handler是非静态的,则会导致Activity或者Service不会被回收。并且消息队列是在一个Looper线程中不断地轮询处理消息,当这个Activity退出时,消息队列中还有未处理的消息或者正在处理的消息,并且消息队列中的Message持有Handler实例的引用,Handler又持有Activity的引用,所以导致该Activity的内存资源无法及时回收,引发内存泄漏。解决方案如下所示:
  • 使用一个静态Handler内部类,然后对Handler持有的对象(一般是Activity)使用弱引用,这样在回收时,也可以回收Handler持有的对象。
  • 在Activity的Destroy或者Stop时,应该移除消息队列中的消息,避免Looper线程的消息队列中有待处理的消息需要处理。 需要注意的是,AsyncTask内部也是Handler机制,同样存在内存泄漏风险,但其一般是临时性的。对于类似AsyncTask或是线程造成的内存泄漏,我们也可以将AsyncTask和Runnable类独立出来或者使用静态内部类。
  1. 容器中的对象没清理造成的内存泄漏 在退出程序之前,将集合里的东西clear,然后置为null,再退出程序
  2. WebView WebView都存在内存泄漏的问题,在应用中只要使用一次WebView,内存就不会被释放掉。我们可以为WebView开启一个独立的进程,使用AIDL与应用的主进程进行通信,WebView所在的进程可以根据业务的需要选择合适的时机进行销毁,达到正常释放内存的目的。
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-07-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 胡飞洋 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、类加载机制
    • 1. ClassLoader的类型
      • 2. ClassLoader的加载过程
      • 二、Java虚拟机的运行时内存模型
        • 1. 程序计数器PC
          • 2. 虚拟机栈
            • 3. 本地方法栈
              • 4. 方法区
                • 5. 常量池:
                  • 6. Java堆
                  • 三、垃圾标记算法
                    • 1、引用计数算法:
                      • 2、根搜索算法
                        • 3、Java中的引用类型
                        • 四、垃圾收集算法
                          • 1. 标记-清除算法
                            • 2.复制算法
                              • 3. 标记压缩算法
                                • 4. 分代收集算法
                                • 五、Android 虚拟机
                                  • 1. Android使用的虚拟机
                                    • 2. 引起GC的原因
                                      • 3.垃圾收集
                                      • 六、常见的内存问题
                                      • 七、常见的内存泄漏场景
                                      相关产品与服务
                                      领券
                                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档