线程安全问题

目录

线程安全

线程安全问题的原因及解决办法

synchronized关键字

死锁


线程安全

在单线程的情况下,程序代码执行顺序都是固定的,程序的运行结果就是固定的.而有了多线程,代码抢占式执行,代码的执行顺序,会出现多种情况,代码的执行顺序就从一种可能性变成了无数种情况,所以需要保证无数线程调度的顺序下,代码的执行结果都是正确的.只要有一种情况下,代码运行不正确,就会出现线程安全问题.

下面通过代码说明线程安全问题


class Counter {
    public int count = 0;
    public void add() {
        count++;
    }
}

public class ThreadDemo7 {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

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

运行结果

在上面代码中我们让线程t1和线程t2都循环50000次执行add()方法,预期的执行结果是count = 1000_00,而结果却不是,那是 因为上面的++  操作分为三步,

1  load 先把内存中的值读取到CPU寄存器上

2  add 把CPU寄存器里的数值进行 +1运算

3  save 把得到的结果写到内存中

如果两个线程并发执行count++,此时就相当于两组 load,add,save进行执行,此时现成的调度顺序不一样就可能产生结果上的差异  

画图演示线程的调度顺序

线程安全问题_第1张图片

当执行正确的调度的时候,t1调度完成把值西写到内存中后t2才开始调度此时就是安全的线程,在第二种情况下,t1先load,t2 load的值是t1修改之前的值,导致t2后续保存数据的时候和t1保存的是同一份数据,就会出现线程不安全问题

线程安全问题_第2张图片

 线程安全问题的原因及解决办法

1 根本原因: 线程之间抢占式执行,随机调度

2 代码结构: 多个线程同时修改同一个变量,当多个线程读取到同一个变量的时候是安全的

3. 原子性;如果修改操作时原子的,是安全的的,修改非原子的就是不全的

所谓原子性指的是 不可拆分的基本单位

解决办法

通过 synchronized关键字进行加锁

synchronized关键字

synchronized 会起到互斥效果 , 某个线程执行到某个对象的 synchronized 中时 , 其他线程如果也执行到 同一个对象 synchronized 就会 阻塞等待 .
进入 synchronized 修饰的代码块 , 相当于 加锁
退出 synchronized 修饰的代码块 , 相当于 解锁
线程安全问题_第3张图片
理解 " 阻塞等待 ".
1.针对每一把锁, 操作系统内部都维护了一个等待队列 . 当这个锁被某个线程占有的时候 , 其他线程尝 试进行加锁, 就加不上了 , 就会阻塞等待 , 一直等到之前的线程解锁之后 , 由操作系统唤醒一个新的 线程, 再来获取到这个锁.
2.如果两个对象针对同一个对象进行加锁,就会出现锁竞争/锁等待.一个线程能够获取到锁,另外一个线程阻塞等待,等到上一个线程解锁,他才能获取到锁,否则就不能加锁
2. 当两个线程对不同对象加锁,此时不会产生竞争/锁冲突,这两个线程都能获取到各自的锁,不会产生阻塞等待
3.当两个线程,一个线程加锁,一个线程不加锁,这个时候不会产生锁竞争/锁冲突
注意 :
上一个线程解锁之后 , 下一个线程并不是立即就能获取到锁 . 而是要靠操作系统来 " 唤醒 ". 这也就是操作系统线程调度的一部分工作.
假设有 A B C 三个线程 , 线程 A 先获取到锁 , 然后 B 尝试获取锁 , 然后 C 再尝试获取锁 , 此时 B 和 C 都在阻塞队列中排队等待 . 但是当 A 释放锁之后 , 虽然 B C 先来的 , 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争 , 并不遵守先来后到的规则
synchronized的使用方法
1. 修饰方法:
 修饰普通方法:锁对象就是当前对象
线程安全问题_第4张图片
此时synchronized修饰的是add方法,当t1执行add()的时候,就针对counter对象加锁,当t2执行add的时候,也尝试对counter对象加锁,但由于counter已经被t1占用,此时t2在对counter对象加锁,就会产生阻塞.
修饰静态方法: 锁对象就是类对象
2. 修饰代码块
  
手动指定锁对象
线程安全问题_第5张图片

当前使用方法就是对代码块进行加锁,进入代码块就加锁,出了代码块就解锁,在这里可以指定任何想指定的对象,不一定是this.

3.可重入

一个线程针对同一个对象,连续加锁两次,是否会有问题,如果没有问题,就是可重入的,否则就是不可重入的.

线程安全问题_第6张图片

针对上面代码,锁对象就是this,只要有线程调用add(),进入add()方法的时候,就会现加锁,紧接着执行到代码块再次尝试加锁,站在this的角度,他认为自己已经加锁了,已经被其他线程调用了,而此时相当于两个线程是同一个线程,如果允许上述操作,就是可重入的,如果不允许就是不可重入的,此时会导致死锁.

在java中,为了避免不小心死锁,java就把synchronized设定成了可重入的.

4. 内存的可见性问题

线程安全问题_第7张图片

上面代码t1线程要循环快速读取,t2进行修改,预期结果是t2把flag改成非0 的值之后,t1随之循环结束,但结果是t1线程一直处于循环状态

线程安全问题_第8张图片 向上面出现的结果可以用汇编来理解,这里有两步操作

1. load 把内存中flag的值读取到寄存器中,

2. cmp 把寄存器中的值和0进行比较,根据比较结果决定下一步往哪个地方执行

相比于tmp来说,load的执行速度太慢,在加上反复load的值都是一样的,jvm就不在真正重复load了,就只读取一次, 一个线程针对变量进行读取操作,另外一个线程针对这个变量进行修改操作,此时读取到的值不一定是修改后的值,向这样的问题就是内存可见性问题

可以使用volatile关键字来解决内存可见性的问题

使用volatile修饰的变量能够保证内存可见性.但volatile不保证原子性

5.指令重排序,本质上是编译器优化出bug了)

死锁

在Java中,下面几种情况会出现死锁

1.一个线程,一把锁,连续加两次,如果锁是不可重入锁,就会死锁.但在Java里,synchronized和ReentrankLock都是可重入锁

2.两个线程.两把锁,t1和t2各自先针对锁A和锁B加锁,在尝试获取对方的锁.

 public static void main(String[] args) {
        Object lockerA = new Object();
        Object lockerB = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (lockerA) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockerB){
                    System.out.println("t1获取到lockerA 和lockerB");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (lockerB) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockerA){
                    System.out.println("t2获取到lockerA 和lockerB");
                }
            }
        });

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

 执行结果

线程安全问题_第9张图片

上面代码没有任何日志,说明没有线程拿到两把锁,此时两个线程都在等待对方先释放锁,就一直处在阻塞状态.

3. 多个线程多把锁

 在学习这个之前,先听一个哲学家就餐的故事

一张圆桌上坐着5名哲学家,每两位哲学家之间的桌上摆一根筷子,桌子的中间是一碗米饭。哲学家们倾注毕生的精力用于思考和进餐,哲学家在思考是,并不影响他人。只有当哲学家饥饿时,才试图拿起左、右两根筷子(一根一根的拿起)。如果筷子已在他人手上,则需要等待。饥饿的哲学家只有同时拿起两根筷子才可以进餐,当进餐完毕后,放下筷子继续思考。

线程安全问题_第10张图片

死锁的四个必要条件

1. 互斥使用.  线程1拿到了锁,线程2就要等待

2. 不可抢占.  线程1拿到锁之后,必须是线程1主动释放锁,而不是线程2就把锁强行获取到

3, 请求和保持. 线程1拿到锁A之后,在尝试获取锁B,线程1还是保持对A的加锁状态,不会因为锁Bj就把锁A释放

4. 循环等待 线程1尝试获取到锁A和B,线程尝试获取到锁B和锁A,线程1 在获取锁B的时候等待线程2释放B,同样,线程2在获取锁A的时候等待线程1释放A.

如何避免死锁

我们只需要给锁编号,然后指定一个固定的顺序来进行加锁.来解决循环等待的问题,

 public static void main(String[] args) {
        Object lockerA = new Object();
        Object lockerB = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (lockerA) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockerB){
                    System.out.println("t1获取到lockerA 和lockerB");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (lockerA) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockerB){
                    System.out.println("t2获取到lockerA 和lockerB");
                }
            }
        });

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

运行结果

 此时我们按照A,B,C的顺序来进行加锁,就解决了循环等待问题.

你可能感兴趣的:(java,算法,开发语言)