Java并发机制底层实现原理

0.144字数 5265阅读 1020
在文章开始之前,先分享下奶奶家养的可爱小猫咪 233333

在现如今的软件开发领域,并发编程是老生常谈的东西。但是要理解并掌握好并发编程却并不是那么容易的事情。对于我来说,在学习过程中能够应用到并发编程的场景不是很多,所以更多的东西一直都是停留在理论层面,或者是仅仅停留在Java语言的层面。为了在以后的工作当中更顺利地进行并发编程,我一直都在学习这方面的知识。之前阅读过《Java并发编程实战》一书,也看过JDK包中一些并发容器类以及同步容器类的源码,同时也坚持阅读相关的博客,所以对并发编程有一点点浅显的了解,为了加深自己对并发相关知识的理解,我打算针对Java并发编程这一块写一些读书笔记并分享一些自己的学习心得。

本文暂时只对Java虚拟机自带的锁机制以及volatile等知识进行了总结,并没有涉及JUC包中类的介绍,后期会加上这一部分。

volatile实现原理

volatile在并发编程中扮演着重要的角色,volatile就像是轻量级的synchronized,它在多处理器中保证了共享变量的可见性。可见性的意思是当一个线程修改一个共享变量时,其他线程能够及时读取到这个修改的值。由于volatile并不会引起线程上下文的切换和调度,所以它比synchronized的使用和执行成本更低。volatile在并发编程中还起着另一个作用---那就是禁止指令重排。我们知道,编译器在对Java代码进行优化的时候可能会发生指令重排,指令重排对于CPU来说就是指令乱序执行,这是多条指令在流水线上执行,很好地利用了多处理器。虽然这样在单线程语义中不会发生什么错误,但是可能在多线程中会出现一些并发性问题,关于这一点我在后面会提到。首先来说说volatile如何保证可见性。

volatile保证可见性

Java语言规范第3版中对volatile的定义是这样的:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更方便。如果一个字段声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
那么volatile是如何保证可见性的呢?

在对volatile修饰的变量进行写操作的时,转变成的汇编代码会多出一条Lock前缀的指令,Lock前缀的指令在多核处理器下引发了两件事情:

  • 将当前处理器缓存行的数据写回到系统内存;
  • 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接与内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完不知道何时会写到内存。如果对被volatile声明的变量进行写操作,JVM虚拟机就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到内存当中。同时为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存当中。

volatile的两条实现原则底层实现

  • Lock前缀指令会引起处理器缓存回写到内存:Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存(在以前的处理器当中,这条指令会锁住总线导致其他CPU无法访问总线从而限制其他处理器无法访问系统内存)。但是在现在的处理器中,一般不会锁总线而是锁缓存,因为锁总线的开销比较大。对于某些处理器来说,它们在锁操作时,总是在总线上声言LOCK#信号。但是对于另外一些处理器来说,它们不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
  • 一个处理器的缓存回写到内存会导致其它处理器的缓存无效:一些处理使用MESI控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,一些处理器能够嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在某些处理器中如果通过嗅探一个处理器来检测其它处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存无效,在下次访问相同内存地址时,强制执行缓存行填充。
volatile禁止指令重排

volatile为写-读建立happens-before关系
在多处理器对指令进行流水线处理时,不可避免地会发生乱序执行,即指令重排的情况发生。在某些时候,这种优化可能会导致并发性问题。但是volatile能够禁止指令重排,其底层是通过在读和写指令前后插入内存屏障实现的。下面以DCL单例为例进行说明:

DCL双锁检测单例模式

上面这个DCL懒汉式看起来很美好地实现单例模式----先检测singleton对象是否为空,如果为空就锁定Singleton类,接着再次检测singleton对象是否为空,若为空就进行实例化操作最后返回实例化的对象。但是它会存在不安全发布的情况---即对象还未完成初始化就发布出去了,将会引发一系列的问题。

下面我们详细说说为什么会发生不安全发布的问题
这里是singleton对象初始化的过程:

  1. 分配对象的内存空间;
  1. 初始化对象;
  2. 设置singleton指向刚刚分配的内存空间。

上面是正常初始化一个对象的流程。可是由于指令重排的存在,步骤2和步骤3可能会乱序执行。此时假如有线程A和线程B同时调用上面代码中的getSingleton()函数,假设A先B一步已经开始进行对象初始化过程了。由于指令重排的存在,A可能先进行步骤3的操作----设置singleton指向分配的内存空间。此时B才开始调用getSingleton()方法,想要获得一个singleton对象。然后它发现singleton指向的内存区域不为空,就直接返回了singleton。可是此时线程A由于乱序执行的原因,可能尚未进行对象的初始化或者是对象初始化还没有完成,这就导致了一个没有完全初始化的对象被发布了,这可能导致非常严重的问题。所以DCL双锁单例模式并不完美。
但是假如把对象用volatile关键字修饰,那就可以避免在对象进行初始化的过程中发生指令重排的情况,从而避免对象的不安全发布现象的发生。

在DCL双锁单利模式中用volatile关键字修饰对象

既然提到了单例,那么下面就介绍另一种线程安全的单例模式实现

基于类初始化的安全单例模式

JVM在类初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化操作。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。基于这个特性,可以实现另一种线程安全的延迟初始化方案:

基于类初始化的安全单例模式

Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化了。

synchronized实现原理

synchronized在大部分时候是实现同步的基础:Java中每一个对象都可以作为锁,具体表现为以下3种形式:

  • 对于普通同步方法,锁是当前实例对象;
  • 对于静态同步方法,锁是当前类的Class对象;
  • 对于同步方法块,锁是synchronized括号里配置的对象。

关于synchronized底层实现原理,我在这篇博客里面介绍了。
Java虚拟机规范
JVM基于进入和退出Monitor对象来实现方法同步以及代码块同步,但两者的实现细节不太一样,代码块同步是使用monitorenter和monitorexit指令实现的,方法同步是在方法表中添加一个ACC_SYNC的标志位,但是方法的同步同样可以使用这两个指令来实现。

锁的升级与对比

锁一共有四种状态,级别由低到高一次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级,锁可以升级但不可以降级(这里和后面提到的ReentrantReadWriteLock中的写锁降级为读锁不是一回事),即轻量级锁升级为重量级锁之后不能降级成轻量级锁。这种锁升级后不能降级的策略的目的是为了提高获取锁和释放锁的效率。

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录当中存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功则代表当前线程已经获得了锁,如果测试失败则需要再测试一下Mark Word中偏向锁的标志是否设置成为了1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

偏向锁使用了一种等到出现竞争才释放锁的机制,所以当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,如果线程不处于活动状态,则将对象头设置为无所状态;如果线程仍然活着,持有偏向锁的栈会被执行,遍历对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合所谓偏向锁,最后唤醒暂停的线程。

关闭偏向锁

偏向锁在jdk1.6和jdk1.7里面是默认启用的,但是它在应用程序启动几秒之后才激活,如有必要可使用参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果我们确定应用程序里所有的锁通常情况下处于竞争状态,可以通过参数来关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,即Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。若成功,当前线程获得锁;若失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁

轻量级锁解锁时,会使用原子的CAS操作将Displaced Mark Word替换到对象头。如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应速度,同步块执行速度非常快
重量级锁 线程竞争不会自旋,不会消耗CPU资源 线程阻塞,响应速度缓慢 追求吞吐量,同步块执行时间比较长

原子操作实现原理

处理器如何实现原子操作
  • 使用总线锁保证原子性。第一个机制是通过总线锁保证原子性,所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存;
  • 使用缓存锁保证原子性。因为在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

但是在这两种情况下处理器不会使用缓存锁定:

  • 当操作的数据不能被还存在处理器内部,或操作的数据跨多个缓存行时,处理器会调用总线锁定;
  • 有些处理器不支持缓存锁定。
Java中如何实现原子操作

在Java中可以通过锁和循环CAS来实现原子操作。从jdk1.5开始,JDK的并发包里面提供了一些类来支持原子操作,如AtomicBoolean(用原子的方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong等。这些原子包装类提供了有用的工具方法,例如以原子的方式将当前值自增1或自减1。
CAS实现原子操作的三大问题

  • ABA问题;
  • 循环时间长开销大;
  • 只能保证一个共享变量的原子操作。从jdk1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是,除了偏向锁,JVM实现锁的方式都使用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

final的内存语义

与锁和volatile相比,对final域的读写更像是普通的变量访问。但其实final在某些时候也可以用来防止对象的不安全发布,下面来说说final的内存语义。

final的重排序规则

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

  • 在构造函数内对一个final域的写入,与随后把这个构造函数的引用赋值给一个引用的变量,这两个操作之间不能重排序;
  • 初次都一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
写final域的重排序规则
  • JMM禁止编译器把final域的写重排序到构造函数之外;
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(这里要注意,此规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会遵守重排序这两个操作。读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用,如果该引用不为空,那么引用对象的final域一定已经被A线程初始化过了。

如果final域为引用类型

对于引用类型,写final域的重排序规则对编译器和处理器做了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外吧这个被构造对象的引用赋值给一个引用变相,这两个操作之间不能重排序。

写final域的规则可以确保在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:那就是在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能再构造函数中"逸出",即不能发生this逸出的情况。

推荐阅读更多精彩内容