• 线程安全(JAVA)


    线程安全对于我们编写多线程代码是非常重要的。

    什么是线程安全?

    在我们平时的代码中有些代码在单线程程序中可以正常执行,但如果同样的代码放在在多个线程中执行就会引发BUG,而这种现象我们一般称为 “线程安全问题”“线程不安全”
    例如:使用两个线程对 count 变量进行自增操作,每个线程10000次。

    private static int count;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });
    
        t1.start();
        t2.start();
    
        t1.join();
        t2.join();
        System.out.println(count);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    在这里插入图片描述
    结果可以看到和我们预期的并不相同,而且当我们多运行几次后,每次的结果还都不相同,这就是一个典型的 线程安全问题
    为什么会出现上述情况呢?

    • 自增操作本质上其实分为三步
      – 从内存把数据读到 CPU
      – 进行加一操作
      – 把新数据写回到 CPU

    • 两个线程是并发执行

    所以就会引发下面这种状况(程序按照时间线从上往下执行):
    在这里插入图片描述
    这里只是简单画了六种,由于线程的调度是无序的所以这里会有无数种情况,但是在这无数种情况中,只有当两个线程的调度每次都满足前两种情况才不会发生BUG。

    引发线程安全的原因

    一般引发线程安全都有以下原因:

    1. 操作系统中线程的调度是随机的(抢占式执行,罪魁祸首)
    2. 多个线程针对同一个变量进行修改
    3. 修改操作不是原子的
    4. 内存可见性问题
    5. 指令重排序问题

    想要解决线程安全问题就需要从上面这几点出发,由于我们上述的代码不涉及4和5所以无需考虑它们,而第一点是系统原因是客观存在的无法更改。

    我们此时有两种解决方法:

    • 将这个代码改为单线程(解决多个线程针对同一个变量进行修改的问题);
    • 让该自增操作变为原子的(解决修改操作不是原子的问题)

    这两种方法都可以解决此代码的线程安全问题,第一种很好实现,那么我们该怎样让这个自增操作变为原子的呢?加锁!

    synchronized 关键字(监视器锁)

    synchronized 关键字是JAVA提供的一种常用的加锁工具。

    注:

    • synchronized关键字在使用时需要搭配()和{};
    • 程序执行进入 { 加锁 离开 } 解锁 ,{} 里面就是被加锁的代码块
    • ()里面用来表示一个加锁的对象(这个对象是啥不重要,它的主要功能就是用来区分多个线程是否在竞争同一个锁)

    互斥性

    如果多个线程对同一个线程尝试进行加锁操作就会产生锁竞争(其中一个线程就会发生阻塞等待),如果是不同对象就不会产生锁竞争,仍然是并发执行。
    我们先随便创建一个Object类型的对象,命名为lock,将count++放入{}中

    private static int count;
    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                synchronized(lock) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 10000; i++) {
                synchronized(lock) {
                    count++;
                }
            }
        });
    
        t1.start();
        t2.start();
    
        t1.join();
        t2.join();
        System.out.println(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

    在这里插入图片描述
    由于我们对count++加了锁所以线程t1和t2就会在执行过程中相互影响。
    当t1线程在执行++操作时,如果t2线程也想执行++操作就会发生阻塞等待,当t1线程执行完++操作出了 } 后会解锁,此时 t2 才会继续向下执行。
    在这里插入图片描述

    此时这个程序的执行顺序就只会是这类正确的类型:
    在这里插入图片描述
    synchronized关键字除了可以修饰代码块之外,还可以修饰实例方法静态方法
    例如:

    //synchronized修饰静态方法
    synchronized public static void countAdd(){
        count++;
    }
    
    • 1
    • 2
    • 3
    • 4

    上述代码就相当于:

    //Test.class是当前类的类对象
    public static void countAdd(){
        synchronized (Test.class) {
            count++;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    //synchronized修饰实例方法
    synchronized public void countAdd(){
        count++;
    }
    
    • 1
    • 2
    • 3
    • 4

    上述代码就相当于:

    public static void countAdd(){
        synchronized (this) {
            count++;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    synchronized所使用的锁存在于对象头中。

    • 对象头:JAVA中一个对象所对应的存储空间中除了你自己定义的一些属性外,还存在一些自带的属性,而这些属性就是对象头。(对象头中就存在一个属性用来描述该对象是否加锁)

    可重入性

    在实际开发中可能会存在如下的代码

    synchronized(lock) {//1
    	//此处执行一些工作……
        synchronized(lock) {//2
    	//此处执行一些工作……
        }//3
    }//4
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    当我们的代码执行到 1 时假设此时可以获取到该锁,可是当执行到 2 时因为 lock 此时已经处于处于加锁状态了,所以理论上此时应该进入阻塞等待状态。
    但是此时代码就会出现死锁(此时 2 处想要获取锁继续向下执行,就需要 1 处将锁释放,可是 1 处释放锁就需要执行到 4 处)。
    所以为了解决上述问题synchronized被设计成了可重入锁,即在加锁时让该对象记录下当前是哪个线程获取的锁,后续如果该线程再次对该对象进行加锁就直接加锁成功。
    锁对象中不光记录了获取锁的对象,还利用计数器记录了该线程获取该锁的次数。以上述代码为例当代码执行到1和2时计数器分别加一,执行到3和4时计数器分别减一,但执行完4计数器刚好减到零此时才会真正的将锁释放。

  • 相关阅读:
    网页自动跳转到其他页面,点击浏览器返回箭头,回不到原来页面的问题
    java毕业设计论文题目基于Lucene全文检索框架实现的SSM博客管理系统
    Android Framework学习之Activity启动原理
    【网络工程】7、实操-万达酒店综合项目(一)
    2021年全国研究生数学建模竞赛华为杯C题帕金森病的脑深部电刺激治疗建模研究求解全过程文档及程序
    面试算法30:插入、删除和随机访问都是O(1)的容器
    不定积分第一类换元法(凑微分法)
    信息系统项目管理师第四版学习笔记——项目绩效域
    手机怎么把几个PDF文件合并到一起?教你一分钟搞定
    【吐血整理】2022年Java 基础高频面试题及答案(收藏)
  • 原文地址:https://blog.csdn.net/2302_76339343/article/details/134277877