【JAVA基础】集合类源码分析_HashMap/HashSet

本篇介绍一个查找效率很高的集合类:HashMap

首先,我们来阅读类名上方的类注释,这有助于我们快速地了解此类的基本特性,阅读HashMap的类注释,如下几点值得我们注意:

  1. HashMap是基于Hash的对于Map接口的一种实现,该实现提供Map接口定义的所有操作。
  2. HashMap的key和value允许是null,同时,HashMap与HashTable大致相同,只不过后者是同步的,而且后者不允许key为null。
  3. HashMap不保证有序,而且在一段时间之后,原来的顺序也有可能变化(扩容之后)。
  4. 如果hash函数使得HashMap存储的元素均匀地分散在buckets(存储元素的数组)当中,那么对于集合的遍历所需的时间则与HashMap实例的capacity属性成比例,因此,如果遍历的效率在程序设计中很重要的时候,切不可把capacity设置的过大。
  5. 由于HashMap的扩容效率很低,所以如果可以确定存储在HashMap中的元素很多时,可以设置一个较大的capacity以防止扩容的发生。
  6. HashMap具有与ArrayList和LinkedList同样的fast-fail机制,不赘述。

OK,以上为类注释中较为重要的几点,下面我们来看源码。

HashMap有如下四个构造方法:
– public HashMap()
– public HashMap(int initialCapacity, float loadFactor)
– public HashMap(int initialCapacity)
– public HashMap(Map < ? extends K, ? extends V> m)

最后一个构造方法是把一个Map m中的键值对拷贝到当前的HashMap中,同时我们可以看到,构造方法中涉及到initialCapacity和loadFactor两个值,下面我们来看具体实现。

HashMap有几个重要的属性:
transient Entry< K,V >[] table;
transient int size;
int threshold;
final float loadFactor;

table是一个Node类型的数组,数组元素为单向链表,链表中存储的则是键值对,Node为定义在HashMap中的一个内部类,代码如下(同样,以注释的方式解释代码):

static class Node<K,V> implements Map.Entry<K,V> {
    // key对应的hash值,存储这个hash是是为了在比较key的时候速度更快
    final int hash;
    // 存储的键
    final K key;
    // 存储的值
    V value;
    // 代表指向下一个Node节点
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    // 省略get和set相关方法和toString、hashCode、equals方法
    // ......
}

需要注意的是,在jdk8之前,table有一个默认值,是个空数组,但是jdk8开始,table没有默认值,是在第一次存放键值对的时候才新建数组并赋给table。

size表示存储的键值对的个数。

threshold代表一个阈值,键值对个数size大于这个阈值的时候会调用resize方法进行扩展。这个阈值的计算方式通常是指table的长度乘以loadFactor。

loadFactor是负载因子,表示整体上table被占用的程度,是一个浮点数,默认初始值为0.75f,可以通过构造方法传递指定的值。

构造方法

主要的构造方法如下,无参及单参数的构造方法最终都是调用此构造方法,老样子,以注释方式解释代码:

public HashMap(int initialCapacity, float loadFactor) {
    // 检查容量的合法性,不合法会抛出异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 此处可以看出,HashMap的数组大小实际上是有限的,最大为2^30
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 检查负载因子的合法性,不合法会抛出异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    // 负载因子赋值
    this.loadFactor = loadFactor;
    // 阈值赋值,此处tableSizeFor方法会返回一个大于等于initialCapacity且为2的幂次方的数
    this.threshold = tableSizeFor(initialCapacity);
}

与之前版本JDK的实现不同,JDK8中的HashMap的table并未有初始的大小,table分配空间的操作放在了第一次put值的过程中,后面的代码我们再说。

保存键值对

JDK8开始,HashMap保存键值对的操作并非直接在put方法中进行,而是在put方法中调用了putVal方法,我们来看putVal的代码:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

首先看参数:hash为根据key值由hash算法计算出来的整数值,前面说了,它会存储在Node中,方便key之间的比较,提高效率;key和value不赘述;onlyIfAbsent这个参数在put方法中传入的是false,它的意义是:如果传入的值为true,则在key相同的情况下,新put的value不会覆盖旧的value值;最后一个evict跟序列化有关,我们暂不介绍。

然后分析代码:

  • 首先会判断table是不是空的,第一次put键值对时为空,此时,调用resize方法,将table初始化大小为大于等于initialCapacity且为2的幂次的数组。
  • 然后通过hash值找到数组中对应的位置,此时可以分为两种情况:
    1. 对应数组位置还没有保存的节点时,会将当前键值对封装成Node对象存放在对应位置;
    2. 对应数组位置已存在保存的节点时,如果key重复,则会根据onlyIfAbsent参数决定是否覆盖value值,如果key不重复,则会向单向链表中加入键值对,同时,若链表长度大于等于8时,会依据table的大小来决定是否把当前索引处的单向链表替换成红黑树以提高查询效率。
  • 然后进行++modCount,含义与ArrayList和LinkedList中的一样,记录修改次数,方便在迭代中检测结构性变化。
  • 最后判断size是否大于阈值threshold,如果大于,则调用resize方法进行扩容。

在putVal方法中我们看到HashMap会将key的hash值也存入Node对象中,我们前边提到这是为了提高key之间的比较效率,那么key的hash值是怎么得到的呢,HashMap的实现如下:

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

这里有个地方是需要注意的,即key为null的时候,返回的hash值是0,从putVal方法中可知,hash值为0的时候,对应键值对是会存储在table的第一个位置上,即table[0]处。

根据键获取值

get方法代码如下:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

在get方法中,调用了getNode方法来获取封装键值对的Node对象,其代码如下:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

代码逻辑较为简单,首先根据key的hash值定位到table的对应位置,然后取出该位置的第一个节点(可能只有一个,也可能是单向链表,有多个),判断此节点的key与传入的参数key是否匹配,匹配则返回该节点,不匹配则判断是否有next节点,有的话再判断是否是平衡树的节点,是的话,就按照平衡树的搜索方法获取节点,不是的话,则按照单向链表的搜索方法获取节点,最后返回该节点,若未搜索到的话返回null值。

注:此处只介绍JDK8的HashMap中运用了平衡树,未详细介绍,后面会单独开篇介绍平衡树。

查看是否包含某个键

代码如下,逻辑与根据键获取值类似,也是调用了getNode方法:

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}

查看是否包含某个值

查询是否包含某个值,会先判断table是否为空,不为空的话,遍历数组,取出每个节点,其实就是每个链表的头节点,然后再遍历链表,直至找到相等的值,若遍历至最后也没找到,则返回一个false,代码如下:

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

根据键删除键值对

代码如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

由代码可知调用了removeNode方法,同时删除特定键值对的方法remove(Object key, Object value)中也调用了removeNode方法,所以我们此处只介绍removeNode方法代码。

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

代码较长,但是逻辑与保存键值对的putVal方法类似:

  1. 先是在table不为空的情况下,根据hash值找到key对应table的位置,这里我们设置此位置索引为index,在table[index]处得到一个单向链表(或树);
  2. 如果链表头节点的key与参数key匹配,则将头节点的下一个节点赋值给table[index],因为是单向链表,所以此时已无法从其它节点处查找到此节点,视为删除;
  3. 另外一种情况是头节点的key不匹配,则遍历此单向链表(或平衡树),直至找到key匹配的节点,此时将匹配节点的上一个节点的next设置为匹配节点的next,同上,由于是单向链表,此时的匹配节点可以视为删除了,然后返回该节点,若为找到key匹配的节点,则返回null值。
  4. 最后++modCount和–size。

以上就是HashMap基本使用方法及内部实现原理,但其实基于JDK8的HashMap源码分析还远没有结束,我们应当注意到如下几个方法:

  1. computIfAbsent(K key, Function< ? super K, ? extends V > mappingFunction)
  2. computeIfPresent(K key, BiFunction< ? super K, ? super V, ? extends V > remappingFunction)
  3. compute(K key, BiFunction< ? super K, ? super V, ? extends V > remappingFunction)
  4. merge(K key, V value, BiFunction< ? super V, ? super V, ? extends V > remappingFunction)
  5. forEach(BiConsumer< ? super K, ? super V > action)
  6. replaceAll(BiFunction< ? super K, ? super V, ? extends V > function)

其中的参数类型Function,BiFunction,BiConsumer等都是在JDK8才开始出现的,现在我们要了解的是这些是函数式接口,它们是JAVA的lambda表达式的基础,那么这些方法到底是什么作用,它们应该怎么使用,在何种场景下使用,我们会在JDK8新特性分析的文章中一一讲解。

好了,HashMap部分到此结束,小结如下:

  • HashMap实现了Map接口,内部使用数组链表和哈希的方式进行实现。
  • 根据键保存和获取值的效率都很高,因为根据hash值定位到数组的对应位置,hash值为整数,故运算效率很高。此外,从JDK8开始,当存储元素的数组某个索引位置上的单向链表节点个数超过8时,HashMap会将此单向链表转变成平衡树,以提高查找效率(平衡树的概念会另开篇介绍)。
  • HashMap中的键值对没有顺序,因为hash值是随机的。
  • 最后需要注意的一点是,之所以table的大小总是2的幂次,是因为在保存及获取键值对的方法中定位数组中的索引时有这样一条语句:tab[i = (n – 1) & hash],n为table的length,当n为2的幂次时,n – 1的结果总是111111…..这样的形式,那么与hash值相与,相当于hash%length这样的取模运算,依此等到数组下标i。

HashSet

了解了HashMap之后,HashSet的实现原理就相当好理解了,HashSet的属性如下:

private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();

有代码可见,HashSet就是在其内部维护了一个HashMap,HashSet的元素就是HashMap当中的key,那么PRESENT属性是干嘛的呢,我们来看add方法:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

OK,一目了然了,HashMap中每个键值对中的value都是PRESENT,但是HashSet只提供访问key的接口,所以这个value是没用的,只是来占位置的。

由于HashMap的特性,导致了HashSet有如下特性:

  1. 没有重复元素
  2. 可以高效的添加、删除元素、判断元素是否存在,效率都为O(1)。
  3. 没有顺序

由特性出发,HashSet的使用场景可以是下面这些:

  1. 排重,如果对排重后的元素没有顺序要求,则HashSet可以方便的用于排重。
  2. 保存特殊值,Set可以用于保存各种特殊值,程序处理用户请求或数据记录时,根据是否为特殊值,进行特殊处理,比如保存IP地址的黑名单或白名单。
  3. 集合运算,使用Set可以方便的进行数学集合中的运算,如交集、并集等运算,这些运算有一些很现实的意义。比如用户标签计算,每个用户都有一些标签,两个用户的标签交集就表示他们的共同特征,交集大小除以并集大小可以表示他们的相似长度。

OK,以上就是HashMap及HashSet源码分析的全部内容,下一篇TreeMap/TreeSet~

    原文作者:java集合源码分析
    原文地址: https://blog.csdn.net/tianma0314/article/details/78170323
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞