0


JavaEE-线程安全问题

1.线程安全的概念

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

为啥会出现线程安全问题?

本质原因: 线程在系统中的调度是无序的/随机的 (抢占式执行).

2.开始说明

先看个线程不安全的例子:

// 线程不安全
class Counter {
    private int count = 100000;
    private Object locker = new Object();

    public void add() {
        synchronized (locker) {
            count++;
        }
    }

    public void sub() {
        count--;
    }

    public int get() {
        return count;
    }
}

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

        // 搞两个线程, 两个线程分别对这个 counter 自增 5w 次.
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.sub();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.sub();
            }
        });
        t1.start();
        t2.start();

        // 等待两个线程执行结束, 然后看结果.
        t1.join();
        t2.join();

        System.out.println(counter.get());
    }
}

你觉得结果会是10_0000吗?

测试结果:

这个代码,是两个线程针对同一个变量各自自增 5w 次.

预期结果是 10w,实际结果 像是 个随机值 一样.每次的结果还不一样!!!

实际结果和预期结果不相符,就是 bug!!就是由多线程引起的 bug~~ => 线程不安全 /线程安全问题!

归根结底,线程安全问题,全是因为,线程的无序调度导致了执行顺序不确定,结果就变化了~~

解释下为啥出现这个情况,其实是和线程的调度随机性密切相关.

count++ 操作,本质上是 三个 cpu 指令构成 :

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

由于 多线程 调度顺序是不确定的实际执行过程中,这俩线程的 + + 操作实际的指令排列顺序就有很多可能!!!不同的排列顺序下,执行结果,可能是截然不同的!!

此时就发现,按照上述执行过程两个线程自增两次,最后结果是 1,说明 bug 就出现了,其中一次自增的结果,被另一次给覆盖了!!!

由于当前这俩线程调度顺序是无序的,你也不知道这俩线程自增过程中,到底经历了啥.有多少次是“顺序执行”有多少次是“交错执行”不知道!!!得到的结果是啥也就是变化的了~~

线程不安全有以下原因:

1) 线程抢占式执行

2)多个线程修改同一个变量

3) 修改操作不是原子的
4)由于内存可见性,引起的线程不安全

5) 由于指令重排序,引起的线程不安全

其中123比较常见,也是与上述count++例子相关;但是34会在另外的场景涉及,但是和上述count++的例子无瓜.下面会说明.

解释一下1)2)3):
1):由于cpu对于线程的调度是无序的,这也就导致了线程会抢占式执行.这也是会导致线程不安全的最主要原因.

  1. :多个线程修改同一个变量 => 是线程不安全的

换言之,一个线程修改/读取同一个变量 =>是线程安全的.
多个线程修改不同变量 => 是线程安全的

多个线程读取同一个变量 =>是线程安全的.
3):如何理解原子性? => 表示不可拆分的最小单位.
比如说,**一条 java 语句不一定是原子的,**换言之,一条Java语句不一定只是由一条指令构成,而是由多条语句构成如上述count++操作,其实是由3条指会构成:load,add,save.也正因为语句可能不是原子性的,这也就导致了两个线程在抢占式执行的时候,所执行的指令是不符预期的,也就可能会导致不可预期的结果.

那针对上述count++操作所引起的线程不安全问题,能否解决呢?
当然有,那就是让count++操作变成原子的.
Java中使用synchronize关键字进行加锁操作
synchronized 会起到互斥效果,某个线程执行到某个对象的 synchronized 中时其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.

举个栗子:

但是,为了保证线程安全,为了实现原子性,就得利用"锁竞争",也就必须得保证**:多个线程是对同一个对象进行加锁.**

在上述代码中,这俩线程是在竞争同一个锁对象()counter对象)!

此时就会产生 锁竞争(t1 拿到锁, t2 就得阻塞)此时就可以保证 + + 操作就是原子的,不受影响了!!

由于 t1 已经率先 lock 了t2 再尝试进行 lock就会出现阻塞等待的情况!!此时就可以保证 t2的 load 一定在 t1的 save 之后此时计算的结果就是线程安全的了!!加锁本质上是把并发的变成了串行的!!

synchronize的工作原理以及如何使用synchronize进行加锁:

synchronized 的工作过程:
1.获得互斥锁
2.从主内存拷贝变量的最新副本到工作的内存
3.执行代码
4.将更改后的共享变量的值刷新到主内存
5.释放互斥锁

如何使用synchronize进行加锁
synchronized 要搭配一个具体的对象来使用.

只不过大家要牢记:如果多个线程尝试对同一个锁对象加锁,此时就会产生锁竞争针对不同对象加锁,就不会有锁竞争~


由于内存可见性而引起线程不安全:

先看看场景:

public class ThreadDemo14 {
    volatile public static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 0) {
                // 空着

            }
            System.out.println("循环结束! t1 结束!");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数: ");
            flag = scanner.nextInt();
        });

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

预期效果:t1 通过 flag == 0 作为条件进行循环初始情况,将进入循环.

实际效果:输入 非 0的值之后,t1 线程并没有退出.循环没有结束,通过 iconsole 可以看到 t1 线程仍然在执行,处在 RUNNABLE 状态.

为啥有这个问题?
首先需要了解一下内存可见性是个啥内存可见性: 一个线程对共享变量值的修改,能够及时地被其他线程看到.
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来).

为此,加上volatile关键字进行修饰,就可以保证各个处理器的缓存是一致的.
为什么会不一致呢?这就涉及到了寄存器和缓存了当重复读一个数据的时候,cpu为了提高效率,只会在第一次从内存中读取数据,此后会把数据加载到寄存器里,以后就直接从寄存器里读取数据,就不会再从内存里读取数据了,可是如果此时其他线程对该数据进行修改了,当前线程由于使用"直接复用寄存器的值"的方式,所以感知不到该值已经被修改了.所以对于当前线程而言,此时该cpu向寄存器里读取到的仍然是旧值,也就是无效值.因此导致线程不安全.

此处咱们的处理方式,就是让编译器针对这个场景暂停优化!!

使用volatile关键字,使编译器停止上述优化volatile强制读写内存,

这也就保证了在各个线程里,cpu在向寄存器读取值的时候,都会重新到内存里进行读取,而不会直接复用寄存器里的旧值.同样,在各个线程里,在对某个数据进行修改时,都一定会将修改后的值写回内存,而不会存在"仅在自己的工作内存里进行值的修改,而不会修改主内存里的值”的情况
具体做法: volatile public static int flag =0 ;
加上 volatile 关键字之后,此时编译器就能够保证每次都是重新从内存读取 flag 变量的值.
此时 t2 修改 flag,t1 就可以立即感知到了.t1 就可以正确退出了!!!

由于指令重排序而导致的线程不安全:

指令重排序,也是编译器优化的策略!调整了代码执行的顺序, 让程序更高效!前提也是保证整体逻辑不变!

谈到优化,都得保证 调整之后的结果 和之前是不变的.单线程下容易保证.如果是多线程,就不好说了!

如果是单线程环境此处就可以进行指令重排序:1 肯定是先执行2 和 3,谁先执行,谁后执行,都可以!!

如果是多线程环境下:假设 t1 按照 1 3 2 的顺序执行,当 t1 执行完 13 之后,即将执行 2 的时候,t2 开始执行.由于 t1 的3 已经执行过了,这个引用已经非空了!!!t2 就尝试调用 s.learn0,可是t1还没有该对象进行初始化,此时的 learn 会成啥样,不知道了,很可能产生 bug !!

3.总结

volatile:1)为了保证内存可见性,volatile强制读写内存,保证每次都是从内存中重新读取数据.

2)为了解决在多线程的某些场景下,编译器对代码重排序而导致优化后的程序执行结果和之前不等价"的问题,volatile禁止指今重排序,保证该场景下的某个逻辑按照”本来的指令顺序”执行.

这里再说一下volatile和synchronize 的区别:

共性:volatile与synchronized都用于保证多线程中数据的安全.

区别:(1) volatie通过强制读写内存和禁止指令重排序来保证线程安全。synchronized则是通过对代码块里的语句进行加锁,实现同一时刻只有一个线程能够访问被锁在代码块里的语句,来保证线程安全.

(2) volatile仅能用在变量级别;而synchronized可用在变量和多条语句中.

(3)volatie仅能实现变量操作的内存可见性,无法保证变量操作的原子性;而synchronized可以实现变量操作的内存可见性与原子性.

volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有无法保证原子性.因为无锁,不需要花费时间在获取锁和释放锁上,也不会导致线程阻塞,所以volatile比synchronize更轻量.

uu们加油呀!!!

标签: jvm java 开发语言

本文转载自: https://blog.csdn.net/m0_67721787/article/details/129722154
版权归原作者 T7ooo3o 所有, 如有侵权,请联系我们删除。

“JavaEE-线程安全问题”的评论:

还没有评论