目录
- HashMap介绍
- HashMap 常问
- (1)HashMap实现原理
- (2) HashMap在什么条件下扩容?
- (3) 讲讲HashMap的get/put的过程?
- (4) 为什么HashMap的在链表元素数量超过8时改为红黑树?
- (5) HashMap的并发问题?
- (6) 你一般用什么作为HashMap的key?
- (7)哈希冲突
- HashMap 源码解析
- 静态成员变量
- HashMap 成员变量
- HashMap put方法
- HashMap resize方法
- HashMap get()
- hash()
- HashMap与HashTable、ConcurrentHashMap区别
- HashTable
- HashMap:
- ConcurrentHashMap:
HashMap介绍
- 说一下HashMap的特性?
1.HashMap存储键值对,key和value都可以是null,key值不可重复,value可以重复;
2.Hashmap不是同步的,线程不安全;
3.HashMap底层是hash表,不能保证是有序的;
4.HashMap的数据结构主要是数组+链表,到JDK1.8时是数组+链表+红黑树。
HashMap 常问
(1)HashMap实现原理
- 看过HashMap源码吗,知道原理吗?
1.HashMap存储键值对,key和value都可以是null,key值不可重复,value可以重复;Entry类其实是一个链表结构,有next指针,可以指向下一个Entry实体;
2.Hashmap不是同步的,线程不安全;
3.HashMap底层是hash表,不能保证是有序的;
4.HashMap的数据结构主要是数组+链表,到JDK1.8时是数组+链表+红黑树。
5.HashMap最常用的就是put和get操作,put就是对key进行hashcode()函数计算得到key在桶数组中的位置来存储Entry对象,get就是根据key计算桶索引,然后比对链表上的key进行查找。
- 为什么用数组+链表?
数组是用于确定桶的位置,利用key的hash值对数组长度取模得到索引,而链表是为了解决hash冲突,当不同的key计算得到的索引一样,就会在数组对应位置上形成一条链表。
- hash冲突你还知道哪些解决办法?
比较出名的有四种 (1)开放定址法 (2)链地址法 (3)再哈希法 (4)公共溢出区域法
HashMap中使用的是链地址法。
- 用LinkedList代替数组结构可以么?可以
Entry[] table = new Entry[capacity];
ps: Entry就是一个链表节点。
List table = new LinkedList();
1.为什么HashMap不用LinkedList,而选用数组?
因为用数组效率最高!
2.ArrayList,底层也是数组,查找也快啊,为啥不用ArrayList?
因为采用基本数组结构,扩容机制可以自己定义,HashMap中数组扩容刚好是2的幂,在做取模运算的效率高。 而ArrayList的扩容机制是1.5倍扩容。
- 了解TreeMap吗?
TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和HashMap不同,它的get、put、remove之类操作都是O(logn)的复杂度。
- 了解 ConcurrentHashMap吗
Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,只是ConcurrentHashMap是线程安全的,使用synchronized+CAS来保证线程安全性。
(2) HashMap在什么条件下扩容?
- HashMap在什么条件下扩容?
如果bucket满了(超过loadfactor*currentcapacity),就要resize。 loadfactor为0.75,为了最大程度避免哈希冲突 currentcapacity为当前数组大小。
- 为什么扩容是2的次幂?
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同。那么就要通过一个算法来实现数据分配均匀。
实际就是取模,hash%length。 但是,取模运算不如位移运算快。
因此,源码中做了优化hash&(length-1)。
2n是100000,-1就是11111,她和hash与运算,可以保证均匀分布。
来看一下jdk1.8里的hash方法源码。这么做就是为了降低hash冲突的几率。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(3) 讲讲HashMap的get/put的过程?
- 知道hashmap中put元素的过程是什么样吗?
- 对key的hashCode()做hash,然后再计算index;
- 如果没碰撞直接放到bucket里;
- 如果碰撞了,以链表的形式存在buckets后;
- 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
- 如果节点已经存在就替换old value(保证key的唯一性)
- 如果bucket满了(超过load factor*current capacity),就要resize。
- 知道hashmap中get元素的过程是什么样吗?
- bucket里的第一个节点,直接命中;
- 如果有冲突,则通过key.equals(k)去查找对应的entry
- 若为树,则在树中通过key.equals(k)查找,O(logn);
- 若为链表,则在链表中通过key.equals(k)查找,O(n)。
- hash算法是干嘛的?还知道哪些hash算法?
把一个大范围映射到一个小范围。
把大范围映射到一个小范围的目的往往是为了节省空间,使得数据容易保存。
比较出名的算法有MD4、MD5等
- 说说String中hashcode的实现?
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。
哈希计算公式可以计为s[0]31^(n-1) + s[1]31^(n-2) + … + s[n-1]
那为什么以31为质数呢?
主要是因为31是一个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。
(4) 为什么HashMap的在链表元素数量超过8时改为红黑树?
- jdk1.8中HashMap与之前有哪些不同?
• 由数组+链表的结构改为数组+链表+红黑树。
• 优化了高位运算的hash算法:h^(h>>>16)
• 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
最后一条是重点,因为最后一条的变动,HashMap在1.8中,不会在出现死循环问题。
- 为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。 当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
- 不用红黑树,用二叉查找树可以么?
二叉查找树在特殊情况下会退化成一条线性结构
- 当链表转为红黑树后,什么时候退化为链表?
为6的时候退转为链表。中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
(5) HashMap的并发问题?
- HashMap在并发编程环境下有什么问题(jdk1.8以后)?
• 多线程put的时候可能导致元素丢失
• put非null元素后get出来的却是null
- 如何解决这些问题?
使用ConcurrentHashmap,Hashtable等线程安全集合类。
(6) 你一般用什么作为HashMap的key?
- 键可以为Null值么?
可以,key为null的时候,hash算法最后的值以0来计算,也就是放在数组的第一个位置。
- 一般用什么作为HashMap的key?
一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
- 因为是final型,字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
- 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
- 用可变类当HashMap的key有什么问题?
hashcode可能发生改变,导致put进去的值,无法get出,如下所示:
HashMap<List<String>, Object> changeMap = new HashMap<>();
List<String> list = new ArrayList<>();
list.add("hello");
Object objectValue = new Object();
changeMap.put(list, objectValue);
System.out.println(changeMap.get(list));
list.add("hello world"); // hashcode发生了改变
System.out.println(changeMap.get(list));
输出结果如下
java.lang.Object@33909752
null
(7)哈希冲突
高16bit不变,低16bit和高16bit做了一个异或。
设计者认为这方法很容易发生碰撞。为什么这么说呢?不妨思考一下,在n - 1为15(0x1111)时,其实散列真正生效的只是低4bit的有效位,当然容易碰撞了。
因此,设计者想了一个顾全大局的方法(综合考虑了速度、作用、质量),就是把高16bit和低16bit异或了一下。设计者还解释到因为现在大多数的hashCode的分布已经很不错了,就算是发生了碰撞也用O(logn)的tree去做了。仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。
HashMap 源码解析
静态成员变量
//默认数组的初始容量是16,必须是2的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//数组的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子是0.75,和数组大小一起使用,判断是否扩容
//size>loadfactor*capacity 扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//一个桶的树化阈值
//当桶中的元素个数超过这个值,链表需要在转为红黑树
//这个值必须是8,要不然频繁转换,效率也不高
static final int TREEIFY_THRESHOLD = 8;
//一个树的链表还原阈值
//当扩容的时候,桶中元素的个数小于这个值,就会把树结构还原为链表结构
//这个值应该就是比上面那个小,至少是6,避免频繁转换
static final int UNTREEIFY_THRESHOLD = 6;
//用到红黑树的最小容量
//哈希表中的容量大于这个值,表中的桶才能进行红黑树转换
//桶元素太多就会扩容,而不是转为红黑树
//这个值不能小于4*TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap 成员变量
transient java.util.HashMap.Node<K,V>[] table;//桶数组
transient Set<Map.Entry<K,V>> entrySet;//返回一个迭代器遍历Map结构
transient int size;//整个hashmap 所包含的节点数
transient int modCount;//修改次数,比如Put,remove的次数
//和迭代器配合使用,在迭代过程中,如果其它线程更改了这个值,抛出ConcurrentModificationException异常
int threshold;//hashmap扩容的阈值,值为 loadFactor*table.length,eg:0.75*16=12,数组大小超过 12时就会进行扩容
final float loadFactor;//负载因子
Node的数据结构:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//key的hash值
final K key;//key
V value;//value
Node<K,V> next;//下一节点的引用
}
HashMap put方法
public V put(K key, V value) {
//hash(key)就是求key的hash值
//调用putVal()方法
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 赋值给本地变量 tab,并且将tab的长度赋值给本地变量 n
//如果tab为空或者数组长度为0,进行初始化,调用 resize()方法,并且获取赋值后的数组长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n-1) & hash根据key的hash值和数组length-1与操作,得到数组的位置,赋值给i
//p=tab[i]是将位置i的数组对应的key赋值给p
///如果当前数组中取出的key为空,就新建一个节点,插到当前数组位置上
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果不为空,表示这样的hash值已经存在了,存在hash冲突,或者直接会替换原来的值
else {
//声明本地变量
Node<K,V> e;
K k;
// 如果取出来的节点 hash值相等,key也和原来的一样( == 或者 equals方法为true),直接将这个节点p赋值给刚刚声明的本地变量e
//另外这里还将节点p的key赋值给了本地变量 k
///检查第一个node是不是要找的值
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果 hash值一样,但不是同一个 key,则表示hash冲突,接着判断这个节点是不是红黑树的节点
//如果是,则生成一个红黑树的节点然后赋值给本地变量 e
//第一个节点是树节点,即属于红黑树冲突处理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不是红黑树,hash冲突,开始扩展链表
else {
//遍历p后面的链表
for (int binCount = 0; ; ++binCount) {
//e表示到p后面的节点,如果e为null,表示下一节点不存在,直接将新的key,value放在链表后面
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//放入后,判断链表长度是否到达红黑树的阈值8,大于等于则调用 treeifyBin()将链表转为红黑树
//treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果后面的节点不为空,表示p后面还有节点
//将e赋值给p(p的下一节点赋值给p),继续下一轮的循环
//如过有相同的key值,就结束遍历
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e不等于null,则表示key值相等,替换原来的value即可
//更新hash与key均相同节点的value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//完成put操作,修改次数+1
++modCount;
//put新节点后,size+1
//如果大于阈值(0.75*初始容量),扩容2倍
if (++size > threshold)
resize();
//插入新节点后,回调方法
afterNodeInsertion(evict);
//插入新节点后,返回null
return null;
}
HashMap resize方法
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 ,判断是否小于最大值,并且原来的数组长度大于默认初始长度(16)
//直接双倍扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
//第一次初始化表
else if (oldThr > 0)
newCap = oldThr;
else {
//第一次调用 resize方法,初始化数组长度,阈值,这里就对应我们前面成员变量的分析了:
//阈值 = 加载因子 * 数组长度
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) {
//原数组的值先置换为null,帮助gc
oldTab[j] = null;
//如果节点的next为空(没有形成链表),直接赋值到新数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果节点的next不为空 ,但是已经是红黑树了,按照红黑树的规则来置换
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 {
//新表是旧表的2倍容量,把单链表拆成两队
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 get()
public V get(Object key) {
Node<K,V> e;
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;
//本地变量赋值,n是数组的长度
//通过key的hash值计算出key在数组中的位置,取出该节点
//如果不为空,表示key在数组中是存在的,接下去就是遍历
///找到插入的第一个node
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
//从第一个node开始,如果hash值相等,key值相等,那么这个节点就是我们想要找的,直接返回。
if (first.hash == hash && // always check first 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 {
//遍历链表后面的node,找到了key和hash值都相同的,返回
if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//没找到,直接返回null
return null;
}
hash()
static final int hash(Object key) {
int h;
//把高16bit和低16bit异或,减少哈希冲突
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap与HashTable、ConcurrentHashMap区别
HashTable
- 底层数组+链表实现,无论key还是value都不能为null;
- 线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
- 初始size为11,扩容:newsize = olesize*2+1
- 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
HashMap:
- 底层数组+链表实现,可以存储null键和null值
- 线程不安全
- 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
- 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
- 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
- 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
- 计算index方法:index = hash & (tab.length – 1)
ConcurrentHashMap:
- 底层采用分段的数组+链表实现
- 线程安全
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
- 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
- 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容