线程安全的粗略讲解


前言

在多线程编程中,线程安全是一个非常重要的概念。当多个线程同时访问共享的资源时,如果不进行合理的同步控制,就会导致数据的不一致性、并发错误和系统崩溃等问题。这种情况称为线程不安全。

线程安全是指多个线程访问共享资源时,不会出现不正确的结果。如果一个程序在单线程环境中执行是正确的,但在多线程环境中执行就不正确,那么这个程序就是线程不安全的。线程安全的代码不仅在单线程环境中正确,在多线程环境中也能正确执行。

线程不安全可能会出现的问题包括竞态条件、死锁、饥饿、数据不一致等。解决线程不安全问题的方法通常包括使用同步机制(如 synchronized、Lock)和使用线程安全的数据结构(如 ConcurrentHashMap、AtomicInteger 等)等。在进行多线程编程时,保证线程安全是必不可少的。


一 线程不安全的原因

我们前面说明了,多线程编程的一些基本概述,这个时候,我们再来说一下,线程不安全的原因,我列出来,以下几点.我们来一一解释一下.

1.抢占式执行
2.多个线程修改一个变量
3.修改操作不是原子的
4.内存可预见性
5.指令重排序

下面我们来具体解释一下,这里来介绍一下,这几个具体的概念什么意思.

1.1 抢占式执行

抢占式执行是指操作系统可以随时停止一个线程的执行,然后切换到另一个线程的执行。这是因为在多线程环境下,操作系统需要协调各个线程的执行,以便让多个线程共享 CPU 资源,从而提高系统的并发处理能力。但是,抢占式执行也会导致线程的执行顺序不确定,从而引发一些线程安全问题。

例如,有两个线程 A 和 B 在执行一个共享变量的操作,假设线程 A 执行了一半,然后被操作系统暂停了,此时线程 B 开始执行相同的操作,然后修改了共享变量的值。当线程 A 再次执行时,它使用的是已经过期的值,从而引发线程安全问题。
如果你还是懵懵懂懂,我们来具体看一个代码例子:

class Counter01 {
    private  int count =0;
    public void add(){
            count++;
    }
    public  int get(){
        return count;
    }
}
public class ThreadDemo14 {

    public static void main(String[] args) throws InterruptedException {
        Counter01 counter = new Counter01();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();

        // 等待两个线程执行结束, 然后看结果.
        t1.join();
        t2.join();

        System.out.println(counter.get());
    }
}

以上这段程序,就是典型的多线程问题,原本我们预期输出结果就是100000,但预期的结果,是大不相同的.
在这里插入图片描述
为什么会出现这样的情况呢.请看我下面的图片分析
在这里插入图片描述

由于当前这俩线程调度顺序是无序的
你也不知道这俩线程自增过程中,到底经历了啥
有多少次是"顺序执行",有多少次是"交错执行”不知道!!!得到的结果是啥也就是变化的了

1.2 多个线程修改一个变量

多个线程修改一个变量会引起线程安全问题。如果多个线程同时对同一个变量进行修改,就会出现竞态条件,从而导致数据出现错误。
我们在来一个代码例子,给大家说明,大家也别急,为什么会运行结果,和预期不一样,我们会在后面给出解决方案

public class SharedVariableDemo {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                count++;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Count: " + count);
    }
}

在上面的代码中,两个线程同时对 count 变量进行修改,导致 count 的最终值不确定。
结果的分析,其实跟我们第一种抢占式执行是一样的.

1.3 修改操作不是原子的

修改操作不是原子的是指一个操作不是一次性完成的,而是分成了多个步骤。如果多个线程同时对同一个变量进行修改,就会出现线程安全问题.
具体代码如下:

public class NonAtomicOperationDemo {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                count++;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Count: " + count);
    }
}

在上面的代码中,count++ 操作不是原子的,它实际上被分成了三个步骤:读取变量的值、对变量进行自增操作、将结果写回变量。

1.4 内存的可预见性

在多线程编程中,一个线程对共享变量的修改可能不会立刻被其他线程所见,这是因为线程之间共享的变量存储在主内存中,每个线程都有自己的工作内存,线程对变量的读写都是在工作内存中进行的,如果一个线程对共享变量进行了修改,这个修改不一定会马上被写入主内存中,其他线程也不能马上看到这个修改,这就是内存可见性问题。

例如,一个线程在自己的工作内存中修改了一个共享变量,而另一个线程在自己的工作内存中读取这个变量,如果这两个工作内存没有及时同步,那么后者读取的值就是过期的,这就可能导致错误。
具体的代码例子如下

public class ThreadDemo15 {
 public static int flag = 0;
    public static void main(String[] args) {
        Thread t1=new Thread(() ->{
            while (flag == 0){
                //什么也不写
            }
        });
        System.out.println("循环结束! t1 结束!");
        Thread t2 =new Thread(() ->{
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

1.5 指令的重排序

指令重排序是指处理器为了提高指令执行的效率,在不影响程序执行结果的前提下,可以重新排序指令的执行顺序。例如,处理器可以把一些无依赖关系的指令放到并行执行的流水线上,以提高程序的运行速度。
但是,在多线程环境下,指令重排序可能会导致程序的执行结果出现错误。例如,下面是一个简单的例子,其中一个线程对变量a进行写操作,另一个线程对变量b进行读操作:

具体的代码例子如下:

public class InstructionReorderingDemo {
    private static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            a = 1;
            flag = true;
        });

        Thread thread2 = new Thread(() -> {
            if (flag) {
                System.out.println("a = " + a);
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }
}

如果处理器对指令进行了重排序,那么就可能会输出 a = 0,因为 thread1 执行时可能会发生指令重排序,导致 flag = true 的操作在 a = 1 的操作之前执行,此时 thread2 中的 if (flag) 会判断为 true,进而输出 a 的值,此时 a 的值还是默认的 0。


二 解决线程不安全的措施

同步化(Synchronization):使用 synchronized 关键字或者 Lock 接口进行同步化,以保证同一时间只有一个线程可以访问共享资源,避免多个线程同时修改导致的冲突。

volatile 修饰的变量, 能够保证 “内存可见性”.代码在写入 volatile 修饰的变量的时候,
改变线程工作内存中volatile变量副本的值
将改变后的副本的值从工作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候,
从主内存中读取volatile变量的最新值到线程的工作内存中
从工作内存中读取volatile变量的副本

当然解决线程安全的措施,有很多种,我们这里只是列举出俩种来做一个介绍,根据上面的例子,我们来提供解决方案.


2.1 抢占式执行

因为是俩个线程对count的先后修改问题,所以我们直接给count加上一个Synchronization就可以了,具体的措施如下所示:

class Counter01 {
  private  int count =0;
    synchronized public void add(){
        synchronized (this){
            count++;
        }

    }
    public  int get(){
        return count;
    }
}
public class ThreadDemo14 {

    public static void main(String[] args) throws InterruptedException {
        Counter01 counter = new Counter01();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();

        // 等待两个线程执行结束, 然后看结果.
        t1.join();
        t2.join();

        System.out.println(counter.get());
    }
}


2.2 多个线程修改同一个变量

解决方案:可以使用锁或者原子变量等方式来保证同一时刻只有一个线程对变量进行修改,从而避免竞态条件。
代码如下:

public class SharedVariableDemo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object(); // 创建一个对象作为锁
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                synchronized (lock){
                    count++;
                }

            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                synchronized (lock){
                    count++;
                }
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Count: " + count);
    }
}


2.3 修改操作不是原子的

修改操作不是原子的是指一个操作不是一次性完成的,而是分成了多个步骤。如果多个线程同时对同一个变量进行修改,就会出现线程安全问题。
解决方案如下:
解决方案:
使用synchronized关键字同步多个线程对同一个变量的访问。

public class NonAtomicOperationDemo {
    private static int count = 0;
  Object lock = new Object(); // 创建一个对象作为锁
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                  synchronized (lock){
                    count++;
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                  synchronized (lock){
                    count++;
                }
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("Count: " + count);
    }
}

2.4 内存的可预见性

在多线程编程中,一个线程对共享变量的修改可能不会立刻被其他线程所见,这是因为线程之间共享的变量存储在主内存中,每个线程都有自己的工作内存,线程对变量的读写都是在工作内存中进行的,如果一个线程对共享变量进行了修改,这个修改不一定会马上被写入主内存中,其他线程也不能马上看到这个修改,这就是内存可见性问题。
解决方案:
为了解决内存可见性问题,可以使用volatile关键字来声明共享变量,它会告诉编译器和JVM,该变量是可见的,不会被本地化到线程的工作内存中,而是直接从主内存中读取和写入。

public class ThreadDemo15 {
 volatile public static int flag = 0;
    public static void main(String[] args) {
        Thread t1=new Thread(() ->{
            while (flag == 0){
                //什么也不写
            }
        });
        System.out.println("循环结束! t1 结束!");
        Thread t2 =new Thread(() ->{
            Scanner scanner=new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

在这个例子中,使用了volatile关键字来修饰flag变量,保证线程之间对该变量的修改是可见的。当线程2将flag变量设为true时,线程1能够及时读取到这个修改,退出循环,输出"Thread1 finished"。如果不使用volatile关键字修饰flag变量,则线程1可能永远无法退出循环,因为它无法及时看到flag变量的修改。


2.5 指令的重排序

解决这个问题的方法是使用 volatile 关键字修饰变量 flag,这样就能保证在写入 flag 变量之后,读取该变量时能够获得最新的值。即使处理器对指令进行了重排序,也会在保证内存可见性的前提下进行。修改后的代码如下:

public class InstructionReorderingDemo {
    private static int a = 0;
    private volatile static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            a = 1;
            flag = true;
        });

        Thread thread2 = new Thread(() -> {
            if (flag) {
                System.out.println("a = " + a);
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }
}

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

忘忧记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值