前言
本章我们开始讲解性能优化相关的话题,首先我们来看下数据结构如何优化:
性能优化
性能优化的本质:线上 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 冲突的问题;
好了,常用数据结构的分析优化就到这里吧~
下一章预告
内存优化
欢迎三连
来都来了,点个关注点个赞吧,你的支持是我最大的动力~