【JavaEE 初阶(二)】线程安全问题

【JavaEE 初阶(二)】线程安全问题

码农世界 2024-06-04 后端 98 次浏览 0个评论

❣博主主页: 33的博客❣

▶️文章专栏分类:JavaEE◀️

🚚我的代码仓库: 33的代码仓库🚚

🫵🫵🫵关注我带你了解更多线程知识

【JavaEE 初阶(二)】线程安全问题

目录

  • 1.前言
  • 2.synchronized
    • 2.1例子
    • 2.2synchronized修饰代码块
    • 2.3 synchronized修饰方法
    • 2.4synchronized特性
    • 3.死锁
      • 3.1死锁的成因
      • 3.2解决死锁
      • 4.volatile
        • 4.1内存可见性问题
        • 4.2volatile解决
        • 5.wait与notify
          • 5.1wait
          • 5.2notify
          • 6.总结

            1.前言

            在上一篇文章中,我们已经初步认识了线程的一些知识,但线程中一个重要问题就是线程安全问题,这篇文章我们就来了解为什么会引起线程安全问题,已经解决方法,有些代码在单个线程中执行是完全正常的,不会出现bug,但同样的代码,让多个线程,同一时间执行那么就可能出现bug,这就称为线程安全问题。


            2.synchronized

            2.1例子

            例:我们让两个线程同时执行cou++操作,各自增5w,预期结构应该为10w,我们通过代码来观察是否符合预期结果。

            public class Demo11 {
                public static int count=0;
                public static void main(String[] args) throws InterruptedException {
                    Object lock=new Object();
                    //Object lock2=new Object();
                    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);
                }
            }
            

            观察结果:

            【JavaEE 初阶(二)】线程安全问题

            我们多次运行发现发现每次count的结尾都不等于10w,并且每次都不同。

            出现这样的原因是为什么呢?其实是因为在执行cou++操作的时候有3步操作

            load 把数据从内存读到cpu中

            add 把寄存器+1

            save 把寄存器中的数据保存到内存中

            那么两个进程同时执行就会出现多种方式:

            【JavaEE 初阶(二)】线程安全问题

            博主列出的只是部分执行情况,实际情况还有更多

            在正确情况下:

            【JavaEE 初阶(二)】线程安全问题

            错误情况下:

            【JavaEE 初阶(二)】线程安全问题

            我们就可以知道线程安全的原因:

            1.操作系统种线程的调度是随机的

            2.两个线程对于同一个变量进行修改

            3.修改操作不是原子性的

            4.内存可见性问题

            5.指令重排序问题

            如果要想解决线程安全问题,就可以使修改操作变为原子性的,那么怎么变为原子性的呢?加锁操作。

            最常见的加锁方法就是synchronized关键字。

            2.2synchronized修饰代码块

            在使用synchronized时,要搭配一个代码块{}进入{就会加锁,出了}就会解锁,我们用代码进行实现。

            【JavaEE 初阶(二)】线程安全问题

            我们发现synchronized()报错,是因为()中需要表示一个用来加锁的对象,这两个对象是啥并不重要,重要的是通过这个对象来区分两个线程是否在竞争同一个锁,如果两个线程在针对同一个对象加锁就会出现锁竞争,那么由于锁的竞争,只有等一个线程解锁后,另一个线程才能再进行count++操作。

            public class Demo11 {
                public static int count=0;
                public static void main(String[] args) throws InterruptedException {
                    Object lock=new Object();
                    //Object lock2=new Object();
                    Thread t1=new Thread(()->{
                        for (int i=0;i<50000;i++){
                            synchronized (lock){
                                count++;
                            }
                        }
                    });
                    Thread t2=new Thread(()->{
                        for (int i=0;i<50000;i++){
                         synchronized (lock){
                                count++;
                         }
                        }
                    });
                    t1.start();
                    t2.start();
                    t1.join();
                    t2.join();
                    System.out.println("count="+count);
                }
            }
            

            2.3 synchronized修饰方法

            synchronized除了修饰代码块以外还可以修饰方法

            class cunter{
                int count;
                public synchronized void count (){
                    //相当于synchronized(this){}
                    count++;
                }
            //修饰静态方法
            //    public static synchronized void count2 (){
                 //相当于synchronized(cunter.class){}
            //        count++;
            //    }
            }
            public class Demo12 {
                public static void main(String[] args) throws InterruptedException {
                    cunter counter=new cunter();
                    Thread t1=new Thread(()->{
                        for (int i=0;i<50000;i++){
                            counter.count();
                        }
                    });
                    Thread t2=new Thread(()->{
                        for (int i=0;i<50000;i++){
                            counter.count();
                        }
                    });
                    t1.start();
                    t2.start();
                    t1.join();
                    t2.join();
                    System.out.println("count="+counter.count);
                }
            }
            

            synchronized用的锁存在java对象头里面的,在一个java对象中,除了自己定义的属性和方法,还有一些自带的属性,这些自带的属性就称为对象头,其中就有属性表示当前对象是否加锁。

            2.4synchronized特性

            synchronized特性

            1.互斥:某一个线程a如果执行某个某个对象的加锁操作时,如果其他线程也想给同一个对象加锁,那么就要 等a执行完成b才能实现加锁操作。

            2.可重入:1个线程中,synchronized 代码块中针对同一把锁加锁多次,不会出现“死锁”问题。

            public class Demo15 {
                public static void main(String[] args) {
                    Object lock=new Object();
                    Thread t1=new Thread(()->{
                        synchronized (lock){
                            synchronized (lock){
                                System.out.println("t1");
                            }
                        }//(1)
                    });//(2)
                    t1.start();
                }
            }
            

            上诉代码,在t线程如果第一次的lock加锁成功,又遇到了一个lock操作,但只有等第一次}(2)de的位置解锁才能执行加锁操作,可是如果使}(2)执行完,就需要先执行加锁操作,这样就导致代码一直注释,没有办法释放锁。所以就把synchronized设置为了“可重入锁”就解决了上述问题。

            但此时又有了新的问题,如果在一个线程中,对一把锁多次加锁,那么在什么时候才释放锁呢?

            public class Demo15 {
                public static void main(String[] args) {
                    Object lock=new Object();
                    Thread t1=new Thread(()->{
                        synchronized (lock){
                            synchronized (lock){
                             synchronized (lock){
                               synchronized (lock){
                           		synchronized (lock){
                           
                                }
                              }
                            }
                          }
                        }
                    });
                    t1.start();
                }
            }
            

            要在这个线程的最外层才能释放锁,在锁对象中,不仅会记录是谁拿到了锁,还会记录加锁了多少次,每加锁一次,计数器++,解锁一次,计数器–,直到最后一个大括号结束。

            3.死锁

            在上述代码中,我们已经提到过死锁了,在1个线程中,针对一把锁连续加锁两次,如果是不可重入,就会出现死锁了。

            如果是两个线程,两把锁(无论是不是可重入,都会死锁)

            例如:(1)t1获取锁A,t2获取锁B (2)t1获取锁B,t2获取锁A

            public class Demo13 {
                public static void main(String[] args) {
                    Object lock1=new Object();
                    Object lock2=new Object();
                    Thread t1=new Thread(()->{
                        synchronized (lock1){
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            synchronized (lock2){
                                System.out.println("t1线程");
                            }
                        }
                    });
                    Thread t2=new Thread(()->{
                        synchronized (lock2){
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            synchronized (lock1){
                                System.out.println("t2线程");
                            }
                        }
                    });
                    t1.start();
                    t2.start();
                }
            }
            

            在t1线程,lock1锁中在等待lock2锁解锁,但此时lock2也在等待lock1解锁,此时会会两个线程一直僵持下去。

            N个线程M把锁:哲学家问题

            有5个哲学家坐在一起吃饭,但只有5根筷子,哲学家就只做两件事情,一件事情为思考,另一件事情就是吃饭,当其中一个哲学家要吃饭时,就会拿起左右两边的筷子,那么此时如果左右相邻的哲学家也想吃饭时,就需要等待正在吃饭的哲学家吃完饭,放下筷子,才能继续吃,在通常情况下,整个系统可以很好的运转,但是当5个哲学家同时拿起左边的筷子时,就会出现死锁问题.

            【JavaEE 初阶(二)】线程安全问题

            死锁是一种严重的bug那么该如何解决死锁问题呢?我们就需要先了解死锁的成因。

            3.1死锁的成因

            1.互斥使用(锁的基本特性):当一个线程有一把锁时,另一个线程也想获取同一把锁就要阻塞等待。

            2.不可抢占(锁的基本特性):当线程a拿到锁时,只有等线程a解除锁,线程b才能再使用。

            3.请求保持:一个线程尝试获取多把锁

            4.循环等待:等待的依赖关系形成了环。

            3.2解决死锁

            互斥和不可抢占性都是锁的基本特性,我们可以通过代码的结果来来避免写成“嵌套锁”但这个方案不一定好使,有的需求可能就是需要进行这种嵌套操作,所以我们最好

            针对循环来解决,可以约定加锁条件避免形成循环等待,针对锁,约定加多把锁的时候,现加编号小的锁,再加编号大的锁并且所有线程都要遵守这一规则。

            public class Demo13 {
                public static void main(String[] args) {
                    Object lock1=new Object();
                    Object lock2=new Object();
                    Thread t1=new Thread(()->{
                        synchronized (lock1){
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            synchronized (lock2){
                                System.out.println("t1线程");
                            }
                        }
                    });
                    Thread t2=new Thread(()->{
                        synchronized (lock1){
                            try {
                                Thread.sleep(1000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            synchronized (lock2){
                                System.out.println("t2线程");
                            }
                        }
                    });
                    t1.start();
                    t2.start();
                }
            }
            

            4.volatile

            4.1内存可见性问题

            计算机运行程序,经常要访问数据,这些数据往往存储在内存中,cpu使用这些变量的时候,要先从内存中读取数据,再对数据进行操作,cpu读取内存相对来说是非常慢的,cpu执行大部分操作都是非常快的,但一旦涉及到读取内存操作,就非常慢。为了解决上诉问题,此时编译器就可能对代码进行优化,把一些本来要读取内存的操作优化为读取寄存器,减少内存的读取次数就大大提高了程序的效率。

            例:

            public class Demo14 {
                public static int isQuit=0;
                public static void main(String[] args) throws InterruptedException {
                    Thread t1=new Thread(()->{
                        while (isQuit==0){
                        }
                        System.out.println("t1进程结束");
                    });
                    t1.start();
                    Thread.sleep(1000);
                    System.out.println("请输入isQuit");
                    Scanner scanner=new Scanner(System.in);
                    isQuit= scanner.nextInt();
                }
            }
            

            预期效果是当过了1s中后,输入isQuit为1,应该结束循环,输出t1进程结束。

            我们来看一看实际结果:

            【JavaEE 初阶(二)】线程安全问题

            很明显,实际结果和预期结果不一样,之前是两个进程修改同一个变量引起的bug,但现在是一个线程修改,另一个线程读,同样也引起了bug,是什么原因呢?

            在t1线程中读取isQuit的值到寄存器中,通过cmp指令比较寄存器的值是否为0,由于这个循环执行的飞快,就需要多次从内存中load,再cmp,此时编译器就发现虽然进行了这么多次load但是load出来的结果没有任何变化,所以编译器就做了一个大胆的决定!只是第一次寻黄的时候读取内存,此后直接从寄存器中读取isQuit的值。它的初心虽然是好的,但是我此后如果修改了isQuit的值,但t1寄存器读取的仍然是isQuit修改前的值就出现了bug。这个问题就称为“内存可见性”问题。

            4.2volatile解决

            在多线程环境下,编译器对是否要进行优化的判定不一定准就需要通过volatile关键字告诉编译器我不需要优化!!!!

            import java.util.Scanner;
            public class Demo14 {
                 public static volatile  int isQuit=0;
                public static void main(String[] args) throws InterruptedException {
                    Thread t1=new Thread(()->{
                        while (isQuit==0){
                        }
                        System.out.println("t1进程结束");
                    });
                    t1.start();
                    Thread.sleep(1000);
                    System.out.println("请输入isQuit");
                    Scanner scanner=new Scanner(System.in);
                    isQuit= scanner.nextInt();
                }
            }
            

            此时就可以结束线程1了:

            【JavaEE 初阶(二)】线程安全问题

            5.wait与notify

            5.1wait

            wait是Object的一个方法,wait是使进程变为阻塞状态。

            我们通过代码来进行演示:

            public class Demo20 {
                public static void main(String[] args) throws InterruptedException {
                    Object object=new Object();
                    System.out.println("wait之前");
                    object.wait();
                    System.out.println("wait之后");
                    
                }
            }
            

            我们发现运行时依然有错:非法监视器状态异常,监视器就是指的sychronized。

            【JavaEE 初阶(二)】线程安全问题

            wait在执行的时候只做三件事情:

            1.释放锁资源

            2.让线程进入阻塞状态

            3.当线程被唤醒重新获取锁

            对代码进行修改:

            public class Demo20 {
                public static void main(String[] args) throws InterruptedException {
                    Object object=new Object();
                    System.out.println("wait之前");
                    synchronized (object){
                        object.wait();
                    }
                    System.out.println("wait之后");
                }
            }
            

            【JavaEE 初阶(二)】线程安全问题

            5.2notify

            这时我们会发现wait会一直持续等待,知道有其他线程调用notify唤醒它。

            notify是一次唤醒一个进程,而notifyAll是一次唤醒所有进程。

            public class Demo16 {
                public static void main(String[] args) throws InterruptedException {
                    Object object=new Object();
                    Thread t=new Thread(()->{
                        try {
                            Thread.sleep(3000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        synchronized (object){
                            System.out.println("进行通知");
                            object.notify();
                        }
                    });
                    t.start();
                    System.out.println("wait之前");
                    synchronized (object){
                        object.wait();
                    }
                    System.out.println("wait之后");
                }
            }
            

            【JavaEE 初阶(二)】线程安全问题

            wait除了默认的无参版本,还有一个带参的版本,但参版本就是指定超时时间避免无休止等待。

            6.总结

            本篇文章主要介绍了sychronized加锁操作,死锁的成因,死锁的解决,内存可见性问题以及内存可见的解决方案,最后介绍了wait和notify的运用。

            下期预告:多线程代码案例

转载请注明来自码农世界,本文标题:《【JavaEE 初阶(二)】线程安全问题》

百度分享代码,如果开启HTTPS请参考李洋个人博客
每一天,每一秒,你所做的决定都会改变你的人生!

发表评论

快捷回复:

评论列表 (暂无评论,98人围观)参与讨论

还没有评论,来说两句吧...

Top