丰富“武器库”之线程安全

一、什么是线程安全

1.概念

  • 线程不安全:编写多线程代码的时候,如果因为系统调度线程的随机性,而引起的BUG,则称线程不安全
  • 线程安全:编写多线程代码的时候,不会因为系统调度线程的随机性而引起BUG,则称线程安全

2.一个经典的案例:

class Test {
    public int count = 0;
    public void Increase(){
        count++;
    }
}
public class TestFour {
    private static Test test = new Test();

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

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

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

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

        System.out.println(test.count);
    }
}

我们本想用上述代码,通过多线程的方法实现count累加,期望的得到的结果是100000,怎料,多次实验的结果均在50000~100000之间的随机数,这就的因为线程不安全而引起的bug

count++的执行流程:

  1. load 把内存中的值,读到CPU的寄存器中
  2. add 把寄存器中的count值 + 1
  3. save 将count值放回到内存中

1.但是由于操作系统调度线程的随机性,就会出现不同的结果

(1). 我们所期待的线程调度顺序是这样的: 如果一直保持串行,得到的结果就是100000,极小概率事件
在这里插入图片描述

一次期待的 t1、t2线程 执行的count++,得到的结果是 2
在这里插入图片描述

(2). 但是由于系统随机调度线程,会出现这样的: 随机组合,杂乱无章,哪种情况执行了多少次也不知道(随机的) t1、t2线程
执行一次count++,得到的结果是 1 如果一次串行的都没有的话,得到的结果 是50000,极小概率事件
在这里插入图片描述

2.加锁操作解决这个线程不安全问题

class Test {
    public int count = 0;
    synchronized public void Increase(){
        count++;
    }

   public void Increase(){
        synchronized(this){
           count++;
      }
   }
}
public class TestFour {
    private static Test test = new Test();

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

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

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

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

        System.out.println(test.count);
    }
}

二、线程不安全的原因

  1. 线程的抢占式执行(根本原因)
  2. 多个线程在修改共享的数据
  3. 线程针对变量的修改操作不是原子的
  4. 内存可见性问题
  5. 指令重排序
  • 针对第一个原因,这是操作系统设置的,我们无能为力
  • 针对第二个原因,如果一个线程修改变量(正如我们学线程前面,写的所有的代码,都是在main()这一个线程中执行的),或者是多个线程读共享的变量,或者是多个线程各自修改不同的变量,都不会出现线程不安全的问题

3.原子性

  • 原子就是不可拆分的最小单位

  • 正如上面的count++操作,是由load,add,save三步操作组成的,所以count++操作就不具备原子性的(不是原子的)

  • 一条java语句不一定是原子的,也不一定只是一条指令

  • 加锁操作本质上就是保证某段代码的原子性,把不是原子的操作打包在一起
    实现的效果也叫作 同步互斥,一个线程执行这段代码,另一个线程也想调用就得等待

以上面的count++为例,加锁后,load、add、save操作在一起就是原子的了,这个三个操作不可拆分,要执行一起执行,要不执行就都不执行

4.内存可见性

  1. 什么是内存可见性问题

对于一个多线程共享的变量,一个线程读这个变量,另一个一个线程修改这个变量,由于编译器的优化,导致修改操作不能被读的操作感知到,这就是内存可见性问题,本质上是多线程引起了编译器对于代码的优化产生了误判

  1. 什么是编译器优化

编译器在编译代码的时候并不是逐字逐句地翻译代码,而是在保证代码原有逻辑不变的前提下,动态调整要执行的指令的内容,从而提高程序的运行效

但是要注意的是,编译器没法把多个线程中的代码联系到一起分析,把一个线程当成独立的个体看;因此,在单线程的场景中,编译器优化是准确的;在多线程的场景中,编译器优化会出现误差的

  1. 内存可见性问题出现在如下场景:
public class Test10 {
    // 设置一个多线程共享的变量isQuit
    private static int isQuit;

    public static void main(String[] args) {
        Thread t = new Thread(() -> {
         // t线程读这个变量isQuit,如果isQuit改为 1 的话,线程结束
            while(isQuit == 0){
            
            }
            System.out.println("t线程结束");
        });
        t.start();

        // 在main线程中修改这个变量
        System.out.println("请输入isQuit的值");
        Scanner scanner = new Scanner(System.in);
        int isQuit = scanner.nextInt();
        System.out.println("main()线程结束");
    }
}

在这里插入图片描述
运行结果可知,t 线程一直在while()循环中执行;
原因分析:

  1. t 线程中的isQuit==0如何执行?
    load 将内存中isQiut的值读到CPU寄存器中
    compare 在CPU寄存器中,将isQuit的值和0比较
  2. main 线程修改 isQiut 的值,放到内存中
  3. 编译器直观的认为,t这个线程中,反复的进行了太多次的load,并且isQuit的值不会改变(编译器没法把多个线程联系在一起),认为这样太耗费时间了(从寄存器中读数据比从内存中读数据快了3~4个数量级),作了个大胆的优化,只执行一次load,不再重新读内存了,而是直接读寄存器
    4.main线程修改了isQuit变量,但是 t 线程获取不到修改结果
  1. 如何解决内存可见性问题
  1. 使用synchronized加锁操作,禁止编译器在相关代码的内部进行上述的优化
public class Test10 {
    // 设置一个多线程共享的变量isQuit
    private static volatile int isQuit;
    private static Object object = new Object();

    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            // t线程读这个变量isQuit,如果isQuit改为 1 的话,线程结束
            synchronized (object) {
                while (isQuit == 0) {

                }
            }
            System.out.println("t线程结束");
        });
        t.start();

        // 在main线程中修改这个变量
        System.out.println("请输入isQuit的值");
        Scanner scanner = new Scanner(System.in);
        isQuit = scanner.nextInt();
        System.out.println("main()线程结束");
    }
}

  1. 使用volatile关键字 修饰变量,再次读这个变量时,禁止了编译器对 “读内存”指令的优化,而是从内存中重新读取这个变量,保证了其内存可见性
public class Test10 {
    // 设置volatile关键字,设置volatile关键字,设置volatile关键字
    private static volatile int isQuit;

    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            // t线程读这个变量isQuit,如果isQuit改为 1 的话,线程结束
            synchronized (Thread.class) {
                while (isQuit == 0) {

                }
            }
            System.out.println("t线程结束");
        });
        t.start();

        // 在main线程中修改这个变量
        System.out.println("请输入isQuit的值");
        Scanner scanner = new Scanner(System.in);
        isQuit = scanner.nextInt();
        System.out.println("main()线程结束");
    }
}

在这里插入图片描述

5.指令重排序

保证代码原有逻辑不变的情况下,重新调整了指令的执行顺序,这个和编译器优化存在关联
参考下一篇文章的单例模式,理解指令重排序

指令重排序这个问题很玄学,只是说有可能发生;编译器根据具体的情况,来看是不是需要指令重排序

三、synchronized的使用

synchronized是一个互斥锁,即一个线程使用它,另一个线程也想使用就得等待

1.synchronized的作用:

  1. 保证某些操作的原子性(线程的“有序性”)
  2. 保证内存可见性
  3. 不可以 禁止指令重排序

2.锁对象
也叫作加锁的对象,把锁对象看做是一个房间,synchronized操作看作是给这个房间加锁,把线程看做是要进入房间的人,synchronized修饰的代码或方法看作是人要进行的操作

  1. 两个线程针对同一个对象加锁(同一个锁对象),会产生竞争,会产生阻塞状态(等待锁的状态(BLOCKED))
  2. 两个线程针对不同的对象加锁(不同的锁对象),不会产生竞争
  3. Java中任何继承自Object类,都可以作为锁对象,类对象也可以的
  4. 加锁操作本质上是 操作Object对象头的一个标志位

3.使用方法:

  1. 加到普通方法上
    进入方法相当于加锁,出了方法解锁
    此处的锁对象相当于this,this就是当前的类
  2. 加到代码片段上,需要指定一个锁对象(可以是this)
  3. 加到静态方法上
    此时的锁对象相当于类对象
  • 针对同一个对象加锁
public class Test11 {
    private static Object locker = new Object();
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        Thread t1 = new Thread(() -> {
            while(true) {
                synchronized (locker) {
                    System.out.println("t1线程开始,输入数字");
                    int a = scanner.nextInt();
                    System.out.println("t1线程结束");
                }
            }
        });
        t1.start();

        // 保证 t1 线程先运行
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t2 = new Thread(() -> {
           synchronized (locker){
               System.out.println("t2线程开始");
           }
        });
        t2.start();
    }
}

这里是引用
此处没有打印 t2 线程的日志
此时 t1 线程占用了locker这个锁对象,t2 线程尝试获取locker时,发现locker被 t1 线程占用,就处在阻塞状态(等待锁的状态)
在这里插入图片描述

  • 针对两个对象加锁
public class Test11 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        Thread t1 = new Thread(() -> {
            while(true) {
                synchronized (locker1) {
                    System.out.println("t1线程开始,输入数字");
                    int a = scanner.nextInt();
                    System.out.println("t1线程结束");
                }
            }
        });
        t1.start();

        // 保证 t1 线程先运行
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t2 = new Thread(() -> {
           synchronized (locker2){
               System.out.println("t2线程开始");
           }
        });
        t2.start();
    }
}

这里是引用
两个线程不会竞争锁对象
在这里插入图片描述

4.可重入锁

可重入性是synchronized的一个重要的特性
如果synchronized不是可重入的,那么很容易出现“死锁”的情况

1.了解“死锁”
如果连续针对同一个对象加锁两次,并且如果锁是不可重入的话,就会出现“死锁”

class Counter{
    public static int count;
    
    synchronized public void add(){
        synchronized (this){
            count++;
        }
    }
} 

如果synchronized是不可重入的,那么这就是一个“死锁”
某个线程调用add()时,第一次给Counter加锁,向下执行,第二次加锁操作时,Counter被占用了,那么线程就处于阻塞等待状态,等待Counter被释放;但是只有这个方法执行完才可以对Counter解锁,僵住了;所以这个线程一直处于一个阻塞等待的状态

2.了解可重入锁

但是,synchronized是可重入锁,不会出现“死锁”状态

可重入锁中持有两个信息:

  1. 当前这个锁对象被哪个线程持有了
  2. 当前这个锁对象被加锁了几次

当线程t 加锁成功后,后续再尝试加锁,检测到锁对象已经被 t 持有了,不会阻塞等待,只是修改了一个计数(+1),并不是真正的“加锁”
依次解锁,计数-1,计数为0,才是真的解锁了

四、volatile的使用

volatile的作用

  1. 保证内存可见性
  2. 禁止指令重排序

五、Java标准库中的类

线程不安全
ArrayList
LinkedList
HashMap
HashSet
TreeMap
TreeSet
StringBuilder
线程安全
StringBuffer核心方法都是synchronized修饰
HashTable没见过
ConcurrentHashMap没见过
Vector没见过

六、JMM(Java内存模型)

Java内存模型
在这里插入图片描述

七、wait() 和 notify()

使用wait()和notify()可以在一定程度上控制线程执行的顺序

1.wait()

wait() 是 Object类 的方法,在某个线程中调用了wait()方法,这个线程就处于阻塞等待状态

1. wait()方法中所做的事情:

  1. 对当前的对象解锁
  2. 让当前线程进入等待状态
  3. 满足一定的条件,线程被唤醒重新尝试获取到这个锁,继续向下执行
public class Test13 {
    private static Object o = new Object();
    public static void main(String[] args) throws InterruptedException {
        System.out.println("yyds");
        
        synchronized (o){
            o.wait();
        }
        System.out.println("qust");
    }
}

2. wait 结束等待的条件

  1. 其他线程调用 该对象的 notify() 方法
  2. 设置等待时间
  3. 其他线程调用该线程的interrupt方法,导致wait抛出InterruptedException异常

3. wait()的使用
wait() 一定要搭配synchronized使用
使用锁对象来调用wait(),如果调用wait()和锁对象不一样,抛出InterruptedException异常

public class Test13 {
    private static Object o = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println("yyds");
            synchronized (o){
                try {
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();

                }
                System.out.println("bye");
            }
        });
        t.start();

        Thread.sleep(3000);

        t.interrupt();
    }
}

在这里插入图片描述
当线程 t 处在等待状态,调用调用该线程的interrupt方法,导致wait抛出InterruptedException异常,结束等待状态

notify()

唤醒一个当前等待的线程

notify()也是Object类的方法,必须搭配synchronized使用
使用锁对象调用,如果调用notify()的对象和锁对象不一样,抛出llegalMonitorStateException异常

1.notify的使用

  1. 解铃还须系铃人,哪个对象调用了wait(),如果要唤醒这个线程,就还要用这个对象调用notify()
  2. 如果当前有多个线程,都在等待状态,调用一次notify(),则随机唤醒一个线程(没有“先来后到”之分)
public class Test13 {
    private static Object o = new Object();
    private static Object o2 = new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println("yyds");
            synchronized (o){
                try {
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();

                }
                System.out.println("bye");
            }
        });
        t1.start();

        // 保证 t1 先执行起来, t2 再执行
        Thread.sleep(3000);
        Thread t2 = new Thread(() -> {
           synchronized (o){
               System.out.println("notify执行前");
               o.notify();
           }
        });
        t2.start();

    }
}

这里是引用
唤醒线程和等待线程的锁对象是相同的

notifyAll()

一次性唤醒所有的等待线程
这些线程都使用同一把锁,一次性被唤醒后,会重新竞争锁对象的

public class Test14 {
    private static Object o = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (o) {
                System.out.println("线程t1进入等待状态");
                try {
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程t1结束等待状态");
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            synchronized (o) {
                System.out.println("线程t2进入等待状态");
                try {
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程t2结束等待状态");
            }
        });
        t2.start();

        Thread t3 = new Thread(() -> {
            synchronized (o) {
                System.out.println("线程t3进入等待状态");
                try {
                    o.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程t3结束等待状态");
            }
        });
        t3.start();

        // 保证这上面这三个线程先执行起来
        Thread.sleep(3000);

        Thread waker = new Thread(() -> {
            synchronized (o) {
                System.out.println("开始唤醒");
                o.notifyAll();
            }
        });
        waker.start();
    }
}

这里是引用在这里插入图片描述

每个线程都有自己独立的一部分寄存器
“读”变量,从内存中读到自己的寄存器中,再读寄存器
“修改”变量,直接修改内存中的变量

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

威少总冠军

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

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

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

打赏作者

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

抵扣说明:

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

余额充值