Java集合(5)一 HashMap与HashSet

引言

HashMap<K,V>和TreeMap<K,V>都是从键映射到值的一组对象,不一样的是,HashMap<K,V>是无序的,而TreeMap<K,V>是有序的,相应的他们在数据结构上区别也很大。java

HashMap<K,V>在键的数据结构上采用了数组,而值在数据结构上采用了链表或红黑树这两种数据结构。 HashSet<K,V>同HashMap<K,V>的关系与TreeSet<E>同TreeMap<K,V>的关系相似,在内部实现上也是使用了HashMap<K,V>的键集,这点咱们一样经过HashSet<K,V>的构造函数能够发现。因此在文章中只会详细解说HashMap<K,V>,对HashSet<K,V>就不作分析。node

public HashSet() {
    map = new HashMap<>();
}

public HashSet(int initialCapacity) {
    map = new HashMap<>(initialCapacity);
}
复制代码

框架结构

HashMap<K,V>在继承结构上和TreeMap<K,V>相似,都继承自AbstractMap<K,V>,同时也都实现了Map<K,V>接口,因此在功能上区别不大,不一样的是实现功能的底层数据结构。同时因为HashMap<K,V>是无序的,没有继承自SortedMap<K,V>,相应的少了一些根据顺序查找的功能。 算法

哈希

在分析HashMap<K,V>的具体实现以前,先来看下什么是哈希? 哈希又叫“散列”,是将任意对象或者数据根据指定的哈希算法运算以后,输出一段固定长度的数据,经过这段固定长度的数据来做为这个对象或者数据的特征,这就是哈希。这句话可能比较绕口,举个例子。数组

在一篇文章中有10000个单词,须要查找这10000个单词中是否存在“hello”这个单词,最直观的办法固然是遍历这个数组,每一个单词跟“hello”进行比较,最坏的状况下可能要比较10000次才能找到须要的结果,若是这个数组无限大,那要比较的次数就会无限上升。那有没有更快速的查找途径呢? 答案就是哈希表。首先将这10000个单词根据一种指定的哈希算法计算出每一个单词的哈希值,而后将这些哈希值映射到一个长度为100的数组内,若是映射足够均匀的话大概数组的每一个值对应100个单词,这样咱们在查找的时候只须要计算出“hello”的哈希值对应在数组中的索引,而后遍历这个位置中对应的100个单词便可。当映射的数组足够大,好比10000,哈希算法足够好,映射一对一,每一个哈希值都不相同,这样理论上最优能够在一次查找就得道想到的结果,最坏的查找次数就是数组的每一个位置所对应的单词数。这样相比较直接遍历数组要快速的多。数据结构

哈希能够大大提升查找指定元素的效率,但受限于哈希算法的好坏。一个好的哈希算法能够将元素均匀分布在固定长度的数组中,相应的若是算法不够好,对性能就会产生很大影响。app

那有没有一个算法可让任意一个给定的元素,都输出一个惟一的哈希值呢?答案是暂时没有发现这样的算法。若是不能每一个元素都对应到一个惟一的哈希值,就会产生多个元素对应到一个哈希值的状况,这种状况就叫“哈希冲突”。框架

哈希冲突

下图中经过一个简单的哈希算法,每一个单词取首字母哈希时,air和airport哈希值同样就产生了哈希冲突。 仍是用以前的例子,当10000个单词存放于一个长度为100的数组中时,若是哈希算法足够好,单词分布的足够均匀,每一个哈希值就会对应100个左右的元素,也就是每一个位置会发生100次左右的哈希冲突。尽管咱们能够经过提升数组长度来减少冲突的几率,好比将100变为10000,这样有可能会一个元素对应一个哈希值。但若是须要存储的单词量足够大的状况下,不管数组多大均可能不够用,同时不少时候内存或者硬盘也不可能无限扩大。哈希算法也不能保证2个不一样元素的哈希值必定不相同,这时哈希冲突就不可避免,就须要想办法来解决哈希冲突。 通常解决哈希冲突有两种通用的办法:拉链法和开放定址法。 拉链法顾名思义就是将同一位置出现冲突的全部元素组成一个链表,每出现一次冲突,就将新的元素放置在链表末尾。当经过元素的哈希值查找到指定位置时会返回一个链表,再经过循环链表来查找彻底相等的元素。 开放定址法就是当冲突出现时,直接去寻找下一个空的散列地址,将值存入其中便可。当散列数组足够大,总会有空的地址,空地址不够用时,能够扩大数组容量。 在HashMap<K,V>中使用的是第一种的拉链法。函数

构造函数

在HashMap<K,V>中有几个重要字段。 Node<K,V>[] table,这个数组用来存储哈希值以及哈希值对应的元素,又叫哈希桶数组。 loadFactor是默认的填充因子,当哈希桶数组中存储的元素达到填充因子乘以哈希桶数组总大小时就须要扩大哈希桶数组的容量。好比桶数组长度为16当存储的数量达到16*0.75=12时则要扩大哈希桶数组的容量。通常取默认的填充因子DEFAULT_LOAD_FACTOR = 0.75,不须要更改。源码分析

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    //默认填充因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //哈希桶数组
    transient Node<K,V>[] table;

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
        this.loadFactor = loadFactor;
        //临界值(第一次临界值为转换后的容量大小)
        this.threshold = tableSizeFor(initialCapacity);
    }

    //默认填充因子 threshold(第一次临界值为转换后的容量大小)
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    //默认填充因子 threshold临界值为0
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
}
复制代码

在构造函数中有个tableSizeFor方法,这个方法是用来将输入的容量转换为2的整数次幂,这样不管输入的数值是多少,咱们都会获得一个2的整数次幂长度的哈希桶数组。好比输入13,返回16,输入120返回128。性能

static final int tableSizeFor(int cap) {
    //避免出现输入8变成16这种状况
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    //低位全变为1以后,进行n + 1能够将低位全变为0,获得2的幂次方
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
复制代码

经过对输入的参数001x xxxx xxxx xxxx位移1位后0001 xxxx xxxx xxxx与原值进行或运算,获得0011 xxxx xxxx xxxx,最高位的1与低一位都变为1。 位移2位后0000 11xx xxxx xxxx与原值0011 xxxx xxxx xxxx进行或运算,获得0011 11xx xxxx xxxx,最高2位的1与低2位都变为1。 位移4位后0000 0011 11xx xxxx与原值0011 11xx xxxx xxxx进行或运算,获得0011 1111 11xx xxxx,最高4位的1与低4位都变为1。 位移8位后0000 0000 0011 1111与原值0011 1111 11xx xxxx进行或运算,获得0011 1111 1111 1111,最高8位的1与低8位都变为1。 位移16位相似。结果就是从最高位开始全部后面的位都变为了1。而后n + 1,获得0100 0000 0000 0000。 能够看下面的例子: 当输入13时: 当输入118时: 这里要注意n = cap - 1,为何要对输入参数减一,是为了不输入2的幂次方时容量会翻倍,好比输入8时若是不进行减一的操做,最终会输出16,读者能够自行测试。

哈希值

那为何必定要用2的整数次幂来初始化哈希桶数组的长度呢?这就要说到哈希值的计算问题。 在HashMap<K,V>中计算元素的哈希值代码以下:

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

在这段代码就是用来获取哈希值的,其中首先获取了key的hashCode,这个hashCode若是元素有从新实现hashCode函数则会使用本身实现的hashCode,在没有本身实现时,hashCode函数大部分状况下会返回元素在内存中的地址,但也不是绝对的,须要根据各个JVM的内在实现来判断,但大部分实现就算没直接使用内存地址,也和内存地址存在必定的关联。

在获取到key的hashCode以后将hashCode的值的低16位和hashCode的高16位进行异或运算,这就是这个函数很是巧妙的地方,异或运算会同时采用高16位和低16位全部的特征,这样就大大增长了低位的随机性,在取索引的时候tab[(n - 1) & hash],将包含全部特征的哈希值和哈希桶长度减1进行与运行,能够获得哈希桶长度的低位值。

使用2的整数次幂能够很方便的经过tab[(n - 1) & hash]获取到哈希桶所须要的低位值,因为低位和高位进行了异或运算,保留了高低位的特征,也就减小了哈希值冲突的可能性。这就是为何这里会使用2的整数次幂来初始化哈希桶数组长度的缘由。

添加元素

经过HashMap<K,V>在添加元素的过程,能够发现HashMap<K,V>使用了数组+链表+红黑树的方式来存储数据。

当添加元素过程当中出现哈希冲突时会在冲突的位置采用拉链法生成一个链表来存储冲突的数据,若是同一位置冲突的数据量大于8则会将哈希桶数组扩容或将链表转换成红黑树来存储数据。同时,在每次添加完数据后,都会检查哈希桶数据的容量,超出临界值时会扩容。

对红黑树不太理解的能够查看前两篇文章。

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //哈希桶数组为空时,经过resize初始化哈希桶数组
    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;
        //若是哈希值相等,而且key也相等,则直接覆盖值
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //p为红黑树 使用红黑树逻辑进行添加(能够查看TreeMap)
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //p为链表
        else {
            for (int binCount = 0; ; ++binCount) {
                //查找到链表末尾未发现相等元素则直接添加到末尾
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //链表长度大于8时,扩容哈希桶数组或将链表转换成红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //遍历链表过程当中存在相等元素则直接覆盖value
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //覆盖value
        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;
}

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //哈希桶数组小于64则扩容哈希桶数组
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        //将全部Node<K,V>节点类型的链表转换成TreeNode<K,V>节点类型的链表
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        //将TreeNode<K,V>链表转换成红黑树
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
复制代码

扩容

添加元素的过程当中,如下2种状况会出现扩容:单个哈希桶存储超过8个元素会检查哈希桶数组,若是整个哈希桶数组容量小于64则会进行扩容;在每次添加完元素后也会检查整个哈希桶数组容量,超过临界值也会进行扩容。扩容源码分析以下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //哈希桶数组已经初始化 则直接向左位移1位 至关于扩容一倍
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //向左位移1位 扩容一倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //哈希桶数组未初始化 而且已经初始化容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //哈希桶数组未初始化 而且未初始化容量 则使用默认容量DEFAULT_INITIAL_CAPACITY
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //初始化哈希桶数组容量以及临界值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //初始化哈希桶数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //将全部元素拷贝到新哈希桶数组中
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //不存在哈希冲突,从新计算哈希值并拷贝
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //冲突结构为红黑树
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //冲突结构为链表
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //扩容后最高位为0,则不须要移动到新的位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        //扩容后最高位为1,则须要移动
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //对哈希值改变的节点移动到新的位置
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
复制代码

在扩容的过程当中,有一个很是巧妙的地方,由于扩容后每一个元素的哈希值须要从新计算并放入新的哈希桶数组中,在哈希值计算的过程当中,因为是乘以2来扩容的,也就是整数次幂。

这样在每次扩容后会多使用一位特征,这样当多使用的这一位特征为0时((e.hash & oldCap) == 0),哈希值实际上是没有变化的,就不须要移动,这一位特征为1时,只须要将位置移动旧的容量大小的便可(newTab[j + oldCap] = hiHead),这样就能够减小移动元素的次数。红黑树和链表结构都是如此。

查找元素

明白HashMap<K,V>的插入以及扩容原理,再来看查找就很是容易理解了,只是简单的经过在链表或者红黑树中查找到相等的值便可。

在查找中一个值是不是咱们须要的值,首先是经过hash来判断,若是hash相等再经过==或者equals来来判断。

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) {
    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) {
        //哈希值相等,而且key也相等,则返回查找到的值
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //哈希值存在冲突,第一个不是要找的key
        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;
}
复制代码

删除元素

删除元素时首先查找到须要的元素,其次根据查找到元素的数据结构来分别进行删除。

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) {
    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;
        //哈希值相等,而且key也相等,则node查找到的节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        //哈希值存在冲突,第一个不是要找的key
        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;
}
复制代码

HashMap的Key

讲解完整个HashMap的实现,咱们能够发现大部分状况下影响HashMap性能最核心的地方仍是在哈希算法上面。尽管理论上HashMap在添加、删除和查找上的时间复杂度均可以达到O(1),但在实际应用过程当中还受到不少因素影响,有时候时间复杂度为O(1)的HashMap可能比,时间复杂度为O(log n)的TreeMap性能更差,缘由就在哈希算法上面。

若是使用一个对象默认的哈希算法,前面咱们说过,大部分JVM哈希算法的实现都和内存地址有直接关系,为了减少碰撞的几率,可能哈希算法极其复杂,复杂到影响效率的程度。因此在实际使用过程当中,须要尽可能使用简单类型来做为HashMap的Key,好比int,这样在进行哈希时能够大大缩短哈希的时间。若是使用本身实现的哈希算法,在使用前须要先测试哈希算法的效率,减少对HashMap性能的影响。

总结

Java集合系列到这里就结束了,整个系列从集合总体框架说到了几个经常使用的集合类,固然还有不少没有说到的地方,好比Queue,Stack,LinkHashMap等等。虽然这是对本身Java学习过程当中的总结,但也但愿这个集合系列对你们理解Java集合有必定帮助,若是文章中有错误、疑问或者须要完善地方,但愿你们不吝指出。接下来打算对java.util.concurrent包下的内容作一个系列进行系统总结,有什么建议也能够留言给我。

相关文章
相关标签/搜索