Java多线程详解⑤(全程干货!!!)线程安全问题 || 锁 || synchronized

这里是Themberfue

· 在上一节的最后,我们讨论两个线程同时对一个变量累加所产生的现象

· 在这一节中,我们将更加详细地解释这个现象背后发生的原因以及该如何解决这样类似的现象


线程安全问题

java 复制代码
public class Demo15 {
    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();
        
        // 改为串行执行
        t1.start();
        t1.join();

        t2.start();
        t2.join();

        System.out.println(count);
    }
}

· 我们先回顾上述代码,如果两个线程并发执行逻辑,同时累加 count 变量 100,000 次后,得到的结果是一个随机值,且这个随机值一定小于 100,000

· 如果改为串行执行,就是 t1 执行完后,t2 再度执行,那么 count 的结果就为 100,000
· 为什么会产生这样的现象?

· 我们先从一行代码入手:count++,有的人就会问:这有什么好分析的,这不就是一个count + 1的操作吗?没错,的确是这样

· 众所周知:CPU(中央处理器)执行的是一系列指令,这些指令定义了它需要执行的逻辑操作,这些指令的集合统称为指令集,指令集有两种,一种是..... (再讲就串台了,这是计算机组成原理的知识哦)

· 常见的指令就有逻辑指令,算术指令等,那么,一个 count++ 其实分为三个指令操作,因为它还设计到变量的修改,而不是单纯地加法

· 我们都知道,把大象放进冰箱分三步:把把冰箱门打开,把大象装进去,再把冰箱门关上

· count++ 也分为三步操作:

1. load:把内存中 count 的值,加载到 cpu 寄存器

2. add:把寄存器中的 count 的值 + 1

3. save:把寄存器中的 count 的值保存到内存中

PS:寄存器就是CPU处理日常任务的小工具,用来存放临时信息
· 操作系统对线程的调度的是随机的,所以在执行这三条指令时,可能不是一口气全部执行完毕,而是执行了一半就不执行了,而后又执行了

· 比如执行 指令1 ,后被调度走,调度回来后执行 指令2 指令3

· 比如执行 指令1 指令2 ,后被调度走,调度回来后执行 指令3

· 比如执行 指令1 ,后被调度走,调度回来后执行 指令 2 ,后被调度走,调度回来后执行指令3

· 多线程的随机调度是造成这个bug出现的原因

· 上述为简单模拟了一遍两次 count++ 的大概流程

· 这是最为理想的情况,就是三条指令一次性执行完毕后再去执行下三条指令,但实际情况却不能保证每次发生这种理想的情况

· 上述情况才是经常发生的,也是导致bug的主要原因

· 尽管执行了两次 count++ 操作,但内存中保存的值为1,结果只增值了一次

· 产生上述问题的原因就是线程安全问题

· 根本原因就是操作系统对于线程的调度是随机的,也就是抢占式执行(这个策略在最初诞生多线程任务操作系统时就诞生了,是非常伟大的发明,后世的操作系统,都是这个策略)

· 第二个原因就是多个线程修改同一个变量

如果是一个线程修改一个变量,不会产生上述问题

如果是多个线程修改不同变量,同样的

如果是多个线程不是同时修改同一个变量,同样的

如果是多个线程同时读取一个变量,同样的

· 第三个原因就是修改的操作,不是原子的,如果是 count++ 是一条指令就可以执行完毕,那么认为该操作就是原子的

· 内存可见性问题

· 指令重排序

· 后续再讨论其细节


加锁

· Java中解决线程安全问题的最主要的方案就是给代码块加锁,通过加锁,可以让不是原子的操作,打包成一个原子的操作

· 计算机中的锁操作,和生活中的加锁区别不大,都是互斥,排他。例如:你上厕所,对当前这个厕所间加锁,那么别人就不能进这个厕所间了,你出厕所门时,此时就是解锁

· 通过使用锁,对先前的 count++ 操作就可以将其变为原子的,在加上锁后,count++ 的三个指令就会完整执行完毕后才可能被调度走

· 加锁操作,不是讲线程锁死到CPU上,禁止这个线程被调度走,是禁止其他线程重新加这个锁,避免其他线程的操作,在这个线程执行过程中插队
· 加锁和解锁这个操作本身是操作系统提供的 api,但是很多语言都对其单独进行了封装,大多数的封装风格都是采取这两个函数:

java 复制代码
Object.lock();

    // 执行的代码逻辑

Object.unlock();

· 但是这样写的弊端也很大,不能保证每次加上锁后都会记住去解锁,所以 Java 提供了一种更为简洁的方式去给某个代码块上锁:

java 复制代码
synchronized {

    // 执行的代码逻辑

}

· 只要进入了代码块(进入 '{' 后)就会加上锁,只要出了代码块(出去 '}' 后)就会自动解锁

· 但在上述伪代码中,synchronized 的使用并不正确,单纯地加锁,但是此时另一个线程又要加锁,我们要怎么判断这个锁有没有被使用(锁又不止一个)

· 所以应该这样使用:

java 复制代码
synchronized (Object) {

    // 执行的代码逻辑

}

· 没错,括号里填写的就是用来加锁的对象,这个对象一般称为锁对象,作为锁的作用去使用

· Object 表示一个类,Java 的所有对象都可以作为锁对象:

java 复制代码
Object locker = new Object();

synchronized (locker) {
    count++;
}
java 复制代码
public class Demo16 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        // Java中,任何一个对象都可以作为锁
        Object locker = new Object();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // 对count的++操作进行上锁
                // load,add,save操作执行完才会调度走
                synchronized (locker) {
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized (locker) {
                    count++;
                }
            }
        });

        // 只有两个线程针对一个对象加锁,才会产生互斥效果
        // 一个线程被上了锁,另一个线程得阻塞等待,直到第一个线程解锁

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

        System.out.println("count = " + count);
    }
}

· 单独对一个线程上一个锁,是不能发挥锁的作用的

· 只有两个线程针对一个对象加锁,才会产生互斥效果

· 一个线程被上了锁,另一个线程就得阻塞等待,直到那个线程解锁,才会继续向下执行

· 运行上述代码,count 的结果恒为 100,000,不可能出现其他值,也就解决了该代码逻辑的线程安全问题

· 通过加锁操作,count++ 操作的三个指令相当于合并成了一个指令,保证每个线程从内存中获取到的值是正确的

· 并不是加上了 synchronized 就一定保证线程安全,得要正确地使用锁,在该使用的时候使用锁,在对的地方使用锁

· 比如,在这个案例中,不是对 count++ 操作操作,而是在 for 循环开始前就上锁:

java 复制代码
synchronized (locker) {
    for (int i = 0; i < 50000; i++) {
        count++;
    }
}

· 这样虽然也是上了锁,但是没什么意义,就相当于等到 for 循环逻辑全部结束后,再解锁,另一个线程停止等待,拿到锁

· 因为这两个线程就这一个相同逻辑,所以这么写就相当于变成了串行执行,不是并发了
· 采取 synchronized 的加锁方式,就可以确保一定会释放锁,不会遇到加锁后但是没有解锁的情况

· 除此之外,synchronized 还可以修饰方法,对这个方法加锁

java 复制代码
class Counter {
    private int count;

    // 使用 synchronized 对方法进行上锁,就相当于是针对this上锁
    synchronized public void addCount() {
        // synchronized (this) {
            this.count++;
        // }
    }

    // 使用 synchronized 对静态方法进行上锁,就相当于是针对类对象上锁(反射)
    public synchronized static void func () throws ClassNotFoundException {
        synchronized (Class.forName("Counter")) {
            System.out.println("func");
        }
    }
    public synchronized static void fuc () {
        synchronized (Counter.class) {
            System.out.println("fuc");
        }
    }

    public int getCount() {
        return count;
    }
}

public class Demo17 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.addCount();
            }
        });

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

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

        System.out.println("count = " + counter.getCount());
    }
}

· 我们如果查看 StringBuffer 类的方法,也可以看到类似的操作


· 下一节我们会更加深入多线程,了解到死锁等相关概念

· 毕竟不知后事如何,且听下回分解~~

相关推荐
远望清一色几秒前
基于MATLAB的实现垃圾分类Matlab源码
开发语言·matlab
confiself10 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
Wlq041515 分钟前
J2EE平台
java·java-ee
XiaoLeisj22 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
杜杜的man25 分钟前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*26 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家27 分钟前
go语言中package详解
开发语言·golang·xcode
llllinuuu27 分钟前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s28 分钟前
Golang--协程和管道
开发语言·后端·golang
王大锤439130 分钟前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang