前言:数月前的思必驰电话面试中就问到了HashMap,当时问的是HashMap和HashTable的区别,今天来研究一下HashMap的原理(全文以jdk1.8的HashMap为讨论对象,之前的版本不做研究,有时间博主再补充)
底层实现:
数组+链表+红黑树
HashMap的主干是一个Node数组。Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对。也有叫做bucket(桶)的,但是个人感觉后者更形象一些。
在jdk1.8及以后,当一个bucket中的链表长度大于8时,链表结构会自动转换为红黑树结构。而红黑树查找、插入、删除的时间复杂度最坏为O(log n),单链表的话就是O(n)。数学函数图
在链表长度如果是小于等于6,虽然时间复杂度是O(n),但是此时查找速度也很快的,而且最重要的是转化为树结构和生成树会消耗一定时间。
hashmap图示:
当size超过8时转换为红黑树结构
我们知道,一般解决哈希冲突的三种办法:
(1):开放定址法
(2):拉链法
(3):再散列法
当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是的哈希冲突,
那么HashMap采用的就是第二种方法,经计算得到的hash值相同的话放到一个“拉链”里
hash算法源码附上:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这~~就涉及到我的知识盲区了,搜索一番:
得出结论:如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征,继而导致hash碰撞,也就是这个操作是为了减少hash碰撞的
首先看看这两者的使用场景:
2者都是有序数据结构,可用作数据容器。红黑树多用在内部排序,即全放在内存中的。B树多用在内存里放不下,大部分数据存储在外存上时才采用的。因为B树层数少,因此可以确保每次操作,读取磁盘的次数尽可能的少。
在数据较小,可以完全放到内存中时,红黑树的时间复杂度比B树低。反之,数据量较大,外存中占主要部分时,B树因其读磁盘次数少,而具有更快的速度。
我们知道,在负载因子为0.75时,链长度大于8的概率为百万分之六,意思就是绝大部分情况下是到不了红黑树这里的,因为hash函数理想状态下应该是散列的,即成均匀分布。
然后再结合上面的应用场景,因为B+树适合大数据量的情况,而本身链表长度大于8的概率就已经微乎其微,所以,我们犯不上去使用一个时间复杂度高的B+。
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
很明显,源码附上
key的hashcode值和value的hashcode的值进行或与运算
红黑树的颜色是保证红黑树查找速度的一种方式,从任意的节点开始到叶节点的路径,黑节点的个数是相同的,这就能保证搜索路径的最大长度不超过搜索路径的最短长度的2倍
//默认起始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大扩容数量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子,代表了table的填充度有多少,默认是0.75,请注意看这一行
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//这俩数就是上面提到的8和6,转换为树和链表的阈值(临界值)
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
//最小树形化容量阈值
static final int MIN_TREEIFY_CAPACITY = 64;
这里博主根据这几个值还发现了几个问题
经过思考和一些参考,我们可以得出以下结论:
首先,我们上面提到了解决hash冲突的方法:拉链法,也就是在理想情况下,经过hash计算的每一个元素都会均匀地分布在每一个Node数组(bucket)里面,但是,假如我现在是0.75的扩容因子,先看以下源码里面的泊松分布的值
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
那么就是当桶中元素到达8个的时候,概率已经变得非常小,每个碰撞位置的链表长度超过8个是几乎不可能的。因为越长的话操作起来越难。
那么假如我的扩容因子为0.95呢? 也就是平均20个桶里面只有1个是空的,那么就是这个代价是相当大的,就相当于是hash碰撞的特别特别厉害的时候才会出现这种情况,数组中的链表也就越容易长,而这种情况的出现会使get等操作效率大大降低!
那么假如负载因子是0.6或者更小呢? 你这个杠精,负载因子小不就扩容的次数越多吗?那扩容他不需要占用资源啊?过来挨打,所以选择0.75是一个这种的办法,而且是一种用空间换取时间的考虑。
根据泊松分布,在负载因子默认为0.75的时候,通过泊松分布看出,当桶中结点个数为8时,出现的几率是亿分之6的(源码为我们算出来了),因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,而转化为树还需要时间和空间,所以此时没有转化成树的必要。
我的理解很鸡肋,就是这是一个经验值,即在这个值下既能保证碰撞的次数比较小,而又保证空间不被浪费。
答:为了减少哈希碰撞的几率,选择了hash算法能让元素比较平衡的放到不同的桶中,而hash算法使用了位与&运算符。源码中使用了tab[i = (n - 1) & hash]。
当n=2时,n-1的二进制的后几位全是1,这时与操作更均匀。即更加均匀的让每一个bucket里面的size相同。
HashMap的默认构造器:
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);
}
好的,再往下,我们讨论一下HashMap的几个常见操作:
get
public V get(Object key) {
if (key == null)
return getForNullKey();
//计算hash值
int hash = hash(key.hashCode());
//先定位到数组元素,再遍历该元素处的链表
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.equals(k)))
return e.value;
}
return null;
}
put源码放上之前,有必要说一个知识点就是:HashMap是非线程安全的
3. 问题3 为什么HashMap是非线程安全的? 这个非安全的原因无非是并发下的put和扩容和删除数据即对数据的操作造成的,下面先看源码:
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(24=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++;
addEntry(hash, key, value, i);
return null;
}
我们知道:当发生 hash 冲突的时候,HashMap 是采用链表的方式来解决的,在对应的数组位置存放链表的头结点。对链表而言,新加入的节点会从头结点加入。
现在假如 A 线程和 B 线程两个线程同时进行插入操作,然后计算出了相同的哈希值对应了相同的数组位置,因为此时该位置还没数据,然后对一个数组同一个位置,两个线程会同时得到现在的头结点,然后 A 写入新的头结点之后,B 也写入新的头结点,那B的写入操作就会覆盖 A 的写入操作造成 A 的写入操作丢失,即put造成的非线程安全。
说扩容之前先看几个重要参数:
//默认起始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大扩容数量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子,代表了table的填充度有多少,默认是0.75,请注意看这一行
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//这俩数就是上面提到的8和6,转换为树的阈值(临界值)
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
//最小树形化容量阈值
static final int MIN_TREEIFY_CAPACITY = 64;
扩容代码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
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;
}
}
}
}
}
return newTab;
}
其中!多个线程同时操作,检测到总数量超过门限值的时候就会同时调用 resize 操作,各自生成新的数组并 rehash 后赋给该 map 底层的数组,结果最终只有最后一个线程生成的新数组被赋给该 map 底层,其他线程的均会丢失。A和B两个人同时对一个map进行扩容,A需要1000容量大小map在先,而B需要100大小的map,那么就会造成A的扩容结果失败。
这里有必要说一下就是:在jdk1.7的时候,HashMap解决hash冲突的时候采取的是头插法,这样在并发下,会造成
源码:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
同上面两个操作,当多个线程同时操作同一个数组位置的时候,也都会先取得现在状态下该位置存储的头结点,然后各自去进行计算操作,之后再把结果写会到该数组位置去,其实写回的时候可能其他的线程已经就把这个位置给修改过了,就会覆盖其他线程的修改。
并发情况下要实现线程安全,可以采用:
因为HashTable操作十分繁重,每个线程,每个操作都用synchronize(悲观锁),以后博主会出一篇博客和大家一块研究一下ConcurrentHashMap ,其实主要的是jdk1.7ConcurrentHashMap用的是分段锁+volatile关键字来保持其内存可见性,而jdk1.8用的是CAS操作(乐观锁)+synchronize关键字。
是不是很狗血但是就是作者不同啊
很明显HashMap符合驼峰命名法,Hashtable不符合,我没有打错字!
Hashtable
public class Hashtable
extends Dictionary
implements Map, Cloneable, java.io.Serializable
HashMap
public class HashMap
extends AbstractMap
implements Map, Cloneable, Serializable
Hashtable是在构造函数初始化,而HashMap是在第一次put()初始化hash数组。
Hashtable
//HashTable构造器
public Hashtable(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal Load: "+loadFactor);
if (initialCapacity==0)
initialCapacity = 1;
this.loadFactor = loadFactor;
table = new Entry[initialCapacity];//初始化Hash数组
threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
initHashSeedAsNeeded(initialCapacity);
}
HashMap
//hashMap的put函数
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);//初始化Hash数组
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
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++;
addEntry(hash, key, value, i);
return null;
}
在HashTable中,hash数组默认大小是11,增加的方式是原来的2 + 1。在HashMap中,hash数组默认大小是16,增加的方式是2原来的而且一定是2的整数(这个在前面有说过)。
HashMap允许空键值,而HashTable不允许。所以我们在使用HashMap get到的键或者值为null的时候,不能判断该键值不存在!
即计算数组下角标方式不同
Hashtable:
int hash = key.hashCode();
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
//注意这里是直接调用的Object超类里面的hashCode
HashMap:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap把Hashtable的contains()方法去掉了,改成了containsvalue()和containsKey()。
我就不列出代码了有点多了
Hashtable的方法是线程安全的,而HashMap不支持线程的同步,不是线程安全的。
Hashtable使用Enumeration,HashMap使用Iterator。这个是快速失败的(fail-fast)还有一种失败方式是安全失败(fail-safe)值得一提的是java.util包下面的所有的集合类都是快速失败的,而java.util.concurrent包下面的所有的类都是安全失败的
快速失败和安全失败是对迭代器而言的。并发环境下建议使用java.util.concurrent 包下的容器类,除非没有修改操作。