JUC2
Future接口
Future接口可以为主线程开一个分支任务,专门为主线程处理耗时的复杂业务。是jdk5新增的接口,它提供了一种异步并行计算的功能
常用实现类:FutureTask,实现了RunnableFuture接口,RunnableFuture接口实现了Runnable接口和Future接口,Thread的构造器只能使用Runnable,而FutureTask的构造器可以传入Callable,因此FutureTask可以当做Runnable创建线程,可以当做Future获取执行结果(取消、查看是否完成等接口里的方法),也可以使用Callable作为参数创建有返回值的线程
优点:future+线程池可以显著提高程序的执行效率
缺点:
- 只能使用get获取异步结果,且get会一直阻塞,直到执行结束,系统性能下降,一般放在最后面
- 使用isDone(),轮询容易导致cpu空转,耗费更多的系统资源
总结:FutureTask对于结果的获取不是很友好,只能通过阻塞或者轮询的方式得到任务的结果
对于复杂任务:使用Future的api不好处理,这时候使用CompletableFuture以声明式的方式优雅的处理这些需求,Future能做的,CompletableFuture都可以实现
- 对于简单业务场景使用完全可以
- 回调通知:对于Future完成后可以告诉调用者,或者执行一些逻辑,也就是回调通知,Future只能通过轮询去判断任务是否完成,非常占cpu并且代码也不优雅
- 创建异步任务:Future+线程池
- 多个任务前后依赖可以组合处理
- 想将多个异步任务的计算结果组合起来,后一个异步任务的计算结果需要前一个异步任务的值,如任务1->任务2->任务3
- 将两个或多个异步计算合成一个异步计算,者几个异步计算相互独立,后面的又依赖前面的处理结果,如任务1,任务2,任务3需要前面两个的结果
- 对计算速度选最快的,当Future集合中某个任务最快结束时,返回结果,返回第一名的处理结果
CompletableFuture类
由于Future接口的get方法会阻塞,isDone方法需要多次调用,因此希望通过传入回调函数,在Future结束时自动调用该回调函数。
阻塞的方式和异步编程的理念相违背,而轮询的方式会耗费无畏的cpu资源,因此jdk8设计了CompletableFuture类,CompletableFuture提供了一种观察者类似的机制,可以让任务执行完成后通知监听的一方。
CompletionStage接口
- CompletionStage接口代表异步计算过程中的某一个阶段,一个阶段完成以后可以触发另一个阶段
- 一个阶段的计算执行可以是一个Function、Consumer或者Runnable
- 一个阶段的执行可能是被单个阶段的完成触发,也可能是多个阶段一起触发
CompletableFuture类实现了Future接口和CompletionStage接口
- java8中,CompletableFuture类提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,并且提供了函数式编程的能力,可以通过回调的方式处理计算结果,也提供了转换和组合CompletableFuture的方法
- 它可能代表一个明确完成的Future,也可能代表一个完成阶段(CompletableFuture),它支持在计算完成以后出发一些函数或执行某些动作
- 是Future的增强版,减少阻塞和轮询,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法
api
- 静态方法:runAsync、supplyAsync、allOf、anyOf、completedFuture
- 获取结果:get、join、getNow
- 修改结果:complete、completeExceptionally
- 强制修改结果:obtrudeValue、obtrudeException
- 取消任务:cancel
- 查看状态:isCancelled、isDone、isCompletedExceptionally
- 处理结果:
- thenApply、thenApplyAsync
- thenAccept、thenAcceptAsync
- thenRun、thenRunAsync
- handle、handleAsync
- whenComplete
- exceptionally
- 任务备用:
- applyToEither、applyToEitherAsync
- acceptEither、acceptEitherAsync
- runAfterEither、runAfterEitherAsync
- 组合任务
- thenCombine、thenCombineAsync
- thenCompose、thenComposeAsync
- thenAcceptBoth、thenAcceptBothAsync
- runAfterBoth、runAfterBothAsync
Java锁
悲观锁和乐观锁
悲观锁:比较悲观,认为自己写数据的时候也会有别的线程写,所以先加锁,保证安全后再写数据
乐观锁:比较乐观,认为不会有别的线程写数据,一般实现方式有:版本号机制、cas算法
synchronized:
- synchronized同步代码块:实际使用的是monitorentor和monitorexit指令,一般是一个enter两个exit,如果里面自己添加了throw,那么是一个enter一个exit
- synchronized普通同步方法:在字节码的flags上会有ACC_SYNCHRONIZED,执行线程会先持有monitor锁,然后再执行方法,在方法完成后释放monitor
- synchronized静态同步方法:在字节码flags上会有ACC_STATIC和ACC_SYNCHRONIZED
每一个对象都关联了一个ObjectMonitor,记录了锁的一些信息,当线程获取锁时,需要先获取该管程对象,它的owner需要执行当前线程才表示拥有该管程对象。
公平锁和非公平锁
公平锁:按照线程申请锁的顺序来获取锁,先到先得。
非公平锁:各个申请锁的线程抢占锁,有可能造成某些线程一直获取不到锁。
ReentrantLock默认非公平锁:
- 没有获取到锁的线程会挂起,恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员看这个时间微乎其微,但从cpu的角度看,这个时间差还是很明显的,所以非公平锁更能充分利用cpu的时间片,尽量减少cpu空闲状态时间。
- 使用多线程很重要的考虑点是线程切换的开销,当采用非公平锁时,一个线程请求获取锁的同步状态,然后释放同步状态,刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。
如果是为了更高的吞吐量,使用非公平锁比较合适,因为节省很多线程切换时间,否则就使用公平锁,各个线程公平使用。
可重入锁(递归锁)
是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(同一个锁对象)。ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可以一定程度上避免死锁。
synchronized隐式锁原理:每个对象都能作为锁,因为每个对象都关联了一个ObjectMonitor,它有锁计数器(_count
记录获取锁的个数、_recursions
记录重入次数、_owner
指向持有ObjectMonitor的线程、_EntryList
记录阻塞的线程、_WaitList
记录等待的线程),当执行monitorexit时锁的计数器减1,
死锁
指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种相互等待的现象。
排除死锁:使用jps -l
查看进程号,使用jstack 进程号
查看线程堆栈。或者使用jconsole图形化界面查看
写锁(独占锁)/读锁(共享锁)
自旋锁SpinLock
无锁 - 独占锁 - 读写锁 - 邮戳锁
无锁 - 偏向锁 - 轻量锁 - 重量锁
总结
LockSupport与线程中断
线程中断
一个线程不应该由其他线程来强制中断或停止,而是应用由线程自己自行停止,所以Thread的stop/suspend/resume都已经被废弃了。
在Java中没有办法立即停止一条线程,而停止线程却尤为重要,比如一个耗时操作,因此Java提供了一种用于停止线程的协商机制————中断,也即是中断标识协商机制。
中断过程完全由程序员自己实现,若要中断一个线程,需要手动调用该线程的interrupt()方法,该方法也仅仅将线程对象的中断标识设置为true。如果线程处于阻塞状态(如sleep/wait/join等),那么会立刻抛出中断异常,中断标识重置为false。
Api:
- interrupt()实例方法:仅仅是将当前线程的中断状态设置为true
- isInterrupted()实例方法:判断当前线程是否被中断
- Thread.interrupted()静态方法:返回中断状态并重置
面试题:
- 如何停止中断运行中的线程?调用线程的interrupt()方法、或设置线程共享变量加volitle或使用AtomicBoolean。(boolean不加volitle也会被线程获取到变量改变,volitle作用后面再看)
- 当前线程的中断标识为true,是不是线程就立刻停止?否,如果线程不去根据这个表示的改变做一些操作的话不会有影响
- 静态方法Thread.interrupted(),谈谈你的理解?返回当前线程的中断标识,并重置中断标志为false。如果线程已经执行结束了返回false。
LockSupport
用于创建锁和其他同步类的基本线程阻塞原语。
3种让线程等待和唤醒的方法:
- 使用Object的wait方法让线程等待,使用Object的notify方法唤醒线程
- 必须在同步代码块或同步方法中使用,也即是先持有锁,否则报java.lang.IllegalMonitorStateException
- 一般成对出现,线程wait之后才能被notify唤醒
- 使用juc包中的Condition的await方法让线程等待,使用signal方法唤醒线程
- 必须在获得锁之后使用condition,否则也报java.lang.IllegalMonitorStateException
- 必须先await才能被signal唤醒
- LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程
- LockSupport.park()阻塞当前线程(如果没有许可的话)
- LockSupport.unpark(Thread)给线程发放许可,如果该线程执行了park则唤醒该线程,如果还没有执行park,则下次执行park时不会再阻塞
- 注意:
- unpark发放许可必须是线程已经启动,否则无效
- 使用t.interrupt()可以唤醒t线程的park()
- 如果线程t是中断状态,则所有的park都不会进行阻塞,已经阻塞的会退出阻塞,所以进行park之前最好检查一下中断状态
- unpark发放许可给线程不会累加,一个线程保存一个许可
- 如果线程在sleep,unpark不会唤醒它
- park和unpark一般配合使用才有效
- 它们本身是用来创建高级同步工具的,对大多数并发控制应用没有什么用处
总结:前两种都需要先获得锁,且wait(await)之后再执行notify(signal)才能唤醒阻塞的线程,如果在之前执行了则无效。使用LockSupport则没有这两个限制,不需要锁,不需要先阻塞再唤醒。
面试题:
- 为什么可以突破wait/notify的原有调用顺序?因为unpark时获得了一个permit,之后再调用park方法时,这个permit可用就会消耗掉,不会阻塞
- 为什么唤醒两次后阻塞两次,但最终结果还会阻塞线程?因为permit的数量最多只有1个,连续调用两次unpark和调用一次效果是一样的,都只会增加一个permit或者说是使permit有效,而调用两次park需要消费两个permit,所以permit不够还是会阻塞
Java内存模型(JMM)
概念
操作系统内存架构:主内存 -> 多级缓存 -> cpu
因为cpu和物理主内存的速度不一致,所以中间会有多级缓存来提高cpu的处理效率,cpu并不直接操作内存,而是先把内存里面的数据读到缓存,而内存的读和写操作就会造成不一致的情况(因为拷贝了一份到缓存里,cpu读的是缓存,写的时候会写到缓存)
JVM规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽调各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
JMM本身是一种抽象的概念,它仅仅描述一组约定和规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的何时写入、以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性、有序性展开的。
总结:
- JMM是JVM规范中定义的一种约定和规范,定义了程序中(尤其是多线程)各个变量的读写访问方式、共享变量何时写入、如何对其他线程可见。
- 通过JMM来实现线程与主内存直接的抽象关系,屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。
JMM规范的三大特性
可见性、原子性、有序性
可见性:
是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道变更。JMM规定了所有的变量都存储在主内存中。
系统主内存共享变量数据修改写入的时机是不确定的,多线程并发下很可能出现脏读,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在线程自己的工作内存中进行,而不能够直接读写主内存的变量。不同线程直接也无法直接访问对方工作内存中的变量,线程间变量赋值均需要通过主内存完成。
原子性:
指一个操作是不可打断的,即多线程环境下,操作不能被其他线程打断
有序性:
对于一个线程的执行代码而言,代码的执行并不是从上到下有序执行的,为了提升性能,编译器和处理器通常会对指令序列进行重写排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫做指定的重排序。
JVM能根据处理器特性(cpu多级缓存、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合cpu的执行特性,最大限度的发挥机器性能。但是指令重排可以保证串行语义一致,不保证多线程间的语义也一致(可能产生脏读)。简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,执行顺序会被优化。
源代码 -> 编译器优化的重排 -> 指令并行重排 -> 内存系统的重排 -> 最终执行的指令
单线程环境里面确保程序最终执行结果和代码执行结果顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的数据依赖性。多线程环境中线程交替执行,由于编译器优化重排的存在两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
JMM规范中多线程对变量的读写过程
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取、赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到线程自己的工作内存,然后对变量进行操作,操作完后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量拷贝副本,因为不同的线程间无法访问对方的工作内存,线程间的通信必须通过主内存来完成
总结:Java内存模型规定所以变量都存储在主内存,各个线程的要操作变量,首先要将变量从主内存拷贝到工作内存,操作完后再将变量写回主内存。不同线程之间的工作内存无法访问,是每个线程私有的。主内存是共享的。
线程的工作内存到底是在主内存上分配的还是在缓存上分配的?应该是在主内存上分配的,在实际执行时会拷贝到缓存中通过cpu计算,计算完成后写回到主内存??
JMM规范中多线程先行发生原则happens-before
在JMM中,如果一个操作执行的结果需要对另外一个操作可见或者代码重排序,那么这两个操作之间必须存在happends-before(先行发生)原则。(包含可见性和有序性的约束)
案例说明:代码
x=5;y=x
,线程A执行第一条,线程B执行第二条,那么如果线程A先执行了,那么线程B使用的y的值一定是5。如果没有happens-before原则,那么现在A赋值完后线程B不知道,那么线程B可能读到的是默认值(因为要拷贝主内存的变量到工作内存),y的值不一定是5。
如果Java内存模型中所有的有序性都仅靠volatile和synchronized完成,那么有很多操作都会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,这是因为JMM有happens-before原则的限制。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段,依赖这个原则,我们可以通过几条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入JMM苦涩难懂的底层编译原理之中。
总原则:
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着一定要按happens-before原则制定的顺序来执行,如果重排序之后的执行结果与按照happens-before关系执行的结果一致,那么这种重排序并不非法
happens-before原则:
- 次序规则:
- 一个线程内,按照代码顺序,写在前面的操作先行发生与写在后面的操作
- 说明:前一个操作的结果可以被后续的操作获取到
- 锁定规则:
- 一个unlock操作先行发生于后面(指时间上的后面)对同一个锁的lock操作
- volatile变量规则:
- 对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的后面指的是时间上的先后
- 传递规则:
- 如果操作A先发生与B,B先行发生与C,那么操作A肯定先行发生与C
- 线程启动规则(thread start rule):
- 线程对象的start方法优先发生于线程的每一个动作
- 线程中断规则(thread interruption rule):
- 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 可以通过Thread.interrupted()检测到是否发生中断。
- 说明:要先调用interrupt()方法设置过中断标志位,才能检测到中断发送
- 线程终止规则(thread termination rule):
- 线程中的所有操作都先行发生于对此线程的终止检测,可以通过isAlive()等手段检测线程是否已经终止
- 对象终结规则(finalize rule):
- 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
- 说明:对象没有完成初始化之前,是不能调用finalize()方法的
总结:在Java语言里面,happens-before原则的语义本质上是一种可见性。A happens-before B意味着A发生过的事情对B来说是可见的,无论A和B是否发生在同一个线程里面
JMM的设计分为两部分:一部分是面向程序员提供,也就是happens-before规则,通俗易懂的向程序员阐述了一个强内存模型,只要理解happens-before规则就可以编写编发安全的程序了。另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。我们只需要关注前者即可,其他繁杂的内容由JMM规范结合操作系统搞定,我们写好代码即可。
案例说明:一个类的int变量value=0,有get/set方法,set方法为++value,那么假设线程A先执行set方法,线程B再执行get方法,线程B得到的值是多少?
- 因为set/get两个操作都不满足happens-before原则,所以线程A先于线程B发生不能保证线程B得到的值是1,有可能线程A执行完后还没有写入主内存,线程B就读不到最新值。线程不安全。
- 两个方法都加synchronized,则两个操作都满足了锁定原则,那么线程A先于线程B发生,能保证B拿到的值一定是1。具体怎么实现的由具体的JVM实现、具体的硬件、操作环境实现。跟实际的编程逻辑符合。
- set方法加synchronized,value加volatile,也能满足,set方法保证了写的原子性,只要执行了一定写入了,由于使用了volatile,get方法的线程也能获取到线程A对变量的修改,获取到的结果一定是1。跟实际编程逻辑符合。如果set不加synchronized能保证吗?不能,因为++value不是原子性的,当两个线程AC同时写的时候,由于要先拷贝变量到工作内存,再写到主内存,写入主内存的可能是1,而AC两个线程的操作都在get之前,get得到的却是1,结果错误。
因此,线程安全要考虑的方面:操作的原子性、可见性、有序性
happens-before原则限制了多线程执行的可见性、有序性
volatile与JMM
volatile修饰的变量由两大特点:可见性、有序性(有时候需要禁止重排)
volatile的两个内存语义:写直接刷新到主内存中,读直接从主内存中读
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新会主内存中
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重写从主内存中读取最新共享变量
内存屏障
volatile保证可见和有序的原理:内存屏障(volatile底层的具体实现)
- 内存屏障,是一类同步屏障指令,是cpu或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。避免了代码重排序
- 内存屏障其实就是一种JVM指令,JMM的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了JMM中的可见性和有序性(禁重排),但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存。
内存屏障之后的读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)
写屏障:告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存。也就是当看到store屏障指令,就必须把该指令之前的所以写入指令执行完才能往下执行(写屏障保证写的数据直接刷新到主内存中)
读屏障:处理器在读屏障之后的读操作,都能在读屏障之后执行。也就是说load指令之后就能够保证后面的读取指令一定能够读取到最新的数据(读屏障保证读的都是最新的)
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障屏障之前,对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。(如果代码上先写后读,那么实际执行时也保证是读的是写后的)
举例:x=5;y=x,线程A执行前面的指令,线程B执行后面的指令,如果x使用了volatile修饰了,那么会在x=5之后加入写屏障,在y=x之前加入读屏障,那么如果线程A执行写早于线程B读代码执行,那么实际获取的结果就能保证线程B读到的就是A写的。怎么保证的?写屏障之后的都要写完才继续执行,保证了都写到主内存,读屏障之后的重写读,保证了读到的都是最新的。我是这么理解的。。。
总结:
- 读屏障(load barrier),在读指令前插入读屏障,让工作内存或cpu高速缓存当中的缓存失效,重新从主内存获取最新数据
- 写屏障(store barrier),在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
- 细分有四种:读读、读写、写写、写读
读写屏障的插入策略:(保证有序性)
- 对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序
- 对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序
happens-before的volatile变量规则:
volatile特性
保证可见性
保证不同线程对于某个变量完成操作后结果及时可见,即共享变量一旦改变所有线程立即可见。
原理解释:
如果没有加volatile关键字,,线程a读取到的共享变量不会改变,可能问题:
- main线程修改后,没有写入主内存
- main线程写入主内存后,线程a没有读取最新的主内存的值
使用volatile可以解决上面两个问题
volatile变量的读写过程:
没有原子性
如对于i++这样的操作,是非原子性的,多个线程同时操作i时,同时操作获取值、增加1、写回主内存,会有问题。即使加volatile关键字,也会出现两个线程同时读到一样的值,分别增加、写回主内存,也会有问题。对于非原子问题一般需要加锁保证同步。
volatile变量不适合在依赖当前值的运算中使用,通常volatile用作保存某个状态的boolean值或者int值
禁止指令重排序
举例参考:https://blog.csdn.net/Unknownfuture/article/details/105023355
如何使用
- 单一赋值可以,比如boolean=true,int i=10
- 状态标志,判断业务是否结束
- 开销较低的读、写锁策略,即读远多于写,结合使用内部锁和volatile变量来减少同步开销。利用volatile保证读取的可见性,利用synchronized保证复合操作的原子性
public class VolatileDemo {
private volatile int value;
public int getValue() {
return value;
}
public synchronized int add() {
return value++;
}
}
- 在双检锁单例模式中,一般要对static的单例对象加volatile,因为在创建单例时,因为重排序,有可能该对象还没有创建完成,此时其他线程在外部可能会读取到null(new对象与赋值不是原子操作,包含了分配内存空间、初始化对象、将对象指向分配的内存空间三步,某些编译器为了性能原因,会将第二第三步重排序,导致先进行赋值再进行初始化对象,这样某个线程可能获取到未完全初始化的对象)
总结
- volatile保证可见性和有序性(通过禁止指令重排实现)
- 可见性:对于一个volatile修饰的变量,写操作的话,这个变量的值会立即刷新会主内存中。读操作的话,能读取到这个变量的最新值,也即使这个变量最后被修改的值
- 禁止指令重排:
- 如果是volatile写的话,禁止volatile写之前的所有写和volatile写重排(StoreStore屏障,保证前面的写都刷新到主内存),禁止volatile写和volatile写下面的volatile读或volatile写或者普通写重排序(StoreLoad屏障,保证volatile写数据刷写到主内存)
- 如果是volatile读的话,禁止volatile读和之后的volatile读和普通读重排序(LoadLoad屏障、LoadStore屏障),禁止volatile读和下面的volatile写或普通写重排序
- 为什么底层会加入内存屏障?
- 字节码中的flags字段会增加ACC_VOLATILE字段,JVM在根据字节码生成机器码的时候如果操作的是volatile变量的话,会按照JMM规范,在相应的位置插入内存屏障
- 内存屏障是什么?
- 内存屏障是一种屏障指令,它使得cpu或者编译器对屏障指令的前后发出的内存操作执行一个排序的约束,也叫内存栅栏或栅栏指令
- 内存屏障能干什么?
- 阻止屏障两边的指令重排序
- 写数据时加入屏障,强制将线程工作内存的数据刷回到主内存
- 读数据时加入屏障,线程工作内存的数据失效,重写读取主内存的数据
- 内存屏障指令:
- volatile写操作前插入StoreStore屏障
- volatile写操作后插入StoreLoad屏障
- volatile读操作后插入LoadLoad屏障、LoadStore屏障
总结:volatile写之前的操作,禁止重排序到volatile之后;volatile读之后的数据禁止重排序到volatile之前,volatile写之后的volatile读,禁止重排序。
如果是volatile写,之前的写是正常的,不能排到volatile写后面去(volatile提前写了,volatile写是兜底的正常);如果是volatile读,之后的读是正常的,不能排到前面去(volatile读延后了,volatile读要领头的正常); ---volatile写;volatile读---;加上第一个volatile写第二个volatile读的情况也不能换(写后读)一共三条。
volatile写操作之前加StoreStore,volatile写操作之后加StoreLoad,禁止volatile写之前的操作重排序到volatile写之后
volatile读操作之后加LoadLoad和LoadStore,禁止volatile读之后的操作重排序到volatile读之前
public class VolatileLoadStoreTest {
int i = 1;
volatile boolean flag = false;
public void write() {
i = 2;
flag = true;
}
public int read() {
if (flag) {
System.out.println("i=" + i);
}
return i;
}
}
CAS
Compare and swap,比较并交换,实现并发算法时常用到的一种技术。它包含三个操作数(内存位置、预期原值、更新值),类似乐观锁
执行cas操作的时候,将内存的值与预期原值比较,如果匹配,那么处理器自动将该位置的值更新为新值;如果不匹配,处理器不做任何操作,多个线程同时执行cas操作只有一个会成功。
当且仅当预期值与内存值相同时,修改内存值为新值,否则什么都不做或重试。重试的行为为自旋。
cas是jdk提供的非阻塞原子性操作,它通过硬件保证了比较更新的原子性。(非阻塞且自身具有原子性,且通过硬件保证,说明效率高还可靠)。cas是一条cpu的原子指令(cmpxchg指令),不会造成数据不一致的问题,Unsafe提供的cas方法底层实现就是cpu指令cmpxchg。
执行xmpxchg指令的时候,会判断当前系统是否为多核,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功后会进行cas操作,也就是说cas的原子性实际上是cpu实现独占的,比起synchronized的重量级锁,这里的排他时间要短的多,所以在多线程情况下性能会比较好。
cas底层原理:谈谈你对Unsafe的理解
Unsafe是cas的核心类,由于java方法无法直接访问底层系统,需要通过本地方法访问,相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类在jdk.internal.misc包中(jdk11),其内部方法操作可以像C的指针一样直接操作内存。通过直接调用该对象的内存地址的值的偏移量获取实际值,该字段使用了volatile修饰,保证了多线程的内存可见性
Unsafe类中的大部分方法都是native的,也就是说Unsafe类中的方法都是直接调用底层资源执行的
AutomicInteger类主要利用cas+volatile和native方法保证原子性、可见性、有序性,从而避免synchronized的高开销
原子引用:AtomicReference,只关注内容,不关注过程
cas与自旋锁:cas是自旋锁的基础,cas利用cpu指令保证了操作的原子性以达到锁的效果,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方法去尝试获取锁,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。好处是减少了线程上下文切换的消耗,缺点是循环会消耗cpu
缺点:
- 如果cas一直不成功,会浪费很大的cpu开销
- cas会导致ABA问题:cas算法实现一个重要前提需要提取内存中某时刻的数据并在当下比较并替换,那么这个时间差会导致数据的变化。如果两个线程通过cas修改数据A,线程1将A改成了X,线程2将A改成了B,假设线程2成功了,之后线程2又进行了修改,将B改成了A,线程1又成功将A改成了X。虽然线程1cas操作成功,不代表这个过程是没有问题的。
版本号时间戳原子引用:关注修改次数,解决ABA问题
相比于AtomicReference,AtomicStampedReference的对比判断为Pair,包含了引用和一个int类型的stamp(可以认为是版本号),底层应该会判断引用是否是同一个,int是否相等
面试题:什么是CAS 底层是Unsafe类 用到的cpu指令的操作的原子性保证 缺点:如果线程一直cas失败容易导致cpu空转,还会产生ABA问题,就是cas操作关注了修改前的数据,不关注修改的过程,加入一个cas操作期间另外有线程修改了数据并又修改回了原有的数据,其他线程是不知道的。使用AtomicStampedReference解决,带版本号
原子操作类
基本类型原子类:实现基本类型的原子操作
- AtomicInteger
- AtomicLong
- AtomicBoolean
数组类型原子类:实现对数组中元素的原子操作
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
引用类型原子类:实现引用类型元素的原子操作
- AtomicReference 可以用来实现自旋锁 但是有ABA问题
- AtomicStampedReference 带版本号的原子引用类型 可以解决ABA问题
- AtomicMarkableReference 将带版本号的原子引用类型简化为true/false 即引用是否修改过
对象属性修改原子类:实现对对象中的某些volatile类型字段的原子操作
- AtomicIntegerFieldUpdater
- AtomicLongFieldUpdater
- AtomicReferenceFieldUpdater
目的:以一种线程安全的方式操作非线程安全对象内的某些字段(有点单独为对象的某个对象加锁的感觉,如果使用synchronized或者单独为了某个字段的修改加lock锁,前者锁了整个对象,后者需要针对每个地方都要加锁比较麻烦)
使用要求:
- 更新的对象属性必须使用public volatile修饰(private好像也可以,自己的内部静态类)
- 因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法newUpdater创建一个更新器,并且需要设置想要更新的类和属性
原子操作增强类:jdk8新增,核心思想是将之前AtomicLong的一个value的更新压力分散多个cell上去,从而降级更新热点。sum执行时并没有限制对base和cells的更新所以LongAdder不是强一致性的,它是最终一致性的
- LongAdder,只能从0开始作加法,性能优于AtomicLong,空间消耗会增加
jdk8推荐使用LongAdder对象比AtomicLong性能更好(减少乐观锁的重试次数)
问题:1,热点商品点赞计数器,点赞数加加统计,不要求实时精确;2,一个很大的list,里面都是int类型,如何实现加加,说说思路
LongAdder继承了Striped64,Striped64有几个比较重要的属性:
int NCPU
,cpu数量,即cells数组的最大长度transient volatile Cell[] cells
,cells数组,2的指数大小transient volatile long base
,基础value值,当并发较低时,只累加该值主要用于没有竞争的情况,通过cas更新transient volatile int cellsBusy
,创建或者扩容cells数组时使用的自旋锁变量
在进行add操作时,如果没有竞争,cas每次都成功,则只更新base;如果cas失败,则首次创建Cell数组;如果多个线程竞争同一个Cell比较激烈时(cas出现了失败),可能就要对Cell进行扩容
整体逻辑:
- 如果Cells为null,尝试cas更新base成功,直接返回(cas没有竞争)
- 如果Cells为null,尝试cas更新base失败(cas有竞争了),uncontended为true,调用longAccumulate
- 如果Cells不为null,但当前线程映射的Cell为null(说明创建后还没有用过),uncontended为true,调用longAccumulate
- 如果Cells不为null,但当前线程映射的Cell不为null(正常使用Cell),cas更新该Cell,成功则返回,否则uncontended设为false,调用longAccumulate
- longAccumulate方法里面会有一个大的cas,进行初始化Cells或者扩容Cells或者在当前cell不为null的进行cas累加或者在base上进行cas累加
总结:
- AtomicLong:线程安全,可允许一些性能损耗,要求高精度时使用,保证精度,性能代价,多个线程对一个热点值进行原子操作
- LongAdder:需要在高并发下有较好的性能表现,且对值的精确度要求不高时可以使用,保证性能,精度代价,LongAdder是每个线程拥有自己的cell进行更新,各个线程一般只对自己cell中的那个值进行cas更新
ThreadLocal
面试题:
- ThreadLocal中的ThreadLocalMap的数据结构与关系?
- TheadLocal的key是弱引用,为什么?
- ThreadLocal内存泄漏问题你知道吗?
- ThreadLocal中最后为什么要加remove方法?
ThreadLocal提供线程局部变量,这些变量与正常的变量不同,因为每个线程在访问ThreadLocal实例的时候都有自己的独立初始化的变量副本,ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(如用户id或事务id)与线程关联起来。
自定义ThreadLocal变量必须使用try-finally进行remove,因为在线程池场景下,线程经常被复用,如果不清理,可能影响后续业务逻辑和造成内存泄漏。
Thread/ThreadLocal/ThreadLocalMap关系:
- Thread是线程类,每创建一个线程,会有一个ThreadLocalMap(Thread的一个属性)用于记录这个线程创建的所有ThreadLocal变量,它的key是ThreadLocal,value是存放的对象。
- ThreadLocal本身并不存储值,它只是自己作为一个key让线程从ThreadLocalMap中获取value
- ThreadLocalMap是ThreadLocal的静态内部类,Entry是ThreadLocalMap的静态内部类,继承了WeakReference(第一层包装,将ThreadLocal包装成WeakReference,第二层包装,使用Entry继承这个WeakReference并增加value字段,entry的key使用的是弱引用的)
源码:
- 初次调用get/set时,如果map为null会创建threadLocalMap并设置该threadLocal变量的值或使用初始值初始化在map里,第一个entry在0号位置,size大小为16
- 再次set更新数据时,直接更新
- 再次set新ThreadLocal时,通过新哈希值取逻辑与获得索引i,如果该位置没有entry,直接创建,并执行cleanSomeSlots(),以log2n的次数执行清理,n为现有的元素个数
- 如果清理成功就返回,清理失败同时达到阈值则扩容(执行
rehash()
),阈值为16*2/3
- 如果清理成功就返回,清理失败同时达到阈值则扩容(执行
- 如果该位置有entry,如果是key为null,value不为null的,直接替换调该entry,执行replaceStaleEntry()并返回
- ...
为什么使用弱引用:当t1所在方法执行完毕后,栈帧销毁强引用t1也就没有了,但是threadLocalMap里的某个key引用还是指向这个对象
- 若key为强引用,就会导致key指向的ThreadLocal对象及v指向的对象不能被垃圾回收,造成内存泄漏
- 若key为弱引用,就会大概率减少内存泄漏的问题,使用弱引用就可以使ThreadLocal对象咋方法执行完毕后顺利被回收且entry的key引用指向为null(此时还有问题,因为value还存在,会内存泄漏,需要手动remove,由于线程池进行线程复用,还可能产生上个线程遗留的value数据导致bug)
每一次set都有可能进行key为null的垃圾进行清除
手动remove会
最佳实践:
- 设置初始化值,避免NPE
- 建议把ThreadLocal修饰为static(实际内容是线程隔离的,不在于它本身,而在于ThreadLocalMap,没必要作为成员变量多次初始化)
- 用完后要在try-finally里面remove
总结:
- ThreadLocal并不解决线程间共享数据的问题
- ThreadLocal适用于变量在线程间隔离且在方法间共享的场景
- ThreadLocal通过隐式的咋不同线程内创建独立实例副本避免了实例线程安全的问题
- 每个线程持有一个只属于自己的Map,并维护了ThreadLocal对象与具体实例的映射,该map由于只被持有它的线程访问,故不存在线程安全问题以及锁的问题
- ThreadLocalMap的Entry对ThreadLocal的引用为弱引用,避免了ThreadLocal对象无法被回收的问题,都会通过expungeStaleEntry、cleanSomeSlots、replaceStaleEntry这三个方法回收key为null的Entry的对象的值以及Entry对象本身,从而防止内存泄漏,属于安全加固的方法
Java对象内存布局与对象头
对象的内存布局:
- 对象头:
- 对象标记(64位下是8个字节):哈希值,gc标记,gc次数,同步锁标记,偏向锁持有者
- 分代年龄:4bit,最大是15
- 默认存储hashCode、分代年龄和锁标志位等信息,这些信息都是与对象自身定义无关的数据,所以MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。
- 它会根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord存储的数据会随着锁标志的变化而变化
- 类型指针(64位下是8个字节,默认开启压缩指针,是4个字节,能表示
2^32*8
的内存,因为对象都是8字节倍数,地址的低3位都是0):指向对象的类元数据的指针 - 数组长度(如果对象是数组)
- 对象标记(64位下是8个字节):哈希值,gc标记,gc次数,同步锁标记,偏向锁持有者
- 实例数据:存放类的属性数据、包括父类的属性数据
- 对齐填充(保证对象是8字节的倍数)
maven依赖jol-core,分析对象在jvm的大小和分布
Synchronized与锁升级
面试题:
- 谈谈你对synchronized的理解,synchronized的锁升级
synchronized锁:由对象头中的MarkWord根据锁标志位的不同而被复用及锁升级策略
synchronized的性能变化:
- jdk5之前,只有synchronized,这个是操作系统级别的重量级操作。
- 如果锁的竞争笔记激烈,性能下降
- 用户态和内核态之间的切换:java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间切换,这种切换会消耗大量系统资源。因为用户态和内核态都有各自专用的内存空间,专用寄存器等,用户态切换至内核态需要传递很多变量、参数,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换会用户态继续工作。
- jdk早期版本,synchronized属于重量级锁,效率低下,因为监视器锁是依赖与底层的操作系统mutex lock(系统互斥锁)实现的,挂起线程和恢复线程都需要转入内核态完成,阻塞或唤醒一个java线程需要操作系统切换cpu状态来完成,这种切换需要消耗很多的cpu时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长,时间成本相对较高。这也是为什么早期synchronized效率低的原因。jdk6之后,为了减少获得锁和释放锁所带来的性能损耗,引入了轻量级锁和偏向锁
任意一个对象都可以作为锁:因为任何一个对象都可以关联了一个管程,如果一个对象被某个线程锁住,则该java对象的MarkWord字段中的LockWord指向monitor的起始地址,monitor的owner字段会存放拥有该锁的线程id
锁指向:
- 偏向锁:MarkWord存储的是偏向的线程id
- 轻量锁:MarkWord存储的是指向线程的栈中LockRecord的指针
- 重量锁:MarkWord存储的是指向堆中的monitor对象的指针
锁升级步骤:
- 无锁状态,会存储:25-unused 31-hashCode(如果调用) 1-unused 4-分代年龄 偏向锁为0 锁标志位01
- 如果开启了偏向锁,程序启动后,对象的锁标志就是101,表示偏向锁,但是存储的线程id都是0
- 偏向锁:单线程竞争(只有一个线程),当线程A第一次竞争到锁时,通过修改MarkWord中的偏向锁id、偏向模式。如果不存在其他线程竞争,那么持有偏向锁的线程将永远不需要同步
- 当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问中便会自动获得锁
- Hotspot的作者经过研究发现,大多数情况下,在多线程中,锁不仅不存在多线程竞争,还存在锁由同一个线程多次获得的情况,偏向锁就是在这种情况下出现的,为了解决只有一个线程执行同步时提高性能
- 注意:偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他线程访问,则持有偏向锁的线程将永远不需要触发同步。也即偏向锁在资源没有竞争情况下消除了同步语句,连cas操作都不做了,直接提高程序性能
- 在锁第一次被拥有的时候,记录下偏向线程id,这样偏向线程就一直持有锁(后续这个线程进入和退出这段加了锁的代码块时,不需要再次加锁和释放锁,而是直接检查锁的MarkWord是不是自己的线程id)
- 如果相等,表示偏向锁就是偏向当前线程的,不需要再尝试获得锁,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程id是否与当前线程id一致,如果一致直接进入同步,无须每次加锁解锁都去cas更新对象头,如果自始至终使用锁的线程都只有一个,很明显偏向锁几乎没有额外开销,性能极高
- 如果不相等,表示发生了竞争,锁已经不是总偏向一个线程了,这个时候会尝试使用cas来替换MarkWord里面的线程id为新线程id。epoch记录当前线程是否在同步状态
- 如果竞争成功,表明之前的线程不存在了,MarkWord里面的线程id为新线程id,锁不会升级,仍为偏向锁
- 如果竞争失败,这时候可能需要升级为轻量级锁,才能保证线程间的公平竞争。(偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的)
- 轻量级锁:当有另外线程逐步来竞争锁的时候,就不能使用偏向锁了,要升级为轻量级锁。
- 竞争线程cas更新对象头失败,会等待持有偏向锁的线程到全局安全点(此时不会执行任何代码,发生STW,如果没有STW,那么一直等吗?STW之前拥有偏向锁的线程已经退出了呢?)撤销偏向锁
- 主要目的是在没有多线程竞争的前提下,通过cas减少重量级锁操作系统互斥量产生的性能消耗
- 设置偏向锁标志为0,锁标志为00,此时轻量级锁由原持有偏向锁的线程持有,继续执行同步代码,而竞争线程会进入自旋等待获得该轻量级锁
- 轻量级锁加锁:jvm会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间(Displaced MarkWord),若一个线程获得锁时发现是轻量级锁,会把锁的MarkWord复制到自己的Displaced MarkWord(包括哈希值、gc年龄),然后线程尝试用cas将锁的MarkWord替换成指向锁记录的指针,如果成功,当前线程获得锁,如果失败,表示MarkWord已经被替换成了其他线程的锁记录,说明在与其他线程竞争,当前线程就尝试使用自旋来获取锁(自旋到达一定次数会升级为重量级锁)
- 轻量级锁释放锁:在释放锁时,当前线程会使用cas将Displaced MarkWord的内容复制回锁的MarkWord里面,如果没有发生竞争,那么复制操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级为重量级锁,那么cas操作失败,此时会释放锁并唤醒被阻塞的线程。
- 重量级锁:指向互斥量(管程?)的指针
- 重量级锁是基于进入和退出monitor对象实现的,在编译时会将同步代码块的开始位置插入monitorenter指令,在结束位置插入monitorexit指令,当线程执行到monitor指令时,会尝试获取对象对应的monitor所有权,如果获取到了,会在monitor的Owner字段中存放当前线程的id,其他线程无法再获取到
不同:
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁(释放偏向锁,升级为轻量级锁?lock record指向自己?然后竞争线程cas尝试获取轻量级锁,到达一定次数升级为重量级锁?然后当前线程同步结束以后,退出轻量级锁,将Displaced MarkWord复制回对象的MarkWord,发现已经升级为了重量级锁,那么它释放当前锁,并唤醒所有在重量级锁中等待的线程??是这样吗)
锁升级为轻量级锁或重量级锁后,MarkWord中保存的分别是线程栈里的锁记录指针和重量级锁指针,已经没有位置保存哈希码和gc年龄了。
- java里面如果一个对象计算过哈希码,就应该保持不变(强烈推荐但不强制,因为hashCode方法可以重载),否则很多依赖哈希码的api都有出错风险。而绝大多数对象哈希码都是来源于Object::hashCode,返回的是对象的一致性哈希码,这个值是强制保持不变的。它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法获取到的值永远不会改变。
- 实际上,当一个对象已经计算过一致性哈希码后(重写的hashCode方法无效),它就再也无法进入偏向锁的状态了(只能升级为轻量级锁)
- 而当一个对象正处于偏向锁的状态时,又收到需要计算哈希码请求时,它的偏向锁状态会被立即撤销,并且锁会膨胀为重量级锁。
- 在重量级锁中,对象头指向了锁的位置(管程对象),代表重量级锁的ObjectMonitor对象里有字段可以记录非加锁状态(01)下的MarkWord,其中自然可以存储原来的哈希码(怎么存的?ObjectMonitor的结构再看看)。释放锁后也会将信息写回到对象头
- 在轻量级锁中,jvm会在当前线程的栈帧中创建锁记录(Lock Record)空间,用于存储锁对象的MarkWord拷贝,该拷贝中可以包含对象的哈希值,所有轻量级锁可以和哈希码共存,gc年龄也存储在这里,释放锁后将这些信息在写回到对象头
java -XX:+PrintFlagsInitial | grep BiasedLock
查看偏向锁相关参数
jdk15逐步废弃偏向锁,jdk17移除
JIT编译器对锁的优化:
- JIT:即时编译器
- 锁的消除:每个线程自己创建锁并使用,没有意义,但是语法上没有错,JIT编译器会优化消除这个锁
- 锁粗化:每个线程内多次使用一个锁进行连续同步,没有意义,但是语法生没有错,JIT编译器会优化为一个同步代码块
AQS(AbstractQueuedSynchronizer)
前置知识:公平锁和非公平锁,可重入锁,自旋思想,LockSupport,双向链表,模板设计模式
抽象的队列同步器:
- 是用来实现锁或者其他同步器组件的公共基础部分的抽象实现,是重量级基础框架及整个juc体系的基石,主要用于解决锁分配给谁的问题
- 整体就是一个抽象的FIFO队列来完成资源获取线程的排队工作,并通过一个int类型变量表示持有锁的状态
和AQS有关的类:
- ReentrantLock
- ReentrantReadWriteLock
- CountDownLatch
- Semaphore
- CyclicBarrier
锁和同步器的关系:
- 锁,面向锁的使用者,定义了和锁交互的api,隐藏了实现的细节,调用即可
- 同步器,面向锁的实现者,DougLee提出统一规范,并简化了锁的实现,将其抽象出来,屏蔽了同步器管理、同步队列的管理和维护、阻塞队列排队和通知、唤醒机制等,是一切锁和同步组件实现的公共基础部分
排队等候就需要形成某种队列,如果共享资源被占用就需要一定的阻塞、等待、唤醒机制来保证锁的分配,这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS同步队列的抽象表现。它将要请求公共资源的线程及其自身的等待状态封装成队列的节点对象(Node),通过cas,自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果
AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作将每条要抢占资源的线程封装成一个Node节点来实现锁的分配,通过cas完成对State值的修改
ReentrantLock:
- 实现了Lock接口,组合了Sync,是它的内部类,Sync继承了AQS,Sync有两个子类,NonfairSync和FairSync,在创建非公平锁的时候返回前者的实例,创建公平锁返回后者的实例
- 调用ReentrantLock::lock方法时,实际上又调用了实例的Sync::aquire,该方法为AQS的模板方法
- 公平锁和非公平锁的lock方法唯一的区别就在于公平锁在获取锁时多了一个限制条件:hasQueuedPredecessors
- AQS的acquire()方法:if条件有三个流程,tryAcquire(),addWaiter(),acquireQueued()
- AQS::tryAcquire由子类实现,FairSync相比于NonfairSync多了调用hasQueuedPredecessors()
- 双向链表中第一个节点为虚节点(哨兵节点),不存储任何信息,只是占位
ReentrantLock、ReentrantReadWriteLock、StampedLock
面试题:
- 你知道java里面有哪些锁
- 读写锁中锁饥饿问题是什么
- 有没有比读写锁更快的锁
- StampedLock知道吗
- ReentrantReadWriteLock有锁降级机制,了解吗
读写锁:一个资源能被多个读线程访问,或被一个写线程访问,但是不能同时存在读写线程。即,只能存在一个写锁,可以存在多个读锁,不能同时存在写锁和读锁。只有在读多写少的情景下,读写锁才具有较高的性能体现。
读写锁有锁饥饿,锁降级
- 锁饥饿,读锁多的情况下,读线程一直占用不释放或者一直是读线程获取到锁,导致写线程长时间无法获取到写锁(使用公平锁一定程度上能解决锁饥饿问题,以牺牲吞吐量为代价)
- 锁降级:将写锁降级为读锁(类似于linux文件的读写权限,写权限高于读权限),即可以获取写锁、获取读锁,再释放写锁,写锁降级为读锁。同一个线程获取到了写锁后,在没有释放写锁的情况下,它还可以继续获取读锁。这就是写锁的降级,降级为了读锁。
- 锁降级是为了让当前线程感知到数据的变化,保证数据的可见性
- 锁降级可以避免别的线程在当前线程写之后再写新的数据而当前线程获取不到自己修改的数据
- 使用:声明一个volatile类型的cacheValid保证可见性,首先获得读锁,如果cache不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid设置为true,然后再释放写锁前立刻获取读锁,此时cache中数据可用,处理cache中的数据,最后释放读锁。这个过程就是一个完整的锁降级过程,目的是保证数据可见性。
- 同一个线程自己持有写锁再去获取读锁,其本质相当于重入
StampedLock是jdk1.8中新增的一个读写锁,也是对jdk1.5中读写锁ReentrantReadWriteLock的优化,由于读写锁会有锁饥饿的问题,StampedLock以乐观读的方式避免锁饥饿的问题。
对比:
- ReentrantReadWriteLock允许多个线程读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态。读写锁也是互斥的,所以在读的时候不允许写,读写锁比传统的synchronized速度要快的多,原因就在于该锁支持读并发,读读共享。
- StampedLock采取乐观模式(认为读的时候不会有人改),获取锁后,其他线程尝试获取写锁不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验(看是不是被改过)。
- 对短的只读代码段,使用乐观模式通常可以减少争用并提高吞吐量
StampedLock特点:
- 所有获取锁的方法,都返回一个邮戳(Stamp),Stamp为0表示获取失败,其他表示获取成功。
- 所有释放锁的方法,都需要一个邮戳(Stamp),这个Stamp必须是和成功获取锁时得到的Stamp一致
- StampedLock不可重入,如果一个线程已经持有的写锁,再去获取写锁会造成死锁。
- StampedLock有三种访问模式:
- Reading(悲观读模式),功能与ReentrantReadWriteLock的读锁类似
- Writing(写模式),功能与ReentrantReadWriteLock的写锁类似
- Optimistic reading(乐观读),无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观的认为读取时没有人修改,加入被修改再实现升级为悲观读模式。
StampedLock缺点:
- 不支持重入
- StampedLock的悲观读锁和写锁都不支持条件变量
- 使用StampedLock一定不要调用中断操作,即不要调用interrupt()方法
总结
- CompletableFuture
- 锁:悲观锁、乐观锁、自旋锁、可重入锁、写锁(独占锁)、读锁(共享锁)、公平锁、非公平锁、死锁、偏向锁、轻量锁、重量锁、邮戳锁
- LockSupport和线程中断
- JMM
- volatile:可见性,有序性,使用内存屏障
- cas:
- 底层原理:cmpxchg指令实现更新变量的原子性
- cas问题:ABA
- 原子操作类AtomicXxx
- ThreadLocal:为每个使用该变量的线程提供独立的变量副本,每个线程都可以独立的改变自己的副本,而不会影响其他线程锁对应的副本
- java对象内存布局和对象头
- synchronized及锁升级
- 锁到底是什么
- 锁升级,无锁 - 偏向锁 - 轻量锁 - 重量锁
- AbstractQueuedSynchronizer:volatile+cas实现的锁模板,保证代码的同步性和可见性,而AQS封装了线程阻塞等待挂起,解锁唤醒其他线程的逻辑,AQS子类只需要根据状态变量,判断是否可获取锁,是否释放锁,使用LockSupport挂起、唤醒线程即可。
- 读写锁、StampedLock
本文来自博客园,作者:Bingmous,转载请注明原文链接:https://www.cnblogs.com/bingmous/p/16977428.html