一、HashMap集合简介
- HashMap是基于哈希表的 Map接口实现,以key-value结构存储,主要存放键值对。不是线程安全,key、value都可以为null,HashMap是无序的。
- JDK7 HashMap由 数组 + 链表组成,Entry数组是HashMap的主体,链表主要是为了解决哈希冲突
- JDK8 HashMap由 数组 + 链表 + 红黑树组成,主要由Node节点组成,链表转成红黑树的条件是 链表长度大于8并且数组长度大于等于64同时满足时,此时才会改为红黑树存储
哈希冲突:两个对象调用的 hashCode 方法计算的哈希值经哈希函数算出来的地址被别的元素占用)而存在的(“拉链法”解决冲突)
将链表转换成红黑树前会判断,即便链表长度大于 8,但是数组长度小于 64,此时并不会将链表变为红黑树,而是选择逬行数组扩容。
这样做的目的是因为数组比较小,尽量避开红黑树结构,这种情况下变为红黑树结构,反而会降低效率,因为红黑树需要逬行左旋,右旋,变色这些操作来保持平衡。同时数组长度小于64时,搜索时间相对要快些。具体可参考treeifyBin() 方法。链表长度小于6时则会从红黑树转回链表
二、HashMap 1.7源码分析
首先查看hashmap中重要属性:
static final int DEFAULT_INITIAL_CAPACITY = 16;//哈希表主数组的默认长度16
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认的负载因子0.75
transient Entry<K,V>[] table; //主数组,每个元素为Entry类型 transient int size; //接口中元素个数 默认0 int threshold;//数组扩容的界限值,门槛值 16*0.75=12
final float loadFactor;//用来接收负载因子的变量
hashmap无参构造器
public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); //this(16,0.75f) }
hashmap带参构造器:对值进行初始化
public HashMap(int initialCapacity, float loadFactor) {
//给capacity赋值,capacity的值一定是 大于你传进来的initialCapacity 的 最小的 2的n次幂
int capacity = 1;
while (capacity < initialCapacity) //while循环跳出 capacity为 16
capacity <<= 1;
//给loadFactor赋值,将装填因子0.75赋值给loadFactor
this.loadFactor = loadFactor;
//数组扩容的界限值,门槛值 12
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//给table数组赋值,初始化数组长度为16
table = new Entry[capacity];
}
put方法
public V put(K key, V value) {
//对空值的判断 这里也证明hashmap key是可以为空的
if (key == null)
return putForNullKey(value);
// 调用hash方法,获取哈希码
int hash = hash(key);
// 得到key对应在数组中的位置
int i = indexFor(hash, table.length);
//如果你放入的元素,在主数组那个位置上没有值,e==null 那么下面这个循环不走
//当在同一个位置上放入元素的时候 e!= null 代表已经有元素(也就是发生哈希碰撞)
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//哈希值一样 (k = e.key) == key 如果是一个对象就不用比较equals了
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
//获取老value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
//返回oldValue
return oldValue;
}
}
modCount++;
// 走addEntry添加这个节点的方法:
addEntry(hash, key, value, i);
return null;
}
hash方法返回key对应的哈希值,内部进行二次散列,尽量保证不同的key得到不同的哈希码
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
//k.hashCode()函数调用的是key键值类型自带的哈希函数,
//由于不同的对象其hashCode()有可能相同,所以需对hashCode()再次哈希,以降低相同率。
h ^= k.hashCode();
/*
接下来的一串与运算和异或运算,称之为“扰动函数”,
扰动的核心思想在于使计算出来的值在保留原有相关特性的基础上,
增加其值的不确定性,从而降低冲突的概率。
不同的版本实现的方式不一样,但其根本思想是一致的。
往右移动的目的,就是为了将h的高位利用起来,减少哈西冲突
*/
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
返回int类型数组的坐标(得到key在对应数组中的位置)
static int indexFor(int h, int length) {
//其实这个算法就是取模运算:h%length,但取模效率不如位运算
return h & (length-1);
}
调用addEntry方法
void addEntry(int hash, K key, V value, int bucketIndex) {
//size的大小 大于 16*0.75=12的时候,比如你放入的是第13个,这第13个你打算放在没有元素的位置上的时候
if ((size >= threshold) && (null != table[bucketIndex])) {
//主数组扩容为2倍
resize(2 * table.length);
// 重新调整当前元素的hash码
hash = (null != key) ? hash(key) : 0;
// 重新计算元素位置
bucketIndex = indexFor(hash, table.length);
}
//将hash,key,value,bucketIndex位置 封装为一个Entry对象:
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
// 获取bucketIndex位置上的元素给e
Entry<K,V> e = table[bucketIndex];
//然后将hash, key, value封装为一个对象,然后将下一个元素的指向为e (链表的头插法)
// 将新的Entry放在table[bucketIndex]的位置上
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 集合中加入一个元素 size+1
size++;
}
JDK7 HashMap基本原理总结
在hashmap中底层主数组是Entry[]类型数组,长度为16,默认负载因子是0.75,(取值0.75与泊松分布有关,空间和时间平衡) 数组扩容的界限值 为16*0.75 = 12,扩容新数组的长度为原长度的2倍 put方法过程中,会首先通过hash方法获取它的哈希码 (hash方法中返回key对应的哈希值,内部进行了二次散列,并保证不同的key得到不同的哈希码,再者不同对象的hashcode有可能相同,所以需对hashcode进行再次哈希,以降低相同率,内部使用一串与运算和异或运算(也被称之为扰动函数,也就是增加值得不确定性,从而来降低冲突的概率)), 然后要得到key在对应数组中的位置(内部通过位运算实现),后面添加元素到集合中
三、HashMap 1.8源码分析
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //认初始容量 - 必须是 2 的幂,默认16
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量(2的幂按位计算,就是1后跟0)
static final float DEFAULT_LOAD_FACTOR = 0.75f; //构造函数中未指定负载因子时,默认使用的负载因子。
static final int TREEIFY_THRESHOLD = 8; //链表元素超过8个,才使用红黑树结构
static final int MIN_TREEIFY_CAPACITY = 64; //数组大于等于64个,才使用红黑树结构
static final int UNTREEIFY_THRESHOLD = 6; //红黑树元素小于6个,就转化为链表
transient Node<K,V>[] table; //表在第一次使用时初始化,并根据需要调整大小。(jdk8之前是Entry对象)
transient int size; //键值映射数
transient int modCount; //HashMap 被结构修改的次数
final float loadFactor; //哈希表的加载因子。
int threshold; //整大小的下一个大小值(容量 * 负载因子)。
put方法
- 把k,v键值对封装到Node节点中
- 底层会调用k的hashcode方法来得出hash值
- 通过hash算法将hash值转换为数组的下标, 下标的位置上如果没有任何元素, 就会把Node 添加到这个位置上 。 如果下标对应的位置上存在链表的话, 此时就会拿着k和链表上每个节点的k进行equals比较, 如果equals方法比较返回是false, 那么新的节点将会添加到链表的末尾。如果其中有一个equals返回了true, 那么这个节点的value将会被覆盖
get方法
- 先调用k的hashcode方法算出hash值,然后通过哈希算法算出数组的下标
- 获取到数组的下标之后,再通过下标快速定位到某个位置上 如果位置上什么都没有, 则返回null
- 如果这个位置上有单向链表, 此时会拿着k和单向链表上的每一个节点的k进行equals, 如果所有的equals方法都返回false的话, get方法则返回null
- 如果其中一个节点的k和参数k进行equals返回true, 那么此时直接返回该节点的value
扩容机制
- HashMap 刚new出来时还没有put元素进去, 没有真正分配存储空间被初始化, 调用resize()函数进行初始化
- 原table中的元素个数达到了capacity * loadFactor这个上限后, 就需要扩容。 此时调用 resize(), 会new一个两倍长度的新Node数组, 并将容器指针(table) 指向新数组 ,并返回 hashmap初始化首次插入数据时, 先resize扩容在插入数据, 之后每当插入的数据个数达到threshold**(阈值)**时就会发生resize,此时是先插入数据再resize。 每次扩容长度为原长度的2倍 扩容方法
/** 初始化或增加表大小。 如果为空,则根据字段阈值中保持的初始容量目标进行分配.
* 否则,因为我们使用的是2的幂,所以每个bin中的元素必须保持相同的索引,或者在新表中以2的幂偏移。
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首次初始化后table为Null
int oldCap = (oldTab == null) ? 0 : oldTab.length;//获取原哈希表容量 如果哈希表为空则容量为0 ,否则为原哈希表长度
int oldThr = threshold;//获取原哈希表扩容门槛,默认构造器的情况下为0
int newCap, newThr = 0;//初始化新容量和新扩容门槛为0
//如果原容量大于 0,这个if语句中计算进行扩容后的容量及新的负载门槛
if (oldCap > 0) {
//判断原容量是否大于等于HashMap允许的容量最大值 2的30次幂
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;//满足则把当前HashMap的扩容门槛设置为Integer允许的最大值
return oldTab;//不再扩容直接返回
}
/**
* newCap = oldCap << 1 ; 类似 newCap = oldCap * 2 移位操作更加高效
* 表示把原容量的二进制位向左移动一位,
* 扩大为原来的2倍,同样还是2的n次幂
* 如果新的数组容量<HashMap允许的容量最大值2的30次幂
* 并且原数组容量>=默认的初始化数组容量2的4次幂 =16
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //新的扩容门槛为原来的扩容门槛的2倍,同样二进制左移操作
}
/**
* 如果原数组容量小于等于零并且原负载门槛大于0,则新数组容量为原负载门槛大小
*/
else if (oldThr > 0)
newCap = oldThr;
/**
* 在默认构造器下进行扩容:初始化默认容量和默认负载门槛
* 如果原数组容量小于等于0并且原负载门槛也小于等于0,则新数组容量为默认初始化容量16
* 新负载门槛为默认负载因子(0.75f) * 16=12;
*/
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
/**
* 如果新负载门槛为 0 则开始使用新的数组容量进行计算
*/
if (newThr == 0) {
float ft = (float)newCap * loadFactor;// 新的数组容量 * 负载因子
//如果新数组容量小于HashMap允许的最大容量(2的30次幂)并且新计算的负载门槛小于HashMap允许的最大容量(2的30次幂),则新的负载门槛为计算后的值的最大整型,否则新的负载门槛为Integer.MAX_VALUE
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;//设置全局负载门槛为计算后的新的负载门槛
/**
* 根据新的数组容量创建新的哈希桶 赋值给newTab
*/
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;//把新创建的哈希桶赋值给全局table
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)
// 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
// 扩容都是按照2的幂次方扩容,因此newCap = 2^n
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 当前index对应的节点为红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 把当前index对应的链表分成两个链表,减少扩容的迁移量
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; // help gc
newTab[j + oldCap] = hiHead; // 扩容长度为当前index位置+旧的容量
}
}
}
}
}
return newTab;
}
面试题
HashMap的底层数据结构?
1.7 数组 + 链表 1.8 数组 + 链表 + 红黑树
如果两个对象的hashCode相同会怎么样?
- 调用equals方法,进行key值比较是否相等,key相等则新值会把旧值覆盖
- 如果key值不相等,则会以链表形式存储
HashMap什么情况下转红黑树,什么情况转链表?
- 链表长度大于8,并且数组长度大于等于64时,才会由链表转成红黑树。如果链表过长,首先是扩容数组,而不是转换树
- 当树中的元素经过删除或者其他原因调整了大小,当小于等于 6 后,将会导致树退化成链表,中间有个过渡值 7,可以防止频繁的树化与退化。
HashMap在7和8分别使用什么插法?
JDK7使用头插法(多线程环境下可能引起死循环),JDK8使用尾插法(虽然解决死循环,但是由于没有给get和put加同步锁,所以可能出现值不一样的情况),多线程环境强烈建议使用ConCurrentHashMap
HashMap的扩容机制?
默认情况下,数组大小为16,负载因子为0.75,扩容阈值为 16 * 0.75 = 12,也就是说原数组中个数达到了 12 后,就需要扩容,调用resize(),然后new一个两倍长度的新Node数组,更换指针指向新数组,扩容长度为原数组长度的2倍。
HashMap指定容量初始化
通过HashMap(int initialCapacity)设置初始容量的时候,HashMap并不一定会直接采用我们传入的数值,而是经过计算,得到一个新值,根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的2的幂并返回。
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;
为什么主数组的长度要为2的倍数?
原因是为了减少hash碰撞,尽量使hash算法的结果均匀
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
如果不是2的整数倍时,那么哈希碰撞 哈希冲突的概率会大大增加
解决hash冲突的办法有哪些?HashMap用的哪种?
- 开放地址法
- 再哈希法
- 链地址法 HashMap采用的是链地址法
HashMap与HashTable的区别?
- hashmap线程不安全,效率比较高 hashtable线程安全 效率低
- hashmap key 和 value可以为空 hashtable都不可以为空