Java并发 - 底层实现原理

本文大部分摘自书籍《Java并发编程的艺术》

此系列文章对应GitHub地址

前言

Java代码在编译后变成Java字节码,字节码被类加载器加载到JVM中执行,最终转换为汇编指令在CPU上执行,所以Java的所有机制都依赖于JVM虚拟机的实现与CPU的指令。

monitor

监视锁,每个对象都有一个唯一的monitor,它被内置到Java对象头中,所以也称为内置锁,满足以下约束:

  • 同时只能一个线程获得锁

  • 线程通过竞争获取该锁

  • 当线程调用某对象的wait方法时,此线程会释放该对象的锁,并进入休眠状态,直到其他线程获取到了该对象的锁并调用了notify、notifyAll,此时休眠的线程需要通过竞争来获取该对象的锁。

  • 只有拥有某对象的锁的线程才能调用该对象的notify、notifyAll方法,否则抛出java.lang.IllegalMonitorStateException。

volatile

在多线程并发编程中synchronized和volatile都扮演者重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的可见性。可见性意思是当一个线程修改一个共享变量时,另一个线程读取到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

volatile的定义

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

volatile实现原理

对被volatile修饰的变量进行写操作的代码,在生成的汇编代码后会多产生一条以lock为前缀的指令,Lock前缀的指令在多核处理器下会引发两件事:

  • 将当前处理器缓存行的数据回写到系统内存。

  • 写回内存的操作会使在其他CPU中缓存了该内存地址的数据无效。

synchronized实现原理

synchronized实现同步的基础:Java中的每一个对象都可以作为锁的来源。具体表现为以下三种形式:

  • 对于普通同步方法,锁是当前实例对象的锁。

  • 对于静态同步方法,锁是当前类的Class对象的锁。

  • 对于同步方法块,锁是synchronized括号中的对象的锁。
    当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

代码块的同步

使用monitorenter与monitorexit指令实现,monitorenter指令在编译后插入到同步块的开始位置,而monitorexit插入到代码块结束处和异常处,JVM确保每个monitorenter必须有对应的monitorexit与之配对,线程执行到monitorenter指令时,将会尝试获取对象锁。

Java对象头

只要使用synchronized实现同步的地方,使用的锁均是monitor,前面提到了monitor存在Java对象头中,若对象是数组类型,则虚拟机用三个字宽存储对象头,若对象是非数组类型,则使用2个字宽存储对象头。对象头包含以下三种数据:

  • Mark Word
    存储对象的hashCode或锁信息等

  • ClassMetadata Address
    存储到对象类型数据的指针

  • Array length
    数组长度(若当前对象是数组)

Mark Word

包含对象的HashCode、分代年龄、锁信息等信息。在运行期间,Mark Word内存储的数据会随着锁标志位的变化而变化,这里指的变化指的是结构的变化,也就是当锁标志位变化时,Mark Word内记录的信息可能就没有分代年龄、HashCode了,下面是32位虚拟机Mark Word结构的变化:

锁状态25bit4bit1bit2bit
23bit2bit是否为偏向锁锁标志位
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标记11
偏向锁线程IDEpoch对象分代年龄101
无锁状态对象HashCode对象分代年龄001

下面是64位虚拟机Mark Word结构的变化:

锁状态25bit31bit1bit4bit1bit2bit
cms_free分代年龄偏向锁锁标志位
无锁hashCode001
偏向锁ThreadID(54bit) Epoch(2bit)101
锁升级与对比

Java SE 1.6中为了减少获得锁释放锁带来的性能消耗,引入了偏向锁、轻量级锁。Java SE 1.6中一共有四种状态,由低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不可以降级,这种策略目的是为了提高获得锁和释放锁的效率。

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

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

  • 关闭偏向锁
    偏向锁在Java 6和7中默认启用,但是他在应用程序启动几秒钟之后才激活,如果有必要可以使用JVM参数来关闭延迟:

    -XX:BiasedLockingStartuoDelay=0

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

  • 轻量级锁解锁
    轻量级锁解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁是,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

  • 锁优缺点对比

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

原子(atomic)本意是不能被进一步分割的最小粒子,而原子操作(atomic operation)意为不可被中断的一个或一系列操作。

  • 处理器如何实现原子操作
    32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

  • 使用总线锁保证原子性
    如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作之后共享变量的值会和期望的不一样。原因是多个处理器可能从各自的缓存中读取变量,然后分别写入系统内存中。想要保证改写操作是原子的,就必须保证各个处理器读写时,不允许其他处理器操作自己缓存的变量,处理器使用总线锁解决这个问题。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线输出此信号时,其他处理器的请求将被阻塞住,那么该线程就独占了共享内存,而这种阻塞其他线程的方法将带来巨大的开销。

  • 使用缓存锁保证原子性
    使用缓存锁是对锁总线的优化,指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当他执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其它处理器回写已被锁定的缓存行的数据时,会使缓存行无效。以下两种情况下处理器不会使用缓存锁定:

    • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行,此时会使用总线锁定。

    • 处理器不支持缓存锁定

Java原子操作的实现
  • 使用循环CAS实现原子操作
    JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令(也就是比较并交换指令)实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,从JDK 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicInteger(原子方法更新的int值)…,这些原子包装类还提供了有用得工具方法,比如以原子的方式将当前值自增1和自减1。

  • CAS实现原子操作的三大问题
    在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法。CAS虽然很搞笑的解决了愿你在操作,但是CAS任然存在三大问题:

    • ABA问题。
      因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值 原来是A,变化成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加版本号,每次变量更新的时候把版本号加1,那么A-B-A就会变成1A-2B-3A。从Java 1.5开始,JDK的Atomic包里提供了AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否

    • 循环时间长开销大。
      自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:他可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是0;它可以避免在退出循环的时候因内存顺序的冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。

    • 只能保证一个共享变量的原子操作。
      当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是多个共享变量操作时。循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值