【多线程】线程安全问题

1. 线程安全

1.1 线程安全的概念

如果多线程环境下运行的结果是符合我们预期的,即和在单线程环境下得到的结果一样,则说明这个程序是线程安全的。而当前代码因为多线程随机调度的顺序出现 bug,那么这个程序的线程就是不安全的。

线程不安全案例: 两个线程对同一变量进行累加,结果小于等于串行的结果,并且最终结果不是固定的。

public class Demo9 {
    
    

    private static int count = 0;

    public static void increase(){
    
    
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
    
    

        Thread t1 = new Thread(() -> {
    
    
            for(int i=0; i<50000; i++){
    
    
                increase();

            }
        });

        Thread t2 = new Thread(() -> {
    
    
            for(int i=0; i<50000; i++){
    
    
                increase();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YCiOuVfL-1660831974401)(C:/Users/bbbbbge/Pictures/接单/1660552194882.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-01uU6HmV-1660831974402)(C:/Users/bbbbbge/Pictures/接单/1660571402079.png)]

上述的代码 bug 其实就是因为线程的随机调度导致的,创建的两个线程都在执行 count++ 操作,而 count++ 操作具体可以分为以下三步:

  1. 把内存中的 count 值 给读到 CPU 的寄存器中
  2. 把寄存器中的 count 值加1
  3. 把寄存器中的 count 值写回到内存中

如果两个线程能够一直串行的执行完各自的操作,那么最终的结果其实没问题,但如果不能,比如 t1 线程将 count 值读取到 CPU 的寄存器中后,然后 t2 线程又将 count 值读取到 CPU 的寄存器,并且执行加1操作写回到了内存中,那么此时 count 的值已经是1了,但是 t1 线程对 寄存器中的 count 值0进行加1,再写回到内存中,最终的值还是1。因此虽然这两个线程都执行了一次 count++ 操作,但 count 的值只加了1,所以会产出上述的结果。

1.2 线程不安全的原因

线程不安全的原因有很多,包括如下:

  • 线程的抢占式执行

    根本原因,但现在无法解决

  • 多个线程修改同一个共享数据

    可以调整代码结构,避免出现多个线程修改一个遍历的情况。

  • 线程针对变量的修改操作不是原子的

    介绍:

    我们可以把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证, A 进入房间后,还没有出来,B 由于也能进入房间,就能打断 A 在房间里的隐私。因此就不具备原子性。而一条 Java 语句不一定是原子的,比如 count++,它其实由三步操作组成。

    不保证原子性给多线程带来的问题:

    如果一个线程正在对一个变量进行操作,中途有其它线程插入进来,那么这个操作就会被打断,造成错误的结果。

    解决方式:

    使用 synchronizer 加锁。

  • 内存可见性

    介绍:

    可见性是指一个线程对共享变量值的修改,能够及时地被其它线程看到。

    问题:

    比如手动创建一个变量为线程 t 中死循环的标志位,在主线程中通过控制这个变量来结束线程,但标志位被设置后 t 线程本应该结束,但它并没有结束,是因为在这种场景下,一个线程度读,一个线程写,由于编译器优化导致了修改操作不能被读操作感知到,本质上是因为多线程引起编译器对于代码优化产生了误判。

    Java 存储模型:

    JMM(Java Memory Model):Java 存储模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。

    JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。

    JMM 的规定:

    • 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

    • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

    • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。

    • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

    • 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据。

    • 当线程要修改一个共享变量的时候,会先修改工作内存中的副本,再同步回主内存

    JMM 这样的规定可能会导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。

    解决方式:

    使用 synchronizer 加锁:禁止编译器相关优化

    使用 volatile 关键字:保存了内存可见性,禁止了编译器相关优化

  • Java 中的编译器优化(包括指令重排序)

    介绍:

    在单线程情况下,JVM、CPU 指令集合会对代码进行优化,如指令重排序,但逻辑不发生改变。该种操作在单线程情况下比较容易判断并完成,但是在多线程情况下,由于代码更加复杂,编译器很难在编译阶段对代码的执行效果进行预测,可能出现逻辑改变的问题。

2. synchronized 关键字

synchronized 是 Java 中的关键字,是一种同步锁。它表示这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程正在用这个方法(或者该类的其他同步方法),有的话要等正在使用 synchronized 方法的线程运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。它包括两种用法:synchronized 方法和 synchronized 块。

2.1 synchronized 的特性

  1. 互斥性(原子性)

    synchronized 会起到互斥效果,某个线程执行到某个对象的 synchronized 准备加锁时,并且当前的锁并未被占用,那么该线程就会进行加锁,在解锁之前如果其它线程也要执行该对象,就会阻塞等待。

    • 进入 synchronized 修饰的代码块,相当于加锁。
    • 退出 synchronized 修饰的代码块,相当于解锁。

    synchronized 用的锁是存在 Java 对象里面的,底层是使用操作系统的 mute lock 实现的。

  2. 刷新内存,保证内存可见性

    synchronized 的工作过程如下:

    1. 获得互斥锁
    2. 从主内存拷贝变量的最新副本到工作内存
    3. 执行代码
    4. 将更改后的共享变量的值刷新到主内存
    5. 释放互斥锁
  3. 有序性

    synchronized 和 volatile 都具有有序性,Java 允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized 保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

  4. 可重入

    synchronized 同步块对同一条线程来说是可重入的,因此不会出现自己把自己锁死的问题。

    比如在一个方法1中加锁,而方法1执行了方法2,并且方法二上也加了锁。当一个线程先调用方法1时,先加了一次锁,但期间又执行了方法2,又加了一次锁,但第一个锁还没有释放。由于 synchronized 是可重入锁,那么这份代码就是正确的,不会出现死锁操作。

    在可重入锁的内部,包含了“线程持有者”和“计数器”两个信息。

    • 线程持有者:如果某个线程加锁时发现锁已经被人占用,但是恰好是自己,那么任然可以继续获取到锁,并让计数器自增。
    • 计数器:解锁的时候计数器递减为0时,才真正的释放了锁。

注意:

  • 上一个线程解锁后,下一个线程并不是立即就能获取到锁,而是靠操作系统来唤醒,即线程的调度。
  • 假设有 A、B、C 三个线程,线程 A 获取到锁,然后 B 尝试获取锁,然后 C 尝试获取锁,此时线程 B 和 C 都会进入阻塞队列排队等待,当线程 A 释放锁后,虽然 B 比 C 先尝试获取锁,但是 B 不一定就比 C 先获取到锁。

2.2 synchronized 的用法

synchronized 的使用很简单,可以用它来修饰实例方法和静态方法,也可以用来修饰代码块。值的注意的是 synchronized 是一个对象锁,也就是它锁的是一个对象。因此,无论使用哪一种方法,synchronized 都需要有一个锁对象。

注意:不同线程针对同一个对象加锁,才会产生竞争;针对不同对象加锁,不会产生竞争。

  1. 修饰实例方法

    synchronized 修饰实例方法只需要在方法上加上 synchronized 关键字。此时,synchronized 加锁的对象就是这个方法所在的实例本身。

    public class SynchronizedDemo {
          
          
    
        public synchronized void method1(){
          
          
            
        }
    }
    
  2. 修饰静态方法

    synchronized 修饰静态方法的使用与实例方法并无差别,在静态方法上加上 synchronized 关键字就行。此时,synchronized 加锁的对象为当前静态方法所在的类的 Class 对象。

    public class SynchronizedDemo {
          
          
    
        public synchronized static void method2(){
          
          
            
        }
    }
    
  3. 修饰代码块

    synchronized 修饰代码块需要传入一个对象。此时,synchronized 加锁的对象即为传入的这个对象实例。

    public class SynchronizedDemo {
          
          
        
        public void method3(){
          
          
            synchronized (this){
          
          
                
            }
        }
    }
    

2.3 Java 标准库中的线程安全类

Java 标准库中很多线程是不安全的,以下类就可能涉及到多线程修改共享数据并且没有任何加锁措施(谨慎在多线程情况下使用,尤其是一个对象被多个线程修改的场景):

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些类的线程是安全的,可以使用一些锁机制来控制(这几个类的核心方法都带有了 synchronized):

  • Vector(不推荐使用)
  • HashTable(不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

还有即使没有加锁,但任然是线程安全的(String 是不可变对象,不能修改):

  • String

3. volatile 关键字

3.1 volatile 的特性

  1. 保证内存可见性

    通过 volatile 修饰变量,当修改该变量时,会将该线程工作内存中的变量副本的值进行修改,并且刷新到主内存中。当读取变量时,会读取到主内存中该变量最新的值到线程的工作内存中。

  2. 禁止指令重排序

3.2 volatile 小结

  • volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值;或者作为状态变量,如 flag = ture,实现轻量级同步。
  • volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
  • volatile 只能作用于属性,我们用 volatile 修饰属性,这样编译器就不会对这个属性做指令重排序。
  • volatile 提供了可见性,任何一个线程对其的修改将立马对其他线程可见。
  • volatile 可以使纯赋值操作是原子的,如 boolean flag = true; falg = false
  • volatile 可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。

4. wait 和 notify 方法

由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知。但为了能够协调多个线程之间的执行先后顺序,可以通以下几种方法解决:

  • wait()wait(long timeout):让当前线程进入等待状态。
  • notify()notifyAll():唤醒在当前对象上等待的线程。

以上几种方法都是 Object 类的方法。

如果此时有两个线程 t1 和 t2,要求 t1 线程先执行,那么可以先让 t2 执行 wait 方法进入阻塞队列中,然后 t1 执行完相关代码后再通过 notify 唤醒 t2 线程。

4.1 wait 方法

wait 方法是 Object 类里的方法,当一个线程执行到 wait 方法时,它就进入到一个和该对象相关的等待池中,同时释放了锁对象,等待期间可以调用里面的同步方法,其他线程可以访问,等待时不拥有 CPU 的执行权,否则其他线程无法获取执行权。

当一个线程执行了 wait 方法后,必须调用 notify 或者 notifyAll 方法才能唤醒,而且是随机唤醒,若是被其他线程抢到了 CPU 执行权,该线程会继续进入等待状态。由于锁对象可以是任意对象,所以 wait 方法必须定义在 Object 类中,因为 Obeject 类是所有类的基类。

wait 方法需要搭配 synchronized 一起使用,否则会报 IllegalMonitorStateException 异常。因为 wait 方法在执行时会进行以下几步操作:

  1. 释放当前的锁
  2. 进行等待
  3. 当通知到来之后,会被唤醒,并同时尝试重新获取到锁,然后继续执行。

wait 方法等待结束的条件:

  • 其它线程调用该对象的 notify 或 notifyAll 方法
  • wait 等待时间超时
  • 其它线程调用该等待线程的 interrupted 方法,导致 wait 抛出 InterruptedException 异常

4.2 notify 方法

  • notify 方法是唤醒等待的线程,需要搭配 synchronized 一起使用,否则会报 IllegalMonitorStateException 异常。
  • notify 方法要在同步方法或同步块中调用,该方法是用来通知那些可能要等待对象的对象锁的其它线程,随其发出通知,并是他们重新获取该对象的对象锁。
  • 如果有多个线程等待,则由线程调度器随机选出一个呈 wait 状态的线程。
  • 使用 notify 方法后,当前线程不会马上释放该对象锁,要等到执行 notify 方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

4.3 notifyAll 方法

notify 方法只是唤醒某一个等待线程,使用 notify 方法可以一次性唤醒所有的等待线程。不过虽然同时可以唤醒所有的线程,但唤醒后,这些线程需要竞争锁,并不会同时进行。

4.4 wait 和 sleep 的差异

  1. sleep 方法是 Thread 类的静态方法,wait 是 Object 类的方法。

  2. sleep 方法必须传入参数(参数为休眠时间),wait 方法可以传入参数,可以不传参数。

  3. sleep 方法必须要捕获异常,而 wait 方法不需要捕获异常。

    线程在 sleep 的过程中过程中有可能被其他对象调用它的 interrupt(),产生 InterruptedException 异常,如果你的程序不捕获这个异常,线程就会异常终止,进入 TERMINATED 状态;如果你的程序捕获了这个异常,那么程序就会继续执行 catch 语句块以及后面的代码。

  4. sleep 方法可以在任何地方使用,但是 sleep 是静态方法,也就是说它只对当前对象有效。而 wait 方法只能在同步方法或者同步代码块中使用。

  5. wait 方法要搭配 sunchronized 关键字一起使用。sleep 方法不需要。

    因为 wait 方法是使一个线程进入等待状态,并且释放其所持有的锁对象,notify 方法是通知等待该锁对象的线程重新获得锁对象,然而如果没有获得锁对象,wait 方法和 notify 方法都是没有意义的,因此必须先获得锁对象再对锁对象进行进一步操作于是才要把 wait 方法和 notify 方法写到同步方法和同步代码块中。

  6. wait 方法和 sleep 方法都是让线程暂停运行一段时间,但使用 wait 方法本质是进行了线程通信,使用 sleep 方法是控制了线程的运行状态。

    wait 和 notify/notifyAll 方法是由确定的对象即锁对象来调用的,锁对象就像一个传话的人,他对某个线程说停下来等待,然后对另一个线程说你可以执行了,这一过程是线程通信。sleep 方法是让某个线程暂停运行一段时间,其控制范围是由当前线程决定,运行的主动权是由当前线程来控制(拥有CPU的执行权)。

猜你喜欢

转载自blog.csdn.net/weixin_51367845/article/details/126414915