前言
本章主要讲解了volatile以及synchronized的原理和语义,以及Java中的原子操作CAS等。
第2章 Java并发编程机制的底层实现原理
一、volatile
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。(当一个程序修改一个共享变量时,另外一个线程能读到这个修改的值)
Java规范定义:允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
volatile使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文切换和调度。
volatile原理
1 | /* 对volatile进行写操作 */ |
转化成汇编代码:1
20x01a3de1d: movb $0x0,0x1004800(%esi)
0x01a3de24: lock addl $0x0,(%esp)
有volatile变量修饰的共享变量进行写操作的额时候会多出第二行汇编代码。
Lock前缀的指令在多核处理器下会发生两件事:
- Lock前缀指令会引起处理器缓存回写到内存。
- 一个处理器的缓存回写到内存会使在其他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. 实现方法:
- 缓存加锁/总线加锁:保证基本内存操作原子性
- 缓存锁定/总线锁定:保证跨缓存行或跨页表访问等复杂的内存操作的原子性
3.2. Java中实现原子操作
从Java1.5开始,JDK并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger、AtomicLong等。
3.2.1. 使用循环CAS实现原子操作
1 | AtomicInteger atomicI = new AtomicInteger(0); |
3.2.2. 使用锁实现原子操作
锁机制保证了只有获得锁的线程才能操作锁定的内存区域(但其实Java除了偏向锁,其他的锁都用了循环CAS的机制获取和释放锁)
3.2.3. CAS实现原子操作的三大问题:
ABA问题。一个线程进行CAS,另一个线程此时修改一个值从A到B,再B回到A,使用CAS检查的线程以为没有变化,实际上却发生过变化。
解决思路:使用版本号
JDK的Atomic包里提供了AtomicStampedRefence来解决ABA问题
循环时间长开销大。如果CAS长时间不成功,CPU开销会很大。(所以一般重试次数要限制)
只能保证一个共享变量的原子操作。
解决办法:
- 使用锁
- 将多个共享变量合并成一个共享变量来操作。JDK提供了AtomicReference,把多个变量放在同一个对象里进行CAS操作。