12 多线程并发拓展
1️⃣死锁
1 概念
所谓的死锁是指两个或者两个以上的线程在执行过程中,因争夺资源而造成的互相等待的情况,这些永远在等待的进程称为死锁进程;由于资源占用是互斥的,当某个进程提出申请资源后,使得有关进程在无外力的情况下永远无法分配到必须的资源而无法继续前进;
2 死锁发生所具备的条件
① 互斥条件 : 指进程对所分配的资源进行排他性的使用,即在一段时间内某资源只有一个进程占用,如果此时还有其他线程请求资源,请求者只能等待(等占用资源的进程释放才可以);
② 请求和保持条件 : 指进程已经保持了至少一个资源,但又提出了一个新的资源请求而该资源已被其他进程占有,此时请求进程将会阻塞,但又不释放自己已经获得的资源;
③ 不剥夺条件 : 指进程已获得的资源在未使用完之前不能被剥夺,只能在使用完时自己释放;
④ 环路等待条件 : 顾名思义就是说在发生死锁的时候,持有资源的进程之间形成一个环形的链,彼此都处于等待状态;
3 死锁代码演示
/**
* 一个简单的死锁类
* 当DeadLock类的对象flag==1时(td1),先锁定o1,睡眠500毫秒
* 而td1在睡眠的时候另一个flag==0的对象(td2)线程启动,先锁定o2,睡眠500毫秒
* td1睡眠结束后需要锁定o2才能继续执行,而此时o2已被td2锁定;
* td2睡眠结束后需要锁定o1才能继续执行,而此时o1已被td1锁定;
* td1、td2相互等待,都需要得到对方锁定的资源才能继续执行,从而死锁。
*/
@Slf4j
public class DeadLock implements Runnable {
public int flag = 1;
//静态对象是类的所有对象共享的
private static Object o1 = new Object(), o2 = new Object();
@Override
public void run() {
log.info("flag:{}", flag);
if (flag == 1) {
synchronized (o1) {
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (o2) {
log.info("1");
}
}
}
if (flag == 0) {
synchronized (o2) {
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
synchronized (o1) {
log.info("0");
}
}
}
}
public static void main(String[] args) {
DeadLock td1 = new DeadLock();
DeadLock td2 = new DeadLock();
td1.flag = 1;
td2.flag = 0;
//td1,td2都处于可执行状态,但JVM线程调度先执行哪个线程是不确定的。
//td2的run()可能在td1的run()之前运行
new Thread(td1).start();
new Thread(td2).start();
}
}
4 如何避免死锁
① 加锁顺序
线程已经要按照一定的顺序进行加锁,从上边的例子我们就已经可以得出这样的结论;② 加锁时限
我们的系统在尝试获取锁的时候可以加上一定的时限,超过时限的时候就放弃对该锁的请求并释放自己占有的锁;③ 死锁检测
这种方法实现起来相对比较难,死锁检测是一种比较好的预防死锁的机制,它主要是对那些不可能实现按序加锁并且锁超时也不可行的场景,每当一个线程获得锁会在线程和锁相关的数据结构中进行记录,同时每当有线程请求锁也将存储在数据结构中,当一个线程请求失败的时候这个线程可以遍历锁的关系图,看是否有死锁发生并决定后续采用什么样的操作,但是这个存储的数据结构需要我们根据实际情况来进行设计;
如果检测到死锁的情况我们可以做什么样的操作呢?
1 释放所有锁进行回退,并且等待一段时间以后(时间是随机)进行重试,这种方法和简单的加锁超时有一些类似,不一样的是只有在死锁发生的时候才会回退;虽然释放了所有的锁但是如果有大量的线程同时请求同一种锁仍然会发生死锁,这个时候我们可以赋予线程优先级来解决这个问题,为了避免同样的情况发生我们可以在死锁发生的时候设置随机的线程优先级;
2️⃣ 并发最佳实践
1 使用本地变量
在多线程并发环境下应该尽量使用本地变量,而不是创建一个类或者实例的变量,通常情况我们使用对象实例作为变量可以节省内存并且可以重用,因为每次在方法中创建本地变量会消耗很多内存;
2 使用不可变类
不可变类比如String Integer等一旦创建就不会改变了,不可变可以降低代码中的同步数量;
3 最小化锁的作用于范围 : S = 1 / (1 - a + a / n)
a = 并行计算部分所占的比例;
n = 并行处理的节点个数;
S = 加速比;
当1 - a = 0的时候是没有串行只有并行,最大的加速比=n;当a = 0的时候只有串行没有并行,此时最小的加速比s = 1;当n = 无穷大时极限的s = 1 / 1 - a,同时这也是加速比的上限;例如如果串行代码占总体代码的25%,那么并行处理的总体性能不可能超过4;S = 1 / (1 - a + a / n)这个公式也被称为安达尔定理;4 使用线程池的Executor,而不是直接new Thread执行
创建一个线程的代价是昂贵的,如果要得到一个可伸缩的Java应用我们需要使用线程池,JDK中提供了各种ThreadPool线程池和Executor;
5 宁可使用同步也不要使用线程的wait和notify
从Java1.5以后增加了许多同步工具,此时我们首先需要考虑的是应该使用同步方法而不是线程的wait和notify;此时使用队列生产消费的设计化比使用线程的等待要好的多,
6 使用BlockingQueue实现生产-消费模式
大部分并发问题都可以使用生产-消费实现,BlockingQueue是其中最好的实现方式,阻塞队列不只是可以实现单个生产单个消费也可以处理多个生产和消费;
7 使用并发集合而不是加锁的同步集合
Java提供了多种并发集合与同步集合,在多线程并发的环境下建议多使用并发集合来解决问题;
8 使用Semaphore创建有界的访问
为了建立可靠的稳定的系统,对于数据库文件系统等必须要做有界的访问,Semaphore是一个可以限制这些资源开销的选择;我们可以使用Semaphore来控制多个线程同时访问某个资源的线程数;
9 宁可使用同步代码块也不使用同步方法
使用同步代码块只会锁定一定对象而不会将整个方法锁定,如果更改共同的变量和类的字段,首先应该选择的是原子性变量;
10 避免使用静态变量
静态变量在并发执行环境下会制造很多问题,如果你优先使用静态变量需要将其先制成final变量,如果是用来保存集合的话我们可以考虑使用只读集合,否则就需要做特别多的同步处理以及并发处理;
3️⃣Spring与线程安全
1 Spring bean
Spring作为一个IOC容器帮我们管理了许许多多的bean,但是Spring并没有保证这些线程的安全,Spring对每一个bean提供了一个scope属性作为该bean的作用域,他是这个bean的生命周期,比如scope为singleton就是单例在第一次被注入时会创建一个单例对象;它是每个bean的默认scope,该对象的声明周期与IOC容器是一致的但是只会在第一次被注入时创建;
2 无状态对象
我们交由Spring管理的对象大多数都是无状态对象,这种不会因为多线程而导致状态被破坏的对象很适合Spring的默认scope,每个单例的无状态对象都是线程安全的;
4 HashMap与ConcurrentHashMap解析
1 HashMap
① 概述从上图中我们可以看出HashMap的底层就是一个数组结构,数组中的每一项又是一个链表;当我们新建一个HashMap的时候就会初始化一个数组出来,HashMap有两个参数会影响他们的性能分别是初始容量(默认16)和加载因子(0.75);容量是哈希表中桶的数量,初始容量是哈希表在创建时的容量,加载因子是哈希表在他的容量自动增加之前可以达到多满的一个尺度,当哈希表中的条目数量超过了加载因子与当前容量的乘积将会调用resize的方法进行扩容,然后将容量进行翻倍;
初始容量与加载因子在初始化的时候是可以指定的,② HashMap的寻址方式
③ 单线程的reHash ④ 多线程并发下的reHash
对于一个新插入的数据或者需要读取的数据,HashMap需要加上key按照一定的计算规则计算出哈希值并对数组长度进行取模结果作为数组的index,在计算机中取模的代价要高于位操作的代价,所以HashMap要求数组的长度必须是2的n次方,此时它将对key的哈希值对n的2-1次方进行与运算它的结果与取模的操作结果是相同的;HashMap并不要求我们在创建的时候传入一个2的n次方的整数,而是在初始化时计算出一个满足条件的容量;众所周知HashMap不是线程安全的,主要体现在扩容操作时的reHash会出现死循环;
2 ConcurrentHashMap
① 概述 ConcurrentHashMap底层仍然是一个数组加链表,与HashMap不同的是ConcurrentHashMap最外层不是一个大的数组而是一个Segment数组,每一个Segment包含一个与HashMap数据结构差不多的链表数组,当我们读取某个key的时候它先取出该key的Hash值并将Hash值的高位对Segment个数取模,从而得到该key属于哪一个Segment,接着就像操作HashMap一样操作Segment,为了保证不同的值均匀的分配到不同的Segment里边它计算Hash值也做了一定的优化,Segment继承自JUC里边的ReentrantLock,所以我们可以很轻松的堆每一个Segment做锁相关的处理;需要注意的是JDK7及以前是基于分段锁来进行处理的;而JDK8对于ConcurrentHashMap进行优化引入了红黑树
3 HashMap与ConcurrentHashMap的不同点
① HashMap不是线程安全的而ConcurrentHashMap是线程安全的;
② HashMap允许key和value而ConcurrentHashMap是不允许的;
③ HashMap不允许通过迭代器遍历的同时通过HashMap来修改而ConcurrentHashMap是允许该行为的并且这个更新对后续的遍历是可见的;