并发编程艺术

《并发编程的艺术》全书阅读笔记。

减少上下文切换

引起上下文切换的原因大概有以下几种:

  1. 当前执行任务的时间片用完之后, 系统CPU正常调度下一个任务
  2. 当前执行任务碰到IO阻塞, 调度器将挂起此任务, 继续下一任务
  3. 多个任务抢占锁资源, 当前任务没有抢到,被调度器挂起, 继续下一任务
  4. 用户代码挂起当前任务, 让出CPU时间
  5. 硬件中断
  • 无锁并发编程 如锁分段 将数据的ID按照Hash分段 不同线程处理不同段数据。
  • CAS算法 不需要加锁的数据更新算法
  • 使用最少的线程 避免创建不需要的线程
  • 协程 在单个线程内实现多任务切换

减少waiting线程数就可以减少上下午切换 因为每一次从waiting到runnable都会引起一次上下午切换。

避免死锁的常见方法

死锁出现时一定有两个及以上的资源

  • 避免一个线程同时获得多个锁
  • 避免一个线程在锁内占有多个资源 尽量一个锁一个资源

死锁是循环抢占长时间

  • 尝试使用定时锁lock.tryLock(timeout)来替代内部锁机制
  • 对于数据库锁 加锁和解锁应该在一个数据库连接中 否则会出现解锁失败的情况

volatile

Java编程语言运行线程间访问共享变量 但要保证变量被准确和一致性的更新 线程应该用排他锁来获取这个变量。Java内存模型保证了所有线程看到的被volatile修饰的变量的值是一致的。

volatile修饰的共享变量在进行写操作的时候会多出一行汇编代码

1
2
volatile Singleton instance;
instance = new Singleton();
1
2
0x01a3de1d: movb $0x0, 0x1104800(%esi);
0x01a3de24: lock addl $0x0, (%esp);

lock前缀是JVM加的 lock前缀的指令在多核处理器下会发生两件事情:

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效 这是依靠缓存一致性协议 每个CPU通过嗅探总线上传播的数据来检查自己的缓存值是否过期 若过期则将相应的缓存行标记为无效状态 当再次从缓存中读取该无效数据时 就会进去内存读取

实现lock的两个协议:

  1. 缓存锁定保证该操作原子性:这期间该缓存被锁定 缓存一致性机制会阻止同时修改该数据。
  2. MESI协议实现其他CPU数据无效来保证一致性:通过嗅探一个处理器来检测其他处理器打算写内存地址 而这个地址当前处于共享状态 那么正在嗅探的处理器就会将其缓存行无效 下次访问该数据时 强制使用缓存行填充。

synchronized

Java中的每个对象都可以作为锁

  • 对于普通同步方法 锁是当前实例对象
  • 对于静态同步方法 锁是当前类的Class对象
  • 对于同步方法块 锁是Synchronized括号中的对象

锁的存储结构

当一个线程访问同步代码块时 必须获得锁 退出或者异常时释放锁。

JVM基于进入和退出Monitor来实现代码块的同步 moniter enter指令在编译后被插入到同步代码块开始位置 monitor exit插入到同步代码块结束位置或者异常位置 任何对象都有一个monitor与之关联 当一个monitor被持有后 其会进入锁定状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
插播一条:

一个字等于多少个字节,与系统硬件(总线、cpu命令字位数等)有关,不应该毫无前提地说一个字等于多少位。

正确的说法:

①:1字节(byte) = 8位(bit)

②:在16位的系统中(比如8086微机) 1字 (word)= 2字节(byte)= 16(bit)

   在32位的系统中(比如win32) 1字(word)= 4字节(byte)= 32(bit)

   在64位的系统中(比如win64) 1字(word)= 8字节(byte)= 64(bit)

synchronized的锁存在对象头

长度 内容 解释
32/64bit Mark Word 存储对象的hashcode,分代年龄与锁信息
32/64bit Class matedata Address 存储一个指向对象类型数据的指针
32/32bit Array Length 对象如果是数组则存储其长度

muoVt1.jpg

锁升级

Java 1.6 为了减少获得锁和释放锁带来的性能消耗而引入偏向锁和轻量级锁。

锁的四种状态:锁能升级不能降级

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

大多数情况下锁不存在多线程竞争 而是有一个线程多次获得 为了降低线程获得锁的代价 从而引入偏向锁。

偏向锁的实现是在对象头中记录获得锁的线程ID

当一个线程到来时获得锁的步骤:

  • 简单测试一下对象头中存的ID是不是该线程ID
  • 若是 则不需要进行CAS操作来加锁解锁 该线程直接获得锁
  • 若失败 检查对象头mark word里偏向锁标志位是否为1
  • 若不是 该线程去竞争锁
  • 若是 用CAS在对象头中存下该线程ID

偏向锁的撤销TODO

轻量级锁

加锁

当线程执行同步代码块时 JVM会在其栈帧中创建存储锁信息的空间 然后将锁住的对象的mark word复制到栈帧锁信息空间中去 然后用CAS将mark word替换为指向锁信息的指针 如果成功 线程获得锁 如果失败 表明还有其他线程已经获得轻量级锁 于是该线程尝试使用原地自旋(CAS的循环尝试)来等待获得锁

解锁

解锁时会使用CAS将锁信息替换回到Mark word中 如果成功 表示锁不存在竞争 锁释放 如果失败 说明有线程在争夺锁 所以当前锁存在竞争 释放锁 轻量级锁膨胀为重量级锁

为了避免无用的自旋消耗CPU 当锁升级为重量级锁时 竞争该锁的线程进入阻塞 锁释放时唤醒这些线程 进入竞争。

重量锁

mubRi9.jpg

原子操作

CAS有三个参数:内存值(即当前值),期望值,更新值,只有当内存值==期望值时才更新成功。

处理器使用缓存锁定和总线锁定来保证复杂操作的原子性。

  • 缓存锁定 修改内部内存地址 然后利用缓存一致性机制(阻止其他CPU修改被多个CPU缓存的内存数据)
  • 总线锁定 利用处理器提供的LOCK#信号 当一个处理器在总线上传输 开销较大

当数据不能被缓存或者跨多个缓存行是 会使用总线锁定

Java实现原子操作

  • 循环CAS来实现

CAS三大问题:

  1. ABA问题:如果一个变量原来是A 后来变成了B 最后又变成了A 那CAS会认为其没有变过 解决方法就是使用版本号 这样就变为1A->2B-3A atomic包中的AtomicStampedReferance解决了这个问题。
  2. 循环时间开销大
  3. 只能保证一个共享变量的原子操作 解决方法就是使用AtomicReferance(其可以保证引用对象操作的原子性),将多个变量放在一个对象中。
  • 通过锁来实现 除了偏向锁 都使用了CAS来实现锁

Java内存模型

  • 线程间通信:用何种方式来交换信息
  1. 共享内存 隐式通信 (Java并发采用的编程模型)
  2. 消息传递 显式通信
  • 线程间同步:控制线程间操作执行的相对顺序
  1. 在共享内存并发模型中 同步是显式的 程序员必须显式在某个方法或代码块上指定线程互斥执行
  2. 在消息传递并发模型中 同步是隐式的 因为消息的发送必须在接收之前 没有任何显式定义

所有实例域,静态域,数组在堆上 受到并发编程的影响
方法形参 局部变量 异常处理参数 线程私有 不受并发编程影响

Java内存模型JMM与线程通信

线程通信由JMM控制 JMM决定一个线程对共享变量的写入何时对另一个线程可见。线程间通信过程 线程A要想和线程B通信 先将线程A本地内存共享变量值写入主内存 线程B读取主内存共享变量值 并更新自己本地内存的共享变量值。

指令序列的重排序

现代的处理器使用写缓冲区临时保存向内存写入的数据 然后将数据刷新到内存中 但这并不保证刷新时读写操作的顺序就是内存实际发生的读写操作顺序。(内存操作顺序被重排)

内存屏障指令(防止部分指令重排序的)

m3M3h6.jpg

顺序一致性内存模型

未同步程序在顺序一致性内存模型中可能的执行情况

m3jgbj.jpg

未同步程序在JMM与顺序一致性内存模型(SCMM)中执行的差异

  • SCMM保证单线程程序按顺序执行 JMM不保证 会进行指令重排序
  • SCMM保证所有线程看到一致的总执行顺序 JMM不保证
  • JMM不保证在32位机上的64位的long与double读写的原子性 SCMM保证

volatile的内存语义及其实现

volatile变量特性:

  • 可见性 对volatile变量的读 总是能看到任意线程对这个变量最后的写
  • 原子性 volatile变量的读写具有原子性 但类似于volatile++的操作不具有原子性

所以多线程下这两段代码的语义是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class volatileExample {

private volatile long i = 0L;

public void setI(long l){

i = l;
}

public void getAndIncrement(){

i++;
}

public long getI(){

return i;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class volatileExample2{

private long i = 0L;

public synchronized void setI(long l){

i = l;
}

public void getAndIncrement(){

long temp = getI();
temp++;
setI(temp);
}

public synchronized long getI(){

return i;
}
}

volatile写的内存语义:

当写一个volatile变量时 JMM会将对变量的修改刷新到主内存中去。

volatile读的内存语义:

当读一个volatile变量时 JMM会将本地内存的值失效 线程从主内存中去读取。

总结:

线程A写一个volatile变量时相当于给线程B发送了对变量修改的情况的消息 线程B读一个volatile变量时相当于接收了线程A发出的修改情况的消息 所以就相当于线程A通过主内存与线程B进行通信。

volatile内存语义的实现

编译器重排序规则和处理器内存屏障来保障(具体还不理解)

m8OtgS.jpg

m8OBEn.jpg

m8OcgU.jpg

锁的内存语义

锁除了让临界区互斥执行外 还可以让释放锁的线程向获取同一个锁的线程发送消息。因此线程A在释放锁之前所有可见的共享变量在线程B获取同一个锁之后将立即变得对B可见。(与volatile的写-读的内存语义一样)

当线程释放锁时 JMM会将该线程对应的本地内存中的共享变量刷新到主内存中去。

当线程释放锁时,JMM会将该线程对应的本地内存置为无效 从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

锁的内存语义的实现

并发编程

线程状态

mJHMWT.jpg

Donate here.