目录
5.sleep()、wait()、join()、yield()
7.为什么wait()、notify()、notifyAll()方法不在JVM提供的Thread类静态方法里?
8.为什么wait()与notify()方法要放在同步代码块中执行?
1.什么是线程池?有哪些优势?常用API有哪些?什么时候用?
一、多线程专题
1.线程和进程的区别是什么?
(1)基本概念:
进程是对程序的封装;是系统进行资源调度和分配的基本单位,实现了操作系统的并发;
线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现了进程内部的并发;线程是操作系统可以识别的最小执行和调度单位;每个线程都独自占有一个虚拟处理器:独立的寄存器组、指令计数器和处理器状态(栈页锁独立的)。每个线程完成不同的任务,但是共享统一地址空间(也就是同样的动态内存(堆区),映射文件(映射区),目标代码等待),打开的文件队列和其他内核资源(例如Socket,文件句柄什么的)。
(2)区别:
根本区别:进程是操作系统资源分配的基本单位,而线程是CPU执行和调度的基本单位;
<1>从资源开销上讲:每个进程都有独立的代码和数据空间(程序上下文),进程之间的切换会有较大的开销;线程可以看作轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小;
<2>从包含关系上讲:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以下次也被成为轻权进程或者轻量级进程;
<3>从内存分配上讲:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源的相互独立的;
<4>从影响关系上讲,一个进程奔溃后,在保护模式下不会堆其他进程产生影响,但是一个线程崩溃整个进程都会死掉。所以多进程要比多线程健壮;
<6>从执行关系上讲,每个独立的进程有程序运行的入口,顺序执行序列和程序出口。但是线程不能独立执行,必须一寸在应用程序中,由应用程序提供多个程序执行控制,两者均可并发执行。
<5>从通信上讲:同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现 变得比较容易。线程间可以直接读写进程数据段(如全局变量)来进行通信—— 需要线程同步和互斥手段的辅助,以保证数据的一致性。而进程间通信IPC主要包括:管道, 系统IPC(包括消息队列、信号量、 信号、共 享内存等) 以及套接字socket。
(3)线程安全的理解?
线程安全指的是,我们写某段代码,在多个线程同时执行这段代码时,不会产生混乱,依然能够得到正确的结果,比如i++,i初始值为0,那么两个线程来通知执行这行代码,如果代码线程是安全的,那么最终的结果一个线程应为1,一个线程是结果为2,如果出现了两个线程的结果都是1,则表示这段代码不安全。
(4)对守护线程的理解?
线程分为用户线程和守护线程,用户线程就是普通的线程,守护线程就是JVM的后台线程,比如垃圾回收线程就是一个守护线程,守护线程会在其它普通线程都停止运行之后自动关闭。我们可以通过设置thread.setDaemon(true)来把一个线程设置为守护线程。
2.实现多线程的方式
方式 | 描述 |
实现Runnable接口/Callable接口 | (1)重写run()方法或者call()方法; (2)Runnable接口无返回值,Callable接口有返回值; (3)call()可以抛出异常,run()不行; (4)Callable任务提供了Future对象(阻塞等待),可以通过它获取异步执行结果 |
继承Thread类 | 缺点:不能继承其他类 |
线程池 | 4种线程池类型、5种阻塞队列、7大核心参数、4种拒绝策略 |
3.线程的五种状态
状态 | 时机 | 描述 |
新建 | new Thread() | 线程创建后的初始状态 |
就绪 | 调用start()方法时 | 进入就绪状态,等待被CPU调用后自动执行run(),现在不执行 |
运行 | CPU调用就绪状态的线程时,执行run()方法 | 只有就绪状态才能变成运行状态,run()方法前必须先start(),否则直接执行的话相当于一个普通的方法,而且是主线程中执行的 |
阻塞 | 调用阻塞方法时 | (1)等待阻塞:调用wait()方法时; (2)同步阻塞:获取synchronized锁失败时; (3)其他阻塞:调用sleep()/join()方法,或者发出I/O请求时;阻塞完还线程会重新变为就绪状态,等待CPU执行。 |
死亡 | 线程执行结束或者异常 | (1)run()/call()执行结束,线程正常结束; (2)线程抛出未捕获的异常(Runnable接口不能捕获异常,Callable可以捕获); (3)调用stop()方法,容易造成死锁,不推荐使用 |
线程礼让yield()线程停止运行,进入到就绪状态,等待CPU调度执行。
wait()会释放锁;并且等待notify()唤醒。
4.线程死锁及解决方法
(1)死锁条件
死锁条件 | 描述 | 破坏死锁条件 |
互斥条件 | 任一时刻资源只被一个线程获取 | 不能破坏,我们本来就想让临界资源互斥 |
请求与保持 | 一个进程因请求资源阻塞时,对已持有的资源保持不放 | 一次性申请全部的资源 |
不可剥夺 | 线程在没有使用完资源前,不能被其他线程所剥夺,只能主动释放 | 当申请不到其他资源时,主动释放自己持有的资源 |
循环等待 | 互相等待对方释放资源,造成头尾相连 | 锁排序,指定获取锁的顺序;资源排序,按顺序获取资源 |
(2)死锁解决
方法 | 关键 | 描述 |
死锁预防 | 破坏死锁条件 | (1)允许资源抢占:当一个线程长时间申请不到它的资源时,主动释放自己所持有的资源; (2)一次性申请全部的资源; (3)资源排序或者锁排序,按照顺序申请资源; |
死锁避免 | 系统在进行资源分配之前,计算此次分配资源的安全性,若分配不会导致系统进入不安全状态,则分配,否则等待; | 银行家算法:保证循环等待条件不成立 |
死锁检测 | 通过dfs深度优先遍历,遍历资源分配图检测是否存在环 | (1)无环:没有死锁; (2)有环,但是涉及到多个资源,不一定有死锁; (3)有环,但是涉及到的资源仅有一个:死锁; |
死锁解除 | 终止死锁进程/抢占死锁进程的资源 | 打破循环等待条件 |
鸵鸟策略 | 忽略死锁 | 例如Linux、Windows等处理死锁都不采取任何行动,保持高性能 |
5.sleep()、wait()、join()、yield()
区别 | sleep() | wait() | yield() | join() |
归属类 | JVM提供的Tread类静态方法 | JDK提供的Object类实例方法 | JVM提供的Thread类静态方法 | JDK提供的Object类实例方法(底层是wait()方法) |
作用 | 暂停线程 | 暂停线程 | 重新就绪状态 | 线程调度 |
是否释放锁 | 睡眠不释放锁 | 进入阻塞队列、释放锁 | 释放锁 | 不释放锁 |
使用时机 | 任何时候 | 搭配synchronized关键字使用 | 想使得同级别的线程重新回就绪状态时 | 需要等待异步线程执行完毕返回结果时 |
解除状态 | 超时或调用Interrupt()方法 | 其他线程调用notify()或者notifyAll()方法 | 根本不会暂停线程,因此不存在唤醒条件 | 当异步线程执行结束时 |
解除后的状态 | 运行 | 就绪 | 就绪 | 运行 |
6.notify()和notifyAll()的区别?
如果线程调用了对象的wait()方法,那么线程便会处于该对象关联的Monitor监视器的阻塞队列WaitSet中,阻塞队列中的线程不会去竞争该对象的锁。
notifyAll()会唤醒所有线程,notify()只唤醒一个线程;
notifyAll()调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,不成功则留在候选队列中等待锁被释放后再次参与竞争。而notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。
是Monitor监视器里面一个候选人队列,等待竞争锁;
7.为什么wait()、notify()、notifyAll()方法不在JVM提供的Thread类静态方法里?
(1)因为Java提供的锁是对象级而不是线程级的,每个对象都关联了一个Monitor监视器对象,因此对应的wait()、notify()、notifyAll()方法也是对象级别的(即JDK提供的Object方法);
(2)如果这些方法定义在JVM中的Thread类中,那么线程正在等待的是哪个对象就不明确了;
8.为什么wait()与notify()方法要放在同步代码块中执行?
(1)这是Java的API强制要求的,如果不这样做,会抛出【Monitor状态异常】的异常;
(2)同时也是为了避免wait()和notify()之间产生竞争条件;
9.线程的阻塞和等待状态有什么区别?
区别就在于,线程阻塞没有获取到锁,而线程等待就是已经获取到锁;
类型 | 区别 |
阻塞 | 当前线程试图获取锁,而锁被其他线程持有着,则当线程就进入了阻塞状态。也就是线程和其他线程抢锁没抢到,就处于阻塞状态了;(此时线程还没进同步代码块) |
等待 | 线程抢到了锁进了同步代码块,(由于某种业务需求)某些条件下Obiect.wait()或join()了,就进入等待状态。(此时线程已经进入了同步代码块);是一种主动行为,你不知道它什么时候被阻塞,也不清楚什么时候会恢复阻塞。 |
10.如何在两个线程间共享数据?
同一个 Runnable ,使用全局变量。
(1)第一种:将共享数据封装到一个对象中,把这个共享数据所在的对象传递给不同的 Runnable
(2)第二种:将这些 Runnable 对象作为某一个类的内部类,共享的数据作为外部类的成员变量,对共享数据的操作分配给外部类的方法来完成,以此实现对操作共享数据的互斥和通信,作为内部类的 Runnable 来操作外部类的方法,实现对数据的操作。
11.什么是FutureTask?
(1)在Java并发程序中Future Task表示一个可以取消的异步运算。它有启动和取消运算、查询运算是否完成和取回运算结果等方法。只有当运算完成的时候才能取回,如果运算尚未完成get()方法就会阻塞;
(2)一个FutureTask对象可以对调用了Callable和Runnable的对象进行包装,由于FutureTask也是调用了Runnable接口所以它可以提交个Exector来执行。
12.如何检测一个线程是否持有锁?
在Thread类中有一个holdLock()方法,返回true则代表该线程目前持有锁;
13.同步代码块内的线程抛出异常怎么办?
同步代码块内的线程抛出异常,会释放锁;
14.守护线程
守护线程是运行在后台的一种特殊线程;独立于控制中断并且周期性的执行某种任务或者等待处理某些发生的事件,JVM垃圾回收线程就是守护线程。
二、线程池专题
1.什么是线程池?有哪些优势?常用API有哪些?什么时候用?
解释:
线程池,顾名思义就是一个线程的缓存,线程是稀缺资源,如果被无限制的创建,不仅会消耗资源,还会降低系统的稳定性,因此Java中提供线程池堆线程进行统一分配、调优和监控。
优势:
(1)线程复用,减少线程创建、销毁时的开销,提高性能。
(2)提高响应速度,当任务到达时,无需等待下次创建就能立即执行。
(3)提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
什么时候用线程池:
(1)多个操作系统之间互不影响,或者多个操作需要汇总之类的场景;
(2)需要处理的任务量很大。
常用API:
①:execute(Runnable command):执行行Ruannable类型的任务.
②:submit(task):可用来提交Callable或Runnable任务,并返回代表此任务的Future对象.
③:shutdown():温柔的关闭线程池,停止接受新任务,并执行完未完成的任务。
④:shutdownNow():强制关闭线程池,未完成的任务会以列表形式返回!
⑤:isTerminated():返回所有任务是否执行完毕。当调用shutdown()方法后,并且所有提交的任务完成后返回为true;当调用shutdownNow()方法后,成功停止后返回为true;
⑥:isShutdown():返回线程池是否关闭,当调用shutdown()或shutdownNow()方法后返回为true。
线程池的五种状态:
2.线程池的7大参数、拒绝策略、阻塞队列
默认线程池:ThreadPoolExector
参数 | 描述 |
corePoolSize (核心线程数) | 当提交一个任务时,线程池会创建一个新线程执行任务,此时线程不会复用。如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行,此时如果核心线程有空闲,回去阻塞队列中领取任务,此时核心线程复用。 |
maximumPoolSize (最大线程数) |
线程池最大线程数量。如果当前阻塞队列满了,且继续提交任务,此时若当前线程数小于maximumPoolSize,则可以创建新的线程(非核心线程)执行任务。
|
keepAliveTime(非核心线程存活时间) |
非核心线程的心跳时间。如果非核心线程在
keepAliveTime
内没有运行任务,非
核心线程会消亡。
|
TimeUnit | keepAliveTime的时间单位。 |
workQueue (阻塞队列) |
用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
(1)ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
(2)LinkedBlockingQueue:基于链表结构的无界阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQueue;
(3)SynchronousQueue:一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用的线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略;
(4)priorityBlockingQueue:具有优先级的无界阻塞队列;
(5)DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期慢时才能从中提取元素;
|
handler (拒绝策略) | 线程池的拒绝策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了四种拒绝策略: (1)AbortPolicy:直接抛出异常,默认策略; (2)CallerRunsPolicy:用调用者所在的线程来执行任务; (3)DiscardOldestPolicy:丢弃阻塞队列中最靠前的任务(最老的任务),并 执行当前任务; (4)DiscardPolicy:直接丢弃任务,不报错; |
ThreadFactory (线程工厂) | 创建线程的工厂,可以设定线程名、线程编号等;默认使用 Executors.defaultThreadFactory() 来创建线程。 |
3.线程池的常用类型:
一般是这样:
(1)最大线程数是无限的线程池,因为有任务就可以创建线程,因此不需要阻塞队列保存线程任务,所以通常为了防止线程果多而导致栈溢出或堆溢出,会搭配一个有界队列;(防止内存溢出)
(2)最大线程数有限的线程池,因为有线程任务可能会多于最大线程池数,因此等待执行的线程任务会搭配一个无界阻塞队列;(防止内存溢出)
(3)与时间相关的线程池,会搭配延迟队列;
类型 | 线程数 | 阻塞队列 |
CachedThreadPool (可缓存线程池) | 核心线程数=0,非核心线程=无线 | 搭配有界阻塞队列SynchronousQueue |
FixedThreadPool (混合线程池) | 核心线程数=最大核心线程数,线程不会被回收 | 搭配无界阻塞队列:LinkedBlockingQueue |
SingleThreadPool(单线程线程池) | 核心线程数=最大核心线程数=1 | 搭配无界阻塞队列:LinkedBlockingQueue |
ScheduleThreadPool(定时线程池) | 核心线程数=最大核心线程数,线程不会被回收;keepAliveTime=0 | 搭配延迟队列:DelayQueue |
4.线程池执行任务流程(详细)
★★★线程池执行流程-总结下描述
步骤 | 方法 | 描述 |
Step1 | 执行execute(任务)/submit(任务) | ① 调用ctl.get()方法,获取当前线程池状态(Running-Shutdown-Stop-Tidying-Terminated),若当前线程池状态不是Running状态或者为Shutdown状态但是工作队列已经为空,那么就直接返回,执行失败; ② 调用workerCount()方法,获取当前【工作线程数】;基于此判断是执行拒绝策略还是执行addWorker()方法; ③ 当【工作线程数 > 核心线程数】并且 【阻塞队列未满】,那就向阻塞队列中添加任务;添加成功后,再次调用ctl.get()方法判断线程池当前状态;若此时线程池状态不是Running,则删除任务并执行拒绝策略 |
Step2 | addWorker(任务,boolean)方法执行时机; 传入true用核心线程执行任务,false用非核心线程执行任务 | (1)当【工作线程数<核心线程数】那几调用addWorker(任务,true),创建一个核心线程去执行; (2)若任务已经添加到了阻塞队列中但是此时工作线程数为0,那么调用addWorker(null,false),创建一个没有绑定任务的非核心线程,当runWorker()中的getTask()方法获取任务后,会将阻塞队列中的任务置换到Worker对象的null任务; (3)若任务【工作线程数>核心线程数】并且【阻塞队列已满】,那么也调用addWorker(任务,true)方法; |
Step3 | 执行addWorker(任务,boolean)方法,返回值是boolean; 任务执行成功返回true,执行失败返回false; | (1)首先调用wokerCount(),获取当前线程池工作线程数量,若【工作线程数>线程池最大容量】,则返回false,并执行【拒绝策略】; (2)如果没有执行拒绝策略,那么正常执行;创建一个worker对象,并与当前要执行任务绑定【即new Worker(任务)】,将这个Worker对象添加到Workers容器(HashSet)中;若添加成功则在addWorker()的最后调用了Worker中任务的start()方法,使得任务进入就绪状态,等待run()方法执行; (3)Worker对象继承了AQS并实现了Runable接口,因此可以当作一个并发安全的线程使用,线程池中真正工作的线程就是绑定了任务Worker线程; |
Step4 | 分配一个线程执行run()方法,底层调用的是runWorker(Worker w)方法,其中还要一个getTask()方法 | (1)runWorker(Worker w)方法去除Worke对象中封装的Runnable任务; (2)若任务为null,那么while死循环的调用getTask()方法,从阻塞队列中获取任务; (3)getTask()方法通过调用workcount()方法获取当前线程池的工作线程数量,判断当前线程是核心线程还是非核心线程;①如果是非核心线程:那么就调用workQueue.poll(keepAliveTime)方法,在存活时机内从阻塞队列中获取任务,若超过了keepAliveTime还没有获得任务,则该非核心线程就会被回收掉,这就是【非核心线程被回收的原理】;②若是核心线程:那么调用workQueue.take()一直从阻塞队列中获取任务,直到获取成功,没有期限;【这也是为什么核心线程能够不被回收的原理】 (4)获取任务后,Worker对象内部的空Runnable任务会置换为阻塞队列中获取的Runnable对象,来达到线程复用的效果;任务不为null之后,那么就直接执行接下来的逻辑,首先执行beforeExecute()方法,做一些线程执行任务之前的工作;然后执行任务.run(),开始真正执行任务,最后在执行afterExecute()方法,做一些线程执行任务完的一些工作; (5)当任务执行结束后,就会从Worker容器中移除; |
Worker对象
Worker是ThreadPoolExecute的一个内部类,继承了AQS,可以通过加锁保证线程安全
Worker实现了Runnable接口,可以当做一个线程去使用
Worker中有两个线程变量:
firstTask:代表任务线程。通过start方法执行任务
thread:代表Worker本身,由ThreadFactory创建,参数为this,所以创建的线程其实指向的是Worker本身,装饰者模式;
线程池中是怎么复用线程的? -runworker()
非核心线程超时提出是如何实现的?-getTask()
步骤 | 描述 |
Step1 | 在源码中ThreadPoolExecutor中提供了一个内置对象Worker,Worker继承了AQS而且实现了Runnable接口,可以当作一个并发安全的线程去使用; |
Step2 | 当执行了run()方法时,线程池中有个方法叫做runWorker(),它内部有一个while()死循环,每个Worker对象不断的调用getTask()方法,尝试从阻塞队列中获取任务; |
Step3 | getTask()方法内部通过调用ctl.get() 方法,获取工作线程数量,并判断工作线程数是否大于核心线程数;如果当前是非核心线程,那么就结合参数keepAliveTime,在有效存活时间while() 死循环调用同步队列中的workQueue.poll(keepAlive Time),如果超时则回收掉非核心线程;若是核心线程,则一直调用workQueue.take() 方法获取任务,无限期,直到线程池关闭; |
Step4 | 当getTask()方法从阻塞队列获取到了任务时,Worker对象内部的Runnable属性会替换为阻塞队列中获取的Runnable对象,然后调用run()方法,来达到线程复用的效果; |
5. 线程池执行工作原理:
(1)创建线程池,开始等待请求;
(2)当调用execute()方法去添加一个请求任务时,线程池会做出如下判断:
<1>如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;
<2>如果正在运行的线程数量大于等于corePoolSize,那么将这个任务放入队列;
<3>如果这个时候队列满了且正在运行的线程数量小于maximumPoolSize,那么创建非核心线程去执行任务。
(3)如果队列满了且正在运行的线程数量大于等于maximumPoolSize,那么线程池会启动饱和拒绝策略来执行;
(4)当一个线程完成任务时,它会从队列取下一个任务来执行;
(5)当一个线程无事可做超过一定的时机(keepAliveTime)时,线程会判断:
<1>如果当前运行的线程数大于corePoolSize,那么这个线程就会被停掉;
<2>如果线程池的所有任务完成之后,它会收缩到corePoolSize的大小。
6.线程池出现异常如何处理?
方式 |
手动在run()方法中加try-catch |
自定义线程工厂(7大参数中的),使用Thread.setDefaultUncaughtExceptionHander方法捕获异常 |
自定义线程池,重写afterExecute进行异常处理,即可处理execute(); 也可以处理submit()的异常 |
方案一:在线程任务的run()方法中添加try-catch,缺点就是每个线程任务都需要加try-catch,太麻烦了;
方案二:自定义线程工厂(7大参数中),使用 Thread.setDefaultUncaughtExceptionHandler方法捕获异常
方案三: 自定义线程池,重写afterExecute进行异常处理,即可处理execute()也可以处理submit()的异常
7.自定义线程池的参数依据
类型 | 线程数 | 描述 |
CPU密集型 | 线程数=CPU核数+1 | 也叫计算密集型,系统中大部分时间用来做计算、逻辑判断等,一般而言CPU占用率相当高。多线程跑的时候,可以充分利用起所用的cpu核心,比如说4个核心的cpu,开4个线程的时候,可以同时跑4个线程的运算任务,此次是最大效率。但是如果线程远远超过cpu核心数量,反而会使得任务效率下降,因为频率的切换线程也是消耗时间; |
IO密集型 | 线程数=((线程等待时间+线程CPU时间)/线程CPU时间)*CPU数目 | 指任务需要执行大量的IO操作,涉及到网络、磁盘的IO操作。当线程因为IO阻塞而进入阻塞状态后,该线程的调度被操作系统内核立即停止,不再占用CPU时间片段,而其他IO线程能立即被操作系统内核调度,等IO阻塞操作完成后,原来阻塞章台的线程重新变成就绪状态,而可以被操作系统调度。所以,像数据库服务器中的IO密集型线程来说,线程数量就应该适当多点。 |
三、AQS简介以及ReentrantLock
AQS
1.谈谈你对AQS的理解?
AQS是多线程同步器,它是J.U.C包中多个组件的底层实现,如 Lock、 CountDownLatch、Semaphore 等都用到了 AQS。 从 本质上来说,AQS 提供了两种锁机制,分别是排它锁,和共享锁。 所谓排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如 Lock 中的 ReentrantLock 重入锁实现就是用到了 AQS 中的排它锁功能。 共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如 CountDownLatch(闭锁) 和 Semaphore(信号量) 都是用到了 AQS 中的共享锁功能。
AQS作为互斥锁来说,它的整个设计体系需要解决的三个核心的问题:①互斥变量的设计以及多线程同时更新互斥变量时的安全性;②未竞争到锁资源的线程的等待以及竞争到锁资源的线程释放锁之后的唤醒③锁竞争的公平性和非公平性。
(原理)AQS采用了一个用voliate修饰的互斥变量state用来记录锁竞争的一个状态,0表示当前没有任何线程竞争锁资源,而大于等于1表示已经有线程正在持有锁资源。一个线程来获取锁资源的时候,首先判断state是否等于0,如果是(无锁状态),则把这个state更新成1,表示占用到锁。此时如果多个线程进行同样的操作,会造成线程安全问题。AQS采用了CAS机制来保证互斥变量state的原子性。未获取到锁资源的线程通过Unsafe类中的park方法对线程进行阻塞,把阻塞的线程按照先进先出的原则加入到一个CLH双向队列中,当获得锁资源的线程释放锁之后,会从双向链表的头部去唤醒下一个等待的线程再去竞争锁。另外关于公平性和非公平性问题,AQS的处理方式是,在竞争锁资源的时候,公平锁需要判断双向链表中是否有阻塞的线程,如果有,则需要去排队等待;而非公平锁的处理方式是,不管双向链表中是否存在等待锁的线程,都会直接尝试更改互斥变量state去竞争锁。假设在一个临界点,获得锁的线程释放锁,此时state等于0,而当前的这个线程去抢占锁的时候,正好可以把state修改成1,那么表示它可以拿到锁,这个过程是非公平的。
2.AQS的实现
同步器的设计是基于模板方法模式的,需要实现同步器的一般的方式是这样:
(1)首先使用基础AbstractQueuedSynchronized并重写指定的方法。
(2)将AQS组合在同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
(3)同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,具体线程等待队列的维护,AQS已经实现。实现同步器要么是独占式,要么是共享式。
AQS也支持同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
(4)在acquire()和acquireShared()两种方式下,线程在等待队列中忽略中断。
在acquireInterruptibly()/acquireSharedInterruptibly()支持响应中断。
方法 | 解释 |
isHeldExclusively() | 判断该线程是否正在独占资源。 |
tryAcquire(int) | 独占式。尝试获取资源,成功返回true,失败返回false |
tryRelease(int) | 独占方式。尝试释放资源,成功返回true,失败返回false |
tryAcquireShared(int) | 共享方式。尝试获取共享资源。负数表示失败;0表示成功,但是没有剩余可用资源,正数表示成功,且有剩余资源。 |
tryReleaseShared(int) | 共享方式。尝试释放资源,如果释放后允许唤醒后序等待节点返回true,否则返回false |
总的来说,AQS就是基于同步等待CLH队列,获取volatile修饰的共享变量state,线程通过CAS自旋改变状态符,成功则获取锁,失败进入同步等待队列。
3.AQS具体实现--独占式和共享式
实现 | 具体实现 |
独占式-ReentrantLock | 独占:只有一个线程能执行; (1)state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1; (2)之后其它线程再想tryAcquire的时候就会失败,直到A线程unlock()到state==0位置,其它线程才有机会获取该锁; (3)A释放锁之前,自己也是可以重复获取此锁(state累加),这就可重入锁的概念。 |
共享式-CountDownLatch (计数器) | 共享:多个线程能同时执行; (1)任务分N个子线程去执行,state就初始化为N,N个线程并行执行; (2)每个线程执行完之后countDown()一次,state就会CAS减一; (3)当N个子线程执行完的时候state减为0,会unpark()主调用线程,主掉用线程会从await()函数返回,继续之后的动作。 |
ReentrantLock可以实现公平锁也可以实现非公平锁,也是互斥锁以及可重入锁。
四、延申问题
1.怎么在不加锁的情况下解决线程安全问题?
所谓的线程安全问题,其实是指多个线程同时对于某个共享资源的访问,导致的原子性、可见性和有序性的问题,这些问题会导致共享数据存在一个不可预测性,使得程序在执行过程中呢,会出现一个超出预期的一个结果,第二个,一般情况下解决线程安全问题的方式,是增加同步锁,常见的是像synchronized、Lock等,由于导致线程安全问题的根本原因,是多线程并行访问共享资源,对共享资源加锁之后呢,多个线程在访问这个资源的时候,必须要先获得锁,也就是先获得访问资格,而同步锁的特征是在同一个时刻,只允许一个线程访问这样一个资源,直到锁被释放,虽然这种方式呢,可以解决线程安全性的一个问题,但同时带来的是加锁和释放锁锁带来的一个性能开销,因为加锁会涉及到用户空间到内核空间的一个转换,以及上下文切换,第三个啊,如何在性能和安全性之间去取得一个平衡,这就引出了一个无锁并发的概念,一般来说呢,会有以下几种方法,第一个是通过自旋锁(CAS),所谓自旋锁是指线程在没有抢占的锁的情况下,先自旋指定的次数,去尝试获得锁,第二个是乐观锁,给每个数据增加一个版本号,一旦数据发送变化,则去修改这个版本号,在Java里面呢,有一个叫CAS的一个机制,可以完成乐观锁的一个功能,第三个是在程序涉及中呢,尽量去减少共享对象的一个使用,从业务上去实现隔离避免并发,以上呢就是我的一个回答。