简介
HashMap是Hashtable的轻量级实现(非线程安全的实现),他们都完成了Map接口,主要区别在于HashMap允许空(null)键值(key),由于非线程安全,效率上可能高于Hashtable,HashMap允许将Null作为一个entryde key 或者value,而Hashtable不允许 HashMap把Hashtable的contains思路方法去掉了,改成containsvalue和containsKey因为contains思路方法容易让人引起误解Hashtable继承自Dictionary类,而HashMap是Java1.2引进的Map interface的一个实现。
最大的区别是,Hashtable的思路方法是Synchronize的,而HashMap不是,在多个线程访问Hashtable时,不需要自己为它的思路方法实现同步,而HashMap 就必须为的提供外同步。另外,Hashtable和HashMap采用的hash/rehash算法都大概一样,所以性能不会有很大的差异。
结构
HashMap和Hashtable是Java开发常用的一种集合。两者的比较是Java面试中的常见问题。查看源码知道,Hashtable存在很久了,@since JDK1.0。HashMap和Hashtable都实现了Map接口,继承结构如下。
Map
├Hashtable
├HashMap
└WeakHashMap
区别
开发中要用哪个还是要根据它们之间的区别来决定。下面看下HashMap和Hashtable的区别。
null
HashMap可以接受为null的键值(key)和值(value),比如:
public void testHashMapAndHashtable() {
HashMap map = new HashMap<String, String>();
map.put(null, "null");
map.put("zw", "zw");
map.put("null", "null2");
map.put("null2", null );
map.putIfAbsent("null", "null3");
System.out.println("==elements == " + map.entrySet());
System.out.println("==HashMap size== " + map.size());
}
输出:
==elements == [null=null, null=null2, zw=zw, null2=null]
==HashMap size== 4
而Hashtable则不行,当我们尝试向Hashtable 实例中添加 null为 key或者 value时均会出现NullPointerException 。
private void printHashtable() {
Hashtable table = new Hashtable<String, String>();
table.put("zw", "zw");
// table.put(null, "null"); // java.lang.NullPointerException
// table.put("null", null); // java.lang.NullPointerException
}
查看Hashtable 的 put() 方法,发现该方法有着一些限制。
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
/** 省略代码 */
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
// value 为 null 直接抛出异常
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
// key 为 null 时,调用hashCode() 函数也会 NullPointerException。
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
/** 省略代码 */
}
synchronized
上面的源码中,Hashtable 的 put() 方法加了synchronized, 可知Hashtable是synchronized , 而 HashMap是非synchronized。因此多个线程可以直接共享一个Hashtable,而不用自己考虑同步问题。很多帖子说Hashtable是遗留类。此外,Java 还提供了ConcurrentHashMap(@since 1.5) 作为 HashTable的替代。 注意是替代 HashTable。ConcurrentHashMap 是不能接受为null的键值(key)或值(value)的,否则抛出java.lang.NullPointerException。
只需要单一线程时,使用HashMap性能要好过Hashtable。如果要使用同步的HashMap,通常做法如下:
Map m = Collections.synchronizeMap(hashMap);
迭代器与fail-fast
这里解释下:
fail-fast,也就是“”快速失败“,它是Java集合的一种错误检测机制。某个线程在对collection进行迭代时,不允许其他线程对该collection进行结构上的修改。比如:线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(添加删除),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast。
说到集合不能不提的一个就是迭代器(Iterator)。先看HashMap的:
final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<K> iterator() { return new KeyIterator(); }
}
HashMap使用 KeyIterator , 对于KeyIterator 代码中有如下检测
if (modCount != expectedModCount)
// 迭代器每次的hasNext()和next()方法都会检查该"mode"是否被改变,当检测到被修改时,抛出Concurrent Modification Exception
throw new ConcurrentModificationException();
至于HashTable,代码如下:
private class KeySet extends AbstractSet<K> {
public Iterator<K> iterator() {
return getIterator(KEYS);
}
}
private <T> Iterator<T> getIterator(int type) {
if (count == 0) {// count: The total number of entries in the hash table.
return Collections.emptyIterator();
} else {
return new Enumerator<>(type, true);// 因此当HashTable 有元素时,使用的是 Enumerator。
}
}
而Enumerator 是快速失败的。下面是我的测试代码:
private void run4FailFast() {
// HashMap<String,String> nameCollect = new HashMap<String,String>();
// Hashtable<String,String> nameCollect = new Hashtable<String,String>();
// WeakHashMap<String,String> nameCollect = new WeakHashMap<String,String>();
ConcurrentHashMap<String,String> nameCollect = new ConcurrentHashMap<String,String>();
nameCollect.put("zhangsan", "zhangsan");
nameCollect.put("lisi", "lisi");
nameCollect.put("wangwu","wangwu");
Iterator iterator = nameCollect.keySet().iterator();
while (iterator.hasNext())
{
System.out.println(nameCollect.get(iterator.next()));
nameCollect.put("zhaoliu", "zhaoliu");
}
}
测试中,只有 ConcurrentHashMap 能正常输出,其余均会抛出 java.util.ConcurrentModificationException。
因此可知:Hashtable 和 HashMap 都是快速失败的。
容量
对于集合来说,都会涉及容量的概念。之前也讲过 ArrayList 和 LinkedList 的容量。
Hashtable 默认容量是 11, 加载因子是 0.75
public Hashtable() {
this(11, 0.75f);
}
而 HashMap 默认是 16, :
/** 省略代码 */
/**
* The default initial capacity - MUST be a power of two.
*/
// 默认容量 2^4 = 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/** 省略代码 */
/**
* The load factor used when none specified in constructor.
*/
// 默认加载因子 0.75f
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** 省略代码 */
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
原理
这里以HashMap 为例:首先是一个数组,即桶。每个桶都会装有 0到多个元素。每个桶中元素都是一个链表。对于HashMap , 我们最常用的就是 put() 和get() 方法。
put
对于 put() 方法,有如下几个步骤:
1. 调用 key 的 hashCode(), (返回的hashCode用于找到bucket位置来储存Entry对象)。
2. 根据返回的hashCode, 计算数组中的位置 i。
3. 根据返回的hashCode, 在 i 出遍历链表找到存储 key 的位置。
4. 如果key 已经存在(equals方法), 旧值覆盖新值。
5. 如果不存在,表头插入。
源码如下(这里我的是 JDK 1.6, 不同版本代码可能有所不同,不过原理一样):
public V put(K key, V value) {
if (key == null)
// 若 key为null,调用putForNullKey方法,保存null 在 table第一个位置中。
return putForNullKey(value);
// 计算key的hash值
int hash = hash(key.hashCode());
// 根据 hash 值,计算key 的hash 值在 table 数组中的位置
int i = indexFor(hash, table.length);
// 在 table[i] 处,对链表进行遍历,以便找到保持 key的位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 如果链表上有 节点的 hash 值与 key.hash 相同 且 key 相等 (equals)
// 新值替换旧值,并返回
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 没有找到位置,将key、value添加至i位置处。
addEntry(hash, key, value, i);
return null;
}
根据 put() 方法可知,该方法大的消耗是对链表的顺序遍历。
根据addEntry() 可知,是在表头添加。
void addEntry(int hash, K key, V value, int bucketIndex) {
// 获取 bucketIndex 处链表
Entry<K,V> e = table[bucketIndex];
// 将新的 entry 放在表头, 同时让原来的 entry 链接在新的 entry后。
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
// 元素的个数 大于threshold,进行扩容
if (size++ >= threshold)
// 这里 threshold 等于 newCapacity * loadFactor,容量扩大两倍
resize(2 * table.length);
}
显而易见,这里的 put 最后的结果很大程度上与 key 的 hashCode() 和 equals() 方法有关。
get
get方法的逻辑很相似:
1. 根据hashCode 计算table中的位置。
2. 对算出的位置中的entry进行遍历。
源码如下:
public V get(Object key) {
if (key == null)
return getForNullKey();
// 利用 hashCode 计算 hash 码
int hash = hash(key.hashCode());
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
// 如果 hash 值相等, 且 key 相同 (equals方法), 返回相应的value
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
根据get() 方法源码可知,该方法最大的消耗是对链表的遍历。
因此理想情况下每个桶里面只有一个元素时,读取速度最快,且不浪费空间。不过这显然是极端情况。
rehashing
我们知道,当一个map填满了75%的bucket时候, 系统对HashMap进行大小的两倍扩容,同时把内容copy到新map中。随着HashMap中元素的数量越来越多,就会发生越来越多的碰撞,所产生的链表长度就会越来越长,根据上面的分析,可知这样必会影响HashMap的速度,因此,为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理。
具体做法是:
1. 当元素个数 > 容量 * 加载因子, 扩容两倍。
2. 重新计算位置,并复制。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
可见这是一个非常耗时的过程。因此好的做法,是初始化时,选择合适的大小。
由此可知,Hashtable有两个影响性能的参数: 初始容量 和 加载因子。
另外这里补充一下:
static int indexFor(int h, int length) {
return h & (length-1);
}
上面put() 和 get() 过程都使用了这个函数。其实这个函数就是计算索引的。为了使table中元素均匀分布,最易想到的做法就是取模,可以取模消耗很大。不过这里利用按位与的方式,因为length 每次都是2 的N次方,所以结果相当于取模。有兴趣的同学可以自己研究。
ConcurrentHashMap
Java 1.5的时候新加了 ConcurrentHashMap 作为 HashTable的替换。由于使用了分段锁技术,并发性比较高。
遍历
SonarLint在扫描时,对Map的遍历有一条Code Smell:
使用entrySet遍历Map类集合KV,而不是keySet方式进行遍历。
说明:keySet其实遍历了2次,一次是转为Interator对象,另一次是从hashMap中取出key对应的value。而entrySet只是遍历一次就把key和value都放到了entry中,效率更好。
public void traverseMap() {
Map<String, String> items = new HashMap<>();
items.put("a", "A");
items.put("b", "B");
items.put("c", "C");
for (Map.Entry<String, String> entry : items.entrySet()) {
System.out.println("key:" + entry.getKey() + ";value:" + entry.getValue());
}
}
到了Java8,也可是使用Map.foreach方法。此时代码更简洁。
public void traverseMap() {
Map<String, String> items = new HashMap<>();
items.put("a", "A");
items.put("b", "B");
items.put("c", "C");
items.forEach((k, v) -> System.out.println("key : " + k + "; value : " + v));
}
总结
在使用Map类集合K/V是一定要注意能不能存储null的情况。如下:
集合类 | Key | Value | Super | 说明 |
Hashtable | 不可null | 不可null | Dictionary | 线程安全 |
ConcurrentHashMap | 不可null | 不可null | AbstractMap | 分段锁 |
TreeMap | 不可null | 可null | AbstractMap | 线程不安全 |
HashMap | 可null | 可null | AbstractMap | 线程不安全 |