Java中的线程安全问题,线程安全问题的解决方法

一、认识线程安全

线程安全问题的出现

        简单来说,某个代码,无论是在单个线程下执行,还是在多个线程下执行,都不会产生BUG,把这个情况称为 “线程安全” 。

        若是某个代码,在单个线程下顺利运行,但是转到多线程情况下,就可能会产生BUG,这个情况称为“线程不安全”或者“存在线程安全问题

线程不安全的原因

  1.      根本原因:操作系统上的线程是 “抢占式执行”、“随机调度” 的,我们无法预知,就导致了线程之间的执行顺序带来了很多变数。(可以称为 “罪魁祸首”)
  2.      代码结构问题:代码中多个线程同时改同一个变量(也是源于上面的根本原因,导致一些指令出现混乱的排序。后面会介绍到可以通过使用 “锁” 来解决这类问题。)
  3.      直接原因:多线程修改操作,本身不是原子(一个程序执行多个cpu指令,执行到一半,就可能会被调度走,从而给其他线程 “可乘之机”)
  4.      内存可见性问题:高度依赖编译器的优化的具体实现,一个线程对共享变量值的修改,未能够及时的被其他线程看到。(在执行一些指令操作时,花费的开销比较大,并且一直没有结果。这时JVM就会出来怀疑,判断,然后会进行代码优化,去除一些JVM认为没有必要的操作,提高程序运行效率。从而会导致,后面的一些操作执行时,被优化的代码无法产生响应,出现BUG。)
  5.      指令重排序问题:   在多线程下,通过JVM、CPU指令集会对某些代码进行优化。把1->2->3的顺序可能会优化为1->3->2,但是在多线程情况下代码的执行复杂程度更高,容易打乱 “保持逻辑不发生变化”这一重要前提。

代码示范:修改共享数据----多个线程修改同一个变量

        下面的线程不安全代码中,涉及到多个线程针对 count 变量进行修改。

        此时这个 count 是一个多线程都能访问的 “共享数据”。

 public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        //通过两个线程,同时修改count变量,使他们分别++
        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);
    }

得到的结果:<100000

二、线程安全问题的解决方法-- 加“锁”

如何实现--“锁”

锁(synchronized):通过特殊的手段把系统的指令打包成一个整体(类似原子)

        锁 具有 “互斥”、“排他” 的特性,在两个线程中对同一个对象 加锁 就会产生“锁竞争”,使其运行中产生 “阻塞(BLOCKED)”。通过锁竞争无法方第二个线程在第一个线程执行的时候插队,需要排队等待,在顺利执行完锁里面的操作后,继续执行。

synchronized 关键字 -- 监视器锁(monitor lock)

1.使用分别在两个线程里使用 synchronized 关键字

 public static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        Object locker = new Object();

        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
           //加锁,使得count++的指令操作在锁里面完成,不被插队,包装成一个完整的count++指令
                synchronized (locker){//注意:这里的locker参数,可以是任意的Object
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
            //加锁,使得count++的指令操作在锁里面完成,不被插队,包装成一个完整的count++指令
                synchronized (locker){//注意:这里的locker参数,可以是任意的Object
                    count++;
                }
            }
        });

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

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

2.把count 放到Test t对象中,通过add方法来修改

class Test{
    public static int count = 0;
    //1.
    public void add(){
        for (int i = 0; i < 50000; i++) {
            /*
            为什么static中不能使用this
                静态方法不依赖于任何对象就可以进行访问,既然都没有对象,就谈不上this了
            static叫静态方法,也叫类方法,它先于任何的对象出现。
            在程序最开始启动(JVM初始化)的时候,就会为static方法分配一块内存空间,成为静态区,属于这个类。
            而非static方法,必须在类实例化的时候,才会给分配内存空间,
            在实例化对象的时候JVM在堆区分配一个具体的对象,this指针指向这个对象。
            也就是说,this指针是指向堆区中的类的对象,而static域不属于this指向的范围所在,所以不能调用。
             */
            synchronized (this){
               //加锁的锁对象,写作this
                count++;
            }
        }
    }
}

public class Thread_12 {
    public static void main(String[] args) throws InterruptedException {
        Test t = new Test();
        //这里的 this 都指向的是 t ,是一样的两个对象加锁,因此任然存在锁竞争
        Thread t1 = new Thread(() -> {
            t.add();

        });
        Thread t2 = new Thread(() -> {
            t.add();
        });


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

        t1.join();
        t2.join();
        System.out.println("count=" + t.count);
    }
}

3. 通过类对象来加锁

class Test{
    public static int count = 0;
    synchronized public void add(){
        for (int i = 0; i < 500000; i++) {
            count++;
        }
    }
}

public class Thread_12 {
    public static void main(String[] args) throws InterruptedException {
        Test t = new Test();
        //两个线程拿到的类对象是同一个对象,因此任存在锁竞争,可以保障线程安全
        Thread t1 = new Thread(() -> {
            t.add();

        });
        Thread t2 = new Thread(() -> {
            t.add();
        });


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

        t1.join();
        t2.join();
        System.out.println("count=" + t.count);
    }
}

以上三个例子,通过实现“锁”后得到的结果:

锁 存在的问题--“死锁”

加锁是能解决多线程安全问题的,但是如果添加方式不对,就可能产生死锁!!

1.一个线程一把锁(“可重入”性)

        若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

public static void main(String[] args) {
        Object locker = new Object();
        Thread t = new Thread(()->{
            //可重入 性,同一个线程可以两次加锁,不会出现阻塞
            //c++中这样使用两个锁会出现 卡死 “死锁”状态
            //正常情况下不使用 可重入锁,
           synchronized (locker){
               synchronized ((locker)){
                   System.out.println("hello");
               }
           }
        });
        t.start();
        

    }

2.两个线程,两把锁

两个线程互不相让,A要获取B,B要获取A,导致接下来都无法执行,形成阻塞,出现死锁

 public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(()->{
           synchronized (A){
               try {
                   Thread.sleep(2000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }

               //尝试获取B,没有释放A
               synchronized (B){
                   System.out.println("t2 ");
               }
           }
        });
        Thread t2 = new Thread(()->{
           synchronized (B){
               try {
                   Thread.sleep(2000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }

               //尝试获取A,没有释放B
               synchronized (A){
                   System.out.println("t1 ");
               }
           }
        });

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

改进,约定加锁顺序,先对A加锁,后对B加锁

Thread t1 = new Thread(()->{
           synchronized (A){
               try {
                   Thread.sleep(2000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               synchronized (B){
                   System.out.println("t2 ");
               }
           }
        });
        Thread t2 = new Thread(()->{
            //改进,直接线获取A,破除循环等待
           synchronized (A){
               try {
                   Thread.sleep(2000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               synchronized (B){
                   System.out.println("t1 ");
               }
           }
        });

3.N个线程,M把锁

在解决这里问题之前我们再来了解一下死锁的产生

死锁产生的 四个 必要条件 (缺一不可,只要破坏其中一个便可解除死锁)

1. 互斥使用,获取锁的过程是互斥的。

        一个线程拿到了这把锁,另一个线程也想要获取,就需要阻塞等待

2. 不可抢占,一个线程拿到了锁之后,只能主动解锁,不让别的线程强行把锁抢走

3. 请求保持,一个线程拿到了锁A后,在持有A的前提下,尝试获取B

4. 循环等待 / 环路等待

上述条件1~3点都不方便去破坏,我们通常可以通过  指定加锁顺序  这样的方式破坏代码结构,来破除循环等待。

三、解决线程安全中的“内存可见性”问题--volatile 关键字

volatile 能保证内存可见性

volatile 修饰的变量,能保证 “内存可见性”

代码示例

在这个代码中

  • 创建两个线程t1 和 t2
  • t1中包含一个循环,这个循环以flag == 0为循环条件
  • t2中从键盘读入一个整数,并把这个整数,并把这个整数赋值给flag
  • 预期当用户输入非 0 的值时候,t1 线程结束
private static int flag = 0;
    //volatile 保证内存可见性 禁止指令从排序
    public static void main(String[] args) {

        Thread t1 = new Thread(()->{
            while (flag == 0){

            }
            System.out.println("t1 线程结束");
        });
        Thread t2 = new Thread(()->{
            System.out.println("请输入flag的值:");
            Scanner scanner = new Scanner(System.in);

            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
//执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环不会结束. (这显然是⼀个 bug)

此时t1 读的是自己工作内存中的内容

当 t2 对 flag 变量进行修改,此时 t1 感知不到 flag 的变化

如果给 flag 加上 volatile


 private volatile int flag = 0;

// 执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环能够⽴即结束.

  • 37
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值