关于HashMap你要知道的事情

一、HashMap的定义和重要成员变量

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable 

熟悉源码的童鞋会很奇怪,为啥AbstractMap已经实现了Map接口,HashMap还要再实现一遍呢?为啥呢?我™也不知道……

直接看HashMap的重要成员变量:

int DEFAULT_INITIAL_CAPACITY = 16:默认的初始容量为16 ;
int MAXIMUM_CAPACITY = 1 << 30:最大的容量为 2 ^ 30 ;
float DEFAULT_LOAD_FACTOR = 0.75f:默认的加载因子为 0.75f ;
Entry< K,V>[] table:Entry类型的数组,HashMap用这个来维护内部的数据结构,它的长度由容量决定 ;
int size:HashMap的大小 ;
int threshold:HashMap的极限容量,扩容临界点(容量和加载因子的乘积);

这些成员变量灰常灰常重要,在HashMap源码内部经常看到。这里重点介绍一下加载因子这玩意儿:加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。

关于这个,再说得细一点,之所以采用Hash散列进行存储,主要就是为了提高检索速度。
众所周知,有序数组存储数据,对数据的检索效率会很高,但是,插入和删除会有瓶颈产生。而链表存储数据,通常只能采用逐个比较的方法来检索数据(查找数据),但是,插入和删除的效率很高。
于是,将两者结合,取长补短,优势互补一下,就产生哈希散列这种存储方式。
具体是怎么样的呢?
我们可以理解成,在链表存储的基础上,对链表结构进行的一项改进。
我们将一个大的链表,拆散成几个或者几十个小的链表。每个链表的表头,存放到一个数组里面。这样,在从大链表拆分成小链表的时候就有讲究了。我们按照什么规则来将一个大链表中的数据,分散存放到不同的链表中呢?在计算机当中,肯定是要将规则数量化的,也就是说,这个规则,一定要是个数字,这样才比较好操作。比如,按照存放时间,每5分钟一个时间段,将相同时间段存放的数据,放到同一个链表里面;或者,将数据排序,每5个数据形成一个链表;等等,等等,还有好多可以想象得到的方法。但是,这些方法都会存在一些不足之处。我们就在想了,如果存放的数据,都是整数就好了。这样,我可以创建一个固定大小的数组,比如50个大小,然后,让数据(整数)对50进行取余运算,然后,这些数据,自然就会被分成50个链表了,每个链表可以是无序的,反正链表要逐个比较进行查询。如果,我一个有200个数据,分组后,平均每组也就4个数据,那么,链表比较,平均也就比较4次就好了。但是,实际上,我们存放的数据,通常都不是整数。所以,我们需要将数据对象映射成整数的一个算法。HashCode方法,应运而生了。每个数据对象,都会对应一个HashCode值,通过HashCode我们可以将对象分组存放到不同的队列里。这样,在检索的时候,就可以减少比较次数。

在实际使用当中,HashCode方法、数组的大小 以及 数据对象的数量,这三者对检索的性能有着巨大的影响。
1.如果数组大小为1,那和链表存储没有什么区别了,而且,还多了一步计算HashCode的时间,所以,数组不能太小,太小查询费时间。
2.如果我只存放1个数据对象,数组又非常大,那么,数组所占的内存空间,就比数据对象占的空间还大,这样,少量数据对象,巨大的数组,虽然能够使检索速度,但是,浪费了很多内存空间。
3.如果所有对象的HashCode值都是相同的数,那么,无论数组有多大,这些数据都会保存到同一个链表里面,一个好的HashCode算法,可以使存放的数据,有较好的分散性,在实际的实现当中,HashSet和HashMap都对数据对象产生的HashCode进行了二次散列处理,使得数据对象具有更好的分散性。

二、HashMap的数据结构

先看代码,主要是两块:

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;
        }
    }
transient Node<K,V>[] table;

没错,其实HashMap的底层就是一个数组,数组的元素是一个单链表。用图看更清晰:
这里写图片描述
Node继承自Entry是HashMap的一个内部类,它也是维护着一个key-value映射关系,除了key和value,还有next引用(该引用指向当前table位置的链表),hash值(用来确定每一个Entry链表在table中位置)。
再看下hashMap的构造函数:

public HashMap(int initialCapacity, float loadFactor) {
    //容量不能小于0
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +                                           initialCapacity);
    //容量不能超出最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    //加载因子不能<=0 或者 为非数字
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);

    //计算出大于初始容量的最小 2的n次方作为哈希表table的长度,下面会说明为什么要这样
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;

    this.loadFactor = loadFactor;
    //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
    threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    //创建Entry数组
    table = new Entry[capacity];
    useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    init();
}

这个构造函数主要做的事情就是:
1. 对传入的 容量 和 加载因子进行判断处理
2. 设置HashMap的容量极限
3. 计算出大于初始容量的哈希表table的长度,这个长度一定是2的次方,并且当次方-1时就小于这个初始容量,有点绕,举个例子来说:初始长度为10,那长度就是16,因为2的3次方为8小于10。 然后用该长度创建Entry数组(table),这个是最核心的。为什么一定要是二的次方呢?这里直接说结论:当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。 原因会在下文中进行测试说明。

三、put

public V put(K key, V value) {
    //如果key为空的情况
    if (key == null)
        return putForNullKey(value);
    //计算key的hash值
    int hash = hash(key);
    //计算该hash值在table中的下标
    int i = indexFor(hash, table.length);
    //对table[i]存放的链表进行遍历
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判断该条链上是否有hash值相同的(key相同)  
        //若存在相同,则直接覆盖value,返回旧value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    //修改次数+1
    modCount++;
    //把当前key,value添加到table[i]的链表中
    addEntry(hash, key, value, i);
    return null;
}

看源码可以知道HashMap是可以把null当做key的,看下putForNullKey方法:

    private V putForNullKey(V value) {
        //查找链表中是否有null键
        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++;
        //如果链中查找不到,则把该null键插入
        addEntry(0, null, value, 0);
        return null;
    }

可以看到HashMap默认把null键的Entry放在数组的0位置,因为null无法获得hash值。下面看addEntry方法:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            //这一步就是对null的处理,如果key为null,hash值为0,也就是会插入到哈希表的表头table[0]的位置
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

这里出现了几个非常重要的方法,也是hashMap最核心的原理所在。

1.hash方法和indexFor方法:

final int hash(Object k) {
        int h = 0;
        if (useAltHashing) {
            if (k instanceof String) {
                return sun.misc.Hashing.stringHash32((String) k);
            }
            h = hashSeed;
        }

        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

    static int indexFor(int h, int length) {
        return h & (length-1);
    }

hash方法没啥可说的,就是对key值进行hash算法获得一个hash值,hash算法请自行百度,这是一个数学方法,没兴趣的同学也可以直接略过,只要记住hash算法的目的就一个,使hash后的那个int值尽量分散。重点看indexFor,其实很简单就是拿当前key的hash值与HashMap的长度-1进行与操作,其本质就是个取模的操作,使用与运算效率要比%高得多。好,我们前面说了HashMap的长度一定是2的次方,那2的次方-1满足什么条件呢?
2^1 - 1 = 0x1;
2^2 - 1 = 0x11;
2^3 - 1 = 0x111;
2^4 - 1 = 0x1111;
……
我们写个代码简单测试一下就知道这个方法的意义何在了:

    public static void main(String[] args)
    {   
        int length = 16;

        for(int i = 1; i <= 32; i++)
        {
            System.out.print((i & (length - 1)) + ", ");
        }
    }

当length = 16时,输出:
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0
当length = 15时,输出:
0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 0,
明显后者碰撞的概率要大得多,同时因为15 - 1 = 0x1110,所以无论hash为多少最后一位都会被与成0,导致最后一位为1的空间永远无法得到利用。

2.resize方法

    void resize(int newCapacity) {
         Entry[] oldTable = table;
         int oldCapacity = oldTable.length;
         if (oldCapacity == MAXIMUM_CAPACITY) {
             threshold = Integer.MAX_VALUE;
             return;
         }

         //创建一个新的 Hash 表
         Entry[] newTable = new Entry[newCapacity];

         transfer(newTable, initHashSeedAsNeeded(newCapacity));
         table = newTable;
         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
     }

     void transfer(Entry[] newTable, boolean rehash) {
         int newCapacity = newTable.length;
         for (Entry<K,V> e : table) {
             while(null != e) {
                 Entry<K,V> next = e.next;
                 if (rehash) {
                     e.hash = null == e.key ? 0 : hash(e.key);
                 }
                 int i = indexFor(e.hash, newCapacity);
                 e.next = newTable[i];
                 newTable[i] = e;
                 e = next;
             }
         }
     }

还记得HashMap中的一个变量吗,threshold,这是容器的容量极限,还有一个变量size,这是指HashMap中键值对的数量,也就是node的数量
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
什么时候发生扩容?
当不断添加key-value,size大于了容量极限threshold时,会发生扩resize。
resize这里是个大坑,因为会导致死锁,而根本原因来自transfer方法,这个方法干的事情是把原来数组里的1-2-3链表transfer成了3-2-1,不太看得懂的同学可以看我下面的演示代码,本质是一样的:

package com.amuro.studyhashmap;

public class HashMapStudy
{
    public static void main(String[] args)
    {
        Node n1 = new Node();
        n1.data = 1;

        Node n2 = new Node();
        n2.data = 2;

        Node n3 = new Node();
        n3.data = 3;

        n1.next = n2;
        n2.next = n3;

        printLinkedNode(n1);
        System.out.println("------");

        Node newHead = mockHashMapTransfer(n1);
        printLinkedNode(newHead);
    }

    static class Node
    {
        int data;
        Node next;
    }

    static void printLinkedNode(Node head)
    {

        while(head != null)
        {
            System.out.println(head.data);
            head = head.next;
        }
    }

    static Node mockHashMapTransfer(Node e)
    {
        Node newHead = null;

        while(e != null)
        {

            Node next = e.next;
            e.next = newHead;
            newHead = e;
            e = next;
            System.out.print("");
        }

        return newHead;
    }

}

结果输出:
1
2
3
_ _
3
2
1
transfer方法的本质就是这个,那为什么会导致死锁呢?简单分析一下:
我们假设有两个线程T1、T2,HashMap容量为2,T1线程放入key A、B、C、D、E。在T1线程中A、B、C Hash值相同,于是形成一个链接,假设为A->C->B,而D、E Hash值不同,于是容量不足,需要新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中(refresh)。这时T2进程闯进来了,T1暂时挂起,T2进程也准备放入新的key,这时也发现容量不足,也refresh一把。refresh之后原来的链表结构假设为C->A,之后T1进程继续执行,链接结构为A->C,这时就形成A.next=B,B.next=A的环形链表。一旦取值进入这个环形链表就会陷入死循环。
所以多线程场景下,建议使用ConcurrentHashMap,用到了分段锁的技术,后面有机会再讲。

最后整理一下put的步骤:
1. 传入key和value,判断key是否为null,如果为null,则调用putForNullKey,以null作为key存储到哈希表中;
2. 然后计算key的hash值,根据hash值搜索在哈希表table中的索引位置,若当前索引位置不为null,则对该位置的Entry链表进行遍历,如果链中存在该key,则用传入的value覆盖掉旧的value,同时把旧的value返回,结束;
3. 否则调用addEntry,用key-value创建一个新的节点,并把该节点插入到该索引对应的链表的头部。

四、get

    public V get(Object key) {
        //如果key为null,求null键
        if (key == null)
            return getForNullKey();
        // 用该key求得entry
        Entry<K,V> entry = getEntry(key);

        return null == entry ? null : entry.getValue();
    }
    final Entry<K,V> getEntry(Object key) {
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

put能看懂的同学看get应该毫无压力,调用hash(key)求得key的hash值,然后调用indexFor(hash)求得hash值对应的table的索引位置,然后遍历索引位置的链表,如果存在key,则把key对应的Entry返回,否则返回null。

从HashMap的结构和put原理我们也能理解为什么HashMap在遍历数据时,不能保证插入时的顺序。这时需要使用LinkedHashMap。

最后把java里map的四个实现类做个总结。
1.Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。 HashMap最多只允许一条记录的键为Null;允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap;可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。

2.Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。

3.LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的.也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。

4.TreeMap实现SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。

最后的最后再加个tip:
ConcurrentHashMap提供的线程安全是指他的put和get等操作是原子操作,是线程安全的。但没有提供多个操作(判断-更新)的事务保护,也就是说:

//T1:                                        
concurrent_map.insert(key1,val1);
//T2:
concurrent_map.contains(key1);

是线程安全的。

//T1,T2同时
if (! concurrent_map.contains(key1) {
    concurrent_map.insert(key1,val1)
}

线程不安全。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值