公告
多线程与并发编程学习笔记02
tags:多线程
多线程与并发编程学习笔记02
13. 阻塞队列 (多线程并发和线程池的时候经常使用)
BlockQueue接口:指定队列大小,若队列满则阻塞等待;取元素时若为空则阻塞等待
Queue接口的类图
2022/08/24/o12Yl6mwFZOHikx.png" alt="image-20220824092340874">
重要的实现类:
-> ArrayBlockingQueue
-> LinkedBlockingQueue
-> SynchronousQueue【重点】
4组API:
| 方法/API | 抛出异常 | 返回值 | 阻塞等待 | 超时等待 |
|---|---|---|---|---|
| 添加元素 | add() | offer() | put() | offer(”a”,2,秒) |
| 移除元素 | remove() | poll() | take() | poll(2,秒) |
| 查看队列首 | element() | peek() | - | - |
SynchronousQueue 同步队列:【进一个取一个】
SynchronousQueue没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。
拥有公平(FIFO)(TransferQueue)和非公平(LIFO)(TransferStack)策略,非公平策略会导致一些数据永远无法被消费的情况.
14.线程池【重要】【3大方法/7大参数/4种拒绝】
14.1 池化技术:线程池、数据库连接池、内存池、常量池…
线程池: 线程池就是首先创建一些线程,它们的集合称为线程池。使用线程池可以很好地提高性能,线程池在系统启动时即创建大量空闲的线程,程序将一个任务传给线程池,线程池就会启动一条线程来执行这个任务,执行结束以后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个任务。
- 工作机制:
- 在线程池的编程模式下,任务提交给整个线程池,而不是直接提交给某个线程,线程池在拿到任务后,就在内部寻找是否有空闲的线程,如果有,则将任务交给某个空闲的线程。
- 一个线程同时只能执行一个任务,但可以同时向一个线程池提交多个任务。
- 优点:
- 避免线程的频繁创建、销毁;提高效率和访问速度。
- 实现线程复用、线程管理、控制最大并发数。
14.2 创建线程池的方法:
- 使用Executors工具类的3种构造方法:
1 | ExecutorService threadPool = Executors.newsingleThreadExecutor(); //单个线程的线程池 |
1 | ExecutorService threadPoo1 = Executors.newFixedThreadpool(5); |
1 | ExecutorService threadPool = Executors.newCachedThreadPool(); |
- 使用ThreadPoolExecutor构造方法:
1 | public ThreadPoolExecutor(int corePoolSize, //核心线程池基本大小 |
两种方法本质:都是调用了ThreadPoolExecutor (7个参数)【因此:线程池要使用此方法去创建,而不是用Executors下的方法】
14.3 线程池调用流程:
1 | 使用构造方法后,返回一个线程池threadPool; |
2022/08/24/fxcMDjmCkoZVbnQ.png" alt="image-20220824130100218">
- 如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
- 如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将该任务添加到任务缓存队列中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;
- 如果当前线程池中的线程数达到maximumPoolSize,则会采取任务拒绝策略进行处理;
- 如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。
14.4 为什么使用阻塞队列?
阻塞队列主要是用于生产者-消费者模型的情况: 比如一个线程从一个空的阻塞队列中取元素,此时线程会被阻塞直到阻塞队列中有了元素。当队列中有元素后,被阻塞的线程会自动被唤醒(不需要我们编写代码去唤醒)。
这样提供了极大的方便性。 如果使用非阻塞队列,它不会对当前线程产生阻塞,就必须额外地实现同步策略以及线程间唤醒策略,这个实现起来就非常麻烦。
拒绝策略:4种:
| ThreadPoolExecutor.AbortPolicy | 丢弃任务,并抛出RejectedExecutionException异常【默认】 |
|---|---|
| ThreadPoolExecutor.AbortPolicy | 丢弃任务,但是不抛出异常 |
| ThreadPoolExecutor.DiscardOldestPolicy | 丢弃队列最老的任务,然后重新提交被拒绝的任务 |
| ThreadPoolExecutor.CallerRunsPolicy | 由调用线程(提交任务的线程)处理该任务 |
14.5 最大线程数如何选择?
CPU密集型任务:maximumPoolSize = CPU核心数量;保持CPU效率最高
IO 密集型任务 :maximumPoolSize = CPU核心数量 * 2;
15. 函数式接口【重要】:
新时代的程序员要掌握的编程技术: lambda表达式、链式编程、 函数式接口、Stream流式计算
函数式接口:只有一个方法的接口 @FunctionalInterface
15.1 4种函数式接口:
Function【函数型】:一个输入,输出一个返回值;
1
Function<String, String> function = str -> {return str;}; //lambda表达式简化
Predicate【判断型】: 一个输入,输出一个boolean的返回值:
1
2
3
4public static void main(String[] args) {
Predicate<String> predicate = String::isEmpty;//判断字符串是否为空的函数
System.out.println(predicate.test("111"));//输出false;
}Consumer【消费型】:一个输入,没返回值:
1
2
3
4public static void main(String[] args) {
Consumer<String> consumer = System.out::println;//只是打印,没有返回值
consumer.accept("12345");//打印了”12345“
}Supplier 【供给型】:没有输入,只有返回值:
1
2
3
4public static void main(String[] args) {
Supplier<Integer> supplier = () -> 1024;//没有参数
System.out.println(supplier.get());//提供1024
}
15.2 Stream 流式计算:
2022/08/24/RmMiufY5bTerFJH.png" alt="image-20220824150553674 " style="zoom:70%;" />16. ForkJoin
Since : JDK1.7+
原理:
- 分治
- 工作窃取:工作窃取算法是指某个线程从其他队列里窃取任务来执行。
- 工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争。
- 工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗了更多的系统资源,比如**创建多个线程和多个双端队列**。ForkJoin类似于分治思想,只是分成小任务后使用的是多个线程并行执行小任务,从而加速计算。
但是还是Stream并行流式计算,速度最快:
1 | long sum = LongStream.rangeClosed(0L, 10_ 0000_ _00L).parallel().reduce( identity:0,Long::sum); |
只需1行代码,运算速度快100倍!
17. 异步回调
是什么?在发起一个异步任务的同时指定一个函数,在异步任务完成时会自动的调用这个函数。
为什么?需要获取异步任务的执行结果,但是又不应该让其阻塞(降低效率),即想要高效的获取任务的执行结果。
怎么实现?使用Future接口下的CompletableFuture类;它的方法有:runAsync();(无返回值) supplyAsync();有返回值;
18. JMM
JMM带来的问题:
1) 可见性问题:
CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中。
要解决共享对象可见性这个问题,我们可以使用 volatile关键字或者是加锁:
2022/08/25/r5Bw8gemhjHISYU.png" alt="image-20220825145545276 " style="zoom:50%;" />
2)竞争问题:
线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 要解决上面的问题我们可以使用 synchronized代码块。
JMM中的指令重排:
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,指令重排包括:
- 编译器优化
- CPU的指令级并行
- 内存系统重排序
as-if-serial关系 : 不管怎么重排序(编译器和处理器为了提高并行度,程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。【仅限于单线程】
多线程下JMM的指令重排带来的问题:在多线程中:对存在控制依赖的操作重排序,可能会改变程序的执行结果.
happens-before关系:保证正确同步的多线程程序的执行结果不被改变,满足可见性的程序都满足此关系。
volitale关键字可解决:
可见性:【原理是MESI】
- 当写一个volatile变量时:JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存。
- 当读一个volatile变量时:JMM会把该线程对应的本地内存变量置为无效。线程接下来将从主内存中读取共享变量。
有序性:【原理是内存屏障】:
- volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障,禁止前面指令和后面指令交换顺序。
- volatile读操作的后面插入一个LoadLoad屏障, 后面插入一个LoadStore屏障,禁止前面指令和后面指令交换顺序。
不保证原子性:
对任意单个volatile变量的读/写具有原子性,但类似于i++这种复合操作不具有原子性。
那么如何保证原子性呢? -> lock、synchronized 【效率低】
-> 使用原子类,例如AtomicInteger等;【底层是CAS】
1
2private volatile static AtomicInteger num = new AtomicInteger();
num.getAndIncrement(); // AtomicInteger + 1 ; 此方法原理是使用了 CAS原子类的底层都直接和操作系统挂钩! 在内存中修改值! 其使用到的Unsafe类是一个很特殊的存在 !
19. 单例模式
1. 饿汉式:【永远提前加载,耗费系统资源】
1 | public class Singleton { |
- 饿汉式:【双重检测+锁+Volatile】重要
1 | public class SingletonLazy { |
为何要双重检测?因为除第一次调用外,大部分情况是单例已经创建,因此在外层加锁会导致每次调用此方法都会加锁,影响性能。所以在内层加锁,并且进入内层之后需要再次进行判断单例是否已经存在,若仍不存在,此时才可放心创建。
为何声明单例时要加volatile?因为
lazy = new SingletonLazy();不是一个原子性操作,其步骤是:- 分配内存空间
- 执行构造方法 初始化对象
- 把对象指向这个内存空间
由于指令重排的存在,可能使上述指令变为1->3->2的顺序,这样就会导致其他线程获取一个null的单例对象;
静态内部类实现:
1
2
3
4
5
6
7
8
9
10
11
12public class Holder {
private Holder(){
}
private static class InnerClass{
private static final Holder HOLDER = new Holder();
}
public static Holder getInstance(){
return InnerClass.HOLDER;
}
}
安全问题:无论是使用以上哪种方式,只要是以private修饰构造器用来限制外部调用的,都可以用反射机制破解!
首先用getDeclaredConstructor()通过反射获取构造器
然后用setAccessible(true)即可无视构造器的private修饰符
1 | public static void main(String[] args) throws Exception{ |
那么,单例类如何防止反射?
在其构造器内加入flag秘钥校验,使其在第二次被调用时抛出异常:
1 | private static boolean flag = false; |
但是,若flag秘钥被泄露,同样可以通过反射将flag设置为相应的值,从而破解单例模式。
- 使用 枚举类enum 【现如今最推荐的单例模式方法】
1 | //enum默认就是单例的,又简单又牛逼,不存在安全问题! |
修复安全问题:
当我们尝试像往常一样使反射破解枚举类时:
出现异常:Cannot reflectively create enum objects【不能通过反射创建枚举对象】
这是因为反射包的newInstance()方法中添加了以上的异常,禁止了通过反射创建枚举对象。
1 | public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { |
序列化问题
当使用反序列化时,也会破坏单例模式,导致反序列化时生成的实例和之前的实例并不是同一个!
而由于枚举类enum优先于反序列化方法readResolve(),因此不会被反序列化破坏单例。
20. CAS 「Compare And Swap」乐观锁
Atomic原子类下的方法都是compareAnd..(expect,newvalue);例如:
1 | AtomicInteger atomicInteger = new AtomicInteger(2022); |
CAS是一条CPU并发原语:它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。CAS并发原语提现在Java语言中就是sun.misc.UnSafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我实现CAS汇编指令.这种操作直接操作内存,通过它实现了原子操作。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B):
判断过程:“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”
自旋锁:CAS的方法使用的是自旋锁,因为Unsafe类下的方法底层代码使用的是do..while()循环;
缺点:
- 自旋锁循环比较耗时。解决办法 -> 【pause指令】
- 一次只能保证一个共享变量的原子性。解决办法 -> AtomicReference【原子引用】
- ABA问题:当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。解决办法 ->【时间戳原子引用】
21. 原子引用:【可以加时间戳】
AtomicReference 类:可将多个变量打包成一个引用类型的对象实现CAS;
AtomicStampedReference 类的CAS方法比原子引用类的CAS方法多了两个变量(期望时间戳,新的时间戳)
1 | AtomicStampedReference<Integer> a = new AtomicStampedReference<>(2022,0); |
当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功!【解决了ABA问题】
21. 可重入锁:(递归锁)
什么是可重入锁:
广义上的可重入锁指的是可递归调用的锁。获取了外层的锁之后,在内层仍然可以获得该锁,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。
synchronized锁的Demo:
1 | //当外层方法sms()获取到对象的锁后,内层方法text()依然可以使用该锁; |
Lock()锁的Demo:
1 | class Phone2 { |
Synchronized、Reentrantlock 都是可重入锁;
注:当递归调用加锁方法时:Synchronized锁了一次,而Reentrantlock锁了两次;
22. 自旋锁
通过原子引用的CAS方法实现自旋:可以自己写一个自旋锁的Demo:
1 | public class MySpinLock { |
23. 死锁
如下图所示,线程A持有资源2,线程B持有资源1,他们同时都想申请对方的资源,所 以这两个线程就会互相等待而进入 死锁状态。
2022/08/26/yuJLIRp4viDE1Kn.png" alt="image-20220826140344353 " style="zoom:50%;" />死锁4条件:
- 互斥条件 **: 该资源任意一个时刻只由一个线程占用**。
- 不可剥夺条件 **: 线程已获得的资源在末使用完之前不能被其他线程强行剥夺**,只有自己使用完毕后才释放资源。
- 请求与保持条件 **: 一个进程因请求资源而阻塞时,对已获得的资源保持不放**。
- 循环等待条件 : 若干进程之间形成一种头尾相接的循环等待资源关系。
如何排查死锁?
使用
2022/08/26/FMx81hGmbsNtdQR.png" alt="image-20220826141834947 " style="zoom:67%;" />jsp-l列出java中的进程:使用
jstack 进程号查看进程信息:
可以看到找到一个deadlock(死锁);并且可以看到每个线程已获取的锁以及他们想要等待获取的锁;