近来为了查漏补缺,重新拿起机械工业出版社的《Java并发编程的艺术》一书,并提取其中要点总结读书笔记记录下来。
1. 并发编程的问题
1.1 多线程一定快吗?
不一定,因为线程有创建和上下文切换的开销。
1.2 如何减少上下文切换
- 无锁并发编程: 多线程处理时用一些方法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同线程处理不同段的数据。
- CAS算法:Java的Atomic包使用CAS算法来更新数据,不需加锁。
- 使用最少线程:避免创建不需要的线程,避免大量线程处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
1.3 避免死锁的几个常用方法
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在同一个数据库连接里,否则会出现解锁失败的情况。
1.4 资源限制问题
并发编程中将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,如果改成并发后受限于CPU等实际资源情况仍在串行执行,则反而会因为增加了上下文切换及资源调度的时间而变得更慢。
使用多线程处理问题时,需要根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源:带宽和硬盘读写速度,数据库操作涉及数据库连接数等。
2. Java并发机制的底层实现原理
2.1 CPU术语定义
术语 | 英文单词 | 术语描述 |
---|---|---|
内存屏障 | memory barriers | 一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1, L2, L3 的或所有) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,他首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回缓存,而不是写回到内存 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
2.2 volatile(用于保证可见性)
2.2.1 定义
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。(Java语言规范第3版中的定义)
2.2.2 实现原理
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
注:关于volatile的使用还会在后续的章节提到(内存模型和线程间通信)
2.3 synchronized
synchronized 实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是Synchonized括号里配置的对象。
synchronized用的锁存在Java对象头里。
2.3.1 Java对象头
- 数据类型对象:虚拟机用3个字宽(Word)存储对象头。
- 非数组类型:虚拟机用2个字宽存储对象头。
注:在32位虚拟机中1字宽等于4字节(32bit)
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/32bit | Array length | 数组的长度(如果当前对象是数组) |
32位JVM的 Mark Word 默认存储结构如下表:
(还不了解CSDN能否支持多表头,先整理成这样,将就看吧)
锁状态 | 25bit | 4bit | 1bit 是否偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
轻量级锁 | 30bit 指向栈中锁记录的指针 | 00 | ||
重量级锁 | 30bit 指向互斥量(重量级锁)的指针 | 10 | ||
GC标记 | 30bit 空 | 11 | ||
偏向锁 | 线程ID(23bit) Epoch(2bit) | 对象分代年龄 | 1 | 01 |
64位虚拟机的 Mark Word 是 64bit 大小的,其存储结构如下表:
锁状态 | 56bit | 1bit cms_free | 4bit 分代年龄 | 1bit 偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|---|
无锁 | unused(25bit) hashCode(31bit) | - | - | 0 | 01 |
偏向锁 | ThreadID(54bit) Epoch(2bit) | - | - | 1 | 01 |
2.3.2 锁升级与对比
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。
4种锁状态,级别从低到高:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
锁可以随竞争情况逐渐升级,但是不能降级(为了提高效率)。
2.3.2.1 偏向锁
2.3.2.1.1 背景
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
2.3.2.1.2 偏向锁加锁
一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里面存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,
只需要测试下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败,需要再测试以下 Mark Word 中偏向锁标识是否已设为 1 :
- 如果不为 1,则使用 CAS 竞争锁,
- 如果已设为 1,则尝试使用 CAS 将对象头的偏向锁指向当前线程。
2.3.2.1.3 偏向锁解锁
偏向锁使用一种等到竞争出现才释放锁的机制。
偏向锁的撤销需要等待全局安全点。然后先暂停拥有偏向锁的线程,检查持有偏向锁的线程是否活着,
如果不处于活动状态,则将对象头设置成无锁状态;
如果线程还活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,张总的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
2.3.2.1.4 关闭偏向锁
在 Java 6 和 Java 7 默认启用,但是它在应用程序启动几秒钟后才激活,有必要的话可使用JVM参数关闭延迟: -XX:BiasedLockingStartupDelay=0。
如果确定应用程序所有锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁,那么程序会默认进入轻量级锁状态: -XX:-UseBiasedLocking=false。
2.3.2.2 轻量级锁
2.3.2.2.1 轻量级锁加锁
- 线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中(Displaced Mark Word);
- 然后线程尝试使用CAS将对象头中的 Mark Word 替换为指向锁记录的指针(将 Mark Word 与栈帧中锁记录比对,如果成功则更新 Mark Word 为指向锁记录的指针)。
如果成功,当前线程获得锁;
如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
2.3.2.2.2 轻量级锁解锁
使用原子CAS操作将 Displace Mark Word 替换回到对象头,
如果成功,则表示没有竞争发生;
如果失败,表示当前锁存在竞争,锁膨胀成重量级锁,引发线程阻塞。
2.3.2.3 锁的优缺点对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求相应时间,同步块执行速度非常块 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
2.4 原子操作的实现原理
原子操作:不可被中断的一个或一系列操作。
2.4.1 处理器如何实现原子操作
处理器提供两个机制来保证复杂内存操作的原子性:
- 总线锁定:当一个个处理器在总线输出锁定信号时,其他处理器的请求将被阻塞住,保证该处理器可以独占共享内存(开销较大)。
- 缓存锁定:内存区域如果被缓存在处理器缓存行中,并且在Lock操作期间被锁定,执行锁操作写回内存时直接修改内部内存地址并允许缓存一致性机制保存操作原子性。
两种情况下处理器不会使用缓存锁定:
- 操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,处理器会调用总线锁定。
- 有些处理器不支持缓存锁定,如 Intel 486 和 Pentium处理器。
2.4.2 Java如何实现原子操作(锁 或 循环CAS)
CAS实现原子操作的三大问题
- ABA问题
- 循环时间长开销大
- 只能保证一个共享变量的原子操作
相关文章参考:
https://blog.csdn.net/dreamytian/article/details/79612345