JUC------
1.概念介绍
JUC:java.util.concurrent包的简称,包含了很多并发工具类
-
进程和线程
- 进程:后台运行的程序,打开一个程序就是启动一个进程,是系统资源分配和独立运行的最小单位
- 线程:进程中实施调度和分配的基本单位,一个进程包含多个线程,(一个程序里面运行多个窗口)
- 协程:用户态的轻量级线程,调度完全由用户控制
-
线程的状态
-
操作系统层面:五种
1.初始化
2.可运行
3.运行
4.阻塞
5.终止
-
java API层面
1.new:刚被创建,还没调用Start方法
2.runnable:调用了start()方法之后,又分为运行状态,可运行状态,阻塞状态
3.阻塞状态分为:BLOCKED , WAITING , TIMED_WAITING
4.TERMINATED终止状态
-
-
JMM是什么
Java Memory Model,简称JMM,是一种抽象的概念,描述了程序中各个变量访问方式的规则或规范。
必须要保证三大特性:1.可见性2.原子性3.禁止指令重排
- 主内存和工作内存
线程在创建时,JVM会给他分配一块工作内存(栈空间),这个工作内存是线程私有的,别的线程访问不到,jmm规定所有的变量都必须存储在主内存中(计算机内存),主内存是共享内存,所有线程都能访问,线程对于变量的操作必须在自己的工作内存中进行,不能直接修改主内存中的变量,需要从主内存中拷贝到自己的工作和内存中进行操作,完事后在写会主内存
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v7ZDDeev-1655129054668)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220419130504024.png)]
2.volatile
在单线程环境中用不到,是jvm提供的轻量级的同步机制具有三大特性
- 1.可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 2.不保证原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 3.禁止指令重排:即程序执行的顺序按照代码的先后顺序执行。
2.1可见性
如果不用volatile修饰变量,当这个变量在某个线程被修改时,别的线程时不知道的。当一个变量被volatile修饰时,这个变量一旦在某个线程中被修改,就会被强制刷新到主内存中,并且会导致其他线程中的volatile变量缓存无效
2.2原子性
一个线程AAA修改了共享变量X的值,但是还未写入主内存时,另外一个线程BBB又对主内存中同一共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说是不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。
例如多线程进行number++操作会导致数值丢失
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5TVyksUL-1655129054669)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220419131429432.png)]
2.2.1解决方法:
- 加入synchronized关键字,但是这是一种重量级的同步机制,会影响很多效率。
- 原子整形AtomicInteger,原子整形就是用来保证原子性的东西。
2.3禁止指令重排
程序在执行时,如果几个数据之间没有依赖性,编译器就可能会进行指令重排优化
但是在多线程环境下,由于这种优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
例如:
public void mySort() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
按照正常单线程环境,执行顺序是 1 2 3 4
但是在多线程环境下,可能出现以下的顺序:
- 2 1 3 4
- 1 3 2 4
上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样
但是指令重排也是有限制的,即不会出现下面的顺序
- 4 3 2 1
因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性
因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行
例如
int a,b,x,y = 0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
x = 0; y = 0 |
因为上面的代码,不存在数据的依赖性,因此编译器可能对数据进行重排
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x = a; | y = b; |
x = 2; y = 1 |
这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性
2.3.1为什么能进行指令重排?
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的顺序
- 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
也就是过在Volatile的写 和 读的时候,加入屏障,防止出现指令重排的
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
也就是过在Volatile的写 和 读的时候,加入屏障,防止出现指令重排的
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
也就是过在Volatile的写 和 读的时候,加入屏障,防止出现指令重排的
2.4 volatile原理(内存屏障)
volatile变量的写指令之后会加上写屏障
读指令之前会加入读屏障
2.4.1保证可见性
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qkCdZlOb-1655129054672)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220526212955973.png)]
写屏障之前的所有对共享变量的改动,都会同步到主内存中,包括图中的num(num没有被volatile修饰,ready被volatile修饰)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cs5GiatM-1655129054673)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220526213311908.png)]
读屏障保证在该屏障之后,对共享变量的读取,加载的是主内存中的最新数据
2.4.2保证有序性
写屏障会保证指令重排序时,写屏障之前的代码不会排在写屏障之后
读屏障会确保指令重排序时,不会讲读屏障之后的代码排在读屏障之前
3.CAS
CAS的全称是compare-And-Swap,是一条CUP的并发原语,是原子类能保证原子性的底层原理
他的功能是判断某个变量的真实值是否跟预期值相等,如果相等则交换
CAS在JAVA中的体现就是UNSAFE类中的各个方法。调用unsafe中的compareAndSwap…方法,jvm会帮我们实现出CAS汇编指令,通过他实现了原子操作,是属于操作系统的范畴是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致的问题,也就是说CAS是线程安全的。
3.1代码使用:
首先调用AtomicInteger创建了一个实例, 并初始化为5
// 创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);
然后调用CAS方法,企图更新成2019,这里有两个参数,一个是5,表示期望值,第二个就是我们要更新的值
atomicInteger.compareAndSet(5, 2019)
然后再次使用了一个方法,同样将值改成1024
atomicInteger.compareAndSet(5, 1024)
完整代码如下:
/**
* CASDemo
*
* 比较并交换:compareAndSet
*
*/
public class CASDemo {
public static void main(String[] args) {
// 创建一个原子类
AtomicInteger atomicInteger = new AtomicInteger(5);
/**
* 一个是期望值,一个是更新值,但期望值和原来的值相同时,才能够更改
* 假设三秒前,我拿的是5,也就是expect为5,然后我需要更新成 2019
*/
System.out.println(atomicInteger.compareAndSet(5, 2019) + "\t current data: " + atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5, 1024) + "\t current data: " + atomicInteger.get());
}
}
上面代码的执行结果为
这是因为我们执行第一个的时候,期望值和原本值是满足的,因此修改成功,但是第二次后,主内存的值已经修改成了2019,不满足期望值,因此返回了false,本次写入失败
3.2CAS底层原理
首先我们先看看 atomicInteger.getAndIncrement()方法的源码
从这里能够看到,底层又调用了一个unsafe类的getAndAddInt方法
3.2.1、unsafe类
Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe类的很多方法都是native修饰的,也就是说unsafe类中的方法都直接调用操作系统底层资源执行相应的任务
为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类
3.2.2、变量valueOffset
表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
从这里我们能够看到,通过valueOffset,直接通过内存地址,获取到值,然后进行加1的操作
3.2.3、变量value用volatile修饰
保证了多线程之间的内存可见性
var5:就是我们从主内存中拷贝到工作内存中的值(每次都要从主内存拿到最新的值到自己的本地内存,然后执行compareAndSwapInt()在再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加1操作)
那么操作的时候,需要比较工作内存中的值,和主内存中的值进行比较
假设执行 compareAndSwapInt返回false,那么就一直执行 while方法,直到期望的值和真实值一样
- val1:AtomicInteger对象本身
- var2:该对象值得引用地址
- var4:需要变动的数量
- var5:用var1和var2找到的内存中的真实值
- 用该对象当前的值与var5比较
- 如果相同,更新var5 + var4 并返回true
- 如果不同,继续取值然后再比较,直到更新完成
这里没有用synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后在进行更新操作。
Unsafe类 + CAS思想: 也就是自旋,自我旋转
3.2.4CAS的缺点
CAS操作没有加锁,不会导致线程一直被挂起,但是可能需要多次进行while循环。
- 循环时间长,开销大(因为执行的是do while,如果比较不成功一直在循环,最差的情况,就是某个线程一直取到的值和预期值都不一样,这样就会无限循环)
- 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以通过循环CAS的方式来保证原子操作
- 但是对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候只能用锁来保证原子性
- 引出来ABA问题?
3.2.5ABA问题
某个变量初始值为A,有两个线程都从主内存拿到这个变量到自己的工作内存中,然后某个线程将这个变量修改为B,之后再修改为A,刷回到主内存中。这时另一个线程也想进行修改操作,会将期望值(A)跟真实值(A注意:这个A是被修改之后的A)比较,发现比较成功之后会修改,但是这个变量实际上已经被别的线程修改过了,只是修改之后改了回来,但另外一个线程不知道被修改过,还是会进行修改操作。
如果不考虑过程,ABA问题可以忽略,但是如果考虑,则需要用到 AtomicStampedReference
这个原子引用类相比于之前的原子类多了一个相当于版本号的变量,变量每次被修改,版本号都要发生变化,如果要修改值,不仅要比较期望值与真实值,还需要比较这个版本号。
4.Collection线程不安全的举例
4.1 ArrayList线程不安全
4.1.1解决方案
方案一:Vector
第一种方法,就是不用ArrayList这种不安全的List实现类,而采用Vector,线程安全的
关于Vector如何实现线程安全的,而是在方法上加了锁,即synchronized
这样就每次只能够一个线程进行操作,所以不会出现线程不安全的问题,但是因为加锁了,导致并发性基于下降
方案二:Collections.synchronized()
List<String> list = Collections.synchronizedList(new ArrayList<>());
方案三:采用JUC里面的方法
CopyOnWriteArrayList:写时复制,主要是一种读写分离的思想
具体实现就是在进行写操作的时候,会先把原有的容器copy一分,在新的容器上进行写操作,写完之后返回新的容器
好处: 这样做的好处是可以对copyOnWrite容器进行并发的读 ,而不需要加锁,因为当前容器不需要添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器
就是写的时候,把ArrayList扩容一个出来,然后把值填写上去,在通知其他的线程,ArrayList的引用指向扩容后的
查看底层add方法源码
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
4.2HashSet线程不安全
Hash的底层就是HashMap。只是他有KEY值,而value值是一个object类型的常量。
**解决方案:**CopyOnWriteArraySet
4.3HashMap线程不安全
**解决方案:**1、使用Collections.synchronizedMap(new HashMap<>());
2、使用 ConcurrentHashMap
Map<String, String> map = new ConcurrentHashMap<>();
5.java的锁
5.1公平锁和非公平锁
5.1.2公平锁
多个线程按照申请锁的顺序来获取锁,类似于排队,不会有别的线程插进来,先来后到,先来先服务,就是公平的,也就是队列
5.1.3非公平锁
Synchronized是非公平锁,ReentrantLock默认也是非公平
多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的线程(也就是某个线程一直得不到锁)
5.1.4如何创建
并发包中ReentrantLock的创建可以指定析构函数的boolean类型来得到公平锁或者非公平锁,默认是非公平锁
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);
5.1.5区别
公平锁:就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列中的第一个,就占用锁,否者就会加入到等待队列中,以后安装FIFO的规则从队列中取到自己
非公平锁: 非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
5.2 可重入锁/递归锁
指的是一个线程外层函数获得锁后,内层函数仍然能获取到该锁的代码,在同一线程在外层方法获取锁的时候,在进入内层方法也会自动获取锁
也就是说:线程可以进入任何一个他已经拥有锁的所同步的代码块
优点:可以避免死锁
synchronized,ReentrantLock都是可重入锁
5.3 自旋锁(CAS机制)
是指尝试获取锁的线程不会立即阻塞,而是会采用循环的方式去获得锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
手写自旋锁
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
5.4 独占锁(写锁) / 共享锁(读锁) / 互斥锁
5.4.1 独占锁:
该锁一次只能被一个线程所持有,对ReentrantLock和Synchronized而言都是独占锁
5.4.2 共享锁
可以被多个线程所持有
对ReentrantReadWriteLock其读锁是共享,其写锁是独占
写的时候只能一个人写,但是读的时候,可以多个人同时读
5.4.3 为什么会有写锁和读锁
原来我们使用ReentrantLock创建锁的时候,是独占锁,也就是说一次只能一个线程访问,但是有一个读写分离场景,读的时候想同时进行,因此原来独占锁的并发性就没这么好了,因为读锁并不会造成数据不一致的问题,因此可以多个人共享读
多个线程 同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行,但是如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写
读-读:能共存
读-写:不能共存
写-写:不能共存
ReentrantReadWriteLock读写锁
写操作时用写锁
// 创建一个写锁
rwLock.writeLock().lock();
// 写锁 释放
rwLock.writeLock().unlock();
当们在进行读操作的时候,在转换成读锁
// 创建一个读锁
rwLock.readLock().lock();
// 读锁 释放
rwLock.readLock().unlock();
这里的读锁和写锁的区别在于,写锁一次只能一个线程进入,执行写操作,而读锁是多个线程能够同时进入,进行读取的操作
6.CountDownLatch,CyclicBarrier,Semaphore
6.1CountDownLatch
概念
让一些线程阻塞直到另一些线程完成一系列操作才被唤醒
CountDownLatch主要有两个方法,当一个或多个线程调用await方法时,调用线程就会被阻塞。其它线程调用CountDown方法会将计数器减1(调用CountDown方法的线程不会被阻塞),当计数器的值变成零时,因调用await方法被阻塞的线程会被唤醒,继续执行
6.2CyclicBarrier
概念
和CountDownLatch相反,需要集齐七颗龙珠,召唤神龙。也就是做加法,开始是0,加到某个值的时候就执行
CyclicBarrier的字面意思就是可循环(cyclic)使用的屏障(Barrier)。它要求做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await方法
6.3 Semaphore:信号量
概念
信号量主要用于两个目的
- 一个是用于共享资源的互斥使用
- 另一个用于并发线程数的控制(控制并发量)
代码
我们模拟一个抢车位的场景,假设一共有6个车,3个停车位
那么我们首先需要定义信号量为3,也就是3个停车位
/**
* 初始化一个信号量为3,默认是false 非公平锁, 模拟3个停车位
*/
Semaphore semaphore = new Semaphore(3, false);
然后我们模拟6辆车同时并发抢占停车位,但第一个车辆抢占到停车位后,信号量需要减1
// 代表一辆车,已经占用了该车位
semaphore.acquire(); // 抢占
同时车辆假设需要等待3秒后,释放信号量
// 每个车停3秒
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
最后车辆离开,释放信号量
// 释放停车位
semaphore.release();
7.阻塞队列BlockingQueue
阻塞队列
BlockingQueue 阻塞队列,排队拥堵,首先它是一个队列,而一个阻塞队列在数据结构中所起的作用大致如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xVBtZv6G-1655129054678)(https://gitee.com/moxi159753/LearningNotes/raw/master/%E6%A0%A1%E6%8B%9B%E9%9D%A2%E8%AF%95/JUC/8_%E9%98%BB%E5%A1%9E%E9%98%9F%E5%88%97/images/image-20200316152120272.png)]
线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素
当阻塞队列是空时,从队列中获取元素的操作将会被阻塞
当阻塞队列是满时,从队列中添加元素的操作将会被阻塞
7.1为什么需要BlockingQueue
好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue都帮你一手包办了
7.2BlockingQueue核心方法
抛出异常 | 当阻塞队列满时:在往队列中add插入元素会抛出 IIIegalStateException:Queue full 当阻塞队列空时:再往队列中remove移除元素,会抛出NoSuchException |
---|---|
特殊性 | 插入方法,成功true,失败false 移除方法:成功返回出队列元素,队列没有就返回空 |
一直阻塞 | 当阻塞队列满时,生产者继续往队列里put元素,队列会一直阻塞生产线程直到put数据or响应中断退出, 当阻塞队列空时,消费者线程试图从队列里take元素,队列会一直阻塞消费者线程直到队列可用。 |
超时退出 | 当阻塞队列满时,队里会阻塞生产者线程一定时间,超过限时后生产者线程会退出 |
7.3 SynchronousQueue
SynchronousQueue没有容量,与其他BlockingQueue不同,SynchronousQueue是一个不存储的BlockingQueue,每一个put操作必须等待一个take操作,否者不能继续添加元素
8.Synchronized和Lock的区别
前言
早期的时候我们对线程的主要操作为:
- synchronized wait notify
然后后面出现了替代方案
- lock await signal
问题
synchronized 和 lock 有什么区别?用新的lock有什么好处?举例说明
- synchronized 和 lock 有什么区别?用新的lock有什么好处?举例说明
1)synchronized属于JVM层面,属于java的关键字
- monitorenter(底层是通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象 只能在同步块或者方法中才能调用 wait/ notify等方法)
- Lock是具体类(java.util.concurrent.locks.Lock)是api层面的锁
2)使用方法:
- synchronized:不需要用户去手动释放锁,当synchronized代码执行后,系统会自动让线程释放对锁的占用
- ReentrantLock:则需要用户去手动释放锁,若没有主动释放锁,就有可能出现死锁的现象,需要lock() 和 unlock() 配置try catch语句来完成
3)等待是否中断
- synchronized:不可中断,除非抛出异常或者正常运行完成
- ReentrantLock:可中断,可以设置超时方法
- 设置超时方法,trylock(long timeout, TimeUnit unit)
- lockInterrupible() 放代码块中,调用interrupt() 方法可以中断
4)加锁是否公平
- synchronized:非公平锁
- ReentrantLock:默认非公平锁,构造函数可以传递boolean值,true为公平锁,false为非公平锁
5)锁绑定多个条件Condition
- synchronized:没有,要么随机,要么全部唤醒
- ReentrantLock:用来实现分组唤醒需要唤醒的线程,可以精确唤醒,而不是像synchronized那样,要么随机,要么全部唤醒
9.线程池
前言
获取多线程的方法,我们都知道有三种,还有一种是实现Callable接口
-
实现Runnable接口
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IHktmZYD-1655129054680)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220609211011071.png)]
-
实现Callable接口
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0aEvMo58-1655129054681)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220609210958586.png)]
-
使用线程池获取
-
继承Thread类,重写run方法(因为java是单继承,如果这个类继承了Thread类就不能继承别的类了,扩展性低)
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6jfFGamF-1655129054681)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220609210652313.png)]
9.1 Callable接口
用法:通过FutureTask类
FutureTask类实现了Runnable接口,并且聚合了Callable接口,通过构造方法能够传递一个Callable接口到FutureTask类。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HDHQps0A-1655129054681)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220419164802874.png)]
get方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2SCo3cwR-1655129054682)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220419164826205.png)]
通过 futureTask.get() 获取到返回值
// 输出FutureTask的返回值
System.out.println("result FutureTask " + futureTask.get());
这就相当于原来我们的方式是main方法一条龙之心,后面在引入Callable后,对于执行比较久的线程,可以单独新开一个线程进行执行,最后在进行汇总输出
最后需要注意的是 要求获得Callable线程的计算结果,如果没有计算完成就要去强求,会导致阻塞,直到计算完成
也就是说 futureTask.get() 需要放在最后执行,这样不会导致主线程阻塞
也可以使用下面算法,使用类似于自旋锁的方式来进行判断是否运行完毕
// 判断futureTask是否计算完成
while(!futureTask.isDone()) {
}
9.2 ThreadPoolExecutor
为什么用线程池
线程池做的主要工作就是控制运行的线程的数量,处理过程中,将任务放入到队列中,然后线程创建后,启动这些任务,如果线程数量超过了最大数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。
它的主要特点为:线程复用、控制最大并发数、管理线程
线程池中的任务是放入到阻塞队列中的
线程池的好处
多核处理的好处是:省略的上下文的切换开销
原来我们实例化对象的时候,是使用 new关键字进行创建,到了Spring后,我们学了IOC依赖注入,发现Spring帮我们将对象已经加载到了Spring容器中,只需要通过@Autowrite注解,就能够自动注入,从而使用
因此使用多线程有下列的好处
- 降低资源消耗。通过重复利用已创建的线程,降低线程创建和销毁造成的消耗
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就立即执行
- 提高线程的可管理性。线程是稀缺资源,如果无线创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控
架构说明
Java中线程池是通过Executor框架实现的,该框架中用到了Executor,Executors(代表工具类),ExecutorService,ThreadPoolExecutor这几个类。
创建线程池
- Executors.newFixedThreadPool(int i) :创建一个拥有 i 个线程的线程池
- 执行长期的任务,性能好很多
- 创建一个定长线程池,可控制线程数最大并发数,超出的线程会在队列中等待
- Executors.newSingleThreadExecutor:创建一个只有1个线程的 单线程池
- 一个任务一个任务执行的场景
- 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行
- Executors.newCacheThreadPool(); 创建一个可扩容的线程池
- 执行很多短期异步的小程序或者负载教轻的服务器
- 创建一个可缓存线程池,如果线程长度超过处理需要,可灵活回收空闲线程,如无可回收,则新建新线程
- Executors.newScheduledThreadPool(int corePoolSize):线程池支持定时以及周期性执行任务,创建一个corePoolSize为传入参数,最大线程数为整形的最大数的线程池
具体使用,首先我们需要使用Executors工具类,进行创建线程池,这里创建了一个拥有5个线程的线程池
// 一池5个处理线程(用池化技术,一定要记得关闭)
ExecutorService threadPool = Executors.newFixedThreadPool(5);
// 创建一个只有一个线程的线程池
ExecutorService threadPool = Executors.newSingleThreadExecutor();
// 创建一个拥有N个线程的线程池,根据调度创建合适的线程
ExecutorService threadPool = Executors.newCacheThreadPool();
9.3 线程池七大参数
1.corePoolSize :核心线程数
2.maximunPoolSize:最大线程数
3.keepAliveATime:非核心线程的空闲存活时间
4.Unit:keepAliveATime的时间单位
5.workQueue:阻塞队列,存放被提交但未被执行的任务(银行候客区)
- LinkedBlockingQueue:链表阻塞队列
- SynchronousBlockingQueue:同步阻塞队列
6.threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程池 一般用默认即可
7.handler:拒绝策略,表示当队列满了并且工作线程大于线程池的最大线程数(maximumPoolSize3)时,如何来拒绝请求执行的Runnable的策略
拒绝策略
以下所有拒绝策略都实现了RejectedExecutionHandler接口
- AbortPolicy:默认,直接抛出RejectedExcutionException异常,阻止系统正常运行
- DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常,如果允许任务丢失,这是一种好方案
- CallerRunsPolicy:该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者
- DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务
9.4 线程池底层工作原理
线程池运行架构图
文字说明:
1.在创建了线程池后,等待提交过来的任务请求。
2.当调用execute()方法添加一个任务请求是,线程池会做出如下判断:
-
如果正在运行的线程数小于核心线程数,马上会创建核心线程运行任务
-
如果正在运行的线程数大于等于核心线程数,任务会放到阻塞队列等待
-
如果这个时候阻塞队列也满了,又来了任务,会判断当前线程数是否大于最大线程数,如果小于最大线程数,会创建非核心线程来运行任务,如果达到了最大线程数,则会启动拒绝策略。
3.当一个非核心线程空闲时,会判断他的空闲时间是否达到keepAliveTime,如果达到就会被销毁。
为什么不用默认创建的线程池?
- 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
- 使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题,如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题
- 线程池不允许使用Executors去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
- Executors返回的线程池对象弊端如下:
- FixedThreadPool和SingleThreadPool:
- 运行的请求队列长度为:Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM
- CacheThreadPool和ScheduledThreadPool
- 运行的请求队列长度为:Integer.MAX_VALUE,线程数上限太大导致oom
- FixedThreadPool和SingleThreadPool:
- Executors返回的线程池对象弊端如下:
线程池的合理参数
生产环境中如何配置 corePoolSize 和 maximumPoolSize
这个是根据具体业务来配置的,分为CPU密集型和IO密集型
- CPU密集型
CPU密集的意思是该任务需要大量的运算,而没有阻塞,CPU一直全速运行
CPU密集任务只有在真正的多核CPU上才可能得到加速(通过多线程)
而在单核CPU上,无论你开几个模拟的多线程该任务都不可能得到加速,因为CPU总的运算能力就那些
CPU密集型任务配置尽可能少的线程数量:
一般公式:CPU核数 + 1个线程数
- IO密集型
由于IO密集型任务线程并不是一直在执行任务,则可能多的线程,如 CPU核数 * 2
IO密集型,即该任务需要大量的IO操作,即大量的阻塞
在单线程上运行IO密集型的任务会导致浪费大量的CPU运算能力花费在等待上
所以IO密集型任务中使用多线程可以大大的加速程序的运行,即使在单核CPU上,这种加速主要就是利用了被浪费掉的阻塞时间。
IO密集时,大部分线程都被阻塞,故需要多配置线程数:
参考公式:CPU核数 / (1 - 阻塞系数) 阻塞系数在0.8 ~ 0.9左右
例如:8核CPU:8/ (1 - 0.9) = 80个线程数
10.死锁编码及定位分析
概念
死锁是指两个或多个以上的进程在执行过程中,因争夺资源而造成一种互相等待的现象,若无外力干涉那他们都将无法推进下去。如果资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。
死锁代码
我们创建了一个资源类,然后让两个线程分别持有自己的锁,同时在尝试获取别人的,就会出现死锁现象
/**
* 死锁小Demo
* 死锁是指两个或多个以上的进程在执行过程中,
* 因争夺资源而造成一种互相等待的现象,
* 若无外力干涉那他们都将无法推进下去
*/
import java.util.concurrent.TimeUnit;
/**
* 资源类
*/
class HoldLockThread implements Runnable{
private String lockA;
private String lockB;
// 持有自己的锁,还想得到别人的锁
public HoldLockThread(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}
@Override
public void run() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "\t 自己持有" + lockA + "\t 尝试获取:" + lockB);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "\t 自己持有" + lockB + "\t 尝试获取:" + lockA);
}
}
}
}
public class DeadLockDemo {
public static void main(String[] args) {
String lockA = "lockA";
String lockB = "lockB";
new Thread(new HoldLockThread(lockA, lockB), "t1").start();
new Thread(new HoldLockThread(lockB, lockA), "t2").start();
}
}
运行结果,main线程无法结束
t1 自己持有lockA 尝试获取:lockB
t2 自己持有lockB 尝试获取:lockA
如何排查死锁
当我们出现死锁的时候,首先需要使用jps命令查看运行的程序
jps -l
我们能看到DeadLockDemo这个类,一直在运行
在使用jstack查看堆栈信息
jstack 7560 # 后面参数是 jps输出的该类的pid
得到的结果
Found one Java-level deadlock:
=============================
"t2":
waiting to lock monitor 0x000000001cfc0de8 (object 0x000000076b696e80, a java.lang.String),
which is held by "t1"
"t1":
waiting to lock monitor 0x000000001cfc3728 (object 0x000000076b696eb8, a java.lang.String),
which is held by "t2"
Java stack information for the threads listed above:
===================================================
"t2":
at com.moxi.interview.study.Lock.HoldLockThread.run(DeadLockDemo.java:42)
- waiting to lock <0x000000076b696e80> (a java.lang.String)
- locked <0x000000076b696eb8> (a java.lang.String)
at java.lang.Thread.run(Thread.java:745)
"t1":
at com.moxi.interview.study.Lock.HoldLockThread.run(DeadLockDemo.java:42)
- waiting to lock <0x000000076b696eb8> (a java.lang.String)
- locked <0x000000076b696e80> (a java.lang.String)
at java.lang.Thread.run(Thread.java:745)
Found 1 deadlock.
通过查看最后一行,我们看到 Found 1 deadlock,即存在一个死锁
11.ThreadLocal
ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量
内部结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NKzegGOs-1655129054686)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220421205005256.png)]
每个线程内部维护了一个ThreadLocalMap,这个Map内部维护了一个Entry数组,Key是ThreadLocal实例,value是要存储的当前线程的变量
set方法:存放线程变量
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
get方法:取出线程变量
ublic T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
remove方法:删除线程变量
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
注意:如果不手动remove会产生内存泄漏的风险
内存泄漏相关概念
Memory overflow:内存溢出,没有足够的内存提供申请者使用。
Memory leak:内存泄漏是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统溃等严重后果。I内存泄漏的堆积终将导致内存溢出。
导致内存泄漏最主要的原因是ThreadLocalMap的key:ThreadLocal是弱引用,而value是强引用
可以看见构造方法中只有key创建使用了Super调用了父类构造方法,创建的对象是弱引用,而value没有使用父类构造方法,是强引用
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-092GrK2l-1655129054687)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220525164158755.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CS7wCZBF-1655129054688)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220421205208536.png)]
因为ThreadLocal是弱引用,所以发生GC时,ThreadLocal一定会被回收,即ThreadLocalMap中的key=null
但是只要当前线程存活,就会一直持有对ThreadLocalMap的强引用,ThreadLocalMap也持有对value的强引用,即存在引用链:threadRef->currentThread->threadLocalMap->entry-> value(前提是当前线程和这个entry没有被手动删除)
所以就会出现key值找不到,但是value还继续存在的情况,由于key值被GC删除,这个value就永远访问不到了,导致这个value对应的内存泄漏
12.AQS
AQS是AbustactQueuedSynchronizer的简称,抽象队列同步器
使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore。
底层就是CLH队列加上volatile修饰的 state变量,state通过cas来不断改变状态,成功则获取锁成功,失败则进入等待队列,同时等待被唤醒
AQS内部维护了一个CLH队列来管理锁。线程会首先尝试获取锁,如果失败就将当前线程及等待状态等信息包装成一个node节点加入到同步队列sync queue里。 接着会不断的循环尝试获取锁,条件是当前节点为head的直接后继才会尝试。如果失败就会阻塞自己直到自己被唤醒。而当持有锁的线程释放锁的时候,会唤醒队列中的后继线程
13.synchronized底层Monitor
Monitor被翻译为监视器或者管程,是由操作系统提供的
每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头中的MarkWord中就被设置指向Monitor对象的指针
将对象的MarkWrod对象置位Monitor指针对应的字节码指令:monitorenter
将对象的MarkWord重置(运行完Synchronized修饰的方法或代码块)对应的字节码指令:monitorexit
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TaHPw6Pk-1655129054688)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220525190040159.png)]
- 刚开始时,Monitor中的Owner为null
- 当Thread-2执行Synchronized(obj)就会再obj对象的Markword字段中设置指向Monitor对象的指针,并且将Monitor的owner(所有者)字段设置为Thread-2,Monitor只能有一个owner
- 在Thread-2上锁的过程中,如果Thread-3,Thread-4也来执行synchronized(obj),就会进入EntryList BLOCKED(阻塞队列)中等待
- Thread-2执行完synchronized(obj)所修饰的方法或代码块中的内容后,会唤醒EntryList中等待的线程来竞争锁,竞争是非公平的
- 途中WaitSet中的Thread-0,Thread-1是之前获得过锁,之后调用过wait()方法,等待notify()方法的线程
14.synchronized锁升级
jdk1.6之后对sync进行了性能优化。jvm引入了偏向锁和轻量级锁,
从此以后锁的状态就有了四种:无锁、偏向锁、轻量级锁、重量级锁
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YHqG486N-1655129054689)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220422214350161.png)]
JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
偏向锁
Synchronized会首先使用偏向锁
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DUg0Gnea-1655129054690)(C:\Users\lenovo\AppData\Roaming\Typora\typora-user-images\image-20220525185229344.png)]
原理:当Thread-1访问Synchronized(obj)代码块时,会在obj对象头中的MarkWord里面添加Thread-1的线程ID,并且也会在栈帧中记录Thread-1的ID,以后某个线程想再次获取这个锁时,需要比较当前线程的ThreadID和obj对象头中的ThreadID是否一致,
如果一致(比如还是Thread-1线程获取锁),则无需CAS来获取锁。如果不一致(比如Thread-2想要获取obj锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看jobj对象头中记录的ThreadID对应的线程(Thread-1)是否存活,
如果没有存活,锁对象会被重置为无锁状态,其他线程可以获得该锁对象将其设置为偏向锁;如果存活,那么立刻查找该线程(Thread-1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
Thread-1获取锁时首先会把锁对象的对象头中的MarkWord复制一份到Thread-1的栈帧中,复制完成后再使用CAS把对象头中的内容替换成Thread-1中存储复制MarkWord的地址
如果在Thread-1复制对象头信息的同时(在Thread-1CAS之前),Thread-2也想获取锁,已经复制了锁对象头信息到Thread-2的栈帧中。但是在Thread-2下一步进行CAS的时候发现Thread-1已经把锁对象头中的信息替换了,Thread-2此次CAS失败,之后线程2不是进入阻塞状态,而是不断CAS自旋来尝试获取锁
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转