如何应对Android面试官 -> 常用数据结构如何进行优化

前言


本章我们开始讲解性能优化相关的话题,首先我们来看下数据结构如何优化:

image.png

性能优化


性能优化的本质:线上 APM 的性能监控,而性能监控通常是以下技术点

ByteCode、Hook(PLT Hook)、JS注入(采集 Web 性能)、Gradle、ASM、javapoet

Java 层需要实现的性能监控能力

  • CPU 指标
  • 内存指标
  • FPS 指标
  • ANR
  • 卡顿
  • GC/OOM
  • 网络(http hook)
  • 功耗
  • 日志回捞

APM 框架的能力

  • 配置(注解 + json)
  • 数据链的保存
  • CPU、GPU、GC、电量
  • ANR FPS
  • Crash

数据结构


常用数据结构性能优化

ArrayList

内部是一个数组,又叫顺序表;

add

性能分析我们主要从 add、get、remove 等操作数据的接口角度来分析;我们来看下 add 方法

private void add(E e, Object[] elementData, int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1;
}

我们来看下 grow 方法

private Object[] grow(int minCapacity) {
    return elementData = Arrays.copyOf(elementData, newCapacity(minCapacity));
}

使用了 Arrays.copyOf 方法,当前要添加数据的位置如果有值,就将当前位置开始的所有数据都向后移动一位,将要插入的数据放到当前位置;

public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
    @SuppressWarnings("unchecked")
    T[] copy = ((Object)newType == (Object)Object[].class)
        ? (T[]) new Object[newLength]
        : (T[]) Array.newInstance(newType.getComponentType(), newLength);
    System.arraycopy(original, 0, copy, 0,
                     Math.min(original.length, newLength));
    return copy;
}

remove

同样的 remove 方法也是使用了 copy 的操作来移动数据;

private void fastRemove(Object[] es, int i) {
    modCount++;
    final int newSize;
    if ((newSize = size - 1) > i)
        System.arraycopy(es, i + 1, es, i, newSize - i);
    es[size = newSize] = null;
}

所以,不管是添加还是删除,实际上发生的都是元素位移,那么就比较耗费性能;

get

而 ArrayList 中效率较高的读取、设置数据是 get 和 set,

public E get(int index) {
    Objects.checkIndex(index, size);
    return elementData(index);
}

为什么 get 的效率高呢?

因为数组内存是连续的,数组存储数据是通过 数组的地址 + i * 「存入数据的字节」例如:elementData 对应的地址是:0x123ff,数组中存放的是 Object,那么第 i 个对应的就是 0x123ff + i * 4,然后就可以快速定位到这个 i 对应的地址在哪里;

所以 ArrayList 的查找快;但是,我们在 Android 开发中,并不能一股脑的上来就选择 ArrayList,因为它的添加和删除还是比较耗费性能的;

LinkedList

那么,针对删除和添加比较耗费性能的情况,我们应该如何进行优化呢? LinkedList 来了;

LinkedList 它是一个双向链表形式的数据结构,每一个元素都是 Node 节点,

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

根据源码可以知道,每一个节点都有指向下一个节点的元素,以及指向上一个节点的元素;

add

我们来看下 LinkedList 中是如何 add 的;

/**
 * Links e as last element.
 */
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

可以看到,add 元素的时候,直接一个赋值搞定的;add 方法是移动指针,将要插入的节点的上一个节点的下一个指针指向要插入的节点,将要插入的节点的下一个节点的上一个指针指向要插入的节点,并将要插入的节点的上一个指针指向上一个,节点的下一个指针指向下一个。这样就插入了一个新的数据,而不需要移动元素;

remove

/**
 * Unlinks non-null first node f.
 */
private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    final E element = f.item;
    final Node<E> next = f.next;
    f.item = null;
    f.next = null; // help GC
    first = next;
    if (next == null)
        last = null;
    else
        next.prev = null;
    size--;
    modCount++;
    return element;
}

可以看到,删除的时候,直接移除对应的指针即可;

所以插入删除效率高,因为是直接移动的指针;但是 LinkedList 在查找方面效率就会变得低下;

get

/**
 * Returns the (non-null) Node at the specified element index.
 */
Node<E> node(int index) {
    // assert isElementIndex(index);

    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

可以看到,这里使用了轮询的操作,因为每个节点的创建所在 class 可能不用,甚至都没在一个内存地址,也就无法像 ArrayList 那样在一个连续的空间通过计算找到,只能通过轮询的方式;

所以,也就造成了 LinkedList 的查找效率比较低下;

所以,如何选择数据结构,需要我们根据实际的业务场景来选择,需要高效查找的时候选择 ArrayList,需要高效添加删除的时候选择 LinkedList;

HashMap

那么,问题来了,有没有一种数据结构,上面两种的优点都包含了呢?既能查找快,也能添加快呢?答案是有的;它就是 HashMap

为什么它会快呢?因为 HashMap 中既有数据,又有链表;

HashMap 有两种形式的数据结构,分别是 1.7 之前和 1.7 之后;

1.7 之前 也就是 Android24 之前 用的都是 1.7 以 数组+链表 的形式;(一个数组,数组中的每一个节点都是一个链表)

在这里插入图片描述

1.7 之后,以 数组 + 链表 + 红黑树的形式;

put

我们来看下 HashMap 是如何 put 数据的;
在这里插入图片描述
如何保证一个 key 对应一个 value,通过 key 拿到 hash 值,通过 hash 值拿到 index,拿到 index 下标在数组中对应的链表,循环这个链表,找到对应的 key,找到了就替换;

key
int hash = hash(key.hashCode());
// 求模运算
int index = (n - 1) & hash

key 是 Object,Object 转 int 完成了装箱操作;

通过 indexFor 获取 index 对应的数组中的下标 i;这样我们就完成了 put 方法需要的 key 操作; 接下来是是 Value,我们来看下 Value 是怎么操作的;

Value

获取 key 的 hashcode 之后,添加 value,调用 addEntry 方法;
在这里插入图片描述
添加或者创建;这里一共执行了三个逻辑

  • 根据下标 bucketIndex 获取的值赋值给 HashMapEntry<K,V> e;
  • 然后 new HashMapEntry(hash, key, value, e); 将新加入的节点的 next 指向 e;
  • 再把新的节点赋值给 table[bucketIndex]
hash 碰撞

我们在使用求模运算获取下标 index 的过程,其实是一个多对一的过程,这个过程带来的问题就是 hashCode1 和 hashCode2 对应了同一个 index,这就产生了 hash 碰撞;

那么怎么解决 hash 碰撞呢?HashMap 提供了链表法来解决 hash 碰撞;

链表法

那么 HashMap 是如何保证一个 Key 对应一个 Value 的呢?
在这里插入图片描述

不关心链表内容空与否,都把这个当前节点作为新加入的节点的 next 节点,这样无论怎么添加,当前节点都是新加入的节点的 next 节点;这就是所谓的链表法,那么链表法到底是如何解决 hash 碰撞的冲突的呢?
在这里插入图片描述

数据 put 的过程中,key 为 KING 和 key 为 BLAKE 的 key 通过 取模 运算之后产生的 indexe 都是 4,那么这个时候就发生了 hash 碰撞,解决方案是,把 BLAKE 放到 KING 的下一个节点上;

get

在这里插入图片描述
在这里插入图片描述

在 getEntry 方法中,通过 key 获取 hash 值,然后获取对应的 index,然后轮询这个 table 获取对应的元素;

这就是通过『链表法』来解决 hash 碰撞的问题;

我们接着拐回去看 put 方法,看下是如何 put 同一个 key 的时候,value 是如何进行替换的;
在这里插入图片描述
在这个 for 循环中,put 的时候,要看下这个节点有没有链表,有的话就轮询这个链表,看下是否有和这个 key 一致的节点,key 相同,则对值进行覆盖;

通过 key 拿到 hash 值,通过 hash 值拿到 index,拿到 index 下标在数组中对应的链表,循环这个链表,找到对应的 key,找到了就替换;

扩容

put 的时候,随之而来的问题就是『扩容』的问题;什么是扩容?扩容的评价标准是怎样的?

加载因子

DEFAULT_LOAD_FACTOR = 0.75f,这个加载因子为什么是 0.75?

这个是经过大量的测算得来的;

阈值

0.75f * 16 = 12;超过这个阈值,就进行扩容,也就是说,HashMap 不会在到达 16 的时候才进行扩容,而是提前就进行了扩容;

默认的 HashMap 有多大?

这里说的其实是 HashMap 中的 table 大小,默认是 16,且必须是 2 的多少次幂;
在这里插入图片描述
DEFAULT_INITIAL_CAPACITY = 1 << 4

扩容的意义

避免 hash 冲突;假设 table 长度是 16,Hash1 = 17,Hash2 = 1,取模之后的 index 都是 1,如果扩容成 32,那么 Hash1 = 17 取模之后是 17,Hash2 取模之后是 2,就降低了 hash 冲突的可能;

hashmap 在哪种情况下效率最低?

所有 hash 全部碰撞,变成一个单链表的时候效率最低;

如何扩容的?

在这里插入图片描述

每次扩容 2 的 N 次倍,数组长度就会改变,hash 运算的结果就会跟着改变;
在这里插入图片描述

每次扩容之后,因为 table 表的长度改变了,依据 length 进行的 hash 运算就会全部失效,就需要将所有的节点都重新 hash 运算一下,获取新的 index;这个 transfer 就是 rehash 的过程;

所以 hashMap 耗性能的地方就在『扩容』,我们要尽可能的避免扩容操作;

如何尽可能的避免扩容?

new HashMap() 的时候计算下阈值,假设是 100 个节点,那么就是 100 / 0.75 + 1,HashMap 会把这个值再次转化为距离这个值最近的 2 的 N 次幂的一个数;
在这里插入图片描述
所以说:HashMap 是一个拿空间换时间的数据结构,当如果只需要扩容一个节点的时候,HashMap 也会扩容至 2 的N 次幂,导致一半的空间被浪费掉了;

而在 Android 中,空间对于手机来说还是比较宝贵的,那么在 Android 上如何应对这种 case 呢?

SparseArray

这个是 Android 量身定制的,是为了避免空间浪费而产生的数据结构;

在这里插入图片描述
采用双数组形式,key 为 int 型数组,value 为 Object 型数组;
在这里插入图片描述
key 的下标和 value 的下标是一样的,这样保证 key-value 能一一对应上;

put

在这里插入图片描述

可以看到 key 的 index 查找是通过 二分查找 的算法来查找的;
在这里插入图片描述

以及 key 和 value 的插入都是采用 System.arraycopy 来完成的;
在这里插入图片描述
这样一设计,不仅能解决 HashMap 带来的问题,而且还能越用越快;
在这里插入图片描述
越用越快的原因在这里,将需要移除的节点标记为 DELETE,并需要 arraycopy 进行移动数组,那么下一次有新的数据添加进来时,只需要将这个 DELETE 替换为新的数据即可,也就是说 put 的时候也就不需要进行 arraycopy 了;

SparseArray 的缺点就是:它的 key 只能是 int 类型;

那么,为了优化这种 case,应该怎么办呢? ArrayMap 来了;

ArrayMap

ArrayMap 是 HashMap + SparseArray 的思想结合体;我们主要看下 put 的时候,key 是怎么转换的;

public V put(K key, V value) {
    final int osize = mSize;
    final int hash;
    int index;
    if (key == null) {
        hash = 0;
        index = indexOfNull();
    } else {
        hash = key.hashCode();
        index = indexOf(key, hash);
    }
    if (index >= 0) {
        index = (index<<1) + 1;
        final V old = (V)mArray[index];
        mArray[index] = value;
        return old;
    }

    index = ~index;
    if (osize >= mHashes.length) {
        final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
                : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);

        if (DEBUG) System.out.println(TAG + " put: grow from " + mHashes.length + " to " + n);

        final int[] ohashes = mHashes;
        final Object[] oarray = mArray;
        allocArrays(n);

        if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
            throw new ConcurrentModificationException();
        }

        if (mHashes.length > 0) {
            if (DEBUG) System.out.println(TAG + " put: copy 0-" + osize + " to 0");
            System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
            System.arraycopy(oarray, 0, mArray, 0, oarray.length);
        }

        freeArrays(ohashes, oarray, osize);
    }

    if (index < osize) {
        if (DEBUG) System.out.println(TAG + " put: move " + index + "-" + (osize-index)
                + " to " + (index+1));
        System.arraycopy(mHashes, index, mHashes, index + 1, osize - index);
        System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
    }

    if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
        if (osize != mSize || index >= mHashes.length) {
            throw new ConcurrentModificationException();
        }
    }

    mHashes[index] = hash;
    mArray[index<<1] = key;
    mArray[(index<<1)+1] = value;
    mSize++;
    return null;
}
int indexOf(Object key, int hash) {
    final int N = mSize;

    // Important fast case: if nothing is in here, nothing to look for.
    if (N == 0) {
        return ~0;
    }

    int index = binarySearchHashes(mHashes, N, hash);

    // If the hash code wasn't found, then we have no entry for this key.
    if (index < 0) {
        return index;
    }

    // If the key at the returned index matches, that's what we want.
    if (key.equals(mArray[index<<1])) {
        return index;
    }

    // Search for a matching key after the index.
    int end;
    for (end = index + 1; end < N && mHashes[end] == hash; end++) {
        if (key.equals(mArray[end << 1])) return end;
    }

    // Search for a matching key before the index.
    for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
        if (key.equals(mArray[i << 1])) return i;
    }

    // Key not found -- return negative value indicating where a
    // new entry for this key should go.  We use the end of the
    // hash chain to reduce the number of array entries that will
    // need to be copied when inserting.
    return ~end;
}

通过源码中

hash = key.hashCode();
index = indexOf(key, hash);

也是通过 二分查找 + 追加 的方式解决 hash 冲突的问题;

好了,常用数据结构的分析优化就到这里吧~

下一章预告


内存优化

欢迎三连


来都来了,点个关注点个赞吧,你的支持是我最大的动力~

  • 27
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值