HashMap
特点
1、HashMap 1.8之前是基于数组+链表数据结构组成,1.8 是基于 数组+链表+红黑树数据结构组成;
1.7 数组是基于Entry实现的;1.8是基于Node实现的都是<K,V>类型的数组,并且都实现了Map.Entry<K,V>;
2、存取是无序的,键和值都可以为null,但是键位置只能有一个null,键是唯一的;
3、阈值(边界值)>8 并且数组长度大于64时,才将链表转换为红黑树,红黑树目的是高效查找;
4、1.7是在构造函数中初始化数组容量,1.8是在put方法中初始化数组容量;
5、是线程不安全的。
一、继承关系:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
二、实例变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 默认容量
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
static final float DEFAULT_LOAD_FACTOR(加载因子):0.75 //HashMap的负载因子,可计算出当前table长度下的扩容阈值:threshold = loadFactor * table.length。
static final int TREEIFY_THRESHOLD = 8; //链表元素容量达到8时转换成红黑树,根据泊松分布计算概率是最低的,仅仅为0.0000006。
static final int UNTREEIFY_THRESHOLD = 6;//当链表的值小于6则从红黑树转回链表。
static final int MIN_TREEIFY_CAPACITY = 64;//表示数组长度大于64时链表转换成红黑树。
transient Node<K,V>[] table;//HashMap的哈希桶数组,非常重要的存储结构,用于存放表示键值对数据的Node元素。
transient Set<Map.Entry<K,V>> entrySet; //HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能。
transient int size; //HashMap中实际存在的Node数量,注意这个数量不等于table的长度,甚至可能大于它,因为在table的每个节点上是一个链表(或RBT)结构,可能不止有一个Node元素存在。
transient int modCount; //HashMap的数据被修改的次数,这个变量用于迭代过程中的Fail-Fast机制,其存在的意义在于保证发生了线程安全问题时,能及时的发现(操作前备份的count和当前modCount不相等)并抛出异常终止操作。
int threshold; ,自动扩容容量为原来的二倍。
int threshold = 16*0.75 //HashMap的扩容阈值,在HashMap中存储的Node键值对超过这个数量时是当前数组的最大长度会进行扩容,扩容后的HashMap容量是之前容量的2倍。
加载因为为啥是0.75?
小于0.75 ,会浪费存储空间,很早就会扩容;
大于0.75,发生hash碰撞概率更大,导致链表很长,导致查找元素效率低。
三、构造函数:
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
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);
this.loadFactor = loadFactor;
//判断指定的初始容量是否是2的n次幂,如果不是将其变为2的n次幂
//但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,
//并且直接赋值给了threshold边界值了;正常的的边界值=cap*0.75
//但是,在jdk1.8以后的构造方法中,并没有对table这个成员变量进行初始化,
//table的初始化放到了put方法中,在put中会对threshold重新计算
this.threshold = tableSizeFor(initialCapacity);
}
Hashmap扩容为啥是2的n次幂:为了减少hash冲突。不是2的n次幂,会通过右移 和 按位或运算变为比指定容量大的最小的2的2次幂。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1; // | 位或运算规则:参加运算的两个数只要两个数中的一个为1,结果就为1
n |= n >>> 2; // >>> :表示右移几位,当前值的二进制右移几位
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
中的cap-1 操作是 防止当指定容量为2的n次幂时,如cap=8,计算容量后变为16,防止容量翻倍。
cap=8
00000000 00000000 00000000 00001000
00000000 00000000 00000000 00000100
>>>1
-----------------------------------
00000000 00000000 00000000 00001100
00000000 00000000 00000000 00000011
>>>2
-----------------------------------
00000000 00000000 00000000 00001111
00000000 00000000 00000000 00000000
>>>4
-----------------------------------
00000000 00000000 00000000 00001111 = 15
四、成员方法
4.1 public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
(1)先计算key的hash:hash(key)
static final int hash(Object key) {
int h;
//key的hashcode,key的hashcode无符号右移运算,进行异或计算。效率高于取模运算,但是结果一样
//将key的hashcode值(由native方法计算得到)再与该值的高16位进行异或运算得到最终的hash值。
//目的尽量使得hash值较为分散。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //相当于 key的hashcode()%n:此处n指的是数组长度。
}
(2)存放k-v:返回的值则将是之前在map中实际与key相关联的Value值(也就是旧的值),如果key没有实际映射值的话那就返回null。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果map为空即map中的table为空,则进行初始化,即进行首次扩容。
if ((tab = table) == null || (n = tab.length) == 0)
//长度n是一个2的次幂数值
n = (tab = resize()).length;
// 使用hash与数组长度减一的值进行异或得到数组下标i,表示key的值或许可能放到这个位置上
// 如果这个位置的值为null,则新建Node<K,V>节点存放
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//else 表示key的hash计算得到的数组下标这个位置的值 不为null,即发生了hash碰撞
//即此处采用链表和红黑树来存储
else {
Node<K,V> e; K k;
// 如果key的hash值相等 并且 key也相等
// 或者key不等于null 并且 key的内容相等 说明两个key是一样的
// 将当前节点p用临时的e标识
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果存在的节点p已经是红黑树类型的节点,表示碰撞已经开始用树来处理了,则走红黑树的插入。
else if (p instanceof TreeNode)
//this 表示当前的map,tab是map中的数组,红黑树的插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {//遍历链表
for (int binCount = 0; ; ++binCount) {
// 当前发生碰撞的节点没有后续节点,表示已经到了链表的尾节点,则直接尾部插入,1.8的尾插法
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;
}
//如果下个节点和碰撞的节点的hash、key相等或者key的内容相等
//用新的节点替换老的节点,退出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key,e是被碰撞的节点,老节点
V oldValue = e.value;
//将用新值覆盖旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
//返回旧值
return oldValue;
}
}
//map变更操作的计数器,比如map结构变化、内容增减、rehash,直接导致外部map并发
// 迭代引起fail-fast问题,该值就是比较的基础
++modCount;
//size map中k-v数量,当容量达到扩容阀值时,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
Hash碰撞产生及解决
Hashmap里面的bucket出现了单链表的形式,散列表要解决的一个问题就是散列值的冲突问题,通常是两种方法:链表法和开放地址法。
1、链表法就是将相同hash值的对象组织成一个链表放在hash值对应的槽位;
2、开放地址法是通过一个探测算法,当某个槽位已经被占据的情况下继续查找下一个可以使用的槽位。
3、java.util.HashMap采用的链表法的方式,链表是单向链表。
(3)链表转红黑树:链表长度大于8,则
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//MIN_TREEIFY_CAPACITY==64
//如果数组为null,或者数组长度小于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;
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);
if ((tab[index] = hd) != null)
// tab表开始链接成树
hd.treeify(tab);
}
}
小结:扩容时机:
1、map中put第一个元素
2、map中元素数量大于阀值threshold
3、链表长度大于8并且数组长度小于64时进行resize
(4)扩容:将整个map的k-v进行散列存储,性能低下
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) {//第一次不会走这里,因为table为null,oldCap ==0
//如果扩容前数组大小已经达到了最大值,即2^30,则设置扩容的最大阈值为Int的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果将就数组容量扩大为原来的2倍小于最大容量,并且旧的容量大于16
//则将数组大小扩大为原来的2位,阈值扩大为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果原始(初始)容量不大于0,并且阈值大于0,则将容量初始化位阈值大小
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//初始容量和阈值都不大于0,则用map中的默认值
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已经是扩容后的table了
table = newTab;
//对oldTab中所有元素进行rehash。
//由于每次扩容是2次幂的扩展(指数组长度/桶数量扩为原来2倍)
//所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {//如果数组j位置的元素不为null,则将该位置的所有元素全部重新hash
oldTab[j] = null;
if (e.next == null)//只有一个元素,直接rehash
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//如果是树,
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//桶中是链表结构
//JDK1.7中旧链表迁移新链表的时候,用的是头插法,如果在新表的数组索引位置相同,则链表元素会倒置;
//JDK1.8不会倒置,用的是双指针,即尾插入
Node<K,V> loHead = null, loTail = null;// low位链表,其桶位置不变,head和tail分别代表首尾指针
Node<K,V> hiHead = null, hiTail = null;// high位链表,其桶位于追加后的新数组中
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {// 是0的话索引没变,是1的话索引变成“原索引+oldCap”
if (loTail == null)
loHead = e;// 总是指向头结点
else
loTail.next = e;// 该操作有可能会改变原链表结构
loTail = e;// 总是指向下一个节点,直到尾节点
}
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;// 原索引+oldCap
}
}
}
}
}
return newTab;
}
get方法
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;
//如果table不为null并且key所在的桶不为null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//先计算桶的头结点是否是想要的节点
if (first.hash == hash && // always check first node
//如果key相等并且key的内容也相等,则返回key对应的value
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果桶中不止一个节点
if ((e = first.next) != null) {
//如果桶是树,就从树中查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//遍历桶中当前的链表
do {
//如果key的hash值和key的地址都相等或者key的内容也相等,则返回key对应的value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
在 Java8 中,HashMap 中的桶可能是链表结构,也可能是树结构。
如果是链结构
将旧链表拆分成两条新的链表,通过 e.hash & oldCap 来计算新链表在扩容后的数组中的新下标。
当 e.hash & oldCap = 0,则节点在新数组中的索引值与旧索引值相同。
当 e.hash & oldCap = 1,则节点在新数组中的索引值为旧索引值+旧数组容量。
在 Java8 中,HashMap 插入元素使用尾插法,扩容时使用了首尾指针保证了链表元素顺序不会倒置,从而解决了 Java7 扩容时产生的环问题。
在 Java7中,HashMap 使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。(线程1准备处理节点,线程二把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。
①HashMap的工作原理
HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。
当两个不同的键对象的hashcode相同时会发生什么? 它们会储存在同一个bucket位置的链表中。键对象的equals()方法用来找到键值对。
②HashMap和Hashtable的区别
1、HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。
2、HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
3、HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
4、另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。
由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
HashMap不能保证随着时间的推移Map中的元素次序是不变的。
要注意的一些重要术语:
-
sychronized意味着在一次仅有一个线程能够更改Hashtable。就是说任何线程要更新Hashtable时要首先获得同步锁,其它线程要等到同步锁被释放之后才能再次获得同步锁更新Hashtable。
-
Fail-safe和iterator迭代器相关。如果某个集合对象创建了Iterator或者ListIterator,然后其它的线程试图“结构上”更改集合对象,将会抛出ConcurrentModificationException异常。但其它线程可以通过set()方法更改集合对象是允许的,因为这并没有从“结构上”更改集合。但是假如已经从结构上进行了更改,再调用set()方法,将会抛出IllegalArgumentException异常。
-
结构上的更改指的是删除或者插入一个元素,这样会影响到map的结构。
我们能否让HashMap同步?
HashMap可以通过下面的语句进行同步:
Map m = Collections.synchronizeMap(hashMap);
③HashMap和HashSet的区别
HashMap和HashSet的区别是Java面试中最常被问到的问题。如果没有涉及到Collection框架以及多线程的面试,可以说是不完整。而Collection框架的问题不涉及到HashSet和HashMap,也可以说是不完整。HashMap和HashSet都是collection框架的一部分,它们让我们能够使用对象的集合。collection框架有自己的接口和实现,主要分为Set接口,List接口和Queue接口。它们有各自的特点,Set的集合里不允许对象有重复的值,List允许有重复,它对集合中的对象进行索引,Queue的工作原理是FCFS算法(First Come, First Serve)。
首先让我们来看看什么是HashMap和HashSet,然后再来比较它们之间的分别。
什么是HashSet
HashSet实现了Set接口,它不允许集合中有重复的值,当我们提到HashSet时,第一件事情就是在将对象存储在HashSet之前,要先确保对象重写equals()和hashCode()方法,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。如果我们没有重写这两个方法,将会使用这个方法的默认实现。
public boolean add(Object o)方法用来在Set中添加元素,当元素值重复时则会立即返回false,如果成功添加的话会返回true。
什么是HashMap
HashMap实现了Map接口,Map接口对键值对进行映射。Map中不允许重复的键。Map接口有两个基本的实现,HashMap和TreeMap。TreeMap保存了对象的排列次序,而HashMap则不能。HashMap允许键和值为null。HashMap是非synchronized的,但collection框架提供方法能保证HashMap synchronized,这样多个线程同时访问HashMap时,能保证只有一个线程更改Map。
public Object put(Object Key,Object value)方法用来将元素添加到map中。
HashSet和HashMap的区别
HashMap HashSet
HashMap实现了Map接口 HashSet实现了Set接口
HashMap储存键值对 HashSet仅仅存储对象
使用put()方法将元素放入map中 使用add()方法将元素放入set中
HashMap中使用键对象来计算hashcode值 HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false
HashMap比较快,因为是使用唯一的键来获取对象 HashSet较HashMap来说比较慢
④面试题
HashMap的工作原理是近年来常见的Java面试题。几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道Hashtable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深。这题经常出现在高级或中高级面试中。投资银行更喜欢问这个问题,甚至会要求你实现HashMap来考察你的编程能力。ConcurrentHashMap和其它同步集合的引入让这道题变得更加复杂。让我们开始探索的旅程吧!
“你用过HashMap吗?” “什么是HashMap?你为什么用到它?”
然后回答HashMap的一些特性,譬如HashMap可以接受null键值和值,而Hashtable则不能;HashMap是非synchronized;HashMap很快;以及HashMap储存的是键值对等等。
“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”
但一些面试者可能可以给出答案,“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。”这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。但是这仅仅是故事的开始,当面试官加入一些Java程序员每天要碰到的实际场景的时候,错误的答案频现。下个问题可能是关于HashMap中的碰撞探测(collision detection)以及碰撞的解决方法:
“当两个对象的hashcode相同会发生什么?” 从这里开始,真正的困惑开始了,一些面试者会回答因为hashcode相同,所以两个对象是相等的,HashMap将会抛出异常,或者不会存储它们。然后面试官可能会提醒他们有equals()和hashCode()两个方法,并告诉他们两个对象就算hashcode相同,但是它们可能并不相等。一些面试者可能就此放弃,而另外一些还能继续挺进,他们回答“因为hashcode相同,所以它们的bucket位置相同,‘碰撞’会发生。因为HashMap使用链表存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在链表中。”这个答案非常的合理,虽然有很多种处理碰撞的方法,这种方法是最简单的,也正是HashMap的处理方法。但故事还没有完结,面试官会继续问:
“如果两个键的hashcode相同,你如何获取值对象?” 面试者会回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。面试官提醒他如果有两个值对象储存在同一个bucket,他给出答案:将会遍历链表直到找到值对象。面试官会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?除非面试者直到HashMap在链表中存储的是键值对,否则他们不可能回答出这一题。
其中一些记得这个重要知识点的面试者会说,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。完美的答案!
许多情况下,面试者会在这个环节中出错,因为他们混淆了hashCode()和equals()方法。因为在此之前hashCode()屡屡出现,而equals()方法仅仅在获取值对象的时候才出现。一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。
如果你认为到这里已经完结了,那么听到下面这个问题的时候,你会大吃一惊。“如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?”除非你真正知道HashMap的工作原理,否则你将回答不出这道题。默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。
如果你能够回答这道问题,下面的问题来了:“你了解重新调整HashMap大小存在什么问题吗?”你可能回答不上来,这时面试官会提醒你当多线程的情况下,可能产生条件竞争(race condition)。
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?:)
为什么String, Interger这样的wrapper类适合作为键? String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
我们可以使用CocurrentHashMap来代替Hashtable吗?这是另外一个很热门的面试题,因为ConcurrentHashMap越来越多人用了。我们知道Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。看看这篇博客查看Hashtable和ConcurrentHashMap的区别。