HashMap底层分析

最近整理HashMap的知识,发现HashMap的底层数据结构在jdk1.8版本之后发生了变化,在1.8版本之前,和散列表的一样,是由散列桶(数组)+链表组成的。在1.8版本之后,结构如下图所示,由散列桶(数组)+链表+红黑树实现。


先贴出HashMap中数据结构的定义

/* 部分HashMap类中属性的定义 */
transient HashMap.Node<K, V>[] table;//这是在HashMap类中定义的Node数组,transient表示该属性不需要加入序列化对象中
transient int size;//HashMap数组当前已存放数量大小
static final int DEFAULT_INITIAL_CAPACITY = 16;//HashMap中数组容量默认初始化大小
static final float DEFAULT_LOAD_FACTOR = 0.75F;//负载因子默认是0.75
static final int TREEIFY_THRESHOLD = 8;//散列桶(数组)中元素存储由链表转至红黑树的阈值
/* 数组元素的定义 */    
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        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;
        }

        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;
        }
    }
/* 红黑树 */    
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }

        /**
         * 返回当前节点的根节点
         */
        final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }

HashMap的扩容机制

什么时候发生扩容:当向hashMap容器中添加元素时,会判断当前容器的元素个数,如果大于或等于数组长度*负载因子时,HashMap就会进行扩容操作,默认扩大至原来的两倍。扩容是一件很耗时的工作。

扩容的时候需要reshash操作,每个的Node节点需要重新分配存储位置,根据key的hash和新数组长度做位运算得到新的数组下标,然后将这个Node节点存放到新数组的这个下标中,由于rehash操作比较耗费时间和空间,所以特殊情况下可以根据需求调整Node数组长度和负载因子的大小。负载因子较大时,去给table扩容的可能性就会少,所以相对占用内存较少(空间上较少),但是每条链上的元素会相对较多,查询的时间也会增长。反之就是,负载因子较少的时候,给table扩容的可能性就高,那么内存空间占用就多,但是链上的元素就会相对较少,查出的时间也会减少。所以才有了负载因子是时间和空间上的一种折中的说法。所以设置负载因子的时候要考虑自己追求的是时间还是空间上的少。


HashMap中的put方法:

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;  
        if ((tab = table) == null || (n = tab.length) == 0)  
            n = (tab = resize()).length;  
    /*如果table的在(n-1)&hash的值是空,就新建一个节点插入在该位置*/  
        if ((p = tab[i = (n - 1) & hash]) == null)  
            tab[i] = newNode(hash, key, value, null);  
    /*表示有冲突,开始处理冲突*/  
        else {  
            Node<K,V> e;   
        K k;  
    /*检查第一个Node,p是不是要找的值*/  
            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);  
               //如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构,               
            //treeifyBin首先判断当前hashMap的长度,如果不足64,只进行  
                        //resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树  
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
                            treeifyBin(tab, hash);  
                        break;  
                    }  
        /*如果有相同的key值就结束遍历*/  
                    if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))  
                        break;  
                    p = e;  
                }  
            }  
    /*就是链表上有相同的key值*/  
            if (e != null) { // existing mapping for key,就是key的Value存在  
                V oldValue = e.value;  
                if (!onlyIfAbsent || oldValue == null)  
                    e.value = value;  
                afterNodeAccess(e);  
                return oldValue;//返回存在的Value值  
            }  
        }  
        ++modCount;  
     /*如果当前大小大于门限,门限原本是初始容量*0.75*/  
        if (++size > threshold)  
            resize();//扩容两倍  
        afterNodeInsertion(evict);  
        return null;  
    }  

put(key,value)的过程:

1、判断数组table是否为空,如果为空就调用resize()创建一个长度为16的数组。

2、根据key的hash得到插入的下标index,如果table[index]==null,就直接新建链表节点,如果链表长度>=8,则拆散链表重建红黑树;

3、如果存储个数达到了阈值,就会促发扩容。

 

在java8之前,HashMap的put操作,是通过hash计算,所有落在同一个散列桶中的元素都是以链表结构存储的。在最坏情况下,put的多个key值hash计算结果都相同,导致HashMap查询时间复杂度为由O(1)涨至O(N),在java8之后,HashMap的put操作会判断桶中的元素个数,当桶中元素个数小于8个的时候,所有节点是以链表结构存储的;当桶中元素大于8个的时候,会将链表中的节点全部拿出,以树形结构重新存储。


  • 插入null的情况:

HashMap允许插入key为null,或value为null的值。且所有key仅允许一个null值,再插入key为null的键值时,会覆盖前者。value允许出现多个为null的值。

HashTable和ConcurrentHashMap不允许key或value插入null值。

key为null时,值存在了哪里?

在put方法里头,其实第一行就处理了key=null的情况。 

if (key == null)  
    return putForNullKey(value);  
//那就看看这个putForNullKey是怎么处理的吧。  
private V putForNullKey(V value) {  
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {  
        if (e.key == null) {  
            V oldValue = e.value;  
            e.value = value;  
            e.recordAccess(this);  
            return oldValue;  
        }  
    }  
    modCount++;  
    addEntry(0, null, value, 0);  
    return null;  
}  
可以看到,前面那个for循环,是在talbe[0]链表中查找key为null的元素,如果找到,则将value重新赋值给这个元素的value,并返回原来的value。 如果上面for循环没找到。则将这个元素添加到talbe[0]链表的表头。 


java7和java8中,table获取数组下标的算法发生了改变。

/* jdk1.7中计算index值的方法 */
static int indexFor(int h, int length) {    
     return h & (length-1);    
 }
/* jdk1.8中计算index值的方法 */
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h =key.hashCode()) ^ (h >>> 16);
    }


HashMap中的get方法:

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;//根据key及其hash值查询node节点,如果存在,则返回该节点的value值。
    }

    final Node<K,V> getNode(int hash, Object key) {//根据key搜索节点的方法。记住判断key相等的条件:hash值相同 并且 符合equals方法。
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&//根据输入的hash值,可以直接计算出对应的下标(n - 1)& hash,缩小查询范围,如果存在结果,则必定在table的这个位置上。
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))//判断第一个存在的节点的key是否和查询的key相等。如果相等,直接返回该节点。
                return first;
            if ((e = first.next) != null) {//遍历该链表/红黑树直到next为null。
                if (first instanceof TreeNode) //当这个table节点上存储的是红黑树结构时,在根节点first上调用getTreeNode方法,在内部遍历红黑树节点,查看是否有匹配的TreeNode。
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash && //当这个table节点上存储的是链表结构时,用跟第11行同样的方式去判断key是否相同。
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null); //如果key不同,一直遍历下去直到链表尽头,e.next == null。
            }
        }
        return null;
    }

hash(Object key)方法根据输入的key值,返回数组下标,先判断第一个节点是否与方法参数相等,不等就遍历后面的链表找到相同的key值并返回对应的Value值即可,在java8中,如果桶中元素大于8个,则就是遍历该红黑树,查询速度会比遍历链表要快,时间复杂度由O(n)变为O(lgn)。


关于HashMap在多线程情况下会出现的问题:


在使用put方法添加新键值对时,当两个key通过hash运算得到相同的数组下标index的时候,就可以用到链表来解决了,HashMap 会在 table[index]处形成链表,采用头插法将数据插入到链表中(jdk1.7)。在jdk1.8之前,在多线程环境下,如果put一个新键值对时,size达到需要HashMap扩容的时候,有可能会出现链表里的Entry顺序颠倒导致出现环链进而导致在getvalue的时候出现死循环问题。具体解释起来比较绕,此处偷懒放上参考博文:图解集合 5 :不正确地使用HashMap引发死循环及元素丢失。我在jdk1.8源码中找了下发现已经没有在多线程情况下导致死循环的transfer方法了,而是对resize方法进行了修改,看了很多篇博客,我的理解是新方法改用尾插法,保持链表中Entry的相对顺序不变,可以避免形成环链。但是这并不能改变HashMap是线程不安全的类, 因为还是存在丢失节点的问题。

把一个线程非安全的集合作为全局共享的,本身就是一种错误的做法,并发下一定会产生错误。

所以,在多线程环境下,要想使用Map类工具,建议使用以下:

1、使用Hashtable或ConcurrentHashMap这两个线程安全的Map

2、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap变成一个线程安全的Map

不过选择了线程安全的办法,那么必然要在性能上付出一定的代价


写的有点杂乱,请见谅。如有不正确的地方,欢迎指出错误。

参考链接:https://github.com/crossoverJie/Java-Interview/blob/master/MD/HashMap.md

                  http://www.importnew.com/25070.html

                  http://blog.csdn.net/tuke_tuke/article/details/51588156



HashMapJava中常用的一种数据结构,它底层采用的是哈希表的实现方式。下面是HashMap底层源码分析: 1. HashMap的数据结构 HashMap的数据结构是一个数组,数组中的每个元素是一个链表,链表中存放了哈希值相同的键值对。当发生哈希冲突时,新的键值对将会添加到链表的末尾。在查找键值对时,首先根据键的哈希值在数组中定位到对应的链表,然后再在链表中查找键值对。这种实现方式的时间复杂度为O(1),但是在发生哈希冲突时,链表的长度会变长,查找效率也会降低。 2. HashMap的put方法 当向HashMap中添加键值对时,首先会计算键的哈希值,然后根据哈希值计算出在数组中的位置。如果该位置为空,则直接将键值对添加到该位置;否则,需要遍历链表,查找是否已经存在相同的键,如果存在,则将旧的值替换为新的值;如果不存在,则将新的键值对添加到链表的末尾。 3. HashMap的get方法 当从HashMap中获取键值对时,首先计算键的哈希值,然后根据哈希值在数组中定位到对应的链表。接着遍历链表,查找是否存在相同的键,如果存在,则返回对应的值;如果不存在,则返回null。 4. HashMap的扩容机制 当HashMap中的元素个数超过数组长度的75%时,会自动扩容。扩容时,会将数组长度扩大一倍,并将原来的键值对重新分配到新的数组中。重新分配时,需要重新计算键的哈希值和在新数组中的位置。这个过程比较耗时,但是可以避免链表过长导致的查找效率降低。 5. HashMap的线程安全性 HashMap是非线程安全的,因为在多线程环境下,可能会存在多个线程同时对同一个链表进行操作的情况,从而导致数据不一致。如果需要在多线程环境下使用HashMap,可以使用ConcurrentHashMap,它是线程安全的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值