在Java中HashMap是一个常用且重要的容器,它基于哈希表实现,提供了高效的插入、删除和查找操作.本文我们将分别讲述JDK7中的HashMap。
使用
HashMap的使用非常简单,下面演示下存数据与取数据.
// 简单示例
public static void main(String[] args) {
// 创建HashMap
HashMap<Object, Object> map = new HashMap<>();
// 存数据,key-value
map.put("1", "rlj");
map.put("2", "mk");
map.put("3", "ali");
// 取数据,根据key获取对应value
System.out.println(map.get("1"));
System.out.println(map.get("2"));
System.out.println(map.get("3"));
}
// 输出
rlj
mk
ali
底层结构
JDK7中HashMap的底层是一个数组,每个数组元素是一个链表.
// enttry组成
K key; // 键值队中的key
V value; // 键值队中的value
Entry<K,V> next; // 下一个Entry对象
int hash; // key的hash值
构造方法
这里我们只看它的默认无参构造方法
// 默认数组大小16,加载因子0.75
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
put元素
下图为HashMap储存一个新元素的过程
public V put(K key, V value) {
// 第一次插入Map是空的,需要初始化数组
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果key = null,单独处理
if (key == null)
return putForNullKey(value);
// 计算key的哈希值
int hash = hash(key);
// 计算key对应的数组索引
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))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 如果没有找到相同的键,将新的键值对插入到链表的头部
addEntry(hash, key, value, i);
return null;
}
下面我们来逐步解析:
初始化数组
private void inflateTable(int toSize) {
// 根据传入的 toSize 参数,找到大于等于 toSize 的最小的 2 的幂次方值作为数组的容量。
// 这个操作确保数组的大小总是 2 的幂次方
int capacity = roundUpToPowerOf2(toSize);
// 根据数组的容量和加载因子(loadFactor)计算阈值
// 当 HashMap 中的元素个数超过了阈值时,就会触发扩容
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 根据计算得到的容量,创建一个新的 Entry 数组作为 HashMap 的存储空间
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
// 总结
1.根据toSize(默认16)计算数组的大小capacity,且capacity必须是2的次方幂(大于等于传入的toSize)
2.根据capacity和加载因子计算扩容的阈值
3.初始化新数组,大小为capacity
正常key处理
先来看正常key的处理逻辑
// 1.计算key的哈希值
// -> int hash = hash(key);
这个方法计算key的hash值,并进行三次向右位移和异或操作,将哈希值的高位和低位进行了混合,
以增加哈希码的随机性,使得元素在 HashMap 中的存储位置更加均匀、分布更广泛.
// 2.计算key对应的数组索引
// -> int i = indexFor(hash, table.length);
// ->
static int indexFor(int h, int length) {
return h & (length-1);
}
// 用于根据哈希值和数组长度计算哈希值在数组中的索引位置。
// 因为数组的大小length都是2的幂次方,所以length-1的二进制所有位都是1,通过与运算可以直接取哈希值的低位作为数组索引。这样可以快速定位数组索引.
// 3.替换旧值
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;
}
}
// 计算出索引下标后,拿到对应Entry对象
// 遍历entry链表,若存在相同的key值,则替换对应的value,并且返回旧值
// 4.增加新节点
// -> addEntry(hash, key, value, i);
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
// 在这个方法主要做了这些事:
1.若满足 当前数组大小大于等于阈值 && 当前要插入位置entry不为null -> 触发扩容
2.若发生了扩容,则重新计算key的hash值,根据新数组的长度计算数组索引
3.将新节点插入 -> createEntry()
// -> 这里是头插法
void createEntry(int hash, K key, V value, int bucketIndex) {
// 取出当前数组所在的第一个entry对象e
Entry<K,V> e = table[bucketIndex];
// 新创建的entry的next为e
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
// 举例:若当时链表为A-B,插入C后则为 -> C-A-B
下面我们来详细讲述下,HashMap的扩容机制
// 5.扩容
// -> resize(2 * table.length);
// ->
void resize(int newCapacity) {
// 取出旧的hash表
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
// 若旧的hash表长度已经为最大值,则不会再进行扩容
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// 创建新数组,长度为旧数组的2倍
Entry[] newTable = new Entry[newCapacity];
// 将旧数组中的元素转移到新数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 新数组作为最新的hash表
table = newTable;
// 重新计算阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
// -> 转移元素
// transfer(newTable, initHashSeedAsNeeded(newCapacity));
// ->
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);
}
// 重新计算hash
int i = indexFor(e.hash, newCapacity);
// 头插法插入到新数组中
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
// 根据上述代码:
1.hashMap扩容到目的主要两个:增加数组的长度、减少链表的长度
2.从旧数组到新数组,因为重新计算了hash,所以元素对应的数组下标可能发生改变了.
3.转移使用头插法,所以链表的顺序会相反
可以根据下图方便理解-元素转移示例图:
null key处理
从下面代码,我们可以知道HashMap的key是可以为null的,并且key=null的entry是存储在数组的第一索引位置的.其他逻辑类似正常key.
if (key == null)
return putForNullKey(value);
// -> 存储在第一个节点
private V putForNullKey(V value) {
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++;
addEntry(0, null, value, 0);
return null;
}
线程安全问题
HashMap是线程不安全的,就比如上述扩容过程中,如果有多个线程操作,可能导致底层链表循环,从而导致后续操作map导致死循环.
get元素
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
// ->getEntry(key)
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 计算hash值
int hash = (key == null) ? 0 : hash(key);
// 定位到对应数组
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))))
return e;
}
return null;
}
总结
以上便是JDK7 HashMap的底层存储逻辑,后续我们再来研究
JDK8的HashMap
>https://blog.csdn.net/2302_77659577/article/details/134690427
底层逻辑,比较两者之间的区别.