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

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

背景

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

先贴代码如下:

java 复制代码
@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属性。

而这个成员变量,其实就是导致本次案例出现了线程不安全的主要原因。

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

java 复制代码
    /**
     * 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()源码如下:

java 复制代码
    /**
     * 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中不为空而已。


好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。

如果这篇文章对你有帮助的话,不妨点个关注吧~

期待下次我们共同讨论,一起进步~

相关推荐
Theodore_10222 小时前
4 设计模式原则之接口隔离原则
java·开发语言·设计模式·java-ee·接口隔离原则·javaee
EasyNVR2 小时前
NVR管理平台EasyNVR多个NVR同时管理:全方位安防监控视频融合云平台方案
安全·音视频·监控·视频监控
冰帝海岸3 小时前
01-spring security认证笔记
java·笔记·spring
世间万物皆对象3 小时前
Spring Boot核心概念:日志管理
java·spring boot·单元测试
‘’林花谢了春红‘’3 小时前
C++ list (链表)容器
c++·链表·list
没书读了4 小时前
ssm框架-spring-spring声明式事务
java·数据库·spring
小二·4 小时前
java基础面试题笔记(基础篇)
java·笔记·python
开心工作室_kaic4 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
懒洋洋大魔王4 小时前
RocketMQ的使⽤
java·rocketmq·java-rocketmq
武子康4 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud