多线程并发出现异常的情况
单例模式
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; }}
可见性
- 引发原因
CPU高速缓存 由于各个线程会在执行是从 主存 加载到CPU高速缓存中执行,节省读取内存时间(CPU速度>> 主存速度)
线程A对共享变量V做了修改,其它线程B看到V的值还是之前的old失效数据
- 后果(以单例模式举例)
失效数据
线程A已经创建了instance,但是线程B读取的instance还是null,会导致创建了两个instance,A和B拿到的实例不一样!!
失效值可能导致错误的结果或者导致活跃性问题。
- fix方案
volatile
加锁(syn、lock)
有序性
- 引发原因
编译优化之指令重排序
CPU执行指令时会有一些指令重排序以期最大效率
- 后果(以单例模式举例)
单例模式赋值和实例化的重排序导致的异常
- fix方案
volatile
加锁
通过Happens-before规则实现
内置锁的释放锁操作发生在该锁随后的加锁操作之前
一个volatile变量的写操作发生在这个volatile变量随后的读操作之前
原子性
- 引发原因
线程切换
CPU调度是时间片轮转如下图
public方法如有对共享变量读取-修改-写入等类型有依赖的操作序列时,需要是原子性完成
自增、先检查后执行
- 后果(以单例模式举例)
竞态条件(单例模式懒加载 先检查后实例化,行为不正确不能保证单例)
对象状态不一致,如一个对象一致性状态变量A+B=C,如果对A、B、C的修改不能原子性地完成,出现不一致
丢失更新,共享变量自增count++ ,如100个线程跑完却没有增加100
- fix方案
加锁
辨别一个类是否是线程安全的
只读共享
样例1
1、
a、不可变对象如String
b、对象创建以后状态就不能改变
c、对象的所有字段field都是final
d、对象正确创建(创建期间this没有暴露出去)
2、代码解释
private final char value[];//可变对象基础上构建不可变类public char[] toCharArray() { char result[] = new char[value.length]; System.arraycopy(value, 0, result, 0, value.length); return result;}// 没有加char[]数组发布出去,不会被意外修改
样例二
事实不可变对象
线程封闭
样例1
1、
栈封闭
无状态没有实例成员变量
栈(局部变量)在运行时是线程私有的
2、代码解释
没有共享变量,不存在以上问题,无需可见、和有序,局部变量是私有,任何操作不影响其它线程,完全隔离
样例二
ThreadLocal 对象仅由一个线程修改
线程安全共享
1、
在内部实现同步
多个线程通过共有public接口访问无需同步
2、hashtable所有的public访问方法都用synchronized修饰
保护对象
1、有锁才能访问保护对象
2、
hashtable发布内部对象时,用本身的对象锁保护
keySet = Collections.synchronizedSet(new KeySet(), this);
为什么hashmap不安全
public class SafeSet { // final private final Set myset = new HashSet<>(); //饿汉 一旦创建不会发布出去 // synchronized 同步访问 public synchronized void addInteger(Integer p){ myset.add(p); } public synchronized boolean containsInteger(Integer p) { return myset.contains(p); }}
- hashMap线程不安全
1.public方法没有做任何同步操作,会引发以上3种问题(原子、可见、有序)
2.不安全地发布共享实例变量
keySet等方法直接返回对象的内部变量,破坏了封装,有可能在外部不通过其接口方法修改了其状态
- hashtable线程安全
1.所有的public访问方法都用synchronized修饰,互斥访问,线程安全共享,与上文中SafeSet实现思路相同
2.安全发布共享实例变量
values、keySet等方法返回的keySet = Collections.synchronizedSet(new KeySet(), this);还是受锁保护 保护对象
hashmap插入
(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 的写入操作丢失。
hashmap扩容
HashMap 插入后超过阈值会触发扩容resize操作,new一个新容量cap的数组,对原数组的键值对重新进行计算hash并写入新数组,然后指向新数组。
if (++size > threshold)// 线程切换 resize();
当A、B线程同时进来,检测到总数量超过阈值的时候就会同时触发 resize 操作,各自生成新的数组并 rehash 后赋给该 map 底层的数组,结果最终只有最后一个线程生成的新数组被赋给该 map 底层,其他线程的均会丢失。
hashmap删除
删除这一块可能会出现两种线程安全问题
1、线程A判断得到了指定的数组位置i并进入了循环,此时,线程B已经删掉位置i数据了,然后线程A那边就没了。但是删除的话,没了倒问题不大,只是A返回的就是null
2、当A、B线程同时操作同一个数组位置的时候,也都会先取得现在状态下该位置存储的头结点,然后各自去进行计算操作,之后再把结果写会到该数组位置去,其实写回的时候可能其他的线程已经就把这个位置给修改过了,就会覆盖其他线程的修改。
jdk7下HashMap的扩容和链表死循环发生的场景
在addEntry的方法中有以下代码。resize(2*table.length);可以看出是将数组扩容成原来数组的两倍。先从判断语句开始看。执行扩容的条件是当HashMap创建的节点数大于阈值的时候并且该索引位置不为空才会进行扩容。也就是说16的默认阈值是12的情况下,前十二个索引都被使用了,第十三次在索引为空的地方创建新的节点,那就暂时不需要扩容,先把这个索引位置的节点名额用了再说。
if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); }
扩容完成后就将要put的key通过hash算法和indexFor求出索引,注意这时候indexFor中的table.lengh参数应该是老数组长度的两倍,扩容过后的新数组。下面主要来看resize扩容方法。
在resize中发现它根据newCapacity创建了一个新的数组,而这个newCapacity就是2*table.length,在创建完成新的数组后,将老数组中的内容转移到新数组内。通过transfer方法。在transfer方法中遍历了table数组,当e(这里的e是老数组中的e)不为空的时候进行转移操作,这里rehash默认是false,没有什么特殊情况,方法体不会被执行
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry e : table) { while(null != e) { Entry next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
单线程下的扩容过程[头插法]
首先是要获得e节点的next指针,然后重新通过hash算法和indexFor方法计算得到新数组的索引,得到索引后开始转移。大致的画一下,在老数组中可以看到e,还有计算新的索引之前把老数组e的next指针所指向的值给了Entry next
e.next = newTable[i]; e的next指针要去指向newTable[i]的位置。为了方便观看,把e节点移动到newTable[i]的上方。
newTable[i] = e;:这一行代码的目的是为了将e放到newTable[i]这个位置上
e = next; :最后一行将e又指向了next(也就是2:2节点)
然后开始下一次的循环。next的内容变味了e.next指针所指的位置,null所在的节点
这个又回到了和第一张图相似的情况,就这样逐步的把链表中的节点转移到新的数组中。最后的结果如下图。当e为null的时候就结束循环了,另外注意的是,并不是在老数组中索引相同的转移新数组中,这里只是个例。真是的索引还是会通过hash和indexFor得出。
多线程扩容过程
假设有两个线程,从是否需要扩容判断那里开始,两个都同时都需要扩容,进入resize方法,在resize方法中两个线程都创建了各自新的数组,大小相同。然后再到transfer方法中准备转移,遍历老数组,对他们来说老数组是公共的,一样的。遍历后进入while循环,当执行到Entry next = e.next;的时候开是发生不同,线程一有了它自己e和next,线程二也会有他自己独立的e和next
再继续往下运行的话,线程1会先执行,然后在执行线程2,根据之前单线程的时候的思路,线程1的e和next最终都会指向null,然后转移到线程1数组中
线程1执行完了,线程2 开始执行他的。e.next = newTable[i]; 线程2的e.next指针要指向newTable[i],把1:1节点的next从null上拿开,指向线程2 数组的newTable[i]。
newTable[i] = e;开始移动
e = next; 移动结束后将e指向next
最后会出现以下情况 正常单线程情况下e最后会指向null来结束循环。但是由于链表逆序导致全部转以后e指向了newTable[i],形成了一个死循环。导致了死循环的发生
HashMap在get操作一直在抢占CPU,按道理get操作是平均O(1)的,不太会造成这种现象
那是因为get操作需要遍历环形的链表,链表如果key不命中,就一直循环下去。