• 【JavaEE】多线程(四)


    多线程(四)

    在开始讲之前,我们先来回顾回顾前三篇所讲过的内容~

    1. 线程的概念

      并发编程,多进程,比较重,频繁创建销毁,开销大

    2. Thread的使用

      1. 创建线程
        1. 继承Thread
        2. 实现Runnable
        3. 继承Thread(匿名内部类)
        4. 实现Runnable(匿名内部类)
        5. 使用lambda'
      2. Thread中的重要性
      3. 启动线程start
      4. 终止线程isInterrupted() interrupt()=>本质上是让线程快点执行完入口方法
      5. 等待线程join a.join()让调用这个方法的线程等待a线程的结束
      6. 获取线程引用
      7. 休眠线程
    3. 线程状态(方便快速判定当前程序执行的情况)

      1. NEW
      2. TERMINATED
      3. RUNNABLE
      4. TIMED_WAITING
      5. WAITING
      6. BLOCKED
    4. 线程安全

      1. 演示线程不安全的例子:两个线程自增5w次

      2. 原因:

        • 操作系统对于线程的调度是随机的
        • 多个线程同时修改同一个量
        • 修改操作不是原子性的
        • 内存可见性
        • 指令重排序
      3. 解决:加锁 => synchronized

        synchronized修饰的是一个代码块

        同时指定一个锁对象

        进入代码块的时候,对该对象进行加锁

        出了代码块的时候,对该对象进行解锁


        锁对象

        • 锁对象到底用哪个对象是无所谓的,对象是谁不重要;重要的是两线程加锁的对象是否是同一个对象

        • 这里的意义/规则,有且只有一个

          当两个线程同时尝试对一个对象加锁,此时就会出现“锁冲突”/“锁竞争”,一旦竞争出现,一个线程能够拿到锁,继续执行代码;一个线程拿不到锁,就只能阻塞等待,等待前一个线程释放锁之后,他才有机会拿到锁,继续执行~

        • 这样的规则,本质上就是把“并发执行” => “串行执行”,这样就不会出现“穿插”的情况了。


    synchronized 关键字

    互斥

    续上文最后,synchronized除了修饰代码块之外,还可以修饰一个实例方法,或者一个静态方法

    class Counter{
        public int count;
    
        synchronized public void increase(){
            count++;
        }
    
        public void increase2(){
            synchronized (this) {
                count++;
            }
        }
        synchronized public static void increase3(){
    
        }
    
        public static void increase4(){
            synchronized (Counter.class){
    
            }
        }
    }
    // synchtonized 使用方法
    public class Demo14 {
        public static void main(String[] args) throws InterruptedException {
            Counter counter = new Counter();
    
            Thread t1 = new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            });
    
            Thread t2 = new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(counter.count);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传


    synchronized用的锁是存在Java对象头里的。

    何为对象头呢?

    Java的一个对象,对应的内存空间中,除了你自己定义的一些属性之外,还有一些自带的属性

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    在对象头中,其中就会有属性表示当前对象是否已经加锁了


    刷新内存

    synchronized的工作过程:

    1. 获得互斥锁

    2. 从主内存拷贝变量的最新副本到工作的内存

    3. 执行代码

    4. 将更改后的共享变量的值刷新到主内存

    5. 释放互斥锁

    但是目前刷新内存这一块知识各种说法都有,目前也难以通过实例验证,pass~


    可重入

    synchronized:重要的特性,可重入的

    所谓的可重入锁,指的就是,一个线程连续针对一把锁,加锁两次,不会出现死锁。满足这个需求就是“可重入锁”,反之就是“不可重入锁”。

    下面见图:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    上述的现象,很明显就是一个bug,但是我们在日常开发中,又难以避免出现上述的代码~例如下面这样的案例:

    public class Demo15 {
        private static Object locker = new Object();
    
        public static void func1(){
            synchronized (locker){
                func2();
            }
        }
    
        public  static void func2(){
            func3();
        }
    
        public static void func3(){
            func4();
        }
    
        public static void func4(){
            synchronized (locker){
    
            }
        }
    
        public static void main(String[] args) {
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    要解决死锁问题,我们可以将synchronized设计成可重入锁,就可以有效解决上述的死锁问题~

    就是让锁记录一下,是哪个线程给它锁住的,后续再加锁的时候,如果加锁线程就是持有锁的线程,就直接加锁成功~

    用一个例子来理解:

    你向一个哥们表白,我爱你,成功了,他接受你了,也就是你对他加锁成功了,同时他也会记得你就是她的男朋友~

    过了几天,你又对他说,宝贝我爱你,这时候的那个哥们当然也不会拒绝,反而会更加基情~

    不过要是换成别人,结果肯定就是不一样的(排除绿你的情况~)


    这里提出个问题:

    synchronized(locker){
      synchronized(locker){
      ........................
      }}
    • 1
    • 2
    • 3
    • 4
    • 5
    1. 在上述代码中,synchronized是可重入锁,没有因为第二次加锁而死锁,但是当代码执行到 }②,此时锁是否应该释放?

    **不能!!!**因为如果释放了锁,很可能就会导致②和①之间的一些代码逻辑无法执行,也就起不到锁保护代码的作用了~

    1. 进一步,如果上述的锁有n层,释放时机该怎么判断?

    无论此处有多少层,都是要在最外层才能释放锁~~
    引用计数
    锁对象中,不光要记录谁拿到了锁,还要记录,锁被加了几次
    每加锁一次,计数器就+1.
    每解锁一次,计数器就·1.
    出了最后一个大括号,恰好就是减成0了,才真正释放锁


    死锁

    那么上面我们讲解了死锁的一种情况,一个线程针对一把锁,加锁两次。

    接下来下面我们继续介绍死锁的情况~

    1. 一个线程针对一把锁,加锁两次,如果是不可重入锁,就会死锁~

      synchronized不会出现,但是隔壁C++的std::mutex就是不可重入锁,就会出现死锁)

    2. 两个线程(t1、t2),两把锁(A、B)(此时无论是不是不可重入锁,都会死锁)

      举个例子:钥匙锁车里,车钥匙锁家里~

      1. t1获取锁A,t2获取锁B
      2. t1尝试获取B,t2尝试获取A

      实例代码

      // 死锁
      public class Demo16 {
          private static Object locker1 = new Object();
          private static Object locker2 = new Object();
      
      //此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
          public static void main(String[] args) {
           Thread t1 = new Thread(()->{
               synchronized (locker1){
                   try {
                       Thread.sleep(1000);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
      
                   synchronized (locker2){
                       System.out.println("t1 加锁成功");
                   }
               }
      
           });
           Thread t2 = new Thread(()->{
               synchronized (locker2){
                   try {
                       Thread.sleep(1000);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
                   synchronized (locker1){
                       System.out.println("t2 加锁成功");
                   }
               }
           });
           t1.start();
           t2.start();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37

      外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

      死锁现象出现

      我们可以在jconsole.exe中看看线程情况~

      外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

      同时也要注意,死锁代码中
      两个synchronized嵌套关系,不是并列关系.
      嵌套关系说明:是在占用一把锁的前提下,获取另一把锁.(则是可能出现死锁)
      并列关系,则是先释放前面的锁,再获取下一把锁.(不会死锁的)

    // 死锁 -> 破嵌套
    public class Demo17 {
        private static Object locker1 = new Object();
        private static Object locker2 = new Object();
    
        //此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
        public static void main(String[] args) {
            Thread t1 = new Thread(()->{
                synchronized (locker1){
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                    synchronized (locker2){
                        System.out.println("t1 加锁成功");
                    }
            });
            Thread t2 = new Thread(()->{
                synchronized (locker2) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                    synchronized (locker1){
                        System.out.println("t2 加锁成功");
                    }
    
            });
            t1.start();
            t2.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    // 破死锁
    public class Demo18 {
        private static Object locker1 = new Object();
        private static Object locker2 = new Object();
    
        //此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
        public static void main(String[] args) {
            Thread t1 = new Thread(()->{
                synchronized (locker1){
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                synchronized (locker2){
                    System.out.println("t1 加锁成功");
                }
            });
            Thread t2 = new Thread(()->{
                synchronized (locker1) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                synchronized (locker2){
                    System.out.println("t2 加锁成功");
                }
    
            });
            t1.start();
            t2.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    第一段代码中使用的是同一个对象作为锁,在t1和t2线程中都使用了locker对象作为锁。这样的话,当t1线程获取到锁并休眠时,t2线程就无法获取到锁,导致t2线程一直等待,从而可能引发死锁。

    第二段代码中使用了两个不同的对象作为锁,分别是locker1locker2。在t1线程中先获取locker1锁,再获取locker2锁;在t2线程中先获取locker2锁,再获取locker1锁。这样的话,两个线程在互斥的同时也保持了顺序,避免了死锁的发生。

    1. N个线程,M把锁(相当于2的扩充)

      此时这个情况,更加容易出现死锁了。

      下面给出一个经典例子:哲学家就餐问题

      外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

      死锁,是属于比较严重的bug,会直接导致线程卡住,也就无法执行后续的工作了~

      那么我们应该怎么避免死锁?

    死锁的成因

    那么首先我们要了解死锁的成因:

    1. 互斥使用。(锁的基本特性)

      当线程持有一把锁之后,另一个线程也想获取到锁,那么就需要阻塞等待、

    2. 不可抢占。(锁的基本特性)

      当锁已经被 线程 1 拿到之后,线程 2 只能等 线程 1 主动释放,不可以强行抢过来

    3. 请求保持。(代码结构)

      一个线程尝试获取多把锁。(先拿到 锁1 之后,再尝试获取 锁2 ,获取的时候, 锁1 不会被释放)

      这种也就是典型的吃着碗里的,看着锅里的

      public class Demo16 {
          private static Object locker1 = new Object();
          private static Object locker2 = new Object();
      
      //此处的sleep很重要,要确保 t1 和 t2 都分别拿到一把锁之后,再进行后续动作
          public static void main(String[] args) {
           Thread t1 = new Thread(()->{
               synchronized (locker1){
                   try {
                       Thread.sleep(1000);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
      
                   synchronized (locker2){
                       System.out.println("t1 加锁成功");
                   }
               }
      
           });
           Thread t2 = new Thread(()->{
               synchronized (locker2){
                   try {
                       Thread.sleep(1000);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
                   synchronized (locker1){
                       System.out.println("t2 加锁成功");
                   }
               }
           });
           t1.start();
           t2.start();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
    4. 循环等待 / 环路等待(代码结构)

      等待的依赖关系,形成环了~

      也即是上面那个例子,钥匙锁车里,车钥匙锁家里

    实际上,要想出现死锁,也不是个容易事情
    因为得把上面4条都占了.
    (不幸的是,1和2是锁本身的特性,只要代码中,把3和4占了,死锁就容易出现了)

    所以说,解决死锁,核心就是破坏上述必要条件,死锁就形成不了~

    针对上述的四种成因,1 2是破坏不了的,因为synchronized自带特性,我们是无法干预 滴~

    对于3来说,就是调整代码结构,避免编写“锁嵌套”逻辑

    对于4来说,可以约定加锁的顺序,就可以避免循环等待


    所以针对上面的哲学家就餐问题,我们可以采取:针对锁进行编号

    比如说约定,加多一把锁的时候,先加编号小的锁,后加编号大的锁(所有线程都要遵守这个规则)

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    这样的话,循环等待就会被解除,死锁也不会出现了~

    回到上述我们讲的synchronized关键字
    在使用规则上,并不复杂,只要抓住一个原则:两个线程针对同一个对象加锁,就会产生锁竞争.
    但是在底层原理上,synchronized还有不少值得讨论的地方.接下来会展开讲讲~


    至此,多线程(四)讲解到这,接下来会持续更新,敬请期待~

  • 相关阅读:
    用Leangoo领歌免费敏捷工具做敏捷需求管理
    04【Spring声明式事、传播行为、AOP事务控制】
    win11 U盘制作
    Springboot 项目中实现文件上传(封装成上传工具模块)
    【UDS】ISO14229之0x3E服务
    【Pytorch】torch. bmm()
    C#通过FTP与异构系统实现业务接口
    【数据结构】二叉树
    数学建模之多项式回归
    【网络编程】高并发服务器|网络套接字函数|TCP服务器函数-大端小端
  • 原文地址:https://blog.csdn.net/m0_73075027/article/details/133090569