深入理解并发编程锁

Java内存模型(JMM)

概述:Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本,本地内存是JMM的一个抽象概念,其实并不存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

java内存模型

可见性

定义:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

存在的问题:由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。

解决方案:可以使用volatile关键字或者是加锁。

原子性

定义:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

存在的问题:我们都知道CPU资源的分配都是以线程为单位的,并且是分时调用,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。而任务的切换大多数是在时间片段结束以后,那么线程切换为什么会带来bug呢?因为操作系统做任务切换,可以发生在任何一条CPU 指令执行完!注意,是 CPU 指令,而不是高级语言里的一条语句。比如count++,在java里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实count++至少包含了三个CPU指令,那在多线程下就会存在一条语句没有全部执行的情况。

解决方案:synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

有序性

定义:即程序执行的顺序按照代码的先后顺序执行。

存在的问题:JVM在真正执行代码的时候可能会进行指令重排序,在多线程下就会存在问题,例如单例模式中的DCL【双重校验】。

指令重排序:一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

解决方案:可以使用volatile关键字或者是加锁。

Volatile详解

Volatile特性

可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。

volatile变量自身绝具有下列特性:

  1. 可见性:`对一个volatile变量的读,总是能看到其他线程对这个volatile变量最后的写入。
  2. 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

Volatile实现原理

volatile关键字修饰的变量会存在一个lock的前缀。
Lock前缀:Locck不是一种内存屏障。但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为COU指令集的一种锁。

同时该指令会将当前处理器缓存行的数据直接写回到系统内存中,切这个写回内存的操作会使其他CPU里缓存了该地址的数据无效。

Synchronized详解

synchronized实现原理

synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步

对于同步代码块,MonitorEnter指令插入在同步代码块的开始位置,而MonitorExit指令则插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit,总体来说,当代码执行到该指令时,将会尝试获取对象Monitor的所有权,即尝试获取该对象的锁

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。【锁的可重入】
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

对应同步方法,从同步方法反编译结果看,方法的同步并没有通过指令monitorenter 和 monitorexit来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标识符。JVM就是根据该标识符来实现方法的同步:当方法被调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor,在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

synchronized使用的锁是存在在Java对象头里面
对象存储的数据
锁信息存在于对象的mark word中。
java对象里的锁信息
但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式。
不同锁的存储信息

锁的状态

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

偏向锁
引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁,以减少不必要的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 world)。
  5. 执行同步代码。
    偏向锁流程
    偏向锁的释放:偏向锁的撤销在上述第四步中有提到,偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁的适用场景:始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其他线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候就会导致stop the world操作;

在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stop the world,导致性能下降,这种情况应当禁用。

jvm开启/关闭偏向锁:
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

轻量级锁
概述:轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。

轻量级锁的加锁过程:

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

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

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

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

自旋锁流程

不同锁的比较

不同锁的比较

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值