请火速收藏!!!盘点2023那些被问的最多的线程相关面试题!
请火速收藏!!!盘点2023那些被问的最多的线程相关面试题!
4.1、wait() 和 sleep() 方法的区别
来源不同:sleep() 来⾃ Thread 类,wait() 来⾃ Object 类。对于同步锁的影响不同:sleep() 不会该表同步锁的⾏为,如果当前线程持有同步锁,那么 sleep 是不会让线程释放同步锁的。wait() 会释放同步锁,让其他线程进⼊ synchronized 代码块执⾏。
使用范围不同:sleep() 可以在任何地⽅使⽤。wait() 只能在同步控制⽅法或者同步控制块⾥⾯使⽤,否则会抛 IllegalMonitorStateException。
恢复方式不同:两者会暂停当前线程,但是在恢复上不太⼀样。sleep() 在时间到了之后会重新恢复;wait() 则需要其他线程调⽤同⼀对象的 notify()/nofityAll() 才能重新恢复。
4.2、线程的 sleep() 方法和 yield() 方法有什么区别?
线程执⾏ sleep() ⽅法后进⼊超时等待(TIMED_WAITING)状态,⽽执行 yield() ⽅法后进⼊就绪(READY)状态。
sleep() 方法给其他线程运⾏机会时不考虑线程的优先级,因此会给低优先级的线程运⾏的机会;
yield() 方法只会给相同优先级或更⾼优先级的线程以运⾏的机会。
4.3、线程的 join() ⽅法是⼲啥⽤的?
⽤于等待当前线程终⽌。如果⼀个线程A执⾏了 threadB.join() 语句,其含义是:当前线程A等待
threadB 线程终⽌之后才从 threadB.join() 返回继续往下执⾏⾃⼰的代码。
4.4、编写多线程程序有⼏种实现⽅式?
通常来说,可以认为有三种⽅式:1)继承 Thread 类;2)实现 Runnable 接⼝;3)实现 Callable接⼝。其中,Thread 其实也是实现了 Runable 接⼝。Runnable 和 Callable 的主要区别在于是否有返回值。
4.5、Thread 调⽤ start() ⽅法和调⽤ run() ⽅法的区别?
run():普通的⽅法调⽤,在主线程中执⾏,不会新建⼀个线程来执⾏。
start():新启动⼀个线程,这时此线程处于就绪(可运⾏)状态,并没有运⾏,⼀旦得到 CPU 时间⽚,就开始执⾏ run() ⽅法。
4.6、线程的状态流转
⼀个线程可以处于以下状态之⼀:
NEW:新建但是尚未启动的线程处于此状态,没有调⽤ start() ⽅法。
RUNNABLE:包含就绪(READY)和运⾏中(RUNNING)两种状态。线程调⽤ start() ⽅法会会进⼊就绪(READY)状态,等待获取 CPU 时间⽚。如果成功获取到 CPU 时间⽚,则会进⼊运⾏中(RUNNING)状态。
BLOCKED:线程在进⼊同步⽅法/同步块(synchronized)时被阻塞,等待同步锁的线程处于此状态。
WAITING:⽆限期等待另⼀个线程执⾏特定操作的线程处于此状态,需要被显⽰的唤醒,否则会⼀直等待下去。例如对于 Object.wait(),需要等待另⼀个线程执⾏ Object.notify() 或Object.notifyAll();对于 Thread.join(),则需要等待指定的线程终⽌。
TIMED_WAITING:在指定的时间内等待另⼀个线程执⾏某项操作的线程处于此状态。跟 WAITING类似,区别在于该状态有超时时间参数,在超时时间到了后会⾃动唤醒,避免了⽆期限的等待。
TERMINATED:执⾏完毕已经退出的线程处于此状态。线程在给定的时间点只能处于⼀种状态。这些状态是虚拟机状态,不反映任何操作系统线程状态
4.7、synchronized 和 Lock 的区别
1)Lock 是⼀个接⼝;synchronized 是 Java 中的关键字,synchronized 是内置的语⾔实现;
2)Lock 在发⽣异常时,如果没有主动通过 unLock() 去释放锁,很可能会造成死锁现象,因此使⽤Lock 时需要在 finally 块中释放锁;synchronized 不需要⼿动获取锁和释放锁,在发⽣异常时,会⾃动释放锁,因此不会导致死锁现象发⽣;
3)Lock 的使⽤更加灵活,可以有响应中断、有超时时间等;⽽ synchronized 却不⾏,使⽤synchronized 时,等待的线程会⼀直等待下去,直到获取到锁;
4)在性能上,随着近些年 synchronized 的不断优化,Lock 和 synchronized 在性能上已经没有很明显的差距了,所以性能不应该成为我们选择两者的主要原因。官⽅推荐尽量使⽤ synchronized,除⾮ synchronized ⽆法满⾜需求时,则可以使⽤ Lock。
4.8、为什么说 synchronized 是⼀种悲观锁?乐观锁的实现原理又是什么?什么是CAS,它有什么特性?
synchronized 显然是⼀个悲观锁,因为它的并发策略是悲观的:
不管是否会产⽣竞争,任何的数据操作都必须加锁,⽤⼾态核⼼态转换,维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。随着硬件指令集的发展,我们可以使⽤基于冲突检测的乐观并发策略。先进⾏操作,如果没有其他线程征⽤数据,那么就操作成功了;如果共享数据有征⽤,产⽣了冲突,就再进⾏其他的补偿措施。这种乐观的并发策略的许多实现不需要线程池挂起,所以被称为⾮阻塞同步。
乐观锁的核⼼算法是 CAS(Compareand Swap,⽐较并交换),它涉及到三个操作数:内存值、预期值、新值。并且仅当预期值和内存值相同时才将内存值修改为新值。
这样处理的逻辑是,⾸先检查某块内存的值是否跟之前读取的⼀样,如果不⼀样则表示此内存值已经被别的线程更改,舍弃本次操作,否则说明期间没有其他线程对此内存值操作,可以把新值设置给此块内存。
CAS 具有原⼦性,它的原⼦性由 CPU 硬件指令实现保证,即使用 JNI 调用 Native ⽅法调⽤由 C++ 编写的硬件级指令,JDK中提供了 Unsafe 类执行这些操作。
任何技术都要找到适合的场景,都不是万能的,CAS 机制也⼀样,也有副作⽤。
问题1:
作为乐观锁的⼀种实现,当多线程竞争资源激烈的情况下,⽽且锁定的资源处理耗时,那么其他线程就要考虑⾃旋的次数限制,避免过度的消耗 CPU。
另外,可以使⽤ LongAdder 来解决,LongAdder 以空间换时间的⽅式,来解决 CAS ⼤量失败后长时间占用 CPU 资源,加⼤了系统性能开销的问题。
问题2:
A-->B--->A 问题,假设有⼀个变量 A ,修改为B,然后⼜修改为了 A,实际已经修改过了,但 CAS 可能⽆法感知,造成了不合理的值修改操作。
整数类型还好,如果是对象引⽤类型,包含了多个变量,那怎么办?即加个版本号或时间戳JDK 中 java.util.concurrent.atomic 并发包下,提供了 AtomicStampedReference,通过为引⽤建立个 Stamp 类似版本号的⽅式,确保 CAS 操作的正确性。
4.9、synchronized 各种加锁场景的作用范围
1)作⽤于⾮静态⽅法,锁住的是对象实例(this),每⼀个对象实例有⼀个锁。
public synchronized void method() {}
2)作⽤于静态⽅法,锁住的是类的Class对象,因为Class的相关数据存储在永久代元空间,元空间
是全局共享的,因此静态⽅法锁相当于类的⼀个全局锁,会锁所有调⽤该⽅法的线程
1 public static synchronized void method() {}
3)作⽤于 Lock.class,锁住的是 Lock 的Class对象,也是全局只有⼀个。
1 synchronized (Lock.class) {}
4)作⽤于 this,锁住的是对象实例,每⼀个对象实例有⼀个锁。
1 synchronized (this) {}
5)作⽤于静态成员变量,锁住的是该静态成员变量对象,由于是静态变量,因此全局只有⼀个。
public static Object monitor = new Object();
synchronized (monitor) {}
4.10、如何检测死锁?
死锁的四个必要条件:
1)互斥条件:进程对所分配到的资源进⾏排他性控制,即在⼀段时间内某资源仅为⼀个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
2)请求和保持条件:进程已经获得了⾄少⼀个资源,但⼜对其他资源发出请求,⽽该资源已被其他进程占有,此时该进程的请求被阻塞,但⼜对⾃⼰获得的资源保持不放。
3)不可剥夺条件:进程已获得的资源在未使⽤完毕之前,不可被其他进程强⾏剥夺,只能由⾃⼰释放。
4)环路等待条件:存在⼀种进程资源的循环等待链,链中每⼀个进程已获得的资源同时被 链中下⼀个进程所请 求。即存在⼀个处于等待状态的进程集合{Pl, P2, …, pn},其中 Pi 等待的资源被P(i+1) 占有(i=0, 1, …, n-1),Pn 等待的资源被 P0占 有,如下图所⽰。
4.11、怎么预防死锁?
预防死锁的⽅式就是打破四个必要条件中的任意⼀个即可。
1)打破互斥条件:
在系统⾥取消互斥。若资源不被⼀个进程独占使⽤,那么死锁是肯定不会发⽣的。但⼀般来说在所列的四个条件中,“互斥”条件是⽆法破坏的。因此,在死锁预防⾥主要是破坏其他⼏个必要条件,⽽不去涉及破坏“互斥”条件。。
2)打破请求和保持条件:
1)采⽤资源预先分配策略,即进程运⾏前申请全部资源,满⾜则运⾏,不然就等待。
2)每个进程提出新的资源申请前,必须先释放它先前所占有的资源。
3)打破不可剥夺条件:当进程占有某些资源后⼜进⼀步申请其他资源⽽⽆法满⾜,则该进程必须释放它原来占有的资源。
4)打破环路等待条件:实现资源有序分配策略,将系统的所有资源统⼀编,所有进程只能采⽤按序号递增的形式申请资号源。
4.12、为什么要使用线程池?直接new个线程不是很舒服?
如果我们在⽅法中直接new⼀个线程来处理,当这个⽅法被调⽤频繁时就会创建很多线程,不仅会消耗系统资源,还会降低系统的稳定性,⼀不⼩⼼把系统搞崩了,就可以直接去财务那结帐了。
如果我们合理的使⽤线程池,则可以避免把系统搞崩的窘境。总得来说,使⽤线程池可以带来以下⼏个好处:
• 降低资源消耗。通过重复利⽤已创建的线程,降低线程创建和销毁造成的消耗。
• 提⾼响应速度。当任务到达时,任务可以不需要等到线程创建就能⽴即执⾏。
• 增加线程的可管理型。线程是稀缺资源,使⽤线程池可以进⾏统⼀分配,调优和监控。
4.13、线程池的核心属性有哪些?
threadFactory(线程⼯⼚):⽤于创建⼯作线程的⼯⼚。
corePoolSize(核⼼线程数):当线程池运⾏的线程少于 corePoolSize 时,将创建⼀个新线程来处理请求,即使其他⼯作线程处于空闲状态。
workQueue(队列):⽤于保留任务并移交给⼯作线程的阻塞队列。
maximumPoolSize(最⼤线程数):线程池允许开启的最⼤线程数。
handler(拒绝策略):往线程池添加任务时,将在下⾯两种情况触发拒绝策略:
1)线程池运⾏状态不是 RUNNING;
2)线程池已经达到最⼤线程数,并且阻塞队列已满时。
keepAliveTime(保持存活时间):如果线程池当前线程数超过 corePoolSize,则多余的线程空闲时间超过 keepAliveTime 时会被终⽌。
4.14、说下线程池的运作流程。
4.15、线程池有⼏种状态,每个状态分别代表什么含义?
线程池⽬前有5个状态:
RUNNING:接受新任务并处理排队的任务。
SHUTDOWN:不接受新任务,但处理排队的任务。
STOP:不接受新任务,不处理排队的任务,并中断正在进⾏的任务。
TIDYING:所有任务都已终⽌,workerCount 为零,线程转换到 TIDYING 状态将运⾏terminated() 钩⼦⽅法。
TERMINATED:terminated() 已完成。
4.16、线程池中的状态之间是怎么流转的?
4.17、线程池有哪些队列?
常⻅的阻塞队列有以下⼏种:
ArrayBlockingQueue:基于数组结构的有界阻塞队列,按先进先出对元素进⾏排序。
LinkedBlockingQueue:基于链表结构的有界/⽆界阻塞队列,按先进先出对元素进⾏排序,吞吐量通常⾼于 ArrayBlockingQueue。Executors.newFixedThreadPool 使⽤了该队列。
SynchronousQueue:不是⼀个真正的队列,⽽是⼀种在线程之间移交的机制。要将⼀个元素放⼊SynchronousQueue 中,必须有另⼀个线程正在等待接受这个元素。如果没有线程等待,并且线程池的当前⼤⼩⼩于最⼤值,那么线程池将创建⼀个线程,否则根据拒绝策略,这个任务将被拒绝。使⽤直接移交将更⾼效,因为任务会直接移交给执⾏它的线程,⽽不是被放在队列中,然后由⼯作线程从队列中提取任务。只有当线程池是⽆界的或者可以拒绝任务时,该队列才有实际价值。
Executors.newCachedThreadPool使⽤了该队列。
PriorityBlockingQueue:具有优先级的⽆界队列,按优先级对元素进⾏排序。元素的优先级是通过⾃然顺序或 Comparator 来定义的。
4.18、使⽤队列有什么需要注意的吗?
使⽤有界队列时,需要注意线程池满了后,被拒绝的任务如何处理。
使⽤⽆界队列时,需要注意如果任务的提交速度⼤于线程池的处理速度,可能会导致内存溢出。
4.19、线程池有哪些拒绝策略?
AbortPolicy:中⽌策略。默认的拒绝策略,直接抛出 RejectedExecutionException。调⽤者可以捕获这个异常,然后根据需求编写⾃⼰的处理代码。
DiscardPolicy:抛弃策略。什么都不做,直接抛弃被拒绝的任务。
DiscardOldestPolicy:抛弃最⽼策略。抛弃阻塞队列中最⽼的任务,相当于就是队列中下⼀个将要被执⾏的任务,然后重新提交被拒绝的任务。如果阻塞队列是⼀个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最⾼的任务,因此最好不要将该策略和优先级队列放在⼀起使⽤。
CallerRunsPolicy:调⽤者运⾏策略。在调⽤者线程中执⾏该任务。该策略实现了⼀种调节机制,该策略既不会抛弃任务,也不会抛出异常,⽽是将任务回退到调⽤者(调⽤线程池执⾏任务的主线程),由于执⾏任务需要⼀定时间,因此主线程⾄少在⼀段时间内不能提交任务,从⽽使得线程池有时间来处理完正在执⾏的任务。
4.20、线程只能在任务到达时才启动吗?
默认情况下,即使是核⼼线程也只能在新任务到达时才创建和启动。但是我们可以使⽤prestartCoreThread(启动⼀个核⼼线程)或 prestartAllCoreThreads(启动全部核⼼线程)⽅法来提前启动核⼼线程。
4.21、核⼼线程怎么实现⼀直存活?
阻塞队列⽅法有四种形式,它们以不同的⽅式处理操作,如下表。
核⼼线程在获取任务时,通过阻塞队列的 take() 方法实现的⼀直阻塞(存活)。
4.22、非核心线程如何实现在 keepAliveTime 后死亡?
原理同上,也是利⽤阻塞队列的⽅法,在获取任务时通过阻塞队列的 poll(time,unit) ⽅法实现的在延迟死亡。
4.23、非核⼼线程能成为核⼼线程吗?
虽然我们⼀直讲着核⼼线程和⾮核⼼线程,但是其实线程池内部是不区分核⼼线程和⾮核⼼线程的。
只是根据当前线程池的⼯作线程数来进⾏调整,因此看起来像是有核⼼线程于⾮核⼼线程。
4.24、如何终⽌线程池?
终⽌线程池主要有两种⽅式:
shutdown:“温柔”的关闭线程池。不接受新任务,但是在关闭前会将之前提交的任务处理完毕。
shutdownNow:“粗暴”的关闭线程池,也就是直接关闭线程池,通过 Thread#interrupt() ⽅法终⽌所有线程,不会等待之前提交的任务执⾏完毕。但是会返回队列中未处理的任务。
4.25、Executors 提供了哪些创建线程池的⽅法?
newFixedThreadPool:固定线程数的线程池。corePoolSize = maximumPoolSize,keepAliveTime为0,⼯作队列使⽤⽆界的LinkedBlockingQueue。适⽤于为了满⾜资源管理的需求,⽽需要限制当前线程数量的场景,适⽤于负载⽐较重的服务器。
newSingleThreadExecutor:只有⼀个线程的线程池。corePoolSize = maximumPoolSize = 1,keepAliveTime为0, ⼯作队列使⽤⽆界的LinkedBlockingQueue。适⽤于需要保证顺序的执⾏各个任务的场景。
newCachedThreadPool: 按需要创建新线程的线程池。核⼼线程数为0,最⼤线程数为Integer.MAX_VALUE,keepAliveTime为60秒,⼯作队列使⽤同步移交 SynchronousQueue。该线程池可以⽆限扩展,当需求增加时,可以添加新的线程,⽽当需求降低时会⾃动回收空闲线程。适⽤于执⾏很多的短期异步任务,或者是负载较轻的服务器。
newScheduledThreadPool:创建⼀个以延迟或定时的⽅式来执⾏任务的线程池,⼯作队列为DelayedWorkQueue。适⽤于需要多个后台线程执⾏周期任务。
newWorkStealingPool:JDK 1.8 新增,⽤于创建⼀个可以窃取的线程池,底层使⽤ ForkJoinPool实现。
4.26、线程池⾥有个 ctl,你知道它是如何设计的吗?
ctl 是⼀个打包两个概念字段的原⼦整数。
1)workerCount:指⽰线程的有效数量;
2)runState:指⽰线程池的运⾏状态,有 RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED 等状态。int 类型有32位,其中 ctl 的低29为⽤于表⽰ workerCount,⾼3位⽤于表⽰ runState,如下图所示
例如,当我们的线程池运⾏状态为 RUNNING,⼯作线程个数为3,则此时 ctl 的原码为:1010 0000
0000 0000 0000 0000 0000 0011
4.27、ctl 为什么这么设计?有什么好处吗?
ctl 这么设计的主要好处是将对 runState 和 workerCount 的操作封装成了⼀个原⼦操作。runState 和 workerCount 是线程池正常运转中的2个最重要属性,线程池在某⼀时刻该做什么操作,取决于这2个属性的值。
因此⽆论是查询还是修改,我们必须保证对这2个属性的操作是属于“同⼀时刻”的,也就是原⼦操作,否则就会出现错乱的情况。如果我们使⽤2个变量来分别存储,要保证原⼦性则需要额外进⾏加锁操作,这显然会带来额外的开销,⽽将这2个变量封装成1个 AtomicInteger 则不会带来额外的加锁开销,⽽且只需使⽤简单的位操作就能分别得到 runState 和 workerCount。
由于这个设计,workerCount 的上限 CAPACITY = (1 << 29) - 1,对应的⼆进制原码为:0001 1111 1111 1111 1111 1111 1111 1111(不⽤数了,29个1)。
通过 ctl 得到 runState,只需通过位操作:ctl & ~CAPACITY。~(按位取反),于是“~CAPACITY”的值为:1110 0000 0000 0000 0000 0000 0000 0000,只有高3位为1,与 ctl 进⾏ & 操作,结果为 ctl ⾼3位的值,也就是 runState。通过 ctl 得到 workerCount 则更简单了,只需通过位操作:c & CAPACITY。
4.28、在我们实际使用中,线程池的⼤⼩配置多少合适?
要想合理的配置线程池⼤小,⾸先我们需要区分任务是计算密集型还是I/O密集型。
对于计算密集型,设置 线程数 = CPU数 + 1,通常能实现最优的利⽤率。
对于I/O密集型,⽹上常⻅的说法是设置 线程数 = CPU数 * 2 ,这个做法是可以的,但其实并不是最优的。
在我们⽇常的开发中,我们的任务⼏乎是离不开I/O的,常⻅的⽹络I/O(RPC调⽤)、磁盘I/O(数据库操作),并且I/O的等待时间通常会占整个任务处理时间的很⼤⼀部分,在这种情况下,开启更多的线程可以让 CPU 得到更充分的使⽤,⼀个较合理的计算公式如下:
线程数 = CPU数 CPU利⽤率 (任务等待时间 / 任务计算时间 + 1)
例如我们有个定时任务,部署在4核的服务器上,该任务有100ms在计算,900ms在I/O等待,则线程数约为:4 1 (1 + 900 / 100) = 40个。
当然,具体我们还要结合实际的使⽤场景来考虑。
4.29、神奇的现象
Volatile是⾯试⾥⾯⼏乎必问的⼀个话题,很多朋友仅限于会⽤阶段,今天我们换个⻆度去了解
Volatile。
先来看⼀个例⼦:
package com.qf.test;
/**
@author Thor
@公众号 Java架构栈
*/
public class Demo1 {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
if(myThread.isFlag()){
System.out.println("here…..");
}
}
}
}
class MyThread extends Thread {
private boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag=" + flag);
}
}
虽然两个线程同时运⾏,第⼀个线程⼀直在循环,第⼆个线程把标记flag改成了true,但是你会发现,控制台永远打印不了“here....”。这是为什么呢?⾸先我们先来了解下JMM
4.30、计算机的内存模型
从图上可以看到,CPU和内存之间加⼊了⼀个⾼速缓存的⻆⾊。我们来分析下原因。在⽬前的计算机中,CPU的计算速度远远⼤于计算机存储数据的速度。为了提升整体性能,在CPU和内存之间加⼊了⾼速缓存。
CPU将计算需要⽤到的数据暂存进缓存中。当计算结束后再将缓存中的数据存⼊到内存中。这样CPU的运算可以在缓存中⾼速进⾏。但是这种情况在多核CPU中会存在⼀个问题,多个CPU使⽤各⾃的⾼速缓存,但多个⾼速缓存在共享同⼀个内存,此时就有可能⼀个CPU更新了数据,但另⼀个CPU还在操作⽼数据。导致脏数据的读写
问题,此时就需要缓存⼀致性协议来解决这个数据⼀致性的问题。
4.31、Java内存模型JMM
Java Memory Model,Java内存模型是Java虚拟机规范中定义的⼀种内存模型规范,也就是说JMM只是⼀种规范,即标准化。不同的虚拟机⼚商依据这套规范,来做底层具体的实现。计算机的内存模型帮我们简单梳理了下思路,接下来我们回到JMM。JMM做了⼀些约定和规范。
⼀段代码中的多线程,操作的共享变量,即成员变量或类变量。线程在操作共享变量时,先从主内存中将变量拷⻉到⼯作内存中,然后线程在⾃⼰的⼯作内存中操作。线程不能访问别⼈⼯作内存中的内容。线程间对变量值的传递是通过主内存进⾏中转。这个操作就会导致可⻅性问题,即⼀个线程更新了共享变量,但另⼀个已经加载了数据到⾃⼰⼯作内存的线程,是没办法看到最新的变量的值。这也是⽂章开始的那个demo出现的问题。
4.32、可见性解决⽅案-加锁
给代码加锁
public class Demo2 {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while(true){
synchronized (myThread) {
if(myThread.isFlag()){
System.out.println("here.....");
}
}
}
}
}
为什么给代码加锁就能解决可⻅性问题呢?
4.33、JMM数据同步
lock(锁定):作⽤于主内存的变量,把⼀个变量标记为⼀条线程独占状态 (触发总线锁)
unlock(解锁):作⽤于主内存的变量,把⼀个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取):作⽤于主内存的变量,把⼀个变量值从主内存传输到线程的⼯作内存中,以便随后的load动作使⽤
load(载⼊):作⽤于⼯作内存的变量,它把read操作从主内存中得到的变量值放⼊⼯作内存的变量副本中
use(使⽤):作⽤于⼯作内存的变量,把⼯作内存中的⼀个变量值传递给执⾏引擎
assign(赋值):作⽤于⼯作内存的变量,它把⼀个从执⾏引擎接收到的值赋给⼯作内存的变量store(存储):作⽤于⼯作内存的变量,把⼯作内存中的⼀个变量的值传送到主内存中,以便随后的write的操作
write(写⼊):作⽤于⼯作内存的变量,它把store操作从⼯作内存中的⼀个变量的值传送到主内存的变量中程序会按照上⾯的流程,在使⽤synchronized的代码前后,线程会获得锁,清空⼯作内存。read将数据读到⼯作内存并load成为最新的副本,再通过store和write将数据写会主内存。⽽获取不到锁的线程会阻塞等待,所以变量的值⼀直都是最新的。
4.34、使⽤Volatile保证可见性
package com.qf.test;
/**
@author Thor
@公众号 Java架构栈
*/
public class VisibilityVolatileDemo3 {
public static void main(String[] args) {
MyVolatileThread myThread = new MyVolatileThread();
myThread.start();
while (true) {
if (myThread.isFlag()) {
System.out.println("here…..");
}
}
}
}
class MyVolatileThread extends Thread {
private volatile boolean flag = false;
public boolean isFlag() {
return flag;
}
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag=" + flag);
}
}
使⽤了volatile后,操作数据的线程先从主内存中把数据读到⾃⼰的⼯作内存中。如果有线程对volatile修饰的变量进⾏操作并且写回了主内存,则其他已读取该变量的线程中,该变量副本将会失效。其他线程需要从主内存中重新加载⼀份最新的变量值。
Volatile保证了共享变量的可⻅性。当有的线程修改了Volatile修饰的变量值并写回到主内存后,其他线程能⽴即看到最新的值。但是Volatile不能保证原⼦性。
4.35、Volatile不能保证原⼦性
先看下⾯这个例⼦。
package com.qf.atomicity;
import java.util.concurrent.CountDownLatch;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class AtomicityDemo1 {
private static volatile int count = 0;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
try {
countDownLatch.await();
for (int i1 = 0; i1 < 1000; i1++) {
count++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
try {
Thread.sleep(500);
countDownLatch.countDown();
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
在这个例⼦中,并不会每次count的结果是10000,有的时候不⾜10000。于是,做如下调整。
private static volatile int count = 0;
当给变量count前加上了volatile修饰后,发现结果依然有可能不⾜10000。为什么会这样,我们先来看下count++的执⾏过程。
count++在执⾏引擎中被分成了两步操作:
• count = 0,先将count值初始化为0
• count=count+1,再执⾏+1操作
这两步操作在左边的线程执⾏完第⼀步,但还没执⾏第⼆步时右边的线程抢过CPU控制权开始完成+1的操作后写⼊到主内存,于是左边的线程⼯作内存中的count副本失效了,相当于左边这⼀次+1的操作就被覆盖掉了。
因此,Volatile不能保证原⼦性。
该如何保证原⼦性呢?⸺加锁。
package com.qf.atomicity;
import java.util.concurrent.CountDownLatch;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class AtomicityDemo1 {
private static volatile int count = 0;
static Object object = new Object();
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(() -> {
try {
countDownLatch.await();
for (int i1 = 0; i1 < 1000; i1++) {
synchronized (object) {
count++;
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
try {
Thread.sleep(500);
countDownLatch.countDown();
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
4.36、Volatile保证有序性-指令重排
我们先来看这个例⼦来了解什么是指令重排。
package com.qf.reorder;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class ReorderDemo {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i=0;
for(;;){
i++;
x=0;
y=0;
a=0;
b=0;
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
shortWait(10000);
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 :" + x + "," + y ;
System.out.println(result);
(x == 0 && y == 0) {
break;
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
在这个例⼦中,x和y的值只会有三种情况:
• x=1 y=1
• x=0 y=1
• x=1 y=0
如果发⽣指令重排,才会出现第四种:
• x=0 y=0
为了提⾼性能,编译器和处理器常常会对既定代码的执⾏顺序进⾏指令重排序
as-if-serial语义
不管怎么重排序,单线程程序的执⾏结果不能被改变。编译器、runtime
和处理器都必须遵守“
as-if
serial语义”。
也就是说,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执⾏结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
使⽤Volatile禁⽌指令重排
使⽤Volatile可以禁⽌指令重排优化,从⽽避免多线程环境下程序出现乱序执⾏的现象。Volatile通过设置内存屏障(Memory Barrier)来解决指令重排优化。
内存屏障
Java编译器会在⽣成指令系列时在适当的位置会插⼊“内存屏障指令”来禁⽌特定类型的处理器重排序。下⾯是内存屏障指令:
如果在指令间插⼊⼀条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory
Barrier指令重排序,也就是说通过插⼊内存屏障禁⽌在内存屏障前后的指令执⾏重排序优化。
Memory Barrier的另外⼀个作⽤是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到
这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可⻅性和禁
⽌重排优化。
接下来看⼀个经典的懒汉式单例模式,可能被指令重排⽽导致错误的结果。
package com.qf.reorder;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class Singleton {
private static volatile Singleton instance;
//私有的构造器
private Singleton() {
}
public static Singleton getInstance() {
//第⼀重检查锁定
if (instance == null) {
//同步锁定代码块
synchronized (Singleton.class) {
//第⼆重检查锁定
if(instance==null){
//注意:这⾥是⾮原⼦操作
instance = new Singleton();
}
}
}
return instance;
}
}
如果在⾼并发场景下,因为
instance = new Singleton(); 是⾮原⼦操作,这个对象的创建要经历这么⼏个步骤:
• 分配内存空间
• 调⽤构造器来初始化实例
• 返回地址给引⽤。
如果此时发⽣了指令重排,先执⾏了分配内存空间后直接返回地址给引⽤,再进⾏初始化。此时在这
个过程中另⼀个线程抢占,虽然引⽤不为空,但对象还没有被实例化,于是报空指针异常。
可以通过加⼊volatile来防⽌指令重排
package com.qf.reorder;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class Singleton {
//防⽌指令重排
private static volatile Singleton instance;
//私有的构造器
private Singleton() {
}
public static Singleton getInstance() {
//第⼀重检查锁定
if (instance == null) {
//同步锁定代码块
synchronized (Singleton.class) {
//第⼆重检查锁定
if(instance==null){
//注意:这⾥是⾮原⼦操作
instance = new Singleton();
}
}
}
return instance;
}
}
那么Volatile是怎么禁⽌指令重排?
4.37、Volatile指令重排语义
为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制
定
volatile重排序规则表:
这个规则在代码中体现:
package com.qf.reorder;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class MemoryBarrierDemo {
int a;
public volatile int m1 = 1;
public volatile int m2 = 2;
public void readAndWrite() {
int i = m1; // 第⼀个volatile读
int j = m2; // 第⼆个volatile读
a = i + j; // 普通写
int i = m1; // 第⼀个volatile读
m1 = i + 1; // 第⼀个volatile写
m2 = j * 2; // 第⼆个volatile写
a = i + j; // 普通写
}
}
4.38、MESI缓存⼀致性协议
在介绍Volatie保证可⻅性时,我们说到当两个线程在操作⼀个volatile修
饰的变量时,操作数据的线
程先从主内存中把数据读到⾃⼰的⼯作内存中。如果有线程对volatile修饰的变量进⾏操作并且写回
了主内存,则其他已读取该变量的线程中,该变量副本将会失效。其他线程需要从主内存中重新加载
⼀份最新的变量值。
那么被迫更新变量的线程是怎么知道操作的数据已被其他线程更新了呢?这就跟MESI缓存⼀致性协议
有关系。
早期技术较为落后,对总线上锁直接使⽤总线锁,也就是说CPU1访问到,CPU2⼀定不能操作,总线
锁并发性较差。MESI⽅式上锁是⽬前较为和谐的总线上锁的⽅式。
MESI协议缓存状态是四个单词的⾸字⺟:
M(Modified修改):当cpu2对变量进⾏修改时,现在cpu内的缓存⾏中上锁,并向总线发信号,此时cpu2中的变量状态为M
E(Exclusive独享):当cpu1读取⼀个变量时,该变量在⼯作内存中的状态是E
S(Shared共享):当cpu2读取该变量时,两个cpu中该变量的状态由E转为S。
I(Invalid⽆效):cpu1嗅探到变量被其他cpu修改的信号,于是将自己缓存⾏中的变量状态设置为i,即失效。则cpu1再从内存中获取最新数据。
总线风暴
由于Volatile的MESI缓存⼀致性协议,需要不断的从主内存嗅探和cas不断循环,⽆效交互会导致总线带宽达到峰值。所以不要⼤量使⽤Volatile,⾄于什么时候去使⽤Volatile,什么时候使⽤锁,根据场景区分。
总结
Volatile
volatile修饰符适⽤于以下场景:某个属性被多个线程共享,其中有⼀个线程修改了此属性,其他线程可以⽴即得到修改后的值,⽐如作为触发器,实现轻量级同步。
volatile属性的读写操作都是⽆锁的,它不能替代synchronized,因为它没有提供原⼦性和互斥性。因为⽆锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
volatile只能作⽤于属性,我们⽤volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
volatile提供了可⻅性,任何⼀个线程对其的修改将⽴⻢对其他线程可⻅,volatile属性不会被线程缓存,始终从主存中读取。
volatile可以在单例双重检查中实现可⻅性和禁⽌指令重排序,从⽽保证安全性。
Volatile和Synchronized区别
volatile只能修饰实例变量和类变量,⽽synchronized可以修饰⽅法,以及代码块。
volatile保证数据的可⻅性,但是不保证原⼦性(多线程进⾏写操作,不保证线程安全)。⽽synchronized是⼀种排他(互斥)的机制。 volatile⽤于禁⽌指令重排序:可以解决单例双重检查对象初始化代码执⾏乱序问题。
volatile可以看做是轻量版的synchronized,volatile不保证原⼦性,但是如果是对⼀个共享变量进⾏多个线程的赋值,⽽没有其他的操作,那么就可以⽤volatile来代替synchronized,因为赋值本⾝是
有原⼦性的,⽽volatile⼜保证了可⻅性,所以就可以保证线程安全了。
4.39、Synchronized
在多个线程操作同⼀共享变量时,在对临界资源操作时,容易出现线程安全问题。因此需要同步机制
来解决线程安全问题。与CAS乐观锁机制相同,Synchronized也能实现上锁,但Synchronized实现的是悲观锁。
Synchronized也称为内置锁或隐式锁,因为其加锁的⽅式很Lock不同,⽤了隐式上锁的⽅式。
学习Synchronized,我们重点关注以下⼏点:
• Synchronized在jdk1.6版本之前性能较差,1.6及之后使⽤了锁的膨胀升级
• Synchronized的底层实现逻辑
Synchronized应⽤场景
Synchronized⼀般⽤在以下这⼏种场景:
修饰实例⽅法,对当前实例对象(this)加锁
根据锁的粒度来选择使⽤哪⼀种,⽐如使⽤静态⽅法上锁,锁的粒度是整个Class对象,如果⼤量线程都在使⽤Class对象作为锁对象,那么锁的粒度很⼤。⽐如 System.out.println() 这种⽅式
底层是对PrintStream上锁,但PrintStream⼜是单例的,因此在代码中如果⼤量使⽤了System.out.println() ,性能会受影响。
/**
* Prints a String and then terminate the line. This method behaves as
* though it invokes <code>{@link #print(String)}</code> and then
* <code>{@link #println()}</code>.
*
* @param x The <code>String</code> to be printed.
*/
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
4.40、Synchronized锁的膨胀升级过程
Synchronized在1.6版本之前性能较差,在并发不严重的情况下,因为Synchronized依然对象上锁,每个对象需要维护⼀个Monitor管程对象,管程对象需要维护⼀个Mutex互斥量对象。Mutex是由操作
系统内部的pthread线程库维护的。上锁需要通过JVM从⽤⼾态切换到内核态来调⽤底层操作系统的指令,这样操作的性能较差。
AQS框架中的ReentrantLock锁通过Java语⾔编写,实现了可重⼊锁和公平锁,且性能⽐Synchronized要好太多。关于ReentrantLock的逻辑在下⼀个章节介绍。
JDK1.6版本为了弥补Synchronized的性能缺陷,设计了Synchronized锁的膨胀升级。也就是根据当前线程的竞争激烈程度,设计了不同效果的锁
对象头
在对象的创建的过程中,涉及到以下过程:
其中为对象设置对象头信息,对象头信息包含以下内容:类元信息、对象哈希码、对象年龄、锁状态
标志等。其中锁状态标志,就是当前对象属于哪⼀种锁。
对象头中的Mark Work 字段(32位)
对象头中的类型指针(Klass Pointer)
类型指针⽤于指向元空间当前类的类元信息。⽐如调⽤类中的⽅法,通过类型指针找到元空间中的该类,再找到相应的⽅法。
开启指针压缩后,类型指针只⽤4个字节存储,否则需要8个字节存储。
4.41、膨胀升级
• ⽆锁状态:当对象锁被创建出来时,在线程获得该对象锁之前,对象处于⽆锁状态。
偏向锁:在⼤多数情况下,锁不仅不存在多线程竞争,⽽且总是由同⼀线程多次获得,因此为了减少同⼀线程获取锁(会涉及到⼀些CAS操作,耗时)的代价⽽引⼊偏向锁。偏向锁的核⼼思想是,⼀旦有线程持有了这个对象,标志位修改为1,就进⼊偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。当这个线程再次请求锁时,⽆需再做任何同步操作,即获取锁的过程,这样就省去了⼤量有关锁申请的操作,从⽽也就提供程序的性能。对于锁竞争⽐较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使⽤偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会⽴即膨胀为重量级锁,⽽是先升级为轻量级锁。
• 轻量级锁:
如果对象是⽆锁的,JVM会在当前线程的栈帧中建⽴⼀个Lock Record(锁记录)的空间,⽤来存放对象的Mark Work拷⻉,然后把Lock Record中的owner属性指向当前对象。接下来JVM会利⽤CAS尝试把对象原本的Mark Word更新回Lock Record的指针,成功就说明加锁成功,于是改变锁标志位,执⾏相关同步操作。
如果失败了,判断当前对象的Mark Word是否指向当前线程的栈帧,如果是就表⽰当前线程已经持有该对象锁。如果不是,说明当前对象锁被其他线程持有,于是进⾏⾃旋。
• ⾃旋锁:
线程通过不断的⾃旋尝试上锁,为什么要⾃旋?因为如果线程被频繁挂起,也就意味着系统在⽤⼾态和内核态之间频繁的切换。⸺我们所有的程序都在⽤⼾空间运⾏,进⼊⽤⼾运⾏状态也就是(⽤⼾态),但是很多操作可能涉及内核运⾏,⽐如I/O,我们就会进⼊内核运⾏状态(内核态)。通过⾃旋,让线程在等待时不会被挂起。⾃旋次数默认是10次,可以通过
-XX:PreBlockSpin 进⾏修改。如果⾃旋失败到达阈值,则将升级为重量级锁。
注意,锁的膨胀升级,只能升不能降,也就是说升级过程不可逆。
4.42、Synchronized的底层实现逻辑
同步代码块的上锁逻辑
先来看⼀个Java例⼦:
ackage com.qf.intro;
/**
* @author Thor
* @公众号 Java项目库
*/
public class LockOnObjectDemo {
public static Object object = new Object();
private Integer count = 10;
public void decrCount(){
synchronized (object){
--count;
if(count <= 0){
System.out.println("count⼩于0");
return;
}
}
}
}
使⽤ javap -c LockOnObjectDemo.class 命令来看其中的信息:
Synchronized内置锁是⼀种对象锁,作⽤粒度是对象,可以⽤来实现对临界资源的同步互斥访问,是可重⼊的。具体的实现逻辑是通过内部对象Monitor(监视器锁)来实现。监视器锁的实现依赖底层操作系统的Mutex Lock(互斥锁)实现。互斥锁是⼀个重量级锁,且性能较低。
Synchronized关键字被编译成字节码后,会被翻译成monitorenter和monitorexit两条指令。这两条指令中的代码会被上锁。
4.43、Monitor监视器锁
任何⼀个对象都有⼀个Monitor与之关联,当对象的Monitor被持有后,该对象处于被锁定状态。具体过程如下:
当我们进⼊⼀个⽅法的时候,执⾏monitorenter,就会获取当前对象的⼀个所有权,这个时候monitor进⼊数为1,当前的这个线程就是这个monitor的owner。
• 如果你已经是这个monitor的owner了,你再次进⼊,就会把进⼊数+1.
• 当执⾏完monitorexit,对应的进⼊数就-1,直到为0,才可以被其他线程持有。
所有的互斥,其实在这⾥,就是看你能否获得monitor的所有权,⼀旦你成为owner就是获得锁者。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp⽂件,C++实现):
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,⽤来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问⼀段同步代码时:⾸先会进⼊ EntryList 集合,当线程获取到对象的monitor后,进⼊ Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
若线程调⽤ wait() ⽅法,将释放当前持有的monitor,owner变量恢复为null,count⾃减1,同时该线程进⼊WaitSet集合中等待被唤醒;
若当前线程执⾏完毕,也将释放monitor(锁)并复位count的值,以便其他线程进⼊获取
monitor(锁);
同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等⽅法会使⽤到Monitor锁对象,所以必须在同步代码块中使⽤。监视器可以确保监视器上的数据在同⼀时刻只会有⼀个线程在访问。
同步⽅法的上锁逻辑
先看这个例⼦:
package com.qf.intro;
/**
* @author Thor
* @公众号 Java项目库
*/
public class LockOnMethodDemo {
public static Object object = new Object();
private Integer count = 10;
public synchronized void decrCount() {
--count;
if (count <= 0) {
System.out.println("count⼩于0");
return;
}
}
}
查看代码指令后:
在同步⽅法⾥有⼀个标志位ACC_SYNCHRONIZED。
同步⽅法的时候,⼀旦执⾏到这个⽅法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调⽤刚才的两个指令:monitorenter和monitorexit。所以归根究底,还是monitor对象的争夺。
总结。
4.44、ReentrantLock介绍
AQS
AQS(AbstractQueuedSynchronizer)定义了⼀套多线程访问共享资源的同步器框架,是⼀个依赖
状态的同步器。AQS定义了很多并发中的⾏为,⽐如:
• 阻塞等待队列
• 共享/独占
• 公平/⾮公平
• 可重⼊
• 允许中断
ReentrantLock介绍
ReentrantLock是基于AQS框架实现的锁,它类似于Synchronized互斥锁,可以保证线程安全。基于AQS强⼤的并发特性和处理多线程的能⼒,ReentrantLock相⽐Synchronized,拥有更多的特性,⽐如⽀持⼿动加锁、解锁,⽀持公平锁等。
先来看⼀个例⼦
package com.qf.lock;
import java.util.concurrent.locks.LockSupport;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class MyLockSupportDemo {
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName()+":开始执⾏。");
for(;;){//⾃旋
System.out.println(thread.getName()+":即将park当前线程");
LockSupport.park();//⽤于阻塞住线程
System.out.println(thread.getName()+":当前线程已被唤醒");
}
}
},"thread-1");
t1.start();
try {
Thread.sleep(5000);
System.out.println("准备唤醒"+t1.getName()+"线程");
LockSupport.unpark(t1);//唤醒阻塞的线程
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
显示结果
thread-1:开始执⾏。
thread-1:即将park当前线程准备唤醒thread-1线程
thread-1:当前线程已被唤醒
thread-1:即将park当前线程
从这个例⼦可以推导出,ReentrantLock的核⼼是这么⼀个逻辑:
LockSupport上锁->⾃旋->队列
Reentrantlock上锁的例⼦
接下来看下Reentrantlock上锁的例⼦
package com.qf.lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class MyReentrantLockDemo {
public static ReentrantLock lock = new ReentrantLock(true);
public static void reentrantLock(){
lock.lock();
System.out.println(Thread.currentThread().getName()+":,第⼀次加锁");
lock.lock();
System.out.println(Thread.currentThread().getName()+":,第⼆次加锁");
lock.unlock();
System.out.println(Thread.currentThread().getName()+":,第⼀次解锁");
lock.unlock();
System.out.println(Thread.currentThread().getName()+":,第⼆次解锁");
}
public static void main(String[] args) {
Thread t0 = new Thread(new Runnable() {
@Override
public void run() {
reentrantLock();
}
},"t0");
t0.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
reentrantLock();
}
},"t1");
t1.start();
}
}
运行结果
t0:,第⼀次加锁
t0:,第⼆次加锁
t0:,第⼀次解锁
t0:,第⼆次解锁
t1:,第⼀次加锁
t1:,第⼆次加锁
t1:,第⼀次解锁
t1:,第⼆次解锁
4.45、公平锁和⾮公平锁
在ReentrantLock内部定义了Sync类,Sync类继承⾃AbstractQueuedSynchronizer类。我们发现
AbstractQueuedSynchronizer是多个AQS关键类中的基类。这个类涉及到上锁的核⼼逻辑
那ReentrantLock是如何实现公平锁和⾮公平锁呢?ReentrantLock默认使⽤⾮公平锁,也可以通过
构造器来显⽰的指定使⽤公平锁。在ReentrantLock中还有两个类继承⾃Sync:
• NonfairSync
• FairSync
他们实现公平和⾮公平的逻辑⾮常简单,我们先看⼀下公平锁,在获锁之前,通过
!hasQueuedPredecessors() 先看下是否有⼈排队,如果没有排队则尝试获锁,如果有排队,
则进⼊排队队列。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&//判断是否之前已有线程在等待锁
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
⽽⾮公平锁⽌是没有这个判断的。也就是说,⾮公平锁的情况下,相对较
晚来的线程,在尝试上锁的
时候,即使之前已经有等待锁的线程存在,它也是有可能上锁成功的。
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
但公平锁则是先等待的,先获得锁,后来的后获得锁。这是 hasQueuedPredecessors() ⽅法的逻辑
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
4.46、AbstractQueuedSynchronizer类的关键属性
ReentrantLock如何获得锁呢?先来看下AbstractQueuedSynchronizer类的结构。
•state:同步器状态变量,值为0时表⽰当前可以被加锁。值为1 时表⽰有线程占⽤,其他线程需要进⼊到同步队列等待,同步队列是⼀个双向链表。
• exclusiveOwnerThread:当前获取锁的线程
• head:指向基于Node类构造的队列的队头,同步队列是⼀个双向链表。
• tail:指向基于Node类构造的队列的队尾,同步队列是⼀个双向链表。
• Thread:表⽰当前线程的引⽤,⽐如需要唤醒的线程。
上锁逻辑
public final void acquire(int arg) {//1
if (!tryAcquire(arg) &&//尝试加锁
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))//没有加锁成功,尝试⼊队
列
selfInterrupt();
}
以公平锁上锁为例,当使⽤lock()上锁,会传⼊1作为cas对state状态量的预计值进⾏修改,前提是查看同步队列中是否没有其他线程等待。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&//查看同步队列
compareAndSetState(0, acquires)) {//CAS设置state值,期望旧值为0,
期望新值为1
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {//如果上锁失败,查看是否是
⾃⼰持有锁,如果是,state+1
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
如果上锁失败,查看是否是⾃⼰持有锁,如果是则state++。
如果没有加锁成功,则尝试进⼊队列
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
队列的细节
虽然说当前线程已经⼊队列了,但线程还没有阻塞,接下来线程要做阻塞。
什么时候被唤醒呢?在lock.unlock()中唤醒
protected final boolean tryRelease(int releases) {
int c = getState() - releases;//state-1
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);//所属线程置空
}
setState(c);
return free;
}
唤醒线程: