[Java并发][2][Java并发机制的底层实现原理]

第2章 Java并发机制的底层实现原理

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制 依赖于JVM的实现和CPU的指令

2.1 volatile的应用

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

2.1.1 volatile的定义与实现原理

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

1
2
0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

volatile变量修饰的共享变量进行写操作的时候会多出上面显示的第二行汇编代码:Lock指令

Lock前缀的指令在多核处理器下会引发了两件事情

  1. 将当前处理器缓存行的数据写回到系统内存
    • 如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据 写回系统内存
    • 但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
    • 在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议
    • 处理器使用嗅探技术保证它的内部缓存系统内存其他处理器的缓存的数据在总线保持一致
    • 处理器发现自己缓存行对应的内存地址 被修改,就会将当前处理器的缓存行设置成无效状态
    • 当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里

2.2 synchronized的实现原理与应用

先来看下利用synchronized实现同步的基础,Java中的每一个对象都可以作为锁。具体表现为以下3种形式:

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

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

从JVM规范中可以看到Synchonized在JVM里的实现原理

  • JVM基于进入和退出Monitor对象实现方法同步和代码块同步。monitorenter指令是在编译后插入到同步代码块的开始位置,而
  • monitorexit插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。
  • 任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。
  • 线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

2.2.1 Java对象头

synchronized用的锁是存在Java对象头里的

Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如表2-3所示。

运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据,如表2-4所示。

2.2.2 锁的升级与对比

CAS,即 Compare And Swap(比较与交换),是一种无锁算法基于硬件原语实现,属于原子指令,能够在不使用锁的情况下实现多线程之间的变量同步

在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态,

这几个状态会随着竞争情况逐渐升级锁可以升级但不能降级,这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

2.2.2.1 偏向锁

轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能

经过研究发现,大多数情况下不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁

偏向锁的获取

  • 一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储 锁偏向线程ID
  • 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁
    • 如果测试成功,表示线程已经获得了锁
    • 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识 是否设置成1(表示当前是偏向锁)
      • 如果没有设置,则使用CAS指令竞争锁
      • 如果设置了,则尝试使用CAS指令将对象头的偏向锁指向当前线程

偏向锁的撤销

  • 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
  • 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着
    • 如果线程不处于活动状态,则将对象头设置成未锁定(标志位为“01”)状态;
    • 如果线程仍然活着,恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
    • 最后唤醒暂停的线程。

偏向锁的获取和撤销流程如下图所示:线程1演示了偏向锁的初始化流程,线程2演示了偏向锁的撤销流程

2.2.2.2 轻量级锁

轻量级锁相对于使用操作系统互斥量实现传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

轻量级锁的加锁过程:

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

轻量级锁的解锁过程:

  1. 通过CAS操作尝试用该线程锁空间中复制的Displaced Mark Word对象替换当前的Mark Word
  2. 如果替换成功整个同步过程就完成了。
  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

下图是两个线程同时争夺锁,导致锁膨胀的流程图。

2.2.2.3 重量级锁

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为**“重量级锁”**。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。

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

2.3 原子操作的实现原理