`

HashMap--阅读源码从jdk开始

阅读更多

目录

 

一、HashMaprehash机制

二、hashcode()equals()方法

三、与Hashtable比较

 

一、HashMaprehash机制

 

前一篇说到在大量数据需要放入到ArrayList时,先确定总体容量大小,尽量使用确定容量的构造方法进行实例化,防止因为自动扩容导致的数组复制。

 

相信大家也猜到了HashMap,也有类似问题。HashMap在自动扩容的过程中会出现rehash,每次rehash的代价是非常昂贵的,为了尽量避免rehash的产生。做法也是和ArrayList一样,先确定HashMap的容量大小,使用确定容量的构造方法实例化HashMap对象。

 

口说无凭,我们还是从jdk源码来了解HashMaprehash

重要的成员变量说明:

//散列表:
transient Node<K,V>[] table;
//默认初始容量大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//默认增长因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

 

 

重点说下散列表,HashMap的精髓:是由一个桶数组+轻量级单向链表或红黑树(jdk1.8)组成,通过计算每个keyhashcode值定位到指定的桶,如果桶中已经存在就放到单向链表尾部(jdk1.8),在jdk1.8中如果单向链表的长度超过8就会转换为红黑树(提升查询速度)。以下源码为jdk1.8版本。

 

再分析HashMap4个构造方法。

1、默认构造方法,建议在容量小于16时使用

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

 

默认初始容量是16

默认增长因子0.75,也就是容量达到“阀值”16*0.73 = 12时,进行扩容,之后rehash(重新计算元素所在的位置)。

 

2、指定容量构造方法,建议在容量大于16时使用

public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

 

 

3、指定容量,指定增长因子构造方法:

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

 

根据自己的业务增长规则,指定初始容量,以及增长因子。在不能确定容量,只能预估的情况下使用,核心目标也是减少扩容次数(rehash)。

 

接下来 重点关注put方法源码,因为rehash可能会在放入HashMap的过程中进行。在阅读源码之前 要搞清楚几个变量的关系:散列表的容量capacity >(大于) 散列表的长度length(数组长度) >(大于) 散列表的阀值threshold

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

 

hash方法,计算hash

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
 
/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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; //这里resize() 会进行初始化HashMap
        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) { //遍历到桶的尾结点都没有找到相同key,把新值插入尾结点
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash); //单向链表长度大于8,转化为红黑树
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k)))) //该key已经存在
                        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(); //容量超过阀值,进行扩容,以及rehash操作
        afterNodeInsertion(evict);
        return null;
    }

 

 

putVal方法里有两次调用resize()方法:

第一次if ((tab = table) == null || (n = tab.length) == 0) 这个时候调用resize()是进行初始化散列表;

第二次在put完成之后,if (++size > threshold)当前数据长度是否超过阀值,如果超过,调用resize()方法进行扩容(2倍),并重新计算各个节点在新散列表中的位置,也就是所谓的rehashresize()方法源码如下:

 

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //老散列表
        int oldCap = (oldTab == null) ? 0 : oldTab.length; //计算老散列表长度
        int oldThr = threshold; //老散列表阈值
        int newCap, newThr = 0; //新散列表容量,和新阈值
        if (oldCap > 0) {//如果老散列表有容量,进行扩容
            if (oldCap >= MAXIMUM_CAPACITY) { //超过最大值不用扩容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold 新容量 新阈值都翻倍,<<运算比直接X2 效率高
        }
        else if (oldThr > 0) // 老容量为0,老阈值不为0,把新容量设置为老阈值。新阀值在后面确定
            newCap = oldThr;
        else {               // 如果都为0,新容量、新阀值 都使用默认值
            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; //如果只有一个结点,rehash 重新计算在新散列表中的位置
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //如果是红黑树,重新计算红黑树在新散列表中的位置
                    else { // preserve order 单项链表 rehash处理逻辑,jdk1.8以前需要根据hash值重新计算位置,jdk1.8有优化,将链表拆分为两份
                        Node<K,V> loHead = null, loTail = null;//保留在原来位置j的链表
                        Node<K,V> hiHead = null, hiTail = null;//迁移到j+ oldCap位置的链表
                        Node<K,V> next;
                        do { //这个循环进行链表拆分
                            next = e.next;
                            if ((e.hash & oldCap) == 0) { //&运算为0,位置不变,在新散列表中还是在j位置:newTab[j]
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else { ////&运算为0,迁移到新散列表中的 j+ oldCap位置
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead; //j位置指向拆分后的第一个链表
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;//j+ oldCap位置指向拆分后的第二个链表
                        }
                    }
                }
            }
        }
        return newTab; //返回新散列表
    }
 

 

可以看出HashMap扩容时,会重新计算每个节点在新散列表中的位置,即便jdk1.8对单向链表的rehash做了优化,代价还是非常昂贵的。应该尽量避免HashMap自动扩容。

 

 

一个例子:

以最近开发的“点击热力图”后端接口(这里只是精简版)为例,由于活动页面每天的点击次数很多,尤其是大促活动(点击次数都在万级别)。不好的写法如下:

/**
     * 获取页面所有位置的点击次数
     * @param url 活动链接
     * @param time 查询日期
     * @return 前端通过jsonp异步调用,需要返回一个json格式的字符给前端
     */
    public String getAllClicks(String url,Date time){
        List<PageClick> clickList = clickDao.getAllClicksByUrl(url,time);
        // ....省略代码....
        Map<String,String> clickMap = new HashMap<>();//使用默认容量16的构造函数,会出现多次rehash
        for (PageClick oneclick:clickList){
            clickMap.put(oneclick.getKey(),oneclick.getValue);
        }
        //....省略代码.....
        return toJson(clickMap);
    }

 

 

优化处理:只需要把Map<String,String> clickMap = new HashMap<>() 改为Map<String,String> clickMap = new HashMap<>(clickList.size()) 即可。

 

小结:只需存储少量节点的情况下(比如少于16),使用默认构造方法HashMap()即可。如需存储大量的节点(比如多余16),尽量使用确定容量的构造方法HashMap(int cap)

 

二、hashcode()equals()方法

 

java面试中的一道经典题目是:是否可以用自定义对象做HampMapkey?答案是视情况而定。如果自定义对象所属类正确的重写了hashcode()equals()方法,则可以作为HampMapkey;否则该自定义对象则无法与基于散列的集合(HashMapHashsetHashtable)一起正常运作。

 

HashMapput(K key, V value)方法源码中可以看到,散列表是通过对象调用keyhashcode()方法获取到hash值,再通过一个按位与运算得到该节点在散列表中位置。如果多个节点hash值相同(hash冲突),就会命中同一个桶,HashMap通过在该桶上添加单向链表(或红黑树)以保存多个节点。遍历单向链表(或红黑树),通过对象keyequals()方法,判断节点是否已经存在,如果已经存在则修改其value值,否则插入新节点。 

 

简单的说:hashcode方法决定节点在散列表的哪个桶里,再由equals方法确定节点在单向链表(或红黑树)的位置。

 

所以只有正确的重写了hashcode()equals()方法的自定义对象才可以作为key放入HashMap。看一段测试代码:

public class Test {
    public static void main(String[] args) {
        Map<Persion,Integer> p = new HashMap<>();
        p.put(new Persion("zhan san","男"),Integer.valueOf(23));
        System.out.println(“”p.get(new Persion("zhan san","男")));
    }
}
 
class Persion{
    private String name;
    private String sex;
 
    public Persion(String name,String sex){
        this.name = name;
        this.sex = sex;
    }
}

 

执行结果为:null

 

这里putget方法里的new Persion("zhan san","")对象,其实是两个对象,所以计算的hashcode就不同,。也就是说put的时候是放到了table[i]get的时候取的是table[j](ij分别是两个hashcode计算出来在散列表table中的位置)。这里table[j]null,所以返回的是null。如果table[j]存放的完全有可能是另外一个节点,这时候会发生无法想象的后果。

 

把代码改下,让Person类重写ObjecthashCodeequals方法。如:

public class Test {
    public static void main(String[] args) {
        Map<Persion,Integer> p = new HashMap<>();
        p.put(new Persion("zhan san","男"),Integer.valueOf(23));
        System.out.println("执行结果为:"+p.get(new Persion("zhan san","男")));
    }
}
 
class Persion{
    private String name;
    private String sex;
 
    public Persion(String name,String sex){
        this.name = name;
        this.sex = sex;
    }
 
    @Override
    public int hashCode(){
        return name.hashCode()+ sex.hashCode();
    }
 
    public boolean equals(Object o){
        if(o == this){
            return true;
        }
        if(!(o instanceof Persion)){
            return false;
        }
        Persion p = (Persion)o;
        return p.name.equals(name) && p.sex.equals(sex);
    }
}
 

 

执行结果为:23

 

覆盖这两个方法也是需要遵守一些通用约定的,否则就会出现一些意想不到的后果。

覆盖equals方法需遵守的约定:

1、自反性 x. equals(x)必须返回true

2、对称性 xy都不为null,如果x. equals(y)=true,那么y. equals(x)=ture

3、传递性 如果x. equals(y)=true, y. equals(z)=true,那么x. equals(z)=true

4、一致性 x y非空 且没有被改的情况下,无论多少次调用x. equals(y),要么永远为true,要么永远为false

 

覆盖equals方法最佳实践:

1、参数为Object类型,方法内部首先判断是不是对象自身(如果是 直接返回true,不需要再做判断),第二步做类型检查,第三步强制转换,第四步判断成员变量值是否相同。

2、如果成员变量为stringwapper(IntegerShort)equals比较;

3、如果为基本类型用==比较(floatdouble除外,比较用封装类的compare方法);

4、如果为其他自定义类型,调用各自重写的equals方法进行比较。

 

覆盖hashcode方法需遵守的约定:

1、只要equals方法里进行比较的成员变量信息没有改变,多次调用hashcode方法返回的都应当是同一个整数。

2、如果两个对象根据equals方法比较相等,那么他们的hashcode方法也需返回同样的整数。

3、不相等的对象产生不相等的散列码。假如所有对象的hashcode方法都返回相等的整数,把这样的对象放入HashMap,所有的节点都会在一个桶里,HashMap将退化为一个单向链表(或者红黑树)。

 

覆盖hashcode方法,可以先对每个成员变量先进行计算(摘自《effective java)

1、如果成员变量为boolean,则f?1:0

2、如果为bytecharshortint型,则(int)f

3、如果为long型,则(int)(f^(f>>>32))  (一种巧妙的做法,如果该long型在int范围内,得到的就是对应的int)

4、如果为float,则Float.floatToIntBits(f)  (浮点数以转化成二进制存储格式,再把这个二进制转化成10进制)

5、如果为double,则Double.doubleToLongBits(f),得到的是一个long型,再按照3中的方法对long进行转化。

6、如果为引用,调用引用的hashcode方法(或者继续递归调用方法),如果为引用为null,则为0

7、如果为数组,需要采用6中的方式对每一个成员单独处理, 再按照8中的方式对每一个成员进行计算。

8、假设设ret=17(非0整数就行),1-6中的计算结果为c7中可以认为有多个c),对每个c进行如下计算:

ret = 31*ret + c;

例如:上面Persion类的hashcode方法我们应该这样来重写:

    public int hashCode(){
        int ret = 17;
        ret = 31*ret + (name.hashCode());
        ret = 31*ret + sex.hashCode();
        return ret;
    }

 

采用以上的规则重写hashcode方法可以保证 计算出来的hashcode值尽量的分散,减少放入HashMap时的hash冲突的可能性,存取的才能更高效。

 

小结:尽量使用不可变类型的对象(如String类型,各种wapper封装类型)作为HashMapkey,如果要使用自定义类型的对象作为key,请按照上述建议重写hashcodeequals方法。

 

三、与Hashtable比较

 

jdk1.8以前HashtableHashMap的实现基本相同,只是Hashtable的大部分方法实现添加synchronized,比如:

public synchronized V get(Object key) {

   //省略代码

}

也就是所谓的Hashtable是线程安全,HashMap是线程不安全的 可以说是Hashtable的简化版实现。

 

调用Collections.synchronizedMap((Map<K,V> m)也可以生成一个线程安全的HashMap,原理也很简单,实际上是对Map所有方法的进行synchronized限制,比如get方法:

    public V get(Object key) {

        synchronized(mutex) {return m.get(key);}

    }

 

 

HashtableCollections.synchronizedMap实现线程安全的方式相同,都是对全表进行加锁,性能低下,不建议使用,而是建议采用局部加锁的ConcurrentHashMap。在jdk1.8版本中对常用的 HashMapConcurrentHashMap都做了优化。这里不再对ConcurrentHashMap进行讲解,以后再单独进行总结。

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics