HashMap数据结构
HashMap是一种散列表
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
HashMap数据的数据结构:数组 + 链表 + 红黑树(jdk1.8)。jdk1.7是用的数组+链表,1.8增加了链表和树的转换。
简单操作流程:当设置值时,把传入的key(key.hashCode())通过hash算法算出这个key在数组哪个下标位置,然后判断此位置的元素的key是否与传入的key相等(“newKey”.equals(“oldKey”)),相等则代表是该元素返回,如果不是,则走链表或者红黑树的查询其余节点逻辑,继续判断key是否相等。所以我们能影响到HashMap的内部源码的逻辑的只有传入key的hashCode()方法和equals()方法,如果有特殊需求需要实现,可以重写hashCode()和equals()进行取值判断。
需要了解的知识点:链表、红黑树。这里不做介绍,只分析HashMap源码和逻辑
HashMap中属性值介绍
- DEFAULT_INITIAL_CAPACITY :默认容量值,HashMap中数组的大小,默认16
- MAXIMUM_CAPACITY:数组的最大值,扩容的时候来限制不能超过最大值,默认1073741824
- DEFAULT_LOAD_FACTOR:默认负载因子,扩容需要用到的值,可以理解为一个百分比,数组用到的容量超过这个会触发扩容操作,默认0.75
- TREEIFY_THRESHOLD:树化阈值,当链表长度超出这个阈值,可能会(转换红黑树还受MIN_TREEIFY_CAPACITY限制)转换为红黑树,默认8
- UNTREEIFY_THRESHOLD:树降级阈值,当树的长度小于这个值,红黑树会退货为链表,默认6 。(树化和树降级的阈值相差2,为了防止频繁的树化和退化,影响性能。)
- MIN_TREEIFY_CAPACITY:树化的阈值,当HashMap中的数组长度大于该值,才允许转换为红黑树。默认64
HashMap内的字段
- size:hash表中元素的个数
- modCount:hash表被操作的次数(插入和删除,插入相同key不算)
- threshold:扩容阈值(扩容阈值 = 数组长度 * 负载因子(DEFAULT_LOAD_FACTOR)),当hash表中长度超过扩容阈值触发扩容
- loadFactor:负载因子,创建HashMap如果没传入负载因子直接等于默认的负载因子DEFAULT_LOAD_FACTOR
HashMap构建方法及使用场景
HashMap提供的4中构造方法
-
HashMap(int initialCapacity, float loadFactor):自定义数组大小和负载因子
自定义数组大小和负载因子,不建议使用,正常需求情况下没必要去传入它的负载因子。当然如果有特殊需求,不想让他超过默认的负载因子就进行扩容可以用这个构造方法。
/** * initialCapacity初始化数组大小 * loadFactor 负载因子 默认0.75f */ public HashMap(int initialCapacity, float loadFactor) { //数组大小不可以小于0 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; //数组的长度必须是2的多少次方,tableSizeFor方法把传进来的数组处理 this.threshold = tableSizeFor(initialCapacity); }
-
HashMap(int initialCapacity):传入数组大小
这个方法如果熟悉HashMap应该经常被使用到,因为有些需求可能已经明确了会存入多少个key,或者预计有多少个key,也有可能有一些大量数据需要放入一个HashMap中然后通过key去判断是否存在,把初始化数组大小传入估计值,可以避免频繁扩容,影响性能。
/** * initialCapacity 初始化数组大小 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
-
HashMap():不传入任何参数,一切使用默认值
这个经常使用到,没什么好说的。
/** * 不传任何参数,所有属性用默认值 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
-
HashMap(Map<? extends K, ? extends V> m):根据一个Map去构建一个Map
根据一个已有map去构建一个新的map,底层逻辑就是去循环已有map的所有值放入新的map中,不建议使用,遍历的时候如果达到扩容阈值还会有扩容的操作,不如自己写一个。
/** * 根据一个Map去构建一个Map */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; //遍历设置值 putMapEntries(m, false); }
小结:HashMap在构建的时候,并没有去创建里面的散列表,仅仅是创建了一个对象,设置了操作中用到的值。创建方法4是例外,它本身就是在创建的时候去设置之前map的值了。
HashMap中的put方法介绍
HashMap在调用put方法时会判断散列表是否被创建,如果没被创建则会调用扩容方法去创建一个散列表。创建完散列表后(也可能没走创建逻辑)根据HashMap的哈希算法算出数组的下标位置,如果下标位置为null,则把当前节点封装成Node对象(链表)存入该位置。如果数组下标位置不为null(哈希冲突导致),则判断是否为红黑树(p instanceof TreeNode),如果是红黑树,去走红黑树取结点的逻辑,取出传入的key对应的红黑树中的结点,如果不是红黑树则走链表逻辑取出传入的key对应链表的结点,在链表逻辑中会判断结点是否大于等于树化阈值和数组长度大于64,如果成立则要进行树化,然后根据取出的结点,去设置新值,返回旧值。如果是新节点插入(没有与新传进来的key相等),会判断数组大小是否大于扩容阈值,如果大于,进行扩容操作。
@Override
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
/**
* put方法,算出hash值调用putVal
* 与putIfAbsent比较 传入的onlyIfAbsent不同
*/
public V put(K key, V value) {
//(hash(key)算出hash值,
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent 如果传true,则key有值不设置值
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab : 散列表的引用
//p : 设置值时,通过hash值找到的数组位置的Node
//n : 散列表数组长度
//i : 路由寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table为null或者长度为0则去初始化散列表。第一次往HashMap中put数据时,才会初始化散列表
if ((tab = table) == null || (n = tab.length) == 0)
//创建散列表
n = (tab = resize()).length;
//如果put值的地方为null,则直接将传进来的key,val构建一个Node,tab[i] 指向新创建的Node
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
/*put值的位置不为null的情况*/
//e : 数组中寻到node结点。不为null,表示hash值的数组位置已经有元素
//k : 在数组中寻到结点的key
Node<K,V> e; K k;
//如果hash值相等,切key值相等,则直接e指向p。(链表头结点或者树的跟结点就是要操作的位置)
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果p不等第一个元素,则判断是否是红黑树,是的话走红黑树的逻辑
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//链表的情况,且链表的头元素与插入的元素不一致,循环链表
for (int binCount = 0; ; ++binCount) {
//如果链表下一个元素为null,说明到链表末尾,创建一个新的Node加到末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果元素大于等于树化的值,转为红黑树。从0开始循环,所以TREEIFY_THRESHOLD - 1
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果找到了hash值一样,切key相同的元素,跳出循环,执行下面替换逻辑
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e不为null,说明HashMap里已经有该key的值,这里做替换值,和返回之前的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//空方法,可以继承重写此方法
return oldValue;
}
}
//modCount表示散列表结构被修改(插入、删除)次数,替换元素不算
++modCount;
//如果散列表元素个数大于扩容的阈值,则进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);//空方法,可以继承重写此方法
//插入返回null
return null;
}
/*
* 作用:让key的hash值的高16位也参与路由运算
*/
static final int hash(Object key) {
int h;
//key是null时,hash值为0,在数组第一位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
put方法小结:HashMap调用put方法是会初始化HashMap的散列表。HashMap的put方法,是通过key的hash值算出所在数组下标,如果有元素则去判断key值是否相等,有元素的情况是因为哈希冲突。插入新元素会根据扩容阈值判断是否需要对HashMap中的散列表进行扩容。
HashMap中get方法介绍
HashMap中的get方法,获取key的value值,通过key算出hash值,然后算出所属的数组位置,获取的该数组位置的元素,如果元素的key值等于传入的key值,则返回该元素,如果key值不相等则判断有下一个结点么,然后判断是红黑树走红黑树获取结点的逻辑,是链表走链表获取结点的逻辑,本质上与put差不多。
/**
* 算出key的hash值调用getNode方法
*/
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
//tab:引用当前hashMap的散列表
// first:散列表位置元素的引用
// e:临时引用
// n:数组长度(n = tab.length)
//k;;临时引用key
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判断散列表不为空,然后根据key算出的hash值找到散列表中的第一个元素(这个元素可能是链表或者红黑树)
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//第一种情况:散列表中对应下标位置的元素第一个元素(链表或树的第一元素)为想要的元素(key.equals(k)),直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//第二种情况:当前数组下标位置不为一个元素且第一个元素的key与找出来的node元素不相等
if ((e = first.next) != null) {
//如果是红黑树,走红黑树逻辑
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//不是红黑树就是链表jdk1.8,链表下一个元素不为null,一直循环查找,找到返回,未找到最后返回null
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
HashMap中扩容介绍
扩容的目的:为了防止hash冲突太多,链化严重影响查询效率,所以要进行扩容
扩容代码逻辑总共分为两部分:
- 计算扩容后的散列表大小和扩容阈值
- 扩容后把旧散列表数组赋值到新散列表上
/**
* 为了防止hash冲突太多,链化严重影响查询效率,所以要进行扩容
*/
final Node<K,V>[] resize() {
//oldTab扩容之前的哈希表
Node<K,V>[] oldTab = table;
//扩容之前的数组大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//触发本次扩容的阈值
int oldThr = threshold;
//newCap代表扩容之后数组大小,newThr扩容之后,下次扩容的阈值
int newCap, newThr = 0;
/*------------------------计算扩容后的散列表大小和扩容阈值---------------------------------*/
//oldCap说明散列表已经初始化过,正常的扩容操作
if (oldCap > 0) {
//如果散列表数组大小已经是最大值,则不进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//oldCap左移移位,实现数值翻倍,并赋值给新的数组大小newCap
//如果新的数组大小,小于最大数组大小,切之前数组大小大于等于默认大小,则数组新的扩容阈值左移一位翻倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
/**
*oldCap == 0 且 oldThr有值的情况
* 1.new HashMap(initCap,loadFactor); 2.new HashMap(initCap);3.new HashMap(map);(map有数据)
*/
//oldCap == 0时(HashMap散列表未被初始化)
else if (oldThr > 0) // initial capacity was placed in threshold
//扩容后散列表大小的值等于阈值
newCap = oldThr;
else {
//oldCap == 0 且 oldThr == 0时。new HashMap()时有这种情况
//扩容散列表大小、扩容阈值设置成默认值
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;
//之前的数组不为null,进行扩容
if (oldTab != null) {
//循环扩容之前的数组
for (int j = 0; j < oldCap; ++j) {
//临时存储元素,指向被操作的Node
Node<K,V> e;
//当前数组下标位置有元素是向下操作,e指向当前操作的元素
if ((e = oldTab[j]) != null) {
//将数组对元素的引用设为null,循环结束方便jvm垃圾回收
oldTab[j] = null;
if (e.next == null)
//如果e的下个元素为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;
}
小结:HashMap扩容主要是为了防止哈希冲突过多,影响查询效率,有两个阈值影响HashMap的扩容,只有两个阈值条件都成立,才会触发扩容。扩容的逻辑就是,创建一个比之前大一倍的数组,将旧数组中的元素,放到新数组中对应的位置。
HashMap小结
HashMap中的操作,主要是对数组、链表、红黑树的操作。数组的性质就是可以通过下标直接得到元素,时间复杂度为O(1),所以通过key的hash值算出数组所在位置速度很快,而且散列表插入的时候是插入到已有的位置,当位置存在元素会存成链表的形式(jdk1.7只有链表),这样就解决了哈希冲突的位置,而对链表就行插入和删除的时间复杂度都是O(1),只需要改变前结点指针所(java中的引用)指向的位置。因为链表查询元素的时间复杂度是O(n),所以jdk1.8链表超过阈值会转成红黑树优化查询效率。个人理解。