[线程]多线程带来的风险-线程安全问题(重要)

一. 什么是线程安全

线程, 随机调度, 抢占式执行的, 这样的随机性, 就会使执行顺序, 产生变数, 可能会产生不同的结果, 如果这种结果认为不可接受, 则认为是bug
多线程代码, 引起了bug, 这样的问题, 就是"线程安全问题"
存在"线程安全问题"的代码, 就成为"线程不安全"

二. 线程不安全一个经典的例子

public class Demo13 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

上述代码, t1对count++50000次, t2对count++50000次, 按理来说count应该等于100000, 但是我们运行起来发现:
在这里插入图片描述
多运行几遍发现:
在这里插入图片描述
在这里插入图片描述
这就是非常严重的bug!!
这就是典型的多线程并发引起的问题
如果让俩个线程串行执行, 就没有任何问题的!!
在这里插入图片描述
在这里插入图片描述

三. 对上述例子的理解

CPU执行一个线程, 就是在执行指令, 再引入多线程随机调度, 那么事情就变得很复杂了
上述代码出现bug的原因在于:
在这里插入图片描述

  1. 这一行代码, 其实是3个cpu指令!!!
    1)把内存count中的数值, 读取到cpu的寄存器中 => load
    2)把寄存器中的值+1, 继续保存在寄存器中 =>add
    3)将寄存器中计算后的值, 写回到内存count中 => save
    (后面的名字使我们自己取得, 真实的cpu指令名字可能不是这个)
  2. 多线程的执行, 是随机调度, 是抢占式的运行模式
    某个线程执行指令的时候, 当他执行到任何一个指令的时候, 都有可能被其他的线程把CPU抢占走(操作系统把前一个线程调度走, 后一个线程上位)

结合上述两点, 实际并行开发的时候, 两个线程执行指令的相对顺序就可能会存在多种可能
不同的执行顺序, 得到的结果就可能会存在差异
可能顺序1:
在这里插入图片描述
这个顺序就是可以得到正确结果的顺序

可能顺序2:
在这里插入图片描述
这样的执行顺序就可能会出现问题:
在这里插入图片描述

四. 出现线程不安全的原因(面试题)

1. 线程在操作系统中是随机调度, 抢占式执行的

这是线程不安全的罪魁祸首

2. 当前代码中, 多个线程同时修改同一变量

注意关键词: 多个线程, 修改, 同一变量
一个线程修改同一变量
多个线程读取同一变量
多个线程修改不同变量
这些都是没有影响的, 不会出现bug

上述代码, 就是多个线程修改一个count变量

3. 线程针对变量的修改操作, 不是"原子"的

原子: 不可拆分的最小单位
如果某个代码, 对应到一个CPU指令, 就是原子
如果对应到多个, 就不是原子的

像count++这种操作, 就不是原子操作 => 对应三条指令
但是有的操作, 虽然也是修改, 但就是原子操作, 比如针对int / double 进行赋值操作, 就对应一个move指令

4. 内存可见性问题, 引起线程不安全

5. 指令重排序, 引起线程不安全

45后续介绍~~

五. 解决上述例子问题 — 上锁

解决线程安全问题, 最普适的方法, 就是通过一些操作, 把上述"非原子"操作, 打包成一个"原子"操作 ------ 上锁

1. 锁

锁, 本质上也是操作系统内核提供的功能, java(JVM)对这样系统api又进行了封装

锁的主要操作

关于锁, 主要操作两个方面:
1)加锁
t1加上锁后, t2也尝试加锁, 就会阻塞等待(都是系统内核控制, 在java中就可以看到BLOCKED状态)
2)解锁
直到t1解锁了之后, t2才可能加锁成功

锁的主要特性

锁的主要特性: 互斥(也叫锁竞争 / 锁冲突)
一个线程获取到锁之后, 另一个线程也尝试加这个锁, 就会阻塞等待

代码中, 可以创建多个锁, 只有多个线程竞争同一把锁, 才会产生互斥, 针对不同的锁, 则不会产生互斥

2. synchronized关键字

给count++加锁:
1.加锁前, 需要设定一个锁对象
java中, 随便拿一个对象, 都可以作为加锁的对象(这是java独特的设定, 其他语言, 只有极少数待定的对象可以用来加锁)
我们创建一个对象:
在这里插入图片描述
2. 使用synchronized(注意格式)
在这里插入图片描述

  1. synchronized后面跟着( ), ( )里面的内容就是**“锁对象”**
    注意!!!
    锁对象的用途只有一个, 有且只有一个, 就是用来区分两个线程是否是针对同一个对象加锁
    如果是, 就会出现互斥 / 锁竞争 / 锁冲突, 就会引起阻塞等待
    如果不是, 就不会出现锁竞争, 也不会阻塞等待
    和对象具体是是啊类型, 和他里面有啥属性, 有啥方法…都没有关系!!
    我们说"给对象加锁", 也不要误会, 锁对象只有区分是否是同一个锁的作用!!!
    可以理解为: 每个对象只有一把锁, 给t1加锁后, t2就不能再用这个对象的锁了!
  2. synchronized下面跟着{ }
    当进入代码块{ }, 就是给上述锁对象进行加锁操作
    当出了代码块{ }, 就是给上述锁对象进行解锁操作

对同一个对象加锁:

在这里插入图片描述
上述两个线程对同一个对象加锁, 那么就会发生互斥, 此时每次conut++操作都不会被打断, 从而真正可以加到100000
在这里插入图片描述
这样的阻塞, 就使t2的load出现在t1的save后, 强行的构造出了"串行执行"的效果
此时的运行结果:
在这里插入图片描述

对不同的对象加锁:
在这里插入图片描述
此时t1t2对不同的锁对象加锁, 此时就不会产生互斥, 他们之间还是"并发执行"的
运行结果为:
在这里插入图片描述
注意!!!
加锁不是"封装"!!!
t1加上object锁之后,
1)如果t2加的也是object锁, 那么t2是不能够"插队"的, 必须要等到t1解锁,
但是其他线程(没加object锁)是可以和t1抢占cpu的, 并不是将t1封装起来一起运行
2)如果t2加的不是object锁, 那么t2是可以抢占的!!

上述代码, 只有count++操作是互斥的, 是串行执行, 但是线程中的循环操作, 条件判断还是继续并行执行的, 也就是说, 当t1解锁之后, t2是不一定马上能上锁的, t1解锁后, 马上进入下一次循环, 又进行上锁操作, 此时t1 t2 是抢占调度的, 下一次是谁上锁是不一定的

3. synchronized的其他写法

我们将上述代码改写一下:

class Count{
    private int count;
    public void add(){
        count++;
    }
    public int get(){
        return count;
    }
}
public class Demo14 {
    public static void main(String[] args) throws InterruptedException {
        Count count = new Count();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count.get());
    }

}

同样也是多个线程对同一变量进行修改操作, 会产生bug
下面我们对add操作进行加锁:

已经有count对象了, 我们就可以直接用count对象进行加锁:
在这里插入图片描述

1.写在方法中

其实我们是对count++进行加锁, 所以可以将锁放在add方法中:
在这里插入图片描述
这里的this就指代count对象

2. 修饰普通方法

此时发现, 加锁的生命周期和方法的声生命周期是一样的, 这个时候, 就可以直接把synchronized写在方法上
在这里插入图片描述
synchronized修饰普通方法, 就相当于是对this加锁

3. 修饰static方法

synchronized也可以修饰static方法, 此时就相当于是对类对象加锁
相当于:
在这里插入图片描述

类对象:
类的属性信息, 包括类的名字, 继承自哪个类, 实现了哪些接口, 提供了哪些方法, 有哪些属性…等等类的全部信息
这些都是我们自己写的, 存在.java源代码中
经过javac编译后, .java => .class字节码文件(但是上述信息仍然存在, 只是变成了二进制)
经过java运行, 就会将.class文件的内容加载到内存中
给后续使用这个类, 提供基础
在内存中保存上述信息的对象, 就是类对象
在java代码中, 可以通过类名.class的方式拿到类对象
一个java进程中, 一个类, 只能有唯一的一个类对象

在这里插入图片描述
如果有多个线程调用func, 则这些线程一定会发生互斥!!!
(因为锁对象是类对象, 只有一个类对象)
在这里插入图片描述
如果多个线程调用add, 就不一定会发生互斥了
(锁对象是this, 指代的对象看你new了几个对象)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值