并发编程
2021-03-29 20:20:12 0 举报
AI智能生成
java 并发编程
作者其他创作
大纲/内容
并发编程
线程基础
创建线程的方式
停止线程的方式
线程的状态切换
手写生产者消费者模式
BlockingQueue
Condition
wait/notify
线程安全问题
活跃性问题
死锁
活锁
饥饿
长时间得不到运行
场景
访问共享变量或资源
依赖时序的操作
不同数据之间存在绑定关系
对方没有声明自己是线程安全的
性能问题
调度开销
上下文切换
缓存失效
线程协作开销
禁止编译器和 CPU 对其进行重排序等优化
反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中
线程池
使用场景
解决的问题
好处
解决线程生命周期的系统开销问题,同时还可以加快响应速度
统筹内存和 CPU 的使用,避免资源使用不当
可以统一管理资源
各大参数
corePoolSize 核心线程数
maxPoolSize 最大线程数
keepAliveTime 空闲线程的存活时间
ThreadFactory 线程工厂
workQueue 任务队列
hander 处理被拒绝的任务
4种拒绝策略
AbortPolicy
抛出异常RejectedExecutionException
CallerRunsPolicy
谁提交谁运行
DiscardPolicy
直接丢弃
DiscardOldestPolicy
丢弃队列头部的任务
6种常见线程池
FixedThreadPool
核心线程数=最大线程数
CachedThreadPool
核心线程数0,最大线程数Integer.Max_VALUE
阻塞队列是SynchronousQueue
ScheduledThreadPool
SingleThreadPool
SingleThreadScheduledPool
ForkJoinPool
常用的阻塞队列
LinkedBlockingQueue
fixed single
SynchronousQueue
cached
DelayedBlockingQueue
schedule
为什么不应该自动创建线程池
FixedThreadPool 的 LinkedBlockingQueue类似无界 可能OOM
SingleThreadPool 的 LinkedBlockingQueue类似无界 可能OOM
CachedThreadPool 最大线程数过大
DelayedWorkQueue也是无界的
合适的线程数量
CPU密集型
核心数的1~2倍
IO密集型
核心数的多倍
线程数 = CPU 核心数 *(1+平均等待时间/平均工作时间)
定制合适的线程池
线程数
阻塞队列
考虑ABQ 初始化之后不能扩容
线程工厂
可自定义
根据业务命名名称
拒绝策略
可以通过实现RejectedExecutionHandler 接口自定义拒绝策略
线程池的关闭
shutdown
执行完正在执行的和任务队列种的队列
新任务会根据拒绝策略拒绝
shutdownNow
首先会给所有线程池中的线程发送 interrupt 中断信号,尝试中断这些任务的执行
然后会将任务队列中正在等待的所有任务转移到一个 List 中并返回
我们可以根据返回的任务 List 来进行一些补救的操作
isShutdown
监测线程池是否已经开始了关闭工作
isTerminated
监测线程池是否真的终结了
锁
锁的分类
偏向锁/轻量级锁/重量级锁
对象头的mark word
待深入
可重入锁/非可重入锁
共享锁/独占锁
公平锁/非公平锁
悲观锁/乐观锁
悲观锁
悲观锁:synchronized 关键字和 Lock 接口
乐观锁
原子类
自旋锁/非自旋锁
可中断锁/不可中断锁
Synchronize
同步代码块是利用 monitorenter 和 monitorexit 指令实现的
同步方法则是利用 flags 实现的
检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁
synchronized 和 Lock
相同点
保证线程安全
保证可见性
可重入
不同点
用法
Lock必须锁对象
Lock是显示加锁,必须finally解锁
加解锁顺序不同
多把Lock锁解锁顺序不一定需要是加锁的反顺序
Lock更灵活
Lock 类在等锁的过程中,如果使用的是 lockInterruptibly 方法,那么如果觉得等待的时间太长了不想再继续等待,可以中断退出,也可以用 tryLock() 等方法尝试获取锁,如果获取不到锁也可以做别的事
synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制
synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁
Lock可以可以设置公平/非公平
如何选择
如果能不用最好既不使用 Lock 也不使用 synchronized。因为在许多情况下你可以使用 java.util.concurrent 包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。
如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock,代码可能会出很大的问题,而使用 synchronized 更安全。
如果特别需要 Lock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock。
Lock锁的常用用法
lock()
在finally中unlock
不能被中断
tryLock()
如果当前锁没有被其他线程占用,则获取成功,返回 true,否则返回 false,代表获取锁失败
在等待了一段指定的超时时间后,线程会主动放弃这把锁的获取,避免永久等待
在等待的期间,也可以随时中断线程,这就避免了死锁的发生
lockInterruptibly()
除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止
unlock()
内部会把锁的“被持有计数器”减 1,直到减到 0 就代表当前这把锁已经完全释放了,如果减 1 后计数器不为 0,说明这把锁之前被“重入”了
公平锁和非公平锁
tryLock 可以插队
ReadWriteLock
获取规则
如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功
如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作
如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写
读锁插队策略
即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的,目的是避免“饥饿”
锁的升降级
自旋锁
不放弃 CPU 时间片,不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止
实现一个自旋锁
JVM对锁的优化
自适应的自旋锁
和最近自旋获取的锁的成功率相关
锁消除
经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉
锁粗化
并发容器
HashMap的线程不安全性
扩容期间取出的值不准确
同时 put 碰撞导致数据丢失
可见性问题无法保证
保证可见性,也就是说,当一个线程操作这个容器的时候,该操作需要对另外的线程都可见,也就是其他线程都能感知到本次操作
死循环造成 CPU 100%
1.8之前
ConcurrentHashMap
1.7与1.8
1.7采用分段锁,所有的Segment继承了ReentrantLock
红黑树
本质是对二叉查找树 BST 的一种平衡策略
每个节点要么是红色,要么是黑色,但根节点永远是黑色的。红色节点不能连续,也就是说,红色节点的子和父都不能是红色的。从任一节点到其每个叶子节点的路径都包含相同数量的黑色节点
查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生
左右子树高度几乎一致,所以其查找性能近似于二分查找,时间复杂度是 O(log(n)) 级别
对比Java7 和Java8 的异同和优缺点
数据结构
Java 7 采用 Segment 分段锁来实现,而 Java 8 中的 ConcurrentHashMap 使用数组 + 链表 + 红黑树
并发度
Java 7 中,每个 Segment 独立加锁,最大并发个数就是 Segment 的个数
Java 8 中,锁粒度更细,理想情况下 table 数组元素的个数(也就是数组长度)就是其支持并发的最大个数,并发度比之前有提高
保证并发安全的原理
Java 7 采用 Segment 分段锁来保证安全,而 Segment 是继承自 ReentrantLock。
Java 8 中放弃了 Segment 的设计,采用 Node + CAS + synchronized 保证线程安全
遇到 Hash 碰撞
Java 7 在 Hash 冲突时,会使用拉链法,也就是链表的形式。
Java 8 先使用拉链法,在链表长度超过一定阈值时,将链表转换为红黑树,来提高查找效率
查询时间复杂度
Java 7 遍历链表的时间复杂度是 O(n),n 为链表长度。
Java 8 如果变成遍历红黑树,那么时间复杂度降低为 O(log(n)),n 为树的节点个数
为什么 Map 桶中超过 8 个才转为红黑树
单个 TreeNode 需要占用的空间大约是普通 Node 的两倍
链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低,而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率
ConcurrentHashMap 和 Hashtable 的区别
实现线程安全的方式不同
Hashtable 实现并发安全的原理是通过 synchronized 关键字
性能不同
迭代时修改的不同
Hashtable(包括 HashMap)不允许在迭代期间修改内容,否则会抛出ConcurrentModificationException 异常,其原理是检测 modCount 变量
CopyOnWriteArrayList
适用场景
读操作可以尽可能的快,而写即使慢一些也没关系
读多写少
读写规则
缺点
内存占用问题
在元素较多或者复杂的情况下,复制的开销很大
数据一致性问题
特点
实现阻塞最重要的两个方法是 take 方法和 put 方法
是否有界
LinkedBlockingQueue 的上限是 Integer.MAX_VALUE,约为 2 的 31 次方,是非常大的一个数,可以近似认为是无限容量
常用方法
add、remove、element
会抛出相应的异常
offer、poll、peek
offer返回false、poll和peek返回null
put、take
常见的阻塞队列
ArrayBlockingQueue
PriorityBlockingQueue
DelayQueue
并发安全原理
阻塞队列最主要是利用了 ReentrantLock 以及它的 Condition 来实现
非阻塞队列则是利用 CAS 方法实现线程安全
选择合适的阻塞队列
功能层面,比如是否需要阻塞队列帮我们排序,如优先级排序、延迟执行等。如果有这个需要,我们就必须选择类似于 PriorityBlockingQueue 之类的有排序能力的阻塞队列
容量,或者说是否有存储的要求,还是只需要“直接传递”。在考虑这一点的时候,我们知道前面介绍的那几种阻塞队列,有的是容量固定的,如 ArrayBlockingQueue;有的默认是容量无限的,如 LinkedBlockingQueue;而有的里面没有任何容量,如 SynchronousQueue;而对于 DelayQueue 而言,它的容量固定就是 Integer.MAX_VALUE
能否扩容。因为有时我们并不能在初始的时候很好的准确估计队列的大小,因为业务可能有高峰期、低谷期。需要动态扩容的话,那么就不能选择 ArrayBlockingQueue
内存结构。在上一课时我们分析过 ArrayBlockingQueue 的源码,看到了它的内部结构是“数组”的形式。和它不同的是,LinkedBlockingQueue 的内部是用链表实现的,所以这里就需要我们考虑到,ArrayBlockingQueue 没有链表所需要的“节点”,空间利用率更高
性能的角度去考虑。比如 LinkedBlockingQueue 由于拥有两把锁,它的操作粒度更细,在并发程度高的时候,相对于只有一把锁的 ArrayBlockingQueue 性能会更好
CAS
相比锁的优势
粒度更细:原子变量可以把竞争范围缩小到变量级别,通常情况下,锁的粒度都要大于原子变量的粒度。
效率更高:除了高度竞争的情况之外,使用原子类的效率通常会比使用同步互斥锁的效率更高,因为原子类底层利用了 CAS 操作,不会阻塞线程。
六类原子类
Atomic* 基本类型原子类\tAtomicInteger、AtomicLong、AtomicBoolean
Atomic*Array 数组类型原子类\tAtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
Atomic*Reference 引用类型原子类\tAtomicReference、AtomicStampedReference、AtomicMarkableReference
Atomic*FieldUpdater 升级类型原子类\tAtomicIntegerfieldupdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
Adder 累加器\tLongAdder、DoubleAdder
Accumulator 积累器\tLongAccumulator、DoubleAccumulator
AtomicInteger 性能不好如何解决
AtomicLong内部的value属性是volatile修饰的,每一次它的数值有变化的时候,它都需要进行 flush 和 refresh
flush 和 refresh 操作耗费了很多资源,而且 CAS 也会经常失败
LongAdder 引入了分段累加的概念,内部一共有两个参数参与计数:第一个叫作 base,它是一个变量,第二个是 Cell[] ,是一个数组
ThreadLocal
保存每个线程独享的对象
每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息
ThreadLocal 是用来解决共享资源的多线程访问的问题吗
一个 Thread 有一个 ThreadLocalMap,而 ThreadLocalMap 的 key 就是一个个的 ThreadLocal
remove()
Future
Callable 和 Runnable 的不同之处
方法名,Callable 规定的执行方法是 call(),而 Runnable 规定的执行方法是 run();
返回值,Callable 的任务执行后有返回值,而 Runnable 的任务执行后是没有返回值的;
抛出异常,call() 方法可抛出异常,而 run() 方法是不能抛出受检查异常的;
和 Callable 配合的有一个 Future 类,通过 Future 可以了解任务执行情况,或者取消任务的执行,还可获取任务执行的结果,这些功能都是 Runnable 做不到的,Callable 的功能要比 Runnable 强大。
Future 的主要功能
Future 相当于一个存储器,它存储了 Callable 的 call 方法的任务结果
方法和用法
get() 方法
执行完毕了,返回执行结果
执行中,阻塞
执行抛出异常,get抛出ExecutionException
任务取消了,抛出 CancellationException
任务超时,抛出 TimeoutException
isDone() 方法:判断是否执行完毕
isDone 方法在返回 true 的时候,不代表这个任务是成功执行的,只代表它执行完毕了
cancel 方法:取消任务的执行
isCancelled() 方法:判断是否被取消
注意点
在 get 的时候应当使用超时限制
Future 生命周期不能后退
“旅游平台”查机票的实现
线程池的实现
CountDownLatch
CompletableFuture
线程协作
Semaphore
第二个流程是在建立完这个构造函数,初始化信号量之后,我们就可以利用 acquire() 方法
第三步就是在任务执行完毕之后,调用 release() 来释放许可证
CyclicBarrier 和 CountdownLatch 有什么异同
相同点:都能阻塞一个或一组线程,直到某个预设的条件达成发生,再统一出发
作用对象不同
CyclicBarrier 要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch 只需等待数字倒数到 0,也就是说 CountDownLatch 作用于事件,但 CyclicBarrier 作用于线程;CountDownLatch 是在调用了 countDown 方法之后把数字倒数减 1,而 CyclicBarrier 是在某线程开始等待后把计数减 1
可重用性不同
CountDownLatch 在倒数到 0 并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier 可以重复使用,在刚才的代码中也可以看出,每 3 个同学到了之后都能出发,并不需要重新新建实例。CyclicBarrier 还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException 异常。
执行动作不同
CyclicBarrier 有执行动作 barrierAction,而 CountDownLatch 没这个功能
Condition、object.wait() 和 notify() 的关系
signalAll() 会唤醒所有正在等待的线程,而 signal() 只会唤醒一个线程
实现简易版阻塞队列
用 Condition 实现简易版阻塞队列
用 wait/notify 实现简易版阻塞队列
Java内存模型
Java内存结构
堆区(Heap)
存储类实例和数组
虚拟机栈(Java Virtual Machine Stacks)
保存局部变量和部分结果,并在方法调用和返回中起作用
方法区(Method Area)
存储每个类的结构,例如运行时的常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类初始化以及接口初始化的特殊方法
本地方法栈(Native Method Stacks)
与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的 Java 方法服务,而本地方法栈则是为 Native 方法服务
程序计数器(The PC Register)
指令重排序
3种情况
编译器优化
CPU 重排序
内存的“重排序”
内存可见性问题
synchronized 不仅保证了临界区内最多同时只有一个线程执行操作,同时还保证了在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下一个线程所看到,也就是能读取到最新的值
主内存和工作内存的关系
1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
(2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
(3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。
happens-before 规则
单线程规则
锁操作规则
线程 A 在解锁之前的所有操作,对于线程 B 的对同一个锁的加锁之后的所有操作而言,都是可见的
volatile 变量规则
对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作
线程启动规则
Thread 对象的 start 方法 happen-before 此线程 run 方法中的每一个操作
线程 join 规则
中断规则
对线程 interrupt 方法的调用 happens-before 检测该线程的中断事件
并发工具类的规则
线程安全的并发容器的存入操作 happens-before 读取操作
如果在获取许可证之前有释放许可证的操作,那么在获取时一定可以看到
Future 任务中的所有操作 happens-before Future 的 get 操作
提交任务的操作 happens-before 任务的执行
volatile 与 synchronized 有什么异同
volatile的适用场景
布尔标记位
无复合操作
作为触发器
volatile 的作用
第一层的作用是保证可见性
Happens-before 关系中对于 volatile 是这样描述的:对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作
这就代表了如果变量被 volatile 修饰,那么每次修改之后,接下来在读取这个变量的时候一定能读取到该变量最新的值
第二层的作用就是禁止重排序
volatile 和 synchronized 的关系
对 volatile 字段的每次读取或写入都类似于“半同步”——读取 volatile 与获取 synchronized 锁有相同的内存语义,而写入 volatile 与释放 synchronized 锁具有相同的语义
单例模式的双重检查锁模式为什么必须加 volatile
双重检查的原因
为什么加Volatile
一定程度禁止相关语句的重排序,从而避免了上述由于重排序所导致的读取到不完整对象的问题的发生
核心思路
仅当预期值 A 和当前的内存值 V 相同时,才将内存值修改为 B
应用场景
ConcurrentHashMap casTabAt
ConcurrentLinkedQueue casNext
数据库
ABA 问题
加版本号解决
自旋时间过长
范围不能灵活控制
有一个解决方案,那就是利用一个新的类,来整合刚才这一组共享变量
4个必要条件
互斥条件
请求与保持条件
不剥夺条件
循环等待条件
定位死锁
jstack
解决死锁问题的策略
避免策略
获取锁的顺序
使用 HashCode 的值来决定顺序
检测与恢复策略
允许死锁发生,但是一旦发生之后它有解决方案
鸵鸟策略
final 关键字和“不变性”
final 的作用
final 修饰变量
一旦被赋值就不能被修改了
static 的 final 变量不能在构造函数中进行赋值
特殊用法:final 修饰参数
我们没有办法在方法内部对这个参数进行修改
final 修饰方法
被 final 修饰的方法不可以被重写,不能被 override
构造方法不允许被 final 修饰
final 修饰类
这个类“不可被继承”
为什么加了 final 却依然无法拥有“不变性”?
final 修饰对象时,只是引用不可变,对象本身的内容依然是可以变化的
关键字 final 可以确保变量的引用保持不变,但是不变性意味着对象一旦创建完毕就不能改变其状态,它强调的是对象内容本身,而不是引用
不变性并不意味着,简单地使用 final 修饰所有类的属性,这个类的对象就具备不变性了
AQS
AQS 内部原理
state 状态
volatile
compareAndSetState 及 setState
FIFO 队列
获取/释放方法
总结与思考
0 条评论
回复 删除
下一页