java并发编程
2021-02-19 16:31:18 1 举报
AI智能生成
java多线程面试的必备宝典
作者其他创作
大纲/内容
第四部分 案例分析
案例分析(一):高性能限流器Guaua RateLimiter
经典限流法:令牌桶算法
Guava采用的是令牌桶算法,其核心是想通过限流器,必须拿到令牌
令牌桶算法
① 令牌以固定速率添加到令牌桶中,假设限流的速率是r/秒,则令牌每1/r秒会添加一个
② 假设令牌桶容量是b,如果令牌桶已满,则新的令牌会被丢弃(b代表的是限流器允许最大突发流量)
③ 请求能够通过限流器的前提是令牌桶中有令牌
Guava 如何实现令牌桶算法
只需要记录下一个令牌产生的时间,并动态更新它,就能够轻松完成限流功能
关键是reserve()方法
案例分析(二):高性能网络应用框架Netty
网络编程性能的瓶颈
BIO模型里,所有read()操作和write()操作都会阻塞当前线程,建立连接不发数据read()操作会一直阻塞
使用BIO模型,一般会为socket分配一个独立的线程,对于现在互联网场景,BIO线程模型无法解决百万连接问题。
Reactor 模式
Handler指I/O句柄,其中handler_event方法处理I/O事件,也就是每个Event Handler处理一个I/O handler;get_Handle()方法可以返回这个I/O的Handler
核心是Reactor类,register_handler()和remove_handler()可以注册和删除一个事件处理器
Netty 中的线程模型
Netty中最核心的概念是事件循环(EventLoop),负责监听网络事件并调用事件处理器进行处理。
一个网络连接只会对应到一个java线程,避免了各种并发问题
处理TCP连接请求和读写请求是通过两个不同的socket完成的。
另一个核心概念是EventLoopGroup,一般会创建两个EventLoopGroup,一个称为bossGroup,一个称为workerGroup。
bossGroup就用来处理连接请求的
workerGroup用来处理读写请求
用Netty实现Echo程序服务端
1. 创建一个事件处理器
2. 创建了bossGroup和workGroup
如果NettybossGroup只3监听一个端口,那bossGroup只需要1个EventLoo[就可以了
3. 之后创建并初始化了ServerBootstrap
默认情况下,Netty会创建“2*CPU核数”个EventLoop,由于稳定性关系,易导致大面积超时。
案例分析(三):高性能队列Disruptor
Disruptor高性能的原因
1. 内存分配更加合理,使用RingBuffer数据结构,数组在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免循环GC
2. 能够避免伪共享,提升缓存利用率
3. 采用无锁算法,避免频繁加锁、解锁的性能消耗
4. 支持批量消费,消费者可以无锁方式消费多个消息
RingBuffer 如何提升性能
RingBuffer本质也是数组,但是做了很多优化,其中一项是和内存分配有关的
程序的局部性原理
(在一段时间内的程序会限定在一个局部范围)
时间局部性:某条指令一旦被执行,不久之后很可能再次被执行;某条数据被访问,不久之后可能被再次访问
空间局部性:某块内存一旦被访问,不久之后这块内存附近的内存也很可能被访问。
如何避免“伪共享‘
什么是伪共享
是指由于共享缓存行导致缓存无效的场景
如何避免
每个变量独占一个缓存行、不共享缓存行就可以了,具体技术是缓存行填充
Disruptor中的无锁算法
利用管程实现的
入队:不能覆盖没有消费的元素
出队:不能读取没有写入的元素
案例分析(四):高性能数据库连接词HiKariCP
什么是数据库连接池?
本质和线程池一样,属于池化资源,作用是避免重量级资源的频繁创建和销毁,也就是避免数据库连接频繁创建和销毁
执行数据库操作步骤
1 通过数据源获取一个数据库连接
2 创建 Statement
3 执行SQL
4 通过ResultSet获取SQL执行结果
5 释放 ResultSet
6 释放 Statement
7 释放数据库连接
如何提升HiKariCP的性能
FastList 解决了哪些性能问题
资源的关闭问题
查找支持逆序查找
会越界检查,保证不会越界
ConcurrentBag解决了哪些性能问题
(使用threadlocal避免并发)
实现了ConcurrentBag的并发容器
存储数据库连接到共享队列 sharedList
线程本地存储 threadList
等待数据库连接的线程数 waiters
分配数据库连接工具 handoffQueue
利用borrow()方法获取一个空闲的数据库连接逻辑
1. 查看线程本地是否有空闲连接,如果有,则返回一个空闲连接
2. 如果线程本地存储中无空闲连接,则从共享队列中获取
3. 如果共享队列中也没有空闲的连接,则请求线程需要等待。
第五部分 其他并发模型
42 Actor模型:面向对象原生的并发模型
Hello Actor模型
本质是一种计算模型,所有的计算都是在Actor中执行的
需借助第三方类库Akka
消息和对象方法的区别
Actor中的消息机制完全是异步的。而调用对象方法,实际上是同步的。
调用对象方法,需要持有对象的引用,所有的对象必须在同一个进程中
发送、接收消息的Actor可以不在一个进程,也可以不在同一台机器
Actor的规范化定义
基础的计算单元
1. 处理能力,处理接收到的消息
2. 存储能力,Actor可以存储自己的内部状态,并且内部状态在不同的Actor之间是绝对隔离的
3.通信能力,Actor可以和其他Actor之间通信
接收消息可以做的事情
1. 创建更多的Actor
2. 发消息给其他Actor
3. 确定如何处理下一条消息
用Actor实现累加器
启动了4个线程来执行累加操作。整个程序没有锁,也没有CAS,但是程序是线程安全的
43 软件事物内存:借鉴数据库的并发经验
用STM实现转账
STM:软件事物内存,简称STM
借助第三方库,Multiverse是不错选择,将转账操作放到atomic(()->{ })
理解MVCC
全称是 Multi-Version Concurrency Controll,也就是多版本并发控制
数据库事务开启时,会给数据库打一个快照,以后所有读写都基于这个快照。
事物执行期间没有发生变化,就可以提交
发生了变化,说明该事物和其他事务读写的数据冲突,是不可以提交的
自己实现STM
STMTxn三个核心方法:读数据get()方法,写数据的set()方法和提交事务的commit()方法(互斥锁)
44 协程:更轻量级的线程
Golang中的协程
创建线程简单,只需要一行代码搞定
实现echo程序的服务端,用的是Thread-Per-Message模式,为每个建立的socket分配一个协程,方案更加简单。
利用协程实现同步
协程的等待成本比线程低,所以基于协程实现同步非阻塞是一个可行方案。(OpenResty里实现的cosocket是一种同步非阻塞方案)
结构化并发编程
会使书写顺序和执行顺序不一致
可以使用三种基本控制结构来代替goto
45 CSP模型:Golang的主力队员
什么是CSP模型
不要以共享内存方式通信,要以通信方式共享内存
Golang中协程之间通信推荐的是使用channel
CSP模型与生产者-消费者模型
容量为0的channel在Golang中称为无缓冲的channel
容量大于0的称为有缓冲的channel
支持双向传输,可以将双向的channel变成单向的channel
CSP模型与Actor模型的区别
Actor模型中没有channel
Actor模型发送消息是非阻塞的,而CSP模型中是阻塞的
CSP模型中,是能保证消息百分百送达的,可能会导致死锁
第一部分 并发理论基础
01 可见性、原子性和有序性问题:并发编程BUG的源头。
① 缓存导致的可见性问题
单核时代,所有的线程都在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为一个线程对缓存的写,对另外一个线程来说一定是可见的。
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代,每颗CPU都有自己的缓存,当多线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存,这时就不具备可见性了。
② 线程切换带来的原子性问题
操作系统允许某个进程执行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(称为“任务切换”),这50毫秒称为“时间片”。
任务切换的时机大多数是在时间片结束的时候,高级语言里一条语句往往需要多条CPU指令完成。操作系统做任务切换,可以发生在任何一条CPU指令执行完。
指令1 :首先,需要把变量count从内存加载到CPU的寄存器
指令2:之后,在寄存器中执行+1操作。
指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)
把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。
③ 编译优化带来的有序性问题
利用双重检查创建单例对象,在new操作时(可能触发空指针异常)
应该执行的路径
1.分配一块内存M
2.在内存M上初始化Singleton对象
3.然后M的地址赋值给instance变量
实际优化后执行的路径
1.分配一块内存M
2.将M的地址赋值给instance变量
3.最后在内存M上初始化Singleton对象
02 java内存模型:看java如何解决可见性和有序性问题
java内存模型
是java虚拟机规范定义的,用来屏蔽掉java在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上能达到内存访问的一致性。
底层怎样实现
主要是通过内存屏障禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指定。对于编译器而言,内存屏障将限制它所做的重排序优化。对于处理器而言,内存屏障将会导致缓存的刷新操作。
使用volatile的困惑
volatile关键字并不是java语言的特产,古老的C语言也有,它最原始的意义就是禁用CPU缓存。
变量X可能被CPU缓存而导致可见性问题(1.5版本对volatile语义进行了增强,加入了Happens-Before规则)
Happens-Before规则(前面一个操作的结果对后续操作是可见的)
①程序顺序性规则
一个线程中,按照程序顺序
②volatile变量规则
对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见。得关联规则3,就有点不一样的感觉了。
③传递性
A Happens-Before B,且B Happen-Before C,那么A Happens-Before C.
④管程中锁的规则
管程是一种通用的同步原语,在java中指的是synchronized,sysnchronized是java里对管程的实现。
管程中的锁在java里是隐式实现的,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
可以这样理解:假设x的初始值是10,线程A执行完代码块后x的值会变成12(执行完自动释放锁),线程B进入代码块时,能够看到线程A对x的写操作,也就是线程B能够看到x==12。
⑤线程start()规则
是指主线程A启动子线程B后,子线程能够看到主线程在启动子线程B前的操作。
⑥线程join()规则
主线程A等待子线程B完成(主线程A通过调用子线程B的join()方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。
被我们忽视的final
final修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。
在1.5以后java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题。(一定要避免逸出)
03 互斥锁(上):解决原子性问题
互斥的概念
同一时刻只有一个线程执行称之为互斥
简易锁模型
线程进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时线程持有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行unlock().
改进后的锁模型
优化后增加了受保护的资源R,其次要保护资源R就得为它创建一把锁LR;最后针对这把锁LR,我们还需在临界区添上加锁操作和解锁操作。
另外,在锁LR和受保护资源之间用一条线做了关联。
锁技术:synchronized
修饰范围
可以用来修饰方法
用来修饰代码块
具体实现
java编译器会在synchronized修饰的方法或代码块前后自动加锁lock()和解锁unlock(),
好处是加锁lock()和解锁unlock()一定成对出现
隐式规则
当修饰静态方法的时候,锁定的是当前类的Class对象
当修饰非静态方法的时候,锁定的是当前实例对象this
锁和受保护资源的关系
受保护资源和锁之间的关联关系是N:1的关系
04 互斥锁(下):如何用一把锁保护多个资源
保护没有关联关系的多个资源
用不同的锁对受保护资源进行精细化管理,能够提升性能。(细粒度锁)
用每个资源一把锁就可以了
保护有关联关系的多个资源
this这把锁可以保护自己的余额this.balance,却保护不了别人的余额target.balance
使用锁的正确姿势
锁能覆盖所以受保护资源
用Account.class作为共享锁。Account.class是所有Account对象共享的,而且这个对象是java虚拟机在加载Account类的时候创建的,不用担心它的唯一性。
代码: synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt; } }
05 一不小心就死锁了,怎么办?
出现四个条件才会出现死锁
1.互斥,共享资源X和Y只能被一个线程占用
2.占用且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
3.不可抢占,其他线程不能强行抢占线程T1占有的资源
4.循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T占有的资源,就是循环等待。
如何预防死锁(互斥没办法破坏)
1.破坏占用且等待条件
2.破坏不可抢占条件
3.破坏循环等待条件
06 用“等待-通知”机制优化循环等待
用Synchronized实现等待-通知机制
同一时刻,只允许一个线程进入synchronized保护的临界区,当有一个线程进入临界区,其他线程就只能进入等待队列里等待。
这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
当调用wait()方法后,当前线程会被阻塞,并且进入到等待队列中,这个等待队列也是互斥锁的等待队列。
线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
尽量使用notifyAll()
notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。
07 安全性、活跃性以及性能问题
安全性问题
什么是线程安全?
其实本质上是正确性,就是按照我们期望执行,不要让我们感到意外。
如何写出线程安全的程序?
避免出现原子性问题、可见性问题和有序性问题(存在共享数据且该数据会发生变化,就是有多个线程会同时读写同一个数据)
避免数据竞争
避免竞态条件(程序执行的结果依赖线程执行的顺序)
活跃性问题
是指某个操作无法执行下去(如死锁、活锁、饥饿)
“死锁”:线程相互等待,而且会一直等待下去,在技术的表现形式是线程永久地“阻塞”了
“活锁”:有时候线程虽然没有发生阻塞,但仍然会存在执行不下去的情况
“饥饿”:是线程因无法访问所需资源而无法执行下去的情况。(解决方案)
① 保证资源充足
② 公平地分配资源
③ 避免持有锁的线程长时间执行
性能问题
阿姆达尔(Amdahl)定律
代表了处理器并行运算之后效率提升的能力,公式如下:S=1/(1-p)+p/n
Java SDK并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
解决方法
① 使用无锁的算法和数据结构
例如线程本地存储 (Thread Local Storage,TLS)、写时复制(Copy-on-write)、乐观锁等;java并发包里面的原子类
② 减少锁持有的时间(将并行程序串行化,要增加并行度)
使用细粒度的锁(java并发包里的ConcurrentHashMap-分段锁技术)、使用读写锁(读是无锁的,只有写的时候才会互斥)
性能度量的指标
① 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
② 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
③ 并发量:指的是能同时处理的请求数量,一般来说,随着并发量的增加,延迟也会增加。
08 管城:并发编程的万能钥匙
概念
指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。
(每个加锁的对象都绑定着一个管程(监视器))
组成部分
synchronized关键字、wait()、notify()、notifyAll()
三种模型
Hasen模型
Hoare模型
MESA模型
解决互斥
将线程不安全的队列封装起来,对外提供线程安全的操作方法(如入队和出队操作)
解决同步
1.对于阻塞队列的入队操作,如果阻塞队列已满,就需要等待直到阻塞队列不满,所以用notFull.await();
2.对于阻塞出队操作,如果阻塞队列为空,就需要等待直到阻塞队列不空,所以就用了notEmpty.await();
3.如果入队成功,那么阻塞队列就不空了,就需要通知条件变量:阻塞队列不空notEmpty对应的等待队列。
4.如果出队成功,那就阻塞队列就不满了,就需要通知条件变量:阻塞队列不满notFull对应的等待队列。
notify()、notifyAll()何时可以使用
除非经过深思熟虑,否则尽量使用notifyAll()
满足的三个条件
1.所有等待线程拥有相同的等待条件
2.所有等待线程被唤醒后,执行相同的操作
3.只需要唤醒一个线程
总结
管程就是一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。
09 java线程(上):java线程的生命周期
通用的线程生命周期
初始状态、可运行状态、运行状态、休眠状态和终止状态。
Java中线程的生命周期
1.NEW(初始化状态)
2.RUNNABLE(可运行 / 运行状态)
3.BLOCKED(阻塞状态)
4.WAITING(无时限等待)
5.TIMED_WAITING(有时限等待)
6.TERMINATED(终止状态)
状态的转换
1. RUNNABLE 与 BLOCKED 的状态转换
用线程等待 synchronized 的隐式锁,synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待
2. RUNNABLE 与 WAITING 的状态转换
1.获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法
2.调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法
3.调用 LockSupport.park() 方法,当前线程会阻塞
3. RUNNABLE 与 TIMED_WAITING 的状态转换
1.调用带超时参数的 Thread.sleep(long millis) 方法
2.获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
3.调用带超时参数的 Thread.join(long millis) 方法
4.调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法
5.调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
4. 从 NEW 到 RUNNABLE 状态
1.继承 Thread 对象,重写 run() 方法
2.实现 Runnable 接口,重写 run() 方法
5. 从 RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止(建议用interrupt,而不用stop)
10 java线程(中):创建多少线程才是合适的?
为什么使用多线程
本质是提高程序性(指标)
延迟
发出请求到收到响应这个过程的时间
吞吐量
单位时间内处理请求的数量
“降低延迟,提高吞吐量”的方法
优化算法
将硬件性能发挥极致(其实就是提升I/O的利用率和CPU的利用率)
多线程的应用场景
如果CPU和I/O设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量
单核时代
多线程主要就是用来平衡 CPU 和 I/O 设备的。如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差
多核时代
利用多核可以降低响应时间
创建多少线程合适?
CPU 密集型计算
(cpu计算时间长)
线程的数量 =CPU 核数”就是最合适的。
不过在工程上,线程的数量一般会设置为“CPU 核数 +1”
I/O 密集型的计算
(I/O计算时间长)
单核
最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
多核
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
11 java线程(下):为什么局部变量是线程安全的?
方法是如何被执行的
CPU的堆栈寄存器
调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出
局部变量存哪里?
局部变量就是放到栈里
调用栈与线程
关系
每个线程都有自己独立的调用栈
是否存在并发?
每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享
线程的封闭
方法里的局部变量,因为不会和其他线程共享
仅在单线程内访问数据
12 如何用面向对象思想写好并发程序?
一、封装共享变量
将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性
二、识别共享变量间的约束条件
这些约束条件,决定了并发访问策略,可能存在竞态条件。
三、制定并发访问策略
方案
① 避免共享
为每个任务分配独立的线
② 不变模式
③ 管程及其他同步工具
Java 领域万能的解决方案是管程,但在特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。
原则
① 优先使用成熟的工具类
Java SDK 并发包里提供了丰富的工具类
② 迫不得已时才使用低级的同步原语
低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
③ 避免过早优化
安全第一,并发程序首先要保证安全
13 理论基础模块热点问题
用锁的最佳实践
锁的性能看场景
竞态条件需要格外关注
方法调用是先计算参数
InterruptedException异常处理需小心
理论值or经验值
第二部分 并发工具类
14 Lock和Condition(上) :隐藏在并发包中的管程
再造管程的理由(设计互斥锁,弥补synchronized)
① 能够响应中断 (当我们给阻塞的线程发送中断信号的时候,能够唤醒它)
② 支持超时(没有获取到锁,不是进入阻塞状态,而是返回一个错误)
③ 非阻塞地获取锁 (获取锁失败,并不是进入阻塞状态,而是直接返回)
如何保证可见性
利用了volatile相关的Happens-Before规则
① 对于线程T1,value+=1 Happens-Before 释放锁的操作unlock();
② volatile变量规则:由于state=1会先读取state,所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
③ 传递性规则:线程T1的value+=1 Happens-Before线程T2的lock()操作
什么是可重入锁
可重入锁:线程可以重复获取同一把锁
可重入函数:多个线程可以同时调用该函数
公平锁与非公平锁
如果传入true就表示需要构造一个公平锁,反之表示构造一个非公平锁
用锁的最佳实践
① 永远只在更新对象的成员变量时加锁
② 永远只在访问可变的成员变量时加锁
③ 永远不在调用其他对象的方法时加锁
15 Lock和Condition(下) :Doubbo如何用管程实现异步转同步
用两个条件变量实现阻塞队列
Lock&Condition实现管程只能使用前面的await()、signal()、signalAll()
synchronized实现的管程才使用wait()、notify()、notifyAll()
同步与异步
通俗点讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。
让程序支持异步的实现
1.调用方创建一个子线程,在子线程中执行方法调用,这种调用称为异步调用
2.方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接return,这种方法我们一般称为异步方法
Dubbo的实现
调用线程通过调用 get() 方法等待 RPC 返回结果
当 RPC 结果返回时,会调用 doReceived() 方法,这个方法里面,调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁,获取锁后通过调用 signal() 来通知调用线程,结果已经返回,不用继续等待了。
16 Semaphore:如何快速实现一个限流器?
Semaphore的介绍
普遍翻译为“信号量”,以前被翻译成信号灯。在编程世界里,线程能不能执行,也要看信号量是不是允许。
提出者:迪杰斯特拉,提出后一直都是并发编程的终结者,直到1980管程被提出才有第二选择
信号量模型
计数器
等待队列
三个方法
init(): 设置计数器的初始值
down(): 计数器的值减1;如果此时计数器的值小于或者等于0,则当前线程将被阻塞,否则当前线程可以继续执行
up(): 计数器的值加1;如果此时计数器的值小于或者等于0,则唤醒等待队列中的一个线程,并将其从等待队列中移除
如何使用信号量
进入临界区之前执行一下 down() 操作,退出临界区之前执行一下 up() 操作就可以了。(acquire() =》 down() 操作,release() =》 up() 操作。)
两个线程 T1 和 T2 同时访问 方法过程
一个线程(假设 T1)的计数器减为 0,另外一个线程(T2)则是将计数器减为 -1。对于线程 T1,计数器的值是 0,大于等于 0,所以线程 T1 会继续执行;
对于线程 T2,计数器的值是 -1,小于 0,对 down() 操作,线程 T2 将被阻塞。所以此时只有线程 T1 会进入临界区执行count+=1;
当线程 T1 执行 release() 操作,也就是 up() 操作的时候,计数器的值是 -1,加 1 之后的值是 0,小于等于 0,T2 将会被唤醒。
于是 T2 在 T1 执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。
快速实现一个限流器
① 假设对象池的大小是 10,那么前 10 个线程调用 acquire() 方法,都能继续执行,相当于通过了信号灯,而其他线程则会阻塞在 acquire() 方法上。
② 通过信号灯的线程,分配了一个对象 t(这个分配工作是通过 pool.remove(0) 实现的),分配完之后会执行一个回调函数 func,而函数的参数正是前面分配的对象 t
③ 执行完回调函数之后,它们就会释放对象(这个释放工作是通过 pool.add(t) 实现的),同时调用 release() 方法来更新信号量的计数器。
④ 如果此时信号量里计数器的值小于等于 0,那么说明有线程在等待,此时会自动唤醒等待的线程。
17 ReadWriterLock :如何快速实现一个完备的缓存?
什么是读写锁?
概念
是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。
基本原则
允许多个线程同时读共享变量
只允许一个线程写共享变量
如果一个线程正在执行写操作,此时禁止读线程共享变量
与互斥锁的区别
读写锁允许多个线程同时读共享变量
读写锁的写操作是互斥的,不允许其他线程执行写操作和读操作
快速实现一个缓存
① 首先声明了一个 Cache<K, V> 类(HashMap),HashMap 不是线程安全的,这里用读写锁 ReadWriteLock 来保证其线程安全
② ReadWriteLock 是一个接口,它的实现类是ReentrantReadWriteLock,通过它创建了一把读readLock()和一把写锁writeLock()
实现缓存的按需加载
如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,调用了 w.lock() 来获取写锁。
获取写锁之后,我们并没有直接去查询数据库,而是重新验证了一次缓存中是否存在,再次验证是否存在,我们才去查询数据库并更新本地缓存
再次验证能够避免高并发场景下重复查询数据的问题。
读写锁的升级与降级
锁的升级:先是获取读锁,然后再升级为写锁(在ReadWriteLock 不支持的)
锁的降级:释放写锁前,降级为读锁(获取读锁的时还是持有写锁的)
超时机制
加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。
18 StampedLock :有没有比读写锁更快的锁?
StampedLock 支持的三种锁模式
读锁
悲观读锁
乐观读
StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。(乐观读操作是无锁的)
写锁
进一步理解乐观读
数据库的version 字段就类似于 StampedLock 里面的 stamp。
StampedLock 使用注意事项
读多写少的场景 性能很好,基本上可以替代 ReadWriteLock,但是 StampedLock 的功能仅仅是 ReadWriteLock 的子集
StampedLock 不支持重入
StampedLock 的悲观读锁、写锁都不支持条件变量
使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。
19 CountDownLatch和CyclicBarrier:如何让多线程步调一致
用CountDownLatch实现等待
每次创建新的新的线程是一个耗时的操作,后面对计数器执行减1操作,通过latch.countDown()来实现,在主线程中通过调用latch.await()来实现对计数器等于0的等待。
进一步优化性能
两次查询操作能够和对账操作并行,对账操作依赖查询操作的结果,是生产者消费者模型,需要有个队列,来保存生产者生产的数据,而消费者则从这个队列消费数据。
线程 T1 和线程 T2 的工作要步调一致,不能一个跑得太快,一个跑得太慢,只有这样才能做到各自生产完 1 条数据的时候,通知线程 T3。
用CycliBarrier实现线程同步
CyclicBarrier 是一组线程之间互相等待
CyclicBarrier 的计数器是可以循环利用的
20 并发容器 :都有哪些“坑”需要我们填?
同步容器及其注意事项
组合操作需要注意竞态条件问题,即便每个操作都能保证原子性,也不能保证组合操作的原子性。
在容器领域一个容易被忽视的“坑”是用迭代器遍历容器。
并发容器及其注意事项
(一) List
List 里面只有一个实现类就是 CopyOnWriteArrayList。CopyOnWrite,顾名思义就是写的时候会将共享变量新复制一份出来,这样做的好处是读操作完全无锁。
CopyOnWriteArrayList 的实现原理
内部维护了一个数组,成员变量 array 就指向这个内部数组,所有的读操作都是基于 array 进行的
CopyOnWriteArrayList 会将 array 复制一份,然后在新复制处理的数组上执行增加元素的操作,执行完之后再将 array 指向这个新的数组。
读写是可以并行的,遍历操作一直都是基于原 array 执行,而写操作则是基于新 array 进行。
注意
CopyOnWriteArrayList 仅适用于写操作非常少的场景,而且能够容忍读写的短暂不一致。
CopyOnWriteArrayList 迭代器是只读的,不支持增删改。因为迭代器遍历的仅仅是一个快照,而对快照进行增删改是没有意义的。
(二) Map
Map 接口的两个实现是 ConcurrentHashMap 和 ConcurrentSkipListMap
ConcurrentHashMap 的 key 是无序的,而 ConcurrentSkipListMap 的 key 是有序的。
它们的 key 和 value 都不能为空,否则会抛出NullPointerException这个运行时异常。下
(三) Set
Set 接口的两个实现是 CopyOnWriteArraySet 和 ConcurrentSkipListSet(用法同map)
(四) Queue
Java 并发包里阻塞队列都用 Blocking 关键字标识,单端队列使用 Queue 标识,双端队列使用 Deque 标识。(阻塞与非阻塞;单端与双端)
将 Queue 细分为四大类
1.单端阻塞队列
2.双端阻塞队列
3.单端非阻塞队列
4.双端非阻塞队列
注意
需要格外注意队列是否支持有界(所谓有界指的是内部的队列是否有容量限制)。实际工作中,一般都不建议使用无界的队列,因为数据量大了之后很容易导致 OOM。
21 原子类:无锁工具类的典范
无锁方案实现的原理
提供了CAS指令(Comare And Swap,即“比较并交换”)
CAS指令包含3个参数:共享变量的内存地址A、用于比较的值B和共享变量的新值C;并且只有内存中的地址A处的值等于B 时,才能将内存中地址A处的值更新为新值C。
自旋
就是循环尝试,可以重新读count最新的值来计算newValue并尝试再次更新,直到成功。
带来的问题:ABA问题(两个A虽然相等,但是第二个A的属性可能已经发生变化了。所以)
java如何实现原子化count+=1
原子类概览
1.原子化的基本数据类型
2.原子化的对象引用类型
3.原子化数组
4.原子化对象属性更新器
5.原子化的累加器
22 Executor与线程池:如何创建正确的线程池?
简述:线程是一个重量级对象,应该避免频繁的创建与销毁。
线程池是一种生产者 - 消费者模式
线程池的使用方是生产者,线程池本身是消费者。
如何使用 Java 中的线程池
核心是 ThreadPoolExecutor
corePoolSize:表示线程池保有的最小线程数。
maximumPoolSize:表示线程池创建的最大线程数。
keepAliveTime & unit:过了某个空闲的时间线程就要被回收了
workQueue:工作队列
threadFactory:通过这个参数你可以自定义如何创建线程
handler:通过这个参数你可以自定义任务的拒绝策略(4种)
CallerRunsPolicy:提交任务的线程自己去执行该任务。
AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException。
DiscardPolicy:直接丢弃任务,没有任何异常抛出。
DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
使用线程池要注意些什么
强烈建议使用有界队列(无界队列易导致OOM错误)
默认拒绝策略要慎重使用(当任务过多会触发执行拒绝策略)
注意异常处理问题,捕获所有异常并按需处理
23 Future:如何用多线程实现最优的“烧水泡茶”程序?
如何获取任务执行结果
3个submit方法
1. submit(Runnable task)
2. submit(Callable task) 是有返回值的
3. submit(Runnable task, T result)
1个FutureTask工具类
FutureTask(Callable<V> callable);FutureTask(Runnable runnable, V result);
Future 接口5个方法
取消任务的方法 cancel()
判断任务是否已取消的方法 isCancelled()
判断任务是否已结束的方法 isDone()
获得任务执行结果的 get()
支持超时机制 get(timeout, unit)
两种方式执行
将 FutureTask 对象提交给 ThreadPoolExecutor 去执行。
利用 FutureTask 对象可以很容易获取子线程的执行结果。
24 CompletetableFuture:异步编程没那么难
CompletableFuture的核心优势
1.无需手工维护线程,没有繁琐的手工维护线程的工作
2.代码更简练并且专注于业务逻辑,几乎所有代码都是业务逻辑相关的
3.语义更清晰
创建CompletableFuture对象
1. static CpmpeltableFuture<void> runAaync(Runnable runnable)
2. static <U> CompletableFuture<U> supplyAsync<Supplier<U> supplier>
3. static CompletableFuture<void> runAsync(Runnable runnable,Executor executor)
4. static <U> CompletableFuture<U> runAsync(Runnable runnable,Executor executor)
理解CompletionStage接口
1.描述串行关系
主要接口
thenApply
thenAccept
thenRun
thenCompose
2.描述AND汇聚关系
主要接口
thenCombine
thenAcceptBoth
runAfterBoth
3.描述OR汇聚关系
主要接口
applyToEither
acceptEighter
runAfterEigther
4.异常处理
CompletionStage exceptionally(fn);
CompletionStage<R> whenComplete(consumer);
CompletionStage<R> whenCompleteAsync(consumer);
CompletionStage<R> handle(fn);
CompletionStage<R> handleAsync(fn)
25 CompletetionService:如何批量执行异步任务?:
CompletionService接口说明
Future <V> submit(Callable<V> task);
Future<V> submit(Runnable task,Vresult);
Future<V> task() throws InterruptedException;
阻塞队列是空,线程会被阻塞
Future<V> poll();
阻塞队列是空,返回null值
Future<V> poll(long timeout,TimeUnit unit) throws InterruptedException;
如果等待了timeout unit 时间,阻塞队列还是空,会返回null值
利用CompletionService实现Dubbo中的Forking Cluster
Dubbo有一种叫做Forking集群模式,在这种模式下,并行地调用多个查询服务,只要一个成功返回,整个服务就可以返回了(缺点是消耗资源偏多)
26 Fork/join:单机版的MapReduce
分治任务模型
一个阶段是任务分解,也就是将任务迭代地分解子任务,直至直接计算出结果
另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果
Fork/Join的使用
是一个并行计算框架,主要用来支持分治任务模型
一部分是分治任务的线程池ForkJoinPool,
另一部分是分治任务ForkJoinTask
fork()方法:会异步地执行一个子任务
join()方法:会阻塞当前线程来等待子任务的执行结果
RecursiveAction子类
定义的抽象方法 compute() 没有返回值
RecursiveTask子类
定义的抽象方法 compute() 有返回值
Fork对应的是分治人物模型里的任务分解,Join对应的是结果合并
ForkJoinPool工作原理
核心组件是ForkJoinPool,本质是一个生产者--消费者模式的实现,内部有多个任务队列,当我们通过ForkJoinool的invoke()或者submit()方法提交时,根据一定的路由规则提交到任务队列中,支持一种“任务窃取”的机制,如果其他线程空闲了可以窃取。
27 并发工具类的模块热点问题
1.while(true)总不让人省心
执行完后break
循环结束前增加Thread.sleep(随机时间)
2.signalAll()总让人省心
更安全
3.Samaphore 需要锁中锁
4.锁的申请和释放要成对出现
5. 回调总要关心执行线程是谁
6.共享线程池:有福同享就要有有难同当
7.线上问题定位的利器:线程栈 dump
第三部分 并发设计模式
28. Immutability模式:如何利用不变性解决并发问题
快速实现具备不可变性的类
不变性模式:简单来讲,就是对象一旦被创建之后,状态就不再发生变化
将一个类所有的属性都设置成final的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了
利用享元模式避免创建重复对象
利用享元模式可以减少创建对象的数量,从而减少内存占用
本质
首先去对象池看看是不是存在;如果已经存在,就利用对象池里的对象;如果不存在,就会新创建一个对象,并且把这个新创建的对象放进对象池里
使用Immutability模式的注意事项
对象的所有属性都是final,并不能保证不可变性
不可变对象也需要正确发布
29. Copy-on-Write模式:不是延时策略的COW(写时复制)
copy-on-Write m模式应用领域
java领域:CopyOnWriteArrayList和CopyOnWriteArrayAet由于无锁,所以将性能发挥到极致
操作系统领域:Btrfs(B-Tree File System)、aufs(advanced multi-layered unification )
函数式编程领域
Docker 容器镜像设计、分布式源码管理系统、Git
本质
体现的是一种延时策略,只有在真正需要复制的时候才复制,而不是提前复制好(还支持按需复制)
30. 线程本地存储:没有共享,就没有伤害
ThreadLocal的使用方法
一个线程调用ThreadId的get()方法,两次get()返回值相同
两个线程分别调用ThreadId的get()方法,两个线程的get()返回值是不同的
ThreadLocal的工作原理
Thread这个类内部有一个私有属性threadLocals,其类型就是ThreadLocalMap,ThreadLocalMap的Key是ThreadLocal.每一个threadLocal对应一个Object类型的Value值
ThreadLocal与内存泄漏
线程池和程序是同生共死的。
Thread持有的ThreadLocalMap一直都不会被回收,再加上ThreadLocal中Entry对ThreadLocal是弱引用,只要结束生命周期就被回收掉。
但Entry中的Value却被Entry强引用,所以Value的生命周期结束了,value也是无法被回收了。
JVM不能做到自动释放对Value的强引用,可以用try{}finally{}方案,手动释放资源
InheritableThreadLocal与继承性
子线程继承父线程的线程变量
线程池中创建线程是动态的,很容易导致继承关系错乱
31. Guarded Suspension模式:等待唤醒机制的规范实现
Guarded Suspension模式
保护性地暂停:一个对象GuardedObject,内部有一个成员变量(受保护对象),以及两个成员方法get(Predicate<T> p)和onChanged(T obj),参数p来描述前提条件,onChanged()就是提供对应的服务
扩展Guarded Suspension模式
本质上是一种等待唤醒机制的实现,只不过Guarded Suspension 模式将其规范化了
扩展后的GuardedObject内部维护了一个Map,其Key是MQ消息id,而Value是GuardedObject对象实例
增加了静态方法create()和fireEvent()
create()方法:用来创建一个GuardedObject对象实例,并根据Key值将其加入到Map
fieEvent()方法:模拟的大堂经理根据包间找就餐人的逻辑
32. Balking模式: 再谈线程安全的单例模式
Balking模式的经典实现
放到多线程场景里,就是一种“多线程版本的if”
是将edit()方法中对共享变量changed的赋值操作抽取到了change()中,这样的好处是将并发处理逻辑和业务逻辑分开
用volatie实现Balking模式
在某些特定场景下,也可以使用volatie来实现,但使用volatile的前提是对原子性没有要求
之所以采用scheduleWithFixedDelay()这种调度方式能保证同一时刻只有一个线程执行方法
Balking模式典型的应用场景
将init()声明为一个同步方法,这样在同一时刻只有一个线程能够执行init()方法
init()方法在第一次执行完会将inited设置为true,后面后续执行init()方法的线程就不会再执行doInit()。
用Balking模式来实现线程安全的单例模式
功能实现上没有问题,但性能会很差,因为synchronized将getInstance()方法串行化了
可以用经典的双重检查方案来进行优化
33. Thread-Per-Message模式:最简单实用的分工方法
如何理解Thread-Per-Message模式
委托他人办理的方式,在并发编程领域总结为一种设计模式,简言之就是为每个任务分配一个独立的线程
用Thread实现Thread-Per-Message模式
网络编程里服务端的实现
为每个客户端请求创建一个独立的线程,当线程处理完请求后,自动销毁
实现方案的痛点
创建线程比较耗时
线程占用的内存比较大
基于操作系统
好处是java线程的调度权完全委托给操作系统稳定、可靠
缺点是创建成本高
基于轻量级线程池
轻量级线程,创建的成本很低
创建速度和内存占用相比操作系统至少有一个数量级上升
用Fiber实现Thread-Per-Message模式
34 Worker Thread模式:如何避免重复创建线程?
Work Thread模式及实现
Work Thread对应到现实世界里,其实就是车间里的工人。
是用阻塞队列做任务池,然后创建固定数量的线程阻塞队列中的任务。(类似于线程池)
正确地创建线程
java的线程池能够避免无限制地创建线程导致OOM,也能无限制接收任务导致OOM,建议创建有界的队列来接收任务
在创建线程池时,清晰地指明拒绝策略。
在实际工作中给线程赋予一个业务相干的名字
避免线程死锁
现象
应用每运行一段时间偶尔就会处于无响应的状态,监控数据看上去一切都正常,但是实际上已经不能正常工作了
方案
为不同的任务创建不同的线程池。提交到相同线程池中的任务一定是相互独立的,否则一定要慎重。
35 两阶段终止模式:如何优雅地终止线程?
如何理解两阶段终止模式
第一阶段主要是线程T1向线程T2发送终止指令
利用interrupt()方法将线程由休眠状态转换成Runnable状态
第二阶段是线程响应终止指令
设置一个标志位,线程通过检查这个标志位,如果发现终止条件则自动退出run()方法。
用两阶段终止模式终止监控操作
监控系统发送采集指令给被监控系统的监控代理,监控代理接收到指令后,从监控目标收集数据,然后回传给监控系统。
如何优雅地终止线程池
shutdown()方法
执行shutdown()后,就会拒绝接收新的任务,会等待线程池中正在执行的任务和已经进入阻塞队列的任务完才关闭线程池
shutdownNow()方法
执行shutdownNow()方法后,会拒绝接收新的任务,同时还会中断线程池中正在执行的任务,已经进入阻塞队列的任务也被剥夺了执行的机会。
36 生产者-消费者模式:用流水线思想提高效率
生产者-消费者模式的优点
任务队列
生产者线程生产任务,并添加到任务队列中,而消费者线程从任务队列中获取任务并执行
解耦
生产者和消费者没有任何依赖关系
支持异步
任务添加到任务队列无须等待任务被消费者线程执行完,能够平衡生产者和消费者的速度差异
支持批量执行以提升性能
生产者线程只需将数据添加到任务队列,然后消费者线程负责将任务从任务队列中批量取出并批量执行。
支持分阶段提交以提升性能
异步刷盘本质是一种分阶段提交
info()和error()方法的线程是生产者
真正将日志写入文件是消费者线程
37 设计模式模块热点问题
避免共享的设计模式
线程版本IF的设计模式
三种简单的分工模式
Thread-per-Message模式
Worker Thread模式
生产者-消费者模式
0 条评论
下一页