重新认识多线程编程06 线程安全

注:没有写05 先写了06  05的练习还没准备好

一、什么是线程安全?

不知道大家有没有思考过这个问题,这里有一个定义可以比较合理的描述线程安全性问题

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就可以说这个类是线程安全的。

这里的访问 应该是以任何形式 调用这个类的公共域或公共行为都可以表现出正确的行为

这里解释一下对象的域 对象的域是指 对象拥有的属性 或者说成员变量,而公共域就是 public 修饰的成员变量。

这里我们要深刻理解面向对象编程的重要性,它约束了一个类的结构,而类是对象的模版,对象由这个模版创造,因为类是对属性和行为的封装,我们可以更好的通过面向对象的思想去判断这个类的线程安全性。

二、如何找到类是否需要处理线程安全

面对这个问题,我们首先需要理解一个概念,就是对象的状态,什么是对象的状态。

对象的状态是指,存储在状态变量中的数据

怎么理解呢?  比如说 一个类的实例中存放的数据,也可以说对象中存放的数据就是 对象的状态,那么对象拥有什么状态是我们比较关心的呢,大致分为两种一种状态是共享的而另一种是可变的。

打个比方 我们拥有一个狗狗类,它拥有一个属性

class dog{

    private String name;

}

我们明确创建一个狗狗对象 也就是实例 并且给它设置一个名字

Dog dog = new Dog();

dog.name = "小金毛";

接下来我们分析 它的状态可能是什么样的

首先 对于这个dog对象的状态 可以理解为 dog的名字 小金毛

那么这个状态 可以是什么呢  可能是 独有的  也可能是共享的 也可能是可变化的

我们在创建这个类的过程中,就要考虑一件事,对象的状态是什么样的? 如果这个对象可能被共享,或者它可以被变化,我们就要考虑,这个对象是否会存在线程安全性问题

打个比方

这条小狗 拥有两个主人

class Master{

   public String name;

   public Dog dog;

}


Master master1 = new Master();

Master master2 = new Master();


主人一 可以修改狗狗的名字

主人二 也可以修改狗狗的名字


如果在单线程的环境下   两次修改就会排队执行 谁先执行不一定 重排序

如果是在多线程情况下  两次修改操作 就会交替执行 多核cpu 也能是并行

对于这种情况来说  这个狗狗类 就需要考虑线程安全的问题,当然这也是狗狗类必须被多线程共享的场景下,才会发生。

举个相对真实的例子,做web开发 都会使用 spring mvc 来处理前端发过来的http请求,而spring mvc 本质上是一个servlet,它会承接请求,然后一路找到对应的 控制器 通过  HandlerMapping映射,也就说我们所说的 controller 然后走到 service处理逻辑  走到 mapper 操作数据库

过程是这样的

通过上图可知  多个请求线程 访问相同的 handler  service  mapper  也就是我们常见的 controller  service mapper  都是单例的

因为它们都是多个线程共享一份实例,那么它们的类的状态 就处于共享的,我们就要考虑它们的公共域的访问安全问题。

打个比方说

我们想要统计一个接口的访问次数,最直观的思想就是 ,我们给对应的handler 添加一个成员变量用来计数的,它是public修饰的 也就是给handler 类添加了一个公共域,然后正常情况下,每次访问这个接口 该公共域都会在原有的基础上加一

注意这不是一个原子操作, i++ 至少可以拆成三条指令来执行,获取当前值  给当前值加一 重新赋值 那么在这种情况下,统计接口的访问次数一定会出错。

原因就是因为handler类的状态是共享的,而访问它的公共域,需要考虑线程安全问题,如果没有考虑,则会出现值不符合预期的情况,当前这对于一个接口统计的场景来说,即使记录错误了,少了几个也没有什么大影响。但是在其他业务的场景下,这种并发安全问题会导致更加严重的后果。

三、在设计类的过程中要优先考虑,该类是否有可能在多线程环境下被访问

事实证明,讲一个已经创建好的类改为线程安全的类,成员远比设计一个线程安全的类代价要大得多。

因此我们最好从创建了的一开始就考虑好,这个类是不是应该被设计成线程安全的类。

可以从三个方面考虑这个问题:

第一个 不在多线程环境下 共享类的状态变量

第二个 将状态变量修改成为不可变的

第三个 考虑在访问状态变量的时候使用同步访问等其他保证线程安全的手段

而无状态的对象一定是线程安全的 

四、竞态条件

我们在上面讨论了给一个无状态的 controller 类 添加了一个 公共域 用来统计 接口的访问次数

经过分析 它还是会产生线程安全问题

而 这种由于 多个线程 执行顺序不确定导致的数据错误现象有一个正式的名称,叫做竞态条件

竞态条件(Race Condition)是多线程或并发编程中一种非常重要且常见的问题。它指的是多个线程或进程之间的执行顺序对程序最终结果产生影响的情况,这种情况通常是由于这些线程访问共享数据或资源时的不正确同步引起的。

具体来说,竞态条件发生在多个线程或进程试图同时访问和修改共享数据的情况下,且最终执行结果依赖于线程执行顺序的不确定性。这种不确定性可能导致意外的行为,例如数据损坏、程序崩溃、死锁或其他不一致性问题。

竞态条件的典型特征包括:

  1. 共享资源:多个线程或进程同时访问和修改同一个共享的资源,比如共享变量、内存区域、文件等。

  2. 执行顺序依赖:程序的最终结果取决于这些线程或进程的执行顺序,而这个执行顺序是不确定的,因为它们是并发执行的。

  3. 未同步访问:没有适当的同步机制来保证在访问和修改共享资源时的正确性,比如使用锁、信号量、互斥量等。

 下面举一个执行顺序依赖的例子

我们都知道单例模式 

public class One{
    
    private static One one = null;

    private One(){

    }

    public static One getInstance(){
        if(one == null){
           one = new One();
        }
        return one;
    }

}

上面实现了一个简单的单例模式   构造器私有化  提供获取单个实例的方法

我们来尝试分析一下这个类

首先这个类是否包含状态呢

答案是肯定的  它拥有一个公共域 One  

我们来分析一下这个类的状态可能是  共享的  可修改的

也就是说我们要考虑这个类是否能使用在多线程环境下 

这就引出下面的问题,这个类 在多线程环境下 一定是不安全的

为什么呢 因为它存在竞态条件 也就是我们上面提到的情况

共享资源  one 这个对象实例 明显是独一份 因为我们要求它单例啊,因此 它属于共享资源

执行顺序依赖   one 这个对象 依赖代码的执行顺序吗 答案是肯定的  首先我们要判断 one 是否是null  然后  在进行创建对象的操作  创建对象的操作 依赖 one的判空操作

这里引出了一个现象 下面出图

由于线程的执行顺序不确定 就会出现 重复创建one对象的情况,那么打破了单例的规则,内存中 创建出了大量重复对象,违背了我们使用单例的意愿。

如何解决这个问题呢?

答案是使用同步 也就是加锁 加上 二次判空的机制,以及禁止指令重排,这个过程还是比较难以理解的。

public class One{
    
    private static volatile One one = null;

    private One(){

    }

    public static One getInstance(){
        if(one == null){
            synchronized(One.class){
                if(one == null){
                    one = new One();
                }
            }
           
        }
        return one;
    }

}

挨个解释一下上面的代码作用

volatile 的作用 有两个说法,第一个是保证了可见性 也就是 当一个线程创建了 one 对象之后,其他线程立马能够感知到。

第二个说法是  禁止了指令重排   对象的创建过程 和 构造器执行的过程不是原子的,有可能对象的

指令重排的影响

在没有使用 volatile 关键字的情况下,Java 编译器和处理器为了提高执行效率,可能会对指令进行重排优化。这种优化包括重排代码的执行顺序,但会保证在单线程环境下程序的行为不会改变。

对象的创建和初始化

在单例模式的双重检查锁定中,对象的创建和初始化过程可以分为以下几个步骤:

  1. 分配内存空间。
  2. 初始化对象。
  3. 将对象指向分配的内存空间。

在没有使用 volatile 的情况下,这些步骤可能会被重排序,例如先分配内存空间,然后将对象指向分配的内存,最后再初始化对象。这种重排在多线程环境中可能会导致一个线程获取到了尚未初始化完成的对象,从而造成不正确的行为。

在形象的描述一下,如果进行了指令重排,将2 和 3 重排  那么因为时间片分配 有可能第一个线程还没有初始化对象的情况下,交出了cpu执行权,而另一个线程 判空逻辑执行,发现有一个残血版的对象不等于空,好家伙直接返回了,我们预期的是 初始化过的对象,结果获得了一个残血的,显然不符合我们的要求,因为以上原因我们选择使用了 轻量级同步 volatile关键字 修饰 one 对象

synchronized 显然是让代码进行同步,当一个线程获取了锁之后,其他线程 将会阻塞

为什么要进行二次判断呢 很简单 仔细想想就知道 还是因为多线程执行顺序的原因

有可能 两个线程都进行了 一次判空 然后在依次获取锁,那么 当第一个线程创建完对象后释放锁,而另一个线程得到锁之后,它不知道对象已经有其他线程创建了,因此我们要再加一次判空,如果此时已经有线程创建了,那么就直接返回,最终才会保证对象的单例。

还可以利用类加载机制 替代上面的版本 更容易理解 但是就跟我们讨论的没有关系了 

public class One {
    private static class SingletonHolder {
        private static final One INSTANCE = new One();
    }

    private One() { }

    public static One getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

最后
 

 我们讨论了线程的安全性,如何发现类是否是线程安全的,介绍了竞态条件,以及举了几个简单的例子,接下来将讨论 java提供的同步机制 代码 应该怎么写,好勒,下次再见吧 古德拜。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值