HashMap
HashMap的基本的概念
在Java程序的开发中,HashMap是最常使用的集合工具,HashMap在Jdk1.7中的底层实现是数组+链表,在Jdk1.8中对于链表做了一定的优化,当链表的长度大于8且HashMap的size大于64的时候,链表会转化为红黑树。
HashMap的基本的API操作
对于HashMap,首先要会最基本的API的使用:
- 创建HashMap ,最简单的方式就是创建一个HashMap对象:
Map<String, Object> map=new HashMap<>();
- 在HashMap中存值
map.put("key","value");
//在put值的时候可以查看改key值之前对应的value值
Object val =map.put("key","value1");
- 在HashMap中取值
Object key = map.get("key");
HashMap的底层数据结构的分析
HashMap的构造函数
- 无参构造
public HashMap() {
//DEFAULT_LOAD_FACTOR 默认的扩容因子 0.75
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- 带有初始化长度的构造函数
public HashMap(int initialCapacity) {
//当只传有带有长度的构造方法时,使用默认的扩容因子0.75,
//然后调用带有长度和扩容因子的构造函数
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 带有初始化长度和扩容因子的构造函数
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
//当初始化的长度小于0的时候,直接抛出异常
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
//当大于最大的长度的时候,将默认的长度改为最大允许的容量
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
//当扩容因子小于等于0的时候,直接抛出异常。
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//1.7中这里调用了init()方法,
this.threshold = tableSizeFor(initialCapacity);
}
- 传入另一个map的构造方法
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
//直接将传入的HashMap复制到新的HashMap中。
putMapEntries(m, false);
}
HashMap的静态字符串的介绍
//hashMap的默认的长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//hashMap允许的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//HashMap的默认的扩容因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//jdk1.8中的链表转化红黑树的长度
static final int TREEIFY_THRESHOLD = 8;
//红黑树转化为链表的长度
static final int UNTREEIFY_THRESHOLD = 6;
//链表转化红黑树的HashMap的最小长度。
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap的数据结构分析
在Jdk1.7中,HashMap的底层实现是数组+链表的结构。
在Jdk1.8中,当hashMap的数组对应的链表的长度大于8且HashMap存放的值大于等于64的时候,链表的数据结构就会转化为红黑树类型的数据结构。
HashMap底层源码阅读分析
jdk1.7中的HashMap的源码
- hash值计算
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//k的hash值后亦或运算。
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
//一些右移操作。对于正数 高位补零 高位数字移到低位数
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
- 数组位置的计算
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
//int 4个字节 32位
//eg h:0101 0101
//16 len:0001 1000 -1
//15 len:0000 1111
//那么就是h和15进行与操作
// ----------------
// 0000 0101
//在jdk1.4中取余比较慢,所以改成了与操作。
//hashCode的高位不管怎么变,与操作其实只和后面的有关系。会导致算的值相同 导致链表边长,就会导致hash冲突。
//所以前面的异或运算,右移,会让高位的数字移动到低位,参与到运算中。
return h & (length-1);
}
- 存放key为null的方法
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//index[0]会固定的存放null的值。 会进行遍历,但是永远只会1个值。
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
- 初始化数组的方法
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
//找到大于等于toSize的2的n次方的数字 eg:初始化时10 那 么就是2^4 16
int capacity = roundUpToPowerOf2(toSize);
//16*3/4=12
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
//hash种子
initHashSeedAsNeeded(capacity);
}
- 添加对象的方法
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//先扩容,随后进行添加。1.8先扩容 随后添加元素。
//size>=阈值并且当前需要存放的值不为空,即当前存放的位置已经有元素了。
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
- 创建entry对象
void createEntry(int hash, K key, V value, int bucketIndex) {
//取出当前位置的元素
Entry<K,V> e = table[bucketIndex];
//将新增的远孙放到头结点,next指针指向原本的头结点
//所以在JDK1.7中的HashMap使用的是头插法
table[bucketIndex] = new Entry<>(hash, key, value, e);
//hashMap当前存的元素++
size++;
}
- put方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
//当数组的为空的时候,调用该方法进行数组的初始化 为什么一定要是2的n次方
inflateTable(threshold);
}
if (key == null){
//当key==null的时候,存放一个空值 所以hashMap支持key==null
return putForNullKey(value);}
//计算该key值的hash值
int hash = hash(key);
//table的长度和hash值进行与操作
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))) {
//为什么hash值对比在前,key值相同在后, hash值不相同,key一定不同。
//hash值相同,key也不一定相同。当hash值相同的时候,在判断key值是否相同,如果首先执行equals的时候.考虑自己的hashCode的重写是否高效。
//hash值是否相等 key的值也相同
//得到旧的值 并返回、此处是hashMap在put的时候的返回值,
//当该key对应的value之前就有的话,就会返回对应的value值并进行覆盖
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// hash值 key value 位数
addEntry(hash, key, value, i);
return null;
}
- get方法
public V get(Object key) {
if (key == null)
//当key值为空的时候,调用key=null的对应的方法
return getForNullKey();
//得到该key值对应的Entry对象。
Entry<K,V> entry = getEntry(key);
//返回value值
return null == entry ? null : entry.getValue();
}
- getEntry方法
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
//当hashMap的长度为0的时候,直接返回
return null;
}
//计算key值对应的Hash值
int hash = (key == null) ? 0 : hash(key);
//方法二中indexFor位置计算
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))))
//根据Hash值和key值找到对应的value并返回。
return e;
}
return null;
}
- HashMap的扩容方法
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
//当旧的容量达到了最大值的时候,返回
threshold = Integer.MAX_VALUE;
return;
}
//新的数组
Entry[] newTable = new Entry[newCapacity];
//将旧的值赋给新的值
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
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);
}
//重新算一下在新的数组中的数组下标。
// 0101 0101
//15 0000 1111
//31 0001 1111
//扩容前 0000 0101
//扩容后 0001 0101
//扩容前和扩容后的数组下标可能 会+扩容前的数组容量。
//即原本是1 扩容后要么是1 要么是1+16=17
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
jdk1.8中的HashMap的源码
这里值列举一些1.7和1.8有些不同的HashMap的代码的地方
在1.8中hashMap将Entry对象名称更改为了Node节点名称
- hash值计算
static final int hash(Object key) {
int h;
//hash 值后异或 h右移16位
//1.8中简化了hash值的计算,
//因为在1.8中当hash冲突过多导致链表变长,会转化成红黑树数据结构
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- put方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//8和6 为什么不取相同的值,如果相同的话,在临界值进行新增和删除操作,会频繁的进行红黑树和链表的转化。
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//n-1&hash 计算当前数组的下标
if ((p = tab[i = (n - 1) & hash]) == null)
//当前的数组是空的话,直接存放。
tab[i] = newNode(hash, key, value, null);
else {
//当前的数组不等于空的时候,
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//是不是树
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
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
//当前的binCount是不是需要转换成树
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)
//当前的hashMap的size时候大于阈值的时候,进行扩容。
resize();
afterNodeInsertion(evict);
return null;
}
1、检查数组是否为空,执行 resize() 扩充。
2、通过hash值计算数组索引,获取该索引位的首节点。
3、如果首节点为 null(没发生碰撞),直接添加节点到该索引位 (bucket)。
4、如果首节点不为 null(发生碰撞),那么有 3 种情况① key 和首节点的 key 相同,覆盖 old value(保证key的唯一性);否则执行 ② 或 ③ ② 如果首节点是红黑树节点(TreeNode),将键值对添加到红黑树。③ 如果首节点是链表,将键值对添加到链表。添加之后会判断链表长度是否到达TREEIFY_THRESHOLD - 1 这个阈值,“尝试”将链表转换成红黑树。
5、最后判断当前元素个数是否大于 threshold,扩充数组。
3. reseize()扩容方法
final Node<K,V>[] resize() {
//创建一个Node数组用于存放table中的元素,
Node<K,V>[] oldTab = table;
//获取旧table的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取旧的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//如果旧的table中有元素
if (oldCap > 0) {
//如果旧table长度>=最大容量限制时不进行扩容,并将扩容阈值赋值为Integer.MAX_VALUE
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 将新table长度赋值为旧table的2倍,
// 判断旧table长度的二倍是否小于最大容量,且旧容量大于等于初始容量,
// 以上判断成立则将新的扩容阀值赋值为旧的扩容阈值的二倍
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"})
//将旧table中的元素放到扩容后的newTable中
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;
//如果数组对应下标位置只有一个元素,对hashCade取余并根据结果直接放到newTable相应的位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果数组对应下标位置的元素是一个红黑树,则拆分红黑树放到newTable中
// 如果拆分后的红黑树元素小于6,则转化为链表
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//数组对应下标位置的元素是一个链表的情况
//根据(e.hash & oldCap)条件对链表进行拆分并放到newTable
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;
}
扩充数组不单单只是让数组长度翻倍,将原数组中的元素直接存入新数组中这么简单。
因为元素的索引是通过 hash&(n - 1) 得到的,那么数组的长度由 n 变为 2n,重新计算的索引就可能和原来的不一样了。
在 JDK1.7 中,是通过遍历每一个元素,每一个节点,重新计算他们的索引值,存入新的数组中,称为 rehash 操作。
而 JDK1.8 对此进行了一些优化,没有了 rehash 操作。因为当数组长度是通过2 的次方扩充的,那么会发现以下规律:
元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。因此,在扩充 HashMap 的时候,不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”。
先计算新数组的长度和新的阈值(threshold),然后将旧数组的内容迁移到新数组中,和 1.7 相比不需要执行 rehash 操作。因为以 2 次幂扩展的数组可以简单通过新增的 bit 判断索引位。
4.链表转红黑树的方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
//table是不是等于空 或者当前的hashMap的长度小于64的时候,也不会转化成红黑树
//这个时候进行数组的扩容、
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
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);
}
}
HashMap 在 JDK1.8 之后引入了红黑树的概念,表示若桶中链表元素超过 8 时,会自动转化成红黑树;若桶中元素小于等于 6 时,树结构还原成链表形式。
红黑树的平均查找长度是 log(n),长度为 8,查找长度为 log(8)=3,链表的平均查找长度为 n/2,当长度为8时,平均查找长度为 8/2=4,这才有转换成树的必要;链表长度如果是小于等于 6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
以 6 和 8 来作为平衡点是因为,中间有个差值 7 可以防止链表和树之间频繁的转换。假设,如果设计成链表个数超过 8 则链表转换成树结构,链表个数小于 8则树结构转换成链表,如果一个 HashMap 不停的插入、删除元素,链表个数在 8 左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
概括起来就是:链表:如果元素小于 8 个,查询成本高,新增成本低,红黑树:如果元素大于 8 个,查询成本低,新增成本高。
HashMap面试常问问题
1.为什么容量总是为2的次幂
因为 n 永远是2的次幂,所以 n-1 通过 二进制表示,永远都是尾端以连续1的形 式表示(00001111,00000011)当(n - 1) 和 hash 做与运算时,会保留hash中 后 x 位的 1,
好处:
&运算速度快,至少比%取模运算块
能保证 索引值 肯定在 capacity 中,不会超出数组长度
(n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n
为什么要通过 (n - 1) & hash 决定桶的索引呢
HashMap计算添加元素的位置时,使用的位运算,这是特别高效的运算;另外,HashMap的初始容量是2的n次幂,扩容也是2倍的形式进行扩容,是因为容量是2的n次幂,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞,避免形成链表的结构,使得查询效率降低!
2、为什么1.8中链表转化红黑树的长度是8,红黑树转化链表的长度是6
为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时6),又会将红黑树转换回单向链表提高性能,这里是一个平衡点。检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是 7 或 8,因为会有一次放弃转换的操作。
由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,
3、HashMap在1.7和1.8扩容的时候的异同
不同点:
(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
(2)扩容后数据存储位置的计算方式也不一样:1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)
(3)JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)
4、为什么在1.7中采用了头插法,在1.8中采用了尾插法
头插法是操作速度最快的,找到数组位置就直接找到插入位置了;
jdk8开始hashmap链表在节点长度达到8之后会变成红黑树,这样一来在数组后节点长
度不断增加时,遍历一次的次数就会少很多很多(否则每次要遍历所有),相比头插法
而言,尾插法操作额外的遍历消耗已经小很多了,也可以避免之前的循环列表问题。
5、1.7中HashMap扩容的死循环是如何造成的
public class HashMapTest {
public static void main(String[] args) throws Exception {
HashMap<String,String> map = new HashMap<String, String>();
TestDeadLock t1 = new TestDeadLock(map);
t1.start();
TestDeadLock t2 = new TestDeadLock(map);
t2.start();
TestDeadLock t3 = new TestDeadLock(map);
t3.start();
}
}
class TestDeadLock extends Thread {
private HashMap<String,String> map;
public TestDeadLock(HashMap<String, String> map) {
super();
this.map = map;
}
@Override
public void run() {
for (int i = 0; i<500000; i++) {
map.put(UUID.randomUUID().toString(), UUID.randomUUID().toString());
System.out.println("Running ~~"+i);
}
}
上面代码在Jdk1.7版本中就会造成HashMap扩容的死循环,在Jdk1.8中不会。
JDK 1.7 HashMap扩容导致死循环的主要原因
HashMap扩容导致死循环的主要原因在于扩容后链表中的节点在新的hash桶使用头插法插入。
新的hash桶会倒置原hash桶中的单链表,那么在多个线程同时扩容的情况下就可能导致产生一个存在闭环的单链表,从而导致死循环。
那么在JDK 1.8中HashMap扩容不会造成死循环的主要原因就从这两个角度去分析一下。
由于扩容是按两倍进行扩,即 N 扩为 N + N,因此就会存在低位部分 0 - (N-1),以及高位部分 N - (2N-1), 所以在扩容时分为 loHead (low Head) 和 hiHead (high head)。
然后将原hash桶中单链表上的节点按照尾插法插入到loHead和hiHead所引用的单链表中。
由于使用的是尾插法,不会导致单链表的倒置,所以扩容的时候不会导致死循环。如果单链表的长度达到 8 ,就会自动转成红黑树,而转成红黑树之前产生的单链表的逻辑也是借助loHead (low Head) 和 hiHead (high head),采用尾插法。然后再根据单链表生成红黑树,也不会导致发生死循环。
虽然JDK 1.8 中HashMap扩容的时候不会造成死循环,但是如果多个线程同时执行put操作,可能会导致同时向一个单链表中插入数据,从而导致数据丢失的。
所以不论是JDK 1.7 还是 1.8,HashMap线程都是不安全的,要使用线程安全的Map可以考虑ConcurrentHashMap。