【并发编程的艺术】JAVA 并发机制的底层原理
系列文章:
一 概述
在上一篇文章,概述了 JVM 体系结构和内存模型的基础概念,我们了解到 synchronized 和 volatile 都属于内存模型中,处理可见性、顺序性、一致性等问题的关键策略,这又涉及到操作系统层面。
Java 代码的执行过程:代码编译->Java 字节码->类加载器加载到 JVM->JVM 执行字节码,最终转化为汇编指令在 CPU 中执行。所以,Java 中使用的并发机制,也依赖于 JVM 的实现和 CPU 指令。本章将重点描述这两个关键字的实现,并由此深入探索操作系统底层原理。
二 背景知识
2.1 相关 CPU 术语定义
下面表格来自《Java 并发编程的艺术》:
2.2 CPU 多级缓存
关于 CPU 多级缓存结构,示意图如下:
Intel Core i7 的高速缓存层次结构如下图所示:
这里涉及到几个问题:
1、为什么需要 cache?
为了缓解 cpu 和内存速度不匹配问题。如果没有缓存,那么处理器时钟周期内,CPU 需要常常等待主存,这会导致浪费 cpu 资源。
2、为什么需要多级缓存?
成本与效率的折衷考虑。越接近 CPU 的缓存速度越快,但相应的成本也会越高,所以不会设置很大(否则直接替换内存就好了)。
另外,L1 Cache 还分为 L1 i-Cache 和 L1 d-Cache,其中 L1 i-cache 存储指令,是只读的; L1 d-cache 存储数据,是读写的。
3、带来哪些问题?
3-1 一致性,多级缓存和内存之间,共享数据如何保障一致性
3-2 乱序执行。处理器为了提高运算速度,可能会做出违背代码原有顺序的优化。
更详细的 CPU 缓存分析,可参考文章: CPU Cache。本文不再做展开描述。
三 volatile
大家应该都或多或少了解过 volatile 的含义或作用,例如 volatile 可以理解为轻量级的 synchronized,用于在多处理器开发时,保证共享变量的可见性。如果 volatile 使用得当,在某些场景可以避免使用 synchronized,等等。
3.1 定义
下面我们给出官方对 volatile 的定义,Java语言规范和虚拟机规范官方文档,valatile field:
The Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.
The Java programming language provides a second mechanism, volatile
fields, that is more convenient than locking for some purposes.
A field may be declared volatile
, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).
翻译过来:
Java 编程语言允许多线程访问共享变量。通常,为了确保共享变量得到一致和可靠地更新,线程应该通过获得一个排它锁来确保它独占使用这些变量,按照惯例,该锁强制这些共享变量互斥。
一个字段可以被声明为 volatile,当 Java 内存模型确保所有线程对这个变量看到的值是一致的。
3.2 volatile 的可见性保障
x64 处理器下,通过 jit 编译器获取下面代码的汇编指令,来分析 CPU 做了什么。代码示例:
private volatile instance = new Singleton(); //java 单例模式实现中的一种
对应的汇编命令:
注意其中的 lock,lock 前缀的指令在多核处理器下会引发两件事:
(1)将当前处理器缓存行的数据写回到系统内存
(2)这个写回内存的操作,会使在其他 CPU 里缓存了该内存地址的数据无效。
在变量声明 volatile 之后,如果对这个变量进行了写操作,JVM 就会向 cpu 发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回系统内存(注意,这时其他处理器缓存的值还是旧的,如果还使用这个旧值就会出问题);为了保证各 cpu 的缓存一致,就需要实现缓存一致性协议(MESI):每个 CPU 通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当发现自己缓存行对应的内存地址被修改,就会把当前处理器的缓存行设置为无效状态;当 CPU 对这个数据进行修改操作时,会重新从系统内存中读取数据到 CPU 缓存。
完整资料可查看What Every Programmer Should Know About Memory。后续会考虑翻译部分关键内容。简单了解,也可以参考文章MESI 缓存一致性协议(翻译,中英对照)。
四 synchronized
synchronized,很多人称为重量级锁,但在 Java SE 1.6 对其进行了一系列优化之后,很多情况下就没那么重了。主要包括为了减少获得锁和释放锁带来的性能消耗,而引入的偏向锁和轻量级锁,以及锁升级机制。
4.1 使用基础
Java 中每个对象都可以作为锁;
线程试图访问同步代码块时,必须先获得锁,退出或抛出异常时必须释放锁。
3 种使用方式和锁定的内容:
1、普通同步方法,锁的是当前实例;
2、静态同步方法,锁的是当前类的 Class 对象;
3、同步方法块,锁的是 synchronized 括号里配置的对象。
4.2 锁的本质
synchronized,在 JVM 中基于进入和退出 Monitor 对象来实现方法同步和代码块同步,二者实现细节稍有不同。代码块同步使用 monitorenter 和 monitorexit 指令来实现的,方法同步是另一种方式,细节在 JVM 规范中没有详细说明(待考证)。
synchronized 用的锁,是在 Java 对象头里面的。数组类型比较特殊,用 3 个字宽存储对象头;非数组对象 2 字宽。32 位虚拟机中,1 字宽=4byte=32bit。对象头结构:
其中,Mark Word 默认存储对象的 HashCode、分代年龄和锁标志位(以下都是 32 位虚拟机下的情况):
运行期间,Mark Word 存储的数据会随着锁标志位的变化而变化。可能变化为存储以下 4 种数据:
其中,偏向锁、轻量级锁、重量级锁就是锁升级机制的重要组成部分。
64 位虚拟机下,Mark Word 是 64bit 大小,结构如下图:
4.3 锁升级机制
锁的四种状态,级别由低到高:无锁状态、偏向锁、轻量级锁、重量级锁。状态会随竞争情况逐渐升级。注意,升级的方向只能从低到高,没有降级策略。
4.3.1 偏向锁
实现:对象头和栈帧中的锁记录里,存储锁偏向的线程 id。撤销机制:等到竞争出现,即其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。并且需要等到全局安全点(此时没有正在执行的字节码)。
获取和撤销流程见下图:
4.3.2 轻量级锁与重量级锁
线程执行同步代码块前,JVM 在当前线程的栈帧中创建用语存储锁记录的空间,并将对象头的 Mark Word 复制到锁记录中。然后线程尝试 CAS 将对象头中的 Mark Word 替换为指向所记录的指针。成功表示当前线程获得锁,否则表示其他线程竞争锁,当前线程通过自旋来获取锁。
解锁时,CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功则表示没有竞争;失败则表示存在锁竞争,会继续膨胀为重量级锁。下图是锁竞争导致膨胀的流程:
注:上面我们提到竞争时线程会尝试自旋来获取锁,而自旋会消耗 CPU 资源。所以一旦锁升级成重量级锁,就不会再恢复到轻量级锁。当锁处于重量级状态下,而其他线程尝试获取锁时,都会被阻塞,只有当持有锁的线程释放锁时才唤醒这些线程,并进行下一轮的锁竞争。
4.3.3 锁优缺点分析
通过资源消耗、线程是否阻塞、响应耗时等角度分析,偏向锁、轻量级锁、重量级锁各自的优缺点如下表所示:
五 总结
本文是并发编程系列的第二篇。通过上篇文章,对 JVM 体系结构和内存模型有了一些了解的基础上,开始介绍内存模型中的锁、可见性的深入分析。在下一篇的文章中,我们将继续分析原子性、和顺序一致性等其他内容。
参考资料:
版权声明: 本文为 InfoQ 作者【程序员架构进阶】的原创文章。
原文链接:【http://xie.infoq.cn/article/8f4488caa381e7abc4bf1ca0e】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论