前言
在前面我们着重介绍了Java线程的基础概念和使用,但是多线程伴随着安全问题。本文将简单的介绍多线程的安全问题,并且介绍基础的解决方案
线程不安全
看个现象
我们先来看看线程不安全的代码会怎么样
public class Demo1 {
// 线程不安全
// 定义一个静态变量 两个线程对其累加 预期结果是2000
public static Integer count = 0;
public static void main(String[] args) {
// 创建两个线程 执行累加
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++){
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++){
count++;
}
});
// 启动线程
t1.start();
t2.start();
try {
// 等待线程执行结束 打印结果
t1.join();
t2.join();
System.out.println(count);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
运行结果: 小于2000的一个随机数
显然这个代码在单线程中是得到2000,但是在多线程的情况中得到一个错误、不符合预期的值
此时我们就可以说 这个代码是线程不安全的
为什么这个代码线程不安全
自增是个非原子性的操作。什么是原子性,这里和MySQL的原子性是相似的,就是要么都执行要么都不执行。
自增在CPU上的指令可以拆分为3步

这三步是非原子的,因为线程在CPU上是竞争调度的,线程从CPU上剥离下来运行到哪个指令了是不确定的
可能会碰巧t1的自增完全执行完成,t2再执行自增 此时t1的自增是正确的
但是更大的可能是t1的自增执行到一半穿插这t2的自增再重新继续执行t1的自增
例如

不难发现 t1 t2 都执行了自增的操作 但是数字只增加了1 这就是为什么得到的结果是一个小于2000的数字
当然了,这个里面如何穿插的可能性是无法枚举的,可能会发生任何情况的穿插。我们的例子是两个自增操作的穿插,但同样t1的一个自增操作中可能穿插了数个t2自增的代码
这显然是代码的bug,下面我们就简单介绍几个处理线程不安全的解决方案
为什么线程不安全
在解决前我们来要介绍一下线程为什么会不安全,只有知道原因才能解决问题
线程的抢占式调度
线程是抢占式调度的,这也是线程不安全的根本原因
简单来说,抢占的线程并不会管被抢占的线程执行到哪里,抢占线程这时候抢占CPU是否合适,只是一味抢占、假设一下线程间是谦让的,后一个线程会谦让上一个线程运行到合适的位置后再得到CPU。但这一点不是我们能从代码层面改变的
线程对一个变量的同时修改
一个线程读写一个变量没问题,两个线程读一个变量没问题,两个线程对不同变量修改没问题
线程一读一写对同一变量可能会有问题(有可能会涉及内存可见性)
线程对同一个变量同时修改会触发线程不安全问题
此时可以考虑优化代码,但同样有需求无法回避开对同一个变量的同时修改
操作是非原子的
前面我们介绍过原子性的概念。此时我们将代码修改为原子操作就可以避免因为非原子性导致的线程不安全
这里可以使用加锁的方式解决
内存可见性
可见性是一个线程修改一个变量其他线程是否能及时知晓
代码被加载到内存中,CPU又会将数据加载到寄存器中,从寄存器中读取数据
读取的操作对于CPU来说很慢,所以CPU发现这个值一直没有被更改就会大胆的不读CPU之都寄存器上的值,如果其他线程修改了,读取的线程感知不到,没法及时响应,就是线程不安全
这是编译器优化的结果
指令重排序
编译器和CPU会对代码进行优化,一提高代码的运行效率,这里的优化就是指令重排序,在保证代码逻辑不变的情况下可能会对代码的执行进行调整。这在单线程中是比较容易判断的,但在多线程的代码中,并不能保证重排序后的代码是符合预期的,这也触发了线程不安全的情况。这里的优化主要是考依赖关系决定,两个没用依赖关系的指令或者代码可能被重排序,有依赖关系则一定不会被重排序
我们可以分别正对上述产生线程不安全问题的原因逐一介绍解决方案
对操作加锁(原子性)
我们可以对一个非原子性的操作加锁变成原子性
先看代码
public class Demo2 {
// 线程安全
// 定义一个静态变量 两个线程对其累加 预期结果是2000
public static Integer count = 0;
public static void main(String[] args) {
// 创建两个线程 执行累加
Object lock = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++){
synchronized (lock){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++){
synchronized (lock){
count++;
}
}
});
// 启动线程
t1.start();
t2.start();
try {
// 等待线程执行结束 打印结果
t1.join();
t2.join();
System.out.println(count);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
运行结果:2000
我们可以使用synchronized关键字,传入一个锁对象,这个对象是谁并不重要,但是要获取同一个对象的锁的线程会发生因为锁产生阻塞,保证每个synchronized中的操作是一个原子性的操作,要么都成功要么都失败
synchronized可以修饰成员方法,等价于synchronized传入的锁对象是this
对于静态方法也可以使用synchronized修饰,等价于传入该类的类对象
对于成员方法,也可以使用类对象当锁对象,但是静态方法没用this只能使用类对象。至于这个锁对象是谁并不重要,重要的是这个锁对象是唯一的,两个线程竞争同一把锁,不能一个是this一个是类对象,这是两把锁
锁对象就是一个Java对象,Java对象被JVM加载到内存中除了自定义的属性外还有自带的属性,就是对象头。这个对象头里包含是否加锁的属性,这也是为什么我们要对唯一的同一个对象加锁
synchronized是可重入锁,简单来说就是同一个线程对一把锁加锁两次还是可以正确加锁,不会触发死锁。这里的实现可以使用程序计数器,在最后一次--到0就释放锁其他时候就对这个计数器加加减减
synchronized是互斥锁,简单来说一个线程加锁,在这个线程释放锁之前,其他想要加锁的线程必须阻塞等待
死锁
前面我们讲到死锁,现在简单介绍一下死锁的概念。简单来说就是左脚踩右脚
前面我们介绍synchronized是可重入锁不会触发死锁,如果是不可重入锁是什么情况呢
第一次加锁正常执行,到第二次加锁需要第一次加锁再释放锁,此时第二次加锁就被阻塞住,但是第二次加锁的阻塞导致第一次加锁无法释放锁,线程就被卡死了,这就是死锁的现象
或者说,AB两个线程,A线程获得b锁,B线程获得a锁此时是正常的,他们两个线程都还没释放锁,A线程想获得b锁,B线程想获得a锁。但是此时a锁被B线程加锁,b锁被A线程加锁,这两个线程互相掐着对方的命脉也死锁了
死锁一般出现在线程中嵌套获得锁对象时,串行获得释放锁一般不会发生死锁
前面介绍的模型是2个线程2把锁,还有n个线程m把锁的模型,当然这比我们介绍的复杂很多,有一个哲学家就餐问题的模型可以描述这个现象,这里就不再展开了
我们可以总结死锁产生的四个必要条件
锁是互斥的
锁是不可抢占的
锁被一直持有不释放
锁等待的关系成环
前两个是锁的特性,我们很难从代码的层面改变
一直持有不释放锁可以优化代码,但是实际上还得看具体情况是否可以优化,一些业务使然必须使用
成环则规定所有线程先获得小/大的线程,对应锁顺序,所有线程按顺序获得锁即可解决
内存可见性
内存可见性一般是对于一个变量,一个线程快速读值,另一个线程突然修改值,读值的线程无法立刻响应
我们可以再变量前使用volatile修饰这个变量,让编译器不要优化,老老实实的去内存读值,这时就不存在内存可见性的问题了
一般只修饰静态或者成员变量,方法内的变量不使用volatile修饰
指令重排序
先看两段代码
public class Demo3 {
// 定义 a 和 flag
public static int a = 0;
public static boolean flag = false;
public static void main(String[] args) {
// 死循环尝试击中 a==0 && flag==true 的情况
while (true) {
// 设置 a==1 && flag==true
a = 1;
flag = true;
if(flag){
if(a == 0)
System.out.println("击中");
}
// 重置 a 和 flag
a = 0;
flag = false;
}
}
}
运行结果:死循环 不会有任何输出 单线程情况下不可能击中
public class Demo4 {
public static int a = 0;
public static boolean flag = false;
public static void main(String[] args) {
while (true){
// 修改 a 和 flag
Thread t1 = new Thread(()->{
a = 1;
flag = true;
});
// 查看击中情况
Thread t2 = new Thread(()->{
if(flag){
if(a == 0)
System.out.println("击中");
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 重置 a 和 flag
a = 0;
flag = false;
}
}
}
运行结果:等待一段时间会输出击中 这代表flag被制true但是a还是0 代码被重排序了
解决方案主要有两个
一个是操作加锁
一个是对变量修饰为volatile
这里就不再演示了
wait和notify
有的时候我们希望线程之间按照我们预期的顺序执行,又不是join那种等待结束后在执行。而是本线程因为不具备运行条件主动触发阻塞,等待其他线程准备好本线程的执行条件后唤醒本线程继续执行,而其他线程也不用退出可以继续完成自己的逻辑,就需要使用wait和notify
wait会将线程阻塞等待,notify则会唤醒线程继续执行,这两个方法是Object的方法
wait的执行流是释放锁,进入阻塞,唤醒后重新获得锁
public class Demo5 {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
synchronized (lock){
lock.wait();
System.out.println("hello");
}
}
}
这里的hello必须等待其他线程唤醒之后才能被执行到
public class Demo5 {
public static void main(String[] args) {
Object lock = new Object();
Thread t1 = new Thread(() -> {
synchronized (lock){
System.out.println("wait 前");
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("wait 后");
}
});
Thread t2 = new Thread(() -> {
synchronized (lock){
System.out.println("notify 前");
lock.notify();
System.out.println("notify 后");
}
});
t1.start();
t2.start();
}
}
运行结果:
wait 前
notify 前
notify 后
wait 后
wait可以传入最大的等待时间 避免死等
wait的机制也可以避免线程饿死,避免没用条件执行的线程一直抢占执行降低执行效率
多个线程可以同时调用wait进入等待,使用notifyall可以唤醒全部
相比之下notify唤醒一个避免锁竞争在一定程度上提高效率
结语
以上便是今天的全部内容。如果有帮助到你,请给我一个免费的赞。
因为这对我很重要。
编程世界的小比特,希望与大家一起无限进步。
感谢阅读!