性能优化的方向
性能优化不是一蹴而就的,应该在日常的各种小细节中一点点优化,积少成多,app也就更流畅了。
而流畅性优化中有个很重要的是数据结构的优化。
android中常见的列表容器:ArrayList、linkedList、HashMap、sparseMap、arrayMap
ArrayList
ArrayList是数组存储的顺序表。
一般不知道用啥的时候,可以用ArrayList。
new ArrayList
new ArrayList时不传数组大小,此时List为一个空的数组。如果传的话,那就new Object[i]大小的数组
add
add有两种情况,一个是直接add,一个是指定index add
- 直接add,如果刚new,并未指定大小(数组为空)。那就扩容成默认大小(10)。
1.2 直接add,数组后面有位置(数据3个 但是elementData[] 为10),直接添加。如果add时数组满了。那就扩容,扩容倍数为int newCapacity = oldCapacity + (oldCapacity >> 1);
new = old + old>>1 约1.5倍
- add时添加在中间,先判断校验数组越界 + 扩容问题。然后
arraycopy(arraycopy就是遍历然后把index后面的全向后移一位)
remove
remove跟add一样,也是System.arraycopy 将后面的全前移一位
总结
ArrayList 的 add、remove需要频繁的把整个列表copy,很影响性能。
也就是说 ArrayList在查找的时候很快(因为使用数组存的,数组是一块连续的内存,通过数组头+i * 数据大小能轻松的算出数据存储的具体地址,所以get很快),add、remove在尾部操作快,中间操作很慢。
linkedList
为了解决ArrayList添加删除操作慢的问题,有了linkedList。
linkedList是双向链表循环的结构。
每个节点都有一个前驱节点,和后驱节点,指向上一个节点,下一个节点。
add
- 尾巴添加节点
- 中间添加节点
final Node<E> newNode = new Node<>(pred, e, succ);说明了node节点new 的时候前面指向原node前面,后面指向原node
remove
跟add反向,断了这个节点的前后关联线,添上新的关联线,GC的时候,这个node就是不可达对象,就会被回收。
总结
linkedList的添加删除不需要移动列表,所以快。
但是linkedList查询、修改的时候,不像ArrayList使用数组存的,没法快速找到存储地址,得挨个遍历,所以速度慢。
HashMap
linkedList、ArrayList都有自己的缺陷,那么想要同时各种好,就有了HashMap。
HashMap分两个版本。
java1.7之前 android 24 之前:数组+ 链表
1.8 之后: 数组+ 链表 + 红黑树
数组+ 链表
transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE
<K,V>:
table数组里存了HashMapEntry<K,V>这样的链表。
造型差不多这样
HashMapEntry<K,V>[] table
纵向的是table数组,数组里存了HashMapEntry<K, V>这样的key,value结构。
table
table数组的长度默认是
put
当put存进一个值的时候
key可以是任意的对象,但是不管什么对象都有他的HashCode,而一个对象的HashCode近似代表了他的存储地址,是一个唯一的int值。
HashMap就是将name的HashCode % table.length = HashCode%length取余数 = hash & (tab.length - 1)
也就是如name的hashCode=344413 table[].length=16,hashCode%length=344413%16=13。所以这个map值存在table[13]的位置处
这个添加的过程叫 装箱
这时如果新添的值,hashCode%length得出来的值之前已经有数据存在这里面了。
此时会将新节点放到原来的链表的头部,然后插入。这叫头插法或者头部插入法。
而这种插入的时候原来位置处已经有链表了,这叫 hash 碰撞或者说Hash冲突
补充知识点:
- 源码处求余数算法 hashCode % length,用的是
int index = hash & (tab.length - 1);
因为为了效率,其实10进制的+-*/%...运算在CPU底层都是转成2进制运算的,所以直接用二进制效率更高。
- put的时候怎么保证key,value 一对一。
put的时候会先根据key的hashCode去table的那个地方,然后for遍历后面的链表,取一下这个hashcode有没有存值,有的话直接修改value。没有在新增
解决hash碰撞
比如这个hashmap,不断地往上面添节点,当table[]越来越满的时候,hash碰撞的几率也越来越高,为了解决这个问题就得将table扩容。
那么什么时候扩容呢?怎么样才算满呢?
加载因子
数学家经过复杂的计算测试等,算出当空间占用60%~75%的时候,在往上添,这时的hash碰撞很严重了,得扩容了。
所以定了一个0.75为扩容前的最好状态,成为加载因子。
阈值
阈值 = 加载因子 * table.length。
也就是 阈值 = 0.75 * 16 = 12。当length为16时,里面添了12个数据,再往里面添就得扩容了。
扩容
扩容是为了减少hash碰撞。
扩容的方式:因为length的值变了,所以hashCode % length也就变了,所以每次扩容都将重新将每个数据都一个一个加到新的空的hashmap里。
所以扩容非常耗费性能,这就要求我们在new HashMap时,尽量的估算HashMap的大小,然后创建这个大小的HashMap。
new HashMap(0.75 * size + 1 ) 比如预估列表大小为100 那就new HashMap(0.75 * 100 + 1 )
new HashMap
new HashMap时,尽量的估算HashMap的大小,然后创建这个大小的HashMap。
计算方式:new HashMap(0.75 * size + 1 )
- 直接new HashMap
为了方式浪费内存,如果直接new HashMap,此时的HashMap是空的,只有在第一次put数据的时候才会创建table[],其实别的List也是这么干的。(默认大小为16)
- new HashMap(int)创建时指定了大小
如果创建时指定了大小,比如 new HashMap(10),创建出来的HashMap并不是10,而是离10最近的2的n次幂,也就是16
HashMap的大小为2的n次幂,这样做是因为效率,二进制算的时候效率高
HashMap的大小为2的n次幂,这样做是因为效率,二进制算的时候效率高
hash & (tab.length - 1)
例子:
6&10 与 6&16
110 & 1001 与 110 & 1111
(& - 两个1为1,不然为0)
(XXXX & 1001)有效数字为 两个1,所以XXXX中间的两位不管是什么结果都是00,只有前后两位可选的,也就是只有1000,1001,0000,0001四种情况(8,9,0,1),情况数量少,hash碰撞的几率更高了。
get
get取的时候先根据tab[hash & (tab.length - 1)]取到tab[]哪个index链表后面。然后根据hashCode去for循环遍历取。
HashMapEntry
HashMapEntry这个HashMapEntry里不仅存了key,value。其实还存了hashCode值和next下一个节点(链表)
resize
扩容resize的实现
1 如果table == null, 则为HashMap的初始化, 返回空table;
2 如果table不为空,扩容2倍, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);
3 遍历oldTable:
3.2 首节点为空, 本次循环结束;
3.1 无后续节点, 重新计算hash位, 本次循环结束;
3.2 当前是红黑树, 走红黑树的重定位;
3.3 当前是链表, JAVA7时还需要重新计算hash位, 但是JAVA8做了优化, 通过(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;
举例:16扩容到32
因为扩容刚好两倍,也就是二进制就最左边多了一个1。
&运算后面部分都一样,只需要关注首位即可。
所以首位为0时,不移动。首位为1时,将oldCap移动到oldCap + i位置
这也是HashMap的长度必须保证是2的倍数的原因。
正因为这种环环相扣的设计, HashMap.loadFactor的选值是3/4就能理解了, table.length * 3/4可以被优化为(table.length >> 2) << 2) - (table.length >> 2) == table.length - (table.lenght >> 2), JAVA的位运算比乘除的效率更高, 所以取3/4在保证hash冲突小的情况下兼顾了效率;
总结
hashMap的速度很快,增删改查都很快,不像ArrayList、linkedList的缺陷。
但是hashMap扩容时非常不效率,而且一扩容就是2的倍数。比如64的大小,多一个扩容就得扩到128又浪费又慢,所以需要在new hashMap的时候,要对数据的量有个预估,然后创建。
而且hashMap的加载因子为0.75,也就是空间利用率最高也就75%
也就是说hashMap是用内存空间换速度
SparseArray
android为了解决HashMap的缺点,自己整了一个SparseArray。
SparseArray = HashMap + 二分查找的思想
SparseArray的构造
两个数组一一对应,分别存key,Value
put
key只能是int,而且key的数组是有序的。
put整体代码
二分查找
后移操作
remove
remove的时候不是删了中间节点,然后全部往前移一个。而是先二分查找定位到位置,remove删了的时候,将他标记为已删除状态DELETED,不移动后面节点。
这样下次添加的时候,直接添就行,不必全部往后移。这样30~500中间的key下次添加直接添就行,所以SparseArray的效率是越用越快的。
new SparseArray
默认大小10,或者指定
总结
SparseArray相较于HashMap性能整体都好一点。
SparseArray的特点还有越用越快。
缺点:key只能是int型
arrayMap
arrayMap = HashMap + SparseArray
结构
ArrayMap中的查找分为如下两步
根据key的hashcode找到在mHashes数组中的索引值
根据上一步的索引值去查找key所对应的value值
其中占据时间复杂度最多的属于第一步:确定key的hashCode在mHahses中的索引值。
而这一步对mHashes查找使用的是二分查找,即Binary Search。所以ArrayMap的查询时间复杂度为 O(log n)
面试题
- HashMap的底层原理是什么?线程安全么?
HashMap底层是用的哈希表,哈希表是由数组+链表组成...见上
HashTable是线程安全的,HashMap是线程非安全的.在多线程的情况下, HashMap会出现死循环的情况。
- HashMap和HashTable的区别
- hashmap concurrenthashmap原理
ConcurrentHashMap提供了简单、安全且代价较小的HashMap同步。
HashTable的synchronized加锁是针对整张Hash表的,即每次操作都锁住整张表;而ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了Lock Stripping,即锁分离、分段锁或段锁技术。
分段锁使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。由于引起了并发概念,其效率相对全部加锁就有了明显改善。
- arraylist和hashmap的区别,为什么取数快?
- 扩容resize()是如何实现的....