目录
1 线程安全
1.1 谈谈你对线程安全的理解
1.2 Java中操作共享数据分类
1.2.1 不可变(Immutable)
1.2.2 绝对线程安全(Thread-safe)
1.2.3 相对线程安全(Thread-compatible)
1.2.4 线程兼容(Thread-adaptive)
1.2.5 线程对立(Thread-hostile)
二、线程安全问题产生的原因及解决方式
2.1 原因
2.2 解决方案
三、临界区和竞态条件
3.1 临界区
3.1.1 临界区的定义
3.1.2 临界区和临界资源
3.2 竞态条件
3.2.1 竞态条件的定义
3.2.2 竞态条件的分类
3.3.3 竞态条件的解决方案
四、总结
1 线程安全
1.1 谈谈你对线程安全的理解
定义:线程安全是指某个函数在并发环境中被调用时,能够正确地处理多线程之间的共享变量,使程序功能正确完成。
ps:所谓的正确完成,其实就是要满足原子性、有序性和可见性。
简单来说,就是多个线程同时访问共享变量时,得到的结果和我们预期的结果一样,就是线程安全。
1.2 Java中操作共享数据分类
ps:此小节参考了《深入理解Java虚拟机(第三版)》一书,第13章第13.2.1小节(第467页)。
线程安全是以多线程之间存在共享数据访问为前提的,为了更好的理解线程安全,我们不应该把它简单的理解为是一个非真即假的二元操作,而是按照“安全程度”由强到弱进行分类。在Java语言中,可以将共享数据的操作分为5类,下面就详细介绍一下。
1.2.1 不可变(Immutable)
定义:指一旦被创建就无法修改其状态的对象。
不可变(Immutable)的对象一定是线程安全的,不需要再进行任何线程安全保障措施。
这里不得不提一下Java中的关键字——final,final可以保证内存的可见性,也就是只要一个不可变的对象被正确的构建出来,其外部的可见状态都不会改变,永远都不会看到它在多个线程之间处于不一致的状态。"不可变"带来的安全性是最直接,最纯粹的。
ps:编辑器为final写(初始赋值)操作之后插入写屏障,在读操作之前插入读屏障。这个由编译器插入的内存屏障,要求处理机在“某些位置”禁用cpu指令重排进行优化。还有volatile、synchronized等关键字也是如此。
我们最熟悉的不可变Java API可能就是String类了,它是一个典型的不可变对象,substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
另外还有很多类似的Java API也是不可变的对象:
- 枚举类型的对象
- java.lang.Number的部分子类:Integer、Long、Float、BigDecimal等
- Java 8中的日期和时间API:LocalDate、LocalTime和LocalDateTime等
1.2.2 绝对线程安全(Thread-safe)
定义:指一个类要满足“不管运行环境如何,调用者都不需要额外的同步措施”。
可以看到,绝对线程安全的定义是很严格的,因此在Java中,并没有绝对线程安全的类,这是因为线程安全是一个相对的概念,它取决于代码的具体实现和运行环境。
1.2.3 相对线程安全(Thread-compatible)
定义:指一个类的方法在单独调用时是线程安全的,但对于多线程环境,调用序列需要额外的同步控制来保证线程安全。
相对线程安全就是我们通常意义上所讲的线程安全,在Java中,大部分线程安全的类都属于这种类型,例如我们熟知的java.util.concurrent包下的大部分类都是相对线程安全的。
尽管Vector类的所有方法都用了synchronized关键字修饰,但在一些情况下还是需要额外的同步手段来保证正确性。请看下面代码:
public static void main(String[] args) {
Vector<Integer> vector=new Vector<>();
for (int i = 0; i < 1000; i++) {
System.out.println("循环第"+i+"次");
new Thread(()->{
if(vector.size()==0){
vector.add(1);
}else {
//添加:最后一个元素加1的值
vector.add(vector.lastElement()+1);
}
}).start();
}
try {
System.out.println("======休眠5秒======");
//休眠5秒,让主线程最后执行完毕
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("vector中最后一个元素的值为:"+vector.lastElement());
}
输出结果如下:
我们的预期值应该是1000,但最后的值是897。所以尽管Vector类的add()、lastElement()方法都是同步的,但是在多线程的环境中,如果对这段代码不做额外的同步措施,那么它也是线程不安全的。这段代码类似于i++操作,出现了“漏加”的情况。
如果要保证正确性,那么可以额外的增加同步措施,代码如下:
public static void main(String[] args) {
Vector<Integer> vector=new Vector<>();
for (int i = 0; i < 1000; i++) {
System.out.println("循环第"+i+"次");
new Thread(()->{
synchronized (vector){
if(vector.size()==0){
vector.add(1);
}else {
//添加:最后一个元素加1的值
vector.add(vector.lastElement()+1);
}
}
}).start();
}
try {
System.out.println("======休眠5秒======");
//休眠5秒,让主线程最后执行完毕
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("vector中最后一个元素的值为:"+vector.lastElement());
}
这里就不展示输出结果了,感兴趣的可以去实操一下😁。
1.2.4 线程兼容(Thread-adaptive)
定义:指对象本身并不是线程安全的,但可以通过额外的同步处理,以确保线程安全。
平常我们说一个类不是线程安全的,通常指的就是这种情况。Java API中大部分的类都是线程兼容的,比如ArrayList和HashMap。
1.2.5 线程对立(Thread-hostile)
定义:指不管调用端是否采取了同步措施,都无法在多线程中并发使用代码。
一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。
ps:正因为这个原因,这两个方法都被声明废弃了。
二、线程安全问题产生的原因及解决方式
读到这,相信你一定对线程安全有了一定了解,那么下面介绍一下导致线程安全问题原因及常见的解决方案。
2.1 原因
主要原因有以下5点:
- 竞态条件:当多个线程访问共享数据并且尝试同时更改数据时,由于执行顺序的不确定性,可能会导致数据的不一致问题。比如,两个线程同时读取共享数据并进行修改,结果可能是其中一个线程的修改被覆盖或丢失。
- 不正确的同步:多个线程在访问共享数据时,没有进行正确的同步操作(如锁、信号量等),没有对临界区代码进行保护,这样可能导致多个线程同时修改数据,产生不可预料的结果。
- 不可见性:多个线程对同一个共享资源进行读写操作时,由于操作的顺序或时机不一致,可能会导致数据的不一致性。例如,一个线程正在修改数据,而另一个线程同时读取了这个数据,就可能读到一个中间状态的数据。
- 非原子性:某些操作需要多个步骤才能完成,如果在这个过程中发生线程切换,可能会导致数据不一致。比如,一个线程对一个整数进行递增操作,需要先读取当前值,然后加一,最后写回,如果在这个过程中发生线程切换,可能导致多个线程同时读取、修改、写入相同的值,结果会出现错误。
- 数据依赖关系破坏:某些操作需要依赖于数据的特定状态,如果多个线程同时修改了这个状态,可能会导致操作的失败或数据的不一致。比如,一个线程在进行某个条件操作之前,需要数据满足一定的要求,但是其他线程可能会同时修改这个数据,导致条件无法满足。
2.2 解决方案
以下是几个常见的解决方案(思想):
- 单线程:既然多线程用问题,干脆就使用单线程执行,从根本上杜绝线程安全问题。比如Redis,就是这种思想,可以参考作者的这篇文章😁。
- 互斥锁:如果一定要用多线程,那么最常见的就是加锁。比如数据库的乐观锁、悲观锁,synchronized、ReentrantLock这种单机锁,还有Redis的分布式锁。
- 读写分离:除了加锁之外,还有一种做法,就是读写分离。比如CopyOnWriteArrayList当有新元素add的时候,先从原有的数组中copy一份出来,然后在新的数组做写操作,写完之后再将原来的数组引用指向新数组。
- 原子操作:原子操作是不可中断的操作,要么全部执行成功,要么全部失败。在多线程环境中,可以使用原子操作来实现对公共资源的安全访问。比如AtomicInteger。
- 不可变模式:上面也提到过,不可变的对象一定是线程安全的。
- 数据不共享:像前面说的,数据共享是线程安全的前提。如果没有数据共享,那么也就没线程安全问题了,那么就可以用ThreadLocal类来解决。(通过为每一个线程创建一份共享变量的副本,来保证各个线程之间的变量的访问和修改互不影响)
三、临界区和竞态条件
上面提到了两个关键词“临界区”和“竞态条件”,下面就简单介绍一下这两个关键词的含义。
3.1 临界区
3.1.1 临界区的定义
定义:指在多线程编程中,访问共享资源的那部分代码区域。
ps:如果多个线程同时访问临界区代码(共享资源),可能会导致数据不确定的结果,因此需要同步机制来限制对临界区的访问,以避免竞态条件和数据不一致问题。
3.1.2 临界区和临界资源
举个例子:假设有A、B两个线程,他们都往一个数组中的index位置存入一个数据并且执行index+1。 A线程存入“hello”,然后index++;B线程存入“world”,然后index++。
public class TestString {
private static int index =0;
public static void main(String[] args) throws InterruptedException {
String[] strings=new String[5];
//存入hello,A线程
new Thread(()->{
strings[index]="hello";
index++;
}).start();
//存入world,B线程
new Thread(()->{
strings[index]="world";
index++;
}).start();
//休眠两秒,让主线程最后执行
System.out.println("=====休眠2秒=====");
Thread.sleep(2000);
System.out.println(Arrays.toString(strings));
}
}
多次执行代码,发现得到的结果并不一致,有可能出现[hello,world,null,null,null],也有可能出现[world,null,null,null,null];以第二个结果为例,当线程A存入hello之后,CPU马上就被线程B所夺,B存入了world覆盖了A存的hello,之后才执行了各自的index++。
线程在运行过程中,会与同一进程内的其他线程共享资源,我们把同时只允许一个线程使用的资源成为临界资源,临界资源的访问可以分为以下四个部分:
- 进入区:为了进入临界区使用临界资源,在进入区要检查是否可以进入临界区;如果可以进入临界区,通常设置响应的“正在访问临界区”标志,以阻止其他线程同时进入临界区。
- 临界区:线程用于访问临界资源的代码。
- 退出区:临界区后用于将“正在访问临界区”标志清除部分
- 剩余区:上述3个部分以外的其他部分。
简单来说,临界资源是一种系统资源,需要不同的线程互斥访问,例如前文代码中的数组;临界区则是每个线程中访问临界资源的一段代码。是属于对象线程的,前面代码中存hello和存world以及index++就可以看成两个临界区。
3.2 竞态条件
3.2.1 竞态条件的定义
定义:指多线程环境中,多个线程对共享资源进行读写操作的顺序不确定,从而导致结果的准确性和一致性无法确定的一种情况。
参考上述A、B线程向数组中分别存hello和world的例子,没有用同步机制来保护临界区,每次的运行结果会出现不一致的情况,因为两个线程同时进行了读写操作,并且执行顺序也不确定,于是就发生了竞态条件。
3.2.2 竞态条件的分类
写-写:多个线程同时进行写操作,可能导致数据的丢失或混乱。
读-写:一个线程在读取共享资源的同时,其他线程进行写操作,可能导致读取到过期或无效的数据。
写-读:一个线程在写操作的同时,其他线程进行读取操作,可能导致读取到中间状态的数据。
3.3.3 竞态条件的解决方案
说到底还是线程安全问题,参考2.2节介绍的解决方案。
四、总结
本篇文章从对线程安全的理解开始,讲述了Java中操作共享数据的5大分类,并用concurrent包下的Vector类举例解释了相对线程安全;接着详细介绍了线程安全问题产生的5大原因及6种常见解决方案;最后通过一个String[]数组存值的代码示例,介绍了临界区、临界资源以及竞态条件。
End:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。