33、线程安全、临界区、竞态
线程安全、临界区、竞态是学习多线程的过程中,经常遇到的几个概念,因此本节我们就来详细讲解一下它们,为以后的学习做铺垫
那么线程安全中的安全两字如何理解呢?什么样的代码是线程安全的代码?什么样的代码线程不安全的代码?如何做到线程安全?
围绕着这几个问题,我们开始本节的学习
1、线程安全概念
在 Java 中,线程安全或不安全描述的对象既可以是类,也可以是函数
1.1、线程安全函数
对于函数来说,两个线程并发执行某个函数,因为线程切换,两个线程执行的指令之间可以任意交叉执行,如下所示
如果任意执行顺序最终得到的结果都是相同的、确定的、符合预期的,那么我们就称这个函数是线程安全的,否则我们就称为这个函数是线程不安全的,或者非线程安全的
1.2、线程安全类
对于类来说,除了要求类中的每个函数都是线程安全的之外,函数间也必须保证线程安全
两个线程并发执行一个类的任意两个不同的函数,两个线程执行的指令之间可以任意交叉执行
如果任意执行顺序最终得到的结果都是相同的、确定的、符合预期的,那么我们就称这个类是线程安全的,否则我们就称这个类是线程不安全的,或者非线程安全的
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() 函数也是线程不安全的
不仅如此,如果一个线程执行 add() 函数,另一个线程执行 substract() 函数,两个线程交叉并发执行,那么结果有可能不符合预期,如下图举例所示
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; } }
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17481643.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步