数据结构
java1.7:数组+链表
java1.8:数组+链表+红黑树
单个节点的结构:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
所以说横着看第一排是一个数组,竖着看是一个个链表,或者一棵树,我们都知道hash算法可以以O(1)的时间复杂度找到数组的节点,但是由于HashMap使用了链地址法去解决hash冲撞,所以极端情况下所有数据都在一个链表上,查找时间复杂度高达O(n),为了尽量避免这种情况才有了扩容和后来的红黑树(后续会仔细说到)
几个方法
1.初始化
HashMap有四个初始化构造方法 :
1、自定义初始容量和扩容因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);//重新计算初始值
}
2、自定义初始容量
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3、默认构造方法
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
4、根据已有的Map接口创建一个元素相同的HashMap,使用默认初始容量与默认负载因子
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
观察以上四种可以看到涉及到自定义初始容量的构造方法最后都会执行到这个方法
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;//右移1位
n |= n >>> 2;//右移2位
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//把减掉的1加回来
}
我们都知道HashMap规定容量都必须是2的整数次幂,如果我们输入的不是2的整数次幂这个方法用于找到大于等于initialCapacity的最小的2的幂,向右移动5次的实质就是把最靠左的1后面所有为0的位都变成1这样做最后+1时就会变成他的最大二次幂,这也是为什么最初cap-1的原因(假如我们传进来的本来就是2的整数次幂,如果不-1最后会变成cap的二倍)根据最大容量值为2的30次幂,移动五次足够 如图:
2.put操作
看下面代码需要知道这几个字段
//实际存储的key-value键值对的个数 算链表
transient int size;
//阈值 capacity(数组容量不算链表)*loadFactory(扩容因子)
int threshold;
//负载因子
final float loadFactor;
//hashmap被改变的次数
transient int modCount;
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
//此时threshold为initialCapacity 默认是16
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
可以发现hash函数并没有直接使用hashcode,那么hashcode有40亿的映射空间如此松散为什么不用hashcode呢?其实是因为40亿空间而hashmap初始数组才16,还需要indexFor方法计算映射的数组下标,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重,所以需要一些扰动函数的操作,尽量让高位也参与计算数组下标,减少碰撞。
static int indexFor(int h, int length) {
return h & (length-1);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//如果此时存在的键值对大于了阈值并且数组不为空则扩容!!!!
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
if ((size >= threshold) && (null != table[bucketIndex])) 注意这行代码
这里我们会发现原来数组扩容并不仅仅是因为超出阈值还有一个条件就是当前数组没有数据,这就要回到最初为什么扩容,最初扩容就是不想让链表太长因为查询链表时间复杂度是O(n)而数组则是O(1),那么既然数组都没有值了为什么扩容呢?不忘初心很重要!!
put总结:如图
3.get操作
public V get(Object key) {
//如果key为null,则直接去table[0]处去检索即可。
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//通过key的hashcode值计算hash值
int hash = (key == null) ? 0 : hash(key);
//indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
get总结:如图
4.其他问题
-
为什么hashmap的容量要是2的整数次幂?
有两个原因:一个是因为可以用&运算代替%运算,效率更高,再就是因为算index时用的是(n-1) & hash,这样就能保证n -1是全为1的二进制数,如果不全为1的话,存在某一位为0,那么0,1与0与的结果都是0,这样便有可能将两个hash不同的值最终装入同一个桶中,造成冲突。所以必须是2的幂。 -
为什么扩容因子是0.75?
简单来说,这是对空间成本和时间成本平衡的考虑,为了减少碰撞,减少链表长度造成的查找成本升高,在恰当的时机扩容而不是等空位较少时扩容
扩容机制
此部分参考了占小狼博客
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;//旧数组的长度
if (oldCapacity == MAXIMUM_CAPACITY) {//旧数组长度是否已经是hashmap最大值了
threshold = Integer.MAX_VALUE;//阈值等于最大值
return;
}
Entry[] newTable = new Entry[newCapacity];//创建新数组
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;//table更新当前数组
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//更新阈值
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//重新计算数组下标
e.next = newTable[i];//链表的头插法
newTable[i] = e;
e = next;
}
}
}
下面列出一个特殊情况
假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,假设负载因子是1。插入第四个元素时假设两个线程同时执行了resize
1. 扩容前table
2. 线程二执行完Entry<K,V> next = e.next;就没有cpu时间片了此时next = b ,e = a;
3. 线程一执行到resize方法结束但是来没来得及更新table
a、b、c节点rehash之后又是在同一个位置7
4.此时transfer方法执行完,执行到了resize方法中这句话table = newTable时时间片回到了线程二
而问题就在此时线程二的next存储的是b结点,等一次循环过后 b结点的next还是a,从此就造成了死循环,且数据c丢失
线程安全问题
HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。
假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完if ((p = tab[i = (n - 1) & hash]) == null)(如果没有hash碰撞则直接插入元素)后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
平衡树与红黑树
此部分参考敖丙的博客
红黑树概念:红黑树(Red Black Tree)是一颗自平衡(self-balancing)的二叉排序树(BST),树上的每一个结点都遵循下面的规则
红黑树的四个规则:
- 每个结点要么黑色要么红色
- 树根结点是黑色
- 树中不存在两个相邻的红色结点
- 从任意一个结点到其任何后代为null的结点 每条路径都有相等数量的黑色结点
几个问题:
1.红黑树与二叉平衡树的区别?
为什么hashmap选择用红黑树来优化链表而不是二叉排序树,二叉平衡树呢?首先先排除二叉排序树(搜索树) 我们都知道树高越低查询越快,二叉排序树极端情况可能会退化成一条链所以如果树能自平衡就好了,想到了平衡二叉树,平衡二叉树维护了高度平衡,为了保证高度平衡需要大量的旋转,效率上也就会降低。而红黑树保证了平衡(并不是高度平衡)的同时效率也相对较高。
2.为什么有红色和黑色结点?
红黑树的本质其实也是对概念模型:2-3-4树的一种实现,也就是4阶b树。但是b树并不好表现所以用二叉排序树加染色来模拟b树,其中红色结点的意义就是与黑色结点结合。
3.红黑树怎么保持平衡?
通过旋转和染色进行平衡