目录
0.背景
经常会看到说HashMap是线程不安全的,ConcurrentHashMap是线程安全的等等说法,不禁有个疑问,什么是线程安全?什么样的类是线程安全的?
1.什么是线程安全性(what)
线程安全定义,最核心是正确性,
正确性:多个线程访问某个类,不管怎么调度这些线程,其代码中不需要额外的同步或协同(synchronized),这个类依然有正确的行为。
线程安全类封装了必要的同步机制,在任何调用对象的public方法或读写pulic字段field时,都不会破坏对象的一致性状态(符合不变性条件或后验条件)
为什么一段看似正确的代码,多线程并发执行就会不正确了呢??大概有以下3种问题
问题 | 引发原因 | 后果(以单例模式举例) | fix方案 |
---|---|---|---|
可见性 | CPU高速缓存 由于各个线程会在执行是从 主存 加载到CPU高速缓存中执行,节省读取内存时间(CPU速度>> 主存速度) 线程A对共享变量V做了修改,其它线程B看到V的值还是之前的old失效数据 | 失效数据 线程A已经创建了instance,但是线程B读取的instance还是null,会导致创建了两个instance,A和B拿到的实例不一样!! 失效值可能导致错误的结果或者导致活跃性问题。 | volatile 加锁(syn、lock) |
有序性 | 编译优化之指令重排序 CPU执行指令时会有一些指令重排序以期最大效率 | 单例模式赋值和实例化的重排序导致的异常 | volatile 加锁 通过Happens-before规则实现 内置锁的释放锁操作发生在该锁随后的加锁操作之前 一个volatile变量的写操作发生在这个volatile变量随后的读操作之前 |
原子性 | 线程切换 CPU调度是时间片轮转如下图 public方法如有对共享变量读取-修改-写入等类型有依赖的操作序列时,需要是原子性完成 自增、先检查后执行 | 竞态条件(单例模式懒加载 先检查后实例化,行为不正确不能保证单例) 对象状态不一致,如一个对象一致性状态变量A+B=C,如果对A、B、C的修改不能原子性地完成,出现不一致 丢失更新,共享变量自增count++ ,如100个线程跑完却没有增加100 | 加锁 |
public class DoubleCheckSingleton {
/**
* 使用volatile,在多线程场景下,确保在判断null时,对所有线程可见
*/
private static volatile DoubleCheckSingleton uniqInstance;
/**
* 构造器私有,防止外部实例化该类
*/
private DoubleCheckSingleton() {}
/**
* 静态方法实例化,由于在类内部,可以调用构造器
*/
public static DoubleCheckSingleton getInstance(){
if (null == uniqInstance) { // 此处判断需要可见性volatile
synchronized (DoubleCheckSingleton.class) {
if (null == uniqInstance) {
//延迟初始化
uniqInstance = new DoubleCheckSingleton();
}
}
}
return uniqInstance;
}
}
2.如何分辨一个类是否线程安全?(HOW)
安全方式 | 样例 | 代码|解释 |
---|---|---|
只读共享 | 不可变对象如String
| |
事实不可变对象
| ||
线程封闭 | 栈封闭 无状态没有实例成员变量 栈(局部变量)在运行时是线程私有的 | 没有共享变量,不存在以上问题,无需可见、和有序, 局部变量是私有,任何操作不影响其它线程,完全隔离 |
ThreadLocal 对象仅由一个线程修改 | ||
线程安全共享 | 在内部实现同步 多个线程通过共有public接口访问无需同步 | hashtable所有的public访问方法都用synchronized修饰 |
保护对象 | 有锁才能访问保护对象 | hashtable发布内部对象时,用本身的对象锁保护 keySet = Collections.synchronizedSet(new KeySet(), this); |
3.为什么hashmap不安全 why
最简单的线程安全集合实现,可以发现是通过以上线程安全共享的方式实现安全。
public class SafeSet {
// final
private final Set<Integer> myset = new HashSet<>(); //饿汉 一旦创建不会发布出去
// synchronized 同步访问
public synchronized void addInteger(Integer p){
myset.add(p);
}
public synchronized boolean containsInteger(Integer p) {
return myset.contains(p);
}
}
集合 | 安全? | 原因|实现 |
---|---|---|
HashMap | N | 1.public方法没有做任何同步操作,会引发以上3种问题(原子、可见、有序) 2.不安全地发布共享实例变量 keySet等方法直接返回对象的内部变量,破坏了封装,有可能在外部不通过其接口方法修改了其状态
|
HashTable | Y | 1.所有的public访问方法都用synchronized修饰,互斥访问,线程安全共享,与上文中SafeSet实现思路相同 2.安全发布共享实例变量 values、keySet等方法返回的keySet = Collections.synchronizedSet(new KeySet(), this);还是受锁保护 保护对象 |
3.1 插入HashMap.put
如下,put方法有很多先检查后操作(需要原子性执行)的代码,由于put()和putVal()代码没有同步,所有的线程可以执行到此处代码,
(1)table==null? 初始化线程A执行check操作后,发生线程切换,B也check table==null操作,A、B都会resize()更新table,产生更新丢失!
if ((tab = table) == null || (n = tab.length) == 0)//(1)线程切换
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)//(2)线程切换
tab[i] = newNode(hash, key, value, null);
(2)tab[i]==null? A 线程和 B 线程计算出相同的哈希值对应了相同的数组位置,此时该位置还没数据,然后对同一个数组位置,两个线程会同时 写入新的头结点,那B的写入操作就会覆盖 A 的写入,造成 A 的写入操作丢失。
3.1.1 HashMap 在扩容的时候
HashMap 插入后超过阈值会触发扩容resize操作,new一个新容量cap的数组,对原数组的键值对重新进行计算hash并写入新数组,然后指向新数组。
if (++size > threshold)// 线程切换
resize();
当A、B线程同时进来,检测到总数量超过阈值的时候就会同时触发 resize 操作,各自生成新的数组并 rehash 后赋给该 map 底层的数组,结果最终只有最后一个线程生成的新数组被赋给该 map 底层,其他线程的均会丢失。
JDK1.7甚至在扩容时并发有可能形成链表回路,导致后续操作遍历链表时有死循环!具体请戳
3.2 HashMap 在删除数据的时候
删除这一块可能会出现两种线程安全问题,
if ((tab = table) != null && (n = tab.length) > 0 &&(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 找到对应节点
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))//切换线程(2)
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key); // 红黑树查找
else { // 链表查找
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); //删除树节点
else if (node == p)
tab[index] = node.next; // 删除链表头
else
p.next = node.next; // 删除链表中间
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
- 线程A判断得到了指定的数组位置i并进入了循环,此时,线程B已经删掉位置i数据了,然后线程A那边就没了。但是删除的话,没了倒问题不大,只是A返回的就是null
- 当A、B线程同时操作同一个数组位置的时候,也都会先取得现在状态下该位置存储的头结点,然后各自去进行计算操作,之后再把结果写会到该数组位置去,其实写回的时候可能其他的线程已经就把这个位置给修改过了,就会覆盖其他线程的修改。
还有很多检查后操作的情况不能保证原子性操作,且有检查的时候不能保证可见性,检查到的值有可能是失效数,执行过程中不能保证其一致性状态,也不能保证符合程序设计者的预期!
且不安全地发布了内部字段,如keyset等,容易被其它现在在类外不可预期地修改!!