美文网首页
Android内存管理及内存泄漏分析(一)

Android内存管理及内存泄漏分析(一)

作者: dffd001V | 来源:发表于2019-03-25 21:17 被阅读0次

    1、堆和栈

    要了解Android的内存,必须先从Java的堆和栈看起,我们先看看《Think In Java》中对它们的定义:

    (1)堆栈(stack):位于通用RAM中,但通过它的“堆栈指针”可以从处理器哪里获得支持。堆栈指针若向下移动,则分配新的内存;若向上移动,则释放那些内存。这是一种快速有效的分配存储方法,仅次于寄存器。创建程序时候,JAVA编译器必须知道存储在堆栈内所有数据的确切大小和生命周期,因为它必须生成相应的代码,以便上下移动堆栈指针。这一约束限制了程序的灵活性,所以虽然某些JAVA数据存储在堆栈中——特别是对象引用,但是JAVA对象不存储其中。

    (2) 堆(heap):一种通用性的内存池(也存在于RAM中),用于存放所有的JAVA对象。堆不同于堆栈的好处是:编译器不需要知道要从堆里分配多少存储区域,也不必知道存储的数据在堆里存活多长时间。因此,在堆里分配存储有很大的灵活性。当你需要创建一个对象的时候,只需要new写一行简单的代码,当执行这行代码时,会自动在堆里进行存储分配。当然,为这种灵活性必须要付出相应的代码。用堆进行存储分配比用堆栈进行存储存储需要更多的时间。

    简单总结就是,栈中存放着局部变量的引用及方法的引用,堆中放着具体的对象。这里需要注意,栈中只放的是变量的引用,而不是变量指向的对象,有一咱例外情况是,现在有一些优化的JVM,在判断到某些局部变量创建的对象在方法退出或代码块退出后就可以回收时,为了加快速度,会把这个对象的内存分配在栈内存中,这种JVM目前很少,可以暂时不用考虑。

    JVM中还有另外几块内存区,我们也简单介绍一下:

    (3)寄存器(register)。这是最快的存储区,因为它位于不同于其他存储区的地方——处理器内部。但是寄存器的数量极其有限,所以寄存器由编译器根据需求进行分配。你不能直接控制,也不能在程序中感觉到寄存器存在的任何迹象。

    (4)静态存储(static storage)。这里的“静态”是指“在固定的位置”(尽管也位于RAM)。静态存储里存放程序运行时一直存在的数据。你可用关键字static来标识一个对象的特定元素是静态的,但JAVA对象本身从来不会存放在静态存储空间里。

    (5)常量存储(constant storage)。常量值通常直接存放在程序代码内部,这样做是安全的,因为它们永远不会被改变。有时,在嵌入式系统中,常量本身会和其他部分分割离开,所以在这种情况下,可以选择将其放在ROM中。(这点的一个例子便是字符串池,所有字面字符串和字符串常量表达式都被放到一个特殊的静态存储空间里。)

    下面这个图看起来会更加直观一些:

    运行时内存区域展示

    上面这个图,我们可以发现,多出一个“本地方法栈”,少一个“常量存储区”,这里需要解释一下,“本地方法栈”也属于栈,不过主要用于JNI Native层的变量内存,这里就不细讲它了;“常量存储区”实际上在包含在方法区中的,严格意义上来说,它不算独立的内存区,但它里面存放的内容又和类定义不同,所以就单提出来了。

    关于Java层的栈内容,这里需要独立说一下,JVM中所谓的栈,指的是线程栈,即每一个线程都有一个自己的栈内存区,如下图所示:

    栈内存展示

    实例分析:

    我们看一下下面这段代码的内存分配情况

    public  class  AppMain { //运行时, jvm 把appmain的信息都放入方法区

    public  static  void  main(String[] args) { //main 方法本身放入方法区。

    Sample test1 = new  Sample( " 测试1 " );  //test1是引用,所以放到栈区里, Sample是自定义对象应该放到堆里面

    Sample test2 = new  Sample( " 测试2 " );

    test1.printName();

    test2.printName();

    }

    }

    public class Sample {   //运行时, jvm 把appmain的信息都放入方法区

    private String name;

    /** 构造方法 */

    public  Sample(String name) {

    this .name = name;

    }

    /** 输出 */

    public void printName() {  //print方法本身放入 方法区里。

    System.out.println(name);

    }

    }

    内存分配情况图

    这里延伸出来一个平时Java经典的面试题:

    String aa = new String("aa"); // 这句代码生成了几个对象?

    2、Java内存自动回收机制

        JVM的代码是开源的,Sun公司并没有对JVM的垃圾回收机制做规范性的要求,同时,JVM对内存垃圾回收提供了很多参数,所以,各家公司在实现自己JVM时,大都不一样,特别是三星的手机,他们的JVM一直比较特殊,在垃圾回收上也表现的跟常规的JVM不一样。

    垃圾回收的两个关键点是:查找垃圾、清理垃圾,这两个关键点都有很多不同的技术,我们分别来介绍一下。

    先介绍一下垃圾查找,常见的有两种查找方法:

    (1)引用计数法

    堆中的每一个对象有一个引用计数,当一个对象被创建,并把指向该对象的引用赋值给一个变量时,引用计数置为1,当再把这个引用赋值给其他变量时,引用计数加1,当一个对象的引用超过了生命周期或者被设置为新值时,对象的引用计数减1,任何引用计数为0的对象都可以被当成垃圾回收。当一个对象被回收时,它所引用的任何对象计数减1,这样,可能会导致其他对象也被当垃圾回收。

    问题:很难检测出对象之间的额相互引用(引用循环问题)

    这种方式理解起来简单直接,执行起来效率也很高,但缺点也十分明显,我们可能会疑问,还有人用这种方式吗,有Objective-C中在用,它的循环引用交给开发者去处理了。

    (2)可达性分析算法(根搜索算法)

    此算法的基本思想就是选取一系列GCRoots对象作为起点,开始向下遍历搜索其他相关的对象,搜索所走过的路径成为引用链,遍历完成后,如果一个对象到GCRoots对象没有任何引用链,则证明此对象是不可用的,可以被当做垃圾进行回收。那么问题又来了,如何选取GCRoots对象呢?在Java语言中,可以作为GCRoots的对象包括下面几种:

    虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

    方法区中的类静态属性引用的对象。

    方法区中常量引用的对象。

    本地方法栈中JNI(Native方法)引用的对象。

    根搜索法

    Java中采用的是根搜索法来判断对象是不是可回收,这里需要注意,Roots结点是可以有多个的,另一方面即使一个对象被确认为可回收的对象,像Object 6,也不是说这个对象肯定会被回收,这就牵扯到finalize()方法的调用了。

    对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。1. 如果对象在进行可达性分析后发现没有与GCRoots相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法,若对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的finalize方法,即该对象将会被回收。反之,若对象覆盖了finalize方法并且该finalize方法并没有被执行过,那么,这个对象会被放置在一个叫F-Queue的队列中,之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,其他的事情交给此线程去处理。2.对F-Queue中对象进行第二次标记,如果对象在finalize方法中拯救了自己,即关联上了GCRoots引用链,如把this关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除,如果对象还是没有拯救自己,那就会被回收。如下代码演示了一个对象如何在finalize方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。

    finallize方法只能被执行一次。

    我们再来介绍一下垃圾清理的方法,一般来说,清理的方法包含三种,也都比较容易理解:

    (1)标记-清除算法

    首先标记出所有需要回收的对象,使用可达性分析算法判断一个对象是否为可回收,在标记完成后统一回收所有被标记的对象。下图是算法具体的一次执行过程后的结果对比:

    标记清楚法

    说明:1.效率问题,标记和清除两个阶段的效率都不高。2.空间问题,标记清除后会产生大量不连续的内存碎片,以后需要给大对象分配内存时,会提前触发一次垃圾回收动作。

    (2)复制算法

    将内存分为两等块,每次使用其中一块。当这一块内存用完后,就将还存活的对象复制到另外一个块上面,然后再把已经使用过的内存空间一次清理掉。下图是算法具体的一次执行过程后的结果对比:

    复制算法

    说明:1.无内存碎片问题。2.可用内存缩小为原来的一半。 3.当存活的对象数量很多时,复制的效率很慢。

    (3)标记-整理算法

    标记过程还是和标记 - 清除算法一样,之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,标记 - 整理算法示意图如下:

    标记整理法

    说明:无碎片

    在进行GC时,为了防止误判断垃圾或回收错误,一般要是暂停其它线程执行的,这样会造成程序的卡顿,而现在硬件的配置越来越高,常规方法回收一次垃圾占用的时间会很大,所以对于如何执行GC,即GC的执行时机和方法也创新出很多不同的方法:

    (1)Serial收集器

    Serial收集器为单线程收集器,在进行垃圾收集时,必须要暂停其他所有的工作线程,直到它收集结束。运行过程如下图所示

    说明:1. 需要STW(Stop The World),停顿时间长。2. 简单高效,对于单个CPU环境而言,Serial收集器由于没有线程交互开销,可以获取最高的单线程收集效率。

    (2)ParNew收集器(Parallel New)

    ParNew是Serial的多线程版本,除了使用多线程进行垃圾收集外,其他行为与Serial完全一样,运行过程如下图所示

    说明:1.Server模式下虚拟机的首选新生收集器,与CMS进行搭配使用。

    (3)Parallel Old收集器

    老年代的多线程收集器,使用标记 - 整理算法,吞吐量优先,适合于Parallel Scavenge搭配使用,运行过程如下图所示

    (4)CMS收集器

    CMS(Conrrurent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器。使用标记 - 清除算法,收集过程分为如下四步:

    初始标记,标记GCRoots能直接关联到的对象,时间很短。

    并发标记,进行GCRoots Tracing(可达性分析)过程,时间很长。

    重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。

    并发清除,回收内存空间,时间很长。

    其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。运行过程如下图所示:

    说明:无法处理浮动垃圾,因为在并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾,同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用。

    还有其它的一些收集方式,像G1,Serial Old等,各种方法都是在尽力减少因为GC造成的运行停顿。

    这一节最后,我们再一下常见的内存收回时的日志

    GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。

    GC_CONCURRENT: 当我们应用程序的堆内存达到一定量,或者可以理解为快要满的时候,系统会自动触发GC操作来释放内存。

    GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。

    GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。

    3、Android内存管理

    Android操作系统,本质上还是一个精简的linux操作系统,我们平常使用的各个APP,尽管数量庞大,且最为我们熟知,但实际上它只是运行在linux系统上的一个虚拟机进程而已,所以我们看Android的内存,会从宏观和微观两个方面来分析,一方面看操作系统的整体内存管理方式,另一方面,看看Dalvik虚拟机内部的内存管理:

    (1)匿名共享内存ashmem(Anonymous Shared Memory)

    Linux系统有自己的内存共享机制mmap。

    mmap系统调用是将一个打开的文件映射到进程的用户空间,mmap系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read(),write()等操作。

    mmap 函数原型:

    void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

    mmap的本质是进程间无法访问彼此之间的内存空间,但通过fd可以操作同一个文件,所以mmap就把一个文件(或者是设备)影射到内存,把这个内存地址给不同时的进程去操作,从而实现共享。当然,它的共享没我们想象的那么low,要不停的写磁盘,它是在内存中完成的,减少多次内存copy。

    mmap的一个很大的缺限是共享内存申请后不可变,这样导致申请多了浪费,申请少了又可能不够,Android就扩展了mmap,在它的基础上实现了匿名共享内存ashmem:

    ashmem为进程间提供大块共享内存,同时为内核提供回收和管理这个内存的机制。

    相比于malloc和anonymous/namedmmap等传统的内存分配机制,其优势是通过内核驱动提供了辅助内核的内存回收算法机制(pin/unpin)。什么是pin和unpin呢?具体来讲,就是当你使用Ashmem分配了一块内存,但是其中某些部分却不会被使用时,那么就可以将这块内存unpin掉。

    unpin后,内核可以将它对应的物理页面回收,以作他用。你也不用担心进程无法对unpin掉的内存进行再次访问,因为回收后的内存还可以再次被获得(通过缺页handler),因为unpin操作并不会改变已经 mmap的地址空间。

    这个pin和unpin,在我们的Bitmap中,就是我们熟悉的BitmapFactory.Options里面的isPurgeable属性,这个可以去研究一下skia的源码,其实isPurgeable设置为true,也不能保证图片的内存一定分配在共享内容中,skia只对大图才会用ashmem存储。

    从pin和unpin我们就可以看出,ashmem的一个最大的好处就是内存利用率, 申请到一块儿大内存后,再分成小块,不用的可以随时回收复用,对于手机,特别是早年硬件配置低的手机,这是非常有用的。Android上大名鼎鼎的Binder使用的就是ashmem实现进程间通信的。

    asheme的源码位置:kernel/mm/ashmem.c

    asheme代码分析可以见这个文章 :Ashmem 对 Android 内存分配与共享的增强

    Android中还用到了pmem,它的作用与ashmem相同,不过它只能申请连续内存,且有最大申请次数限制,所以它主要用在一些特殊设备接口上,比如:DSP、GPU等。

    (2)dalvik内存

    对于dalvik内存,我们首先需要明确一点,一个dalvik就是一个进程,正常的app都至少对应一个dalvik(多进程的app在执行到相关程序时,会起多个),所以,大家的手机上平时都是多个dalvik同时处于运行状态。dalvik与进程对应起来后,每个dalvik使用的内存资源对于操作系统而言,就是对进程内存的管理,各进程之间内存资源相互独立,都有自己的内存相对地址空间。

    Dalvik VM和JVM 在内存上的主要区别是 Dalvik VM是基于寄存器的架构(reg based),而JVM是栈机(stack based)。reg based VM的好处是可以做到更好的提前优化(ahead-of-time optimization)。 另外reg based的VM执行起来更快,但是代价是更大的代码长度。

    简单一句话总结就是:Dalvik效率高,移植难;JVM跨平台容易,效率稍逊。

    Android现在采用的ART运行模式是比dalvik更先进的VM,我们这是里就不分析ART了。

    我们看一下JVM启动时的常见参数设置:

    java -Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

    -Xms 表示初始堆大小

    -Xmx 表示最大堆大小

    -Xss 表示每个线程的堆栈大小

    对于Dalvik,每个手机也都有类似的启动参数,一般可以在文件/system/build.prop中查看:

    dalvik.vm.heapstartsize=8m

    dalvik.vm.heapgrowthlimit=192m

    dalvik.vm.heapsize=512m

    dalvik.vm.heapstartsize=8m 相当于虚拟机的 -Xms配置,该项用来设置堆内存的初始大小。

    dalvik.vm.heapgrowthlimit=192m 相当于虚拟机的 -XX:HeapGrowthLimit配置,该项用来设置一个标准的应用的最大堆内存大小。一个标准的应用就是没有使用android:largeHeap的应用。

    dalvik.vm.heapsize=512m 相当于虚拟机的 -Xmx配置,该项设置了使用android:largeHeap的应用的最大堆内存大小。

    所以,我们在写Android程序时,对于内存分配的大小,特别是一些缓存,像图片缓存,byte[]缓存,其它对象缓存等,都要考虑这些值,而不是根据Android版本号或者屏幕大小去统一设置一个缓存大小值 。

    获取Dalvik堆内存大小的几种方法:

    // 1、通过ActivityManager获取

    ActivityManager activityManager=(ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE);

    activityManager.getMemoryClass();

    activityManager.getLargeMemoryClass();

    // 2、通过Runtime获取

    Runtime rt = Runtime.getRuntime();

    rt.getFreeMemory();

    rt.getMaxMemory();

    rt.getTotalMemroy();

    // 注意:ActivityManager.getMemroyInfo(),这个方法不是用来获取dalvik内存的,这是获取系统总内存的,我们设置缓存大小时,一般不以它有依据;

    问题:一个dalvik占用的内存是很大的,如果手机上同时运行着几十个dalvik,手机内存受的了吗?我们接着往下看。

    (3)VSS RSS PSS USS

    在看这几个内存值之前,我看需要先了解一下linux中的动态链接库的内存分配,所谓动态链接库,就是我们平时编出来的so文件,linux为了提供动态链接库的效率,使用共享内存来加载so,它使用的内存是mmap,这部分内存可以被各进程共享。所以,在Android中,so的库内存是只有一份的,但要注意,so中的代码新申请的内存是属于进程的,并不在共享内存中。

    这也就解释了上面的问题,dalvik中的很多的so都是共享的,所以有很大一部分是共享内存,真正在dalvik进程的内存没有那么多;尽管如此,如果一个进程只是想执行一些与Android组件无关的代码(像文件处理),最好直接写一个可执行的文件,而不是在dalvik中执行。

    我们来看看关于这四个内存指标的解释:

    VSS:Virtual Set Size,虚拟耗用内存。它是一个进程能访问的所有内存空间地址的大小。这个大小包含了一些没有驻留在RAM中的内存,就像mallocs已经被分配,但还没有写入。VSS很少用来测量程序的实际使用内存。

    RSS:Resident Set Size,实际使用物理内存。RSS是一个进程在RAM中实际持有的内存大小。RSS可能会产生误导,因为它包含了所有该进程使用的共享库所占用的内存,一个被加载到内存中的共享库可能有很多进程会使用它。RSS不是单个进程使用内存量的精确表示。

    PSS:Proportional Set Size,实际使用的物理内存,它与RSS不同,它会按比例分配共享库所占用的内存。

    例如,如果有三个进程共享一个占30页内存控件的共享库,每个进程在计算PSS的时候,只会计算10页。

    PSS是一个非常有用的数值,如果系统中所有的进程的PSS相加,所得和即为系统占用内存的总和。当一个进程被杀死后,它所占用的共享库内存将会被其他仍然使用该共享库的进程所分担。在这种方式下,PSS也会带来误导,因为当一个进程被杀后,PSS并不代表系统回收的内存大小。

    USS:Unique Set Size,进程独自占用的物理内存。这部分内存完全是该进程独享的。USS是一个非常有用的数值,因为它表明了运行一个特定进程所需的真正内存成本。当一个进程被杀死,USS就是所有系统回收的内存。USS是用来检查进程中是否有内存泄露的最好选择。

    以上这些都是官方解释的中文译本。

    查看这几个指标的方法:

    top  | grep app名称

    ps  |  grep app名称

    procrank | grep app名称

    dumpsys meminfo app名称

    前两个命令只能查到VSS RSS内存占用信息。

    而后面两个命令可以查出  PSS USS内存占用。

    top

    dumpsys meminfo

    我们平时遇到的OOM中的heapSize,一般指的是uss,并不包括共享内存部分。

    (4)LowMemoryKiller

    Android是一个多任务系统,也就是说可以同时运行多个程序,这个大家应该很熟悉。一般来说,启动运行一个程序是有一定的时间开销的,因此为了加快运行速度,当你退出一个程序时,Android并不会立即杀掉它,这样下次再运行该程序时,可以很快的启动。随着系统中保留的程序越来越多,内存肯定会出现不足,这个时候Android系统开始挥舞屠刀杀程序。这里就有一个很明显的问题,杀谁?

    Android系统中杀程序的这个刽子手被称作"LowMemory Killer",它是在Linux内核中实现的。这里它实现了一个机制,由程序的重要性来决定杀谁。通俗来说,谁不干活,先杀谁。

    一:Android六大进程:

    Android将程序的重要性分成以下几类,按照重要性依次降低的顺序:

    1.前台进程(foreground):

    目前正在屏幕上显示的进程和一些系统进程。举例来说,当你运行一个程序,如浏览器,当浏览器界面在前台显示时,浏览器属于前台进程(foreground),但一旦你按home回到主界面,浏览器就变成了后台程序(background)。我们最不希望终止的进程就是前台进程。

    2.可见进程(visible):

    可见进程是一些不再前台,但用户依然可见的进程,举个例来说:widget、输入法等,都属于visible。这部分进程虽然不在前台,但与我们的使用也密切相关,我们也不希望它们被终止(你肯定不希望时钟、天气,新闻等widget被终止,那它们将无法同步,你也不希望输入法被终止,否则你每次输入时都需要重新启动输入法)

    3.次要服务(secondary server):

    目前正在运行的一些服务(主要服务,如拨号等,是不可能被进程管理终止的,故这里只谈次要服务)简单来说就是一些杀掉了不影响系统稳定运行,但是严重影响用户使用的服务。如GMS(GoogleMobile Service),即谷歌移动服务、拨号器等,杀掉相当影响用户使用。

    4.后台进程(hidden):

    虽然作者用了hidden这个词,但实际即是后台进程(background),就是我们通常意义上理解的启动后被切换到后台的进程,如浏览器,阅读器等。当程序显示在屏幕上时,他所运行的进程即为前台进程(foreground),一旦我们按home返回主界面(注意是按home,不是按back),程序就驻留在后台,成为后台进程

    5.内容供应节点(content provider):

    没有程序实体,进提供内容供别的程序去用的,比如日历供应节点,邮件供应节点等。在终止进程时,这类程序应该有较高的优先权(对于用户来说看不到只是代码里有这个)

    6.空进程(empty):

    没有任何东西在内运行的进程,有些程序,在程序退出后,依然会在进程中驻留一个空进程,这个进程里没有任何数据在运行,作用往往是提高该程序下次的启动速度或者记录程序的一些历史信息。这部分进程无疑是应该最先终止的。

    二:oom_adj和oom_score

    Low Memorry Killer的机制主要是通过进程的oom_adj和oom_score来进行内存的处理的

     1. 每一个进程都有一个oom_adj值,取值范围[-17,15]。

     2. 每一个进程都有一个oom_score值,它是根据oom_adj计算出一个值,分数越大越容易被杀死。

     3. 内存紧张时,LMK基于oom_adj和oom_score值来决定是否要回收一个进程。

     4. oom_adj值越小,越不容易被杀死,其中,-17时 oom_score为0表示不会被杀死。

     5. 查看oom_adj和oom_score方法:

    cat proc/pid/oom_adj

    cat proc/pid/oom_score

     六大进程分别对应的oom_adj值:

    oom_adj的值 

    在这个表中,前面代表的是程序重要性的名称,后面的数字代表的com_adj的数值分配,当然了,越小的值代表程序越重要,被Kill的可能性也就更小。

    三:LMK的进程回收策略

    Low Memory Killer Driver在用户空间指定了一组内存临界值及与之一一对应的一组oom_adj值,当系统剩余内存位于内存临界值中的一个范围内时,如果一个进程的oom_adj值大于或等于这个临界值对应的oom_adj值就会进入被杀掉队列。(不同手机阀值不一样) 以华为荣耀4为例:

    killer 阈值

    这里其实算出来的是一个阈值,阈值的意思是当手机内存小于阈值的情况下,内存就会开始逐级回收该类型的内存了。阈值中数值的单位是内存中的页面数量,一般情况下一个页面是4KB。比如说15 级别是 30720 * 4K = 123 M,即当手机内存小于123M的时候开始回收15级别的应用的内存,即选择一个oom_adj值最大并且消耗内存最多的进程来回收。

    对于oom_score的计算方法:

    oom_score计算

    LMK的机制各厂商会有一些自己的优化,像华为的OS就对杀进程级别就修改比较多。同时oom_abj也不一定只有上面6个值 ,所以经常会遇到我们使用官方的优先级,但最终还是会被系统杀死,这也是为什么我们做进程保活时,都是多种方法并用,以应对不同厂商的LMK机制。

    adj配置文件的位置:/sys/module/lowmemorykiller/parameters/

    这里大家可以思考一个问题,也是面试中常见的一个问题:IntentService和Thread的区别是什么?

    (5)dalvik Memory回收机制

    dalvik的内存回收机制,实际上跟目前主流的JVM内存回收机制类似,都是采用分代回收的方式。我们先看看分代的内存分配:

    这里所说的内存分配,主要指的是在堆上的分配,一般的,对象的内存分配都是在堆上进行,但现代技术也支持将对象拆成标量类型(标量类型即原子类型,表示单个值,可以是基本类型或String等),然后在栈上分配,在栈上分配的很少见,我们这里不考虑。

    Java内存分配和回收的机制概括的说,就是:分代分配,分代回收。对象将根据存活的时间被分为:年轻代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是方法区)。如下图

    内存分配过程

    年轻代(Young Generation):对象被创建时,内存的分配首先发生在年轻代(大对象可以直接被创建在年老代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉(IBM的研究表明,98%的对象都是很快消亡的),这个GC机制被称为Minor GC或叫Young GC。注意,Minor GC并不代表年轻代内存不足,它事实上只表示在Eden区上的GC。

    年轻代上的内存分配是这样的,年轻代可以分为3个区域:Eden区(伊甸园,亚当和夏娃偷吃禁果生娃娃的地方,用来表示内存首次分配的区域,再贴切不过)和两个存活区(Survivor 0 、Survivor 1)。内存分配过程为

    一次gc的内存变化过程

    绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;

    <1>最初一次,当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);

    <2>下次Eden区满了,再执行一次Minor GC,将消亡的对象清理掉,将存活的对象复制到Survivor1中,然后清空Eden区;

    <3>将Survivor0中消亡的对象清理掉,将其中可以晋级的对象晋级到Old区,将存活的对象也复制到Survivor1区,然后清空Survivor0区;

    <4>当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。

    从上面的过程可以看出,Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下高效,如果在老年代采用停止复制,则挺悲剧的。

    在Eden区,HotSpot虚拟机使用了两种技术来加快内存分配。分别是bump-the-pointer和TLAB(Thread-Local Allocation Buffers),这两种技术的做法分别是:由于Eden区是连续的,因此bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;而对于TLAB技术是对于多线程而言的,将Eden区分为若干段,每个线程使用独立的一段,避免相互影响。TLAB结合bump-the-pointer技术,将保证每个线程都使用Eden区的一段,并快速的分配内存。

    年老代(Old Generation):对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Young GC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫 Full GC。

    可以使用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。

    如果对象比较大(比如长字符串或大数组),Young空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。用-XX:PretenureSizeThreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。

    可能存在年老代对象引用新生代对象的情况,如果需要执行Young GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。解决的方法是,年老代中维护一个512 byte的块——”card table“,所有老年代对象引用新生代对象的记录都记录在这里。Young GC时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

    关于新生代和老生代一般占用内存的大小分配规则:

    堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。

    默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。

    默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。

    JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。

    因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。

    相关文章

      网友评论

          本文标题:Android内存管理及内存泄漏分析(一)

          本文链接:https://www.haomeiwen.com/subject/cwlivqtx.html