目录
一、HashMap的rehash机制
二、hashcode()和equals()方法
三、与Hashtable比较
一、HashMap的rehash机制
前一篇说到在大量数据需要放入到ArrayList时,先确定总体容量大小,尽量使用确定容量的构造方法进行实例化,防止因为自动扩容导致的数组复制。
相信大家也猜到了HashMap,也有类似问题。HashMap在自动扩容的过程中会出现rehash,每次rehash的代价是非常昂贵的,为了尽量避免rehash的产生。做法也是和ArrayList一样,先确定HashMap的容量大小,使用确定容量的构造方法实例化HashMap对象。
口说无凭,我们还是从jdk源码来了解HashMap的rehash。
重要的成员变量说明:
//散列表: transient Node<K,V>[] table; //默认初始容量大小 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认增长因子 static final float DEFAULT_LOAD_FACTOR = 0.75f;
重点说下散列表,HashMap的精髓:是由一个桶数组+轻量级单向链表或红黑树(jdk1.8)组成,通过计算每个key的hashcode值定位到指定的桶,如果桶中已经存在就放到单向链表尾部(jdk1.8),在jdk1.8中如果单向链表的长度超过8就会转换为红黑树(提升查询速度)。以下源码为jdk1.8版本。
再分析HashMap的4个构造方法。
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倍),并重新计算各个节点在新散列表中的位置,也就是所谓的rehash,resize()方法源码如下:
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面试中的一道经典题目是:是否可以用自定义对象做HampMap的key?答案是视情况而定。如果自定义对象所属类正确的重写了hashcode()和equals()方法,则可以作为HampMap的key;否则该自定义对象则无法与基于散列的集合(HashMap、Hashset、Hashtable)一起正常运作。
从HashMap的put(K key, V value)方法源码中可以看到,散列表是通过对象调用key的hashcode()方法获取到hash值,再通过一个按位与运算得到该节点在散列表中位置。如果多个节点hash值相同(hash冲突),就会命中同一个桶,HashMap通过在该桶上添加单向链表(或红黑树)以保存多个节点。遍历单向链表(或红黑树),通过对象key的equals()方法,判断节点是否已经存在,如果已经存在则修改其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
这里put和get方法里的new Persion("zhan san","男")对象,其实是两个对象,所以计算的hashcode就不同,。也就是说put的时候是放到了table[i],get的时候取的是table[j](i、j分别是两个hashcode计算出来在散列表table中的位置)。这里table[j]为null,所以返回的是null。如果table[j]存放的完全有可能是另外一个节点,这时候会发生无法想象的后果。
把代码改下,让Person类重写Object的hashCode和equals方法。如:
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、对称性 x、y都不为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、如果成员变量为string或wapper类(Integer、Short等)用equals比较;
3、如果为基本类型用==比较(float、double除外,比较用封装类的compare方法);
4、如果为其他自定义类型,调用各自重写的equals方法进行比较。
覆盖hashcode方法需遵守的约定:
1、只要equals方法里进行比较的成员变量信息没有改变,多次调用hashcode方法返回的都应当是同一个整数。
2、如果两个对象根据equals方法比较相等,那么他们的hashcode方法也需返回同样的整数。
3、不相等的对象产生不相等的散列码。假如所有对象的hashcode方法都返回相等的整数,把这样的对象放入HashMap,所有的节点都会在一个桶里,HashMap将退化为一个单向链表(或者红黑树)。
覆盖hashcode方法,可以先对每个成员变量先进行计算(摘自《effective java》):
1、如果成员变量为boolean,则f?1:0。
2、如果为byte、char、short或int型,则(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中的计算结果为c(7中可以认为有多个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封装类型)作为HashMap的key,如果要使用自定义类型的对象作为key,请按照上述建议重写hashcode和equals方法。
三、与Hashtable比较
在jdk1.8以前Hashtable与HashMap的实现基本相同,只是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);}
}
Hashtable和Collections.synchronizedMap实现线程安全的方式相同,都是对全表进行加锁,性能低下,不建议使用,而是建议采用局部加锁的ConcurrentHashMap。在jdk1.8版本中对常用的 HashMap和ConcurrentHashMap都做了优化。这里不再对ConcurrentHashMap进行讲解,以后再单独进行总结。