并发编程知识点
2022-05-12 17:54:19 24 举报
AI智能生成
Java并发编程知识点及简要描述
作者其他创作
大纲/内容
进程是一次代码执行的过程,是CPU资源分配的最小单位,线程是CPU调度的最小单位。
1个进程中至少有1个线程,同一个进程中的所有线程共享这个进程获得的CPU资源。
进程与线程
并发:多个事件在短期内交替执行,但由于切换的非常快,我们主观的认为是同时执行。
并行:多个事件同时执行(并行的条件一定是有多个CPU或多个CPU核心)。
并行与并发
充分利用CPU资源,提升程序的性能和执行效率。
优点
1.创建和销毁线程以及线程的上下文切换会消耗额外的资源。
2.可能会引发线程安全问题。
缺点
并发编程优缺点
CPU调度线程时,不同线程不是一次性将任务执行完,而是一直在切换运行的,每个线程在一次切换中运行的时间,就是CPU时间片。
时间片
CPU执行任务依赖的环境,如寄存器、程序计数。
上下文用来保存程序执行的状态。
上下文
线程失去时间片时,保存程序执行状态,下次切换回来时,读取之前保存的状态。
线程保存状态或读取状态的过程,叫做上下文切换。
上下文切换
1. 尽可能少的创建线程,线程少切换就少。
2. 在保证线程安全的前提下,尽可能的不加锁。
3. 尽可能的使用CAS代替重量级锁。
如何减少切换
CPU上下文切换
线程在操作系统中有两种状态,为了操作系统的安全,对操作系统硬件相关的操作是由内核态的线程来处理的,其它的用户指令由用户态的线程执行。
一个线程可以在内核态与用户态之间切换,切换是涉及到变量的传递与保存及上下文切换等,会消耗额外的资源
概念
Java中没有实现线程,使用的是操作系统的线程
线程的创建、销毁、阻塞、唤醒都会涉及到内核态与用户态的切换
与Java的关系
内核态与用户态
某些耗时的操作可以通过多线程异步执行,避免这些耗时的操作阻塞主流程。
异步
将一个耗时的操作拆分成多个子操作,每个子操作都是一个单独的线程来执行,最终将结果汇总在一起。
并行
使用场景
基础知识
1. 集成Thread类
2. 实现Runnable接口
常规方式
1. 实现Callable接口,作为参数创建FutureTask。
2. 使用线程池创建线程。
特殊方式(底层还是Runable)
Java创建线程的方式
Java中的普通函数,在线程对象中用来被操作系统回调,执行需要并发处理的逻辑。
run方法
native方法,调用底层的c++函数,在操作系统中创建并启动线程,等待CPU调度。
start方法
run()和start()
初始状态:线程对象实例化,但还没有调用start()
NEW
运行状态:调用start()进入这个状态,包括线程在操作系统中的就绪状态和运行状态
RUNNABLE
阻塞状态:线程被synchronized阻塞
BLOCKED
等待状态:线程在这个状态下需要其它线程对它进行唤醒或中断操作才能退出这个状态。
Object.wait()
thread.join()
LockSupport.park()
进入:
Object.notify() / notifyAll()
LockSupport.unPark(thread)
退出:
WAITING
超时等待状态:拥有等待状态的特性,同时会在等待超时后自动退出这个状态。
Thread.sleep(long)
Object.wait(long)
thread.join(long)
LockSupport.parkNanos(long) / parkUtil(long)
超时后自动退出
TIMEDWAITING
终止状态:线程执行完毕后切换到这个状态。
TERMINATED
状态及转换条件
1. 使用stop()强行中断,是一个过时方法,现在已经不用了。
2. 对于线程中使用while循环的情况,可以在循环条件中使用中断标识,由其他线程修改中断标识来控制。
3. 使用interrupt(),由线程自行决定是否中断。
中断方式
不管线程是什么状态,是否执行完毕都强行中断,可能会导致数据不一致的情况出现。
stop()的缺陷
作为中断标识,中断循环。
用于打破阻塞或等待(常用)
interrupt的使用
线程对象调用,不重置中断状态,查看指定线程的中断状态。
thread.isInterrupted()
Thread类调用,会重置中断状态,查看当前线程的中断状态。
Thread.interrupted()
查看线程是否中断
线程的中断
线程的生命周期
多个线程对共享资源进行写操作时,导致写入后的共享资源结果与预期结果不符的情况。
原子性:一个操作不可分割,要么同时成功,要么同时失败。
可见性:一个线程对共享变量做了写操作,另一个线程可以感知到。
有序性:程序执行的顺序按照代码的顺序执行。
并发编程的三要素
三要素任意一个不满足都会导致线程安全问题
什么是线程安全问题
可以将非原子性的代码放到同步代码块,每次只有一个线程可以进入同步代码块,即加锁。
保证原子性
加锁的代码段对共享变量的操作本身是具有可见性的,线程在进入同步块时会从内存中获取共享变量的最新数据,并且在退出时将工作内存中的缓存值同步到主内存中。
使用volatile修饰共享变量,volatile底层通过lock的汇编指令使用CPU的缓存一致性协议,使一个线程对共享变量的修改立即从缓存行刷新到内存中,并通知其他线程将持有同一个共享变量的缓存行置为过期状态。其他线程在使用这个共享变量时,会从内存中重新获取最新值。
遵循Happens-before规则的代码天然具有可见性(这个可见性是由Java编译器提供的保证)
保证可见性
使用volatile修饰共享变量,通过编译器的内存屏障来禁用指令重排序优化。
保证有序性
如何保证线程安全
多个线程对共享变量只读不写
线程只对未线程逃逸局部变量做修改,变量是私有的自然不会有线程安全问题
场景
方法逃逸:由方法形参传入到其它方法。栈帧都是压在同一个栈中,不会有影响
线程逃逸:将引用数据类型的局部变量赋值给共享变量,让其它线程可以访问到。这种可能会导致线程安全问题
逃逸分析
绝对线程安全的代码
线程安全问题
保证互斥同步的基本手段
作用
同一对象实例调用时会互斥
实例方法
无论怎么调用都会互斥
静态方法
修饰方法
由传入的锁对象决定,如果是同一对象就会互斥。这种方式可以更加灵活的指定锁粒度
修饰代码块
使用方式
synchronized锁的是monitor对象,JVM为每个Java对象都分配了一个monitor对象,所以每个Java对象都可以作为锁对象
synchronized锁的到底是什么
C++实现的对象,作为同步互斥锁的监视器
Java中每个对象都有一个对象头,在对象头的Mark Word中有指向monitor的指针
通过owner这个关键属性,指向当前持有锁的线程,同时,一把锁最多只能由1个线程持有
如果持有锁的线程再次进入同步块,会将重入次数(recursions)+1,释放锁时将重入次数递减,直到递减为0时才会完全释放锁
如何实现互斥
这些线程会在monitor的阻塞队列(EntryList)中阻塞,等待当前持有锁的线程在释放锁后唤醒,唤醒后再次抢锁
未抢到锁的线程去哪了
wait会释放当前线程持有的锁资源,加入到等待队列(waitSet)中,并唤醒阻塞队列尾的线程
notify会唤醒waitSet中的队列尾的线程,将它加入到阻塞队列中
notifyAll是依次唤醒waitSet中的线程,将它们加入到阻塞队列中
wait和notify
monitor
如果一个线程正常运行过程中操作的对象,不可能被其它线程访问到。这种操作即使加了锁,也会被编译器认为不必要存在而被消除掉
锁消除
对于正常的逻辑而言,锁的粒度是越细越好,但在某些场景中,使用更粗的粒度能达到同样的效果并且消耗更少,这时编译器就会做锁粗化的优化
锁粗化
线程的阻塞和唤醒涉及到内核态与用户态的切换,而有些阻塞时间很短,没有必要去浪费这部分资源,就会考虑不让线程阻塞的方式
自旋锁就是线程通过CAS+循环,不断的去尝试获取锁,使线程处于一个活跃的状态
为什么会有自旋锁
如果线程长时间活跃又一直没有抢到锁,这种情况下反而比阻塞消耗的资源更多
自旋锁的缺点
为了弥补自旋锁的缺点的,我们一般会定义自旋的次数,如果超过某些次数就让线程阻塞,synchronized默认是10次
不再是固定的次数,而是根据上一次自旋的次数来确定下一次自旋的次数,比如每次自旋都自旋到极限的次数,就应该考虑减少自旋让线程挂起了
上一次自旋次数多,下一次分配的自旋次数就少,反过来也是同理,由这种自适应自旋的方式,更好的节约CPU资源
自适应自旋
自旋次数
自旋锁
锁优化
是synchronized中最轻量的锁,严格的说偏向锁并不是锁,只是在对象头中记录了一下当前持有monitor对象的线程Id
有些同步代码块的线程竞争很小,甚至可能大多数情况都是一个线程在不断的持有锁和释放锁,这时候使用偏向锁就几乎没有加锁和解锁的消耗
偏向锁在存在线程竞争时就会升级,而偏向锁的撤销需要等到全局安全点,容易造成STW,使用户体验变差
因为偏向锁的缺点,JDK8以后都是默认禁用偏向锁的,之前的版本可以通过-XX:-UseBiasedLocking禁用
禁用
偏向锁
轻量级锁其实就是锁优化中的自旋锁,在存在少量线程竞争的情况下,使用CAS+循环的方式来代替阻塞
可以避免线程在内核态与用户态之间切换,节约资源
线程长时间持有锁或者多个线程竞争激烈的情况下,导致CPU空转消耗的资源大于阻塞消耗的资源
轻量级锁
即使用monitor实现的监视器锁
重量级锁
锁类型
1. 锁对象没有偏向就使用CAS偏向当前进入同步块的线程
2. 锁对象偏向当前线程自己,直接进入同步代码块
CAS成功则进入同步代码块
CAS失败则撤销偏向锁,升级为轻量级锁
3. 锁对象不是偏向的当前线程,则通过CAS替换MarkWord,使锁对象重新偏向为当前线程
1. 将MarkWork赋值到栈中的LockRecord中,使用CAS将MarkWord指向LockRecord
2. CAS成功则进入同步代码块
指向当前线程则进入同步代码块
没有指向当前线程则自旋一次重新做加锁操作
3. CAS失败检查是否指向当前线程
4. 自旋次数达到上限则膨胀为重量级锁
在上面的monitor中已提到
加锁过程(简述)
存在线程竞争的情况就会升级为轻量级锁
超过自旋次数会膨胀为重量级锁
存在两个以上的线程在竞争锁
触发锁升级
锁一旦升级是不可逆的
锁升级
两个或两个以上的线程互相持有对方想要抢占的锁,进入了一个循环等待的过程,就是死锁
什么是死锁
互斥:只能有一个线程持有锁
占有且等待:线程等待是不会释放锁
不可抢占:线程持有的锁,只能自己释放,不能被其它线程抢占
循环等待:多个线程互相等待对方先释放锁
出现死锁的条件
锁天然具有互斥、占有且等待、不可抢占的特性,要避免死锁只能从循环等待入手。即按照指定的顺序进行加锁和解锁
如何避免死锁
对于已经发生的死锁可以通过JPS查询Java进程号,通过Jstack进程号打印出线程信息,就可以找到是哪一行代码出现了死锁
死锁诊断
死锁
synchronized
单线程环境下,操作系统和编译器都不会对影响程序运行结果的操作进行重排序
as-if-serial
多线程环境下,程序执行的结果不会改变
happens-before
与as-if-serial的区别
开发者希望JMM尽可能的限制死操作系统,这样就可以更少的关注线程安全问题,但限制的过死又会影响操作系统的执行效率,JMM为了平衡开户者与操作系统的需求,提供了happens-before规则
核心概念
单个线程的每个操作happens-before后续操作,和as-if-serial是一回事。注意JMM允许对不影响结果的操作进行重排序
程序顺序规则
同一个锁的解锁操作对后续的加锁操作可见
解锁后会将同步块中的共享变量值从工作内存刷新到主内存,下一个获取锁的线程会将共享变量从主内存同步到它的工作内存
监视器规则
对同一个变量的volatile写,happens-before后续的读操作
通过汇编码Lock触发缓存一致性协议,使一个线程对共享变量的写操作对另一个线程可见,同时通过内存屏障,禁止volatile写与后续读操作的指令重排序
volatile规则
由A happens-before B ,B happens-before C 可以得到 A happens-before C
传递性规则
一个线程的启动,happens-before这个线程的任意后续操作
start规则
一个线程的所有操作,happens-before这个线程的join操作
join规则
六种规则
一个线程对共享变量的操作如果遵循了happens-before规则,那这个操作结果一定对另一个线程可见
结论
实现变量的线程隔离
不同过方法形参进行参数传递
1. 每个Thread类中都维护了一个ThreadLocal的内部类ThreadLocalMap,这个Map是线程私有的
2. 使用时会将TreadLocal对象作为key,需要隔离的变量作为value保存到ThreadLocalMap中
如何实现线程隔离
弱引用的对象会在下一次GC时被回收掉,使用弱引用的key在一定程度上避免了内存泄露
弱引用的key
value不使用弱引用,是因为在ThreadLocal的生命周期内,如果value被回收,可能在业务上会引起NPE
强引用的value
Entry对象
与HashMap的链式寻址法同步,ThreadLocalMap底层没有链表结构,所有的Entry都是保存为一个数组元素
hash冲突后,从冲突位置依次下标+1,直到找到还没有放置元素的位置
hash冲突
魔数的存在是为了作为key的ThreadLocal能够在数组中更加均匀的分配,减少Hash冲突
TheadLocal中维护了一个全局类变量nextHashCode,每实例化一个ThreadLocal对象,就会是这个全局类变量加上一个魔数,并将这个HashCode分配给这个新的ThreadLocal对象
魔数0x61c88647
开放寻址法
即使是key使用了弱引用,也会因为value还是强引用而导致内存泄露
泄露原因
每次使用完ThreadLocal方法后,主动调用remove()方法,清理ThreadLocalMap中key已经失效的Entry对象
解决方式
内存泄露
初始长度为16;扩容参数为长度的2/3,约为10
当前长度size >= 扩容参数 - 扩容参数 * 1/4
结论:当前长度到总长度的一半时就会扩容,新创建一个容量为之前一倍的数组
扩容
ThreadLocalMap
使子线程可以共享父线程的线程局部变量
在一个线程执行过程中创建的线程,这个被创建的线程就是正在执行过程中的线程的子线程
什么是父子线程
1. 每个Thread中都有一个inheritableThreadLocals
2. 在创建线程是可以通过CurrentThread()方法获取当前线程
3. 如果当前线程中的inheritableThreadLocals不为null,子线程就会父线程的inheritableThreadLocals作为参数,去创建inheritableThreadLocals,从而实现父线程的线程局部变量向子线程传递
如何实现的共享
由于传递是在创建新的线程时发生的,类似于线程池这种重复使用线程的方式,就有可能导致变量传递失效
传递失效的情况
InheritableThreadLocal
ThreadLocal
CAS的全称CompareAndSwap,通过一个预期值来判断当前内存中的值有没有被修改,如果没有就做修改操作
CAS又叫乐观锁
可以保证共享变量在并发条件下的原子性
CAS的概念和作用
预期值expect,用做是否做更新的判断
内存偏移量offset,用于找到变量在内存中的当前值
更新值update,用来更新变量在内存中的值
需要用到三个值
比较expect和当前内存偏移量offset的值是否相等,如果相等则将变量更新为更新至update然后返回true,如果不相等返回false
原理
1. 先通过内存偏移量获取变量在内存中的当前值,作为预期值
2. 计算更新至update
3. 调用CAS传入offset,expect,update三个值,然后判断返回值,true为修改成功,false为修改失败
操作顺序
CAS的原理
通过CAS+自旋的方式在保证对共享变量操作的原子性的情况下,避免阻塞线程导致的状态及上下文切换带来的性能消耗
1. 自旋时间过长,相对于阻塞有更大的开销
2. 只能保证单行操作的原子性,不能保证代码块的原子性
如果有一个线程在做CAS操作时,有另外一个线程修改了内存值,马上又修改回来,对于前一个线程来说,它是无法感知到的
但是ABA问题对实际的操作结果不会有影响
3. ABA问题
优缺点
悲观锁认为线程之间一定会存在竞争,所以先让发生竞争的线程做抢锁操作,只有抢到锁的线程才能进入临界区执行代码
悲观锁
乐观锁认为线程之间只有很小的可能会存在竞争,所以不会有抢锁操作,而是先执行需要执行的代码,然后再更新内存值的时候判断原始的内存值有没有被其它线程修改,根据判断的结果选择是否需要将执行结果刷新到内存
乐观锁
乐观锁与悲观锁的区别
CAS
AQS全程AbstractQueueSynchronizer,是Java中的一个并发编程框架,通过这个框架实现了一些并发编程中的实用功能
锁Lock
线程池ThreadPoolExecutor
信号量semaphore
栅栏CountdownLatch
回环屏障CyclicBarrier
实现功能
通过一个状态变量state和一个队列来实现
作为所有线程都可以访问的共享资源,有独占和共享两种模式
只允许一个线程独占,可以作为独占锁的互斥量
独占模式
允许多个线程同时访问
共享模式
state
对于没有抢占到锁资源的线程,会在这个队列中挂起,直到释放锁一次从队列头唤醒
CLH队列
实现原理
一个线程竞争锁时,会先检查是否有其它线程正在等待锁,如果有则进入队列排队
公平锁
不管什么情况,线程都会先做一次抢锁操作,没抢到才进入队列排队
非公平锁
公平锁与非公平锁
AQS
ReentrantLock是Java通过AQS框架实现的可重入互斥锁
synchronized通过monitor监视器实现的互斥
ReentrantLock通过AQS中的state变量实现互斥
实现方式
JDK6之前synchronized底层只有重量级锁,ReentrantLock的性能更好
JDK6之后,synchronized加入了轻量级锁等优化,两者的性能基本持平
性能
synchronized只支持非公平锁
ReentrantLock支持公平锁与非公平锁
synchronized的阻塞不可中断
ReentrantLock使用LockInterruptly可以使用interrupt中断阻塞
是否可中断
ReentrantLock中的Condition对象相对于synchronized的wait()使用更加灵活,且支持多个Condition条件
线程通信
与synchronized的区别
1. 使用CAS尝试获取锁,失败则进入AQS中的acquire()方法
2. 进入tryAquire()判断是否重入,是则重入次数+1,不是重入则进入addWaiter(),将当前线程加入到队列尾
3. 在addWaiter()中通过CAS+自旋去替换队列尾,直到替换成功
4. 加入到队列后就会进入acquireQueued方法,会判断自己是不是在头节点的下一个节点,如果是则尝试抢锁,如果不是则将自己挂起等待唤醒
加锁流程
1. 每次调用unlock(),都会进入tryRelease(),使重入次数减1
2. 重入次数减到0后,就会发起解锁操作,将唤醒阻塞队列中头节点的下一个节点中的线程,让它继续自旋抢锁
解锁流程
和synchronized中的wait()和notify()类似,Condition是依赖于Lock存在的
使用限制
某些任务在执行的一定程度的时候需要暂停,让其他线程处理,这时候就可以通过await()方法释放锁资源,让其他线程可以抢到锁
使用await()和signal()方法完成线程间的通信
不管当前线程重入锁的次数是多少,都会一次性释放资源
释放资源时会将当前重入次数保存起来,下次抢锁的时候直接取出来set到state中
1. 释放锁资源
线程是在ConditionWaiter的等待队列中将自己挂起的
2. 线程等待位置
线程调用signal()会从等待队列头唤醒一个线程,加入到等锁队列尾
调用signalAll()之后,会依次从等待队列头唤醒线程加入到等锁队列尾
3. 被唤醒后如何重新抢锁
Condition条件控制
ReentrantLock
1. Callable需要配合FutureTask使用
2. Callable支持父线程阻塞等待子线程执行完毕
3. Callable支持返回值和异常抛出
与Runnable的区别
Callable的阻塞和返回值都是通过FutureTask实现的,而FutureTask实现了Runnable接口
1. 重写Callable中的call()方法,这个方法是一个普通的Java方法,不会被线程回调
2. 在FutureTask中会创建一个Thread对象,通过Thread对象中的run()方法调用call()方法实现线程的调度
线程调用
1. 在FutureTask中定义了一个成员变量outCome用于接收返回值
2. 在run()方法中调用call()方法,将获取到的返回值或抛出的异常设置到outCome中
返回值实现
1. 返回值一定需要子线程执行完毕才能获取到,所以父线程调用FutureTask的get()方法时,如果子线程还没执行完,则会阻塞父线程
2. 父线程的阻塞类似于AQS,是将自己加入到一个等待队列中,然后将自己park。
3. 子线程执行完毕后,充等待队列中唤醒父线程,父线程获取成员变量outCome的值,判断是正常值就return它,异常值就抛出
返回值获取
Callable
它们都可以看做是一个包含阻塞功能的计数器,可以通过await()方法将当前线程阻塞,待计数器归零后唤醒
相同点
CountDownLatch用在一个线程等待其它多个线程执行完毕这种一等多的场景,起到fork/join的效果
CyclicBarrier用在多个线程互相等待,分别执行不同的任务,直到所有线程都执行完了才进行下一步
CountdownLatch使用AQS的共享模式,state不为0时,线程在阻塞队列中挂起
CyclicBarrier使用ReentrantLock和Condition来实现,state不为0时,线程在条件等待队列中挂起
阻塞原理
CountDownLatch不可复用
CyclicBarrier可以复用
是否复用
不同点
1. 在实例化CountDownLatch的时候,传入计数器数值,赋值给AQS的state
2. 需要阻塞的线程调用await()方法,如果当前state字段值不为0,则进入等待队列挂起
3. 执行子流程的线程,每执行完一次就调用countDown()方法,使state值减1,state减到0后就将挂起的线程唤醒
栅栏CountDownLatch
count用于当前计数
parties用于重置计数器
1. 实例化时传入计数器值,这个值会保存到parties和count两个字段中
递减后的count值不为0,则使用condition.await()将线程挂起
递减后count值为0,则使用condition.signalAll()将挂起的线程唤醒
2. 线程需要阻塞时,调用await()方法,使count值递减1
执行流程
栅栏与回环屏障
线程池中的线程使用完后可以放回到池中,可以减少创建和销毁线程的开销
实现线程复用
如果线程过多,CPU时间片切换频繁,每个线程分到的时间片就更少
无限制的创建线程还会导致服务器的线程资源耗尽
限制线程的创建数量
可以实现定时任务
其它功能
线程池作用
固定长度的线程池,每提交一个任务就会创建一个线程,直到到达线程池的长度
newFixedThreadPool
无限长度的线程池,每提交一个任务就创建一个线程,在线程池空闲时又会回收一部分线程
newCachedThreadPool
支持定时任务的线程池
newScheduledThreadPool
单例线程池,只有一个线程,任务是串行执行的
newSingleThreadExecutor
Executors创建的线程池类型
核心线程数:提交任务时创建,空闲时也不会被回收,除非allowCoreThreadTimeOut设置为true
corePoolSize
最大线程数:任务超过了核心线程数,也超过的队列长度时创建,空闲时会被回收
maximumPoolSize
存活时间:非核心线程空闲超过了存活时间,就会被回收
keepAliveTime
存活时间的时间单位
TimeUnit
阻塞队列:任务超过了核心线程数,会加入到队列中阻塞等待
BlockingQueue
线程工厂:线程池创建工作线程时使用,可以自定义线程名模板
ThreadFactory
抛异常
丢弃最新的
重试
丢弃最早的
拒绝策略:核心线程数、非核心线程数及队列都满了就执行拒绝策略
RejectedExecutionHandler
ThreadPoolExecutor的参数
核心线程->队列->非核心线程->拒绝策略
线程池
并发编程
0 条评论
下一页