HashMap学习笔记

138 阅读6分钟

HashMap

红黑树

  • 二分查找树:BST有一个弊端,那就是在极端条件下,BST会退化成链,BST树的特点
    • 左子树的值小于等于根的值
    • 右子树的值大于等于根的值
    • 左右子树也都是二叉查找树
  • 平衡二叉树(AVL),平衡二叉树(AVL)具有BST的所有特性,它还有其独有的特性,那就是:左右子树的高度差最多为1。有了该特性,平衡二叉树将保保持它的平衡性,弥补了BST的不足;左右子树高度差最多为1这个条件过于严格,导致我们几乎每次进行插入或删除结点时,都会破坏平衡二叉树的结构,以至于要进行左旋或右旋处理,再将其变为平衡的。如果在那种插入、删除很频繁的场景中,平衡树需要频繁地进行调整,这会使平衡树的性能大打折扣。还有一个次要原因是,AVL的实现代码比较复杂,理论上的数据结构模型应用于实际还需要稍许修改折中。
  • 红黑树查找的最坏时间复杂度也为O(logn),但它在插入、删除等操作中,不会像AVL那样,频繁地破坏红黑树的规则,所以不需要频繁地调整,红黑树是一种自平衡的二叉查找树
    • 结点带颜色属性(红或黑)
    • 根节点为黑色
    • 每个叶节点都是黑色的空节点(NIL结点)
    • 每个红色结点的两个子节点都是黑色(从每个叶子到根的所有路径上不能有两个连续的红色结点)
    • 从任一结点到其每个叶子的所有路径都包含相同数目的黑色节点

image.png

HashMap的整体结构

HashMap的整体结构是一个数组,数组中的元素Node<k,v>称为哈希桶,哈希桶可以为链表,也可以为红黑树

HashMap的属性

  • 默认容量DEFAULT_INITIAL_CAPACITY=16
  • 默认加载因子DEFAULT_LOAD_FACTOR=0.75f;,加载因子为0.75的意思就是,HashMap最多放75%乘以容量Capacity的键值对,若数据量超过了75%乘以容量,则需进行扩容
  • TREEIFY_THRESHOLD,树阈值=8;哈希桶转化为红黑树的阈值,当某个哈希桶中结点数大于8时,且数组长度大于MIN_TREEIFY_CAPACITY = 64,该哈希桶将会转换为红黑树结构
  • UNTREEIFY_THRESHOLD=6是红黑树转化为链表的阈值。当某个哈希桶中结点数小于6时,该哈希桶会转换为链表,前提是它当前是红黑树结构
  • MIN_TREEIFY_CAPACITY=64,链表长度大于TREEIFY_THRESHOLD = 8(且数组长度不大于MIN_TREEIFY_CAPACITY = 64),数组进行扩容,链表不转为红黑树,还是链表
  • Node< K,V >[] table,table表示存储哈希桶的数组,数组元素类型为Node<K,V>,即哈希桶。table中的元素称之为哈希桶,哈希桶可以是链表结构,也可以是红黑树结构
  • Set< Map.Entry< K,V > > entrySet,Map.Entry<K,V>是Map中定义的接口,其实就是表示键值对,其中定义了对key,value的get,set方法,以及key和value的比较方法等
  • size,键值对的数量
  • modeCount:hashmap被结构性修改的次数,主要用于在迭代器迭代时,若又对HashMap进行了修改,会抛出ConcurrentModificationException
  • threshold:临界值,当大于临界值的时候,hashmap会进行扩容
  • loadFactor:加载因子作为变量使用

HashMap的内部类:

  • TreeNode<k,v>红黑树节点,继承了LinkedHashMap.Entry<K,V>,相当于继承了Node<K,V>,因为LinkedHashMap.Entry<K,V>继承了HashMap.Node<K,V>。TreeNode<K,V>内部类中包含了在红黑树中插入、查找元素,将红黑树转化为链表等方法 -** Node<k,v>链表节点**,实现了Map.Entry<K,V>接口,其属性有key,value,key对应的hash值,以及下一个Node结点,方法包括key,value对应的get、set方法等

HashMap的方法

构造方法

空参构造HashMap()
HashMap(int initialCapacity, float loadFactor)

使用该构造函数,将设置对应的初始容量装填因子,该方法会验证参数的正确性,将属性loadFactor赋为对应参数,并设置属性threshold(数组扩容临界值)为tableSizeFor(initialCapacity),tableSizeFor方法用于计算>=参数的最小2的整数次方,因为HashMap的大小总是2的整数幂

HashMap(int initialCapacity)
HashMap(Map< ? extends K,? extends V > m)

该方法调用了putMapEntries(m, false)方法,putMapEntries方法其实就是把Map中的数据放入HashMap中

image.png

image.png

扩容方法resize()

主要对hashmap进行扩容

  • HashMap实行了懒加载, 新建HashMap时不会对table进行赋值, 而是到第一次插入时, 进行resize时构建table;----第一次put的时候
  • 当HashMap.size 大于 threshold时, 会进行resize;threshold的值我们在上一次分享中提到过: 当第一次构建时, 如果没有指定HashMap.table的初始长度, 就用默认值16, 否则就是指定的值; 然后不管是第一次构建还是后续扩容, threshold = table.length * loadFactor;

扩容的流程:扩大容量和迁移元素两个过程

  1. 如果table == null, 则为HashMap的初始化, 生成空table返回即可;
  2. 如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);
  3. 遍历oldTable:
    • 首节点为空, 本次循环结束;
    • 无后续节点, 重新计算hash位, 本次循环结束;
    • 当前是红黑树, 走红黑树的重定位;
    • 当前是链表, JAVA7时还需要重新计算hash位, 但是JAVA8做了优化, 通过(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;

image.png

put方法:

实际上是调用了putVal方法,除开key,value外,还将key的hash值作为了参数,通过调用hash(key)方法来获得key对应的hash值 putval的执行流程

image.png

remove方法

clear方法

get方法

containsKey方法

containsValue方法


HashMap的长度为什么是2的n次方

HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化, hash&(length-1),hash%length==hash&(length-1)的前提是length是2的n次方

hashMap进行排序

  • 使用TreeMap,这个方法最简单了
HashMap<Integer, Student> map = new HashMap<>();
TreeMap<Integer,Student> sortedMap =new TreeMap<>(map);



/*TreeMap 是一个有序的key-value集合,它是通过红黑树实现的。
TreeMap 继承于AbstractMap,所以它是一个Map,即一个key-value集合。
TreeMap 实现了NavigableMap接口,意味着它支持一系列的导航方法。比如返回有序的key集合。
TreeMap 实现了Cloneable接口,意味着它能被克隆。
TreeMap 实现了java.io.Serializable接口,意味着它支持序列化。
TreeMap基于红黑树(Red-Black tree)实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的
Comparator 进行排序,具体取决于使用的构造方法。
TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度是 log(n) 。
另外,TreeMap是非同步的。 它的iterator 方法返回的迭代器是fail-fastl的。*/
  • 仅对key或value进行排序
List<Integer> mapKeys = new ArrayList<>(map.keySet());
Collections.sort(mapKeys);
  
 
List<Student> mapValues = new ArrayList<>(map.values()); 
Collections.sort(mapValues);
  • 如果不希望排序的MAP中有KEY的值的重复,可以用sortedset
SortedSet<String> mapKeys = new TreeSet<>(map.keySet());
这个时候要POJO重写hashcode和equals方法:`

  • 根据KEY排序:
Map<Integer, Student> sortedMap = map.entrySet()
                                  .stream()
                                  .sorted(Map.Entry.comparingByKey())
                                  .collect(Collectors
                                    .toMap(Map.Entry::getKey,
                                           Map.Entry::getValue,
                                           (e1, e2) -> e1,
                                           LinkedHashMap::new))
  • 根据Value进行排序
sortedMap = map.entrySet()
              .stream()
              .sorted(Map.Entry.comparingByValue())
               .collect(Collectors
                          .toMap(Map.Entry::getKey,
                                 Map.Entry::getValue,
                                 (e1, e2) -> e1,
                                 LinkedHashMap::new));
  • 也可以使用Collections.reverseOrder方法,逆序排序:
sortedMapDesc = map.entrySet()
                  .stream()
                  .sorted(Collections.reverseOrder(Map.Entry.comparingByKey()))
                  .collect(Collectors
                   .toMap(Map.Entry::getKey,
                          Map.Entry::getValue,
                          (e1, e2) -> e1,
                           LinkedHashMap::new));

HashMap 1.7和1.8

  • 底层数据结构不一样,1.7是数组+链表,1.8则是数组+链表+红黑树结构(当链表长度大于8,转为红黑树)。
  • JDK1.8中resize()方法在表为空时,创建表;在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表。
  • 1.8中没有区分键为null的情况,而1.7版本中对于键为null的情况调用putForNullKey()方法。但是两个版本中如果键为null,那么调用hash()方法得到的都将是0,所以键为null的元素都始终位于哈希表table【0】中。
  • 当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表尾部;而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法,将节点添加到链表头部。
  • 1.7中新增节点采用头插法,1.8中新增节点采用尾插法。这也是为什么1.8不容易出现环型链表的原因。
  • 1.7中是通过更改hashSeed值修改节点的hash值从而达到rehash时的链表分散,而1.8中键的hash值不会改变,rehash时根据(hash&oldCap)==0将链表分散。
  • 1.8rehash时保证原链表的顺序,而1.7中rehash时有可能改变链表的顺序(头插法导致)。 在扩容的时候:1.7在插入数据之前扩容,而1.8插入数据成功之后扩容

image.png

线程安全问题

  • 1.7中transfer()链表使用头插法,多线程情况下,会成环;
  • 1.8中putVal()若桶为空,多线程操作,值会出现覆盖情况。 HashTable、Collections.synchronizedMap及ConcurrentHashMap都是实现线程安全的Map。
  1. HashTable:直接在方法上加synchronized来锁住整个数组,粒度比较大。
  2. Collections.synchronizedMap:Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。
  3. ConcurrentHashMap:使用分段锁,降低锁粒度,让并发度大大提高。