第1章 并发编程的挑战

1.1 上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给 每个线程 分配 CPU时间片 来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

CPU 通过 时间片分配算法 来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

1.1.3 如何减少上下文切换
  • 无锁并发编程。 如将数据的ID 按照 Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS 算法。Java 的Atomic 包使用CAS 算法来更新数据,而不需要加锁。
  • 使用最小线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量的线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

1.2 死锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class DeadLockDemo {
private static String A = "A";
private static String B = "B";

public static void main(String[] args) {
DeadLockDemo deadLockDemo = new DeadLockDemo();
deadLockDemo.deadLock();
}

private void deadLock() {
Thread t1 = new Thread(()->{
synchronized (A) {
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
});

Thread t2 = new Thread(()->{
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
});
t1.start();
t2.start();
}
}

线程0和线程1都处于阻塞状态,两个线程互相持有一个锁,并且等待其他的线程那个锁。

避免死锁的几个常用方法
  • 避免一个线程同时获取多个锁。
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  • 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制。
  • 对于数据库锁,加锁和解锁必须在同一个数据库连接里,否则会出现解锁失败的情况。
出现死锁四个必要条件
  1. 资源互斥:一个资源只能被一个进程使用
  2. 请求与保持:当一个进程因请求资源而阻塞时候,保持已获得资源不放
  3. 不剥夺:进程已获得资源,在未使用完成之前,不能被其他进程强行剥夺
  4. 循环等待:若干进程之间形成一种头尾相接的循环等待资源关系

1.3 资源限制的挑战

硬件资源限制有带宽的上传/下载速度、硬盘读写速度和 CPU 的处理速度。软件资源限制有数据库的连接数和 socket 连接数等。

1.4 理解并发/并行

并行(parallel):多线程分别绑定多cpu,可以让两个以上的线程同时运行;

在单CPU系统中,系统调度在某一时刻只能让一个线程运行,虽然这种调试机制有多种形式(大多数是时间片轮巡为主),但无论如何,要通过不断切换需要运行的线程让其运行的方式就叫并发(concurrent);

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

Java 中所使用的并发机制依赖于JVM的实现和CPU的指令。

2.1 volatile的应用

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

Java 语言规范第3版中对 volatile的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过 排他锁 单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁要更加方便。如果一个字段被声明为 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。

被volatile修饰的共享变量进行写操作,会多一个 Lock 前缀。总线锁

1
ox01a3de24 lock addl $0x0,(%esp);

Lock 前缀的指令在多核处理器下会引发了两件事情。

(1) 将当前处理器缓存行的数据写回到系统内存。

(2) 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

2.2 synchronized的实现原理与应用

Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步代码块,锁是Synchronized括号里配置的对象。

JVM 基于进入和退出 Monitor(监控) 对象来实现方法同步和代码块同步。monitorenter 指令是在编译后插入到同步块的 开始位置,而monitorexit 是插入到 方法结束处和异常处,JVM 要保证每个monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到 monitorenter指令时,将会尝试获取对象所对应的 monitor的所有权,即尝试获取对象的锁。

2.2.1 Java对象头

synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word) 存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机里,1字宽等于4字节,即32bit。

长度 内容 说明
32/64 bit Mark Word 存储对象的hashCode或锁信息等
32/64 bit Class Metadata Address 存储到对象类型数据的指针
32/64 bit Array length 数组的长度(如果当前对象是数组)

Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的 默认存储结构 如下:

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的 hashCode 对象分代年龄 0 01

在运行期间,Mark Word里存储的数据会随着 锁标记位的变化而变化 。Mark Word可能变化为存储以下4种数据。

image-20190429144648763
image-20190429144648763
image-20190429145829594
image-20190429145829594

2.3 原子操作的实现原理

原子(atomic)本意是”不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为”不可被中断的一个或一系列操作”。

Java如何实现原子操作
  • 使用循环 CAS 来保证原子操作
  • Cas 实现原子操作的三大问题
    1. ABA问题
    2. 循环时间长开销大
    3. 只能保证一个共享变量的原子操作
  • 使用锁机制实现原子操作

第3章 Java内存模型

3.1 Java内存模型的基础

3.1.1 并发编程模型的两个关键问题

线程之间如何 通信 及线程之间如何 同步,是并发编程模型的两个关键问题。

  • 线程之间的 通信机制 有两种:共享内存消息传递

    在共享内存的并发模型里,线程之间共享程序的公共状态,通过 写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里(例如分布式),线程之间没有公共状态,线程之间必须 通过发送消息来显式进行通信

  • 同步 是指程序中用于控制不同线程间操作发生相对顺序的机制。

    共享内存并发 模型里, 同步是显式 进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在 消息传递的并发 模型里,由于消息的发送必须在消息的接收之前,因此 同步是隐式进行 的。

Java 的并发采用的是共享内存模型,Java 线程间的通信总是 隐式通信

3.1.2 Java内存模型的抽象结构

第11章 Java并发编程的实践