首页 > 基础资料 博客日记

【Java】线程安全问题

2024-04-09 08:00:05基础资料围观234

文章【Java】线程安全问题分享给大家,欢迎收藏Java资料网,专注分享技术知识


在之前的文章中,已经介绍了关于线程的基础知识。

我的主页: 🍆🍆🍆爱吃南瓜的北瓜
欢迎各位大佬来到我的主页进行指点
一同进步!!!

🍇一、观察如下代码

我们创建两个线程t1和t2,对静态变量count执行++操作各50000次。
我们的预期结果是100000。但是当两个线程分别执行++操作时最后的结果是否为100000呢?

看这样一段代码

 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();
        System.out.println(count);
    }
运行三次 结果如下



上述结果和预期差距很大
我们将线程写作串行执行

运行结果如下

第一次的代码运行没有达到我们的预期结果,是因为两个线程同时操作一个共享变量,涉及到线程安全问题。

🍇二、线程安全概念

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象时线程安全的。

🍇三、线程不安全的原因

  1. 线程在系统中是随机调度的,是抢占式执行的

这是系统决定的

  1. 多个线程对同一个变量进行修改

如果没有抢占式执行,一个线程接着一个线程逐个完成自己的任务,那么也就不会担心这样的情况

  1. 线程针对变量的操作,不是“原子”操作

原子操作:是指不可再细分的操作
而文章开头举的例子中n++这一操作,在系统中其实被细分成三步:
第一步:把内存中count的值加载到CPU寄存器中
第二步:把寄存器中的值+1,还是继续保存在寄存器中
第三步:把寄存器的值写回到内存中的count。

  1. 内存的可见性问题,引起的线程不安全
  2. 指令重排序引起的线程不安全

如下是对文章开头的案例做出的解释

🍇四、解决线程不安全

线程的随机调度这是系统决定的。无法干预
通过修改代码结构,来避免多个线程对同一个变量的修改。
解决原因三,我们就引入锁的概念

1.锁synchronizatied

 锁本质上是操作系统提供的功能,由JVM封装提供api供我们使用。

锁后面带上()
()里面写的就是“锁对象”
锁对象的用途,有且只有一个,那就是用来区分,两个线程是否针对同一个对象加锁。
如果是,就会出现锁竞争/锁冲突/互斥,就会引起阻塞等待。
如果不是,就不会出现锁竞争,也就不会阻塞等待。

至于这个对象是什么类型,没有关系。

在Java中,synchronized进入{ 就自动上锁,出 } 就自动解锁
免去了加锁解锁

锁的特性
1) 互斥

synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 
其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待.

• 进⼊ synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁
2)可重⼊
synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题;
可重入实例

 public static int count = 0;
    public static void main(String[] args) {
        Object locker = new Object();
        Thread thread = new Thread(()->{
           synchronized (locker){
               synchronized(locker){
                   for (int i = 0; i < 50000; i++) {
                       count++;
                   }
               }
           }
        });
    }

在可重⼊锁的内部, 包含了 “线程持有者”“计数器” 两个信息.

  • 如果某个线程加锁的时候, 发现锁已经被⼈占用, 但是恰好占⽤的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

synchronized
修饰普通方法,相当于对this加锁
修饰静态方法,相当于对类加锁

死锁

如下就是一段典型的死锁代码

public class main2 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object lock1 = new Object();
        Object lock2 = new Object();

        Thread t1 = new Thread(()->{
            synchronized (lock1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (lock2){
                    for (int i = 0; i < 5000; i++) {
                        count++;
                    }
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (lock2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (lock1){
                    for (int i = 0; i < 5000; i++) {
                        count++;
                    }
                }
            }
        });

        t2.start();
        t1.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

线程t1拿到了锁lock1,并上锁,进入sleep等待
此时线程t2拿到了CPU的执行权,拿到了锁lock2,并上锁,进入sleep等待
t1又拿到CPU的执行权,此时想要拿到锁lock2,但是此时锁lock2被线程t2占用着,
此时线程t1拿不到锁lock2,不能	进行之后的操作,想要跳出这个代码块,
但是想要开启锁lock1,就必须拿到锁lock2,此时就进入了死锁的状态。

产生死锁的必要条件

  1. 锁具有互斥特性
    这是锁的基本特点,一个线程拿到锁之后,其他线程就只能阻塞等待。
  2. 锁不可抢占(不可被剥夺)
    一个线程拿到锁之后,除非它自己释放锁,否则别人抢不走
  3. 请求和保持
    一个线程拿到一把锁之后,不释放这个锁的前提下,在尝试获取其他锁
  4. 循环等待
    多个线程获取锁的过程中,出现了循环等待,A等待B,B又等待A。

以上四点缺一不可,缺少一个都构成不了死锁

如何避免死锁

第一点和第二点是synchronized的基本特性,这是更改不了的。

所以只能从第三点第四点开始入手

第三点的解决方案是

在书写代码时,尽量避免锁的循环嵌套,可以有效避免死锁的发生

第四点的解决方案是

约定好加锁的书顺序让所有的线程都按照规定的顺序来加锁

2.内存可见性问题‘

观察如下代码

public class main3 {
   static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1  = new Thread(()->{
            while (count == 0){
                  //循环体中什么都没有
            }
            System.out.println("线程t1结束");
        });
       Thread t2 = new Thread(()->{
           Scanner scanner = new Scanner(System.in);
           System.out.println("请输入整数count");
           count = scanner.nextInt();
       });

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

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


    }

}

运行如下

我们预期看到的应该是输入1后,程序结束。
但结果与预期并不相同,这是为什么呢?

问题主要出在循环体这里


我们可以看到循环体内什么都没有
站在指令的角度来刨析问题
1) load操作,从内存中将count的值读取到CPU寄存器中。
2)cmp操作 (比较 同时跳转)
条件成立,继续顺序执行
条件不成立,跳转到另一个地址来执行。

由于当前循环体是空的,循环体旋转速度很快
短时间内出现大量的load和cmp操作反复执行的效果
load执行消耗的时间会比cmp多很多(相差几个数量级)
这时JVM发现,load速度非常慢且每次load执行的结果都是一样的(t2未更改时)此时,JVM就会把load操作给优化掉,当然这是JVM的一个优化的bug

那么如何解决呢?

在循环内书写IO操作或阻塞操作(sleep),就会使旋转体的速度降低了。
 while (count == 0){
                System.out.println("hello t1");
            }

IO操作使循环速度减低
如果循环操作中存在IO操作,就没有优化load操作的必要了
IO操作不能被优化!!!

这就是内存可见性问题’

一个线程针对一个变量进行读操作,另一个线程针对这个变量进行修改,此时读的线程,不一定能感知到这个变量被改了

解决方案

引入volatile关键字
这个关键字从字面意思上理解是 “易变的,不稳定的”,如果给变量加上这个关键字,仿佛在告诉 JVM/编译器,这个变量很不稳定,极有可能发生变化,从而不让编译器优化!

格式如下

volatile static int count = 0;

但是volatile不能保证原子性
实例如下

public class main4 {
    public volatile static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }

    //第一次执行9979
    //第二次执行9190
    //第三次执行9715
}

把光标放置到count++这条语句上,可以发现如下报错。
对易变字段 ‘count’ 的非原子操作

对 count 这个易变字段执行了一个非原子操作,这可能会导致在多线程环境下数据的不一致或不可预测的行为。在实际编程中,如果你需要对某个字段进行复合操作(比如先读后写或先比较后更新),并且这个操作需要在多线程环境中是安全的,那么仅仅使用 volatile 是不够的,你还需要使用其他同步机制(如 synchronized 块或 java.util.concurrent.atomic 包中的原子类)来确保操作的原子性。

小结
使用synchronized可以保证原子性
使用volatile可以保证内存可见性
如果后面写代码的时候,既要考虑原子性,又要考虑内存可见性,直接把 synchronized 和 volatile 都加上即可。

以上就是本文所有内容,如果对你有帮助的话,点赞收藏支持一下吧!💞💞💞


文章来源:https://blog.csdn.net/2202_75795446/article/details/137358334
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云