1.什么是线程安全问题

1.1线程安全的概念

如果多线程环境下代码运行的结果是符合我们的预期的,即在单线程环境应该的结果,则说明这个线程是安全的

一般的代码,在多线程中产生bug,就是线程安全问题

2.产生线程安全问题的原因

我们可以从一段代码中来解析产生线程安全问题的原因

public class demo2 {
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1=new Thread(()->{
            for (int i=0;i<1000;i++){
                count++;
            }
        });
        Thread thread2=new Thread(()->{
            for (int i=0;i<1000;i++){
                count++;
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
    
}

1)操作系统对线程的调度是随机的(根本原因)

在了解为什么会产生线程安全问题之前,我们要清楚的知道操作系统对线程的调度的特点:操作系统对线程的调度是随机的 ,就因为操作系统对线程的调度是随机的,所以操作系统要调用哪个线程是未知的,(抢占式执行)这是产生线程安全问题的根本原因

2)多个线程同时修改同一个变量(产生数据竞争)

上图可知,thread1和threads2同时对count变量进行修改,此时是⼀个多个线程都能访问到的 "共享数据"。

3)修改操作不是原子的

修改操作的指令不只有一条,例如上图在的count++,包含三条指令:

读取count在内存中的数据到寄存器中;让count的值在寄存器中++(此时修改后的值还在寄存器上);把count的值从寄存器中读取到内存上,完成修改操作

当多个线程在进行这样的修改操作的时候,就很容易在第一个线程还未操作完的时候强行调用第二个线程对这个变量进行修改操作。

此时两个线程执行完之后的count是1而不是2,与我们单线程操作后count=2不同

4)内存可见性(编译器优化)

编译器会对我们的代码进行优化,在不改变逻辑的条件下,提升代码执行的速率,但在多线程下,编译器的判断可能会出现错误,导致优化后的逻辑与优化前的逻辑不一样

当一个线程读取一个变量,另外一个线程修改同一个变量的时候,就可能会因为编译器优化而没有读取到修改后的值:编译器误认为该变量没有被修改,于是就将在内存上读取变量改成在寄存器中读取变量,导致没有读取到修改后的结果

java内存模型

每个线程都有自己的“工作内存”——cpu的寄存器,同时这些线程共享同一个“主内存”——内存。当一个线程循环进行读取操作的时候,就会把主内存的值拷贝到自己的工作内存中,后续另一个线程修改变量的值时,也是先修改自己工作内存中,变量的值,修改后再把值拷贝到主内存中,由于第一个线程仍在读取自己的工作内存,因此无法感知到主内存的值被修改——与编译器优化原理一致

5)指令重排序

3.如何解决线程不安全

1)join()

例如:我们在main主线程中调用t1.join(),意思是在main线程中等待t1线程执行完之后再执行main线程

我们可以利用这个操作,来把多线程的随机调度变成串行执行

利用这个等待thread1线程结束之后再执行thread2线程,就可以操控线程1和线程2的执行顺序,

但一定是等到thread1执行结束后在开始执行thread2,若是把thread1.join()放在thread2.start()之后,就没有意义了

2)synchronized

通过加锁,让不是原子的操作打包成原子。

语法:

synchronized(用来加锁的对象){//加锁

//被加锁的代码

}//解锁

两个线程针对同一个对象加锁才会产生互斥的效果,一个线程加上了锁,另一个线程就得阻塞等待,等到第一个线程释放锁,才会有机会进入。

如果是不同的锁对象,就不会产生互斥的效果,线程安全问题没有得到改变

java采用的synchronized确保只要出了}一定能释放锁,无论是因为return还是异常,因为里面调用了那些代码都可以确保unlock被执行到

synchronized还可以用来修饰方法,想当于对this进行加锁,如果是静态方法,,不存在this,此时synchronized修饰相当于针对类对象加锁

3)volatile

针对编译器优化问题导致的内存可读性,通过volatile修饰变量,可以告诉编译器这个变量是“易改的”,让编译器碰到要修改这个变量的操作的时候不进行优化

注意:volatile针对的是编译器优化问题,而不是修改操作不是原子性问题

4)wait和notify

等待/通知

协调线程之间的执行逻辑顺序,可以让后执行的代码的逻辑等待先执行的代码的逻辑先跑,虽然无法干预调度器的调度顺序,但是可以让后执行的逻辑(线程)等待到先执行的逻辑跑完,通知当前的线程让他继续执行

wait和join的区别:join也是等待,但是join是等一个线程走完之后才能继续走,wait是等另一个线程执行notify,才继续走(不需要等另一个线程走完)。

但锁的等待是不受控制的,不确定其他线程有没有加锁

当多个线程争夺同一把锁的时候,获得锁的线程如果释放了,不确定哪个线程得到锁

操作系统的线程调度是随机的,其他线程都处于阻塞状态,当前这个释放锁的线程是就绪状态,这个线程大概率会再次拿到这本锁。这样其他线程就没有机会,出现线程饿死。

上述场景适合使用wait/notify

当拿到锁的时候,发现要执行的任务时机还未成熟,就用wait等待,当时机成熟的时候,再用notify通知加锁。

wait和notify都是Object的方法,每个对象都能使用。

产生阻塞的方法都有抛出InterruptedException异常,意味着随时可能被interrupt方法唤醒

object.wait()

第一件事是:先释放Object对象对应的锁,而能够释放锁的前提是Object对象此时处于加锁状态,才能释放。

synchronized(object){//加锁
wait(object);//执行wait,解锁
//继续加锁
}//释放锁

注意:synchronized和wait要对同一个对象才有效。

由上图可知,没有锁就没有wait

wait也提供了“死等”和“超时版本”

可以在wait中设置等待时间,当超过该时间时,没有notify也会继续加锁。

wait和sleep的区别:

  1. wait使用notify提前唤醒,sleep可以使用interrupt提前唤醒,但sleep的本质是通知线程终止。
  2. wait必须搭配锁使用,先加锁才能用wait,但sleep不需要
  3. 如果都是在synchronized内部使用,wait会释放锁,sleep不会释放锁

object.notify();

同样需要先拿到锁,再进行notify(属于java中给出的限制)

wait操作必须搭配锁进行,notify原则上没有涉及到加锁解锁操作,但在java中,强制要求notify搭配锁使用。

synchronized(objecct){

object.notify();
}

wait notify synchronized 都是针对同一个对象使用,并且notify必须在wait的后面,如果notify在wait的前面,就无法唤醒wait。

如果有多个线程在同一时刻对同一个对象使用wait,进行notify操作会随机唤醒一个wait,wait的数量和notify的数量一致时,所有的wait都会被唤醒。

notifyAll可以唤醒所有的wait,但因为唤醒之后还要加上锁,其中一个线程上锁之后,另一个线程就会因为加锁失败而阻塞等待,所以更推荐一个notify对应一个wait。

4.死锁

当代码一旦触发死锁,线程就会卡住。

4.1产生死锁的三个场景

1)一个线程,一把锁,锁两次(可重入锁)

这是我们在写代码中很容易犯的错误,例如在调用函数时套了一层锁,在函数执行时又套了一层锁,就相当于给同一层代码加上了两层锁,这就会造成可重入锁。

第一次进行加锁的时候可以成功,但是第二次加锁的时候意味着锁对象已经被占用,再加锁就会发生阻塞->死锁。

为了解决这个问题,java引入了可重入的概念。但是如果是不同线程就不是可重入锁,要确保两次加锁是在同一个线程里面

如何自己实现一个可重入锁:

1.在锁的内部记录当前是在哪个线程里面持有的锁,后续每次加锁的时候都进行判定;

2.通过计数器记录当前加锁的次数,从而确定什么时候进行真的解锁

2)两个线程两把锁,每个线程获取一把锁之后,尝试获取对方的锁(不释放锁的前提在拿对方的锁)

在双方都加锁的情况下,如果线程1想拿线程2的锁,线程2同样想拿线程1的锁,就得双方先各自解锁,但双方又同时在加锁的状态,就会形成一个死锁

如上图代码就会发生死锁

加入sleep的原因是让t1一口气拿到两把锁,释放锁之后t2再拿到两把锁。这样就不会构成死锁。

3)n个线程,m把锁

A想拿到B的锁,B想拿到C的锁,C想拿到A的锁,构成死锁

4.2构成死锁的四个必要条件

1.锁是互斥的,一个线程拿到锁之后,另一个线程去尝试获取锁,必须阻塞等待(本质)

2.锁是不可抢占的(不可剥夺)

3.请求和等待

一个线程拿到锁之后,不释放锁的前提下,尝试去获取另外一把锁

4.循环等待

多个线程多把锁之间的等待过程构成了死锁

4.3如何避免死锁

构成死锁的四个必要条件只要少了一个条件就无法构成死锁。

针对3):代码中加锁的时候不要“嵌套”,而是使用并列锁

针对4):约定好加锁顺序

例如约定好每个线程加锁的过程优先获取小的锁,再获取大的锁

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐