要点归纳
- 数据结构
- 1.7 HashMap 的数据结构 为 数组 + 链表 ,[1.8引用数组+链表+红黑树]
- 初始化
- 构造方法
- 调用初始化
- put 方法
- 主要过程
- 判断分配空间
- key为空处理
- 计算索引位
- key冲突 处理过程
- addEntry 、是否扩容
- 扩容
- 扩容过程
- 环形链表问题
之前了解过的可按照上面提到的回想下,是否都掌握。
定义
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{ ... }
可看到 HashMap 继承了 AbstractMap ,实现了 Map、Cloneable、Serializable
Cloneable:标记为colone、Serializable:标记为可被序列^化
来看下 定义的属性
/**
* 定义默认长度 16 2^4
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大长度 2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认加载因子 [size >= 阈值(容量*加载因子) 则会进行扩容]
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 空的Entry 数组对象
*/
static final Entry<?,?>[] EMPTY_TABLE = {};
/**
* 用于存放key value的对象 [还存储了当前对象的hash值 以及 next Entry *hash冲突时会形成链表结构]
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/**
* 当前元素个数
*/
transient int size;
/**
* 当前数组的阈值
*/
int threshold;
/**
* 当前加载因子
*/
final float loadFactor;
/**
* 记录当前集合被修改的次数
*/
transient int modCount;
构造方法
/**
* 无参构造 则会取 默认长度 及 默认加载因子 调用下面的构造方法
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/**
* 手动定义长度 及 默认加载因子 调用下面的构造方法
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* map类型的参数 构造方法
*/
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
/**
* 指定长度 及 加载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
//校验 长度正确性
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;
threshold = initialCapacity;
init();
}
以上则会发现,调用构造方法,虽然定义了 长度 及 加载因子,但并未进行初始化。
此时 table 对象 仍然为 EMPTY_TABLE
put方法
public V put(K key, V value) {
//校验 当前 数组为空 则进行初始化 [✨此过程为要点]
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//key 为 null 时,进行针对 null key 的赋值逻辑 [✨此过程为要点]
if (key == null)
return putForNullKey(value);
//计算 key 对应的 hash 值
int hash = hash(key);
//根据 hash 值 计算出 索引值 i [该key 对应的value 应放在 数组的索引位置] [✨此过程为要点]
int i = indexFor(hash, table.length);
//遍历 i 索引对应的 entry对象,如果hash 及 key 相等[引用 或 值 相等]
//则将 value 替换 oldValue 并将 oldValue返回 [✨此过程为要点]
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++;
//若上方没有匹配到相同的key 添加 Entry 对象
addEntry(hash, key, value, i);
return null;
}
inflateTable 初始化方法
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
//返回 第一个 大于或等于 参数 2 的 幂值 16 -> 16 、 17 -> 32
//为什么一定要取 2的幂 后续会讲解 [✨此过程为要点]
int capacity = roundUpToPowerOf2(toSize);
//重新计算阈值 = 计算后的数组大小 * 加载因子 和 MAXIMUM_CAPACITY 比较取最小
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//创建Entry对象
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
putForNullKey方法
private V putForNullKey(V value) {
//取出 table[0] 位置上的 entry对象 判断是否存在 null key 进行替换
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
//记录修改次数
modCount++;
//调用添加entry方法 设置 key 为 null
addEntry(0, null, value, 0);
return null;
}
hash 方法
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//获取k的hashCode值 自异或
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 值 无符号右移 20 位 异或 无符号右移 12 位 并 自异或
h ^= (h >>> 20) ^ (h >>> 12);
//在 进行 一下的 移位 和 异或
return h ^ (h >>> 7) ^ (h >>> 4);
}
多段移位[扰动处理 4次位运算 + 5次异或] 则是为了减少hash冲突 [✨此过程为要点]
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
// h 按位与上 长度 -1
return h & (length-1); [✨此过程为要点]
}
比如:key = "ABCD" 计算后的 hash = "2054250" 按位与计算 转化为二进制
当前 length = 16
h & (length-1) = h & 15
h 1 1111 0101 1000 0110 1010
0 0000 0000 0000 0000 1111
= 0 0000 0000 0000 0000 1010
= 10
再比如:key ="LL" hahs="2315" 按位与计算 转化为二进制
当前 length = 16
h & (length-1) = h & 15
h 1001 0000 1011
0000 0000 1111
= 0000 0000 1011
= 11
过多就不举例了,结论是 无论 得到的hash值无论多少,只要 & 上当前长度 -1
其取值范围 一定会在 [0,长度-1] 从而可保证在数组的 取值范围内
[✨此过程为要点]
上面提到过 数组长度要取 2幂 此处做解释
例如 key ="LL" hahs="2315" 按位与计算 转化为二进制
当前 length = 15 的话
h & (length-1) = h & 14
h 1001 0000 1011
0000 0000 1110
= 0000 0000 1010
= 10
假如 hash="2314"
h & (length-1) = h & 14
h 1001 0000 1010
0000 0000 1110
= 0000 0000 1010
= 10
可见即使hash 值不一致,由于最后一位 为 0,得到的索引却相同,则大大增加了 碰撞的几率
addEntry方法
void addEntry(int hash, K key, V value, int bucketIndex) {
//[✨此过程为要点]
//判断是否进行扩容 当前长度是否 大于等于 阈值 并且 当前位置 不得空 才进行扩容
if ((size >= threshold) && (null != table[bucketIndex])) {
//扩容为 当前length * 2
resize(2 * table.length);
//计算扩容后hash值
hash = (null != key) ? hash(key) : 0;
//计算扩容后 index
bucketIndex = indexFor(hash, table.length);
}
//创建新的entry对象
createEntry(hash, key, value, bucketIndex);
}
createEntry方法
先看正常创建 entry 方法,下面讲扩容
/**
* hash hash值
* key key值
* value value值
* bucketIndex 索引位置
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
//e = 数组 当前索引上的值
Entry<K,V> e = table[bucketIndex];
//创建 Entry
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
/**
* Entry对象 构造方法
*/
Entry(int h, K k, V v, Entry<K,V> n) {
hash = h;
key = k;
value = v;
next = n;
}
创建逻辑 将新建的 key-value 赋值为 数组的当前位置
将数组原值 赋值到 当前对象 的 next ,从而形成链表结构
此过程为 单链表 头插法 [✨此过程为要点]
resize 扩容
[✨此过程为要点]
hashMap 扩容 在多线程下 不安全,其原因是 容易出现 循环链表,导致get时 出现死循环
下面会 详细介绍 过程↓
/**
* newCapacity 扩容数组长度
*/
void resize(int newCapacity) {
//oldTable 存储当前 table对象
Entry[] oldTable = table;
//校验当前长度 = 最大长度 则直接 赋值 Integer.MAX_VALUE 为 阈值 并返回
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//创建 新 Entry 用于存储 扩容后的 Entry
Entry[] newTable = new Entry[newCapacity];
//将 原数组的元素 移动到 新数组上 [✨此过程为要点]
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//将 扩容后的 entry对象 赋值 给 全局 table
table = newTable;
//重新计算 阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* newTable 用于存放 扩容后 新 Entry
* rehash 是否重新进行hash
*/
void transfer(Entry[] newTable, boolean rehash) {
//新数组长度
int newCapacity = newTable.length;
//遍历 -> 横向遍历数组上的 元素
for (Entry<K,V> e : table) {
//遍历 当前位置上 存在可能为链表结构的 对象
//直到 当前对象为空
while(null != e) {
//创建局部变量next作为 e对象中指向的 下一个元素
Entry<K,V> next = e.next;
//判断是否重新hash
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//计算扩容后 新的 索引位
int i = indexFor(e.hash, newCapacity);
//将newTable[i] 作为 e.next 的值
e.next = newTable[i];
//再将 e 作为 newTable[i]的值
newTable[i] = e;
//将局部变量next的值 赋值给e 进行下一次循环
e = next;
}
}
}
[✨此过程为要点]
总结的来说:转移元素采用头插法
以下红框的代码 就是 将 oldTable中的值 转移到 newTable 上的过程;
entry对象 中 除了 记录了 key,value外,还记录了 next,和hash。
①:将 当前entry对象上的 next 对象 记录下来 ,作为 next 局部变量
②:将newTable[i] 当前位置上的元素 指向 entry的 next 对象
③:在将 e 放到 newTable[i] 的位置上
④:再将 第①步存储的 next 对象指向 e 对象,用于下次 while循环,从而转移链表中剩下的对象
//根据新长度 计算 索引值 暂不考虑 调用重新计算 hash值
int i = indexFor(e.hash, newCapacity);
比如:h & (length-1)
情况1: h = 1010 1010
原数组长度为 16
h = 1010 1010
15 = 0000 1111
i = 0000 1010 = 10
扩容后 数组长度为 32
h = 1010 1010
31 = 0001 1010
i = 0000 1010 = 10
情况2:
情况1: h = 1011 1010
原数组长度为 16
h = 1011 1010
15 = 0000 1111
i = 0000 1010 = 10
扩容后 数组长度为 32
h = 1011 1010
31 = 0001 1010
i = 0001 1010 = 26
可见 扩容后 重新计算的 索引位置 与 扩容前 高一位 的 值有关
若为 0 ,则计算结果不变,若为 1 ,则结果是 原来的 值 + length
举例:下面是将 oldTable[0] 位置上的元素 转移到 newTable[0] 上的过程
我们可看到 采用头插法 转移完 元素 ,原有链表上的 元素 在新 数组上 是倒置的链表
rehash 扩容时 重新计算hash值
至于再 转移前 是否进行 rehash [重新计算hash值],我们可看到 是根据 rehash 的值 决定。
我们来看下 调用transfer 之前的方法 是调用了 initHashSeedAsNeeded()
/**
* capacity 数组length
*/
final boolean initHashSeedAsNeeded(int capacity) {
//hashSeed 默认是 0 此时 currentAltHashing 为 false
boolean currentAltHashing = hashSeed != 0;
//sun.misc.VM.isBooted() jvm底层方法 此时会返回true
//capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD
//ALTERNATIVE_HASHING_THRESHOLD 这个值 会取 Integer.MAX_VALUE
//所以得出结论 当 数组长度 大于 等于 Integer.MAX_VALUE 时 会 重新 计算hash值
boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
// fasle 异或 true 才为 true
boolean switching = currentAltHashing ^ useAltHashing;
if (switching) {
hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0;
}
return switching;
}
所以得出结论 当 数组长度 大于 等于 Integer.MAX_VALUE 时 会 重新 计算hash值
正常运行时 如果手动不指定 数组长度 则 扩容是 进行 rehash的几率 很小,所以暂不考虑
多线程情况下 扩容 导致 循环链表问题 ***
引用如下图 所示
T1线程获得执行权,进行将K4,V4 移动到 newTable上
如下图: T1线程移动完 的结果,注意此时 T2线程中 的引用还在,并且依然是 e = k4,v4 next = k3,v3
T2线程获得了 cpu执行权 开始接着 执行 接下来的代码 转移元素
T2线程 继续执行下面的代码 将 e对象 移动到 T2的 newTable[i]的位置上
Entry<K,V> next = e.next;
···
e.next = newTable[i];
newTable[i] = e;
e = next;
移动后 结果如下图:
此时的e 指向 next指向的对象 k3,v3
继续进行下一次 移位
Entry<K,V> next = e.next;
···
//此时 e.next 已经等于 newTable[i] (k4,v4已经在newTable[i]的位置) 了,所以此次没变化
e.next = newTable[i];
newTable[i] = e;
e = next;
继续进行下一次 移位
//此时 next 为 k4,v4 的next ,为null
Entry<K,V> next = e.next;
···
//e.next 指向了 k3v3 ,此时 整个newTable[i]链表 已经形成了 循环链表
e.next = newTable[i];
//接着走下面的代码
newTable[i] = e;
e = next;
相当于 k3,v3 和 k4,v4 调换了下 ,next 指向了null,e = nuxt ,故 也为 null
[✨此过程为要点]
e 为 null 下次 while 将中止 ,此时 T2 线程 扩容后的 newTable[i] 位置上的链表 形成了 循环链表。
当 某次 调用 get(key) 方法时,key 刚好所在 这个链表的位置时 ,进行遍历链表取值的时候 会造成死循环 !!!
get方法
上面提到 若存在循环链表时,死循环是由于 get 方法 匹配到 该链表 后 进行遍历 时产生的。所以下面说下 get 方法:
public V get(Object key) {
//若key为空 则调用 获取 getForNullKey 方法
if (key == null)
return getForNullKey();
//否则 调用 getEntry方法
Entry<K,V> entry = getEntry(key);
//返回 enty不得null 则返回 entry的 value
return null == entry ? null : entry.getValue();
}
/**
* 获取 key 为空 索引上的值
*/
private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//根据key得到 索引位置 ,此处 判断 如果 key为null 则直接进入 0 位置找 ,因为 key为null 会put到 0位置
int hash = (key == null) ? 0 : hash(key);
//调用 indexFor 方法 得到 索引值 位置上的 entry对象
//遍历 entry 对象 的链表结构
for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
//如果 判断 如果 hash相等 并且 ( k的引用相等 或者 k的值相等 ) 则返回该对象
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
由于 不同的 值 得到的 hash 值 可能相等 所以还要进一步判断 引用 或 值 是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))
)
拓展
HashMap 遍历
方法一 entrySet
第一种是 通过 entrySet 方法 返回所有 entry的set集合 ,通过遍历这个集合 实现 :
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<>(15);
map.put("A","1");
map.put("B","2");
map.put("C","3");
//获取entrySet
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//遍历
for (Map.Entry<String, String> e : entrySet) {
System.out.println(e.getKey() + "->" + e.getValue());
}
}
方法二 迭代器
方法二 是通过迭代器 遍历
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<>(15);
map.put("A","1");
map.put("B","2");
map.put("C","3");
//通过迭代器遍历
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while(iterator.hasNext()){
Map.Entry<String, String> e = iterator.next();
System.out.println(e.getKey() + "->" + e.getValue());
}
}
HashMap 遍历时删除 元素
entrySet遍历
如果用第一种 遍历方式 删除 则会出现 ConcurrentModificationException 异常
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<>(15);
map.put("A","1");
map.put("B","2");
map.put("C","3");
//获取entrySet
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//遍历
for (Map.Entry<String, String> e : entrySet) {
if("B".equals(e.getKey())){
map.remove(e.getKey());
}
System.out.println(e.getKey() + "->" + e.getValue());
}
}
这是由于 HashMap类中定义了 HashIterator 迭代器,初始化中 定义了一些属性
这里 主要看 expectedModCount 这个属性 为 modCount
modCount:记录了 对map操作的次数
当遍历 entry时 会调用 Map.Entry 中 的 nextEntry 方法 ,
该方法中 会判断 medCount 值 是否与 expectedModCount 一致 不一致则抛出异常
medCount 不一致 是因为调用了 map.remove(key) 而 hashMap的remove 方法中并没有 更新 medCount 值
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
iterator遍历
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<>(15);
map.put("A","1");
map.put("B","2");
map.put("C","3");
//通过迭代器遍历
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while(iterator.hasNext()){
Map.Entry<String, String> e = iterator.next();
if("B".equals(e.getKey())){
iterator.remove();
}
System.out.println(e.getKey() + "->" + e.getValue());
}
}
原因是 iterator 的remove方法 同步了 expectedModCount 值
之后再 判断 时 已经正确 不会抛出异常
总结
针对Jdk7
HashMap的默认长度是多少?
调用无参构造,默认长度为16
HashMap为什么Key可为空?
put方法校验当key为null 时,会将元素存储到 table[0] 的位置上
HashMap的数据结构与底层原理?
1.7 采用数组 + 链表
当 key 冲突时 ,会形成链表
【拓展】关于1.8 后续会出文章详解
1.8 采用 数组 + 链表 + 红黑树
当碰撞时 形成链表,当链表长度大于TREEIFY THRESHOLD会形成红黑树
HashMap计算索引为什么是h & (length-1) ?
由于底层存放的是entry数组结构,计算索引时,要使得 取值范围刚好在 0 ~ 数组长度-1 的范围内。
第一想法可能是想到 用 取模 运算 h % length ? 不是相同的结果么?
不用取模运算 是因为 取模运算是 是 进行一步一步的除法,得到最终的余数。
计算机在二进制位运算 时的效率 要远大于 取模运算的。
HashMap的长度为什么要取2的次幂?
如果不采取2的次幂值,当进行put时,计算索引 " h & (length-1) " 时,转换为二进制位运算 最后一位 为0 ,
会大大增加不同hash值 计算索引值相同的几率,从而倒置碰撞的几率增大。
例如 key ="LL" hahs="2315" 按位与计算 转化为二进制
当前 length = 15 的话
h & (length-1) = h & 14
h 1001 0000 1011
0000 0000 1110
= 0000 0000 1010
= 10
假如 hash="2314"
h & (length-1) = h & 14
h 1001 0000 1010
0000 0000 1110
= 0000 0000 1010
= 10
不同的h值计算的索引一致
HashMap是线程安全的么?为什么?
不是线程安全的。
多线程在扩容时容易产生循环链表情况,当下次get到当前位置上时,会造成死循环!
原因是:
由于在hashMap 在扩容时 会将原数组上的值 转移到 扩容后的数组上,采用头插法。
会产生链表元素倒置情况。
若两个线程都进行扩容,进行完 Entry<K,V> next = e.next;该操作时,线程2挂起。
线程1执行完transfer方法后,线程2继续执行,由于已经存储了 数组开始时的引用,
在转移元素时会产生循环链表情况。【具体过程 可看上面扩容过程】
在调用get方法时,key匹配到产生循环链表上时,会产生死循环。
线程安全的Map有哪些?
我们会想到
HashTable
是因为 hashTable 在 put、get、size、remove 等等方法上 加上了synchronized 简单粗暴 保证了线程同步。
因为是 直接 synchronized 加在 方法上 效率受很大影响
ConcurrentHashMap
底层采用分段的数组 + 链表实现 只锁住当前段,效率较高
HashMap为什么在1.8中引用了红黑树?
1.7 采用数组 + 链表
当 key 冲突时 ,会形成链表
【拓展】关于1.8 后续会出文章详解
1.8 采用 数组 + 链表 + 红黑树
当碰撞时 形成链表,当链表长度大于TREEIFY THRESHOLD会形成红黑树
因为:
如果采用数组 + 链表的形式,当元素达到一定量的时候,并且key冲突的元素过多时,会导致链表过长。
我们都知道,链表结构的缺点 遍历查找时 大大降低了效率。
以上内容,若有不足或错误,还望指正。
不止于前 未来可期 ···