1、基本特性&原理
1.1:以键值对形式存在,键和值允许为空。其存储的数据结构为哈希表,元素的存取顺序不能保证一致性。在jdk1.7以前哈希表的底层是采用数组、链表结构实现,但在jdk1.8后是采用数组、链表(处理冲突)与红黑树实现。当链表的单个长度超过阈值8并且长度不能小于转红黑树tab最小长度,则将链表转换成红黑树(防止哈希碰撞攻击)。降低时间复杂度,从而实现查询效率提升:
1.1.1:通过hashCode()方法定位元素位置 ,避免遍历寻找元素位置从而降低时间复杂度
1.1.2:采用邻接链表+桶排序+红黑树
桶排序思想:若所有的桶(将一类数放一起)装满,扩大内存空间,将桶的数量扩大为原来的一倍,然后进行重新排序,从而降低时间复杂度
1.2:是非线程安全的,因为没有加锁。在1.7采用的是头插法,容易导致循环链表发生;1.8优化为尾插法,不会导致循环链表问题,但是可能同时插入会造成数据丢失
2、put值过程
2.1、jdk1.7 – put值主要源码分析
过程简述:
- 如果哈希表还未创建,则调用inflateTable()初始化
- 如果键为null,那么调用putForNullKey()插入键为null的值
- 如果键不为null,计算hash值并得到桶中的下标,然后遍历桶中链表,找到目标节点则替换旧值
- 如果没有找到目标节点,则调用addEntry()插入新节点
基本属性
//默认初始容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//空Entry数组
static final Entry<?,?>[] EMPTY_TABLE = {};
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//HashMap存储的键值对数
transient int size;
//阈值 = 容量*加载因子
int threshold;
//实际加载因子
final float loadFactor;
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
//判断table是否为空,为空则进行初始化(entry数组分配空间)
inflateTable(threshold);
}
//进入putForNullKey()可看出HashMap的key是允许为空的
if (key == null)
return putForNullKey(value);
//调用hash()方法计算当前key的哈希值
int hash = hash(key);
//将该key的hash值与数组长度进行&运算,计算出当前key的数组存放下标
int i = indexFor(hash, table.length);
//遍历、判断新旧值的hash值与key是否相等,相等则覆盖旧值并返回旧值
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))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//保证并发访问异常快速响应
modCount++;
//新增一个entry数组
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
//当前map中键值对数大于等于阈值并且将要发生哈希冲突时,触发resize()扩容,否则直接调用createEntry()进行新增
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
//扩容后重新计算下标数组下标
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
//获取待插入元素位置
Entry<K,V> e = table[bucketIndex];
//将待插入元素指向原有元素(即:头插法,保证每个新元素在第一位,同时将链表整体下移)
table[bucketIndex] = new Entry<>(hash, key, value, e);
//map中键值对数加1
size++;
}
void resize(int newCapacity) {
//newCapacity = 扩容后的容量,获取原数组数据&容量值
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//判断原数组容量值是否等于最大容量值,是则修改阈值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//new一个扩容后的新数组
Entry[] newTable = new Entry[newCapacity];
//将旧数组的数据与扩容后的新数组进行转换
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//遍历旧数组中的所有entry数组
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
//如果需要重新计算hash值(initHashSeedAsNeeded()决定)
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//获取新的下标
int i = indexFor(e.hash, newCapacity);
//头插法
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
2.2、jdk1.8 – put值主要源码分析
过程简述:
1、如果table数组为空则调用resize()
2、如果不为空通过计算出来的索引位置判断是否为空,是则调用newNode()新增节点
3、否则不为空,即:将发生hash冲突
3.1、如果p节点与传入的hash值和key相等,则为目标节点,开始覆盖
3.2、如果不相等并且p节点是树节点,则调用putTreeVal()查找目标节点,开始覆盖
3.3、否则为普通链表,通过尾插法追加节点元素,当链表节点数大于阈值8则调用treeifyBin()转红黑树
基本属性
//默认初始容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转红黑树阈值
static final int TREEIFY_THRESHOLD = 8;
//红黑树转链表阈值
static final int UNTREEIFY_THRESHOLD = 6;
//转红黑树tab最小长度
static final int MIN_TREEIFY_CAPACITY = 64;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
java.util.HashMap.Node<K,V>[] tab; java.util.HashMap.Node<K,V> p; int n, i;
//若table为空或table的长度为0,则调用resize()进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过hash值计算下标位置,并将下标位置的头结点赋值给p,若p为空,则在该索引位置新增节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//存在哈希冲突
java.util.HashMap.Node<K,V> e; K k;
//若p节点的key和hash值与传入的相等,则p节点为目标节点,将p赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//若p节点是树节点,则调用putTreeVal()方法查找目标节点
else if (p instanceof java.util.HashMap.TreeNode)
e = ((java.util.HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否则为普通链表节点,遍历链表,binCount统计链表节点数
else {
for (int binCount = 0; ; ++binCount) {
//当p的next节点为空时,则新增节点(尾插法)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//当链表节点数大于等于链表转红黑树阈值-1时,调用treeifyBin()将链表结构进行树化或扩容
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//当节点e的key和hash值与传入的都相等,e即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//map中已经存在该key,覆盖原值并返回原值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//当插入的节点数大与阈值,则调用resize()进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
3、扩容机制
jdk1.7:
1、new一个扩容后的数组
2、调用transfer()方法,遍历原来数组的链表上的所有元素
3、获取每个元素的key,并重新计算在新数组上对应的下标值
4、将该元素放到新数组对应的下标上
5、遍历完后,将新数组赋值给table对象
jdk1.8:
1、扩容则new一个新数组
2、遍历原来数组的链表或红黑树上的所有元素
2.1:当为红黑树时:
遍历红黑树上所有的节点,计算每个元素在新数组的下标位置并统计个数;
当元素个数大于阈值8,则生成新的红黑树;否则生成链表
2.2:当为链表时:
重新计算所有元素在新数组中的下标位置,赋值
3、遍历完后,将新数组赋值给table对象
4、常见面试问题
4.1、为什么最大容量是2的32次方
如上图:当元素个数大于阈值threshold时,就会调用resize()进行扩容。但是当老表容量大于等于MAXIMUM_CAPACITY = 1 << 30,阈值则会被设置为Integer.MAX_VALUE后返回,不会再继续扩容。
4.2、为什么HashMap是线程不安全的,而HashTable与ConcurrentHashMap是线程安全的
从jdk1.7与1.8的源码中可以看出HashMap没有加锁,并且put()操作过程中当传入的hash和key值在数组中存在相等的不为空的,会出现值覆盖。如上图:假定k1与k2键值对完全相同,在多线程环境下进行put()操作,当k1、k2同时在1号位置并执行完成,可能会造成数据不一致。另一方面是在多线程扩容的情况下使用头插法导致循环链表问题。故,HashMap是线程不安全。
如上图:在Hashtable的源码中可以发现通过synchronized进行了加锁操作。同样k1,k2在多线程同一位置环境下执行,不论哪个先执行,后执行的一个必须要获取到先执行所释放的锁。故,Hashtable是线程安全的。
ConcurrentHashMap 是有segment数组和HashEntry组成的。如上图:源码中,segment继承ReentrantLock(可重入锁),保证了线程安全。在1.8中进行了锁优化(putVal()为例,如下图),采用synchronized +cas进行加锁保证线程的并发安全。故,ConcurrentHashMap 是线程安全的。
4.3、为什么说jdk1.7的头插法会导致循环链表问题、1.8的尾插法会可能数据丢失
头插法:
尾插法:
当多线程同时插入,数据可能会被覆盖从而导致数据丢失。
4.4、在jdk1.7中,是不是当数组长度超过阈值8就一定会转为红黑树结构?
不是。请看下面源码:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//判断tab为空 或者 tab的长度小于转红黑树tab最小长度,满足其中的任一条件执行的是扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//否则,tab对应下标位置不为空才开始树化
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);
}
}