变量的线程安全分析 —— 纠错与笔记
变量的线程安全分析 —— 纠错与笔记
老师在本节的讲解中,存在较多错误的地方,在此进行纠错并记录。
成员变量的线程安全分析
下面的例子在运行时确实会抛出 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(),而不会查找子类中是否有同名方法,更不用说动态绑定。