
《Java并发编程的艺术》阅读笔记
如何减少上下文切换
减少上下文切换
- 无锁并发编程。
- 多线程竞争锁,会引发上下文切换 -> 考虑避免使用锁;
- 如,将数据的ID按照Hash算法取模分段,不同线程处理不同段的数据;
- CAS算法
- Java的Atomic包使用CAS算法来更新数据,不需要加锁;
- 使用最少线程
- 避免创建不必要的线程;
- 协程:在单线程实现多任务的调度,在单线程里维持多个任务间的切换;
避免死锁
- 避免一个线程同时获取多个锁;
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源;
- 尝试使用定时锁,使用
lock.tryLock(timeout)
来替代使用内部锁机制; - 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则可能出现解锁失败的情况;
服务器集群
可以使用ODPS或Hadoop搭建一个服务器集群。
volatile
定义:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应却表通过排它锁单独获得这个变量;
CPU术语
《Java并发编程的艺术》Page.9
术语 | 英文 | 描述 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于实现对内存操作的顺序限制。 |
缓冲行 | cache line | CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行会加载整个缓存行,现存CPU需要执行几百次CPU指令。 |
原子操作 | atomic operations | 不可中断的一个或一系列操作。 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3或所有)。 |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取。 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作称为写命中。 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域。 |
如何实现可见性
- 如果对volatile变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据协会到系统内存;
- 为了保证各个处理器的缓存是一致的,实现了缓存一致性协议
- 每个处理器通过嗅探总线上传播的数据来检查自己的缓存值是否过期,
- 当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,
- 当处理器对这个数据进行修改操作,会重新从系统内存中把数据读到处理器缓存里。
实现原则
Lock前缀指令会引起处理器缓存协会到内存。
一个处理器的缓存回写到内存会导致其他处理器的缓存无效
- IA-32处理器和Intel 64处理器使用MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性;
- IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和他们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致;
- 在Pentium和P6 family处理器中,如果有处理器嗅探到别的处理器打算写内存地址,而这个地址处于共享状态,那么这个处理器会使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
案例:追加字节数提高volatile
《Java并发编程的艺术》Page.10
著名Java并发编程大师Dong lea在JDK 7 里新增了一个队列集合类LinkedTransferQueue,在使用volatile变量时,使用追加字节的方式来优化队列出队,入队的性能:
1 | private transient final PaddedAtomicReference<QNode> head; |
- 这里的目的是:将对象追加到64字节;
- 一个对象引用4字节,有15个变量即60个字节,再加上父类value变量,一共64个字节;
为什么要添加到64字节?
对于英特尔酷睿i7,酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器上的L1、L2、L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行;
这意味着,若队列头结点和尾节点不足64字节的话,处理器会将他们读到同一个高速缓存行中;
当一个处理器试图修改头结点,会将整个缓存行锁定;在缓存一致性机制下,其他处理器会不能访问高速缓存的尾节点;
而队列的入队和出队操作需要不停修改头结点尾节点,在多处理器情况下会严重影响到队列的入队和出队效率;
追加到64字节,填满高速缓冲区的缓存行,避免头结点和尾节点加载到同一个缓存行,使头、尾节点不互相锁定。
是不是所有的volatile变量都要追加到64字节呢?
不是。
- 缓存行非64位宽的处理器不能;
- 如P6系列和奔腾处理器,他们的L1和L2高速缓存行是32个字节宽;
- 共享变量不会被频繁地写。
- 追加字节的方式需要处理器读取更多字节到高速缓冲区,本身带来一定性能损耗,如果变量本身不被频繁写,锁的几率是非常小的,就没必要通过追加字节的方式来避免互相锁定。
其他:该写法Java7下不生效,Java7会淘汰或重新排列无用字段;
Synchronized原理
synchronized实现同步的基础:Java中每个对象都可以作为锁;
- 对于普通同步方法,锁的是当前实例对象;
- 对于静态同步方法,锁是当前类的Class对象;
- 对于同步代码块,锁是Synchronized括号里配置的对象。
在JVM中原理
当一个线程视图访问同步代码块时,首先必须得到锁,退出或抛出异常时必须释放锁;
在JVM中,代码块的同步是使用monitorenter和monitorexit指令实现的:
- monitorenter插入到同步代码块的开始位置,monitorexit插入到同步代码块的结束和异常位置;
- JVM保证:每个monitorenter必须有对应的monitorexit与之配对;
- 任何一个对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态;
- 线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取monitor的锁。
Java对象头
synchronized用的锁在Java的对象头里;
- 若对象是数组类型,则虚拟机用3个字宽存储对象头;
- 若对象是非数组类型,则用2个字宽存储对象头;
- 在32位虚拟机中,1字宽等于4字节,即32bit;
Java对象头的长度
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 对象类型数据的指针 |
32/32bit | Array Length | 数组的长度(若对象是数组) |
Java对象头的Mark Word默认存储对象的:HashCode,分代年龄和锁标记位。
32位JVM Mark Word Java对象头存储结构
锁状态 | 25bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
Mark Word 运行期间变化
锁状态 | 25bit | 4bit | 1bit | 2bit | |
---|---|---|---|---|---|
23bit | 2bit | 是否是偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | |||
GC标记 | 空 | 11 | |||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
在64位虚拟机下,Mark Word是64bit大小的;
64位虚拟机下,Mark Word 存储结构
锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit | 2bit |
---|---|---|---|---|---|---|
cms_free | 分代年龄 | 偏向锁 | 锁标志位 | |||
无锁 | unused | hashCode | 0 | 01 | ||
偏向锁 | ThreadId(54bit) Epoch(2bit) | 1 | 01 |
锁的升级和对比
Java SE1.6为了减少锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁;
在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
锁可以升级但不能降级,即偏向锁升级成轻量级锁后不能降低到偏向锁,目的是为了提高获得和释放锁的效率。
偏向锁
HotSpot作者研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。为了让线程获得锁的代价更低而引入了偏向锁;
当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储指向当前线程的偏向锁。若测试成功,表示线程已经获得了锁。若测试失败,则需要再测试一下Mark Word中偏向锁的标志是否为1(表示当前是偏向锁);若没有设置,则使用CAS竞争锁;若设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁执行流程
- 一个线程访问同步块
- 测试对象头的Mark Word是否存储指向当前线程的偏向锁
- 存在
- 线程获得锁
- 不存在
- 测试Mark Word中偏向锁的标志是否为1
- 是:尝试使用CAS将对象头的偏向锁指向当前线程
- 不是:使用CAS竞争锁
- 测试Mark Word中偏向锁的标志是否为1
- 存在
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁;
偏向锁的撤销,需要等待一个全局安全点(这个时间点没有正在执行的字节码);
当出现竞争,首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,若线程不活跃,则将对象头设置为无锁状态;若线程存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程;
撤销流程
- 出现竞争
- 暂停拥有偏向锁的线程
- 检查持有偏向锁的线程是否存活
- 未存活
- 对象头设置为无锁状态;
- 存活
- 拥有偏向锁的栈会被执行
- 遍历对象的锁记录
- 栈中的锁记录和对象头的Mark Word
- 要么重新偏向于其他线程,
- 要么恢复好无锁或标记对象不适合作为偏向锁
- 未存活
- 唤醒暂停的线程