引用类型的局部变量线程安全问题
背景
最近博主在看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上进行多次,看看是否会出现抛异常的情况。
贴上博主某一次运行结果为异常的截图证明:
好,接下来的内容就围绕两个问题来展开讨论:
- 这个程序为什么会以抛异常为结局?
- 异常信息的打印中显示的是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中不为空而已。
好了,以上就是我个人对本次内容的理解与解析,如果有什么不恰当的地方,还望各位兄弟在评论区指出哦。
如果这篇文章对你有帮助的话,不妨点个关注吧~
期待下次我们共同讨论,一起进步~