前言
复习、复习、复习
昨天被一个大佬问到一些问题,有些问题确实没有思考过,集合类相关的源码大多看过,有些看过不止两遍,都针对画过图,but but ..... 例如常见的HashMap,往深里问思想,如果没看过相关解释,可能就想不出来。程序员应该不止于代码,思维也很重要。
HashMap的是怎么扩容的,它的Hash值是怎么计算的?index位置又是如何计算的?为什么要这么算?为什么扩容是 2 倍 而不是 3 倍、4 倍呢?重写的hashCode 有什么作用?
LinkedHashMap 数据结构是 什么样的? LruCache 内部存储结构又如何?是怎样存的?
接下来会先针对以上问题进行分析解答,然后再分析一些常用的集合类(分析时候只会贴一些关键代码)例如 SparseArray 、ArrayMap等,以后被问到性能优化也可以谈一谈这些。
环境 : Android API 29
目录
一、HashMap
1、初始变量
loadFactor = 0.75 负载因子
threshold = 16 * 0.75 = 12 在存储完元素后会根据该值判断是否需要扩容
table size = 16
TREEIFY_THRESHOLD = 8 链表转换成红黑树时机
UNTREEIFY_THRESHOLD = 6 红黑树转换成链表时机
MIN_TREEIFY_CAPACITY = 64 树行化时机
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
resize() 方法
当通过new HashMap() 创建时,put元素时会调用resize方法,进行一个元素大小等的设置
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
为什么负载因子是 0.75?
负载因子为0.75时,空间利用率比较高,而且避免了比较多的hash冲突,表现为链表或者是红黑树的高度比较低,设置其它合适的值也可以,但是0.75更遵循泊松分布。数组越大,hash冲突不就越少么,但是数组也不能太大了。
2、存放数据
put(K key, V value)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
首先看一下 其中的 hash(key)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里首先计算key的hashCode 为 h,然后 与 h 无符号右移 16 位后 异或 得到最后的 hash值。
为什么要使用 hashCode 去操作获取 hash 值?
如果直接用key去操作获取肯定不行,因为key可能会是字符串,字符串不能做位运算,对象类型也不能
为什么要无符号右移 16 位呢?
右移 16 位取出来的 是 h 的高 16 位,也就是说 这里是进行了 h 的高 16 位 和 h 的 低 16 位作异或处理,可以将 h 的 高 位和低位信特征混合保存起来(源码内针对这个方法有解释)。计算元素存放位置是通过 (n - 1) & hash 来计算的, 这里 & 运算相当于 取余运算 hash % n (为什么呢?当n是 2 的幂次方是, n % m = m & ( n - 1) ),假如table大小为16,不做右移运算,那么 可能多个 key.hashCode 都是一样的,例如最后四位都是 0000,都与 1111( n -1 = 15 二进制) 做 & 运算,得到的元素存放位置都是 0 ,如果做了右移异或操作,保留了高位和低位的特征能更好的体现key.hashCode特征,将能降低hash碰撞冲突。
为什么使用异或操作?
如果使用 & ,计算出来的结果会偏向 1 ,采用 | ,计算出来的结果会偏向 0 ,采用 ^ ,偏向 0 和 偏向 1 概率一样。
它和HashTable的区别在于它当key为 null 时,hash方法会返回 0 ,而 HashTable 直接通过 key.hashCode() ,所以HashTable key不能为空。
接下来看 putVal 方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 注释 1 如果 对应存储位置没有存Node数据
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; //注释 2 如果 找到了对应的key则更新数据
else if (p instanceof TreeNode)//注释 2 如果 是树节点 进行红黑数操作,后面分析
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//注释 3 寻找最后一个节点 给next节点创建Node
p.next = newNode(hash, key, value, null);
//注释 4 如果 bitCount >= 8 - 1 ,转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//交换 下一轮for 操作
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
寻找table存储位置是怎么做到的?
通过 ( n - 1) & hash 值得到位置,见注释 1 处
这么做和 hash % n 有什么区别 ,为什么要取模?
当n是 2 的幂次方时,两者结果一样,& 操作 比 取余 运算速度快,因为取余操作,底层也要转换成位运算。因为key.hashCode取出来的hash值不是数组的索引下标,为了能随机的计算出索引下标,常见的hash算法就是取模。
如何寻找到已有的key上的元素?
通过计算的hash值=该元素的hash值 并且key相同或者是两者equals,其实可以理解为先判断两者是否hashCode一样,然后判断是否equals。如果不同对象所含的元素一样,认为这是同一个key,就必须得重写hashCode和equals方法。(见上述代码注释 2 )
hash冲突怎么处理?
见注释 3 处
采用链表形式,获取到存储位置的链表,在末尾追加一个新节点,如果发现table扩容到大于64并且链表长度大于 8 了,就会自动转成红黑树,往红黑树里添加一个新节点
为什么要等到table大小大于64并且链表长度大于 8 转成 红黑树?转成其它数据结构或者不转可不可以?
因为红黑树占用空间比链表大,转化也比较耗时,数组容量小的情况可以优先选择充分利用数组,扩容来处理冲突。
针对为什么是8,源码注释有说明,当hashCode离散性很好的时候,树形化用到的概率很小,因为数据均匀分布在每个链表中,几乎不会有链表长度超出阈值,但是在随机 hashCode情况下,离散性差,JDK又不能阻止这种离散性比较差的算法,导致不均匀的分布。不过在理想情况下,随机hashCode 下的链表节点遵循泊松分布,源码中给出了链表长度达到 8 时的概率时 0.00000006,几乎是不可能事件,所以选择了8,完全是根据概率统计决定的。
至于为什么是选择红黑树,查询复杂度,链表是 O(N),而 红黑是是 O(logN),绘制函数曲线,在 N >= 8 时,红黑树表现好。O(logN)的查询速度超过 8 时 比 O(N)情况好,但是使用红黑树内存空间将会是链表的2倍。
根据源码注释,个人理解为这里是先根据概率论选择了阈值8,再确定选择了红黑树结构
其中删除元素时,红黑树转链表的阈值为6,主要是因为,如果也将该阈值设置于8,那么当hash碰撞在8时,会反生链表和红黑树的不停相互激荡转换,白白浪费资源。
3、扩容
java 8 之前,HashMap初始化时会默认容量16,java 8 中,HashMap默认容量为 0,只有第一次put元素时,才会扩容到 16。
if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
扩容 resize() 方法最关键之一就是上述代码,扩容2倍。
扩容时会创建一个新数组newTab,重新根据负载因子计算扩容阈值,然后复制数组到新数组中,原来的数据都会置为null,复制过程中分两种情况 : 当前index位置只有一个Node元素,直接复制到新table的新位置;当前位置是链表,如果是红黑是,进行红黑树的移动做法,如果不是,则判断当前链表是否需要移动,不需要则直接存放到新表的index+oldCap位置 (因为扩容是翻倍的,所以与原来的 (n-1) & hash 计算相比,只是多了一个bit 位,所以新位置要么就是原来的位置,要么就是 原位+旧容量位置)。
新位置寻找,会根据该Node元素的hash值,然后重新 与 newTab的大小减 1 作 & 运算(e.hash & (newCap - 1) )。
因为之前确定数组下标是通过 hash & (n-1) 操作的,假如 n = 16,就是 hash & 111,然后在扩容时,扩容一倍,大小为16,新的数组下标是 hash & 1111,如果 hash & 1000 (oldCap旧数组大小16) == 0 ,说明高位是0,高位是0,那么说明 hash & 1111 == hash & 111 啊,这不就巧了吗?这种情况数组就在原位置,直接把数据放在对应的index即可。如果 hash & 1000 == 1,说明高位是1,hash & 1111与hash & 111的区别?大了不?大了多少?1000 == 16 == 之前数组的大小,则数组index就需要往后移 1000(二进制) + 原位置。
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
为什么扩容是2倍而不是3倍,4倍呢?
因为扩容成2倍,数组大小依旧是2的幂次方,2的幂次方-1,它的二进制低位全部都是1,这样保证了hash & (n-1) 后能够完整的保留hash的低位,避免不同的hash值过来后被分配到一样的卡槽中。
二、LinkedHashMap
LinkedHashMap 继承自HashMap ,双向链表结构,重写了HashMap在put元素等操作时调用的方法(afterNodeAccess、afterNodeInsertion)、创建Node节点的方法。如果开启了访问顺序accessOrder,则每次被访问的数据都会放到链表最后。
put元素时,先会放到链表Node节点的next指针上,然后更换节点的after、before指向,将该元素放到链表最后。
结构图
为什么要新建一个继承HashMap的LinkedHashMap呢?
HashMap是无序的,当我们希望有序去存储key - value 时,就可以使用LinkedHashMap。并且相对HashMap增加了一个优点,LinkedHashMap内部维护了一个双向链表,所以在判断哈希表中是否存在某个键值对的时候,可以对链表进行遍历查找,不需要在整个数组桶里面找。
LinkedHashMap 可以设定访问模式,其中 get、put都算对元素的访问,会将访问的节点移动到链表末尾。常见的LruCache就是利用了LinkedHashMap,达到最近最少使用策略。
三、LruCache
请设计一个LruCache 缓存机制
public class MyLruCache<K, V> {
private int cacheSize;
private float factory = 0.75f;
private LinkedHashMap<K, V> cacheMap;
public MyLruCache(int cacheSize) {
this.cacheSize = cacheSize;
cacheMap = new LinkedHashMap<>(cacheSize, factory, true);
}
public V get(K key) {
if (cacheMap.containsKey(key)) {
return cacheMap.get(key);
}
return null;
}
public void put(K key, V value) {
cacheMap.put(key, value);
}
}
最近最少使用
LruCache就是一个持有一定数量强引用数据的缓存。当访问一个数据时,这个数据就会被移动到数据队列的头部(经常用到的数据),当数据添加到缓存满时,队列尾部的数据(也就是不常用到的数据)会被删除并被回收。
笔者环境是API 29,LruCahce 中get()方法中取数据代码如下,可以说是访问时,数据会被移动到队列的尾部。
synchronized (this) {
mapValue = map.get(key);
if (mapValue != null) {
hitCount++;
return mapValue;
}
missCount++;
}
在put方法中,每次都会执行 trimToSize 方法,利用while死循环,来找到链表末尾的数据,并删除。查找代码如下
Map.Entry<K, V> toEvict = null;
for (Map.Entry<K, V> entry : map.entrySet()) {//LinkedHashMap是有序的
toEvict = entry;
}
// END LAYOUTLIB CHANGE
if (toEvict == null) {
break;
}
key = toEvict.getKey();
value = toEvict.getValue();
map.remove(key);
Glide中LruCache缓存是如何做的?(后续会专门分析Glide的编解码和缓存)
它有一个ResoureLruCache和一个DiskLruCache
首先,你需要明确一点,Glide中的 LruCache 不是 android util包下的,初始容量是100,而LruCache最大容量是根据屏幕大小和可用内存来计算的,它是在资源释放时缓存。Key是EngineKey类型,重写了equals和hashCode方法,Value是 EngineResource类型,
其中EngineKey是保存了网络地址url或者是Bitmap和图片的宽高、裁剪参数等信息的。EngineResource是根据服务器返回的数据,按照裁剪参数等包装成的一个类。
每次put数据时,和android util包不一样,
public synchronized Y put(@NonNull T key, @Nullable Y item) {
final int itemSize = getSize(item);
if (itemSize >= maxSize) { //注释1 所有存入的Bitmap的大小要比你设定的最大缓冲值还大
onItemEvicted(key, item); // 这个方法是提供给子类用,当前Bitmap被清除
return null;
}
if (item != null) {
currentSize += itemSize; // 注释2 记录当前大小
}
@Nullable final Y old = cache.put(key, item); //注释 3 这里就是LinkedHashMap put了,把元素放到链表队列末尾
if (old != null) {
currentSize -= getSize(old);
if (!old.equals(item)) {
onItemEvicted(key, old); //注释3 清楚old bitmap
}
}
evict(); //注释4 这里调用的是 trimToSize 开始限制容量
return old;
}
trimToSize() 方法删除是从 LinkedHashMap队列头删除,链表队列头作为最近不常使用的元素。因为你每次get、put都是将元素移动到链表末尾。
protected synchronized void trimToSize(long size) {
Map.Entry<T, Y> last;
Iterator<Map.Entry<T, Y>> cacheIterator;
while (currentSize > size) {
cacheIterator = cache.entrySet().iterator();
last = cacheIterator.next();
final Y toRemove = last.getValue();
currentSize -= getSize(toRemove);
final T key = last.getKey();
cacheIterator.remove();
onItemEvicted(key, toRemove);
}
}
那它的DiskLruCache是如何实现缓存的?
通过工厂模式和构建者模式,创建了DiskCache的包装类DiskLruCacheWrapper来对外使用,数据经过解码后将Key映射成SafeKey,内部通过LinkedHashMap存放了safeKey-entry,然后拿到Entry类,然后再创建Editor,将数据写到指定文件,文件名key + .temp + 文件长度形式。取文件的时候,通过safeKey拿到对应的Entry,获取文件。
每次外部调用创建DiskLruCache都是通过内部open方法,创建journal文件,journal文件是用来记录缓存的操作记录的,目录是/data/data/packagename/cache。
至于DiskLruCache如何创建journal文件和读取、写入缓存的细节,后续再仔细分析。
四、HashTable
初始容量是11,继承自Dictionary,put、get方法都加了 synchronized 修饰,所以是线程安全的,扩容是2n+1,每次数据都是放在链表头部,并且key和value都不能存null。
为什么初始容量是11,而HashMap是16?
首先HashTable寻址方法是 (key.hashCode() & 0x7FFFFFFF) % tab.length,这里直接用HashCode,不像HashMap为了增强分散效果而做二次hash。其实扩容的时候,是 oldCapacity * 2 + 1。所以应该能联想到,默认11 就是因为质树求余的分散效果好,如果容量是 2 的幂次方,那么 hashCode & 0x7FFFFFFF 高位将会被忽略。
HashTable每次找到数据,如果没有在table相应index中存储,将会把数据直接放到链表头
HashtableEntry<K,V> e = (HashtableEntry<K,V>) tab[index];
tab[index] = new HashtableEntry<>(hash, key, value, e);
五、HashSet
实现了Set接口,内部持有HashMap对象,每次value都是固定的Object对象,HashSet的 add(key) 方法就是往HashMap中添加 key - object 数据,方法非线程安全的。
六、SparseArray
可以说SparseArray是为了针对HashMap做的优化,key是 int类型的,mKeys数组存放key,mValues存放值,每次通过二分法在mKeys中找到值为key的元素,返回在mKeys中的下标,然后根据这个下标去更新、存放值。
那他是如何解决HashMap中的散列碰撞问题的呢?
它通过下标映射方法,规避了这种问题。
需要注意以下几点
**1、**如果调用remove删除元素后,元素会标记DELETE状态,在下一次put元素时,如果容量超过了mKeys.length,会执行gc方法,将mKeys和mValues中那些标记了DELETE状态的元素元素删除,数据整体前移。
private void gc() {
int n = mSize;
int o = 0;
int[] keys = mKeys;
Object[] values = mValues;
for (int i = 0; i < n; i++) {
Object val = values[i];
if (val != DELETED) {
if (i != o) {
keys[o] = keys[i];
values[o] = val;
values[i] = null;
}
o++;
}
}
mGarbage = false;
mSize = o;
}
**2、**如果put元素时,没有找到对应的下标,将会对计算出的索引值index取反,返回值要么是0,要么为负数,但是插入数据前也会进行取反操作。通过二分法这种方式,mKeys内部的值一直是按照递增的方式来排列的,因为下面代码的 lo 它最后结果是 lo = hi + 1,所以插入元素到 lo 位置时,后面元素需要整体后移。
static int binarySearch(int[] array, int size, int value) {
int lo = 0;
int hi = size - 1;
while (lo <= hi) {
final int mid = (lo + hi) >>> 1;
final int midVal = array[mid];
if (midVal < value) {
lo = mid + 1;
} else if (midVal > value) {
hi = mid - 1;
} else {
return mid; // value found
}
}
return ~lo; // value not present
}
**3、**没有找到下标,是如何存放元素,即扩容是怎么扩的
public static <T> T[] insert(T[] array, int currentSize, int index, T element) {
if (currentSize + 1 <= array.length) {
System.arraycopy(array, index, array, index + 1, currentSize - index);
array[index] = element;
return array;
}
T[] newArray = (T[]) Array.newInstance(array.getClass().getComponentType(),
growSize(currentSize));
System.arraycopy(array, 0, newArray, 0, index);
newArray[index] = element;
System.arraycopy(array, index, newArray, index + 1, array.length - index);
return newArray;
}
通过系统API,将 0 ~ index-1 间的元素复制到 newArray,然后再将下标 index 后的元素复制到 newArray的 index+ 1位置,即将数据插入到index位置,原有数据向后移。
七、ArrayMap
当key为null时,hash = 0,也就是说key = null,mArray[0]存放对应的值。通过new ArrayMap() 方式使用,hash值就是 key.hashCode() ,然后得到该hash在mHash中的位置。这个有点类似SparseArray了。
那么扩容是怎么实现的呢?
在put元素时,如果大小已经超过了mHashes.length,将会判断扩容大小
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
: (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE); //BASE_SIZE = 4
超过8个将会扩大自己的 1/ 2。在开始扩容操作之前会检查数据有被操作,例如删除、添加,有的话就会抛 ConcurrentModificationException 异常。然后在 size = 4、 8 时会从缓存里面取 array[0]和array[1]赋值给mArray和mHashes (这里为什么要用缓存呢?)。然后复制数组到新数组里面,然后将旧的数据放到缓存里面(为了减少对象创建的开销),最后再设置新的数据。
缓存是怎么做的呢?
设置缓存
private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
if (hashes.length == (BASE_SIZE*2)) {
synchronized (ArrayMap.class) {
if (mTwiceBaseCacheSize < CACHE_SIZE) {
// 将传过来的 array[0]和array[1]分别保存 mTwiceBaseCache和 hashes
array[0] = mTwiceBaseCache;
array[1] = hashes;
//array所有数据 除了 下标 0 1 的数据都置空
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
//mTwiceBaseCache 指向 array
mTwiceBaseCache = array;
mTwiceBaseCacheSize++;
if (DEBUG) Log.d(TAG, "Storing 2x cache " + array
+ " now have " + mTwiceBaseCacheSize + " entries");
}
}
} else if (hashes.length == BASE_SIZE) {
// mBaseCache
}
mTwiceBaseCache结构如下图
取缓存
private void allocArrays(final int size) {
if (mHashes == EMPTY_IMMUTABLE_INTS) {
throw new UnsupportedOperationException("ArrayMap is immutable");
}
if (size == (BASE_SIZE*2)) {
synchronized (ArrayMap.class) {
if (mTwiceBaseCache != null) {
//这里其实就是设置缓存的逆过程
final Object[] array = mTwiceBaseCache;
mArray = array;
mTwiceBaseCache = (Object[])array[0];
mHashes = (int[])array[1];
array[0] = array[1] = null;
mTwiceBaseCacheSize--;
if (DEBUG) Log.d(TAG, "Retrieving 2x cache " + mHashes
+ " now have " + mTwiceBaseCacheSize + " entries");
return;
}
}
} else if (size == BASE_SIZE) {
//mBaseCache
}
mHashes = new int[size];
mArray = new Object[size<<1];
}
八、ArrayList
在第一次add数据时,会设置容量为10,后续将扩容1.5倍,删除数据 O(N) 、添加数据O(1)
九、ArraySet
实现了 Collection接口,与ArrayMap一样,区别在于mArray数组,它是直接存值。并不需要存放key。
十、LinkedList
链表结构,每次操作都相当于是对链表的操作,查找O(N)、删除O(1)、添加O(1)
十一、ThreadLocal
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
得到当前Thread内部持有的ThreadLocal.ThreadLocalMap对象,调用ThreadLocalMap的set方法存值
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
从上述代码可以看出 key 是当前ThreadLocal,key和value组成一个Entry存放在table中,从中可以看出,一个线程对应于一个ThreadLocalMap,内部有table数组,存放不同ThreadLocal和对应的Value值,当然一个ThreadLocal可以在不同的线程中使用,即调用set发方法。