在1.7中HashMap的存储结构:数组 + 链表
数组的特点:存储空间有限且连续,扩容较麻烦,需要先new一个新数组,然后将旧数组的数据全部转移到新数组;查找较快,可直接通过索引得到对应的数据;在数组大小足够的情况下,若在非最后一个位置插入,也需要new新数组,然后转移旧数组数据,再插入
链表的特点:存储空间非连续,插入较为方便,可直接改变next指针;但是查找较慢,若需要查找的数据在链表的最后一个,则需要从头遍历到最后一个节点
HashMap综合了数组和链表的特点,通过Hash算法,将key值映射到固定的位置,利用合适的存储空间,将数据存储到特定的位置,当出现Hash冲突,会将数据添加到链表中
Hash算法:把任意长度的输入转变为固定长度的输出,输出空间要远远小于输入空间;对于输入来说,对于相同的输入,每次的计算结果都要相同,只要输入有丁点变换,计算结果都要不同;结果不可逆。
知道了HashMap的存储结构,那数组和链表中寸的是什么?直接存的数据吗?
并不是直接存储相应数据的,而是把对应的数据封装到一个HashMap的内部类Entry中,存放的实际是一个个Entry对象
注释中有很多内容,记得留意
一、Entry类
HashMap的数组和链表中存储的是Entry对象
static class Entry<K,V> implements Map.Entry<K,V> {
final K key; // 键
V value; // 值
Entry<K,V> next; // 当出现hash冲突的时候,使用头插法,设置节点的next节点
int hash; // key的hash值
//构造方法
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
// 返回这个entry对象存储的key
public final K getKey() {
return key;
}
// 返回这个entry对象存储的value
public final V getValue() {
return value;
}
//设置新的value
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//判断传入方法参数的entry对象和此对象是否相等
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
//只有两者的key和value都相等时,才返回true
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
//计算这个对象的hashcode
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
//当向HashMap中put数据的时候,当map中的key和要添加的这个key相等的时候,会调用此方法
void recordAccess(HashMap<K,V> m) {
}
//从HashMap中删除数据的时候,会调用此方法
void recordRemoval(HashMap<K,V> m) {
}
}
二、HashMap的使用
1.new一个HashMap
2.put数据
3.get数据
4.删除数据
public static void main(String[] args) {
Map<Integer,String> map = new HashMap<Integer,String>();
//向map中添加数据
map.put(1,"一");
map.put(2,"二");
map.put(3,"三");
//获取指定key对应的value值
System.out.println("key = 1,value = " + map.get(1));
System.out.println("遍历整个map");
for(Map.Entry<Integer,String> entry : map.entrySet()){
System.out.println("key = " + entry.getKey() + ",value = " + entry.getValue());
}
//根据对应的key删除map只的指定数据
map.remove(1);
System.out.println("删除后的数据");
for(Map.Entry<Integer,String> entry : map.entrySet()){
System.out.println("key = " + entry.getKey() + ",value = " + entry.getValue());
}
}
*******运行结果为*******
key = 1,value = 一
遍历整个map
key = 1,value = 一
key = 2,value = 二
key = 3,value = 三
删除后的数据
key = 2,value = 二
key = 3,value = 三
在后文中会对常用的方法进行深入的解析
三、HashMap的参数
HashMap中有几个重要的参数
1.Capacity:HashMap中的table(数组)的长度,默认为16,其值必须为2的幂,若不是,则会变为大于它并最接近它的那个2的幂;最大值为2的30次方,若传入的capacity的值大于2的30次方,将会变为2的30次方
2.load factor(加载因子):决定hashmap的阈值,默认为0.75。此值越大,可以在扩容前添加更多的元素,空间利用率更高,但是hash冲突概率会增大,链表更长,查找效率较低。此值越小,扩容更频繁,增加了不必要的性能开销,空间利用率低,hash冲突概率减小,查找效率较高。因此为了在时间和空间上得到一个更好的平衡,将此值设置为0.75较为合适
3.threshold(阈值):threshold = capacity * load factor,默认为12.当hash表的大小 >= 阈值时,就会以2倍的capacity对oldtable进行resize操作,期间会用到transfer方法进行数据的转移
4.size : 已经存储的entry的数量
四、深入HashMap源码
列出一些默认值
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
int threshold;
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size;
1.构造方法
//1.无参构造方法
public HashMap() {
//调用了构造方法3,传入默认的capacity和load factor
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//2.传入指定的capacity
public HashMap(int initialCapacity) {
//实际调用构造方法3
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//3.capacity和load factor都由自己传入
public HashMap(int initialCapacity, float loadFactor) {
//传入的capacity不能大于2的30次方,否则会等于被赋值为2的30次方
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
this.loadFactor = loadFactor;
//设置阈值,但并不是真正的阈值,最终会在第一次向map中put数据的时候,也就是初始化table的时候设置阈值
threshold = initialCapacity;
//空方法
init();
}
//4.传入一个map,新构造出map有传入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);
// 将传入的子Map中的全部元素逐个添加到HashMap中
putAllForCreate(m);
}
}
2.put
public V put(K key, V value)
// map中的存储数组table采用了懒加载的方式,会在第一次向map中put数据的时候,对table进行初始化
//此时的threshold实际是capacity
if (table == EMPTY_TABLE) {
//inflatetable会在下面详解
inflateTable(threshold);
}
//hashmap允许key和value为空,会将所有key为null的entry存储到table[0]中
if (key == null)
//putForNullKey会详解
return putForNullKey(value);
//若key不等于null,计算key的hash值
//在hash()方法中,key先调用hashcode方法,然后再对得到的结果进行9次位运算,其意义是:使计算结果分布均匀,减少hash冲突的发生
int hash = hash(key);
// 根据hash值,计算最终存储在table中的索引,一般来说是用table的长度对hash值取模,但是在indexFor方法中有略微不同
//对hash值和table.length - 1 进行&运算,结果和取模一致,但是因为位运算效率高,所以采取这个方法
/*
1.table.length为什么减1?
首先hash值是一个32位的数,table长度始终为偶数,若以偶数计算,其二进制最后一位都为0,计算得到的存储位都为偶数,
也就是说entry只会存储到偶数的位置上,一方面增加了hash冲突的概率,另一方面浪费空间
2.为什么不采用得到的hash值作为存储位置
hash值是一个32位的有符号的整数值,最大可达20多亿,也就是说table必须得有那么长,而实际不需要那么长,换种说法是,硬件
层面无法满足,直接使用hash值作为存储下表,会造成数组下表越界
*/
//i为在table上的存储位置
int i = indexFor(hash, table.length);
//若table[i]上已经有entry,则对这条链表进行遍历,查看是否有key相同的entry
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//若有key相同的entry,则用新value覆盖旧的vlaue,并返回旧的value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//若table[i] 没有entry,则添加一个entry,这个方法在下面会详讲
addEntry(hash, key, value, i);
return null;
}
inflateTable
这个方法只有在第一次向map中put值的时候会被调用
private void inflateTable(int toSize) {
//capacity的值只允许为2的幂,若不是,则会按照下面规则进行转换
//1.转换后的值必须大于它
//2.转换后的值必须最接近它
//3.转换后的值必须是2的幂
//举个🌰:传入17,则是被转换为32
int capacity = roundUpToPowerOf2(toSize);->>分析1
// 计算真正的阈值
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//用capacity初始化table
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
putForNullKey
private V putForNullKey(V value) {
//查找以table[0]为头节点的链表中是否已经存在key为null的节点,若存在,则用新vlaue覆盖旧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++;
//若没有,则使用头插法将节点插入到table[0]中
addEntry(0, null, value, 0);
return null;
}
addEntry
void addEntry(int hash, K key, V value, int bucketIndex) {//bucketIndex为要插入的数组下标
/*
插入前先判断当前的全部entry数量是否大于等于阈值,且要插入的table[bucketIndex]不为空,
同时满足以上条件会进行扩容操作,resize()方法在下文详解
*/
if ((size >= threshold) && (null != table[bucketIndex])) {
//以旧table数组长度的2倍扩容
resize(2 * table.length);
//重新计算要插入的这个key的hash值
hash = (null != key) ? hash(key) : 0;
//重新计算要插入的数组下表,这个新下表要么是原来的下标,要么是新下标 = 原下标 + 旧数组长度
bucketIndex = indexFor(hash, table.length);
}
// 1.2 若容量足够,则创建1个新的数组元素(Entry) 并放入到数组中--> 分析2
createEntry(hash, key, value, bucketIndex);
}
resize
void resize(int newCapacity) {
//保存旧数组
Entry[] oldTable = table;
//旧数组的长度
int oldCapacity = oldTable.length;
//如果旧数组的长度已经是最大值,则将阈值设置为int的最大值,然后返回
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//以旧数组的2倍长度创建一个新的数组
Entry[] newTable = new Entry[newCapacity];
//将数据从旧数组转移到新的数组中
transfer(newTable);
//将新数组的引用指向table
table = newTable;
//设置新的阈值
threshold = (int)(newCapacity * loadFactor);
}
transfer
这个方法在多线程的情况下会出现链表成环,进而当插入,查询的时候会死循环,待会用图示模拟单、双线程的转移情况
void transfer(Entry[] newTable) {
//保存旧数组的引用
Entry[] src = table;
//新数组的长度
int newCapacity = newTable.length;
//开始转移整个table包括table上链表的数据
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
//e不等于空,说明下面有链表
if (e != null) {
//环节1
src[j] = null;
do {
//保存下一个节点,防止断链
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
//使用头插法插入
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
多线程下链表成环的原因
请记住jdk1.7中的插入采用的是头插法。通过对比单线程下tansfer后的结果,可以发现同一链表上的元素的位置前后已经发生颠倒。而其中某个线程仍然是之前链表位置的指向,最终会成环;除此之外,当链表长度大于2时,还会造成原链表第三个位置及其之后的节点丢失。
3.get
get操作和put操作的原理基本相同,这里不再对源码进行分析,而给出流程描述
get(K key)
1.判断传入的是否为null,不为null则到流程2;为null,则到table[0]中查找,是否已有key == null的entry存在,若不存在则使用头插法添加节点;若存在key == null的节点,则用新的value覆盖旧的value,结束。
2.根据key计算hash值,再根据hash值计算在table中的下标i,遍历以table[i]为头节点的链表,找到与key相等的节点,并返回对应的value,结束。
五、总结
HashMap结合了数组和链表的特性,并利用好的Hash算法和扩容机制,减少了Hash冲突的发生,但多线程下仍不安全,会出现链表成环、数据丢失等情况,HashMap的主要内容就介绍到这。