HashMap

HashMap低层实现原理

HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法),如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为O(1),因为最新的Entry会插入链表头部,仅需简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

1.相关重要字段

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大于数组长度,因为是链地址法

2.put 和 get 方法

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.

3.线性不安全策略

如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。在迭代过程中,判断modCount跟expectedModCount是否相等,如果不相等就表示已经有其他线程修改了Map。

4.重写hashcode()和equals()方法

如果不重写equals()方法,HashMap没有判断两个对象相等的标准。如果不重写hashcode(),将对用object默认的hashcode方法(根据对象地址生成hashcode),如果new了两个对象,它们的属性均相同,但由于是两个对象,所以object生成的hashcode不同,但在hashmap中这两个key应该当做相同的key,但不重写hashcode则无法实现。

线性不安全的HashMap

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);
    }
}

HashMap_第1张图片
我们假设有两个线程同时需要执行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插入的记录就凭空消失了,造成了数据不一致的行为。

你可能感兴趣的:(Java并发容器)