继续深入Java基础系列。今天是研究下哈希表,毕竟我们很多应用层的查找存储框架都是哈希作为它的根数据结构进行封装的嘛。
本系列:
文章结构:(1)哈希概述及HashMap应用;(2)HashMap源码分析;(3)再次总结关键点
一、哈希概述及HashMap应用:
根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。
对比:数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。哈希则是一种寻址容易,插入删除也容易的数据结构。
实际应用:
1.Hash主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128位的编码,这些编码值叫做Hash值. 也可以说,Hash就是找到一种数据内容和数据存放地址之间的映射关系
2.查找:哈希表,又称为散列,是一种更加快捷的查找技术。我们之前的查找,都是这样一种思路:集合中拿出来一个元素,看看是否与我们要找的相等,如果不等,缩小范围,继续查找。而哈希表是完全另外一种思路:当我知道key值以后,我就可以直接计算出这个元素在集合中的位置,根本不需要一次又一次的查找!
HashMap概述:
Hash表 是一种逻辑数据结构,HashMap是Java中的一种数据类型(结构类型),它通过代码实现了Hash表 这种数据结构,并在此结构上定义了一系列操作。
HashMap关键点罗列:
1.基于数组来实现哈希表的,数组就好比内存储空间,数组的index就好比内存的地址;
2.每个记录就是一个Entry 《K, V>对象,数组中存储的就是这些对象;
3.HashMap的哈希函数 = 计算出hashCode + 计算出数组的index;
4.HashMap解决冲突:使用链地址法,每个Entry对象都有一个引用next来指向链表的下一个Entry;(也就是链地址法)
但是!JDK1.8升级了!!
JDK1.8之前:使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)降低到O(n)。
在JDK1.8:为了解决在频繁冲突时hashmap性能降低的问题,使用平衡树来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)。
在Java 8中使用常量TREEIFY_THRESHOLD来控制是否切换到平衡树来存储。目前,这个常量值是8,这意味着当有超过8个元素的索引一样时,HashMap会使用树来存储它们。
这一动态的特性使得HashMap一开始使用链表,并在冲突的元素数量超过指定值时用平衡二叉树替换链表。不过这一特性在所有基于hash table的类中并没有,例如Hashtable和WeakHashMap。目前,只有ConcurrentHashMap,LinkedHashMap和HashMap会在频繁冲突的情况下使用平衡树。
5.装填因子:默认为0.75;
(加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 rehash 操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生 rehash 操作。)
6.继承于AbstractMap,实现了Map、Cloneable、java.io.Serializable接口。
7.不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
8.影响性能的因素:“初始容量” 和 “加载因子”
容量 是哈希表中桶的数量,初始容量 只是哈希表在创建时的容量。加载因子 是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。
HashMap基本操作:
public class TestHashMap {
public static void main(String[] args) {
HashMap<String, String> hashMap = new HashMap<String, String>();
hashMap.put("fu", "辅助");
hashMap.put("ad", "输出");
hashMap.put("sd", "上单");
System.out.println(hashMap);//toString重写了,所以可直接打出
System.out.println("fu:" + hashMap.get("fu"));//拿出key为fu的键值
System.out.println(hashMap.containsKey("fu"));//判断是否存在fu的键
System.out.println(hashMap.keySet());//返回一个key集合。
System.out.println("判空:"+hashMap.isEmpty());//判空
hashMap.remove("fu");
System.out.println(hashMap.containsKey("fu"));//判断是否存在fu的键
Iterator it = hashMap.keySet().iterator();//遍历输出值。前提先拿到一个装载了key的Set集合
while(it.hasNext()) {
String key = (String)it.next();
System.out.println("key:" + key);
System.out.println("value:" + hashMap.get(key));
}
}
}
二、HashTable重要的部分源码分析:(基于jdk1.8)
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16,必须是2的幂。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//!!Java 8 HashMap的分离链表。 在没有降低哈希冲突的度的情况下,使用红黑书代替链表。
/*
使用链表还是树,与一个哈希桶中的元素数目有关。下面两个参数中展示了Java 8的HashMap在使用树和使用链表之间切换的阈值。当冲突的元素数增加到8时,链表变为树;当减少至6时,树切换为链表。中间有2个缓冲值的原因是避免频繁的切换浪费计算机资源。
*/
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
//HashMap的一个内部类,实现了Map接口的内部接口Entry。Map接口的一系列方法
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//哈希值
final K key;
V value;
Node<K,V> next;//对下一个节点的引用(看到链表的内容,结合定义的Entry数组,哈希表的链地址法!!!实现)
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }//获取Key
public final V getValue() { return value; }//获取Value
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
// 存储数据的Node数组,长度是2的幂。
// HashMap采用链表法解决冲突,每一个Entry本质上是一个单向链表
transient Node<K,V>[] table;
//缓存我们装载的Node,每个结点。这也是跟keySet一样,可用于遍历HashMap。遍历使用这个比keySet是快多的喔,一会介绍并给例子。
transient Set<Map.Entry<K,V>> entrySet;
// HashMap的底层数组中已用槽的数量
transient int size;
// HashMap被改变的次数
transient int modCount;
// HashMap的阈值,用于判断是否需要调整HashMap的容量(threshold = 容量*加载因子)
int threshold;
// 加载因子实际大小
final float loadFactor;
/*
构造器
*/
public HashMap(int initialCapacity, float loadFactor) {
//初始容量不能<0
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//负载因子不能 < 0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
//当在实例化HashMap实例时,如果给定了initialCapacity,由于HashMap的capacity都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
//本质还是上面的构造器,只不过不选择加载因子而已
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//这里更是两个都不选,都选取默认的大小。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
//这个大概了解就是,可以用Map来构造HashMap咯
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
//那堆获取size,判空方法就不列了。
// 获取key对应的value
public V get(Object key) {
Node<K,V> e;
// 获取key的hash值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab;
Node<K,V> first, e;
int n;
K k;
// 在“该hash值对应的链表”上查找“键值等于key”的元素。也就是取出这个链表上,索引对应的值。
//这里一边判断一边赋值了,1.把要查的那一行table数组给到临时数组tab(那一行的table数组是通过hash计算得出的);2.把那一行的数组的第一个给到暂存结点first。(所谓第一个其实是单链表中头部,链表的头插法)
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 直接命中
if (first.hash == hash && // 每次都是校验第一个node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 未直接命中。
if ((e = first.next) != null) {
//如果已经变成红黑树存储方式了,当然用树的方式去查找。
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 遍历单链表,在链表中获取
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//什么贵都没找到
return null;
}
//红黑树查找法
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
//根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
/**
* 从根节点p开始查找指定hash值和关键字key的结点
* 当第一次使用比较器比较关键字时,参数kc储存了关键字key的 比较器类别
* 非递归式的树查询写法。。。有点复杂。
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;//一开始是根节点,然后遍历下去,表示当前结点
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h) //如果给定哈希值小于当前节点的哈希值,进入左节点
p = pl;
else if (ph < h)//如果大于当前节点,进入右结点
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk))) //如果哈希值相等,且关键字相等,则返回当前节点,终止查找。
return p;
else if (pl == null) //如果左节点为空,则进入右结点
p = pr;
else if (pr == null)//如果右结点为空,则进入左节点
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0) //如果不按哈希值排序,而是按照比较器排序,则通过比较器返回值决定进入左右结点
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)//如果在右结点中找到该关键字,直接返回
return q;
else
p = pl; //进入左节点
} while (p != null);
return null;
}
//同理推出是否包含某key的方法
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
/*
插入方法!!为了好理解,我们先去看下文讲的jdk1.7的put吧,这个jdk1.8的红黑树、链式的转换太复杂了,一会回来再看。
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
//判断table是否为空
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//创建一个新的table数组,用resize确定大小,并且获取该数组的长度
//根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {//如果对应的节点存在
Node<K,V> e; K k;
//判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 该链为链表,就用链地址法
else {
//遍历table[i],判断链表长度是否大于TREEIFY_THRESHOLD(默认值为8),大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//树转型
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;
}
}
// 写入覆盖
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;
}
/*
树形化总过程:遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容
但是我们发现,之前的操作并没有设置红黑树的颜色值,现在得到的只能算是个二叉树。在 最后调用树形节点 hd.treeify(tab) 方法进行塑造红黑树。红黑树的构造就看我github不久后的复习笔记吧。
*/
//如果冲突达到8位,就转树形结构,这就是转型的代码:
//将桶内所有的 链表节点 替换成 红黑树节点
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果当前哈希表为空,或者哈希表中元素的个数小于 进行树形化的阈值(默认为 64),就去新建/扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果哈希表中的元素个数超过了 树形化阈值,进行树形化
// e 是哈希表中指定位置桶里的链表节点,从第一个开始
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;//红黑树的头、尾节点
do {
//新建一个树形节点,内容和当前链表节点 e 一致
TreeNode<K,V> p = replacementTreeNode(e, null);
//确定树头节点
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//让桶的第一个元素指向新建的红黑树头结点,以后这个桶里的元素就是红黑树而不是链表了
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
/*
扩容机制,也是确定容量的方法
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
*/
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);
}
// 计算新的resize上限
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) {
// 把每个bucket都移动到新的buckets中
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 { // 原索引+oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {// 原索引放到bucket里
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) { // 原索引+oldCap放到bucket里
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
/*
删除:
1.计算哈希值找到对应的桶
2.在桶中寻找,当然查之前要判断桶类型是红黑树还是链表
3.找到只会就删除嘛,当然也要对应桶类型
*/
public V remove(Object key) {
Node<K,V> e;
//查找到则返回该结点,没有则返回null
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;
}
/*
清空:遍历所有桶的所有元素并至null
*/
public void clear() {
Node<K,V>[] tab;
modCount++;
if ((tab = table) != null && size > 0) {
size = 0;
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
}
jdk1.8的扩容:
使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”.
既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
对比jdk1.7:插入和扩容方法
说明:jdk1.7查询是的计算哈希,遍历单链表。因为它们怎么改结构,本质还是个哈希。而jdk1.8在冲突小于8的时候是像1.7的查询方式,但大于8的时候,就会改成红黑树,使用二叉查找树的结构性查询方式了,并且jdk1.8的插入涉及的存储结构的转换,从链地址法冲突处理到8位后就改成红黑树存储。
来看下1.7的插入和扩容机制源码,简单得不行。
/*
可以看到很简单的步骤:1.计算哈希,找到对应的Entry,;2.插入单链表到头部
*/
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);//注意,HashMap可以存放null的key!!当然只能存一个!下面我们来看看,怎么放null的
int hash = hash(key);
int i = indexFor(hash, table.length);//计算哈希
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//计算的哈希值锁定对应的Entry并且遍历单链表
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;
}
private V putForNullKey(V value) {
//nullkey的话,就锁定第一个Entry了,遍历单链表找到插入位置先
//如果找到了e.key==null,就保存null值对应的原值oldValue,然后覆盖原值,并返回oldValue
//如果在table[0]Entry链表中没有找到就调用addEntry方法添加一个key为null的Entry
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);//添加
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//判断是否要扩容.hashmap每次扩容的大小为2倍原容量,默认容量为16,hashmap的capacity会一直是2的整数幂。
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
//扩容
void resize(int newCapacity) {//传入新的容量
Entry[] oldTable = table;//引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {//扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //这样的话就修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity];//初始化一个新的Entry数组
transfer(newTable, initHashSeedAsNeeded(newCapacity));//将数据转移到新的Entry数组里,并判断是否需要更新哈希值
table = newTable;//HashMap的table属性引用新的Entry数组
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阈值
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//根据put中锁定的Entry去遍历
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {//要不要重新分配hash
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//重新计算每个元素在数组中的位置,找到存放在新数组中的位置,再放置
e.next = newTable[i];//标记
newTable[i] = e; //将元素放在数组上
e = next;//访问下一个Entry链上的元素
}
}
}
找了一张好图,说明JDK1.7的。原文
假设原来大小只是2,那么扩容根据2的次幂,就是到4了。然后重新计算分配hash,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。所有的Node重新rehash的过程。
三、再次总结关键点:
(1)实现了Map、Cloneable、java.io.Serializable接口。意味着支持全部map操作,支持被克隆,支持序列化,能通过序列化去传输。
(2)HashMap的函数则是非同步的,它不是线程安全的。
若要在多线程中使用HashMap,需要我们额外的进行同步处理。 对HashMap的同步处理可以使用Collections类提供的synchronizedMap静态方法,或者直接使用JDK 5.0之后提供的java.util.concurrent包里的ConcurrentHashMap类。
(3)HashMap的key、value都可以为null。
HashMap的key、value都可以为null。 当HashMap的key为null时,HashMap会将其固定的插入table[0]位置(即HashMap散列表的第一个位置);而且table[0]处只会容纳一个key为null的值,当有多个key为null的值插入的时候,table[0]会保留最后插入的value。
(4)HashMap只支持Iterator(迭代器)遍历。是“从前向后”的遍历数组;再对数组具体某一项对应的链表,从表头开始进行遍历。
方式是:1.通过entrySet()获取“Map.Entry集合”。2. 通过iterator()获取“Map.Entry集合”的迭代器,再进行遍历。
public class TestHashMap {
public static void main(String[] args) {
HashMap<String, String> hashMap = new HashMap<String, String>();
for (int i = 0; i < 100000; i++) {
hashMap.put(String.valueOf(i), "fuzhu");
}
Iterator it = hashMap.keySet().iterator();//遍历输出值。前提先拿到一个装载了key的Set集合
long start = System.nanoTime();
while(it.hasNext()) {
String key = (String)it.next();
//System.out.println("key:" + key);
//System.out.println("value:" + hashMap.get(key));
}
long time = System.nanoTime() - start;
long start2 = System.nanoTime();
Iterator it2 = hashMap.entrySet().iterator();
while(it2.hasNext()) {
Map.Entry key = (java.util.Map.Entry)it2.next();
//System.out.println("key:" + key);
// System.out.println("value:" + hashMap.get(key));
}
long time2 = System.nanoTime() - start2;
System.out.println("测试耗时1!!!!"+time);
System.out.println("---------------------------------");
System.out.println("测试耗时2!!!!"+time2);
}
}
/*
最终结果:
如果注释部分不执行,keySet方式是比entrySet快得多(对应下图1),但是注释部分执行时,也就是真正的遍历取值时,entrySet比keySet快得多(对应下图2)
*/
//原因:keySet方式拿到的是装载String的set(需要再次转换拿到key),而entrySet方式拿到的是装载Map.Entry类型的set,无须再次转换,直接getvalue
图1
图2
(5)HashMap的工作原理:
通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket桶位置,然后根据TREEIFY_THRESHOLD判断桶装载的类型,红黑树还是链表,再进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,然后根据TREEIFY_THRESHOLD判断桶装载的类型,并进一步调用equals()方法确定键值对。
在Java 8中,如果一个bucket桶中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
(6)equals()和hashCode()的都有什么作用?
通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点
因为hashcode相同,所以它们的bucket位置相同,‘碰撞冲突’会发生。因为HashMap使用链表或红黑树存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表或红黑树中。
找到bucket桶位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。因此,设计HashMap的key类型时,如果使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。
(7)HashMap添加元素时,是使用自定义的哈希算法。HashMap不支持contains(Object value)方法,没有重写toString()方法。
(8)HashMap的大小超过了负载因子(load factor)定义的容量,会怎样?
超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,jdk1.7会重新计算哈希值,但是jdk1.8很少重新计算,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。
(9)重新调整HashMap大小出现的线程问题:
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。因此在并发环境下,我们使用CurrentHashMap来替代HashMap
(10)为什么String, Interger这样的wrapper类适合作为键?
因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能
(11)使用的取舍:
1. 若在单线程中,我们往往会选择HashMap;而在多线程中,则会选择Hashtable。
2.若不能插入null元素,则选择Hashtable;否则,可以选择HashMap。
3.在多线程中,我们可以自己对HashMap进行同步,也可以选择ConcurrentHashMap。当HashMap和Hashtable都不能满足自己的需求时,还可以考虑新定义一个类,继承或重新实现散列表;当然,一般情况下是不需要的了。
好了,深入Java基础(四)--哈希表(1)HashMap应用及源码详解讲完了。本博客系列是这个系列的第五篇,阅读这些源码收获很大,当然过程也是很有点苦逼的,比如这篇我之前不小心没保存,前前后后4天才写完,当然也不后悔,这是积累的必经一步,我会继续出这个系列文章,分享经验给大家。欢迎在下面指出错误,共同学习!!你的点赞是对我最好的支持!!