当前位置: 首页 > article >正文

引用类型的局部变量线程安全问题分析——以多线程对方法局部变量List类型对象实例的add、remove操作为例

引用类型的局部变量线程安全问题

  • 背景
  • 注意事项
  • 分析
  • 步骤拆解(文字描述+时序图)

背景

最近博主在看B站上的一个JUC并发编程视频中,碰到了一个比较有争议性的局部变量线程安全讨论问题。

先贴代码如下:

@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafeTest {

    public static void main(String[] args) {
        ThreadSafeSubclass subclass = new ThreadSafeSubclass();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                subclass.method1(50000);
            }
        });

        t1.start();
    }
}

@Slf4j(topic = "c.ThreadSafe")
class ThreadSafe {

    public void method1(Integer loopNumber) {
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
        log.debug("finalSize: {}", list.size());
    }

    public void method2(List<Integer> list) {
        list.add(1);
    }

    public void method3(List<Integer> list) {
        list.remove(0);
    }

}

class ThreadSafeSubclass extends ThreadSafe {
    @Override
    public void method3(List<Integer> list) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                list.remove(0);
            }
        }).start();
    }
}

小伙伴们可以先在自己的IDE上进行多次,看看是否会出现抛异常的情况。

贴上博主某一次运行结果为异常的截图证明:
在这里插入图片描述

好,接下来的内容就围绕两个问题来展开讨论:

  1. 这个程序为什么会以抛异常为结局?
  2. 异常信息的打印中显示的是index=0,但size却是1,index < size,为什么?为什么还会无法remove?

注意事项

本次讨论不考虑指令重排等复杂因素,只考虑到多线程的并发执行、CPU时间片和任务调度度的影响。


分析

先解释下上述的代码:

  • 在main线程中创建了一个新线程,新线程执行了ThreadSafeSubclass的method1(),在method1()中循环了50000次的method2()、method3(),注意,method2() -> method3() 是顺序执行的;
  • 在method2()中对传入的List形参进行了add操作;
  • 在method3()中新建立了个线程,对传入的List形参进行了remove操作。

好,接下来就是很多看同一门教程的小伙伴最有争议的点了。
既然 method2() -> method3() 是顺序执行的,那么理论上即使 method3() 的remove操作是在新的线程中执行,method2() 的已执行次数 >= method3() 创建的新线程这个似乎是板上钉钉的事情了。
既然数量关系上是如此,那无论多线程环境下如何做线程切换,method3() 执行 remove 操作时,List集合中应该是要有足够的元素可以供给删除的,对吧?

但是,重点来了!
小伙伴们忽略了一个点,就是在List集合中有一个成员变量(注:不是局部变量,区分开!)size属性。
List类中有成员变量size
而这个成员变量,其实就是导致本次案例出现了线程不安全的主要原因。

在ArrayList.add()中,源代码如下:

    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return <tt>true</tt> (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        // 重点:此处进行了数组的元素添加操作,
        // 同时对 size 成员属性进行了 +1操作
        elementData[size++] = e;
        return true;
    }

同理,查看ArrayList.remove()源码如下:

    /**
     * Removes the element at the specified position in this list.
     * Shifts any subsequent elements to the left (subtracts one from their
     * indices).
     *
     * @param index the index of the element to be removed
     * @return the element that was removed from the list
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E remove(int index) {
    	// 重点1: 对传入的形参index检查是否会越界(后续报错就是这里)
    	// 详细方法实现在下面
        rangeCheck(index);

        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
                      
        // 重点2:将数组的最后一个元素置为null
        // 并将 size -1
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

    /**
     * Checks if the given index is in range.  If not, throws an appropriate
     * runtime exception.  This method does *not* check if the index is
     * negative: It is always used immediately prior to an array access,
     * which throws an ArrayIndexOutOfBoundsException if index is negative.
     */
    private void rangeCheck(int index) {
        if (index >= size)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }

    /**
     * Constructs an IndexOutOfBoundsException detail message.
     * Of the many possible refactorings of the error handling code,
     * this "outlining" performs best with both server and client VMs.
     */
    private String outOfBoundsMsg(int index) {
        return "Index: "+index+", Size: "+size;
    }

说明:remove中会将数组的最后一个元素置为null,是因为上面的代码已经将要删除的元素进行了覆盖,即将被删除元素的后续元素整体往前移1个位置。因此最后的数组位置需要置为null。


add()remove()源码中我们可以看出,这两个操作都需要对成员变量size进行读写操作。
而我们的案例中,add()和remove()这两个操作都是对同一个List实例对象的操作(因为被操作的List是从形参中传递过来的,而实参是method1()在调用method2()、method3()之前就先创建好的局部变量List)。
因此,当add()和remove()被分配到了不同的线程每调用一次method3()都是去创建了一个新的线程去执行remove()操作)中去执行时,就会出现竞态条件,也就是说,size成员属性会有线程不安全问题。
而从我贴的结果截图中可以看出,抛异常是因为我们在remove()时出现了数组下标越界问题。而这个异常的判断,正是通过 index(此案例中固定为0) 和 size 的大小比较来决定是否抛出。



步骤拆解(文字描述+时序图)

好,现在对截图中的结果进行一步步的拆解分析,以单核CPU为base:
(注意⚠️:只举例一种可能的情况,实际能达到截图中的结果的情况非常多)
1、main线程启动,新建了t1线程,并启动,main结束(假设CPU时间片足够执行完毕的情况下);
2、t1线程开始执行,调用method1(),在t1的线程栈中为method1()分配了栈帧,并在该栈帧中创建了局部变量List<Integer> list = new ArrayList<>();;接着开始准备进入50000次的循环;
3、i=0,第一次循环开始,调用了method2(),并将局部变量list作为参数传入;method2()正常执行,往数组中写入了元素,并将size+1,此时size=1;
4、接着调用method3(),并将局部变量list作为参数传入;method3()正常执行,创建了个新的线程(命名为new-1)并启动,新线程进入就绪状态,准备得到CPU时间片后执行remove();
5、i=1,第二次循环开始,准备调用method2(),假设此时的t1线程的CPU时间片已经用完了,t1让出CPU使用权,进入就绪状态;线程new-1分配到了CPU,开始执行remove();
6、new-1先执行了rangeCheck(),index=0,size=1,符合remove操作条件,因此开始remove,将指定index之后的所有元素往前移动1位,以覆盖index位置上的元素;
7、接着,new-1的执行来到了elementData[--size] = null;这一步,这一步有两个细分的步骤,分别是1️⃣计算出size-1=0,并2️⃣在计算后的size(即0)下标位置对元素置为null。但很不巧的是,new-1的CPU时间片用完了,该让出CPU使用权了;
8、t1线程重新获取到了CPU使用权,开始执行method2()的add()操作,此时由于new-1未能及时把size-1后的值写回成员变量的内存位置,因此t1线程读取size时仍然为1,就会在数组的第二个位置上加上新的元素,并让size+1=2;此时实际的数组的第一个位置为null,第二个位置上有元素;
9、接着调用method3(),并将局部变量list作为参数传入;method3()正常执行,创建了个新的线程(命名为new-2)并启动,新线程进入就绪状态,准备得到CPU时间片后执行remove();
10、i=2,第三次循环开始,准备调用method2(),跟前面一样,此时的t1线程的CPU时间片已经用完了,t1让出CPU使用权,进入就绪状态;
11、不同的是,这次任务调度器偏心了,再度挑选线程分配CPU时间片时,任务调度器再次选择了线程t1;因此,t1线程重新获取到了CPU使用权,开始执行method2()的add()操作,执行完毕后,size+1=3,数组的第三个位置上有元素;接着执行method3(),然后i=3开始第4次循环时,CPU时间片使用完毕,t1让出CPU使用权,进入就绪状态;
12、接着,new-1重新获得了任务调度器的青睐,获取到了时间片来使用CPU,new-1根据线程栈中的程序计数器上的指令,执行了上一次未能执行完毕的指令,即写回成员变量值size=0,并将数组的0号位置为null。执行完毕后,new-1线程结束,进入终止状态。此时的堆内存中的list.size成员变量值为0;
13、任务调度器重新分配CPU给t1、new-2、new-3中的其中一个,此次是new-2、new-3中的一个获取到了CPU使用权,开始使用CPU执行remove();
14、由于执行remove()的第一步是先进行范围检查rangeCheck(),此时的index=0、size=0,因此index >= size,符合抛出IndexOutOfBoundsException异常的条件,因此程序进入抛出异常的方法体中;
15、此时时间片再度使用完毕,任务调度器选择了t1线程作为了下一个执行的线程,t1线程又开始执行method2(),将size+1=0+1=1写回了成员变量中,同时对index=0的下标位置添加了元素,此时的数组中index=0、1、2三个位置上均有了元素。
16、t1时间片再度使用完毕,CPU使用权又回到了前一个执行的线程中(new-2或new-3),而此时线程开始封装抛异常的文本信息了,即return "Index: "+index+", Size: "+size;,此时index=0,而size却是1,因此最终抛出了如开头所贴的图中所示的异常信息:Exception in thread "Thread-4311" java.lang.IndexOutOfBoundsException: Index: 0, Size: 1。线程执行完毕,结束生命周期;整个流程分析结束。


以上就是对开头所贴的结果截图的拆分步骤分析,主要是为了解决小伙伴们的一个疑惑:为什么多线程下可能(注意⚠️:不是一定)会抛异常?以及抛出的异常中,明明 index < size,但却还是会抛异常?


最后附上一张时序图(简略版)供小伙伴们结合上述的步骤文字描述进行理解:
引用类型局部变量的线程不安全分析时序图


补充说明:开头所贴的结果截图中的finalSize的实际大小不具有参考价值,因为这是在t1线程执行结束前打印的,此时可能还有部份的method3()中创建的新线程还未分配到时间片去执行remove操作。因此每次运行的finalSize的大小都会不一样的。这个打印日志只是为了证明整个程序执行完毕后,list中不为空而已。




        好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。
        如果这篇文章对你有帮助的话,不妨点个关注吧~
        期待下次我们共同讨论,一起进步~


http://www.kler.cn/a/409014.html

相关文章:

  • 差分进化算法原理与复现
  • android bindService打开失败
  • 【C++】ReadFile概述,及实践使用时ReadFile的速率影响研究
  • 腾讯云 AI 代码助手:产品研发过程的思考和方法论
  • 数据结构与算法——1122—复杂度总结检测相同元素
  • Ubuntu20.04 Rk3588 交叉编译ffmpeg7.0
  • node.js中使用express.static()托管静态资源
  • Java项目实战II基于微信小程序的南宁周边乡村游平台(开发文档+数据库+源码)
  • 工业边缘计算网关在生产设备数据采集中的应用
  • C51数字时钟/日历---LCD1602液晶显示屏
  • 线性代数的发展简史
  • 7-10 解一元二次方程
  • Android 数据处理 ------ BigDecimal
  • 【什么是RabbitMQ】
  • Flink学习连载第二篇-使用flink编写WordCount(多种情况演示)
  • TCL大数据面试题及参考答案
  • HTML 元素类型介绍
  • Python3.9.13与深度学习框架TensorFlow的完整详细安装教程
  • Charles抓包工具-笔记
  • MyBatis 的多对一,一对多以及多对多的增删改查的xml映射语句
  • (Keil)MDK-ARM各种优化选项详细说明、实际应用及拓展内容
  • 【Github】如何使用Git将本地项目上传到Github
  • 闲聊?泳池清洁机器人?
  • 数字化学习管理:SpringBoot在线课程系统
  • Go消费kafka中kafkaReader.FetchMessage(ctx)和kafkaReader.ReadMessage(ctx)的区别
  • 设计模式——传输对象模式