只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

33、线程安全、临界区、竞态

内容来自王争 Java 编程之美

线程安全、临界区、竞态是学习多线程的过程中,经常遇到的几个概念,因此本节我们就来详细讲解一下它们,为以后的学习做铺垫
那么线程安全中的安全两字如何理解呢?什么样的代码是线程安全的代码?什么样的代码线程不安全的代码?如何做到线程安全?
围绕着这几个问题,我们开始本节的学习

1、线程安全概念

在 Java 中,线程安全或不安全描述的对象既可以是类,也可以是函数

1.1、线程安全函数

对于函数来说,两个线程并发执行某个函数,因为线程切换,两个线程执行的指令之间可以任意交叉执行,如下所示
如果任意执行顺序最终得到的结果都是相同的、确定的、符合预期的,那么我们就称这个函数是线程安全的,否则我们就称为这个函数是线程不安全的,或者非线程安全的
image

1.2、线程安全类

对于类来说,除了要求类中的每个函数都是线程安全的之外,函数间也必须保证线程安全
两个线程并发执行一个类的任意两个不同的函数,两个线程执行的指令之间可以任意交叉执行
如果任意执行顺序最终得到的结果都是相同的、确定的、符合预期的,那么我们就称这个类是线程安全的,否则我们就称这个类是线程不安全的,或者非线程安全的
image

1.3、总结

如果一个类是线程安全的,那么所有的函数都是线程安全的
如果一个类的所有的函数都是线程安全的,那么并不能推出这个类就一定是线程安全的

2、临界区和竞态

我们把有可能引起线程不安全的局部代码块,叫做临界区(Critical Section)
我们把两个线程竞争执行临界区的这种状态,叫做竞态(Race Condition)
两个线程处于竞态执行临界区,就有可能执行出错

具体来讲,什么样的代码才是临界区呢?临界区一般包含以下两个特征

  • 访问共享资源
  • 包含复合操作

2.1、访问共享资源

共享资源包括类中的成员变量、通过函数参数传递进来的共享对象等
如果某个函数只访问局部变量,而局部变量存储在栈中供线程独享,那么这个函数就不存在线程安全问题
除此之外,如果代码只包含对共享资源的读操作,那么这段代码一般也不会存在线程安全问题

2.2、包含复合操作

复合操作由多个操作组成,比如先检查再执行、先读取再修改后写入,这些复合操作一般都是非原子操作
实际上,之前讲到的非双重检测的单例就属于先检查再执行这类复合操作,自增操作就属于先读取再修改后写入这类复合操作
除此之外,往 LinkedList、HashMap 中添加元素,底层都是复合操作

// 先检查再执行
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
// 先读取再修改后写入
public class Demo {
private int count = 0;
public void increment() {
count++;
}
}

3、线程安全分析

在平时的开发中,尽管我们很少编写多线程代码,但是我们编写的代码常常会运行在框架、容器中,框架、容器往往会使用多线程,并发执行我们编写的代码
因此,学会编写线程安全的代码非常重要,不过要想写出线程安全的代码,首先要能查找到哪些代码线程不安全

前面已经对线程安全和不安全做了一些介绍,线程不安全的代码一般都会存在临界区和竞态
不过,临界区和竞态是线程不安全的必要条件,而非充分条件,也就是说,两个线程并发竞争执行访问共享资源并且包含复合操作的代码,并不一定就会出问题
代码存在临界区和竞态,只能说明代码存在线程不安全的风险,我们需要继续深入分析,看两个线程交叉执行临界区,是否真的存在执行结果不确定、不符合预期的情况

3.1、示例 1

我们举个例子进一步解释一下,如何分析是否线程安全,示例代码如下所示

public class Counter {
private int count = 0;
public void add(int value) {
count += value;
}
public void subtract(int value) {
count -= value;
}
}

在上述代码中,add() 函数访问共享资源(count),并且包含复合操作(count += value 类似自增操作),因此 add() 函数是临界区
在竞态下,也就是两个线程并发交叉执行 add() 函数中的代码,就有可能出现结果不符合预期的情况,比如 count 原本是 0,两个线程同时执行 add(5),最后 count 值有可能为 5 而非 10
因此 add() 函数是线程不安全的,同理,substract() 函数也是线程不安全的
image

不仅如此,如果一个线程执行 add() 函数,另一个线程执行 substract() 函数,两个线程交叉并发执行,那么结果有可能不符合预期,如下图举例所示
image

3.2、示例 2

除此之外,如果临界区访问的共享资源有多个,那么我们还需要查看指令重排序是否会影响执行结果,导致线程不安全,比如第 30 节中例子,如下所示

public class Demo {
private static boolean ready = false;
private static int value = 1;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!ready) {
}
System.out.println(value);
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
value = 2; // 写操作
ready = true; // 写操作
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}

3.3、示例 3

在分析代码是否线程安全时,我们还需要关注代码中所使用的其他类或函数是否线程安全,是否会引起自己编写的类或函数的线程安全性问题
除此之外,特别需要注意的是,使用线程安全的类或函数编写的代码,也不一定是线程安全的,示例代码如下所示

public class IdGenerator {
private int seq = 1;
public synchronized int incrementAndGet() {
seq++;
return seq;
}
// 判断 seq 是否为奇数
public synchronized boolean checkOdd() {
if (seq % 2 == 1) return true;
return false;
}
}
public class Demo {
private IdGenerator idGen = new IdGenerator();
public void test() {
// 获取偶数并使用
if (idGen.checkOdd()) {
int id = idGen.incrementAndGet();
System.out.println(id);
}
}
}

在上述代码中,尽管 IdGenertor 是线程安全的(使用了 synchronized 锁,关于 synchronized 锁,我们下一节讲解)
但使用线程安全的 IdGenerator 类编写的 test() 方法,却不是线程安全的
在单线程环境下,test() 函数的打印结果永远都是偶数,但是在多线程环境下,两个线程竞争执行 test() 函数,有可能会打印出奇数,不符合我们的预期

4、互斥和同步

以上我们讲解了几种线程不安全的代码,对于指令重排导致的线程不安全问题,我们可以使用 volatile 关键字来禁止指令重排序,不过这种线程不安全的情况并不常见
最常见的是非原子操作多线程交叉执行导致的线程不安全问题,对应的解决的方法是:通过对临界区加锁,让临界区变为原子操作,一个线程执行完临界区代码之后,才允许下一个线程执行

对临界区加锁,让临界区变为原子操作,目的是让多个线程互斥访问临界区,互斥是多线程要解决的两个核心问题之一,而另一个核心问题是同步
同步指的是多个线程之间如何协同执行,比如一个线程等待另一个线程执行完成之后再执行

实际上,专栏中的多线程模块很大部分都是在讲如何互斥和同步,专栏中的多线程模块主要包括 5 部分:基础理论、互斥锁、同步工具、并发容器、线程管理

  • 互斥锁部分主要讲解实现临界区互斥的手段,提供了各种不同粒度和作用的锁
    比如:偏向锁、轻量级锁、重量级锁、自旋锁、读写锁、原子类等,最大限度的减小加锁范围、提高代码并发执行程度
  • 同步工具主要包括:条件变量、信号量、Latch、Barrier 等,用来实现各种线程协作模式
  • 并发容器部分讲解了一些线程安全的容器(比如 ConcurrentHashMap)以及支持线程同步的容器(比如各种阻塞队列),方便程序员直接使用,不用自己从零开始实现

5、课后思考题

请举几个工作中遇到的线程不安全的代码例子,对照一下是否符合我们讲到的临界区的两个特点(访问共享资源和包含复合操作)
举例如下,满足临界区的两个特点

public class Demo {
private ArrayList<Integer> list = new ArrayList<>();
private int capacity;
private int size = 0;
public Demo(int capacity) {
this.capacity = capacity;
}
public void add(Integer data) {
if (size < capacity) {
list.add(data);
size++;
}
}
public Integer get() {
if (size > 0) {
size--;
return list.remove(size);
}
return null;
}
}
posted @   lidongdongdong~  阅读(65)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开