1 什么是并发编程
并行: 同一时刻, 多个任务同时执行.(一个 CPU 核 处理一个任务)
并发: 一个时间段内, 多个任务都在执行, 并且都没有执行结束. (一个 CPU 核处理多个任务)
在多线程编程中, 线程的个数往往多于 CPU 的个数, 所以一般称之为 并发编程.
并发编程可以提高系统性能, 提高吞吐量.
2 线程安全问题
共享资源: 多个线程都可以访问的资源. 或者说, 多个线程共享的资源.
线程安全问题是指:
当多个线程同时 读写 一个 共享资源 并且没有采取同步措施时, 导致出现 脏数据 的问题.
一个经典的线程安全问题的例子:
多线程 执行 count++
操作, count++
不是一个原子操作, 实际上 count++
是 3 个原子操作(获取变量值-计算-写入变量中)组成的. 当两个线程同时执行 count++
时, 本来期望是 count=count+2
, 但可能实际结果是 count=count+1
.
3 Java 共享变量的内存可见性问题
Java 内存模型(Java Memory Model) 规定, 将所有的变量保存在 主内存 中.
当线程使用变量时, 会把主内存中的变量 复制 到 自己的 工作内存 中, 线程读写变量时 操作的是 自己工作内存中的变量.
JMM 是一个抽象的概念, 实际上线程的 工作内存 可能是 CPU 的 一级缓存或二级缓存.
虽然 CPU 架构不同可能导致 线程工作内存 有所不同, 但是从 JMM 角度, 多线程操作 共享变量 的过程是一样的:
当一个线程操作共享变量时, 它首先从 主内存 复制 共享变量 到自己的 工作内存, 然后对 工作内存 中的变量进行处理, 处理后将变量更新到 主内存.
内存可见性问题示例:
- 线程A 获取 共享变量X 的值, 由于工作内存没有, 所以加载主内存中 X 的值. 假设主内存中 X=0, 线程A 将 X+1 后写回主内存, 此时 主内存 和 线程A 的工作内存里 X 的值都是 1;
- 线程B 获取 共享变量X 的值, 由于工作内存没有, 所以加载主内存中 X 的值. 此时 X=1, 线程B 将 X+1 后写回主内存, 此时 主内存 和 线程B 的工作内存里 X 的值都是 2;
- 线程A 又要修改 X 的值, 由于工作内存中已经有 X, 而且 X=1, 那么问题来了, 现在主内存的 X=2, 线程A 获取到的却是 X=1. 这就是共享变量的内存不可见问题, 也就是 线程B 写入的值 对 线程A 不可见.
可以通过 volatile
关键字 或者 加锁 解决共享变量 内存不可见 问题.
4 Java 中的原子性操作
所谓原子操作, 是指执行一些列操作, 要么全部执行, 要不全部不执行.
如下代码是线程不安全的:
public class Test {
private long value;
public long get() {
return value;
}
public void increment() {
value++;
}
}
对于 increment
方法, value++
不是原子操作, 我们可以通过 synchronized
关键字将其变成原子操作:
public class Test {
private long value;
public synchronized long get() {
return value;
}
public synchronized void increment() {
value++;
}
}
注意:
synchronized
可以保证 内存可见性 和 原子性, 所以可以实现线程安全.
但是 synchronized
是独占锁(exclusive clock), 没有获取到监视器锁的线程会被阻塞.
对于像 get
这种读操作, 多线程读并不会存在线程安全问题, 所有直接加 synchronized
会降低并发性能. 如果直接去掉的话, 内存可见性无法保证. 也可以用 volatile
来保证内存可见性, 这样性能应该会有所提升.
其实, Java 内部已经 通过 CAS算法 实现了一些原子操作类, 比如 AtomicLong
, 这些类可以保证线程安全, 而且没有加锁, 可以保证并发性能.
5 Java 中的 CAS 操作
使用 锁 有一个不好的地方, 就是 当一个线程没有获取到 锁 时会被 阻塞挂起, 这会导致线程 上下文切换 和 重新调度 开销.
Java 提供了非阻塞的 volatile
关键字来解决 共享变量的 内存可见性问题, 这在一定的程度上弥补了锁带来的开销问题, 但是 volatile
只能保证内存可见性, 不能保证原子性.
CAS 即 Compare and Swap, 是 JDK 提供的 非阻塞原子性操作. 它通过 硬件保证了 比较-更新 操作的原子性.
CAS 算法通过 Unsafe
类来实现.
CAS 可能会存在 ABA 问题, JDK 中的 AtomicStampedReference
类给每个变量加了一个时间戳, 从而避免了 ABA 问题的产生.
备注:
所谓 ABA 问题, 就是 线程T1 对变量X 修改时, 先比较 X 的值是不是为 A, 如果为 A 就修改.
但 X=A 并不能保证 X的值没有改变过, 可能 X 开始等于A, 但后来被其他线程改成了 B, 最后又被改成了 A.
虽然最终 X=A, 但这个 A 已经不是最初那个 A 了.
Reference
[1]. Java 并发编程之美-翟陆续