Java集合(四)Map、HashMap

    Java集合(一)集合框架概述
    Java集合(二)List、ArrayList、LinkedList
    Java集合(三)CopyOnWriteArrayList、Vector、Stack
    Java集合(四)Map、HashMap
    Java集合(五)LinkedHashMap、TreeMap
    Java集合(六)Hashtable、ConcurrentHashMap
    Java集合(七)Set、HashSet、LinkedHashSet、TreeSet
    Java集合(八)BlockingQueue、ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue

【Map】

一、Map简介

  Collection接口的实现类中存储的是具体的单个元素,Map中存储的是键值对。
  常用的Map实现类有:HashMap、LinkedHashMap、TreeMap、HashTable和ConcurrentHashMap。

优点缺点
HashMap基于哈希表实现,查询快,效率高元素存储时无序,非线程安全
LinkedHashMap基于哈希表和链表实现,可以保留元素插入时的顺序非线程安全
TreeMap存储的元素有序非线程安全
HashTable线程安全,不允许null值效率低
ConcurrentHashMap线程安全,性能较好

  至于具体使用哪个Map时,参考:

  在Map中,HashMap是适用性最广的。也就是说如果要确定用Map,但不确定用哪种Map,一般可以使用HashMap。
  如果要用线程安全的Map,就用ConcurrentHashMap。

二、常见Map实现类比较*

2.1 HashMap和TreeMap

HashMapTreeMap
是否线程安全非线程安全非线程安全
元素是否排序
效率高(哈希表)低(树)
底层实现JDK1.7是数组+链表,JDK1.8是数组+链表+红黑树红黑树
适用场景元素不需要排序元素需要排序

2.2 HashMap和ConcurrentHashMap

HashMapConcurrentHashMap
是否线程安全非线程安全线程安全
底层实现数组+链表(JDK1.8之前)
数组+链表+红黑树(JDK1.8之后)
segment数组+链表(JDK1.8之前)
数组+链表+红黑树(JDK1.8之后)
是否支持Null值键值都允许有Null键值都不允许有Null

  可以将ConcurrentHashMap理解为HashMap的高并发版本。

2.3 ConcurrentHashMap和Hashtable

ConcurrentHashMapHashtable
线程安全的实现方式JDK1.7是segment分段锁
JDK1.8是CAS和synchronized
全局synchronized锁
效率
默认大小1611
扩容2倍newCapacity = oldCapacity * 2 + 1
底层数据结构JDK1.7是数组+链表
JDK1.8是数组+链表+红黑树
数组+链表

  ConcurrentHashMap和Hashtable最大的差异体现在实现线程安全的方式上。

  • Hashtable(实现线程安全的方式)
      Hashtable(同一把锁) :使用synchronized来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态。如使用put添加元素,另一个线程不能使用put添加元素,也不能使用get,效率很低。
  • JDK1.7版本的ConcurrentHashMap(实现线程安全的方式)
      在JDK1.7中,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,即最多同时有16个线程可以访问,是Hashtable效率的16倍
      ConcurrentHashMap中的Segment实现了ReentrantLock,所以Segment是一种可重入锁。
      一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构,一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得对应的Segment的锁。
  • JDK1.8版本的ConcurrentHashMap
      到了JDK1.8的时候,ConcurrentHashMap已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用synchronized和CAS来操作。

      在并发环境下,选择Map实现类时,可以选择ConcurrentHashMap。
      虽然ConcurrentHashMap的整体性能要优于Hashtable,但在某些场景中,ConcurrentHashMap依然不能代替Hashtable。例如,在强一致的场景中ConcurrentHashMap就不适用,原因是ConcurrentHashMap中的get、size等方法没有用到锁,ConcurrentHashMap是弱一致性的,因此有可能会导致某次读无法马上获取到写入的数据。
      如果对数据有强一致要求,则需使用Hashtable;在大部分场景通常都是弱一致性的情况下,使用ConcurrentHashMap即可;如果数据量在千万级别,且存在大量增删改操作,则可以考虑使用ConcurrentSkipListMap。

2.4 HashTable和HashMap

  HashTable和HashMap的不同点:

HashTableHashMap
线程安全性线程安全线程不安全
效率
对null key和null value的支持key、value都不能为null,否则会抛出空指针异常key、value都可为Null;
不过HashMap最多只允许一条记录的键为null;
允许多条记录的值为null。
遍历方式支持Iterator和Enumeration两种遍历只支持Iterator遍历
初始容量默认11默认16
扩容大小2n+12n
底层结构数组+链表数组+链表+红黑树
  • 是否支持fail-fast
      HashMap的迭代器(Iterator)是fail-fast迭代器;而Hashtable的enumerator迭代器不是fail-fast的。
      HashMap提供对key的Set进行遍历,因此它是fail-fast的;HashTable提供对key的Enumeration进行遍历,它不支持fail-fast。
  • 计算hash值的方法
     Hashtable直接使用对象的hashCode(即根据对象的地址或者字符串或者数字算出来的int类型的数值),然后再使用除留余数来获得最终的位置。 JDK1.8中Hashtable的哈希值计算方式:
   int hash = key.hashCode();
   int index = (hash & 0x7FFFFFFF) % tab.length;

  Hashtable在计算元素的位置时需要进行一次除法运算,而除法运算是比较耗时的。
  HashMap为了提高计算效率,将哈希表的大小固定为了2的幂。在取模预算时,不需要做除法,只需要做位运算(位运算比除法的效率要高很多)。 JDK1.8中HashMap的哈希值计算方式:

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

三、Map的相关问题

3.1 Map接口提供了哪些不同的集合视图*

  Map接口提供三个集合视图。

  • 1、key的集合视图
	//a set view of the keys contained in this map
	Set<K> keySet();

  返回Map中包含的所有key的一个Set视图。

  • 2、value的集合视图
	//a collection view of the values contained in this map
	Collection<V> values();

  返回一个Map中包含的所有value的一个Collection视图。

  • 3、键值对的集合视图
	//a set view of the mappings contained in this map
	Set<Map.Entry<K, V>> entrySet();

  返回一个map钟包含的所有映射(键值对)的一个集合视图。

3.2 在解决hash冲突的时候,为什么选择先用链表,再转红黑树*

  因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个的时候,链表结构可以保证查询性能。当元素大于8个的时候, 红黑树搜索时间复杂度是O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是插入和删除节点的效率变慢了。如果一开始就用红黑树结构,元素太少,插入和删除节点的效率又比较慢,浪费性能。

3.3 HashMap设计成数组(哈希表)+链表(和红黑树)的优点

  哈希表的优点:在哈希表中进行添加、删除、查找等操作,性能很好,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。
  当哈希冲突时,一个节点就要保存多个数据。使用链表+红黑树,可以处理哈希冲突的情况,并且保存不错的查询效率。

【HashMap】

一、HashMap介绍

  HashMap是基于哈希表的Map实现,其底层结构是数组,但数组中存的不是普通的某种类型的元素,存储的是链表或红黑树(1.7是数组+链表,1.8则是数组+链表+红黑树结构)。

  • 哈希表
      哈希表可以简单理解为哈希表=哈希函数+数组。比如一个键值对(key,value),哈希函数可以通过key计算出一个值,这个值就是数组的下标index,然后在数组的index把value存进去,这样就完成了一个键值对的存储。
      HashMap使用哈希表来存储的,哈希表为解决冲突,可以采用开放地址法和链地址法等来解决,HashMap采用了链地址法。链地址法简单来说就是数组加链表的结合,在每个数组元素上都有一个链表结构,当数据被hash后,得到数组下标位置,把数据放在对应数组下标元素的链表上。

  • JDK1.7中的HashMap

      HashMap里面是一个数组,数组中的每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类Entry的实例,Entry包含四个属性:key、value、hash和用于单向链表的next。

  • JDK1.8中的HashMap结构
      JDK1.8中对HashMap进行了一些修改,最大的不同就是利用了红黑树,所以其由数组+链表+红黑树组成。
      在JDK1.7中的HashMap中,查找元素时,根据hash值能够快速定位到数组的具体下标。之后需要顺着链表一个个比较下去才能找到我们需要的元素,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在JDK1.8中,当链表中的元素超过了8个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
      JDK1.8版本里,HashMap的结构示例:

1.1 HashMap的特点*

  • 1、底层实现是 链表+数组+红黑树(JDK 1.8)
      JDK 1.8的HashMap的数据结构是数组为主干,链表或红黑树为辅助(链表节点较少时仍然是以链表存在,当链表节点较多时(大于8)会转为红黑树)。当一个元素要存储HashMap时,先通过哈希方法找到要存入的数组的下标,然后将value存在对应的位置上。
      JDK1.8中引入红黑树,提高了HashMap的性能。
  • 2、HashMap的底层是个Node(键值对) 数组
      在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
  • 3、HashMap的key和value都允许为空
  • 4、HashMap的key不允许重复
      当向HashMap中存入key相同的数据时,后者会覆盖前者,value允许重复。
  • 5、非线程安全
      HashMap是非线程安全的,在并发场景下使用线程安全的集合ConcurrentHashMap来代替。
  • 6、元素无序
      此处的无序指的是遍历HashMap中元素的顺序和存入HashMap中元素的顺序是基本不一致的(HashMap的遍历顺序是不确定的)。
  • 7、2倍扩容
      2倍扩容可以保证元素减少哈希碰撞的概率,使元素分布得更加均匀。

1.2 HashMap的使用

  • 1、构造方法
	//构造一个空的 HashMap ,具有默认初始容量(16)和默认负载因子(0.75)
	public HashMap()
	//构造一个空的 HashMap,具有指定的初始容量和默认负载因子(0.75)
	public HashMap(int initialCapacity)
	//构造一个空的 HashMap,具有指定的初始容量和负载因子
	public HashMap(int initialCapacity, float loadFactor)
  • 2、遍历
	//获取HashMap中key的集合,用来遍历key
	public Set< K > keySet()
	//返回HashMap中Entry构成的Set,用来遍历<key,value>键值对
	public Set<Map.Entry<K,V>> entrySet()
	//返回此HashMap中包含的value的集合
	public Collection< V > values()
  • 3、获取某个key对应的value
	//返回到指定key所映射的value,不存在该key则返回null
	public V get(Object key)
	//返回到指定key所映射的值,不存在该key则返回默认值
	public V getOrDefault(Object key, V defaultValue)	
  • 4、添加键值对
	//存入一个键值对,如果key存在则替换原有的键值对
	public V put(K key, V value)
  • 5、删除键值对
	//从该Map中删除指定key的映射
	public V remove(Object key)
	//删除指定key和指定value构成的键值对
	public boolean remove(Object key, Object value)	
  • 6、键值对替换
	//替换指定key的value
	public V replace(K key, V value)
	//只有当指定key和指定value构成键值对时,才替换指定的value
	public boolean replace(K key, V oldValue, V newValue)
  • 7、包含判断
	//如果此HashMap包含指定key,则返回true
	public boolean containsKey(Object key)
	//如果此HashMap包含指定value,则返回true
	public boolean containsValue(Object value)
  • 8、其他
	//判断HashMap中是否包含元素
	public boolean isEmpty()
	//清空HashMap
	public void clear()	
	//返回此HashMap中键值对的数量
	public int size()	

二、从源码理解HashMap

  先看一些变量:

	//默认的初始容量(容量为HashMap中槽的数目)是16,且实际容量必须是2的整数次幂
	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
	//最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换) 
	static final int MAXIMUM_CAPACITY = 1 << 30;
	//默认负载因子
	static final float DEFAULT_LOAD_FACTOR = 0.75f;
	//当桶(bucket)上的结点数大于这个值时会转成红黑树
	static final int TREEIFY_THRESHOLD = 8;
	//当桶(bucket)上的结点数小于这个值时树转链表
	static final int UNTREEIFY_THRESHOLD = 6;
	//桶中结构转化为红黑树对应的table的最小值
	static final int MIN_TREEIFY_CAPACITY = 64;
	//存储数据的Entry数组,长度是2的幂
	//HashMap采用链表法解决冲突,每一个Entry本质上是一个单向链表
	transient Node<K,V>[] table;
	//存放具体元素的集
	transient Set<Map.Entry<K,V>> entrySet;
	//存放元素的个数,不等于数组的长度
	transient int size;
	//每次扩容和更改map结构的计数器
	transient int modCount;
	//临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
	int threshold;
	//负载因子
	final float loadFactor;

  从源码中能看出HashMap中的一些默认值:

类别
初始容量16
最大容量2的30次方
负载因子0.75
链表转红黑树的节点阈值8
红黑树转链表的节点阈值6
链表转红黑树数组的元素个数阈值64

2.1 Node*

  在HashMap的底层数组中,存储的元素是Node。Node是HashMap 的一个内部类,其实现了Map.Entry接口,本质就是一个映射(键值对)。从Node的定义中只有next可以看出,Node是单向的

	//链表节点, 继承自Entry
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //哈希值,用来定位索引位置
        final K key;  //key
        V value;  //value
        Node<K,V> next;  //链表后置节点

		//省略一些方法...
		
		//每一个节点的hash值,是将key的hashCode和value的hashCode异或得到的
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

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

  可以看出:

  • 1、HashMap的一个节点由4部分组成:哈希值、key、value和对下一个节点的引用。
  • 2、hashcode,是根据key和value的哈希值共同计算的。
  • 3、要比较两个节点是否相等时,节点中的key和value都相等才行。

2.2 哈希函数*

  即hash(Object key)方法:

	//JDK1.8中的哈希方法
	static final int hash(Object key) {
	     int h;
	     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	}

  HashMap的数据结构是:链表+数组/红黑树,所以HashMap里的元素位置应该尽量分布均匀些,使得每个位置上的元素尽量少(最理想的情况是每个位置只有一个元素)。
  我们可以将hash方法的内容分成两部分:

	h = key.hashCode(); //1、计算出key的哈希值
	h ^ (h >>> 16); //2、高位参与运算

  有了key对应的哈希值,就可以使用(tab.length - 1) & hash来计算该key在数组中的下标,进而进行其他操作。
  不过在JDK1.8中,取模运算的步骤并没有被封装到hash方法里,什么时候用什么时候计算:

	n = tab.length;
	(n - 1) & hash; //取模运算

  因此,JDK1.8中根据key计算对应的数组下标共三步:

  1. 获取key的hashCode值。
  2. 高位运算。原始hashcode值的高16位异或低16位((h = key.hashCode())^ (h >>>16)
  3. 取模运算。

  HashMap底层的数组长度length总是2的n次方时, h&(length -1)运算等价于h % length (对length取模),但&比%具有更高的效率

2.3 创建HashMap对象

  • 1、指定“容量大小”和“加载因子”
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // HashMap的最大容量只能是MAXIMUM_CAPACITY(2的30次方)                                     
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //加载因子不能小于0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        //根据初始容量,计算出扩容临界值
        this.threshold = tableSizeFor(initialCapacity);
    }
  • 2、其他的构造函数
	//只指定“容量大小”
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

	//初始容量为16,加载因为为0.75
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
    }

  在以上的三个构造方法中,常用的是无参构造方法。使用无参构造方法时,创建的HashMap初始容量为16,负载因子为0.75,图示:

2.4 添加元素*

  HashMap中添加元素的过程:

  HashMap的put方法详细流程总结:

  • 1、put(key, value)中直接调用了内部的putVal方法,并且先对key进行了hash操作。
      【put方法中计算key的hash值,并调用putVal方法】。
  • 2、putVal方法中,先检查HashMap数据结构中的索引数组表是否位空,如果是的话则进行一次resize操作。
      【判断数组是否为空,为空就扩容】。
  • 3、以HashMap索引数组表的长度减一与key的hash值进行与运算,得出在数组中的索引。如果索引指定的位置值为空,则新建一个k-v的新节点。
      【如果计算出来的下标位置没值,直接插入】。
  • 4、如果不满足的3的条件,则说明索引指定的数组位置的已经存在内容,这个时候称之哈希碰撞。
      【如果计算出来的位置没值,代表发生了哈希碰撞】;
  • 5、判断key索引到的节点(暂且称作被碰撞节点)的hash、key是否和当前待插入节点(新节点)的一致。如果新旧节点的内容不一致时,则再看被碰撞节点是否是树类型,如果是树类型的话,则按照树的操作去追加新节点内容;如果被碰撞节点不是树类型,则说明当前发生的碰撞在链表中(此时链表尚未转为红黑树),此时进入一轮循环处理逻辑中。
      【判断发生哈希碰撞的位置是红黑树结构还是链表结构,如果是树类型的话,则按照树的操作去追加新节点,否则进入树结构处理逻辑】。
  • 6、循环中,先判断被碰撞节点的后继节点是否为空,为空则将新节点作为后继节点,作为后继节点之后并判断当前链表长度是否超过最大允许链表长度8,如果大于的话,需要进行一轮是否转树的判断;如果在一开始后继节点不为空,则先判断后继节点是否与新节点相同,相同的话就记录并跳出循环;如果两个条件判断都满足则继续循环,直至进入某一个条件判断然后跳出循环。
      【挨个遍历链表上的Node的key和新插入节点的key是否相同,相同就替换value,否则追加。此过程中还要注意是否链表长度>8,大于的话,就调用treeifyBin】。
  • 7、步骤8中转树的操作treeifyBin,如果map的索引表为空或者当前索引表长度还小于64(最大转红黑树的索引数组表长度),那么进行resize操作就行了;否则,就转换成红黑树,如果被碰撞节点不为空,那么就顺着被碰撞节点这条树往后新增该新节点。
      【treeifyBin方法的逻辑:如果数组长度>64才转换成红黑树,否则仅resize,再追加元素】。
  • 8、最后,回到那个被记住的被碰撞节点,如果它不为空,默认情况下,新节点的值将会替换被碰撞节点的值,同时返回被碰撞节点的值。
      【返回旧value值】。
  • 9、在上面判断流程走完之后,计算HashMap全局的modCount值,并判断当前元素数是否大于容量扩充的阈值,大于的话则进行一轮resize操作。
      【++modCount,再次判断是否需要扩容】。

  HashMap的put方法简单流程总结:

  1. 如果table没有初始化就先进行初始化过程。
  2. 使用hash算法计算key的索引。
  3. 判断索引处有没有存在元素,没有就直接插入。
  4. 如果索引处存在元素,则遍历插入,有两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入。
  5. 某个Node链表的数量大于阈值8 && 数组容量 >=64,就要转换成红黑树的结构。
  6. 添加成功后会检查是否需要扩容。

  添加元素代码:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

	/**
     * @param hash 根据静态方法hash获得的hash值
     * @param key 键
     * @param value 值
     * @param onlyIfAbsent if true,当键相同时,不修改已存在的值
     * @param evict if false, the table is in creation mode. 不用管
     * @return previous value, or null if none 以前的value,或null
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //tab 哈希数组,p 该哈希桶的首节点,n hashMap的长度,i 计算出的数组下标
        Node<K,V>[] tab; Node<K,V> p; int n, i;
		//1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则
        //直接在该索引位置新增一个节点即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //发生了哈希碰撞(也就是该数组下标上已经存在着其它节点),有以下几种情况
        else {
            //e 临时节点的作用, k 存放该当前节点的key
            Node<K,V> e; K k;
            // 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点
            //即为要查找的目标节点,将p节点赋值给e节点(第一种情况)
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法
            //查找目标节点(第二种情况,hash值不等于首节点)
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行
                //查找,使用binCount统计链表的节点数(第三种情况,hash值不等于首节点)
                //遍历链表
                for (int binCount = 0; ; ++binCount) {
                    // 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                         // 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法
                         //将链表节点转为红黑树节点,
                        // 减一是因为循环是从p节点的下一个节点开始的
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                        	//把这个链表转化成红黑树,然后直接退出循环
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 8.如果e节点的hash值和key值都与传入的相同,则e节点
                    //即为目标节点,跳出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    // 将p指向下一个节点
                    p = e;
                }
            }
            // 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖
            //该节点的value,并返回oldValue
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

2.5 删除元素

//移除某个节点
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}
 
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    //tab 哈希数组,p 待删除节点的Node,n 长度,index 当前数组下标
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 1.如果table不为空并且根据hash值计算出来的索引位置不为空, 将该位置的节点赋值给p
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        //node 存储要删除的节点,e 临时变量,k 当前节点的key,v 当前节点的value
        Node<K,V> node = null, e; K k; V v;
        // 2.如果p的hash值和key都与入参的相同, 则p即为要删除的目标节点, 赋值给node
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        // 3.否则将p.next赋值给e,向下遍历节点
        else if ((e = p.next) != null) {
            // 3.1 如果p是TreeNode则调用红黑树的方法查找节点
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            // 3.2 否则,进行普通链表节点的查找
            else {
                do {
                    // 当节点的hash值和key与传入的相同,则该节点即为目标节点
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;	
                        break;
                    }
                    p = e;  // p节点赋值为本次结束的e,在下一次循环中,e为p的next节点
                } while ((e = e.next) != null); // e指向下一个节点
            }
        }
        // 4.如果node不为空(即根据传入key和hash值查找到目标节点),则进行移除操作
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 4.1 如果是TreeNode则调用红黑树的移除方法
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            // 4.2 如果node是该索引位置的头节点则直接将该索引位置的值赋值为node的next节点,
            // “node == p”只会出现在node是头节点的时候,如果node不是头节点,则node为p的next节点
            else if (node == p)
                tab[index] = node.next;
            // 4.3 否则将node的上一个节点的next属性设置为node的next节点,
            // 即将node节点移除, 将node的上下节点进行关联(链表的移除)
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            // 5.返回被移除的节点
            return node;
        }
    }
    return null;
}

2.6 获取元素

//以key为条件,找到返回value。没找到返回null
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
 
final Node<K,V> getNode(int hash, Object key) {
	//first 头结点,e 临时变量,n 长度,k key
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1.对table进行校验:table不为空 && table长度大于0 && 
    // table索引位置(使用table.length - 1和hash值进行位与运算)的节点不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 2.检查first节点的hash值和key是否和入参的一样,如果一样则first即为目标节点,直接返回first节点
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 3.如果first不是目标节点,并且first的next节点不为空则继续遍历
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                // 4.如果是红黑树节点,则调用红黑树的查找目标节点方法getTreeNode
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                // 5.执行链表节点的查找,向下遍历链表, 直至找到节点的key和入参的key相等时,返回该节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    // 6.找不到符合的返回空
    return null;
}

2.7 是否包含某个key/value

  即containsKey和containsValue方法:

	public boolean containsKey(Object key) 
	{
		//调用核心方法getNode,判断是否存在对应节点
		return getNode(hash(key), key) != null;
	}

	//其实就是遍历Node<K,V>数组,看要查询的指定value是否在数组的value中
    public boolean containsValue(Object value) {
        Node<K,V>[] tab; V v;
        if ((tab = table) != null && size > 0) {
            for (int i = 0; i < tab.length; ++i) {
                for (Node<K,V> e = tab[i]; e != null; e = e.next) {
                    if ((v = e.value) == value ||
                        (value != null && value.equals(v)))
                        return true;
                }
            }
        }
        return false;
    }

2.8 遍历

  • 1、用于遍历键值对中的键
    public Set<K> keySet() {
        Set<K> ks;
        return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
    }
  • 2、用于遍历键值对中的值
    public Collection<V> values() {
        Collection<V> vs;
        return (vs = values) == null ? (values = new Values()) : vs;
    }
  • 3、遍历键值对
    public Set<Map.Entry<K,V>> entrySet() {
        Set<Map.Entry<K,V>> es;
        return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
    }

2.9 扩容机制*

  • 1、在JDK1.8中,当HashMap中的键值数量对大于阀值(目前容量 * 负载因子)时或者初始化时,就调用resize方法进行扩容;
  • 2、每次扩展的时候,都是扩展2倍
  • 3、扩展后Node对象的位置要么在原位置,要么移动到原始位置+增加的数组大小的位置。例如capacity为16,索引位置5的节点扩容后,只可能分布在新表索引位置5索引位置21(5+16)

  在putVal()中,在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方。
  在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上。
  导致JDK1.8中,HashMap扩容后,同一个索引位置的节点重hash最多分布在两个位置的根本原因是:

  1. table的长度始终为2的n次方;
  2. 索引位置的计算方法为 “(table.length - 1) & hash”。

  HashMap扩容是一个比较耗时的操作,定义HashMap时尽量给个接近的初始容量值。
  1.8扩容机制:当元素个数大于threshold时,会进行扩容,使用2倍容量的数组代替原有数组。采用尾插入的方式将原数组元素拷贝到新数组。1.8扩容之后链表元素相对位置没有变化,而1.7扩容之后链表元素会倒置。
  1.7链表新节点采用的是头插法,这样在线程一扩容迁移元素时,会将元素顺序改变,导致两个线程中出现元素的相互指向而形成循环链表,1.8采用了尾插法,避免了这种情况的发生。
  `头插法:1.7将新元素放到数组中,原始节点作为新节点的后继节点。尾插法:1.8遍历链表,将元素放置到链表的最后。

  原数组的元素在重新计算hash之后,因为数组容量n变为2倍,那么n-1的mask范围在高位多1bit。在元素拷贝过程不需要重新计算元素在数组中的位置,只需要看看原来的hash值新增的那个bit是1还是0,是0的话索引没变,是1的话索引变成“原索引+oldCap”(根据 e.hash & (oldCap - 1) == 0 判断) 。这样可以省去重新计算hash值的时间,而且由于新增的1bit是0还是1可以认为是随机的,因此resize的过程会均匀的把之前的冲突的节点分散到新的bucket。

final Node<K,V>[] resize() {
	//oldTab 为当前表的哈希桶
    Node<K,V>[] oldTab = table;
    //记录一下原来哈希数组的长度、临界值
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    //初始化新哈希表的长度和临界值
    int newCap, newThr = 0;
    // 1.原数组的容量>0
    if (oldCap > 0) {
        // 1.1 判断原数组的容量是否超过最大容量值(1 << 30):如果超过则将阈值设置
        //为Integer.MAX_VALUE,并直接返回原数组。
        // 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯地
        //将阈值扩容到最大
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 1.2 否则,将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16,
        // 则将新阈值设置为原来的两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; 
    }
    // 2.如果当前表是空的,但是有阈值。代表是初始化时指定了容量、阈值的情况
    else if (oldThr > 0)
    	//那么新表的容量就等于旧的阈值
        newCap = oldThr;
    // 3.如果当前表是空的,而且也没有阈值。代表是初始化时没有任何容量/阈值参数的情况  
    else {
    	//此时新表的容量为默认的容量 16
        newCap = DEFAULT_INITIAL_CAPACITY;
        //新的阈值为默认容量16 * 默认加载因子0.75f = 12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 4.//如果新的阈值是0,对应的是  当前表是空的,但是有阈值的情况
    if (newThr == 0) {
    	//根据新表容量 和 加载因子 求出新的阈值
        float ft = (float)newCap * loadFactor;
        //进行越界修复
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 5.//更新阈值 
    threshold = newThr;
    //根据新的容量 构建新的哈希桶
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //更新哈希桶引用
    table = newTab;
    // 6.如果老数组不为空,则需遍历所有节点,将节点赋值给新数组
    if (oldTab != null) {
    	//遍历老的哈希桶
        for (int j = 0; j < oldCap; ++j) {
        	//当前的节点 e
            Node<K,V> e;
            //如果当前桶中有元素,则将链表赋值给e
            if ((e = oldTab[j]) != null) {  
                //把已经赋值之后的变量置位null, 以便垃圾收集器回收空间
                oldTab[j] = null; 
                //如果当前链表中就一个元素,(没有发生哈希碰撞)
                if (e.next == null)
                	//直接将这个元素放置在新的哈希桶里。
                    //这里取下标 是用 哈希值 & 桶的长度-1 。 
                    //由于桶的长度是2的n次方,这么做其实是等于 一个模运算。但是效率更高
                    newTab[e.hash & (newCap - 1)] = e;
                // 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //如果发生过哈希碰撞,节点数小于8个。则要根据链表上每个节点的哈希值,
                //依次放入新哈希桶对应下标位置
                else {
                    //因为扩容是容量翻倍,所以原链表上的每个节点,现在可能存放在原来
                    //的下标,即low位, 或者扩容后的下标,即high位。 
                    //high位=  low位+原哈希桶容量
                    //低位链表的头结点、尾节点
                    Node<K,V> loHead = null, loTail = null;
                    //高位链表的头节点、尾节点
                    Node<K,V> hiHead = null, hiTail = null; 
                    //临时节点 存放e的下一个节点
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 9.1 利用哈希值 & 旧的容量,可以得到哈希值去模后,是大于等于
                        //oldCap还是小于oldCap,等于0代表小于oldCap,应该存放在低位,
                        //否则存放在高位
                        if ((e.hash & oldCap) == 0) {
                        	// 如果loTail为空, 代表该节点为第一个节点
                            if (loTail == null) 
                            	// 则将loHead赋值为第一个节点
                                loHead = e; 
                            else
                            	// 否则将节点添加在loTail后面
                                loTail.next = e;  
                            // 并将loTail赋值为新增的节点  
                            loTail = e; 
                        }
                        // 9.2 如果e的hash值与老数组的容量进行与运算为非0,则扩容后的
                        //索引位置为高位
                        else {
                        	// 如果hiTail为空, 代表该节点为第一个节点
                            if (hiTail == null) 
                            	// 则将hiHead赋值为第一个节点
                                hiHead = e; 
                            else
                            	// 否则将节点添加在hiTail后面
                                hiTail.next = e; 
                            // 并将hiTail赋值为新增的节点   
                            hiTail = e; 
                        }
                    // 并将hiTail赋值为新增的节点
                    } while ((e = next) != null);
                    // 10.将低位链表存放在原index处
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 11.将高位链表存放在新index处
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    // 12.返回新数组
    return newTab;
}

三、HashMap常见问题

3.1 HashMap的设计相关问题

3.1.1 HashMap的数组长度为什么一定是2的次幂*

  简单来说,就是让数据在哈希表上分布地更加均匀。

  HashMap中根据key的哈希值计算下标的方式是tab[i = (n - 1) & hash],&为二进制中的与运算。

与运算规则:0&0=0; 0&1=0; 1&0=0; 1&1=1;
即:两位同时为“1”,结果才为“1”,否则为0。

  因为HashMap的数组长度都是2的n次幂 ,那么对于这个数再减去1,转换成二进制的话,就肯定是最高位为0,其他位全是1的数。
  以数组长度为8为例,那8-1转成二进制的话,就是0111。 那我们举一个随便的hashCode值,与0111进行与运算看看结果如何:

 第一个key:        hashcode值:10101001    
                            &      0111                                      
                                   0001  (十进制为1-------------------------------------------                           
 第二个key:       hashcode值:11101000    
                           &       0111      
                                   0000  (十进制为0--------------------------------------------               
 第三个key:       hashcode值:11101110    
                          &       0111      
                                  0110  (十进制为6

  这样得到的数,就会完整的得到原hashcode值的低位值,不会受到与运算对数据的变化影响。
  如果数组容量为7,7减去1转换成二进制是0110,此时再进行与运算:

 第一个key:      hashcode值:10101001    
                         &       0110                                      
                                 0000  (十进制为0------------------------------------------                           
  第二个key:      hashcode值:11101000    
                          &       0110      
                                  0000  (十进制为0--------------------------------------------               
  第三个key:      hashcode值:11101110    
                          &       0111      
                                  0110  (十进制为6

  可以看到,当数组长度不为2的n次幂 的时候,低位不同的hashCode值与数组长度减一做与运算的时候,会出现重复的数据。
  因为当数组容量不为2的n次幂 的话,对应的-1所转换的二进制数肯定有一位为0 , 这样不管hashCode值对应的该位,是0还是1 ,最终得到的该位上的数肯定是0,这带来的问题就是HashMap上的数组元素分布不均匀,而数组上的某些位置,永远也用不到
  图示:

  这将带来的问题就是HashMap数组的利用率太低,并且链表可能因为上边的(n - 1) & hash运算结果碰撞率过高,导致链表太深。所以说HashMap的长度一定是2的次幂,否则会出现性能问题。

3.1.2 HashMap默认加载因子为什么是0.75*

  0.75是对空间和时间效率的一个平衡选择(提高空间利用率和减少查询成本的折中),空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低。根据泊松分布,loadFactor取0.75碰撞最小。加载因子一般不会修改,除非在时间和空间比较特殊的情况:
  1)如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值,如0.5。
  2)如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,如1。

3.1.3 HashMap链表转红黑树的阈值为什么是8

  如果hashCode分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为8的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。

3.1.4 HashMap红黑树转链表的阈值为什么是6

  在Java的HashMap实现中,当红黑树中的节点数量小于6时,会将红黑树重新转换为单链表。这个策略的主要目的是在内存和性能之间取得一种平衡,以避免浪费过多的内存,因为红黑树相对于单链表会占用更多的内存。
  以下是为什么会这样做的原因:

  1. 内存开销:红黑树相对于单链表来说,需要更多的内存来存储额外的节点和树结构。在元素较少的情况下,使用红黑树会浪费内存,因为红黑树的节点结构比链表的节点结构复杂。当节点数量很小时,将红黑树转换为链表可以减少内存开销。
  2. 维护复杂性:红黑树的维护和操作相对于单链表来说更复杂,因此当元素数量较少时,使用链表可以更高效地执行操作,因为链表的简单性能更好。
  3. 避免过度优化:过早地将元素转换为红黑树可能会导致过度优化,因为红黑树的操作相对耗时,当元素数量很小时,这些操作可能会比链表更慢。因此,只有当链表中节点数量超过一定阈值时才转换为红黑树,以避免不必要的开销。

  这个阈值的默认值在不同版本的Java中可能有所不同,但通常在5到8之间。当元素数量超过这个阈值时,红黑树可以提供更好的性能,因为它的时间复杂度为O(log n),而不是链表的O(n)。但当元素数量减少到阈值以下时,为了减少内存开销和操作的复杂性,将红黑树转换为链表是一个合理的优化选择。这个策略充分权衡了内存和性能之间的权衡。

3.1.5 HashMap是怎么解决哈希冲突的

  通过引入单向链表来解决Hash冲突。当出现Hash冲突时,比较新老key值是否相等,如果相等,新值覆盖旧值。如果不相等,新值会存入新的Node结点,指向老节点,形成链式结构,即链表。
  当Hash冲突发生频繁的时候,会导致链表长度过长,以致检索效率低,所以JDK1.8之后引入了红黑树,当链表长度大于8时,链表会转换成红黑书,以此提高查询性能。

  • 哈希
      简单的说,哈希就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
      所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同
  • 哈希冲突
      当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希冲突)。
  • 解决Hash冲突的方法
      有开放定址法、再哈希法、链地址法。
      开放定址法:又称开放定址法,当哈希碰撞发生时,从发生碰撞的那个单元起,按照一定的次序,从哈希表中寻找一个空闲的单元,然后把发生冲突的元素存入到该单元。这个空闲单元又称为开放单元或者空白单元。
      再哈希法:提供多个不同的hash函数,当 R1=H1(key1) 发生冲突时,再计算 R2=H2(key1) ,直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。
  • HashMap是使用链表法解决哈希冲突
      在哈希表中,每一个桶(bucket)都会对应一条链表,所有哈希值相同的元素放到相同槽位对应的链表中。

      在插入的时候,我们可以通过散列函数计算出对应的散列槽位,将元素插入到对应的链表即可,时间复杂度为O(1);在查找或删除元素时,我们同样通过散列函数计算出对应的散列槽位,然后再通过遍历链表进行查找或删除,时间复杂度为O(k),k为链表长度。如果是红黑树,则时间复杂度为O(log k)。
3.1.6 负载因子的大小,对HashMap有什么影响

  负载因子的大小决定了hashmap的数据密度。
  负载因子如果太大的话,容易发生数据的碰撞,所链接的链表就会很长,每次插入或者查询的时候比较的次数就比较多,徒增功耗
  负载因子如果太小的话,就容易出发扩容机制,所链接的链表就会缩短,数据碰撞就会减小,但是会浪费很多的空间和内存,经常的扩容就会浪费很多的性能,导致数组中很多的内存没有被填充就被扩容,最后导致浪费。

3.2 HashMap在JDK1.7和JDK1.8中的区别*

  JDK1.8之前,HashMap底层是数组和链表结合在一起使用。HashMap通过key的hashCode,经过扰动函数处理过后得到hash值,然后通过(n - 1) & hash判断当前元素存放的位置(这里的n指的是数组的长度)。如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同。如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
  所谓扰动函数指的就是HashMap的hash方法。使用hash方法也就是扰动函数是为了防止一些实现比较差的hashCode()方法 换句话说使用扰动函数之后可以减少碰撞。

  总的来说,JDK1.8版本的HashMap主要解决或优化了以下问题:

1>resize扩容优化(即扩容后哈希的计算方式)。
2>引入了红黑树,目的是避免单条链表过长而影响查询效率。
3>解决了多线程死循环问题。JDK1.8之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉,JDK1.8将之前的头插法改成了尾插法。

  • 1、数据结构
      JDK1.8之前采用的是拉链法(数组 + 链表 )。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
      相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为8)&& 数组元素数量大于等于64时,将链表转化为红黑树,以减少搜索时间(数组 + 链表 + 红黑树)。在删除元素时,如红黑树节点数量数量小于等于6,会转回链表。
  • 2、hash值计算方式
      JDK1.7中的做法是:扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算。具体看下JDK1.7中哈希值的计算方式:
 final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
 
        h ^= k.hashCode();
 
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

  JDK1.8中的做法是:扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算。哈希值的计算方式:

	static final int hash(Object key) {
	     int h;
	     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
	}
  • 3、扩容后存储位置的计算方式
      JDK1.7中的做法是:全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1))。
      JDK1.8中的做法是:按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量)。

  JDK1.8中,HashMap扩容的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

  • 4、添加数据的规则
      JDK1.7中的做法是:无冲突时,存放数组;冲突时,存放链表
      JDK1.8中的做法是: 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8 & 数组容量 >=64:树化并存放红黑树
  • 5、插入数据方式
      JDK1.7中的做法是头插法(先将原位置的数据移到后1位,再插入数据到该位置,这样能提高插put操作的性能,但在多线程情况下有链表逆序和循环链表的问题–会导致死循环)。
      JDK1.8中的做法是尾插法(直接插入到链表尾部或红黑树)。

3.3 HashMap中的key*

  适合做HashMap中key的类,应该至少有2个特点:

  1. 重写了equals和hashCode方法。重写equals方法有利于key的比较;重写hashCode方法有利于不同对象获得不同的hash值。
  2. final类型,即不可变性,保证key的不可更改性,不会存在同一个对象获取到的hash值不同的情况。

  String、Integer等包装类符合这两点,所以适合做HashMap的key。String更常用一些。
  HashMap中的key可以为null,不建议用可变对象。如果是可变对象的话,对象的属性改变,则对象的Hashcode也改变,导致下次无法查到已存在Map中的数据。
  因此,如果可变对象在HashMap中被用作键,那就要小心在改变对象状态的时候,不要改变它的哈希值,即保证成员变量改变时,该对象的哈希值不变即可。

3.4 HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标

  hashCode()方法返回的是int整数类型,其范围为-(2 31) 到 (2 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,因此导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置。
  简单来说,就是hashCode的值范围比HashMap的实际存储范围大

  • 那怎么解决呢(以JDK1.8中的实现为例)
      1)HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;
      2)在保证数组长度为2的幂次方的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配"的问题。

3.5 HashMap在多线程下的问题

3.5.1 JDK1.7中HashMap在多线程环境下的问题(死循环)*

  代码示例:

public class HashMapInfiniteLoop{
	private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
	public static void main(String[] args){
		map.put(5,"C");
		
		new Thread("Thread1"){
			public void run(){
				map.put(7,"B");
				System.out.println(map);
			};
		}.start();
		
		new Thread("Thread2"){
			public void run(){
				map.put(3,"A");
				System.out.println(map);
			};
		}.start();
	}
}

  Map初始化了一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个元素的时候,就要进行扩容。
  通过设置断点让线程1和线程2同时debug到transfer方法的首行。此时两个线程已成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;”这一行。然后放开线程2的断点,让线程2进行扩容,如图:

  Thread1的e指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
  线程一被调度回来执行,先执行newTable[i]=e,然后是e=next,导致了e指向了key(7),而下一次循环的next=e.next导致了next指向了key(3)。

  e.next=newTable[i]导致key(3).next指向了key(7)。此时的key(7).next已经指向了key(3),环形链表就出现了。


  于是,当用线程一调用map.get(11)时,就出现了死循环。
  在并发的情况,发生扩容时,采用头插法,可能会产生循环链表,在执行get的时候,会触发死循环,产生死锁,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap。

3.5.2 JDK1.8中HashMap在多线程环境下的问题(数据覆盖)*

  JDK1.8中HashMap的确不会因为多线程put导致死循环(JDK1.7版本中的问题),但是依然有其他的弊端,比如数据丢失等等。
  数据丢失:当多线程put的时候,当index相同而又同时达到链表的末尾时,另一个线程put的数据会把之前线程put的数据覆盖掉,就会产生数据丢失。

	if ((e = p.next) == null) {
		p.next = newNode(hash, key, value, null);
	}
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值