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

Java具有“一次编译,到处运行”的特性,而这与JVM(Java Virtual Machine, Java虚拟机)密不可分。因为.java后缀的代码到最后运行,需要先经过编译器编译为.class后缀的字节码,接着字节码被类加载器加载到JVM中,最后转变为0-1的汇编指令才能被CPU执行等这一系列过程。本章的宗旨为深入底层了解Java并发机制的实现原理。

volatile的应用

  1. volatile的定义
    Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获取这个变量。

  2. volatile作用
    volatile是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性”(即一个线程修改一个共享变量时,另外一个线程能读到这个修改的值)。由于使用volatile不会引起线程切换上下文和调度,所以某种程度上使用volatile会比用synchronized的成本低。

  3. volatile实现原则
    (1)Lock前缀指令会引起处理器缓存回写到内存
    (2)一个处理器的缓存回写到内存会导致其他处理器的缓存失效

  4. 优化volatie方法———追加字节优化
    因为大多数处理器的L1、L2、L3缓存的高速缓存行是64字节,不支持部分填充缓存行。所以处理器会把不满足64字节的节点读入到一个高速缓存行,这样会导致当一个处理器要修改时,64字节的高速缓存行都会被锁定。又由于缓存的一致性协议,会导致此时其他所有处理器的缓存行失效,从而无法进行操作,所以这将及其影响性能。
    但并不是盲目地每次都通过追加字节来优化volatie的使用,以下两种情况就不适用:
    (1)少部分缓存行不是64字节宽度的处理器
    (2)不频繁地写共享变量:共享变量不被频繁写,意味着同时竞争的概率小,用锁的概率也就小。

  5. 扩展
    (1)CPU的术语定义

术语 对应英文名 术语解释
内存屏障 memory barriers 是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行 cache line CPU告诉缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令
原子操作 atomic operation 不可中断的一个或一系列操作
缓存行填充 cache line fill 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1, L2, L3的或所有)
缓存命中 cache hit 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取
写命中 write hit 当处理器将操作数写回到一个内存缓存的区域时,它会首先检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数协会到缓存,而不是写到内存

    (2)cpu、缓存、内存、硬盘的关系
逻辑上将内存+缓存称为“内存储空间”,将硬盘称为“外存储空间”。由于cpu无法直接读写硬盘存储的数据,所以需要内存作为二者之间的桥梁,而又由于内存的读写速度与cpu处理速度相差较大,因此引入了缓存的概念,来匹配cpu与内存之间速度的差异,相应地存在一级、二级、三级缓存。

    (3)缓存一致性协议
每个处理器通过嗅探(一般指嗅探器,可以窃听网络上流经的数据包)在总线上传播的数据来检查自己缓存的值是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改时,会重新从系统内存中读取数据到处理器缓存。

synchronized的实现原理与应用

Java SE 1.6前synchronized被称为重量级锁,1.6版本后对某些情况进行了优化。该节主要介绍1.6版本后引入的偏向锁轻量级锁,以及锁的存储结构和升级过程

Java中锁存在的三种形式:

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是Synchronized括号里配置的对象

JVM中Synchronized实现原理
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,代码块同步用monitorenter和monitorexit实现,尽管方法同步的实现用的是另一种方式,但也可以用这两条指令实现。

monitorenter和monitorexit
monitorenter指令是在编译后插入到同步代码块开始的地方,monitorexit则是插入到方法结束处和异常处。JVM中一个monitorenter必与一个monitorexit相对应,而且每个对象都有一个monitor。运行到monitorenter指令时,会尝试获取当前对象对应的monitor所有权,即尝试获得对象的锁。

Java对象头

Java对象头里存储了synchronized用的锁,32位虚拟机中Java对象头的长度如下表所示:

长度 内容 说明
32/64bit Mark Word 存储对象的hashCode或锁信息等
32/64bit Class Metadata Address 存储到对象类型数据的指针
32/32bit Array length 数组的长度(如果当前对象是数据)

锁的升级与对比

Java SE 1.6后锁有四种状态:

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

锁的状态会随着竞争情况依次由上到下升级,同时锁可以升级但不能降级,这种策略保证了获得锁和释放锁的效率。

偏向锁

研究发现大多数的锁总是由同一线程多次获得,所以为了降低线程获取锁的代价引入了偏向锁。

偏向锁初始化流程

关闭偏向锁
Java6/7中偏向锁默认是开启的,它在应用程序启动几秒后才激活,可通过JVM参数-XX:BiasedLockingStartupDelay=0来关闭延迟。如果可以确认所有的锁通常情况下处于竞争状态,就可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false

轻量级锁

轻量级锁及膨胀流程图

锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不适用自旋 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较快
### 原子操作的实现原理
#### 术语定义
CPU术语定义
术语名称 英文 解释
缓存行 Cache line 缓存的最小操作单位
比较并交换 Compare and Swap CAS操作需要输入两个数值,一个旧值(期望操作前的指)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换新值,发生了变化则不交换
CPU流水线 CPU pipeline CPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由5~6不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5~6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,以此提高CPU的执行速度
内存顺序冲突 Memory order violation 内存顺序冲突一般由假共享引起,假共享指多个CPU同时修改同一缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线

处理器实现原子操作的两个机制

  1. 通过总线锁保证原子性
    总线锁:使用处理器提供的一个LOCK #信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以单独共享内存。
    总线锁开销较大,目前处理器在某些场合使用缓存锁定代替总线锁定来优化。
  2. 通过缓存锁定保证原子性
    缓存锁定:内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么它执行锁操作回写到内存时,处理器不在总线上声言LOCK #信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。

两种不使用缓存锁定的情况

  • 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,处理器会调用总线锁定;
  • 处理器不支持缓存锁定

Java实现原子操作

可通过锁和循环CAS方式实现原子操作,自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*
基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count
*/
public class Counter {

private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;

public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();

}
// 等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}

/**
* 使用CAS实现线程安全计数器
*/
private void safeCount() {
for (; ; ) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
// 循环执行CAS操作,直到成功为止
if (suc) {
break;
}
}
}

/**
* 非线程安全计数器
*/
private void count() {
i++;
}

}
1
2
3
4
5
6
7
8
9
结果:
999963
1000000
519

Process finished with exit code 0
---------------------------------
可以看出线程安全的计数器最终得出的结果正确,线程不安全的计数器得到的结果比真实结果要小,说明其中发生了读写操作不是原子的情况。
Java1.5后提供了支持原子操作的类:AtomicBoolean、AtomicInteger、AtomicLong。

CAS实现原子操作的三大问题

  1. ABA问题

问题定义:如果一个值由A变成B,再变成A,那么使用CAS进行检查时会认为它没有发生变化,但实际上有变化。
解决思路
①在变量前增加版本号,每次变量更新时就让版本号加1,上面的问题会从A->B->A,转变为1A->2B->3A,这样CAS在检查时就会发现变量值已经发生了改变。
②Java1.5后提供了AtomicStampedeReference类解决ABA问题,其compareAndSet方法先检查当前引用与预期引用是否相等,接着检查当前标志与预期标志是否相等,都相等的话,再用原子方式更新引用和标志值。

1
2
3
4
5
6
7
// AtomicStampedeReference类的compareAndSet方法
public boolean compareAndSet(
V expectedReference, //预期引用
V newReference, //更新后的引用
int expectedStamp, //预期标志
int newStamp //更新后的标志
)

  1. 循环时间长开销大

问题定义:在进行自旋CAS时,如果一直不成功则需要一直循环,这将带来极大的CPU开销。
解决思路
使用支持处理器pause指令的JVM,因为该指令能做到以下两点:
①延迟流水线执行指令,降低CPU消耗的执行资源;
②避免在退出循环时因内存顺序冲突而引起CPU流水线被清空,提高CPU执行效率。

  1. 只能保证一个共享变量的原子操作

问题定义:操作多个共享变量时,循环CAS无法保证操作的原子性
解决思路:
①使用锁
②将多个共享变量合并成一个共享变量操作(比如两个共享变量i=2,j=a,合并为ij=2a)
③Java1.5后提供了AtomicReference类保证引用对象之间的原子性,可将多个变量放入一个对象进行CAS操作。

JVM实现锁的方式都用了循环CAS,即但一个线程进入同步块时使用循环CAS方式获取锁,该线程退出同步块时使用循环CAS释放锁。

码哥 wechat
欢迎关注个人订阅号:「码上行动GO」