什么是线程安全问题
线程安全问题指的是在多线程编程环境中,由于多个线程共享数据或资源,并且这些线程对共享数据或资源的访问和操作没有正确地同步,导致数据的不一致、脏读、不可重复读、幻读等问题。线程安全问题的出现,通常是因为线程之间的并发执行导致了数据竞争(Race Condition)或者时序问题(Timing Issues)。以上是网上找到的回答,我认为只要是在多线程代码实现产生bug都可以称为线程安全问题.
线程安全问题举例
public class ThreadDemo {
public static int count;
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t1=new Thread(()->{
for (int i = 0; i < 5000; i++) {
count++;
}
});
t.start();
t1.start();
t.join();
t1.join();
System.out.println(count);
}
}
这串代码预期结果是10000,但是无论执行多少次都达不到预期的效果
这里主要原因涉及到指令的执行顺序
因为很多操作在cpu上都会又细分为多个操作,例如count++分为 load,add,save,多线程不能穿插执行,必须要等第一个线程操作完数据保存到内存后,第二个再执行,不正常的执行顺序,可能会将其中一个线程操作的数据覆盖等影响,大概率结果会有错误
线程安全的五大原因
1.系统调度是随机的:
线程在系统中随机调度,是抢占式执行的,这种情况我无法修改和干预,当多个线程访问并修改同一内存位置的数据时,由于线程的随机调度,可能导致数据的不一致性
2.原子性问题:
某些操作(如自增、自减等)在单线程环境下是原子的,但在多线程环境下可能不是原子的。这些操作可能被拆分成多个步骤,(希望的是每个cpu指令都是原子的,要么不执行,执行就执行完为止)从而导致数据的不一致性,上面的代码线程不安全主要是原子性问题
3.内存可见性问题:
由于Java内存模型的原因,一个线程对共享变量的修改可能无法立即被其他线程看到。这可能导致线程读取到旧的数据值,从而引发问题
4指令重排序:编译器和处理器为了提高性能,可能会对指令进行重排序。但在多线程环境下,这种重排序可能会破坏代码的语义,导致线程安全问题。
线程安全问题的解决方法
加锁synchronized
public class ThreadDemo1 {
public static int count;
public static void main(String[] args) throws InterruptedException {
String s="锁无所谓是什么变量";
Thread t=new Thread(()->{
for (int i = 0; i < 5000; i++) {
synchronized (s){
count++;
}
}
});
Thread t1=new Thread(()->{
for (int i = 0; i < 5000; i++) {
synchronized (s){
count++;
}
}
});
t.start();
t1.start();
t.join();
t1.join();
System.out.println(count);
}
}
这样锁就把count打包成一个操作了,是原子性,当执行count的时候就不会出现执行一半的情况
加锁后需要注意几点
1.此时count++操作是串行执行,其余操作例如for循环都是并行执行
2.如果两个线程同时尝试加锁,此时只有一个线程可以获取锁成功,而另一个线程就会阻塞 等待。阻塞到另一个线程释放锁之后,当前线程才能获取锁成功。
3.当t1释放锁之后,t1线程还是会同时争夺这把锁
可重入锁
可重入锁指的是,本身加锁的线程能够加第二次锁,直接通过不会阻塞(写错了也能执行),下面的例子就是给一个程序加了两次锁,依旧可以执行成功
public class ThreadDemo1 {
public static void main(String[] args) {
Object locker =new Object();
Thread t1=new Thread(()->{
synchronized (locker){
synchronized (locker){
System.out.println("hi");
}
}
});
t1.start();
}
}
死锁
死锁(Deadlock)是一个或多个线程因为竞争资源而造成的一种状态,在这种状态下,线程们会无限期地等待一个永远不会发生的条件,从而导致程序无法继续执行。
死锁的经典的三种场景
1.一个线程一把锁,如果是不可重入锁,在加上一把锁,就会出现死锁
2.两个线程,两把锁,第一个线程有A锁,第二个线程有B锁,第一个线程又尝试获取B锁,第二线程尝试获取A锁,就会出现死锁
public class ThreadDemo2 {
public static void main(String[] args) {
Object A=new Object();
Object B=new Object();
Thread t1=new Thread(()->{
synchronized(A){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B){
System.out.println("t1两把锁");
}
}
});
Thread t2=new Thread(()->{
synchronized (A){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B){
System.out.println("t2两把锁");
}
}
});
t1.start();
t2.start();
}
}
3.哲学家吃面问题/N个线程M把锁
假设有五位哲学家围坐在一张圆形餐桌旁,每人面前有一碗面,每两个哲学家之间有一只筷子。因为用一只筷子很难吃到面,所以哲学家必须用两只才能吃到面,而他们只能使用自己左右手边的那两只。哲学家们有两种状态:进餐或思考。每当一个哲学家饥饿时,他会试图去拿他左右两边的筷子,但每次只能拿一只。只有当他拿到两只筷子时,才能开始进餐,吃完后需要放下餐叉继续思考。
在这个问题中,筷子是共享资源,而哲学家们对筷子的竞争可能导致死锁的发生。例如,如果每个哲学家都拿起左手边的筷子,等待右手边的餐叉变得可用,而右手边的筷子又被其他哲学家持有,那么就会形成死锁状态,因为每个哲学家都在等待其他哲学家释放资源,而这永远不会发生。
怎么避免死锁
- 设置加锁顺序(最好的方法):线程按照一定的顺序加锁,确保每个线程在请求多个锁时都按照相同的顺序进行。这样可以防止循环等待的情况,从而降低死锁的风险。设置加锁顺序,使得每位哲学家在尝试拿起筷子时都遵循相同的顺序。例如,可以规定所有哲学家都先尝试拿起自己左侧的筷子,然后再拿起右侧的筷子。这样,在任何时候,最多只有一个哲学家能够拿起他左右两侧的筷子,从而避免了死锁的情况
- 避免使用多个锁:尽量将代码设计成只使用一个锁的情况,减少因为多个锁之间的依赖关系导致的死锁4。
- 设置加锁时限(超时重试):在获取锁的时候尝试加一个获取锁的时限,超过时限则放弃操作并释放之前获取到的锁,然后等待一段时间后进行重试。这种方法允许在没有获取锁的时候继续执行其他任务,减少死锁的可能性。
volatile
当一个程序读,一个程序写的时候,此时就容易出现内存可见性问题,volatile就是解决这个问题的,其中一个核心功能就是保证内存可见性
下面的例子创建了两个线程,一个线程不断判断变量的值死循环,另一个等待输入值,使死循环停止,当我们改变flag之后,t1线程并没有结束,这里解释一下原因是编译器发现每次循环都要读取内存,开销太大,于是就把读取内存操作优化为读取寄存器操作,提高效率,就导致写线程做出的修改,读线程感知不到
import java.util.Scanner;
class Counter {
public int flag;
}
public class ThreadDemo {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
while(counter.flag==0){
//此处为了代码简洁好演示,什么都不做
}
});
Scanner sc = new Scanner(System.in);
Thread t2 = new Thread(()->{
counter.flag = sc.nextInt();
});
t1.start();
t2.start();
}
}
当我们在while循环中加上sleep后发现代码可以执行成功了,因为之前一直循环一秒钟可能循环上百亿次,在循环中加上sleep一秒钟也就执行上百次,大大减少开销
这里最主要的解决方法是加上volatile,告诉编译器这里不需要优化
public volatile int flag=0;//保证内存可见性,禁止指令重排序