大厂很可能会问到的JMM底层实现原理

JMM(Java Memory Model),Java内存模型。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个虚拟计算机模型,JMM隶属于JVM。Java1.5版本对其进行了重构,现在的JMM仍沿用了Java1.5的版本。

JMM遇到的问题与现代计算机中遇到的问题是差不多的。
物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。

物理内存模型

根据《Jeff Dean在Google全体工程大会的报告》我们可以看到
在这里插入图片描述
现代计算机中,CPU的运算速度远超读取内存的速度,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了
在这里插入图片描述
L1分为一级数据缓存(Data Cache,D-Cache,L1D)和一级指令缓存(Instruction Cache,I-Cache,L1I),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器(L0)、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3
在这里插入图片描述
越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,每一层的数据是下一层的数据的子集。

物理内存模型带来的问题

缓存一致性

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以减少CPU与内存的交互。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
在这里插入图片描述
处理器A和处理器B并行执行内存访问,它们可以同时把共享变量写入自己的写缓冲区(步骤A1,B1),然后从内存中读取另一个共享变量(步骤A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(步骤A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。

在这里插入图片描述
如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

伪共享

前面我们已经知道,CPU中有好几级高速缓存。但是CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache的Cache Line大小都是64Bytes。Cache Line可以简单的理解为CPU Cache中的最小缓存单位,今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存。
一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。
为了避免伪共享,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法。但是这种方式在Java7以后可能失效。
Java8中已经提供了官方的解决方案,Java8中新增了一个注解@sun.misc.Contended 。
比如JDK的ConcurrentHashMap中就有使用
在这里插入图片描述
加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效:
在这里插入图片描述
点击这里查看伪共享示例。

Java内存模型(JMM)

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
在这里插入图片描述
在这里插入图片描述

Java内存模型带来的问题

可见性问题

在这里插入图片描述
如上图,3个count,2个为副本。启动2个线程对count进行累加,线程1更改count的时候,如果不刷新到主内存,线程2还在对原来的值进行累加。

在多线程的环境下,如果某个线程首次读取共享变量,则首先到主内存中获取该变量,然后存入工作内存中,以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作,则先将新值写入工作内存中,然后再刷新至主内存中。但是刷新时间虽然很短且并不确定。

解决方式:使用volatile关键字或加锁。

竞争问题

在这里插入图片描述

如果这两个加1操作是串行的,最终主内存中的count的是3。然而图中两个加1操作是并行的,当它们值更新到工作内存的副本后,会争相刷新主内存。在这里,不管是线程1还是线程2先刷新计算结果到主内存,最终主内存中的值只能是2。

解决方式:加锁。

重排序

重排序类型

除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:
在这里插入图片描述

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
在这里插入图片描述
不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。

as-if-serial

as-if-serial的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器可以让我们感觉到:单线程程序看起来是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

控制依赖性

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因)。
在这里插入图片描述
但是对多线程来说就完全不同了:这里假设有两个线程A和B,A首先执行init ()方法,随后B线程接着执行use ()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?答案是:不一定能看到。

在程序中,当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。猜测执行实质上对操作3和4做了重排序,问题在于这时候,a的值还没被线程A赋值。

当操作1和操作2重排序,操作3和操作4重排序时,可能会产生什么效果?操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,这时就会发生错误!

所以在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

内存屏障

Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

  1. 保证特定操作的执行顺序。
  2. 影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

JMM把内存屏障指令分为4类:
在这里插入图片描述
toreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

临界区

在这里插入图片描述
JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得多线程在这两个时间点按某种顺序执行。

临界区内的代码则可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

happens-before

在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对Java程序员来说,理解happens-before是理解JMM的关键。
JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

定义

用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second).

理解

上面的定义看起来很矛盾,其实它是站在不同的角度来说的。
1)站在Java程序员的角度来说:JMM保证,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)站在编译器和处理器的角度来说:JMM允许,两个操作之间存在happens-before关系,不要求Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的。

总结

A操作happens-beforeB操作,程序上A写在B前面且A结果对B可见,但它们可能被重排序。

处理类别

happens-before关系的处理就分为两类:

  1. 会改变程序执行结果的重排序
  2. 不会改变程序执行结果的重排序

处理策略

JMM对这两种不同性质的重排序,采用了不同的策略,如下:

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求。

在这里插入图片描述

规则

JMM为我们提供了以下的happens-before规则:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

volatile

volatile特性

volatile变量具有下列特性:

  • 保证可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 不保证原子性:类似于volatile变量++这种复合操作不具有原子性。变量++操作可以看成:读、算、写三步。

volatile的内存语义

内存语义:在 JVM 中的内存方面实现原则。

  • volatile的内存语义:
    当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

  • volatile的内存语义:
    当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

共享变量:我理解为同一个方法里的变量。

所以对于代码
在这里插入图片描述
如果我们将flag变量以volatile关键字修饰,那么实际上:线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值都被刷新到主内存中。

在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。

volatile内存语义的实现

volatile重排序规则表:
在这里插入图片描述
总结起来就是:

  • volatile写不能与之前操作重排序。
  • volatile读不能与之后操作重排序。
  • 先volatile写再volatile读不能重排序。

volatile的内存屏障

在Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题。

  • volatile写
    在这里插入图片描述

  • volatile读
    在这里插入图片描述

volatile的实现原理

通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。
在具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据全部刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。

final

final基本类型

对应final域,编译器和处理器需要遵守两个重排序规则:

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    背景:一个线程读另一个线程的普通域,其普通域可能还没有被初始化完毕。导致读的是默认值,而不是初始值。
    理解:写final域的重排序规则可以确保在对象引用在任意线程可见之前其final域已经初始化完毕
    原理:final域的写入与构造函数执行完成之间加入了StoreStore屏障。
    在这里插入图片描述
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序(加入了LoadLoad屏障)。
    背景:一个线程读另一个线程的普通域,没有先读该线程的引用。导致读的是默认值,而不是初始值。
    理解:读final域的重排序规则可以确保在读一个对象的final域之前先读包含这个final域的对象的引用
    原理:读对象的引用与读对象里的final域之间加入了LoadLoad屏障。
    在这里插入图片描述

总结:

  1. final域初始化完毕,其对象才能被其它线程引用。
  2. 先读final域的对象的引用,才能读final域。

final引用类型

因为引用类型的成员域也要初始化,增加了如下规则:

  • 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    背景:一个线程读另一个线程的普通引用域,其普通引用域可能还没初始化完毕。这个初始化包括了,引用的写入和引用对象的成员域的写入。
    理解:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。
    原理:final引用的写入和final引用对象的成员域的写入与构造函数执行完成之间加入了StoreStore屏障。
    在这里插入图片描述
    JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步(lock或volatile)来确保内存可见性。

总结:

  • final引用域初始化完毕,其对象才能被其它线程引用。

锁的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
    点击这里查看更多。

synchronized的实现原理

synchronized在JVM里的实现都是基于进入和退出Monitor(监视器)对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的monitorenter和monitorexit指令来实现。

  • 对同步块,monitorenter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorexit指令则插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。
  • 对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。
    JVM就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取Monitor,获取成功之后才能执行方法体,方法执行完后再释放Monitor。在方法执行期间,其他任何线程都无法再获得同一个Monitor对象。

synchronized使用的锁是存放在Java对象头里面:
在这里插入图片描述
具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,
在这里插入图片描述
但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式:
在这里插入图片描述

锁的状态

一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

各种锁

自旋锁

并不是一种锁,而是锁的一种实现方式

原理

如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用CPU做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又不能获取到CPU,造成CPU的浪费。

自旋锁时间阈值

自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋次数很重要
JVM对于自旋次数的选择,jdk1.5默认为10次,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。
JDK1.6中-XX:+UseSpinning开启自旋锁; JDK1.7后,去掉此参数,由JVM控制。

偏向锁

引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些CAS操作(比如等待队列的一些CAS操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。
偏向锁获取过程:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
  5. 执行同步代码。
    在这里插入图片描述
    偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
    偏向锁的适用场景
    始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
    在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用。
    jvm开启/关闭偏向锁
    开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
    关闭偏向锁:-XX:-UseBiasedLocking

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
轻量级锁的加锁过程:
在代码进入同步块的时候,如果同步对象锁状态为无锁状态且不允许进行偏向(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级锁失败多次之后,轻量级锁就会转化为重量级锁,重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

在这里插入图片描述

不同锁的比较

在这里插入图片描述

JDK对锁的更多优化措施

  • 逃逸分析
    如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:
    同步消除synchronization Elimination,如果一个对象不会逃逸出线程,则对此变量的同步措施可消除。
  • 锁消除和粗化
    消除无意义的锁获取和释放,可以提高程序运行性能。
    • 锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可能发生共享数据竞争,则会去掉这些锁。
    • 锁粗化:将临近的代码块用同一个锁合并起来。

【并发编程】目录:

【并发编程】之走进Java里的线程世界

【并发编程】之学会使用线程的并发工具类

【并发编程】之学会使用原子操作CAS

【并发编程】之深入理解显式锁和AQS

【并发编程】之一文彻底搞懂并发容器

【并发编程】之Java面试经常会问到的线程池,你搞清楚了吗?

【并发编程】之Java并发安全知识点总结

【并发编程】之大厂很可能会问到的JMM底层实现原理

©️2020 CSDN 皮肤主题: 岁月 设计师:pinMode 返回首页