JUC 并发编程(一)
吐槽
学不完的技术🤮
CompletableFuture
Future
Future接口(FutureTask实现类)定义了操作异步任务执行一些方法,如获取异步任务的执行结果,取消任务的执行,判断任务是否被取消,判断任务执行是否完毕等。比如主线程让一个子线程去执行任务,子线程可能比较耗时,启动子线程开始执行任务后,主线程就去做其他事情了,忙其他事情或者先执行完,过了一会才去获取子任务的执行结果或变更的任务状态
Future是Java5新加的一个接口,它提供了一种异步并行计算的功能。如果主线程需要执行一个很耗时的计算任务,我们就可以通过future把这个任务放到异步线程中执行。主线程继续处理其他任务或者先行结束,再通过Future获取计算结果。主要是为了异步多线程任务执行且返回有结果。三个特点:多线程/有返回/异步任务
Future优缺点
CompletableFuture为什么会出现
CompletionStage
- CompletionStage代表异步计算过程中的某一个阶段,一个阶段完成以后可能会触发另外一个阶段
- 一个阶段的计算执行可以是一个Function,Consumer或者Runnable。比如: stage.thenApply(x -> square(x)).thenAccept(x -> System.out.print(x)).thenRun(() -> System.out.println())
- 一个阶段的执行可能是被单个阶段的完成触发,也可能是多个阶段一起触发
代表异步计算过程中的某一个阶段,一个阶段完成以后可能会触发另外一个阶段,有些类似Linux系统的管道分隔符传参数
runAsync和supplyAsync
- public static CompletableFuture
runAsync(Runnable runnable) - public static CompletableFuture
runAsync(Runnable runnable, Executor executor) - public static
CompletableFuture supplyAsync(Supplier supplier) - public static
CompletableFuture supplyAsync(Supplier supplier, Executor executor)
runAsync方法没有返回值supplyAsync有返回值。没有指定Executor的方法,直接使用默认的ForkJoinPool.commonPool()作为它的线程异步代码,如果指定线程池,则使用我们自定义的或者特别指定的线程池执行异步代码
CompletableFuture的优点
常用方法
- 获得结果和触发计算
- get()
- get(long timeout, TimeUnit unit)
- join()
- getNow(T valueIfAbsent) 如何程序还没有执行完成则返回传入的参数valueIfAbsent的值
- complete(T value) 是否打断get方法立即返回括号值
- 对计算结果进行处理
- thenApply 由于存在依赖关系(当前步骤错,不走下一步),当前步骤有异常的话就叫停
- handle 有异常也可以往下走,根据带的异常参数可以进一步处理
- 对计算结果进行消费
- thenAccept 接收任务的处理结果,并消费处理,无返回结果
- thenRun 任务A执行完执行B,并且B不需要A的结果
- 对计算速度选用
- applyToEither 谁快用谁
- 对计算结果进行合并
- thenCombine 两个CompletableFuture任务都完成后,最终能把两个任务的结果一起交给thenCombine来处理,先完成的先等着,等待其他分支任务
线程池运行选择
- 没有传入自定义线程池,都用默认线程池ForkJoinPool
- 传入了自定义线程池
- 如果你执行第一个任务的时候,传入了一个自定义线程池
- 调用thenRun方法执行第二个任务时,则第二个任务共用第一个任务线程池
- 调用thenRunAsync执行第二个任务时,则第一个任务使用的是你自己传入的线程池,第二个任务使用的是ForkJoin线程池
- 备注
- 有可能处理太快,系统优化切换原则,直接食用main线程处理
- 其他如: thenAccept和thenAcceptAsync,thenApply和thenApplyAsync等,它们之间的区别也是同理
多线程锁
乐观锁和悲观锁
- 悲观锁: 认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。synchronized关键字和Lock的实现都是悲观锁。适合写操作多的场景,先加锁可以保证写操作时数据正确
- 乐观锁: 认为自己在使用数据时不会有别的线程修改数据或资源,所以不会添加锁。在Java中是通过使用无锁编程来实现,只是在更新数据时的时候去判断,之前有没有比的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功加入; 如果这个数据已经被其他线程更新,则根据不同的实现方式执行不同的操作,比如放弃修改,充实抢锁等。判断规则: 1. 版本号机制Version,2. 最常用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升
八锁案例
现在有两个线程和一个实体类,代码如下:
1 | public synchronized void sendEmail() { |
1 | public synchronized void sendSms() { |
1 | new Thread(() -> { |
1 | new Thread(() -> { |
- 标准访问有A B两个线程,请问先打印邮件还是短信
- sendEmail方法中加入暂停4秒钟,请问先打印邮件还是短信
一个对象里面如果有多个synchronized方法,某一个时刻,只要一个线程去调用其中一个synchronized方法了,其他的线程都只能等待,换句话说,某一个时刻内,只能有唯一的一个线程去访问这些synchronized方法,锁的是当前对象this,被锁定后,其它的线程都很难进入到当前对象其它的synchronized方法
- 添加一个普通的hello方法,请问先打印邮件还是hello
- 有两部手机,请问先打印邮件还是短信
加个普通方法后和同步锁无关。换成两个对象后,不是同一把锁了,情况立刻变化
- 有两个静态同步方法,有一部手机,请问先打印邮件还是短信
- 有两个静态同步方法,有两部手机,请问先打印邮件还是短信
都换成静态同步方法后,情况又变化,三种synchronized锁的内容有一些差别: 1. 对于普通同步方法,锁的是当前实例对象,通常指this,具体的一部手机,所有的普通同步方法用的都是同一把锁 -> 实例对象本身 2. 对于静态同步方法,锁的是当前类的Class对象,如Phone.class唯一的一个模版 3. 对于同步方法块,锁的是synchronized括号内的对象
- 有一个静态同步方法,有一个普通同步方法,有一部手机,请问先打印邮件还是短信
- 有一个静态同步方法,有一个普通同步方法,有两部手机,请问先打印邮件还是短信
当一个线程试图访问同步代码时它首先必须得到锁,正常退出或抛出异常时必须释放锁。
所有的普通同步方法用的都是同一把锁 -> 实例对象本身,就是new出来的具体实例对象本身,本类this也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取该锁的方法释放锁后才能获取锁
所有的静态同步方法用的也是同一把锁 -> 类对象本身,就是我们说过的唯一模版Class。具体实例对象this和唯一模版Class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞争条件的,但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁
公平锁和非公平锁
- 公平锁: 是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买后来的人在队尾排着,这是公平的
1 | ReentrantLock lock = new ReentrantLock(true); |
- 非公平锁: 是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转或者饥饿的状态(某个线程一直得不到锁)
1 | ReentrantLock lock = new ReentrantLock(); |
为什么会有公平锁/非公平锁的设计?为什么默认非公平?
什么时候用公平?什么时候用非公平?
可重入锁(递归锁)
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提是锁对象是同一个对象),不会因为之前已经获取过还没释放而阻塞。如果是一个有synchronized修饰的递归调用方法,程序第二次进入被自己阻塞了岂不是天大的笑话,出现了作茧自缚,所以Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定成都避免死锁
Synchronized重入的实现原理
死锁及排查
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉那它们豆浆无法推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁
代码演示
1 | new Thread(() -> { |
1 | new Thread(() -> { |
排查
- 纯命令
- jps -l
- jstack 进程编号
- 图形化
- jconsole
中断机制
什么是中断机制
中断方法
方法 | 作用 |
---|---|
void interrupt |
实例方法,Just to set interrupt flag。仅仅是设置线程的中断状态为true,发起一个协商而不会立刻停止线程 |
static interrupt |
静态方法,Thread.interrupt(),判断当前线程是否被中断并清除当前中断状态。这个方法只做了两件事: 1. 返回当前线程的中断状态,测试当前线程是否已被中断 2. 将当前线程的中断状态清零并重新设置为false清除线程中断状态 |
boolean isInterrupt |
实例方法,判断当前线程是否被中断(通过检查中断标识位) |
具体来说,当对一个线程调用interrupt()时,如果该线程处于正常活动状态,那么会将该线程的中断标识设置为true,仅此而已。被设置中断标识的线程将继续正常运行不受影响,所以,interrupt()并不能真正的中断线程,需要被调用的线程自己进行配合才行。如果线程处于被阻塞状态(例如处于sleep wait join等状态),在别的线程中调用当前线程对象的interrupt方法,那么线程将立即退出被阻塞状态,并抛出一个interruptedException异常
大厂面试题
如何停止中断运行中的线程?
LockSupport
线程等待和唤醒的方法:
- 使用Object中的wait方法让线程等待,使用Object中的notify方法唤醒线程
- 使用JUC包中的Condition的await方法让线程等待,使用signal方法唤醒线程
- LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程
为什么可以突破wait/notify的原有调用顺序?
为什么唤醒两次后阻塞两次,但最终结果还会阻塞线程?
JMM
JMM(Java内存Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量吧的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性,可见性和有序性展开的。JMM的关键技术点都是围绕多线程的原子性,可见性和有序性展开的。通过JMM来实现线程和主内存之间的抽象关系。屏蔽各个硬件平台和操作系统的内存访问差异以实现让Java程序在各种平台下都能达到一致的内存访问效果
- 可见性
当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有变量都存储在主内存中
- 可见性
当一个线程修改了某一个共享变量的值,其他线程是否能够立即哦知道该变更,系统主内存共享变量数据修改被写入的时机是不确定的,多线程并发下很有可能出现脏读,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程中变量的值传递均需要通过主内存来完成
- 有序性
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提升性能,编译器和处理器通常会对指令序列进行重新排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。JVM能根据处理器特性(CPU多级缓存系统,多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能,。但是,指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生脏读),简单说,两行以上不相干的代码执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化
happens-before
如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。我们没有时时,处处,次次添加volatile和synchronized来完成程序,这是因为Java语言中JMM原则下有一个先行发生(Happens-Before)的原则限制和规矩,这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排序在第二个操作之前。两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法
happens-before之8条
- 次序规则
一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作。器哪一个操作的结果可以被后续的操作获取
- 锁定规则
一个unLock操作先行发生于后面(时间上的先后)对同一个锁的lock操作
- volatile变量规则
对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的后面同样是时间上的先后
- 传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则(Start)
线程对象的start方法先行发生于次线程的每一个动作
- 线程中断规则(Interruption)
对线程interrupt方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过Thread.interrupted()检测到是否发生中断,也就是说你要先调用interrupt方法设置过中断标志位,我才能检测到中断发送
- 线程终止规则(Termination)
线程中的所有操作都先行发生于对此线程的终于检测我们可以通过isAlive等手段检测线程是否已经终止执行
- 对象终结规则(Finalizer)
一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize方法的开始,也就是对象没有完成初始化之前是不能调用finalize方法
在Java语言里面,Happens-Before的语义本质上是一种可见性,A Happens-Before B意味着A发生过的事情对B来说是可见的,无论A事件和B事件是否发生在同一个线程里。JMM的设计分为两部分: 一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解happens-before规则,就可以编写并发安全的程序了。另一部分是针对JVM实现的,为了尽可能的对编译器和处理器做约束从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。我们只需要关注前者就好了,也就是理解happens-before规则即可,其他繁杂的内容有JMM规范结合操作系统给我们搞定,我们只需要写好代码即可
volatile
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置位无效,重新回到主内存中读取最新共享变量
- 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取
内存屏障
内存屏障(也称内存栅栏,屏障指令等,是一类同步屏障指令,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性
- 内存屏障之前的所有写操作都要回写到主内存
- 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)
写屏障(Store Memory Barrier): 告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存。也就是说当看到Store屏障指令,就必须把该指令之前所有写入指令执行完毕才能继续往下执行
读屏障(load Memory Barrier): 处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在Load屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 保证Load1的读取操作在Load2及后续读取操作之前执行 |
StoreStore | Store1; StoreStore; Store2 | 在Store2及其后的写操作执行前,保证Store1的写操作已刷新到主内存 |
LoadStore | Load1; LoadStore; Store2 | 在Store2及其后的写操作执行前,保证Load1的读操作已读取结束 |
StoreLoad | Store1; StoreLoad; Load2 | 保证Store1的写操作已刷新到主内存之后,Load2及其后的读操作才能执行 |
读写过程
- read: 作用于主内存,将变量的值从女主内存传输到工作内存,主内存到工作内存
- load: 作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
- use: 作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
- assign: 作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
- store: 作用于工作内存,将赋值完毕的工作变量的值写回给主内存
- write: 作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述6条只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令
- lock: 作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程
- unlock: 作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
不保证原子性
对于volatile变量不具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是多线程环境下,数据计算和数据赋值可能多次出现,若数据加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存最新值,操作出现写丢失问题。即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致,由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。所以volatile变量不适合参与到依赖当前值的运算,如i = i++之类的,那么依赖可见性的特点volatile可以用在哪些地方呢?通常volatile用做保存某个状态的boolean值or int值
使用场景
- 单一赋值可以,但是含复合运算赋值不可以(i++之类)
- 状态标志,判断业务是否结束
- 开销较低地读,写锁策略
- DCL双端锁的发布
CAS
Compare And Swap 的缩写,中文翻译成比较并交换,实现并发算法时常用到的一种技术。它包含三个操作数: 内存位置,预期原值以及更新值。执行CAS操作的时候,将内存位置的值与预期原值比较。执行CAS操作的时候,将内存位置的值与预期原值比较:
- 如果相匹配,那么处理器会自动将该位置值更新为新值
- 如果不匹配,处理器不做任何操作,多个线程同时执行CAS操作只有一个会成功
CAS有三个操作数,位置内存值V,旧的内存值A,要修改的更新值B。当且仅当旧的预期值A和内存V相同时,将内存值V修改为B,否则什么都不做或重来,当它重来重试的这种行为称为自旋
硬件级别的保证
CAS时JDK提供的非阻塞原子性操作,它通过硬件保证了比较-更新的原子性它是非阻塞的且自身具有原子性,也就是说这玩意效率更高且通过硬件保证,说明这玩意更可靠。CAS是一条CPU的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令compxchg。执行compxchg指令的时候,会判断当前系统是否为多核系统,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功之后会执行CAS操作,也就是说CAS的原子性实际上是CPU实现独占的,比起用synchronized重量级锁,这里的排他时间要短很多,所以在多线程情况下性能会比较好
源码解析
上面是那个方法都是类似的,主要对4个参数做一下说明。
- var1:表示要操作的对象
- var2: 表示要操作对象中属性地址的偏移量
- var3: 表示需要修改数据的期望的值
- var5/var6: 表示需要修改的新值
Unsafe
Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,给予该类可以直接操作特定内存的数据。Unsafe类存在与sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法。注意Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都是直接调用系统底层资源执行相应任务。变量valueOffset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存便宜地址获取数据的。变量value用volatile修饰,保证了多想成之间的内存可见性
我们知道i++是线程不安全的,那么atomicInteger.getAndIncrement()是如何保证的呢?
底层汇编源码分析
1 | new AtomicInteger().getAndIncrement(); |
假设线程A和线程B两个线程同时执行getAndAddInt操作(分别泡在不同CPU上):
- AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据JMM模型线程A和线程B各自持有一份值为3的value的副本分别到各自的工作内存
- 线程A通过getIntVolatile(var1, var2)拿到value值3,这时线程A被挂起
- 线程B也通过getIntVolatile(var1, var2)方法获取到value值3,此时线程B没有被挂起并执行compareAndSwapInt方法,比较内存值也为3,成功修改为4,线程B打完收工,一切OK
- 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的值数字3和主内存的值数字4不一致,说明该值已经被其他线程抢先修改过了,那么A线程本次修改失败,只能重新读取重新来一遍了
- 线程A重新获取value值,因为变量value被volatile修饰,所以其他线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功
AtomicReference
1 | AtomicReference<User> atomicReference = new AtomicReference<>(); |
自旋锁(spinlock)
CAS是实现自旋锁的基础,CAS利用CPU指令保证了操作的原子性,以达到锁的效果,至于自旋,看字面意思也很明显,自己旋转。是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
1 | Thread thread = Thread.currentThread(); |
1 | Thread thread = Thread.currentThread(); |
CAS缺点
- 循环时间长
getAndAddInt方法执行时,有个do while,如果CAS失败,会一直进行尝试。如果CAS长时间不成功,可能会给CPU带来很大的开销
- ABA问题
CAS算法实现一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换,那么这这个时间差内会导致数据的变化。比如说一个线程1从内存位置V中取出A,这时候另一个线程2也从内存中取出A,并且线程2进行了一些操作将值变成了B,然后线程2又将V位置的数据变成A,这时候线程1进行CAS操作发现内存中仍然是A,预期OK,然后线程1操作成功。尽管线程1的CAS操作成功,但是不代表这个过程就是没有问题的
AtomicStampedReference
1 | AtomicStampedReference<Book> stampedReference = new AtomicStampedReference<>(book, 1); |
ABA问题代码演示
1 | new Thread(() -> { |
1 | new Thread(() -> { |