Hash碰撞的解决方式
- 提起存储键值对,首先想到的是Map集合,但是对于hash算法导致的hash碰撞,一般有两种解决方式: 链表法跟开放地址法,对于Android应用开发来说,正好对应着HashMap跟ArrayMap的解决方式
ArrayMap: 开放地址法
- 笔者在使用Retrofit请求网络时由于需要将数据封装成map集合发送给后端,但是一般都是提交数据量很小的内容,因此使用ArrayMap替换HashMap已节约内存空间.这里通过源码分析两者之间的差别
- 首先查看构造函数
public ArrayMap() {
this(0, false);
}
public ArrayMap(int capacity) {
this(capacity, false);
}
/**
* identityHashCode: 是否使用系统的System.identityHashCode(key),跟对象存储位置地址值相关 ,
* false时使用对象的hashCode,如果重写就使用对象的hashCode方法(String 重写了,所以map键常用的字符串是一致的),未重写则使用object的hashcode
* 同系统的调用同一个本地方法,结果值一致
*/
public ArrayMap(int capacity, boolean identityHashCode) {
mIdentityHashCode = identityHashCode;
// 这是同HashMap不同的地方,如果当前没有设置大小默认为0,hashMap默认数组为16,节约空间了吧
if (capacity < 0) {
mHashes = EMPTY_IMMUTABLE_INTS;
mArray = EmptyArray.OBJECT;
} else if (capacity == 0) {
mHashes = EmptyArray.INT;
mArray = EmptyArray.OBJECT;
} else {
// 初始化值,根据提供的大小,但同HashMap会改变(注意并非指定多少即为多大空间数组)
allocArrays(capacity);
}
mSize = 0;
}
- 搞懂几个数据含义
- ArrayMap是由两个数组构成的 mHashes 和 mArray
- mHashes : 由键的Hash值根据大小有序的数组 mHashes [key1.hash , key2.hash , ....],(key1.hash跟key2.hash完全可以相等,这个没关系的)
- mArray: 键值对依次排列的数组 mArray[key1, value1, key2, value2 ....]
- 查看 put()方法
@Override
public V put(K key, V value) {
final int osize = mSize;
final int hash;
int index;
if (key == null) { //key是空,则通过indexOfNull查找对应的index,支持存储null键
hash = 0;
index = indexOfNull();
} else { //否则 通过key查找下标index
hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode(); //构造函数中指定使用哪种hash值
index = indexOf(key, hash); //稍后解释(根据hash值及key找到对应的下标index ,存在为正,不然为 ~位置 一个负数值)
}
if (index >= 0) { //找到了当前存储的key键在mArray中替换成新值value并返回旧值
index = (index<<1) + 1;
final V old = (V)mArray[index];
mArray[index] = value;
return old;
}
index = ~index; //将indexOf中找到的~index在次~即为(~~index) == index一个正值,即为数据将要插入的位置
if (osize >= mHashes.length) { //判断是否需要扩容大小,扩容为默认 4 ->8 -> 24 -> 72(不足向上取,比如初始7个数据则数组大小为8),扩容后赋值并回收原有数组
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
: (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
if (DEBUG) Log.d(TAG, "put: grow from " + mHashes.length + " to " + n);
//将原来的数组赋值保存后在使用allocArrays(n)对原来数组根据新大小n进行扩容
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
allocArrays(n); //具体的扩容函数,创建一个新的数据,可能存在缓存数组,稍后讲解
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) { //对于两个线程同时扩容报错,同HashMap一致是非线程安全
throw new ConcurrentModificationException();
}
if (mHashes.length > 0) {
//将上方保存的原有数据拷贝到新数组中,注意下标从0开始及大小原有数组长度
if (DEBUG) Log.d(TAG, "put: copy 0-" + osize + " to 0");
System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
System.arraycopy(oarray, 0, mArray, 0, oarray.length);
}
//释放原有数组,已经在上方拷贝过了,这里涉及到缓存数据
//查看是否数据还有利用价值,其后会讲到,大致是对数据量为4或者8做一个缓存以便其后还会利用到
//如果数据量超过8就就没有必要占用内存去缓存它了
freeArrays(ohashes, oarray, osize);
}
if (index < osize) { //index是指当前数据key的hash值下标即key应该插入数组的位置不是最后一位则移动他将插入位置及其后的所有数据腾出位置给其插入使用
if (DEBUG) Log.d(TAG, "put: move " + index + "-" + (osize-index)
+ " to " + (index+1));
//注意: 将mHashes的index 位置以后的向后移动一位,同时mArray也是同时向后移动2位,给key.hash,跟(key,value)插入让出位置
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; //在特定位置插入mHashes,及mArray数组上
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;
mSize++; //数据值+1
return null;
}
- 初始化数组函数 allocArrays(capacity)用于数组的初始化及缓存数据
- 看这个缓存函数需要搞懂一个数组扩容大小改变关系(在put函数中BASE_SIZE = 4)
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1)) : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
- 默认值为 4 ,第一次扩容为 4 * 2 = 8 , 以后每次扩容为当前值得 3 倍.即下次为 24 ->72 ...
private void allocArrays(final int size) {
if (mHashes == EMPTY_IMMUTABLE_INTS) {
throw new UnsupportedOperationException("ArrayMap is immutable");
}
//设置缓存用的,当数据量由24 降为 8时不用再次重新创建加载,而是直接使用mTwiceBaseCache缓存的数组即可
if (size == (BASE_SIZE*2)) {
synchronized (ArrayMap.class) {
if (mTwiceBaseCache != null) { //缓存 8数据存在
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) {
synchronized (ArrayMap.class) {
if (mBaseCache != null) {
final Object[] array = mBaseCache;
mArray = array;
mBaseCache = (Object[])array[0];
mHashes = (int[])array[1];
array[0] = array[1] = null;
mBaseCacheSize--;
if (DEBUG) Log.d(TAG, "Retrieving 1x cache " + mHashes
+ " now have " + mBaseCacheSize + " entries");
return;
}
}
}
mHashes = new int[size];
mArray = new Object[size<<1];
}
- 对于查找indexOf(key,hash)
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;
}
// 使用二分查找到当前hash值在mHashes数组中的下标,如果不存在返回(~当前需要插入的位置)后续在~即为正在需要插入的位置 (~~A == A)
int index = binarySearchHashes(mHashes, N, hash);
if (index < 0) { //如果没有找到就返回一个负值
return index;
}
//如果当前index下标的key同查找的一致就返回
if (key.equals(mArray[index<<1])) {
return index;
}
// 先向上查找相同hash值是否key一致,否则向下查找
int end;
for (end = index + 1; end < N && mHashes[end] == hash; end++) {
if (key.equals(mArray[end << 1])) return end;
}
for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
if (key.equals(mArray[i << 1])) return i;
}
// 如果都没有找到,将返回需要插入位置的负值,注意这里是相同hash值的最后一个其后添加数据
return ~end;
}
- remove 方法:
- 在某种条件下,会重新分配内存,保证分配给ArrayMap的内存在合理区间,减少对内存的占用。但是如果每次remove都重新分配空间,会浪费大量的时间。
- 因此在此处,Android使用的是用空间换时间的方式,以避免效率低下。无论从任何角度,频繁的分配回收内存一定会耗费时间的。
public V removeAt(int index) {
final Object old = mArray[(index << 1) + 1];
final int osize = mSize; //msize是当前存储实际数据大小,mHashes.length为当前申请的数组大小
final int nsize;
if (osize <= 1) {
// 当实际数据为1时,即移除以后恢复成默认值null
if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to 0");
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
mHashes = EmptyArray.INT;
mArray = EmptyArray.OBJECT;
freeArrays(ohashes, oarray, osize);
nsize = 0;
} else {
nsize = osize - 1;
// 如果当前数组大于 8并且实际数据量小于 数组长度的 1/3 则重新分配内存空间
if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
//重新分配后实际数组的大小
final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2);
if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to " + n);
//备份数据以便后期复制使用
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
allocArrays(n); //重新分配数据,注意里面会用到已经缓存的4和8的数据
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) { //判断是否线程安全
throw new ConcurrentModificationException();
}
//移除index上的数据分两个部分移除,首先复制0--index大小的数据就是到 (index -1)处
if (index > 0) {
if (DEBUG) Log.d(TAG, "remove: copy from 0-" + index + " to 0");
System.arraycopy(ohashes, 0, mHashes, 0, index);
System.arraycopy(oarray, 0, mArray, 0, index << 1);
}
// 再次从index + 1 处移动nsize - index的数据复制到新数组中
if (index < nsize) {
if (DEBUG) Log.d(TAG, "remove: copy from " + (index+1) + "-" + nsize
+ " to " + index);
System.arraycopy(ohashes, index + 1, mHashes, index, nsize - index);
System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1,
(nsize - index) << 1);
}
} else { //不用重新分配内存,直接可用的则直接移动其后的数据,并将最后一位设置成默认null
if (index < nsize) {
if (DEBUG) Log.d(TAG, "remove: move " + (index+1) + "-" + nsize
+ " to " + index);
System.arraycopy(mHashes, index + 1, mHashes, index, nsize - index);
System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1,
(nsize - index) << 1);
}
mArray[nsize << 1] = null;
mArray[(nsize << 1) + 1] = null;
}
}
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
mSize = nsize; //减少一位并返回删除的旧值
return (V)old;
}
-
重点关注缓存 freeArrays(final int[] hashes, final Object[] array, final int size) , 结合 allocArrays(int size)同看
- 注意参数的内容 : hashes 跟 array的长度关系是 1/ 2 , 切记切记啊
private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
if (hashes.length == (BASE_SIZE*2)) { //如果当前缓存的大小为8
synchronized (ArrayMap.class) {
if (mTwiceBaseCacheSize < CACHE_SIZE) {
array[0] = mTwiceBaseCache; //第一次的array[0] = null, 但是其后的将会是一个链表结构这一次缓存中的array[0] ->指向的是上一次缓存的整体数字,但是array的中长度依然是 16 ,最多缓存 10个
array[1] = hashes;
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
mTwiceBaseCache = array;
mTwiceBaseCacheSize++;
if (DEBUG) Log.d(TAG, "Storing 2x cache " + array
+ " now have " + mTwiceBaseCacheSize + " entries");
}
}
} else if (hashes.length == BASE_SIZE) { //同上所述不过缓存的数据为4,array的总长度为8,最多缓存10个
synchronized (ArrayMap.class) {
if (mBaseCacheSize < CACHE_SIZE) {
array[0] = mBaseCache;
array[1] = hashes;
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
mBaseCache = array;
mBaseCacheSize++;
if (DEBUG) Log.d(TAG, "Storing 1x cache " + array
+ " now have " + mBaseCacheSize + " entries");
}
}
}
}
- 再次查看 allocArrays
private void allocArrays(final int size) {
if (mHashes == EMPTY_IMMUTABLE_INTS) {
throw new UnsupportedOperationException("ArrayMap is immutable");
}
if (size == (BASE_SIZE*2)) { //8数据同下
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) { //如果当前缓存的有数据,则array是一个大小为8的链式结构,加入缓存了多次,但是被复用以后其被赋值给mArray的时候依然是一个大小为8且在后续会被重新赋值的,所以不用担心链式多层结构问题
synchronized (ArrayMap.class) {
if (mBaseCache != null) {
final Object[] array = mBaseCache;
mArray = array;
mBaseCache = (Object[])array[0]; //将下一层缓存的数据重新赋值给4个缓存
mHashes = (int[])array[1]; //当前为4的数组赋值给mHashes
array[0] = array[1] = null;
mBaseCacheSize--; //缓存池中数量减1
if (DEBUG) Log.d(TAG, "Retrieving 1x cache " + mHashes
+ " now have " + mBaseCacheSize + " entries");
return;
}
}
}
mHashes = new int[size];
mArray = new Object[size<<1];
}
-
缓存数据图如下
image
- 特别解释,为何最大缓存数据是10层:
- 没有注意几个数据情况 :几个缓存数据及他们的长度均是静态成员变量,也就是他们是跟类一致而跟对象无关,所以是所有的ArrayMap对象共用的,不管你创建多少ArrayMap都将共用这最多10个缓存池,相当于线程池一样儿的
static Object[] mBaseCache; static int mBaseCacheSize; static Object[] mTwiceBaseCache; static int mTwiceBaseCacheSize;
SparseArray
- 同ArrayMap一样是由两个数组构成的,不同的是他并不是map集合
//成员变量分析
public class SparseArray<E> implements Cloneable {
private static final Object DELETED = new Object(); //数组中的数据如果被删除,可能仅仅标记value为DELETED并不会每次都重新移动数组,太消耗时间了,同时可以直接在Deleted位置上put数据覆盖之
private boolean mGarbage = false; //是否需要垃圾回收,即value中存在DELETED,但并不一定会被回收的,在delete(key)时被标记为Deleted
private int[] mKeys; //一个key对应一个value,且key是int型不可重复,多在从0-N有序列使用,比如RecycleView中的viewType就是有序且不可重复的,缓存即使用SparseArray
private Object[] mValues;
private int mSize;
public SparseArray() { //默认keys和values数组长度为10
this(10);
}
- 存储put
public void put(int key, E value) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
//如果找到i则i对应的value数组位置修改值
if (i >= 0) {
mValues[i] = value;
} else {
i = ~i; //没有找到,这个只是根据二分查找到第一个大于i的位置插入这里,其后值向后移动
if (i < mSize && mValues[i] == DELETED) { //如果当前位置被标记为Deleted删除标记,表示该位置可用
mKeys[i] = key;
mValues[i] = value;
return;
}
//有Deleted则mGarbage = true,数组是可以靠移动而不是扩容就能容纳该put新值的
if (mGarbage && mSize >= mKeys.length) {
gc(); //新gc将deleted标志都删除掉
// Search again because indices may have changed.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}
//如果位置没有Deleted,且数组已经满了,则二倍的扩容
mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
}
}
- put步骤:
- 如果当前i位置根据二分查找找到了,则替换原来值为value新值
- 如果当前i不存在,则找到第一个大于i的位置标示插入位置,此时有两种情况,1该位置设置为Deleted则直接插入即可,否则判断是否已满且有被标记Deleted的位置,通过GC移动数组,将deleted位置处删除则可正常的插入了
- 如果数组满了,且没有Deleted标志,则需要2倍的扩容数组后在插入啦!
- 这里的gc比较有意思,可以看一下,使用双指针移动删除数组中被标记为Deleted
private void gc() {
// Log.e("SparseArray", "gc start with " + mSize);
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;
// Log.e("SparseArray", "gc end with " + mSize);
}
- get和delete函数都很简单,这里只贴出来,标记为Deleted即可,不用真正的去移动数组,提高效率
public E get(int key, E valueIfKeyNotFound) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i < 0 || mValues[i] == DELETED) {
return valueIfKeyNotFound;
} else {
return (E) mValues[i];
}
}
/**
* Removes the mapping from the specified key, if there was any.
*/
public void delete(int key) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i >= 0) {
if (mValues[i] != DELETED) {
mValues[i] = DELETED;
mGarbage = true;
}
}
}
HashMap
- 对于大数据量的存储还是推荐使用HashMap,因为ArrayMap如果大量数据插入时需要时间复杂度O(n),而使用hashMap最坏时间复杂度O(logn,都在一个红黑树下),平均时间复杂度O(1),主要看数据量来换算是否已时间换取空间对于很多算法选择无非就是时间跟空间两者的平衡
-
数据结构为:HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体;
- 根据使用场景设置初始容量及负载因子
- hashMap实现了Map接口,使用键值对进行映射,map中不允许出现重复的键(key)
- Map接口分为两类:TreeMap跟HashMap
- TreeMap保存了对象的排列次序,hashMap不能
- HashMap可以有空的键值对(null-null),是非线程安全的,但是可以调用collections的静态方法synchronizeMap()实现
- HashMap中使用键对象来计算hashcode值
- HashMap比较快,因为是使用唯一的键来获取对象
-
源码解析:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { HashMap.Node<K, V>[] tab; HashMap.Node<K, V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) //列表为空或者长度为0 初始化一个HashMap n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) //当前位置上没有数据new一个链表 tab[i] = newNode(hash, key, value, null); else { //当前数组Hash位置上面已经存在了值 HashMap.Node<K, V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) //有相同的hash键,直接替换 e = p; else if (p instanceof HashMap.TreeNode) //如果当前是红黑树使用红黑树的添加 e = ((HashMap.TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value); else { //当前为链表结构的添加 for (int binCount = 0; ; ++binCount) { //计算当前链表的长度 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //放到链表尾部 /** * 当前链表从0开始遍历一直到 7,所以长度大于等于 8转化为红黑树存储,如果remove时树的数据量=<6 ,红黑树会转化成链表结构存储 * * 解析:因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。 * 链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。 * 选择6和8,中间有个差值7可以有效防止链表和树频繁转换,而导致的不停转化树与链表导致效率低下的问题; */ if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); //如果 break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //记录变化次数,防止在多线程中迭代器取值导致错误,fail-fill检查机制 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
-
通过源码我们可以得出:
-
当我们往HashMap中put元素的时候,先根据key的hashCode重新计算hash值,根据hash值得到元素在数组中的下标,(hash值计算 = 32位hash的 hash值 ^ 高16位后再 & 上 length -1 )
- 如果数组该位置已经存放有其他元素,则在这个位置上以链表的形式存放,新加入的放在链头,最后加入的放在链尾,如果链表中有相应的key则替换value值为最新的并返回旧值;
- 如果该位置上没有其他元素,就直接将该位置放在此数组中的该位置上;
- 我们希望HashMap里面的元素位置尽量的分布均匀写,使得每个位置上的元素只有一个,这样当使用hash算法得到这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用再遍历链表,这样就大大优化了查询效率;
- 计算hash值的方法hash(int h),理论上是对数组长度取模运算 % ,但是消耗比较大,源代码调用为:
/** * Returns index for hash code h. */ static int indexFor(int h, int length) { return h & (length-1); //HashMap底层数组长度总是2的n次方,每次扩容为2倍 } //Map数组初始化取值: //当 length 总是 2 的 n 次方时,h& (length-1)运算等价于对 length 取模,也就是 h%length,但是 & 比 % 具有更高的效率。 // Find a power of 2 >= initialCapacity int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; //每次增加2倍,所以总长度一定为2的次方
-
为何hash码初始化为2的次方数探讨:
image分析:
-
当它们和 15-1(1110)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8 和 9 会被放到数组中的同一个位置上形成链表,那么查询的时候就需要遍历这个链 表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为 15 的时候,hash 值会与 15-1(1110)进行“与”,那么最后一位永远是 0,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
-
而当数组长度为16时,即为2的n次方时,2n-1 得到的二进制数的每个位上的值都为 1,这使得在低位上&时,得到的和原 hash 的低位相同,加之 hash(int h)方法对 key 的 hashCode 的进一步优化,加入了高位计算,就使得只有相同的 hash 值的两个值才会被放到数组中的同一个位置上形成链表。
-
当数组长度为 2 的 n 次幂的时候,不同的 key 算得得 index 相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了
-
-
-
-
总结: 根据上面 put 方法的源代码可以看出,当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部——具体说明继续看 addEntry() 方法的说明。
-
HashMap的扩容机制
- 什么时候扩容: 达到阈值,数组大小 * 加载因子默认16* 0.75 = 12个
- JDK 1.7没有引入红黑树,使用数组加上链表,JDK1.8引入红黑树,使用数组+链表+红黑树存储,当链表值大于等于8转化为红黑树,当remove时红黑树大小小于等于6时会转化为链表,设置一个过渡值7防止不停地put,remove导致不听转化而使得效率低下;
if (loHead != null) { if (lc <= UNTREEIFY_THRESHOLD) //6 tab[index] = loHead.untreeify(map); //将红黑树转化成链表 else { tab[index] = loHead; if (hiHead != null) // (else is already treeified) loHead.treeify(tab); } }
-
归纳HashMap
-
简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据 hash 算法来决定其在数组中的存储位置,在根据 equals 方法决定其在该数组位置上的链表中的存储位置;当需要取出一个Entry 时,也会根据 hash 算法找到其在数组中的存储位置,再根据 equals 方法从该位置上的链表中取出该Entry。
-
初始化HashMap默认值为16, 初始化时可以指定initial capacity,若不是2的次方,HashMap将选取第一个大于initial capacity 的2n次方值作为其初始长度 ;
-
初始化负载因子为0.75,如果超过16 * 0.75 = 12个数据就会将数组扩容为2倍 长度为32 , 并后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能。 如果负载印在越大,对空间利用越充分,但是会降低查询效率,如果负载因子越小,则散列表过于稀疏,对空间造成浪费(时间<-> 空间转换: 0.75最佳)
-
多线程中的检测机制:Fail-Fast,通过标记modCount(使用volatile修饰,保证线程间修改的可见性) 域 修改次数,在迭代初始化时候赋值,以后每次next的时候都会判断是否相等
HashIterator() { expectedModCount = modCount; if (size > 0) { // advance to first entry Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } } final Entry<K,V> nextEntry() { if (modCount != expectedModCount) throw new ConcurrentModificationException();
-
建议使用concurrent HashMap在多线程中
-
Map的遍历,
-
使用entrySet获取键值对的Entry集合,只需要遍历一次
-
使用keySet先遍历所有的键,在根据键调取get(key),需要遍历两次
-
JDK1,8以上新增forEach()遍历,先遍历hash表,如果是链表结构遍历后再遍历下一个hash值的链表
public final void forEach(Consumer<? super Map.Entry<K,V>> action) { Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; // Android-changed: Detect changes to modCount early. for (int i = 0; (i < tab.length && modCount == mc); ++i) { for (Node<K,V> e = tab[i]; e != null; e = e.next) action.accept(e); } if (modCount != mc) throw new ConcurrentModificationException(); } } Map map = new HashMap();
-
Iterator iter = map.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry entry = (Map.Entry) iter.next();
Object key = entry.getKey();
Object val = entry.getValue();
}
```
HashMap头添加扩容导致死循环分析
- 在JDK1.7之前HashMap的扩容是从头部添加元素,导致在多线程环境下put操作使得Entry链表形成环形数据结构,则next节点永不为null,导致死循环获取Entry
- 主要是在put元素HashMap扩容阶段:JDK1.7的扩容函数为:
void transfer(Entry[] newTable) {
Entry[] src = table; //旧的hash表
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next; //注意添加顺序,是由头部开始添加,这个地方会导致循环的产生,加入线程A执行完毕挂起了,此时e = 3 , next = 7 , 3.next = 7的
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
- 分析,如果对于线程A , B分别对HashMap扩容
- 对于上方的扩容函数当两个线程A,B运行到Entry<K,V> next = e.next, 时
- 对于 旧Hash表中的3 ->7线程A此时 e =3, e.next = next = 7 , 3指向的是 7 运行到这个时候A挂起了,
- 这个时候B开始正常运行扩容 这个时候还是 3->7 添加到新表时先添加3,在3的前面添加7 此时运行完成以后是 7->3,即7.next = 3 , 3.next = null ,这个时候还是正常的操作
- 但是,还记得我们上面的线程A是3->7的时候挂起了,如果此时开始运行下面代码 e.next = newTable[i]; e = 3 ,而此时newTable[i]已经被线程B修改成7了,因此3.next = 7这一步替换了3步中的3.next = null,happened before原则满足 ,将3重新添加到链表头结点处,e重新赋值为7 ,还记得3中的 7.next = 3,注意这里并没有修改7.next的值哦,因此put进入死循环中
- 为了防止这个JDK1.8优化了从尾部添加元素,安全性无法保证多线程会被覆盖!
Hashtable(默认容量 11 ,加载因子0.75)
-
概述
和HashMap一样儿,Hashtable也是一个散列表,它存储的内容是键值对,implements Map<>
-
成员变量的含义:table, count, threshold, loadFactor, modCount
- table是一个Entry[]数组类型,Entry就是一个单向链表,而key-value 键值对就是存储在entry中
- count是Hashtable的大小,是保存键值对的数量
- threshold为判断是否需要扩容 = 容量 * 加载因子
- loadFactor 加载因子
- modCount用来实现fail-fast同步机制
-
put方法
- 判断value 是否为null,为空抛出异常;
- 计算key的hash值获得key在table数组的位置index,如果table[index]元素不为空,进行迭代,如果遇到相同的key,则直接替换,并返回旧的value
- 否则将其插入到table[index]位置上
public synchronized V put(K key, V value) { // Make sure the value is not null确保value不为null if (value == null) { throw new NullPointerException(); } // Makes sure the key is not already in the hashtable. //确保key不在hashtable中 //首先,通过hash方法计算key的哈希值,并计算得出index值,确定其在table[]中的位置 //其次,迭代index索引位置的链表,如果该位置处的链表存在相同的key,则替换value,返回旧的value Entry tab[] = table; int hash = hash(key); int index = (hash & 0x7FFFFFFF) % tab.length; for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) { if ((e.hash == hash) && e.key.equals(key)) { V old = e.value; e.value = value; return old; } } modCount++; if (count >= threshold) { // Rehash the table if the threshold is exceeded //如果超过阀值,就进行rehash操作 rehash(); tab = table; hash = hash(key); index = (hash & 0x7FFFFFFF) % tab.length; } // Creates the new entry. //将值插入,返回的为null Entry<K,V> e = tab[index]; // 创建新的Entry节点,并将新的Entry插入Hashtable的index位置,并设置e为新的Entry的下一个元素 tab[index] = new Entry<>(hash, key, value, e); count++; return null; }
Hashtable与HashMap的比较
-
HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过
synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);- 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它 ;
- 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
- 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小。
- 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
HashMap jdk1.8红黑树
- 为何在1.8以后使用红黑树替换链表优化hash碰撞,首先链表容易维护但不利于查找O(n),红黑树是一种特殊的二叉树,其利于查找(二分查找法O(logn)),但相对应的维护比较复杂(需要左旋,右旋及更改节点颜色等)
- 因此对于数量小于等于6的hash碰撞使用链表存储,对于数量大于等于8的hash碰撞优化成使用红黑树存储,增删改查比较快捷,空出数字7是为了频繁的在6-8之间增加删除而导致不停的变化数据起到一个缓冲作用
- 选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,链表长度低于6,就把红黑树转回链表,因为根本不需要引入红黑树,引入反而会慢。
红黑树
- 在红黑树中的hash值一致,他是根据comporeTo()来比较数值的:比如对于常见的String键来说,相同的hash值是根据native compareTo()比较两个字符串差值来计算出红黑树的大小,因此红黑树中不存在相同数值的数据,一旦相同就表示是存储的相同的键(equals方法相同)需要替换值了
- 简单说一下什么是红黑树:特点
- 根节点为黑色
- 子节点跟他的父节点不能同时是红色:即红色节点的子节点都是黑色节点
- 从根节点到任何一个叶子结点其经过的黑色节点个数相同
- 所有空叶子结点都是黑色的,这里的叶子结点指的是空结点,常用 NIL 表示,一般可以省略,但在用到的时候需要补全它
- 注意:由于特性2,3可确保没有一条路径会比其他路径长出俩倍,因此它是一个相对平衡的二叉树,相对于普通二叉树可能退化成链表更优秀,同时相对于完全二叉树(最大跟最小最多差1且最深的节点都位于左边),平衡二叉树(左子树跟右子树深度差的绝对值不能超过1,且左右子树也是平衡二叉树)需要转化步骤更少,通过更少的更改数据既能达到相对平衡的效果,对于Hash冲突解决最优,无非还是空间换取时间操作
-
左旋跟右旋的理解
image - 分析:
- 对L进行左旋就是将L的右节点设置成x的父节点,即将L变成一个左节点==>左旋中的左是指将旋转的节点变成一个左节点
- 对P进行右旋就是将P的左节点设置成p的父节点,即将P变成一个右节点==>右旋中的右是指将旋转的节点变成一个右节点
查找 时间复杂度O(logN)
-
对于红黑树的查找来说
image
插入
- 注意:
- 红黑树插入节点均是最底层的叶子节点处
- 插入节点颜色为红色,不为黑色是因为根据特性3,任何一个叶子节点经过黑色节点个数相同,如果是黑色一定会更改其中个数导致不相同而需要重新左旋右旋更改颜色等多余操作,如果是红色则有可能不需要做任何操作也达到平衡了
- 假设: 待插入节点为N,其父节点为P, 其祖父节点为G,U是N的叔叔节点(P的兄弟节点),则插入红黑色有下面几种情况:
- N是根节点,即红黑树的第一个节点
- N的父节点P为黑色
- P是红色的不是根节点(根节点一定是黑色),他的兄弟节点U也是红色的
- P为红色,而U为黑色
- 对于以上1,2,3情况来说,他们都不涉及旋转,只涉及颜色翻转而已
- 对于情况1 , 2 并不影响红黑树的性质即不会破坏平衡,直接插入即可
- 对于3来说,P,U均为红色,直接变色即可,P和U变成黑色,G变成红色,如果G是根节点,直接变黑,否则则将G作为插入节点,递归向上检查是否造成不平衡
- 对于情况4来说,P为红色,U为黑色,这种分成四种情况:
- P是G的左孩子,若N是P的左孩子,则将祖父节点G右旋即可
- P是G的左孩子,若N是P的右孩子,则将P先左旋转(左转后情况如1),然后再将祖父节点G右旋转
- P是G的右孩子,若N是P的右孩子,则将祖父节点G左旋转即可
- P是G的右孩子,若N是P的左孩子,则先将P右旋转(右旋转后情况如3),然后在将祖父节点G左旋转
- 由于添加只能是叶子节点,所以只能是这四种情况,注意黑色U可能是Nul节点的
删除
- 对于删除操作来说,只能是一下四种情况:
- 删除节点的左,右子树都不为Nul;
- 删除节点的左子树为Nul,右子树非空;
- 删除节点的右子树为Nul,左子树非空;
- 删除节点的左,右子树均为Nul;
在TreeMap源码中可以查看以上四种情况分析: 对于1来说,删除节点最终还是删除非空的一个叶子节点
private void deleteEntry(TreeMapEntry<K,V> p) { modCount++; size--; // If strictly internal, copy successor's element to p and then make p // point to successor. if (p.left != null && p.right != null) { //对应第一种情况,左,右子树均不为Nul TreeMapEntry<K,V> s = successor(p); p.key = s.key; p.value = s.value; p = s; } // 没有返回,将第一种情况转化成了 2,3,4 种情况 // Start fixup at replacement node, if it exists. TreeMapEntry<K,V> replacement = (p.left != null ? p.left : p.right); if (replacement != null) { //对应于2,3中情况,左,右子树有一个为Nul // Link replacement to parent replacement.parent = p.parent; if (p.parent == null) root = replacement; else if (p == p.parent.left) p.parent.left = replacement; else p.parent.right = replacement; // Null out links so they are OK to use by fixAfterDeletion. p.left = p.right = p.parent = null; // Fix replacement if (p.color == BLACK) fixAfterDeletion(replacement); } else if (p.parent == null) { // return if we are the only node. root = null; } else { // 第四种情况,左右子树均为Nul if (p.color == BLACK) fixAfterDeletion(p); if (p.parent != null) { if (p == p.parent.left) p.parent.left = null; else if (p == p.parent.right) p.parent.right = null; p.parent = null; } } }
- 我们可以知道被删除的节点一定是最底层的一个非空的叶子节点
- 若被删除的节点D(转化以后真正需要删除的节点)为红色
网友评论