在平时开发中,HashMap,HashTable,HashSet 都是经常用到的键值映射数据结构,在这里我主要写一些平时我们使用这些数据结构中容易忽视的问题。
HashMap
HashMap的结构
HashMap 底层是一个Entry数组来支撑的,我觉得叫Entry链表数组支撑更为合适。
结构图:
每个entry数组里面的元素要么为null要么就是一个entry链表;而每个entry对象就是一个entry链表的节点也是一个键值对的抽象表示;
HashMap的性能因素
HashMap主要影响其性能的有两个因素,一个是初始容量,一个是载入因子;HashMap(int initialCapacity初始容量, float loadFactor载入因子),我们在遍历HashMap的时候,会对整个数组都进行遍历,也就是说性能跟entry数组的长度有关(容量)。如果将初始容量设置的过大,实际上我们没装几个东西在里面,那么遍历的时候,会遍历所有数组组元素。这里已经指出了,我们不希望容量设置的过大,那么当put数据的时候检测到容量超过我们的阀值threshold,就会重新构造一个两倍的数组出来,从而达到扩容的母的。 if (size++ >= threshold) resize(2 * table.length); threshold = 当前容量*loadFactor载入因子。我们始终要抓住一点,HashMap要经常遍历,我们应该让他在合适的时间选择扩容,避免过早的遍历更大的容量数组。所以我们应当尽量避免将loadFactor设置的过小。
哈希冲突
当我们put两个元素的时候,如果他们的哈希值都一样,或者说哈希值不一样,但是数组下标一样的时候,那么到底谁该放在同个槽里呢?这就是通俗的哈希冲突。为了解决哈希冲突,jdk采用链表的方式来解决哈希值的冲突。下面我们看看源码来分析。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());//计算键的哈希值
int i = indexFor(hash, table.length);//找到该哈希值对应的entry数组下标
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//如果entry数组下标对应的entry链表里面,put之前就存在与这个指定的key关联的entry对象,那么直接替换旧的value,并返回这个旧的value给调用者。
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//当哈希算法计算出来的哈希值相同,并且(key是同一个对象||两个key equals判断相同)即表示存在旧的key关联的entry
V oldValue = e.value;
e.value = value;
e.recordAccess(this);//这个 hashMap 无需关心。
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);//当entry数组下标对应的entry链表没有与指定的key关联的entry对象时,增加一个新的entry对象,哈希冲突也是在这个函数里解决的。
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];//把旧的链表地址暂时保存在一个变量中
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);//采用头插法,直接插到链表的头部
if (size++ >= threshold)
resize(2 * table.length);
}
假设我们要put两组数据,分别是put(0,0),put(10,10) ,假设计算哈希值的算法 int hash = key % 10; 那么0 和 10 的哈希值都为0,然后int i = indexFor(hash, table.length);
两组数据key对应的数组下标都是0;
那么是怎么插入的呢?先put(0,0)
在put(10,10) 哈希冲突,在链表头部插入解决。
并发情况下HashMap的死循环问题
其实HashMap本不该在并发环境下使用,应该考虑选择HashTable,ConcurrentHashMap。我们就来分析下HashMap的死循环问题。
当多个线程同时put数据的时候就有可能出现死循环的问题。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];//把旧的链表地址暂时保存在一个变量中
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);//采用头插法,直接插到链表的头部
if (size++ >= threshold)
resize(2 * table.length);//多线程下,多个线程可能会同时执行这个函数
}
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);//当2个线程同时执行这个转移数据到新的数组时就有可能出现问题。
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {//这个do while 循环要做的操作就是翻转旧的链表插入到新数组里面。
Entry<K,V> next = e.next;//标记1,假设线程1执行完这步
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
我们来个正常版的单线程环境下的resize操作,看图:
trasnfer(newTable)之前
trasnfer(newTable)完成后
我们可以看出来,实际就是翻转链表插入到新容量的entry数组里面。
再来看看死循环版本,有两个线程put数据都进行transfer(newTable) 操作,那么就会可能出现死循环。
当线程1 传输数据时,执行完了标记1的时候切换到了线程2,线程2执行了一次完整的翻转链表到新的entry数组时,线程1继续跑就会出现。
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {//这个do while 循环要做的操作就是翻转旧的链表插入到数组里面。
Entry<K,V> next = e.next;//标记1,假设线程1执行完这步
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
用图分析吧:
原数组:
此时线程1执行到了标记1,然后切换到了线程2执行一个链表的翻转插入。
然后此时链表情况是这样的。
线程1在do while 里面就不断的循环 do (.....)while (e.next != null),这样线程1就根本停不下来了。
HashSet
HashSet底层的存储结构是一个HashMap,HashSet 添加的每一个值实际上就是在底层的HashMap里添加一个实体Entry<Key,Object> e,而这里的Object 其实就是一个摆设。HashSet利用了HashMap的Key的唯一性,确保了在该数据结构中不会添加重复的值(保证了值的唯一性)。另外HashSet是允许插入null值的。根据HashSet的值的唯一性和快速添加的特性,我们可以想到,如果我们要快速添加大量的不能重复的元素到一个数据结构中,那么HashSet 是一个非常好的选择。
HashTable
HashTable 跟HashMap 一样是一个存储键值对映射的数据结构,跟HashMap 不一样的是,HashTable 是线程安全的,HashTable 要插入的key 和 value 都不能为null。为啥不能为null呢?jdk文档里面说了,HashTable 是继承了字典的一种数据结构,我们可以在这种字典里提高一个键值对以供查找,但是key 或者 value 任何一个都不能为null。
我是怎么认为的呢?就像我们查字典一样,你总不能造个没有含义,没有表现的文字在字典里面吧,如果有我查到了,这是个null,没有含义,这完全违背了我们想通过查字典获取真相的初衷啊。
HashTable 的线程安全型是靠对每个操作加锁的方式完成的。也就是锁住当前的HashTable实例对象。如果在并发大量的情况下,那么锁竞争会很严重。我以为如果在并发情况不大的情况下当我们又想保证数据的并发安全性,我觉得HashTable也是一种非常好的选择。当然在并发量大的情况下,就优先选择ConcurrentHashMap 。
我自己写了个测试程序,在计数为2亿次的并发put测试中,不同线程数量,对HashTable 和 ConcurrentHashMap 的表现分析。
代码:
package hash_set_map_table;
import java.util.Hashtable;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class HashThreadTask implements Runnable {
static int TIME = 200000000;
//2个线程,并发put 200000000次,ConcurrentHashMap 31718 ms,Hashtable 35535 ms;
//20个线程,并发put 200000000次,ConcurrentHashMap 38732 ms,Hashtable 48357 ms;
//100个线程 并发put 200000000次,ConcurrentHashMap 36380 ms,Hashtable 46299 ms;
//200个线程 并发put 200000000次,ConcurrentHashMap 35801 ms, Hashtable 50579 ms;
private int threadId;
public HashThreadTask(int threadId) {
this.threadId = threadId;
}
public int getThreadId() {
return threadId;
}
public void setThreadId(int threadId) {
this.threadId = threadId;
}
static AtomicInteger count = new AtomicInteger();
public static Hashtable<Integer, Integer> getHashTableInstance() {
return TableHolder.table;
}
public static ConcurrentHashMap<Integer, Integer> getConcurrentHashMap() {
return ConcurrentHashMapHolder.map;
}
public static class TableHolder {
public static Hashtable<Integer, Integer> table = new Hashtable<Integer, Integer>();
}
public static class ConcurrentHashMapHolder {
public static ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<Integer, Integer>();
}
public static void main(String[] args) {
for (int i = 0; i < 200; i++) {//这里控制线程数量 测试数据依次为 2个线程,20个线程,100个线程,200个线程
HashThreadTask task = new HashThreadTask(i);
Thread thread = new Thread(task);
thread.start();
}
long s = System.currentTimeMillis();
while (count.get() != TIME) {
}
System.out.println("cost time : "+ (System.currentTimeMillis() - s) + " count:" + count.get());
}
public void run() {
// ConcurrentHashMap<Integer, Integer> container = getConcurrentHashMap();
Random random = new Random(System.currentTimeMillis());
Hashtable<Integer, Integer> container = getHashTableInstance();
do {
int old = count.get();
if (old < TIME) {
int i = random.nextInt(10000);
container.put(i, i);
count.compareAndSet(old, old+1);
}
}
while (count.get() < TIME);
}
}
结果:
//2个线程,并发put 200000000次,ConcurrentHashMap 31718 ms,Hashtable 35535 ms;
//20个线程,并发put 200000000次,ConcurrentHashMap 38732 ms,Hashtable 48357 ms;
//100个线程 并发put 200000000次,ConcurrentHashMap 36380 ms,Hashtable 46299 ms;
//200个线程 并发put 200000000次,ConcurrentHashMap 35801 ms, Hashtable 50579 ms;
在并发量不大的时候,当我们又想保证数据的并发安全性的话,我觉得HashTable 优于 ConcurrentHashMap,因为Hashtable 没那么吃内存。
当在并发量大的时候,Hashtable 就输的一塌糊涂了,所以在这种大并发环境下,我们应当毫不犹豫的选择ConcurrentHashMap。