HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法),如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为O(1),因为最新的Entry会插入链表头部,仅需简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
transient int size:实际存储的key-value键值对的个数
int threshold:阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到.
final float loadFactor:负载因子,代表了table的填充度有多少,默认是0.75
transient int modCount:hashmap 的修改次数
如果负载因子取得太大,threshold与capacity太接近,当容量增大时,冲突会增加,造成同一地址链表过大;如果太小,哈希表太稀疏,浪费存储空间。负载因子可以大于1(即threshold大于数组长度,因为是链地址法)
put:首先判断key是否为null,若为null,则直接调用putForNullKey方法(存储位置为table[0]或table[0]的冲突链上)。若不为空则先计算key的hashcode值,然后对该哈希码值进行再哈希, 然后把哈希值和(数组长度-1) 进行按位与操作, 得到存储的数组下标,如果table数组在该位置处有元素,循环遍历链表,比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头(最先保存的元素放在链尾)。若table在该处没有元素,则直接保存。
get方法的实现相对简单是首先通过 key 的两次 hash 后的值与数组的长度-1 进行与操作, 定位到数组的某个位置, 然后对该列的链表进行遍历,通过key的equals方法比对查找对应的记录。当返回为 null 时, 你不能判断是没有找到指定元素, 还是在 hashmap中存着一个 value 为 null 的元素, 因为 hashmap 允许 key,value 为 null.
如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map。
如果不重写equals()方法,HashMap没有判断两个对象相等的标准。如果不重写hashcode(),将对用object默认的hashcode方法(根据对象地址生成hashcode),如果new了两个对象,它们的属性均相同,但由于是两个对象,所以object生成的hashcode不同,但在hashmap中这两个key应该当做相同的key,但不重写hashcode则无法实现。
hashMap实现没有锁的机制
一、在多线程情况下使用hashmap 进行put 操作会引起死循环,因为多线程会导致HashMap 的Entry链表形成环形数据结构。
具体分析一下hashMap实现:
1.先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。 如果这个元素所在的位置上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,而先前加入的放在链尾
public V put(K key, V value)
{
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry 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, y, value, i);
return null
void addEntry(int hash, K key, V value, int bucketIndex)
{
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry(hash, key, value, e);
//查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
if (size++ >= threshold)
resize(2 * table.length);
}
jdk1.8之前主要使用头插法,jdk 1.8以后使用尾插法。
2.如果size大小超过threshold 那么则要进行resize操作,建立一个更大的hash表。
void resize(int newCapacity)
{
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
3.多个线程同时往HashMap添加新元素时,多次resize会有一定概率出现死循环
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry e = src[j];
if (e != null) {
src[j] = null;
do {
Entry next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next; } while(e!=null);
}
}
我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,需要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,我们发现这三个entry都落到了第二个桶里面。
假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。
如果在取链表的时候从头开始取(现在是从尾部开始取)的话,则可以保证节点之间的顺序,那样就不存在这样的问题了。
二、put的时候导致的多线程数据不一致。
这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。