本文作者:王一飞,叩丁狼高级讲师。原创文章,转载请注明出处。
上几篇讨论了并发环境下list容器的操作, 本篇我们来聊下另外一个集合容器:Map
家族体系
Map:以key-value对的形式存在,一种数据结构,一个key, 映射一个value值, map中不能包含重复的key值, 一个key最多只能映射到一个值。
常用方法有:
添加: V put(K key, V value);
删除: V remove(Object key);
修改: V put(K key, V value);
查询: V get(Object key);
常见的实现类:
HashMap
LinkedHashMap
TreeMap
Hashtable
ConcurrentHashMap
HashMap
要研究HashMap在并发环境下使用, 先得了解hashmap实现原理.HashMap是基于哈希表的 Map 接口的实现。(传送门:哈希表百度百科),其底层维护一个数组与链表.源码说话:
注意:
jdk8以前hashMap结构: 数组 + 链表
jdk8以后hashMap结构: 数组 + 链表 + 红黑树
此处源码使用的jdk8
源码:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
//初始容量, 默认16,俗称桶, 可以认为数组长度
//一个桶对应一条链表
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//真实的负载因子, 不特意指定是等于0.75f;
final float loadFactor;
//阈值:所能容纳kv对最大数量,超过这个值,则需扩容
//规则: threshold = capacity * loadFactor
int threshold;
//哈希表, 如果必要, 长度以2的n次方方式拓展
transient Node<K,V>[] table;
}
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
HashMap运作规则:
JDK8 HashMap结构图
1>HashMap 初始化时(不特意指定), 默认创建长度为16的一维数组, 用于保存Node(即kv对)/挂载链表投节点
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
2>map添加元素时map.put(key, value), 先通过哈希算法h = hash(key)计算出当前Node(key, value)在table数组中位置, 如果table[h]位置为null.table[h] = Node(key, value), 如果有值, 称之为碰撞, 则创建链表, 将当前Node(key, value) 拼接在链表后面.
3>JDK8的特性, 添加后,table某个位置的挂载链表个数大于等于TREEIFY_THRESHOLD=8时, 为提高查询效率,则将该位置下的链表转换成红黑树.
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;
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) {
//创建新node
p.next = newNode(hash, key, value, null);
//如果node链表超过8
if (binCount >= TREEIFY_THRESHOLD - 1)
//转换成红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果map的容量大于threshold, map扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4> 添加后, 如果Map 的的size > threshold 阈值, 则需要对map扩容, 算法:
情况一:使用空参HashMap()构造器构建map/首次初始化
newSize = DEFAULT_INITIAL_CAPACITY = 16
newThreshold = newSize = oldSize* loadFactor
情况二:非空参构造器创建Map对象/后续初始化
newSize = oldSize *2 //扩容2倍
newThreshold = oldThreshold * 2
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; // 2倍
}
else if (oldThr > 0)
newCap = oldThr;
else {
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;
//....省略数据拷贝
}
到这大体了解HashMap底层实现, 细心的朋友应该可以看出来, hashMap所有的操作并没有使用synchronized修饰, 也就说, hashMap在高并发的环境下存在明显的线程安全问题.
场景1:多线程复合操作时
线程t1给map添加数据(key),而线程t2操作相同的key值, 先添加后获取. 某一时刻, t2先添加,如果此时切换到t1执行, t1会覆盖t2添加的数据, t2再次读取时, 数据被修改了, 出现脏读问题.
public class App {
public static void main(String[] args) throws InterruptedException {
final HashMap<String, String> map = new HashMap<>();
Thread t1 = new Thread(new Runnable() {
public void run() {
map.put("key", "t1");
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
public void run() {
map.put("key", "t2");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(map.get("key")); //t1
}
}, "t2");
t2.start();
Thread.sleep(2000);
t1.start();
}
}
场景2:多线程同时添加相同hash 码值时
多个线程同时执行put操作时, 如果key的hash码一样时, 根据HashMap的实现,会有多个key添加到数组的同一个位置,如果此位置已经被占用,挂载新节点时,容易发生线程put的数据被覆盖。
public class User {
private String name;
public User(String name) {
this.name = name;
}
//保证hashMap调用hash算出的hashcode一致
public int hashCode() {
return 1;
}
}
public class App {
public static void main(String[] args) throws InterruptedException {
//final Hashtable<User, String> map = new Hashtable<>();
final HashMap<User, String> map = new HashMap<>();
//3个相同的线程, 同时往map中添加
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(new User(1 + ""), i + "t1");
}
System.out.println(Thread.currentThread().getName() + "..end...");
}
}, "t1").start();
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(new User(1 + ""), i + "t1");
}
System.out.println(Thread.currentThread().getName() + "..end...");
}
}, "t2").start();
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(new User(1 + ""), i + "t1");
}
System.out.println(Thread.currentThread().getName() + "..end...");
}
}, "t3").start();
Thread.sleep(1000);
System.out.println(map.size());
}
}
JDK1.8计算的hash 码算法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
上面程序运行存在几种结果:
1:结果为3000, 这是正确的
2:结果少于3000, 这就出现key值被覆盖了.原因:链表node移位时,出现并发问题.
3:报异常, 类转换异常.原因:前面说过,jdk8中hashmap同一个位置, 挂载大于等于8个节点会转换成红黑树, 多个线程争夺时,这个临界点会出问题.
java.lang.ClassCastException: HashMap$Node cannot be cast to HashMap$TreeNode
4:报异常, 内存溢出异常, 这是场景3问题, map扩容造成的.
Exception in thread "t3" Exception in thread "t2" Exception in thread "t1" java.lang.StackOverflowError
场景3:多线程同时扩容时
多线程刚好同时对hashMap进行扩容,最终只有一个线程扩容的table替换旧table数组, 那么其他线程put的数据会丢失.另外更有甚者, 可能会造成put操作的死循环(详细参考一个大牛写的:HashMap死循环)
HashMap 在设计之初就没考虑过线程安全的问题, 所以在并发环境下HashMap并不是首选, 更多偏向下面几个Map.
HashTable&Collections.synchronizedMap
HashTable 跟hashMap底层实现类似, 但在设计上考虑到线程安全操作, hashTable中所有的核心操作都加上synchronized修饰,确保操作的安全性. 所以并发环境下, hashTable不会出现场景2, 场景3 情况.而场景1中的复合操作还需要额外加锁, 确保操作安全. 具体操作, 参考上上遍并发中的list的案例.
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
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;
}
Collections.synchronizedMap 的操作跟HashTable大同小异,都是通过synchronized给操作方加锁, 这里不累赘了.
ConcurrentHashMap
ConcurrentHashMap 是jdk1.5引入的并发map实现类, 该类的设计者推荐并发环境首选Map实现了, 那么它能解决上面场景1,场景2,场景3的并发为问么?篇幅所限, 我们下回分解.
想获取更多技术视频,请前往叩丁狼官网:http://www.wolfcode.cn/openClassWeb_listDetail.html
网友评论