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

前言

本章主要讲解了volatile以及synchronized的原理和语义,以及Java中的原子操作CAS等。

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

一、volatile

volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。当一个程序修改一个共享变量时,另外一个线程能读到这个修改的值

Java规范定义:允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

volatile使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文切换和调度。

volatile原理

1
2
/* 对volatile进行写操作 */
instance = new Singleton(); //instance是volatile变量

转化成汇编代码:

1
2
0x01a3de1d: movb $0x0,0x1004800(%esi)
0x01a3de24: lock addl $0x0,(%esp)

有volatile变量修饰的共享变量进行写操作的额时候会多出第二行汇编代码。
Lock前缀的指令在多核处理器下会发生两件事:

  1. Lock前缀指令会引起处理器缓存回写到内存。
  2. 一个处理器的缓存回写到内存会使在其他CPU里的该缓存数据无效(个人理解:可以保证数据一致性)

volatile使用优化

例:JDK7的并发包新增的队列集合类LinkedTransferQueue在使用volatile变量时,采用追加字节的方式来优化队列出入队的性能。他将共享变量追加到64字节,原因是现在许多CPU缓存行的大小是64字节宽,不支持部分填充缓存行, 当一个处理器试图修改缓存行的时候会将整个缓存行锁定,若字节数小于64,则头尾节点可能会缓存到同一个缓存行,这样会互相锁定降低修改效率。

二、synchronized

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

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

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

2.1. Java对象头

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

对象头里的Mark Word默认存对象的HashCode,分代年龄和锁标记位

锁状态 25bit 4bit 1bit 是否偏向锁 2bit 锁标志位
重量级锁 - 指向互斥量(重量级锁)的指针 - 10
轻量锁 - 指向栈中锁的指针 00
偏向锁 线程ID 对象分代年龄对象分代年龄 1 01
无锁状态 对象的HashCode 对象分代年龄 0 11
GC标记 - 11

2.2. 锁的升级和对比

为了减少获得锁和释放锁的性能消耗,引入“偏向锁”和“轻量级锁”

锁从低到高的4个状态:无锁 —> 偏向锁 —> 轻量级锁 —> 重量级锁

这几个状态会随着竞争逐渐升级(不可以降级,目的是为了提高获得/释放锁的效率)

1. 偏向锁

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

原理:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进出同步块时不需要进行CAS操作来加锁或解锁,只需要简单的测试一下对象头的MarkWord里是否存储着指向当前线程的偏向锁。(偏向锁在Java6和Java7内是默认启用的)

撤销偏向锁:直到竞争出现才会释放锁。流程:①暂停拥有偏向锁的线程 ②解锁,将线程ID设置为空 ③恢复线程

(注意撤销偏向锁与解锁不同:撤销偏向锁是指将偏向锁的偏向线程改为别的线程或空,解锁是释放锁)

2. 轻量级锁

加锁:JVM在当前线程的栈帧中创建用于存储锁的空间,并拷贝对象头中的MarkWord到锁记录中,再由线程尝试将对象头中的MarkWord替换为指向锁的指针。成功则获得锁,失败则有竞争,线程会采用自旋来获得锁(消耗CPU,为了避免无用的自旋,有必要升级为重量级锁)

解锁:使用CAS将拷贝的MarkWord替换回对象头。如果成功,表示没有竞争发生;如果失败,则当前锁存在竞争,锁会膨胀成重量级锁。

3. 重量级锁

线程试图获取重量级锁的时候,都会被阻塞,当持有锁的线程释放锁后会唤醒这些线程,然后进行新一轮的竞争

锁的优缺点和对比

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

三、原子操作的实现原理

3.1. 实现方法:

  1. 缓存加锁/总线加锁:保证基本内存操作原子性
  2. 缓存锁定/总线锁定:保证跨缓存行或跨页表访问等复杂的内存操作的原子性

3.2. Java中实现原子操作

从Java1.5开始,JDK并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger、AtomicLong等。

3.2.1. 使用循环CAS实现原子操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
AtomicInteger atomicI = new AtomicInteger(0);
int i = 0;
/**
* 使用CAS实现线程安全计数器
* CAS:Compare and set(switch)
* 在操作前先比较旧值有没有变化,没变化才换成新值,否则不替换(可以保证数据一致性)
*/
private void safeCount(){
for (;;){
int i = atomicI.get();
//CAS操作(旧值,新值):旧值相同才替换为新值,否则不替换
boolean suc = atomicI.compareAndSet(i, i++);
if (suc) {
break; //修改成功则break,否则继续循环到操作成功(可能会由于长时间不成功带来巨额开销)
}
}
}

/** 非线程安全计数器 */
private void count(){
i++;
}
3.2.2. 使用锁实现原子操作

锁机制保证了只有获得锁的线程才能操作锁定的内存区域(但其实Java除了偏向锁,其他的锁都用了循环CAS的机制获取和释放锁)

3.2.3. CAS实现原子操作的三大问题:
  1. ABA问题。一个线程进行CAS,另一个线程此时修改一个值从A到B,再B回到A,使用CAS检查的线程以为没有变化,实际上却发生过变化。

    解决思路:使用版本号

    JDK的Atomic包里提供了AtomicStampedRefence来解决ABA问题

  2. 循环时间长开销大。如果CAS长时间不成功,CPU开销会很大。(所以一般重试次数要限制)

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

    解决办法:

    1. 使用锁
    2. 将多个共享变量合并成一个共享变量来操作。JDK提供了AtomicReference,把多个变量放在同一个对象里进行CAS操作。

本文标题:第2章 Java并发编程机制的底层实现原理

文章作者:Aaron.H

发布时间:2018年04月28日 - 20:04

最后更新:2018年09月18日 - 12:09

原始链接:https://uncleaaron.github.io/Blog/Java/Java并发编程艺术/第2章-Java并发编程机制的底层实现原理/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。