文章目录
并发编程-理论基础
一、前言
很多小伙伴学习并发编程,上来就是看 JUC 包,背面试题,但是对并发编程的底层原理不甚了解,导致写出的程序出现奇怪的问题,也没有足够的理论支撑去排查,说实话,我一开始也是这样的。但是随着学习的深入和面试的经历,我越发了解到系统学习并发编程的重要性。
从今天开始,我就带领小伙伴们,从理论触发,逐渐吃透并发编程
俗话说得好,基础不来,地动山摇,在学习并发编程之前,我们要先了解并发编程解决的原因、其引发非问题
这里要提醒一句,并发编程和 JMM (内存模型)密不可分,小伙伴在学习之前,可以先去复习一下 JMM 的知识哦
二、理论基础
1、为什么需要多线程
众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异(在耗时的 IO 执行的时候,可以切换线程执行其他任务,这是多线程出现的最重要的原因)
这种方式导致原子性问题
- CPU 增加了缓存,以均衡与内存的速度差异(CPU 寄存器,一级缓存,二级缓存…其均衡与内存的差异的主要做法,是先将修改写入缓存中,然后在一定时间后,才会将缓存内容写入内存,这样,可以将多次对内存的修改,打包成一次进行修改,减少对内存总线的占用)
导致 可见性问题
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用
导致有序性问题
2、并发问题的根源
可见性、原子性、有序性
1)可见性
可见性,是由于 CPU 缓存导致的
我们通过下面的例子来看看具体可见性问题的成因
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值
2)原子性
**原子性:**即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
3)有序性
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序
重排序后出现问题的例子如下:
// Processor A
a = 1; //A1
x = b; //A2
// Processor B
b = 2; //B1
y = a; //B2
// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0
这段代码,因为重排序的存在,执行结果可能为 x = y = 0
那为什么需要重排序?重排序提高效率又体现在哪里?
通过下图可以发现,经过重排序后,会先将对数据写后的结果,写入缓冲区中,接着,不是按照顺序去刷新主存,而是先去读取主存中的数据,之所以会出现这么荒唐的结果,是因为处理器希望所有缓存对主存的操作一次完成,从而提高效率,而这也就造成了我们程序员的麻烦
3、Java 是如何解决并发问题的
前门我们提到,并发问题从根上来说,就三个问题:原子性,有序性、可见性
在前一天的 JMM 知识分享中我们提到,JMM 为我们屏蔽了不同 CPU 的差异(通过编译期间,为不同 CPU插入不同的内存屏障),保证我们程序员有一个统一的内存模型
说是这么说,但是具体展现给程序员的是个什么东东呢,就好像操作系统总得给个 shell 吧?
其实,JMM 封装后的体现,主要是下面这些:
- volatile、synchronized 和 final 三个关键字
- Happens-Before 规则
那我们再从可见性,有序性,原子性这三个部分,讲讲 JMM 具体是怎么解决的:
1)解决原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行
x = 10; //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x; //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++; //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1; //语句4: 同语句3
如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2)解决可见性
volatile关键字来保证可见性
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值
通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3)解决有序性
在Java里面,可以通过volatile关键字来保证一定的有序性
另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
当然JMM是通过Happens-Before 规则来保证有序性的。
4、8个 Happens-before 规则
前面讲到,如果说 JMM 是 OS的话,那 synchronized lock volatile 就是 shell
那咱们下面要介绍的 Happens-before 规则,就类似于 shell 调用的底层函数了
先行发生原则,让一个操作无需控制就能先于另一个操作完成。
知晓这些规律,Java 并发编程的过程中,有些问题我们就无需考虑安全性了(比如担心没调用 start() 线程就启动,后一个 lock 锁在前一个锁释放的时候就加锁等)
1)单一线程规则
在一个线程内,在程序前面的操作先行发生于后面的操作
2)管程锁定原则
Monitor Lock Rule
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
3)volatile 变量规则
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作
4)线程启动规则
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作
5)线程加入规则
Thread 对象的结束先行发生于 join() 方法返回
6)线程中断规则
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。(先保证有中断,再保证会检测到中断)
7)对象终结规则
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
8)传递性
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C
看完了 happens-before 规则,有的同学可能会想:“就这?”,这些规则虽然看起来简单,但是却默默无闻的为我们在背后做了很多事情(编译器插入内存屏障等)。了解 happens-before 规则,我们程序员就能更专心与业务中出现的并发问题了
5、线程安全的实现方法
线程安全包括三个主要实现方法:
- 互斥同步
- 非阻塞编程
- 无同步方案
这些部分,我们今天先不细说,后面几天再慢慢品,今天先讲讲它们的概念
1)互斥编程
互斥方案,主要就是使用 synchronized 和 JUC 锁 (ReentrantLock)
2)非阻塞编程
前面说到的互斥编程,属于悲观锁,认为同步必定会带来问题,所以在操作前必加锁
说到悲观锁,那一定就有乐观锁了,咱们的非阻塞编程,就是乐观锁
1 - CAS
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。
硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
2 - AtomicInteger
AtomicInteger 就是 java 中对 cas 的实现
其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
以下代码使用了 AtomicInteger 执行了自增的操作:
private AtomicInteger cnt = new AtomicInteger();
public void add() {
cnt.incrementAndGet();
}
3 - ABA
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效
3)无同步方案
1 - 栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的
对这句话不太了解的小伙伴,可以再去复习一下 JVM 内存模型的相关知识哦
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
public static void main(String[] args) {
StackClosedExample example = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> example.add100());
executorService.execute(() -> example.add100());
executorService.shutdown();
}
执行结果:
2 - ThreadLocal
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
其实说的通俗一点,就是 ThreadLocal 为每个线程单独提供了一个空位,每个线程都可以使用这个空位,但是不同线程不能访问其他线程的这个空位
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。
其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
ThreadLocal 其实就是 JVM 维护的一个 Map,key 值是不同线程,value 就是之前说的给每个线程留下的空位,每个线程想在这个空位中存什么都可以
每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
- set 方法:
public void set(T value) {
Thread t = Thread.currentThread();
// key 是线程
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
- get 方法:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
要注意,使用 ThreadLocal 有出现内存泄露的风险(因为它的底层数据结构的问题,就拿 web 请求为例吧,如果我们为每个请求都在 TheadLocal 中存一些数据,那么一旦请求多了,这些数据因为底层 Map 的存在,无法被 GC 回收,就会造成 OOM)
应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险
3 - 可重入代码
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
三、小结
今天分享的知识,只是并发编程的一些入门基础,并没有细讲 JUC 包怎么用,并发代码怎么写。从后面几天开始,我们会不断深入,带领各位走入并发编程的世界!