Android 优化内存空间

  • 没有内存泄漏,并不意味着内存就不需要优化了,在移动设备上,由于物理设备的存储空间有限,Android 系统对每个应用进程也都分配了有限的堆内存备的存储空间有限,Android 系统对每个应用进程也都分配了有限的堆内存,因此使用最小内存的对象或者资源可以减小内存开销,同时让 GC 能更高效地回收不再需要使用的对象,让应用堆内存保持充足的可用内存,使应用更稳定高效地运行。

对象引用

从 Java1.2 版本开始引入了三种对象引用方式:软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference)三个引用类,引用类的主要功能就是能够引用但仍可以被垃圾收集器回收的对象。在引入引用类之前,只能使用强引用(StrongReference),如果没有指定对象引用类型,默认是强引用,如下代码段就产生了一个强引用对象:Object A = new Object();Object B = A;

在不妨碍内存收集的情况下,软引用、弱引用和虚引用对象提供了三种方式来引用堆对象。每种引用对象都有不同的行为,而且它们与垃圾收集器之间的交互也有所不同。需要说明的是,内存中的一个对象可以被多个引用(可以是强引用、软引用、弱引用或虚引用)引用。

  • 强引用

强引用是使用最普遍的引用。如果一个对象具有强引用,垃圾回收器(GC)就绝不会回收它。当内存空间不足时,Java 虚拟机会抛出 OutOfMemoryError 错误,不会回收具有强引用的对象来解决内存不足的问题。因此,如果是强引用的对象,在应用的生命周期中如果不再需要使用,一定要记得释放或转成弱引用,以便让系统回收。

  • 软引用

软引用在保持引用对象的同时,保证在虚拟机报告内存不足的情况之前,清除所有的软引用。关键之处在于,垃圾收集器在运行时可能会(也可能不会)释放软引用对象。对象是否被释放取决于垃圾收集器的算法以及垃圾收集器运行时可用的内存数量。

如果一个对象只具有软引用,则内存空间足够,GC 时就不会回收它;如果内存空间不足,就会回收这些对象的内存,并且在没有回收该对象时,该对象可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用引用的对象被垃圾回收器回收,Java 虚拟机会把这个软引用加入与之关联的引用队列中。

  • 弱引用

弱引用类的一个典型用途就是规范化映射(canonicalized mapping)。另外,对于那些生存期相对较长,而且重新创建的开销也不高的对象来说,弱引用也比较有用。关键之处在于,垃圾收集器运行时如果碰到了弱可及对象,将释放 WeakReference 引用的对象。

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象, 不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

  • 虚引用

虚引用类只能用于跟踪即将对被引用对象进行的收集。同样,它还能用于执行pre-mortem 清除操作。虚引用必须与 ReferenceQueue 类联合使用。需要ReferenceQueue 是因为它能够充当通知机制。当垃圾回收器确定某个对象是虚可及对象时,PhantomReference对象就被放在它的 ReferenceQueue 上。将 Phantom Reference 对象放在 ReferenceQueue 上会有一个通知,表明虚引用对象引用的对象已经结束,可供收集了。这使用户能够刚好在对象占用的内存被回收之前采取行动。

虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,它就和没有任何引用一样,在任何时候都可能被垃圾收集器回收。

从以上我们知道不同引用类型,在 GC 时的策略是不同的,因此根据业务需求合理使用不同引用,以提高内存的使用效率。

减少不必要的内存开销

  • AutoBoxing

Java 语言中有一个 AutoBoxing(自动装箱)功能,我们知道 Java 提供了基本数据类型,如 boolean(8 bits)、int(32 bits)、float(32 bits)、long(64 bits)和复杂数据类型(通过一个类描述),如 Boolean、Integer、Float。为了能够让这些基础数据类型在大多数 Java 容器中运作,需要做一个 AutoBoxing 的操作,把这些基础类型转换成 Boolean、Integer、Float,如下图所示:
在这里插入图片描述
这些复杂数据类型提供了与基本数据类型相通的功能,但可以使用于泛型集合,在程序运行过程中,这种自动转换的过程就是自动装箱的过程,自动装箱的核心就是把基础数据类型换成对应的复杂类型,不需要开发者自己转换。

因此在编程时不需要关心这种转换,Java 会自动把整数类型换成复杂类型,但如果代码中出现以下情况:

Integer num = 0;
        for (int i = 0; i < 10000000; i++) {
            num += i;
        }

通过打印消耗时间,使用Integer花费时间是75ms , 使用int花费时间是9ms。

这就意味着要消耗更多的性能,每次循环虚拟机都必须创建一个新的整数对象,并把它加到其他整数对象前面,创建一个新的整数对象。在自动装箱转化时,都会产生一个新的对象,这样就会产生更多的内存和性能开销,因为这些对象比基础数据类型对象还要大。基础整数(int)对象只有 4 字节,而 Integer 对象有 16 字节,因此在内存和时间性能上都有额外的开销。

因此,在写程序时需要避免这类情况。特别是 HashMap 这类的容器,基本上只要使用这个设计基元的容器,进行增、删、改、查操作时,都会产生大量的自动装箱操作,为了避免这些 AutoBoxing 带来的效率问题,Android 特地提供了一些 Map 容器用来替代 HashMap,不仅减少了时间开销,还减少了内存占用。

对于不必要的内存开销等问题应该及早发现,不要让问题积少成多。一般可以通过TraceView 查看耗时,如果发现调用了大量的 integer.value,就说明发生了 AutoBoxing,需要立即优化代码。

内存复用

在 Android 系统中,有些数据是可以复用的,并且在开发过程中,系统也提供了可利用的接口或方法。比如以下三种复用的场景,通过内存复用可以有效减少应用的内存开销。

  • 有效利用系统自带的资源

Android 系统本身内置了大量的资源,比如一些通用的字符串、颜色定义、常用 Icon 图片,还有些动画和页面的样式以及简单布局,如果没有特别的要求,这些资源都可以在应用程序中直接引用。
直接使用系统资源不仅可以在一定程度上减少内存的开销,还可以减少应用程序的自身负重,减小 APK 的大小,并且复用性更好。

  • 视图复用

出现大量重复子组件,而子组件是大量重复的,可以使用 ViewHolder 实现 ConvertView复用,这基本上是所有容器控件的处理方式,如 ListView、GridView 等。

  • 对象池

可以在设计程序时显式地在程序中创建对象池,然后实现复用逻辑,对相同的类型数据使用同一块内存空间,也可以利用系统框架既有的具有复用特性的组件减少对象的重复创建,从而减少内存的分配与回收。

  • Bitmap 对象的复用

利用Bitmap中的inBitmap的高级特性,提高Android系统在Bitmap的分配与释放效率,不仅可以达到内存复用,还提高了读写速度。使用 inBitmap 属性可以告知 Bitmap 解码器尝试使用已经存在的内存区域,新解码的 bitmap 会尝试使用之前那张 bitmap 在 heap 中占据的 pixel data 内存区域,而不是向内存重新申请一块区域来存放 bitmap。

使用最优的数据类型

Android应用开发,大部分使用的是Java语言编程,在Java中有很多的数据结构和类型,但不一定都是最省内存的数据结构,需要根据实际的开发需求,选择最好的数据类型或类类型。在 Android 上也意识到这一类情况,因此 Android 针对移动开发提出了一系列的数据类容器结构优化。

  • HashMap 与 与 ArrayMap

HashMap 是 Android 应用开发过程中经常使用的容器之一,当然它是非常有用的,但它并不是最节约的容器,会占用大量内存。一个典型 HashMap 的工作原理如图
在这里插入图片描述

HashMap 是一个散列链表,向 HashMap 中 put 元素时,先根据 key的 HashCode 重新计算 hash 值,根据 hash 值得到这个元素在数组中的位置,如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。也就是说,向 HashMap 插入一个对象前,会给一个通向 Hash 阵列的索引,在索引的位置中,保存了这个 Key 对象的值。这意味着需要考虑的一个最大问题是冲突,当多个对象散列于阵列相同位置时,就会有散列冲突的问题。因此,HashMap 会配置一个大的数组来减少潜在的冲突,并且会有其他的逻辑防止链接算法和一些冲突的发生。

很明显,这个向稀疏阵列填入对象的大阵列,从内存节省的角度来看,是非常不理想的。因此,Android 为了解决 HashMap 的这个问题,提供一个替代容器:ArrayMap。

ArrayMap 提供了和 HashMap 一样的功能,但避免了过多的内存开销,方法是使用两个小数组,而不是一个大数组。其中一个数组记录对象 Key Hash 过后的顺序列表,另外一个数组按 Key 的顺序记录 Key-Value 值,根据 Key 数组的顺序,交织在一起,如图
在这里插入图片描述

在需要获取某个 Value 时,ArrayMap 会计算输入 Key 转换过后的 hash 值,然后使用二分查找法对 Hash 数组寻找到对应的 index,然后可以通过这个 index 在另外一个数组中直接访问需要的键值对。如果在第二个数组键值对中的 key 和前面输入的查询 key 不一致,就认
为发生了碰撞冲突。为了解决这个问题,ArrayMap 会以该 key 为中心点,分别上下展开,逐个对比查找,直到找到匹配的值。因此会带来一个问题,就是随着 ArrayMap 中对象数量的增加,需要访问单独对象的时间也会变长。

从上面的描述我们知道,ArrayMap 在内存上是连续不间断的,那么在删除和插入时要如何处理呢?删除一个元素时,把将要删除的元素放到最后,其他元素向前移,或者是把所有元素的重置大小和一个副本,来删除想要的值。插入时需要重新配置数组,添加完成后移动所有元素来保证 ArrayMap 选择的顺序。删除和插入的示意图如图
在这里插入图片描述
总地来说,在 ArrayMap 中执行插入或者删除操作时,从性能角度上看,比 HashMap 还要更差一些,但如果只涉及很小的对象数,比如 1000 以下,就不需要担心这个问题了。因为小的连续阵列,当值特别小时,相比 HashMap,ArrayMap 能节省更多的内存,对于空的映射,没有什么配置会占用空间。HashMap 和 ArrayMap 内存占用对比如图
在这里插入图片描述
与 HashMap 相比,ArrayMap 在循环遍历(顺序遍历)时也更加简单高效,从如下代码中可以看出 HashMap 和 ArrayMap 之间的差异。

 Map<String, String> hashMap = new HashMap<>();
        for (Iterator it = hashMap.entrySet().iterator(); it.hasNext(); ) {
            Object o = it.next();
        }

        Map<String ,String> arrayMap = new ArrayMap();
        for (int i=0;i<arrayMap.size();i++){
            Object key = arrayMap.keyAt(i);
        }

可以总结出在以下两个场景考虑优先使用 Array-Map:

  • 当对象的数目非常小(1000 以内),但是访问特别多,或者删除和插入频率不高时。
  • 当有映射容器,有映射发生,并且所有映射的容器也是 ArrayMap 时。

枚举类型

JDK 1.5 版本开始支持枚举类型,枚举类型采用关键字 enum 来定义,所有的枚举类型都是继承自 enum 类型,使用枚举来实现。如果使用枚举类型(Enums)来定义常量,会使代码更易读并且更安全,但在性能上却比普通常量定义差很多。我们知道 Android 系统在应
用启动后,会给应用单独分配一块内存。应用的 dex code、Heap 以及运行时的内存分配都会在这块内存中。而使用枚举类型的 dex size 是普通常量定义的 dex size 的 13 倍以上(只是dex code 增加),同时,运行时的内存分配,一个 enum 值的声明会消耗至少 20 bytes,这还没有算上其中的对象数组需要保持对 enum 值的引用。每个枚举项都会被声明成一个静态变量,并被赋值。因此,当应用程序中的代码或包含的 Lib 中大量使用 enum 时,对本身内存小的手机会带来不可忽视的影响。下面看下如何普通定义一个常量,并且达到类型安全的效果。

private final int UI_PERF_LEVEL_0 = 0;
    private final int UI_PERF_LEVEL_1 = 1;

    public int getLevel(int level) {

        switch (level) {
            case UI_PERF_LEVEL_0:
                return 0;
            case UI_PERF_LEVEL_1:
                return 1;
            default:
                throw new IllegalArgumentException("UnKnow");
        }

    }

从以上代码可以看出,对于 getLevel(int level)中的参数 level 是不安全的,如果传入的值不是 0 或 1,就会抛出异常,也就是说对输入的参数没有约束,会导致上层的业务带来一个处理异常的逻辑,增加了代码的不安全因素和维护成本。

而枚举可以避免此问题,下面通过枚举类型来实现相同的代码逻辑。

    public enum PER_LEVEL {
        UI_PERF_LEVEL_0,
        UI_PERF_LEVEL_1
    }
    
   public int getLevel(PER_LEVEL level) {

        switch (level) {
            case UI_PERF_LEVEL_0:
                return 0;
            case UI_PERF_LEVEL_1:
                return 1;
            default:
                throw new IllegalArgumentException("UnK");
        }

    }

可以看出,输入参数 level 已经约束在枚举类型 PER_LEVEL 中,这样就不用再做容错处理。

枚举的最大优点是类型安全,但在 Android 平台上,枚举的内存开销是直接定义常量的三倍以上,所以 Android 的官方文档也提醒了开发者尽量避免使用枚举类型,同时提供注解的方式检查类型安全,目前提供了 int 型和 String 型两种注解方式:IntDef 和 StringDef,用来提供编译期的类型检查,以下代码通过 IntDef 来实现。

private static final int UI_PERF_LEVEL_0 = 0;
    private static final int UI_PERF_LEVEL_1 = 1;


    @Retention(RetentionPolicy.SOURCE)
    @IntDef({UI_PERF_LEVEL_0, UI_PERF_LEVEL_1})
    public @interface PER_LEVEL {

    }

    public static int getLevel(@PER_LEVEL int level) {

        switch (level) {
            case UI_PERF_LEVEL_0:
                return 0;
            case UI_PERF_LEVEL_1:
                return 1;
            default:
                throw new IllegalArgumentException("UnK");
        }

    }

这里可以看到,@PER_LEVEL 注解放到 getLevel 方法中,对参数的数据类型进行限制。如果在使用 getLevel 时直接赋值一个 Int 参数,IDE 会报错,@PER_LEVEL 也可以放到返回值等其他整型属性的地方对参数进行限制,通过这个方法既保证了类型安全,又不会给内存带来额外的开销。

LruCache

LruCache 在 android.util 包下(android-support-v4 的包中提供),可以翻译为最近最少使用缓存,它用强引用保存需要缓存的对象,它内部维护一个队列(实际上是 LinkedHashMap内部的双向链表,不支持线程安全,LruCache 对它进行封装,添加了线程安全操作),当其中的一个值被访问时,它被放到队列的尾部,当缓存将满时,队列头部的值(也就是最近最少被访问的)被丢弃,之后可以被垃圾回收。

LruCache 比较重要的几个方法如下。

  • public final V get(K key)
    返回 cache 中 key 对应的值,调用这个方法后,被访问的值会移动到队列的尾部。
  • public final V put(K key,V value)
    根据 key 存放 value,存放的 value 会移动到队列的尾部。
  • protected int sizeOf(K key,V value)
    返回每个缓存对象的大小,用来判断缓存是否快要满了,这个方法必须重写。
  • protected void entryRemoved(boolean evicted,K key,V oldValue,V newValue)
    当一个缓存对象被丢弃时调用的方法,这是个空方法,可以重写,不是必需的,第一个参数为 true:当缓存对象是为了腾出空间而被清理时(trimToSize 时)。第一个参数为 false:缓存对象的 entry 被 remove 移除或者被 put 覆盖时。

Android 的官网也一直推荐使用 LruCache 作为图片内存缓存,里面保存了一定数量的对象强引用。在使用时需要注意 LruCache 的容量,这个容量既不能太大,太大会造成其他可用内存变小,容易导致 OOM,但又不能太小,否则起不到缓存的作用。Google 官方文档给出以下意见作为参考:

  • 分配 LruCache 大小时考虑应用剩余内存有多大。
  • 一次屏幕显示多少张图片,有多少张图片是缓存起来准备显示的。
  • 考虑设备的分辨率和尺寸,缓存相同的图片数,手机的 dpi 越大,需要的内存也越大。
  • 图片分辨率和像素质量决定了占用内存的大小。
  • 图片访问的频繁程度是多少,如果存在多个不同要求的图片类型,可以考虑用多个LruCache 来做缓存,按照访问的频率分配到不同的 LruCache 中。

总之分配的 LruCache 既不能太大,又不能太小,具体在应用中还要综合考虑。

发布了119 篇原创文章 · 获赞 28 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/ldxlz224/article/details/100655287