并发
2021-04-14 09:39:41 13 举报
AI智能生成
并发编程全解析,包括volatile、CAS、AQS、synchronized、lock、线程池、JUC、ThreadLocal等
作者其他创作
大纲/内容
AQS
AbstractQueuedSynchronizer:抽象队列同步器
加锁就会导致阻塞,有阻塞就需要排队,实现排队必然需要某种形式的队列来进行管理
原理
AQS的实现依赖内部的同步队列,队列内部维护了一个state域和一个FIFO的双向链表,如果当前线程竞争锁(修改state状态)失败,
那么AQS会把当前线程加入到同步队列中,同时阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒队首的阻塞节点
那么AQS会把当前线程加入到同步队列中,同时阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒队首的阻塞节点
通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,实现控制并发的效果
实现
独占锁
ReentrantLock
加锁过程
尝试加锁
加锁失败,线程入队列
线程入队列后,进入阻塞状态
ReentrantReadWriteLock
共享锁
CountDownLatch
CyclicBarrier
Semaphore
源码
AQS
头指针:Node head
尾指针:Node tail
状态位:volatile int state
持有锁的线程:exclusiveOwnerThread
获取锁:acquire()
tryAcquire()
尝试CAS获取锁,获取失败则进入等待队列,公平锁这里多一个是否需要排队的判断
addWaiter()
判断队列是否为空,不为空则直接把当前节点入队,否则先初始化队列再入队
acquireQueued()
判断当前线程是否排在第一位,是则请求获取锁,否则入队睡眠阻塞
释放锁:release()
tryRelease()
修改state标志位,将持有锁的线程置为null
unparkSuccessor()
唤醒阻塞线程,重新获取锁
JUC
locks(锁)
Lock 接口
ReentrantLock
原理:CAS+AQS 👉
请求线程先通过CAS尝试修改state状态位获取锁,如果没有获取到,就加入AQS队列
并且被挂起,当锁被释放之后, 排在队首的线程会被唤醒,通过CAS再次尝试获取锁
并且被挂起,当锁被释放之后, 排在队首的线程会被唤醒,通过CAS再次尝试获取锁
公平与非公平
如果是非公平锁, 新进来的线程会尝试竞争锁,竞争不到才去排队;如果是公平锁,新来的线程会直接排到队尾,由队首的线程获取到锁
重入锁
现象:如果当前线程已经持有锁了,释放锁之前线程自己是可以重复获取此锁的(state会累加)这就是可重入的概念
但要注意获取多少次就要释放多少次,最后保证state能回到零态
但要注意获取多少次就要释放多少次,最后保证state能回到零态
原理:AQS的父类维护了一个属性,用来存储当前持有锁的线程ID,每次获取锁的时候对比线程ID看是否可重入
LockSupport
用于创建锁和其他同步类的基本线程阻塞原语,方法park()和unpark()提供了阻止和解除阻塞线程的有效手段
LockSupport是什么?
线程的等待/唤醒机制
park():阻塞线程
unpark():解除阻塞线程
3种等待/唤醒方法
使用Object中的wait()方法和notify()方法
必须配合Synchronized关键字使用
使用Condition接口的await()和signal()方法
必须配合lock.lock()方法使用
LockSupport的park()和unpark(thread)方法
不用配合锁来使用,等待和唤醒谁先运行都可以
常见问题
为什么可以先唤醒线程后阻塞线程?
因为unpark获得了一个凭证,之后再调用park方法消费凭证,故不会阻塞
为什么先唤醒两次后阻塞两次,但最终还是会阻塞?
因为凭证的数量最多为1,多次调用也不会累加凭证
Condition 接口
一个Condition实例本质上绑定到一个锁来使用,相当于一个队列,要获得特定Condition实例要调用 lock.newCondition();
await():使当前线程等待 发信号或 interrupted
signal():唤醒当前队列的一个等待线程
signalAll():唤醒所有等待线程
ReadWriteLock 接口
ReentrantReadWriteLock
读写锁维护着一对关联的锁,一个读锁和一个写锁
通过分离读锁和写锁,使得并发性有了较大的提升
通过分离读锁和写锁,使得并发性有了较大的提升
读的时候可以被多个线程同时读
写的时候只能允许一个线程去写
特点
公平性:支持公平锁和非公平锁
重入性:支持重入锁
锁降级:遵循获取写锁,再获取读锁,最后释放写锁的次序,如此写锁能够降级成为读锁
更加细粒度,在读场景多时可以明显提高效率,写场景多时不推荐使用
代码示例
高频面试题
要求使用两个线程,交替打印出 1A2B3C4D5E6F7G
方式一:使用Lock结合Condition完成
方式二:使用LockSupport完成
ReentrantLock如何避免死锁?
响应中断 lockInterruptibly()
可轮询锁 tryLock()
定时锁 tryLock(long time)
lock和lockInterruptibly的区别?
lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时
中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常
中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常
tryLock和lock的区别?
tryLock 能获得锁就返回 true,不能就立即返回 false
lock 能获得锁就返回 true,不能的话一直等待获得锁
tryLock(long timeout,TimeUnit unit),可以增加
时间限制,如果超过该时间段还没获得锁,返回 false
时间限制,如果超过该时间段还没获得锁,返回 false
Synchronized和Lock的区别?
lock是接口,属于API层面,Synchronized是关键字,属于JVM层面
Synchronized是非公平锁,ReentrantLock两者都可以,默认非公平
Synchronized可以给方法和代码块加锁,lock只能给代码块加锁
Synchronized是自动释放锁,不会死锁;ReentrantLock需要手动释放锁
如果忘记unlock()则会出现死锁,所以我们一般会在finally中使用unlock()
如果忘记unlock()则会出现死锁,所以我们一般会在finally中使用unlock()
ReentrantLock更加灵活,结合Condition.signal(),可以给指定wait()对象解锁
executor
Future
RunnableFuture
FutureTask
启动和取消运算
查询运算是否完成
取回运算结果
SwingWorker
CompletableFuture
supplyAsync():用来开启一个有返回值的异步任务
代码举例
thenCompose():当第一个任务完成时才会执行第二个任务,开启新线程,最终结果由第二个任务返回
代码举例
thenCombine():两个异步任务全部完成时才会执行某些操作
代码举例
thenApply():与thenCompose类似,把多个任务串联起来处理,下一个任务会用到上一个任务的结果
代码举例
applyToEither():哪个任务先执行完,就取哪个结果返回
代码举例
ScheduledFuture
Callable
Callable接口类似于Runnable,但是Runnable不会返回结果,并且无法抛出返
回结果的异常,而Callable功能更强大一些,被线程执行后可以返回值,这个
返回值可以被Future拿到,也就是说Future可以拿到异步执行任务的返回值
回结果的异常,而Callable功能更强大一些,被线程执行后可以返回值,这个
返回值可以被Future拿到,也就是说Future可以拿到异步执行任务的返回值
Callable用于产生结果,Future用于获取结果
Executor
ExecutorService
AbstractExecutorService
ThreadPoolExecutor
ScheduledExecutorService
ScheduledThreadPoolExecutor
RejectedExecutionHandler
ThreadPoolExecutor.AbortPolicy
ThreadPoolExecutor.CallerRunsPolicy
ThreadPoolExecutor.DiscardOldestPolicy
ThreadPoolExecutor.DiscardPolicy
execute() 和 submit() 的区别?
execute()是Executor接口中的方法,没有返回值,submit()是ExecutorService中的方法
submit()方法中调用了execute()方法,增加了FutureTask返回值
collections(并发容器)
Queue
BlockingQueue
ArrayBlockingQueue
有界阻塞队列,先进先出,存取相互排斥
数据结构:静态数组
容量固定必须指定长度,没有扩容机制
没有元素的位置也占用空间,被 null 占位
锁:ReentrantLock
存取是同一把锁,操作的是同一个数组对象
阻塞对象
notEmpty
无元素可取时,阻塞在该对象上(count=0)
notFull
放不进去时,阻塞在该对象上(count=length)
入队
从队首开始添加元素,记录putIndex
每次都唤醒notEmpty
出队
从队首开始取出元素,记录takeIndex
每次都唤醒notFull
两个指针都是从队首向队尾移动,保证队列的先进先出原则
LinkedBlockingQueue
无界阻塞队列,可以指定容量,默认为 Integer.MAX_VALUE,先进先出,存取互不干扰
数据结构:链表
内部类 Node 存储元素
锁分离:存取操作的是不同的Node对象
takeLock
putLock
阻塞对象
notEmpty
无元素可取时,阻塞在该对象上(count=0)
notFull
放不进去时,阻塞在该对象上(count=length)
入队
队尾入队,由last指针记录
出队
队首出队,由head指针记录
线程池中为什么使用LinkedBlockingQueue而不用ArrayBlockingQueue?
锁分离
SynchronousQueue
说是不存储元素(无缓冲)的阻塞队列,其实不准确,准确的说是完成了线程间的数据交换
数据结构:链表
在其内部类中维护了数据
第一个线程Thread0是消费者访问,此时队列为空,则入队(创建Node结点并赋值)
第二个线程Thread1也是消费者访问,与队尾模式相同,继续入队
第三个线程Thread2是生产者,携带了数据e,与队尾模式不同,不进行入队操作
直接将该线程携带的数据e返回给队首的消费者,并唤醒队首线程Thread0,出队
直接将该线程携带的数据e返回给队首的消费者,并唤醒队首线程Thread0,出队
锁:CAS+自旋(没有使用锁)
阻塞:自旋了一定次数后调用 LockSupport.park()
存取调用同一个方法:transfer()
put、offer 为生产者,携带了数据 e,为 Data 模式,设置到 QNode 属性中
take、poll 为消费者,不携帯数据,为 Request 模式,设置到 QNode 属性中
过程
线程访问阻塞队列,先判断队尾节点或者栈顶节点的 Node 与当前入队模式是否相同
相同则构造节点 Node 入队,并阻塞当前线程,元素 e 和线程赋值给 Node 属性
不同则将元素 e(不为 null) 返回给取数据线程,队首或栈顶线程被唤醒,出队
公平模式
TransferQueue
队尾匹配,队头出队,先进先出
非公平模式
TransferStack
栈顶匹配,栈顶出栈,后进先出
PriorityBlockingQueue
一个支持优先级排序的无界阻塞队列
优先级高的先出队,优先级低的后出队
数据结构:数组+二叉堆(完全二叉树)
可指定初始容量,会自动扩容,最大容量是Integer.MAX_VALUE
可理解为无界队列,超出最大长度会报 OOM 异常
锁:ReentrantLock
存取是同一把锁
阻塞对象:NotEmpty
出队,队列为空时阻塞
入队
不阻塞,永远返回成功,无界
根据比较器进行堆化(排序)自下而上
传入比较器对象就按照比较器的顺序排序
如果比较器为 null,则按照自然顺序排序
出队
优先级最高的元素在堆顶(弹出堆顶元素)
弹出前比较两个子节点再进行堆化(自上而下)
LinkedTransferQueue
数据结构:链表Node
由链表结构组成的无界阻塞队列
锁:CAS+自旋(没有使用锁)
阻塞:自旋了一定次数后调用 LockSupport.park()
可以自己控制存元素是否需要阻塞线程,比如使用四个添加元素的方法就不会阻塞线程,只入队元素,使用 transfer() 会阻塞线程
取元素与 SynchronousQueue 基本一样,都会阻塞等待有新的元素进入被匹配到
LinkedBlockingDeque
一个链表阻塞双端队列,无界可以指定容量,默认为 Integer.MAX_VALUE
数据结构:链表(同LinkedBlockingQueue)
内部类 Node 存储元素
锁:ReentrantLock(同ArrayBlockingQueue)
存取是同一把锁,操作的是同一个数组对象
阻塞对象(同ArrayBlockingQueue)
notEmpty
无元素可取时,阻塞在该对象上(count=0)
notFull
放不进去时,阻塞在该对象上(count=length)
入队
出队
DelayQueue
一个使用优先级队列实现的无界阻塞队列
数据结构:PriorityQueue
与PriorityBlockingQueue类型,不过没有阻塞功能
锁:ReentrantLock
阻塞对象:Condition available
入队:不阻塞,无界队列,与优先级队列入队相同,available
出队
为空时阻塞
检查堆顶元素过期时间
小于等于0则出队
大于0,说明没过期,则阻塞
判断leader线程是否为空
(为了保证优先级)
(为了保证优先级)
不为空(已有线程阻塞),直接阻塞
为空,则将当前线程置为leader,
并按照过期时间进行阻塞
并按照过期时间进行阻塞
手写阻塞队列
仿照ArrayBlockQueue
CopyOnWriteArrayList
CopyOnWriteArraySet
ConcurrentSkipListSet
ConcurrentMap
ConcurrentHashMap(在集合脑图中重点介绍)
ConcurrentNavigableMap
ConcurrentSkipListMap
atomic(原子类)
基本类型
AtomicBoolean
AtomicInteger
AtomicLong
基本类型数组
AtomicIntegerArray
AtomicLongArray
更新字段
AtomicIntegerFiledUpdater
AtomicLongFiledUpdater
AtomicReferenceFieldUpdater
引用类型
AtomicReference
AtomicMarkableReference
AtomicStampledReference
AtomicReferenceArray
累加器
LongAdder
DoubleAdder
LongAccumulator
DoubleAccumulator
tools(并发辅助类)
CountDownLatch
减法计数器:让一个或者多个线程阻塞,等待其他多个线程完成某件事情之后,被阻塞的线程再执行
主要方法
new CountDownLatch(int num); //创建计数器
countDownLatch.countDown(); //计数器 -1
countDownLautch.await(); //等待计数器归 0
原理
当一个或多个线程调用 await() 方法时,调用线程会被阻塞,等待计数器归零再往下执行
当线程调用 countDown() 方法会将计数器减 1,计数器为0时阻塞线程被唤醒(无法重置)
代码示例
CyclicBarrier
加法计数器:允许一组线程互相等待,直到到达某个公共屏障点,再释放等待线程(可以重置)
主要方法
new CyclicBarrier(int num, ()->{ //执行逻辑 });
cyclicBarrier.await(); //阻塞并计数+1
原理
先定义一个公众屏障点,然后写满足条件后的执行逻辑
创建多个线程执行子任务,使用await()方法阻塞并计数
代码示例
Semaphore
信号量:主要用于多个共享资源的互斥使用,指定初始资源容量,也可以实现限流的功能
主要方法
semaphore.acquire(); //获取凭证
semaphore.release(); //释放凭证
原理
访问特定资源前,先使用 acquire() 获取凭证,如果凭证为0,则该线程阻塞
直到有空闲资源时,再获取许可凭证;访问资源后,使用 release() 释放许可
代码示例
Phaser
阶段器:一种可重用的同步屏障,功能上类似于CyclicBarrier和CountDownLatch,但在使用上更为灵活。
非常适用于在多线程环境下同步协调分阶段计算任务(ForkJoin框架中的子任务之间需同步时,优先使用Phaser)
非常适用于在多线程环境下同步协调分阶段计算任务(ForkJoin框架中的子任务之间需同步时,优先使用Phaser)
Fork/Join
分治的思想:把大任务拆分成小任务,提升工作效率。特点是工作窃取,ForkJoin中维护的都是双端队列,当其中
一个队列中的子任务执行完,不会干等着另一个队列,而是窃取队列中未完成的任务帮助执行(适用大数据量场景)
一个队列中的子任务执行完,不会干等着另一个队列,而是窃取队列中未完成的任务帮助执行(适用大数据量场景)
task.fork(); 拆分任务,并把子任务压入线程队列,然后join合并结果:task1.join() + task2.join();
Exchanger
线程间交换数据,允许两个线程在某个汇合点交换对象,在某些管道设计时比较有用
CompletableFuture
(优雅的使用并发)
(优雅的使用并发)
supplyAsync():用来开启一个有返回值的异步任务
代码举例
thenCompose():当第一个任务完成时才会执行第二个任务,开启新线程,最终结果由第二个任务返回
代码举例
thenCombine():两个异步任务全部完成时才会执行某些操作
代码举例
thenApply():与thenCompose类似,把多个任务串联起来处理,下一个任务会用到上一个任务的结果
代码举例
applyToEither():哪个任务先执行完,就取哪个结果返回
代码举例
线程池
为什么要用线程池?
线程复用,减少频繁创建/销毁线程的系统开销
提高响应速度,当任务到达时,不需要创建就能立即执行
控制最大并发数,方便管理线程,防止资源不足
何时使用线程池?
单个任务处理时间比较短
需要处理的任务数量很大
线程池底层原理
工作原理
工作线程数未达到核心线程数时,新建线程执行任务
工作线程数达到核心线程数且阻塞队列未满,把任务放入阻塞队列
阻塞队列已满,且工作线程数小于最大线程数时,新建线程执行任务
工作线程数达到了最大线程数时,采用拒绝策略来处理,默认抛异常
threadPoolExecutor.execute(...)
1、任务非空校验
2、判断并添加核心线程执行任务
3、判断并进行任务入队列(二次校验)
4、添加普通线程执行任务,如果失败则执行拒绝策略
AddWork()
调用位置
当工作线程数 < 核心线程数的时候,addWorker(command, true)添加核心线程
double check的时候,如果发现线程池处于running状态但里面没有工作线程,
则添加个空任务和一个普通线程,即创建了一个新的线程去阻塞队列中拿任务
则添加个空任务和一个普通线程,即创建了一个新的线程去阻塞队列中拿任务
队列已满时,创建非核心线程执行,如果失败了则执行拒绝策略
作用
第一个for循环:判断当前的线程池状态是否能接受新任务
第二个for循环:判断线程池中当前的工作线程数量
尝试通过CAS方式增加工作线程数workerCount
如果增加成功,则跳出这个双循环,往下执行
启动一个工作线程,开启一个独占锁
runWorker()
getTask()
获取任务
threadPoolExecutor.allowCoreThreadTimeOut(true):允许核心线程超时销毁
task.run()
运行任务
线程池状态
负数用补码表示:取反+1
高3位记录线程池运行状态
低29位记录当前工作线程数
五种
RUNNING:接收新任务,并处理队列任务
SHUTDOWN:不接收新任务,但处理现有队列中的任务(调用pool.shutdown()方法)
STOP:不接收新任务,也不处理队列任务,并且会中断正在处理的任务(pool.shutdownNow())
TIDYING:所有线程已终止,工作线程数为0,TIDYING 状态时会执行钩子函数 terminated()
TERMINATED:线程池彻底终止的状态
线程池参数
corePoolSize
核心线程数,默认线程池中没线程,等任务来了才创建线程
如调用了预创建线程prestartAllCoreThreads(),线程池会提前创建并启动所有核心线程
maximumPoolSize
如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务
如果是无界队列则这个参数没有效果
如果是无界队列则这个参数没有效果
keepAliveTime
线程存活时间,该参数只有在线程数大于 corePoolSize 时才会生效
unit
keepAliveTime 的单位。TimeUnit
workQueue
ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO,但是不设置大小时就是 Integer.MAX_VALUE
SynchronousQueue:不存储元素的阻塞队列,每次插入都必须等待上一个移出
PriorityBlockingQueue:一个具有优先级的无界阻塞队列
threadFactory
顾名思义,创建线程的工厂,可以给线程重命名
handler
JDK原生拒绝策略
AbortPolicy:中止策略,直接抛出拒绝执行的异常,也是默认策略(注意处理好抛出的异常)
CallerRunsPolicy:调用者运行策略,用调用者所在的线程来执行任务
DiscardOldestPolicy:弃老策略,丢弃阻塞队列中等待最久的任务,并执行当前任务
DiscardPolicy:丢弃策略,直接丢弃当前任务,不触发任何动作
自定义拒绝策略
只需要实现RejectedExecutionHandler接口就可以了
扩展第三方拒绝策略
Dubbo中线程池
继承自AbortPolicy,直接抛异常
加强了日志输出,并且输出dump文件记录堆栈信息
Netty中线程池
Netty中的实现很像JDK中的CallerRunsPolicy,舍不得丢弃任务
与CallerRunsPolicy不同的是,Netty是新建了一个线程来处理的
Netty在创建线程时未做任何的判断约束,除非new不出线程才会抛异常
常见问题
使用线程池的风险?
线程池自己引发的死锁
所有正在执行的线程都在等待阻塞队列中阻塞线程的结果
线程泄露
问题:线程抛出异常而没有被捕捉到时,或者永远等待用户输入的线程
解决:要么只给予它们自己的线程,要么只让它们等待有限的时间
资源不足
避免线程池过大造成资源浪费,适当的调整线程池的大小
如何高效的使用线程池?
不要对那些同步等待其它任务结果的任务排队
给可能需要很长时间的操作要指定最长等待时间
理解任务,有效地调整线程池大小
Executor 和 Executors 的区别?
Executor 是接口,Executors 是工具类
Executors
Executors.newFixedThreadPool(10);
创建一个定长线程池,可控制线程最大
并发数,超出的线程会在队列中等待
创建一个定长线程池,可控制线程最大
并发数,超出的线程会在队列中等待
CorePoolSize: n
MaximumPoolSize: n
keepAliveTime: 0
LinkedBlockingQueue
MaximumPoolSize: n
keepAliveTime: 0
LinkedBlockingQueue
只有10个核心线程工作
使用无界队列
Executors.newCachedThreadPool();
创建一个可缓存线程池,如果线程池长
度超过处理需要,可灵活回收空闲线程,
若无可回收,则新建线程
创建一个可缓存线程池,如果线程池长
度超过处理需要,可灵活回收空闲线程,
若无可回收,则新建线程
CorePoolSize: 0
MaximumPoolSize: max
keepAliveTime: 60L
SynchronousQueue
MaximumPoolSize: max
keepAliveTime: 60L
SynchronousQueue
没有核心线程,都是临时线程
最大线程数是Integer.Max_Value
Executors.newSingleThreadExecutor();
创建一个单线程化的线程池,它只会用唯
一的工作线程来执行任务, 保证所有任务
按照指定顺序(FIFO, LIFO, 优先级)执行
创建一个单线程化的线程池,它只会用唯
一的工作线程来执行任务, 保证所有任务
按照指定顺序(FIFO, LIFO, 优先级)执行
CorePoolSize: 1
MaximumPoolSize: 1
keepAliveTime: 0
LinkedBlockingQueue
MaximumPoolSize: 1
keepAliveTime: 0
LinkedBlockingQueue
只有一个核心线程工作
使用无界队列
为什么不推荐使用以上3种线程池?
因为newFixedThreadPool和newSingleThreadExecutor使用的是LinkedBlockingQueue
而newCachedThreadPool的线程数又无限大,容易引发内存溢出的问题
而newCachedThreadPool的线程数又无限大,容易引发内存溢出的问题
生产环境如何合理配置线程池?
主要看业务类型
CPU密集型
该任务需要大量的运算,几乎没有阻塞,CPU一直全速运行
配置尽可能少的线程数量
CPU核数 +/- 1
N-1
N+1
IO密集型
该任务需要大量的IO,即大量的阻塞
应配置尽可能多的线程
CPU核数/(1-阻塞系数)
阻塞系数=阻塞时间/(阻塞时间+计算时间)
阻塞系数=w/w+c ==> 1+w/c
阻塞系数=w/w+c ==> 1+w/c
等同于另一种写法:CPU核数*(1+w/c)
并发数=线程数/单个任务时间
针对IO密集型的优化点
将任务放入队列前,先检査线程池的线程数是否小于最大线程数
如果是,则直接创建线程执行,否则,再尝试放入队列中
如果是,则直接创建线程执行,否则,再尝试放入队列中
dubbo 和 tomcat 的线程池都有类似实现
如果线上突然宕机,线程池中队列的请求怎么办?
数据会丢失, 可以在提交任务到队列前在数据库中插入一条任务信息,并更新其状态,
系统重启后启动后台线程去数据库中扫描未提交的任务信息重新添加到线程池中
系统重启后启动后台线程去数据库中扫描未提交的任务信息重新添加到线程池中
线程池被创建后里面有线程吗?如果没有的话,你知道有什么方法对线程池进行预热吗?
全部启动:调用preStartAllCoreThreads()
启动一个:调用preStartCoreThread()
核心线程数会被回收吗?需要什么设置?
核心线程数默认是不会被回收的,如果需要回收核心线程数,需要设置allowCoreTimeOut为true
ThreadLocal
为每个线程创建一个变量的副本,每个线程可以单独访问自己的副本,而不与其他线程共享,当前线程结束后GC
使用
声明ThreadLocal对象:ThreadLocal<Person> tl = new ThreadLocal<>();
调用方式
tl.set(new Person());
tl.get()
图解
实现原理
每个线程(Thread类)都有一个ThreadLocalMap对象(threadLocals),key为threadLocal对象(tl)的弱引用,value为对应的副本值
ThreadLocalMap数据结构:类似 HashMap 的 key-value 键值对,底层实现是table[]数组,没有链表结构,使用开放定址法解决hash冲突
源码分析
set()
先获取当前线程:Thread t = Thread.currentThread();
再获取属于当前线程的map对象
ThreadLocalMap map = getMap();
return t.threadlocals;(线程类的属性)
return t.threadlocals;(线程类的属性)
通过 map.set(this, value)
底层 new Entry(key, value) 给数组赋值,Entry类继承自WeakReference
Entry类的构造方法中有 super(key):创建弱引用对象指向key(tl 对象)
Entry类的构造方法中有 super(key):创建弱引用对象指向key(tl 对象)
弱引用防止
内存泄漏
内存泄漏
ThreadLocal<Person> tl = new ThreadLocal<>(); tl 指向ThreadLocal对象,强引用
tl.set(new Person()); => map.set( key, Person); key是指向ThreadLocal对象的 弱引用
这样当外部强引用消失时(tl=null或者方法运行完弹栈),ThreadLocal对象能够得到回收
否则如果map的key也是强引用,则只要线程还在运行,ThreadLocal的内存就不会被释放
否则如果map的key也是强引用,则只要线程还在运行,ThreadLocal的内存就不会被释放
get()
拿到当前线程,获取map对象,通过Entry中的key获取value后返回
remove()
拿到当前线程,获取map对象,将 map 中对应 tl 的 key 移除
什么地方存在内存泄露?
①外部强引用消失时,ThreadLocal对象可能无法回收,导致内存泄漏(jdk优化:通过弱引用已解决)
②ThreadLocal对象被回收后,map中Entry的value就访问不到了,如果线程一直运行则导致内存泄漏
解决②:ThreadLocal用完后,一定要调用remove()方法(线程池慎用ThreadLocal)
应用
线程本地变量/存储,避免了将对象作为参数传递的麻烦
延伸:把变量设置为static不是也可以传递吗?
static有局限性,多线程访问时会有数据污染
threadlocal可以保证每个线程访问私有的变量
Spring中@Transactional注解,使用ThreadLocal存储数据库连接connection,保证事务方法每次拿到的都是同一个connection
Spring中Bean在singleton作用域时,使用ThreadLocal解决共享变量的线程安全问题
线程
进程和线程
进程是操作系统分配资源的基本单位
线程是操作系统执行调度的基本单位
线程切换
CPU
ALU:计算单元
Registers:寄存器组(用来存数据)
PC:程序计数器
由操作系统的线程调度器控制
平分时间片算法
CFS算法
线程的特性
可见性
存在问题
数据的读取顺序:从内存读到 L3 → L2 → L1 → 寄存器后,ALU开始做计算
再次读取数据时,寄存器优先从 L1(本地缓存)中读取,不会重新从内存中读
解决
lock前缀指令
触发总线锁,独占对象的使用,保证对象被修改时刷新到主内存
触发MESI协议,使其他CPU缓存中该变量的副本失效
volatile
happen-before原则
MESI缓存一致性协议
有序性
存在问题
指令重排(单线程没有影响)
多线程下会有问题,比如DLC单例会取到半成品
解决
内存屏障
禁止指令重排序
原子性
存在问题
多线程下线程安全问题
比如100个线程做累加,最后结果小于预期值
解决
加锁
线程通信/同步的方式
volatile
synchronized
ReentrantLock
wait()、notify()
ThreadLocal
生命周期
6 种状态
新建(NEW)
使用 new 关键字创建线程后进入新建状态,此时还没有调用 start()
可运行(RUNNABLE)
线程调用 start() 方法后,进入就绪状态,等待CPU分配时间片
操作系统中就绪(READY)和运行中(RUNNING)两种状态的统称
操作系统中就绪(READY)和运行中(RUNNING)两种状态的统称
阻塞(BLOCKED)
当进入 synchronized 同步代码块或同步方法时,且没有获取到锁,
线程就进入了 blocked,直到锁被释放,重新进入 runnable 状态
线程就进入了 blocked,直到锁被释放,重新进入 runnable 状态
等待(WAITING)
当线程调用 wait() 或者 join() 时,会进入到 waiting 状态,当调用
notify 或 notifyAll 时,或者 join 的线程执行结束后会进入 runnable
notify 或 notifyAll 时,或者 join 的线程执行结束后会进入 runnable
超时等待(TIMED_WAITING)
当线程调用 sleep (time)或者 wait (time)时,进入 timed waiting 状态
当休眠时间结束后,或者调用 notify 或 notifyAll 时会重新进入 runnable
当休眠时间结束后,或者调用 notify 或 notifyAll 时会重新进入 runnable
终止(TERMINATED)
程序执行结束,线程进入 terminated 状态
扩展
java.lang.Thread类的源码中有个内部枚举,这里可以看到6种状态
创建线程的方式
继承Thread类
重写run方法,没有返回值
new MyThread().start();
缺点:Java是单继承,如果继承Thread就不能继承其他类
代码实现
实现Runnable接口
重写run方法,没有返回值
在创建Thread对象时传进去
优点:不受单继承的限制
代码实现
实现Callable接口
重写call方法,有返回值,可抛异常
需要用FutureTask在外部封装一下再传递给
Thread,FutureTask就是Runnable的实现类
Thread,FutureTask就是Runnable的实现类
FutureTask使用场景?
两件事或多件事同时完成
FutureTask两个个构造函数?
第一个构造函数要求传入Callable对象
第二个构造函数要求传入Runnable对象和返回值类型
FutureTask的其他方法?
get()
会一直等待子线程运行结束
get(5,TimeUnit.SECOND)
传入等待时间,超时后会抛出TimeoutException异常,需要捕捉处理
isDone()
询问子线程是否执行完成,返回值是boolean
优点
在主线程中可获取到子线程的返回值
直接调用FutureTask对象的get()方法
为什么可以获取到?如何获取的?
在主线程中可获取到子线程发生的异常
通过getCause()方法获取子线程的异常
代码实现
使用线程池(单独重点介绍)
终止/退出线程的方式
run() 方法运行结束
如何判断线程是否停止
this.interrupted()
返回线程状态并停止线程
this.isInterrupted()
不具备清除状态功能
使用退出标志位/共享变量
定义全局变量,使用volatile修饰的boolean退出标志位来控制循环
调用interrupt()方法终止线程
区分阻塞和和非阻塞两种情况
区分阻塞和和非阻塞两种情况
调用interrupt()时会抛出异常,处于阻塞状态中的线程可捕获interruptedException异常,通过break跳出循环
调用interrupt()时会调用interrupted()函数,未阻塞的线程可使用isInterrupted()判断线程的中断标志来退出循环
守护线程
在java线程中有两种线程,一种是用户线程,一种是守护线程,典型得守护线程就是垃圾回收线程
守护线程是一种特殊得线程,当进程中不存在用户线程(非守护线程)时,守护线程自动销毁
可以使用setDaemon()设置线程为守护线程,注意不能把一个正在运行的线程设置为守护线程
所以,setDaemon()方法必须在start()方法前面,并且守护线程中产生得线程也是守护线程
所以,setDaemon()方法必须在start()方法前面,并且守护线程中产生得线程也是守护线程
常见问题
谈谈你对线程安全的理解?
当多个线程访问一个对象时,如果不进行额外的同步控制或其他的协调操作,
调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的
调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的
什么时候考虑线程安全?
多个线程访问同一个资源
资源是有状态的,比如字符串拼接
如何做到线程安全?
使用Synchronized关键字给代码块或者方法加锁
比如StringBuffer的源码中,方法上均添加了Synchronized
sleep()与wait()的区别?
sleep() 属于Thread类,wait() 属于Object类
sleep() 不会释放对象锁,wait() 会释放对象锁
sleep() 必须指定时间,wait() 可指定也可以不指定
sleep() 可以使用在任何代码块,wait() 必须在同步方法或同步代码块中使用
思考:为什么wait要定义在Object中而不定义在Thread中?
Java的锁是对象级别的,不是线程级别的
sleep() 休眠指的就是线程休眠,所以在Thread类
思考:为什么wait必须写在同步代码块中?
避免 CPU 切换到其他程,而其他线程又提前执行了 notify 方法,那这样就
达不到我们的预期(先 wait 再由其他程来唤醒),所以需要一个同步锁来保护
达不到我们的预期(先 wait 再由其他程来唤醒),所以需要一个同步锁来保护
notify()和notifyAll()有什么区别?
使用notifyall可以唤醒所有处于wait状态的线程,使其重新进入锁的争夺队列中,
而notify只能唤醒一个,推荐使用notifyall
而notify只能唤醒一个,推荐使用notifyall
interrupted()和isInterrupted()的区别?
interrupted查询当前线程的中断状态,并且清除原状态
isInterrupted仅仅是查询当前线程的中断状态
Java中用到的线程调度算法是什么?
有两种调度模型:分时调度模型和抢占式调度模型
分时调度模型是指让所有的线程轮流获得cpu的使用权,并且平均分配每个线程
占用的CPU的时间片这个也比较好理解
占用的CPU的时间片这个也比较好理解
java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU
如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU
如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU
多线程的应用场景
文件的上传下载、后台定时任务、异步处理消息(邮件、短信)
为什么使用多线程
吞吐量和伸缩性,通过增加CPU核数来提升性能
并发与并行的区别?
并发:同一个 CPU 执行多个任务,按细分的时间片交替执行
并行:在多个 CPU 上同时处理多个任务
CPU
存储层次结构(金字塔)
寄存器 → L1缓存 → L2缓存 → L3缓存 → 主内存 → 本地磁盘 → 远程数据库
越往上访问速度越快、成本越高,空间更小。越往下访问速度越慢、成本越低,空间越大
多级缓存
为什么要设置多级缓存?
为了解决CPU运算速度与内存读写速度不匹配的矛盾
多级缓存的工作原理
在CPU和内存之间,引入了L1高速缓存、L2高速缓存、L3高速缓存
每一级缓存中所存储的数据全部都是下一级缓存中的一部分
每一级缓存中所存储的数据全部都是下一级缓存中的一部分
当CPU需要数据时,先从缓存中取,加快读写速度,提高CPU利用率
总线锁和缓存锁
总线锁
锁住总线,通过处理器发出lock指令,总线接受到指令后,阻塞其他处理器的请求,
直到此处理器执行完成。这样该处理器就可以独占共享内存的使用
直到此处理器执行完成。这样该处理器就可以独占共享内存的使用
缺点:一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,影响多核处理器的性能
缓存锁
不锁总线,只锁住被缓存共享的对象(缓存行)
缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成
缓存行(cache line)
为了压榨CPU的性能,一次获取一整块的内存数据(64个字节)放入缓存
为什么是
64字节?
64字节?
与CPU架构有关,通常有32字节、64字节、128
字节不等。目前64位架构下,64字节最为常用
字节不等。目前64位架构下,64字节最为常用
设置的太小了,读取速度快,但命中率太低
设置的太大了,命中率很高,但读取速度慢
伪共享问题
多核多线程并发场景下,如果多核要操作的共享变量处于
同一缓存行,某CPU更新该缓存行中的数据,会导致其他
处理器缓存中的缓存行失效,每次用还要去主存重新加载
同一缓存行,某CPU更新该缓存行中的数据,会导致其他
处理器缓存中的缓存行失效,每次用还要去主存重新加载
解决方案:字节填充
字节填充
long在java中占用8字节,在其前后额外填充7个long类型的变量
将目标变量放入缓存行时,可以实现一个缓存行中只有目标变量
将目标变量放入缓存行时,可以实现一个缓存行中只有目标变量
缓存一致性协议
当多个处理器都涉及同一块主内存区域的更改时,将导致各自的的缓存数据不一致,为了解决这个问题
所以提出了缓存一致性协议,这类协议有 MSI、MESI、MOSI等,其中最常见的就是 Intel 的MESI 协议
所以提出了缓存一致性协议,这类协议有 MSI、MESI、MOSI等,其中最常见的就是 Intel 的MESI 协议
MESI协议
MESI 协议是四种状态的缩写,用来修饰缓存行的状态(在每个缓存行前额外使用2bit,来表示这四种状态)
监听(嗅探)机制
M 已修改
Modified
Modified
该缓存行的数据被修改了,和主存数据不一致
监听所有想要修改此缓存行对应的内存数据的操作,该操作必须等缓
存行数据更新到主内存中,状态变成 S (Shared)共享状态之后执行
存行数据更新到主内存中,状态变成 S (Shared)共享状态之后执行
E 独占
Exclusive
Exclusive
该缓存行和内存数据一致,数据只在本缓存中
监听所有读取此缓存行对应的内存数据的操作,如果发生这种操作,
Cache Line 缓存状态从独占转为共享状态
Cache Line 缓存状态从独占转为共享状态
S 共享
Shared
Shared
该缓存行和内存数据一致,数据位于多个缓存中
监听其他缓存使该缓存行失效或者独享该缓存行的操作,如果检测到
这种操作,将该缓存行变成无效
这种操作,将该缓存行变成无效
I 失效
Invaild
Invaild
该缓存行的数据无效
没有监听,处于失效状态的缓存行需要去主存读取数据
JMM
通俗来讲,每个线程都有自己的工作内存(本地内存),线程之间的共享变量存储在主内存,工作内存中存储
了共享变量的副本,不同线程之间无法直接访问对方工作内存中的变量,线程间的通信均需要在主内存完成
了共享变量的副本,不同线程之间无法直接访问对方工作内存中的变量,线程间的通信均需要在主内存完成
工作内存 = 虚拟机栈
主内存 = 堆区 + 方法区
线程安全的特性
原子性
可见性
有序性
CAS
CAS是一种自旋操作,有3个操作数,变量v,期待值a,修改值b,当a=v时,将变量值修改为b,否则什么都不做
Compare and Swap:比较并交换
底层原理是sum.misc.Unsafe类,调用native方法实现
在系统的最底层CPU指令支持:lock cmpxchg
在系统的最底层CPU指令支持:lock cmpxchg
cmpxchg:CPU级别的CAS操作,非原子性
lock:多核CPU时,采用类似总线锁来完成CAS
lock指令在执行后面指令的时候锁定一个北桥信号
缺点
ABA问题
使用时间戳或版本号机制
只能保证一个共享变量的原子操作
如果CAS一直不成功会给CPU带来很大开销
应用:Atomic包
Volatile
可见性
一句话概括
线程在对volatile修饰的变量 执行写操作时会立刻把写入的值刷新到主内存,同时使副本失效,同步数据
① happen-before
对于两个操作 A 和 B,这两个操作可以在不同的线程中执行。如果 A Happens-Before B
那么可以保证,当 A 操作执行完后,A 操作的执行结果对 B 操作是可见的(8种规则之一)
那么可以保证,当 A 操作执行完后,A 操作的执行结果对 B 操作是可见的(8种规则之一)
原理:执行写操作的时候JVM会给CPU发送一条 lock前缀指令
CPU会立即将这个值写回主内存(总线锁)
同时使其他CPU缓存中的副本失效(触发MESI)
② MESI:缓存一致性协议
原理:各个CPU会对总线进行嗅探,如果发现有人修改了某个缓存的数据,那么CPU就会
将自己本地的缓存过期掉,再次读取那个变量的时候,就会从主内存重新加载最新的数据
将自己本地的缓存过期掉,再次读取那个变量的时候,就会从主内存重新加载最新的数据
MESI是四种修饰缓存行的四种状态的缩写,详细分析见上一小节(CPU架构)
有序性
内存屏障
禁止指令重排
禁止指令重排
内存屏障其实也是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成
JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令来禁止特定的指令重排序
JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令来禁止特定的指令重排序
什么是指令重排?
计算机底层字节码的执行顺序并不是按照你写的顺序
因素
编译器优化的重排
指令并行也可能重排
内存系统也会重排
禁止指令重排的应用
DCL单例模式
四种类型
LoadLoad:保证load1的读取操作在load2及后续读取操作之前执行
LoadStore:在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
StoreLoad:保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行
StoreStore:在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
不保证原子性
如果一个变量被volatile修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是
最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性
最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性
解决
加锁
AtomicInteger原子类
应用
单例模式的懒汉式(double-check)
适合只有一个线程修改,其他线程读取的情况
比如作为状态位,A线程调用了某个方法让B线程感知
Synchronized
锁类型
可重入锁:同一个线程可以多次获取同一把锁,不会因为之前已经获取过还没释放而阻塞
非公平锁:请求资源的线程在进入ContentionList前先尝试自旋获取锁,获取不到再排队
作用范围
静态方法
锁对象:类对象
成员方法
锁对象:实例对象
代码块
看括号中指定的是类对象还是实例对象
monitorenter、monitorexit
底层原理
线程尝试获取Monitor对象,对象内部维护着两个队列,一个计数器,获取锁之后计数器+1
Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步
如果monitor的计数器为0,则线程占有monitor,然后将计数器设置为1,该线程即为monitor的所有者,
如果当前线程已经占有monitor,只是重新进入,则monitor的计数器加1;执行monitorexit时计数器减1,
如果其他线程已经占用了monitor,则当前线程进入阻塞状态,直到monitor的进入数为0再尝试获取
如果当前线程已经占有monitor,只是重新进入,则monitor的计数器加1;执行monitorexit时计数器减1,
如果其他线程已经占用了monitor,则当前线程进入阻塞状态,直到monitor的进入数为0再尝试获取
Monitor
虚拟队列:Contention List,把线程包装为
竞争队列:EntryList
等待队列:WaitSet
Owner:当前已经获取到所资源的线程被称为 Owner
MarkWord里存什么?
锁信息、GC信息、HashCode
锁膨胀
1.6以后Synchronized进行了优化(无锁→偏向锁→轻量级锁→重量级锁)
只能升不能降(四种状态)
循环等待的锁就是轻量级锁:消耗CPU资源
拥有等待队列的锁就是重量级锁:不消耗CPU
锁标志位(MarkWord)
01:表示无锁或者偏向锁
00:表示轻量级锁(自旋锁)
10:表示重量级锁
过程
1、线程访问同步资源时,先查看锁标志位,如果处于“01”状态,则再查看偏向锁标识,0表示无锁,1表示偏向锁
2、如果是无锁状态则通过CAS将threadid设置为当前线程,让其获取偏向锁后继续执行,线程不会主动释放偏向锁
3、如果是偏向锁状态,则查看持有偏向锁的线程是否是自己,如果是则直接往下执行,不需要重新加锁,效率高
4、如果持有偏向锁的线程不是自己,那么就会去执行 撤销偏向锁 操作,升级为轻量级锁,尝试自旋获取轻量级锁
5、如果锁竟争情况严重,自旋尝试一定次数后(默认10次)依然没有拿到锁,则将轻量级锁升级为重量级锁(10)
6、当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(不消耗CPU),等待将来被唤醒
撤销偏向锁过程?
在一个全局安全点(没有字节码在执行)去暂停拥有偏向锁的线程,然后检查其状态,如果持有锁
的线程已经退出,则将偏向锁状态置为0,当前线程再去获取偏向锁,替换MarkWord中的ThreadID
获取偏向锁后继续执行,如果持有锁的线程仍处于活动状态,就会将偏向锁升级为轻量级锁(00)
的线程已经退出,则将偏向锁状态置为0,当前线程再去获取偏向锁,替换MarkWord中的ThreadID
获取偏向锁后继续执行,如果持有锁的线程仍处于活动状态,就会将偏向锁升级为轻量级锁(00)
如何判断持有偏向
锁的线程已经退出?
锁的线程已经退出?
涉及到JVM的c++源码(thread.cpp),线程执行start时,会将自己写入thread_list
线程执行结束后,从thread_list中移除,只需判断thread_list是否存在该线程即可
线程执行结束后,从thread_list中移除,只需判断thread_list是否存在该线程即可
扩展
只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁
撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态
为什么要引入偏向锁?
并发度很低的情况下,同一个线程可直接获取锁,避免轻量级锁内存拷贝的麻烦
偏向锁也可以直接升级为重量级锁,重度竞争耗时过长会升级重量级锁
为什么要引入自旋锁?
自旋锁不会让线程阻塞,避免线程在操作系统用户态和内核态的切换,提升效率
轻量级锁的加锁过程?
JVM会先在当前线程的栈帧中分配Lock Record空间,将锁对象头中的Mark Word拷贝到当前线程的Lock Record
中,称为Displaced Mark Word,然后使用CAS,将对象头中的Mark Word修改为指向当前线程栈中Lock Record
的指针如果成功,则获取轻量级锁,执行同步块中的代码,如果失败,则进行自旋竞争锁
中,称为Displaced Mark Word,然后使用CAS,将对象头中的Mark Word修改为指向当前线程栈中Lock Record
的指针如果成功,则获取轻量级锁,执行同步块中的代码,如果失败,则进行自旋竞争锁
升级为重量级锁的过程?
JVM配置参数可指定次数,后来JVM优化为自适应自旋锁,这个参数没了,JVM通过算法升级
偏向锁的效率一定比自旋锁高吗?
不一定,因为在撤销偏向锁时也是需要消耗资源的,如果已经知道某个资源
一定会有多个线程竞争,建议关闭偏向锁,直接使用轻量级锁,效率更高
一定会有多个线程竞争,建议关闭偏向锁,直接使用轻量级锁,效率更高
关闭偏向锁:-XX:-UseBiasedLocking
偏向锁默认开启,但会有4秒延时
为什么4秒后再开启?
一是JVM启动时有很多资源要同步(耗时),
二是如果一开始竞争就很激烈,直接轻量级锁
二是如果一开始竞争就很激烈,直接轻量级锁
关闭偏向锁延时:-XX:BiasedLockingStartupDelay=0
锁膨胀是不是可逆的?
一般不可逆,极端条件下是可逆的(阿里P8级别面试题)
借助 MarkWord中的 epoch
0 条评论
下一页