变量的线程安全分析 —— 纠错与笔记
变量的线程安全分析 —— 纠错与笔记
老师在本节的讲解中,存在较多错误的地方,在此进行纠错并记录。
成员变量的线程安全分析
下面的例子在运行时确实会抛出 IndexOutOfBoundsException
,但原因并不是老师所说的:线程 1 还未执行完 method2()
将元素添加到 list
中,线程 2 就执行了 method3()
尝试从空的 list
中移除元素。
错误很明显,不论线程切换导致指令如何交错,至少单个线程内的指令是顺序执行的,这意味着,如果线程 2 执行到了 method3()
,此前就一定已经执行完了 method2()
。
因此,无论线程 1 是否执行了 method2()
,至少能保证线程 2 的 method2()
已执行,list
中至少放入了一个元素,从而 method3()
不可能是从空的 list
中移除元素。
1 | public class TestThreadSafe { |
既然不是老师提到的原因,那又是什么原因呢?后续老师在视频评论区贴出了正确的原因,整理如下:
1 | new Thread(() -> { |
问题的关键就在于,ArrayList
中对 size
的操作并非是原子性的。
1 | // list.add |
list.add("1")
方法源码中,存在 size++
的自增操作,该操作编译为字节码时,对应多条指令,因此在多线程切换的情况下,存在指令交错的可能,从而导致部分线程的自增丢失。
一旦自增丢失,那么后续还需要进行两次 list.remove(0)
操作,显然第一次还会成功,第二次就会因为 size
已经为 0 而抛出 IndexOutOfBoundsException
。
局部变量的线程安全分析
对于之前的代码,将成员变量 list
移动到 method1()
方法的内部作为一个局部变量存在,然后该局部变量以方法参数的形式传递给 method2()
和 method3()
,修改如下:
1 | class ThreadSafe { |
这样修改后,类就从原先的线程不安全,转变为线程安全了!当多个线程同时来调用 method1()
时,每个线程都会创建一份自己私有的 list
变量,没有共享自然也就没有伤害。
接下来,老师对于暴露引用的局部变量的线程安全分析的讲解,讲得不是很好,这里做一个补充。
局部变量如果引用了一个堆中的对象,且该对象还逃离了方法的作用域,就有可能存在线程安全问题。一般比较常见的是,方法存在返回值,以 return
的方式返回了该局部变量所引用的对象,后续所返回的堆中的对象,再被多个线程去操作,就存在线程安全问题了。
但这里老师讲解另外一种非常冷门的暴露的情况,通过子类继承来暴露局部变量引用的对象:
假设
ThreadSafe
类的method2()
和method3()
是public
方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class ThreadSafe {
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
}新增一个子类
ThreadSafeSubClass
来继承ThreadSafe
,重写method3()
方法,方法内新开一个线程去操作传入的局部变量list
。1
2
3
4
5
6
7
8class ThreadSafeSubClass extends ThreadSafe{
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
这种情况下,子类 ThreadSafeSubClass
就又变成了一个线程不安全的类了,而且它的问题比之前的 ThreadUnsafe
还要严重!
ThreadUnsafe
好歹还是需要多个线程同时去执行同一个 ThreadUnsafe
实例的 method1()
方法时,才会出现问题。
ThreadSafeSubClass
则是只需创建一个 ThreadSafeSubClass
实例,然后调用其 method1()
方法即出现问题!后面我们再解析这个问题发生的原因。
老师举的这个例子,其主要目的是想说明一下:private
以及 final
这样的修饰符能在一定程度上保证线程安全性,比如这里,如果我们对 ThreadSafe
这个线程安全的类,将其 method1()
方法设为 final
的,将 method2()
method3()
设为 private
的,就能很好地限制子类以继承的方式来暴露局部变量,从而保证了系统整体的线程安全。
ThreadSafeSubClass
的线程安全问题是什么
上面我们说过:ThreadSafeSubClass
只需创建一个 ThreadSafeSubClass
实例,然后调用其 method1()
方法即出现问题。限制我们就来看看,问题是怎么发生的,先看代码:
1 | public class TestThreadSafe { |
反复运行以上代码时,有几率会抛出 IndexOutOfBoundsException
异常。
虽然 method3()
是启动一个新线程来执行 list.remove(0)
操作,但每次创建该新线程之前,都会调用 method2()
执行 list.add("1")
来添加一个元素。
直觉上,对于每个新开启的线程,都能确保 list
中必然存在至少一个元素,能够让其执行 list.remove(0)
操作,而不是从空 list
中移除元素,为什么还会抛出 IndexOutOfBoundsException
异常呢?
其实问题和最开始提到的成员变量的线程安全问题是一样的,关键同样在于:ArrayList
中对 size
的操作并非是原子性的。
1 | // list.remove |
list.remove(0)
方法源码中,存在 --size
的自减操作,该操作在编译为字节码时,同样对应多条指令,因此,在多线程切换的情况下,存在指令交错的可能。
下面我们就给出一条问题发生的路径:
- 首先主线程中,创建
ThreadSafeSubClass
实例,并调用该实例的method1()
方法 - 主线程调用
method2()
方法添加了一个元素,list
的size
值递增为 1 - 主线程调用
method3()
方法,方法内部开启了新线程 Thread A 来执行list.remove(0)
- Thread A 在执行
remove
方法中的--size
操作相关的多条字节码时,发生线程的上下文切换,递减后的值 0 没有及时更新到size
中,主内存中size
的值依然为 1 - 切换到主线程,开始下一次循环
- 主线程第二次调用
method2()
方法添加了一个元素,size
值递增为 2 - 切换到 Thread A
- Thread A 继续执行上次的
--size
操作剩余的字节码,将递减后的值 0 更新到size
中,使得主内存中的size
的值从 2 变为 0 - Thread A 执行完毕进入终止状态,切换到主线程
- 主线程第二次调用
method3()
方法,方法内部开启了新线程 Thread B 来执行list.remove(0)
- Thread B 在执行
remove
方法时,发现size=0
这个异常值,因此抛出异常。
复习:动态绑定
我们都知道,如果 ThreadSafe
的 method3()
为 public
的,那么我子类重写 method3()
,并且通过子类对象实例去调用 method1()
,底层会动态绑定到子类实现的 method3()
版本。
但如果 ThreadSafe
的 method3()
为 private
的,子类编写一个同名的 method3()
方法,此时通过子类对象实例去调用 method1()
,实际会执行哪个版本的 method3()
呢?
用以下代码做个测试:
1 | public class A { |
测试发现,实际打印的是 m3-A
而非 m3-B
!
这里做一个复习:动态绑定并不适用于所有的方法。
在本例中,子类 B
的对象实例在调用 method1()
时,实际执行的是父类 A
中的版本,然后在 method1()
内部执行到 method3()
时,由于父类 A
内部的方法 method3()
是 private
的,不可能被重写,因此会 JVM 直接调用父类 A
版本的 method3()
,而不会查找子类中是否有同名方法,更不用说动态绑定。