15 Java线程安全的类以及hashmap与并发的hashmap的介绍
一 Java线程安全类的概述
类的名称 | 特点 | 说明 |
---|---|---|
Stack,Vector,Hashtable | 采用synchronized保证线程安全性 | 不推荐使用,属于历史遗留类,采用synchronized保证安全性 |
Collections synchronized系列 | Collections的装饰器实现,采用synchronized保证线程安全性 | |
JUC | 利用CAS,多把锁提升效率 | 推荐使用 |
1-1 遗留的安全集合
比如vector(Stack)以及HashTable
- 方法通过synchronized关键字进行修饰
hashtable的put方法源码
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
1-2 使用 Collections 装饰的线程安全集合
使用Collections修饰的线程安全类:
- 这些类都是装饰器类,将原始对应的容器对象传入。
- 类的内部属性包含 原始的对象 + synchronized加锁的对象,对原始对象的每一个方法调用本质上还是通过synchronized加锁保护。
Collections.synchronizedCollection
Collections.synchronizedList
Collections.synchronizedMap
Collections.synchronizedSet
Collections.synchronizedNavigableMap
Collections.synchronizedNavigableSet
Collections.synchronizedSortedMap
Collections.synchronizedSortedSet
synchronizedMap的部分源码实现
从中可以发现:
- 将线程不安全的map存储在自己的成员变量中。
- 将原始的map方法的使用同名函数进行了修饰并通过synchronized(mutex)进行了原子性保护。
/*可以看到synchronizedMap接受的参数是Map<K,V>,返回的是修饰后的Map*/
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
/*关键1:将线程不安全的map存储在自己的成员变量中*/
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
/*
关键2:将原始的map方法的方法使用同名函数进行了修饰,通过synchronized(mutex)
进行了原子性保护。直接调用原有的map的方法。
*/
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
public void putAll(Map<? extends K, ? extends V> map) {
synchronized (mutex) {m.putAll(map);}
}
public void clear() {
synchronized (mutex) {m.clear();}
}
private transient Set<K> keySet;
private transient Set<Map.Entry<K,V>> entrySet;
private transient Collection<V> values;
public Set<K> keySet() {
synchronized (mutex) {
if (keySet==null)
keySet = new SynchronizedSet<>(m.keySet(), mutex);
return keySet;
}
}
public Set<Map.Entry<K,V>> entrySet() {
synchronized (mutex) {
if (entrySet==null)
entrySet = new SynchronizedSet<>(m.entrySet(), mutex);
return entrySet;
}
}
public Collection<V> values() {
synchronized (mutex) {
if (values==null)
values = new SynchronizedCollection<>(m.values(), mutex);
return values;
}
}
......
}
1-3 JUC工具提供的线程安全类
JUC提供的线程安全类,按照名字划分,可以分为以下三类:
Blocking类:大部分实现基于锁(通常是ReentrantLock锁),并提供用来阻塞的方法
- 这种类会让很多线程在不满足条件的时候阻塞。
CopyOnWrite类:基本思想是多线程修改的时候进行数据拷贝的方式来避免多线程读写的时候的并发安全性。
- 适用于读多写少的场景,对数据的修改开销比较重
Concurrent类(推荐使用):能够支持并发的容器,这种类型的容器性能比较高。
- 内部很多操作使用 cas 优化,对数据的保护采用多把锁,从而可以提供较高吞吐量
- 缺点:弱一致性问题
- 遍历时弱一致性(fail safe机制),例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,但迭代器得到的内容可能是旧的内容(遍历的线程与修改的线程内容不一致)
- 求大小弱一致性,size 操作未必是 100% 准确
- 读取弱一致性
注意点:非安全容器遍历时如果发生了修改,使用 fail-fast 机制也就是让遍历立刻失败,抛出
ConcurrentModificationException,不再继续遍历 ,而对于Concurrent类则可以一个线程修改数据,另外一个线程遍历数据。
谈谈fail-fast与fail-safe是什么以及工作机制
二 ConcurrentHashMap的使用
[kənˈkɜːrənt]
2-1 ConcurrentHashMap的简单的正确应用:多线程计数
场景:现在有26个文件,每个文件中由多行字符串组成,每个字符串是26个字母中任意一个,现要求采用多线程的方式统计每个字母出现次数。
实现
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
class Solution{
// 用于统计字母的个数
/*
supplier:Map类型的容器,用于存储字母的统计数据,key是字母,value是字母的统计次数
consumer:2个参数的消费者,参数1:Map<String, V>, 参数2:List<String>>
*/
static <V> void demo(Supplier<Map<String, V>> supplier, BiConsumer<Map<String, V>, List<String>> consumer) {
Map<String, V> counterMap = supplier.get();
List<Thread> ts = new ArrayList<>();
/*一共有26个文件,每个文件都使用一个线程进行读取,
* 每个文件读取的结果存放在列表中。
* */
for (int i = 1; i <= 26; i++) {
int idx = i;
Thread thread = new Thread(() -> {
List<String> words = readFromFile(idx);
consumer.accept(counterMap, words);
});
ts.add(thread);
}
ts.forEach(t -> t.start());
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counterMap);
}
public static List<String> readFromFile(int i) {
/*读取一个文件中的所有字符,注意这个文件格式是txt 并且每行是1个英文字母
* 读取的结果存放在ArrayList中,里面每个元素是一个String,每个String只有一个字母
* */
ArrayList<String> words = new ArrayList<>();
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("./tmp/" + i + ".txt")))) {
while (true) {
String word = in.readLine();
if (word == null) {
break;
}
words.add(word);
}
return words;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
public class test1{
public static void main(String[] args) {
Solution.demo(
()->new HashMap<String, Integer>(),
(map, words) ->{
for(String word: words){
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter+1;
map.put(word,newValue);
}
}
);
}
}
执行结果
- 可以看到原本正确结果应该是每个字母出现200次,但是由于HashMap线程不安全,所以其统计的结果有偏差。
{a=199, b=200, c=197, d=196, e=198, f=199, g=198, h=197, i=199, j=198, k=198, l=198, m=199, n=197, o=195, p=199, q=197, r=199, s=200, t=198, u=200, v=200, w=199, x=200, y=196, z=199}
错误的改进(直接将HashMap换成ConcurrentHashMap)
Solution.demo(
()->new ConcurrentHashMap<String,Integer>(),
(map, words) ->{
for(String word: words){
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter+1;
map.put(word,newValue);
}
}
);
执行结果:统计的错误是因为concurrentHashMap只能够保证单个方法的线程安全,无法保证get与put多个方法组合使用的线程安全性。
{a=198, b=200, c=200, d=200, e=198, f=199, g=199, h=198, i=200, j=200, k=198, l=200, m=200, n=200, o=199, p=198, q=199, r=194, s=198, t=199, u=199, v=200, w=200, x=199, y=200, z=199}
正确的改进
public class test1{
public static void main(String[] args) {
Solution.demo(
() -> new ConcurrentHashMap<String, LongAdder>(8,0.75f,8),
(map, words) -> {
for (String word : words) {
// computeIfAbsent:将查询与放入的方法变为原子操作
// 返回值:the current (existing or computed) value associated with the specified key,
LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder());
// 使用基于CAS机制的累加器确保累加原子性
value.increment(); // 2
}
}
);
}
}
三 hashmap
3-1 hashmap的基础知识
常识:
JDK7: hashmap:数组+链表
JDK8: hashmap:数组+链表+红黑树
当发生冲突时?
7是将节点插入链表头部(头插法),8是将节点插入链表尾部
/**简介:hashmap实现了Map interface,并且支持null的key和value,hashmap大致上可以看作hashtable,相比较
hashtable支持为null的key和value
影响hash表的性能的关键因素:
1)初始容量(initial capacity)
2)装载因子(load factor)
问题1:扩容的好处:
当数组的元素超过阈值(装载因子)3/4时,数组会进行扩容操作,元素的hash值会被重新计算,扩容之后会使得原本数目中的冲突产生的链表缩短,数组元素分布的更加均匀。(扩容的好处)。
--------------------------------------------------------------------------
问题2:装载因子为什么是0.75?
原因:0.75的装载因子是时间与空间代价的tradeoff,较高的的装填因子会降低空间的开销但是会提升查找代价(反应在get/put方法上)。
问题3:该如何设置初始容量?
在设置初始容量的时候应该考虑map中元素的个数以及装载因子,目的是减少rehash操作,因此初始容量应该大于元素个数/装载因子。
问题4:并发修改hashmap与访问会发生什么(fail fast机制)?
if the map is structurally modified at any time after the iterator is created, in any way except through the iterator's own remove method, the iterator will throw a {@link ConcurrentModificationException}. Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.
问题5:hashtable中桶会如何变化?
当桶特别大(overpopulated)的情况下,会转化为TreeNode的桶,Tree bins (i.e., bins whose elements are all TreeNodes) are ordered primarily by hashCode. because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use. And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution.
问题5:什么时候会转换为treeNode bin?
The bin count threshold for using a tree rather than list for a bin. Bins are converted to trees when adding an element to bin with at least this many nodes. The value must be greater than 2 and should be at least 8 to mesh with assumptions in tree removal about conversion back to plain bins upon shrinkage.
hasnmap中的属性和部分源码
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
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;
}
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
transient Node<K,V>[] table;
/**
* Holds cached entrySet(). Note that AbstractMap fields are used
* for keySet() and values().
*/
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
3-2 JDK7 hashmap并发死链问题
死链:发生在多线程环境下,2个线程插入key的时候造成链表形成环形的问题。
本质上:循环链表因为扩容的时候、链表倒置了(头插法)
JDK8:通过 head 和 tail 两个变量、将扩容时链表倒置的问题解决了、循环链表的问题就解决了.但是无论如何、在并发的情况下、都会发生丢失数据的问题.
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 这里table是链表
for (Entry<K,V> e : table) {
// 1处
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
// 2 处
// 将新元素加入 newTable[i], 原 newTable[i] 作为新元素的 next
newTable[i] = e;
e = next;
}
}
}
模拟死链的发生:
原始链表,格式:[下标] (key,next) (容量为16的原始链表,还没扩容,因此有3个hash值为1的key在一个桶内)
[1] (1,35)->(35,16)->(16,null)
线程 a 执行到 1 处 ,此时局部变量 e 为 (1,35),而局部变量 next 为 (35,16) 线程 a 挂起
线程 b 开始执行(进行扩容后,key为16的元素被rehash到扩容后数组的位置17,需要调整链表)
第一次循环
[1] (1,null)
第二次循环
[1] (35,1)->(1,null)
第三次循环
[1] (35,1)->(1,null)
[17] (16,null) // key为16的元素被rehash到扩容后数组的位置17
// key为35和1的的元素依旧发生冲突,都在数组位置1,但是由于头差法的原因,在链表中顺序与扩容前对比是颠倒的
切换回线程 a,此时局部变量 e 和 next 被恢复,引用没变但内容变了:e 的内容被改为 (1,null),而 next 的内
容被改为 (35,1) 并链向 (1,null)
第一次循环
[1] (1,null)
第二次循环,注意这时 e 是 (35,1) 并链向 (1,null) 所以 next 又是 (1,null)
[1] (35,1)->(1,null)
第三次循环,e 是 (1,null),而 next 是 null,但 e 被放入链表头,这样 e.next 变成了 35 (2 处)
[1] (1,35)->(35,1)->(1,35)
已经是死链了
// 第二个线程又重复了扩容后的操作,依次插入key=1的节点,和key = 35的节点,当他插入key=1的节点时,由于头插法变成(35,1)->(1,35)->(35,1)->(1,35)
总结:HashMap在多线程环境下容易出现数据丢失,数据重复,死循环问题(扩容的时候),如果保证使用hashmap在多线程环境下使用的时候,不会扩容,那么可以避免死循环的发生。
- 在JDK1.7中链表的死循环的主要原因在于扩容时候的头插法
- 在JDK1.8中采用链表与红黑树组合的方式,死循环会发生在链表与红黑树转换的时候。
关于JDK1.8中死循环问题