Java容器源码分析——HashMap

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/ghw15221836342/article/details/99826493

本文主要参考了Java Collection Framework 源码剖析这位博主的专栏,写的很好,感兴趣的可以去看一下!

  • TreeMap:基于红黑树实现;
  • HashMap:基于哈希表实现;
  • HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁;
  • LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序;

容器中主要包括 CollectionMap 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表;
Map

HashMap

1、存储结构

Entry 是构成哈希表的基石,是哈希表所存储的元素的具体形式内部包含了一个Entry类型的数组table;

Entry内部存储着键值对,包含了四个字段,从next字段可以看出Entry是一个链表;
即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry;
拉链法

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; // hash(key.hashCode())方法的返回值
        final K key;//键值对的键 
        V value;//键值对的值
        Node<K,V> next;//下一个节点

        Node(int hash, K key, V value, Node<K,V> next) { //Node的构造函数
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

2、HashCode计算

在JDK1.8的源码中,hashcode的计算:高16位异或低16位

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

3、HashMap参数以及扩容机制

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)

为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证;

HashMap参数

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量是16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量是2的30次方
static final float DEFAULT_LOAD_FACTOR = 0.75f;//负载因子是0.75
static final int TREEIFY_THRESHOLD = 8;//这是一个阈值,当桶(bucket)上的链表数大于这个值时会转成红黑树
static final int UNTREEIFY_THRESHOLD = 6;//也是阈值同上一个相反,当桶(bucket)上的链表数小于这个值时树转链表
static final int MIN_TREEIFY_CAPACITY = 64;//树的最小的容量

初始容量是16,达到阈值扩容,阈值等于最大容量*负载因子,每次扩容2倍,总是2的n次方;

扩容机制:
为了保证HashMap的效率,系统必须要在容量达到threshold进行扩容。扩容操作十分耗时,需要重新计算这些元素在新table数组中的位置并且进行复制处理,看一下源码中的resize()操作;

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;

        // 若 oldCapacity 已达到最大值,直接将 threshold 设为 Integer.MAX_VALUE
        if (oldCapacity == MAXIMUM_CAPACITY) {  
            threshold = Integer.MAX_VALUE;
            return;             // 直接返回
        }

        // 否则,创建一个更大的数组
        Entry[] newTable = new Entry[newCapacity];

        //将每条Entry重新哈希到新的数组中
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);  // 重新设定 threshold
    }

重哈希主要是一个重新计算原HashMap中的元素在新table数组中的位置并进行复制处理的过程

  void transfer(Entry[] newTable) {

        // 将原数组 table 赋给数组 src
        Entry[] src = table;
        int newCapacity = newTable.length;

        // 将数组 src 中的每条链重新添加到 newTable 中
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;   // src 回收
                // 将每条链的每个元素依次添加到 newTable 中相应的桶中
                do {
                    Entry<K,V> next = e.next;
                    // e.hash指的是 hash(key.hashCode())的返回值;
                    // 计算在newTable中的位置,注意原来在同一条子链上的元素可能被分配到不同的子链
                    int i = indexFor(e.hash, newCapacity);   
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

4、get源码

HashMap只需要根据key的哈希值定位到table数组的某个特定的桶,查找并返回该key对应的value即可;

以JDK1.7中的代码为例
public V get(Object key){
	//若为null,调用getForNullKey方法返回对应的value;
	if(key==null)
		//从table的第一个桶中寻找key为null的映射;若不存在,直接返回null;
		return getForNullKey();
	//根据key的hashCode值重新计算它的hash码
	int hash = hash(key.hashCode());
	//找到table数组内对应的桶;
	for(Entry<K,V> e = table[indexFor(hash,table.length)];
						e!=null;
						e = e.next;){
		Object k;
		//若搜索的key与查找的key相同,则返回相应的value
		if(e.hash==hash&&((k==e.key)==key||key.equals(k)))
			return e.value;
	}
	return null;	
}
//针对键值为NULL的键值对
private V getForNullKey() {
        // 键为NULL的键值对若存在,则必定在第一个桶中
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        // 键为NULL的键值对若不存在,则直接返回 null
        return null;
    }

调用HashMap中的get(Object key)的方法后,若返回值是NULL,存在以下两种可能:

  • 该key对应的值就是NULL;
  • HashMap中不存在该key;

5、put源码

HashMap保存数据的过程,先判断key是否为null,若为null,则直接调用putForNullKey方法;若不为空,则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组中该位置有元素,查找是否存在相同的key,若存在则覆盖原有key的value,否则将该元素保存在链表头部(最先保存的元素放在链表尾部),若table此处没有元素,则直接保存;

public v put(K key,V value){
	//当key==null时,调用putForNullKey,并将该键值保存到table上的第一个位置
	if(key==null)
		return putForNullKey(value);
	//根据key的hashCode计算hash值
	int hash = hash(key.hashCode());
	
	//计算该键值对在数组中的存储位置(判断在哪个桶)
	int i = indexFor(hash,table.length);

	//在table的第i个桶上进行迭代,寻找key保存的位置
	for(Entry<K,V> e = table[i];e!=null;e=e.next){
		Object k;
		//判断该条链表上是否存在hash值相同且key值相等的映射,若存在,直接覆盖value,并返回旧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++;//修改次数+1,快速失败机制
	//原HashMap中无该映射,将其添加至链表头部;
	addEntry(hash,key,value,i);
	return null;	
}

void addEntry(int hash,K key,V value,int bucketIndex){
	//获取bucketIndex处的链表
	Entry<K,V> e = table[bucketIndex];
	//将新创建的 Entry 链入 bucketIndex处的链表的表头 	
	table[bucketIndex] = new Entry<K,V>(hash,key,value,e);//参数e,是Entry.next;
	//如果size超过threshold,扩充table大小,再散列
	if(size++>=threshold)
		resize(2*table.length);
}

通过hash()方法取得了Key的hash值,但如何才能保证元素能够均匀的分不到table的每个桶中?
HashMap采用了indexFor方法处理,简单高效。

static int indexFor(int h,int length){
	return h&(length-1);//等价于取模运算
}

对NULL键的特别处理:putForNullKey()
HashMap 中可以保存键为NULL的键值对,且该键值对是唯一的。若再次向其中添加键为NULL的键值对,将覆盖其原值。此外,如果HashMap中存在键为NULL的键值对,那么一定在第一个桶中

private v putForNullKey(V value){
	//若key==null,则将其放入table的第一个桶内
	for(Entry<K,V> e = table[0];e != null;e = e.next){
		if(e.key == null){
			//若已经存在key为null的键替换其值,并返回旧值
			V oldValue = e.value;
			e.value = value;
			e.recordAccess(this);
			return oldValue;
		}
	}
	modCount++;
	addEntry(0,null,value,0);//否则将其添加到table[0]桶内
	return null;
}

6、JDK 1.8中的优化(HashMap)

Java8中的改进

  1. HashMap是数组+链表+红黑树;当链表长度>=8时转化为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能;
  2. Java8中对于hashMap的扩容不是重新计算所有元素在数组的位置,而是使用2次幂的扩展;元素要么在原位置,要么是在原位置再移动2次幂的位置;
  3. HashMap在存放自定义类的时候,需要自定义类中的hashCode和equals,通过hash(hashCode)然后模运算,然后定位在Entry数组的下标,遍历之后的链表,通过equals比较有没有相同的key,有就直接覆盖,没有就重新创建一个Entry.

7、常见问题

1、HashMap为什么线程不安全?
主要是由于Hash冲突扩容导致的;
HashMap在扩容的时候可能会生成环形链表,造成死循环;
HashMap采用链表法来解决Hash冲突当A线程和B线程同时对一个数组位置调用addEntry,两个线程同时得到现在的头结点,那么其中一个线程的写入就会造成另一个线程被覆盖,导致写入操作丢失;
当多个线程检测到总数量超过阈值执行resize操作,各自生成新的数组并且rehash后给map底层的table,最终只会有一个线程生成的新数组被赋给table变量,其它线程均会丢失;
要想实现线程安全,就需要使用collections类的静态方法synchronizeMap()实现;

2、HashMap中的key可以是任意对象或类型吗?

  • 可以为null,但不能是可变对象,可变对象的属性改变,HashCode也会发生改变,导致无法查找到Map中的数据;
  • 保证对于成员变量的改变能够使得对象的哈希值不变;

3、HashMap为什么可以插入null值
先判断key是否为null,若为null,则直接调用putForNullKey方法去遍历table[0]桶内的链表,寻找e.key == null,没有找到就遍历结束;找到了就采用value去覆盖oldValue,并且返回oldValue;如果在table[0]Entry链表中没有找到就调用addEntry方法添加一个key为null的Entry;
4、HashMap在高并发情况下会出现什么问题
扩容问题,上面提到了扩容对于HashMap的影响;
扩容问题会引起数据丢失,也会造成链环导致死循环;
链环
多线程扩容,如果线程1和线程2都需要进行扩容;
在线程1中,我们发现这三个entry都落到了第二个桶里面;假设线程1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B];

线程2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A];

此时线程1重新被调度运行,此时的线程1持有的引用是已经被线程2 resize之后的结果;线程1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next;

通过线程2的resize之后,[7,B]的next变为了[3,A]。此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。

5、HashMap和HashSet之间的区别
HashMap和HashSet之间的区别
HashSet基于HashMap来实现,HashSet中存储的是一个对象,其中没有重复的元素
Hashset 底层是Hashmap 但是存储的是一个对象,Hashset 实际将该元素e 作为key 放入Hashmap,当key 值(该元素e)相同时,只是进行更新value,并不会新增加,所以set 中的元素不会进行改变。

展开阅读全文

没有更多推荐了,返回首页