关于线程安全问题

简介: 关于线程安全问题

什么是线程安全


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


线程安全问题及解决


线程不安全原因:

  1. 抢占式执行 (罪魁祸首)
  2. 多个线程修改同一个变量
  3. 修改操作不是原子的 (单个cpu指令)
  4. 内存可见性引起的线程不安全
  5. 指令重排序引起的线程不安全


第一种情况 (针对1,2,3点)


举个例子:

count初始值为0, 我们要对count加加2W次, 规定要用两个线程进行加加, 每个线程加加1W次.

写出如下代码:

class Counters{
    private int count = 0;
    public void add() {
        count++;
    }
    public int get() {
        return count;
    }
}
public class Test2 {
    public static void main(String[] args) throws InterruptedException {
        Counters counters = new Counters();
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                counters.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                counters.add();
            }
        });
        t1.start();
        t2.start();
        //等待两个线程结束
        t1.join();
        t2.join();
        System.out.println(counters.get());
    }
}


按理说结果应该是结果应该是20000的, 可是我们发现运行结果却次次不同:



为什么会出现这种情况呢?

其实 count++ 这一个操作是由三个CPU指令构成 (体现第3点: 不是原子的):

  1. load, 把内存中的数据读取到CPU寄存器中.
  2. add, 把寄存器中的值进行 +1 运算.
  3. asve, 把寄存器中的值写回内存中.


由于多线程调度是随机的, 它们是抢占式执行的, 因此这两个线程的++的实际指令有多种排列方式.

像下面这种线程就是安全的 :

但它的排列方式有很多种, 上面两种是极小概率出现的情况, 大部分情况是3个指令分开来排列的, 比如 :

就拿左边的情况来举例吧.

线程 1 先执行 load 指令,将 0 读取到寄存器中, 然后线程 2 又读取 0, 并++,此时 count 变为 1, 再写入内存中, 此时内存中的 count 就是1了, 然后线程 1 接着执行++, 这里是拿之前读取到寄存器中的 0 进行++ , 得到 1 后再写入内存中, 此时我们发现执行了两次++, 但 count 的值仍为 1 , 这就与我们的预期不符了, 这便是线程不安全, 出现了bug.

(这里体现第2点 : 多线程修改同一个变量)


解决 (synchronized关键字)


我们发现如果让线程指令一直是以下面这种方式运行, 那肯定没问题.

这里就引入了一个关键字 synchronized, 这就是相当于是给代码加锁, 先来看看它的使用 :

class Counters{
    private int count = 0;
    public void add() {
        synchronized (this) { //这里就是给this加锁, 当执行完count++后自动就解锁了
            count++;     // 如果两个线程针对同一个对象加锁,就会出现"锁竞争"
        }   //如果两个线程针对不同对象加锁就不会出现"锁竞争"
    }    
    public int get() {
        return count;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counters counters = new Counters();
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                counters.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 10000; i++) {
                counters.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counters.get());
    }
}

注意 : synchronized 后面括号里是锁对象, 锁对象可以是任意一个 Object 对象, 内置类型(基本数据类型)不行.

上面括号里的 this 就相当于 counters.


举个例子可能好理解点 :

把执行count++这个操作理解为上厕所, 比如张三去上厕所, 上厕所时把门给锁了, 这就相当于给count++加锁, 这时别人就看不到里面, 也不知道里面啥情况, 只有等张三上完厕所把锁给解了, 其他人才能去上厕所, 但其他人都很急就出现"锁竞争"情况, 他们都很急, 没有排队, 所以下一个上厕所的人是随机的.上面的锁对象就是厕所的蹲位.


我们看看输出结果:


符合预期.

其实还有几种加锁写法:

class Counters{
    private int count = 0;
    Object object = new Object();  //创建一个对象放入括号里
    public void add() {
        synchronized (object) {  //因为线程之间资源是共用的, 所以不会产生两个object对象
            count++;
        }
    }
    public int get() {
        return count;
    }
}

还有一种运用反射来获取对象的方法(一般不用):

class Counters{
    private static int count = 0;
    public void add() {
        synchronized (Counters.class) {
            count++;
        }
    }
    public int get() {
        return count;
    }
}

还可以给方法加锁 :

class Counters{
    private int count = 0;
    synchronized public void add() {  //直接在方法前加synchronized修饰
        count++;        //此时就相当于以this为锁对象
    }
    public int get() {
        return count;
    }
}

如果 synchronized 修饰的是静态方法, 那就不是给 this 加锁了, 而是给类对象加锁.

class Counters{
    private static int count = 0;
    synchronized public static void add() {
        count++;
    }
    public int get() {
        return count;
    }
}


第二种情况 (针对4,5点)


再来举个例子:

import java.util.Scanner;
public class ThreadDemo4 {
    public static int flag;  
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(flag == 0) {
            }
            System.out.println("t1循环结束, t1结束!");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag的值 :");
            flag = scanner.nextInt();  
        });
        t1.start();
        t2.start();
    }
}

我们想通过线程 t2 来控制 t1 的循环, 当我们输入 flag 为非零值时, 循环应该是会终止的, 我们来看看效果 :

可以看到循环好像并没有停下来, flag 的值明明已经不是零了, 为什么还是在循环呢?

其实 while 循环里的 flag == 0 这个操作可以分为两个指令:

  1. load, 从内存读取数据到 cpu 寄存器
  2. cmp, 比较寄存器里的值是否为 0


注意 : 此时这里 load 的时间开销要远大于 cmp.

(读内存比读硬盘快几千倍, 读寄存器又比读内存快几千倍)

一秒就可能执行上亿次这两个指令.

这时, 编译器就发现两点:

  1. load 的开销很大
  2. 每次 load 的结果都是一样的


这时编译器就做出了一个大胆的操作, 把 load 给优化掉了(去掉了), 只有第一次执行 load 才真正执行了, 后续循环都是 cmp ,就是拿第一次 load 到的值去比较.


编译器优化是一件很普遍的事情, 编译器优化能够智能的调整代码的逻辑, 保证结果不变的前提下, 通过一些操作来让程序执行效率大大提升.

编译器对于单线程的代码优化结果是非常准确的, 但多线程下就不一定了, 可能调整之后代码效率是提高了, 但代码结果变了, 出现了bug.


解决 (volatile 关键字)


Java引入volatile 关键字, 来让编译器暂停优化.

被 volatile 修饰的变量, 编译器会禁止对其进行优化, 从而保证每次都是从内存中重新读取数据到寄存器.

import java.util.Scanner;
public class ThreadDemo4 {
    public volatile static int flag;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while(flag == 0) {
            }
            System.out.println("t1循环结束, t1结束!");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag的值 :");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

8bc9f6c16b084cd8b18baf195280b2ce.png


注意 :


volatile 不保证原子性

volatile 保证了内存的可见性(就是每次都从内存中读取被修饰的变量)

volatile 适用场景是一个线程读, 一个线程写的情况.

上面的 synchronized 则适用于多线程写

volatile 还有一个特性: 禁止指令重排序(指令重排序也是编译器的优化策略)


相关文章
|
6天前
|
存储 安全 Java
我们来聊聊线程安全吧
我们来聊聊线程安全吧
|
9月前
|
缓存 安全 Java
认识线程安全和解决线程安全
认识线程安全和解决线程安全
|
8月前
|
安全 Java 编译器
深入理解线程安全
在多线程编程中,线程安全是一个至关重要的概念。线程安全可能到导致数据不一致,应用程序崩溃和其他不可预测的后果。本文将深入探讨线程安全问题的根本原因,并通过Java代码示例演示如何解决这些问题。
92 0
|
8月前
|
安全
KafkaProducer线程安全吗?
KafkaProducer线程安全吗?
|
9月前
|
存储 安全
什么时候考虑线程安全?
什么时候考虑线程安全?
68 0
|
10月前
|
安全 Java 调度
什么是线程安全的?
首先要明白线程的工作原理,jvm有一个main memory,而每个线程有自己的工作内存,一个线程对一个variable进行操作时,都要在自己的工作内存里面建立一个copy,操作完之后再写入主内存。多个线程同时操作同一个variable,就可能会出现不可预知的结果。
58 0
|
安全 Java 编译器
多线程(四):线程安全
多线程(四):线程安全
143 0
多线程(四):线程安全
|
存储 开发框架 算法
线程安全
线程安全
69 0
|
存储 安全 程序员
你管这叫"线程安全"?
今日份的干粮: 1.什么叫线程安全? 2.线程安全与变量的关系? •变量又与堆/栈/静态存储区有密切关系
你管这叫"线程安全"?