变量的线程安全分析 —— 纠错与笔记

老师在本节的讲解中,存在较多错误的地方,在此进行纠错并记录。

成员变量的线程安全分析

下面的例子在运行时确实会抛出 IndexOutOfBoundsException,但原因并不是老师所说的:线程 1 还未执行完 method2() 将元素添加到 list 中,线程 2 就执行了 method3() 尝试从空的 list 中移除元素。

错误很明显,不论线程切换导致指令如何交错,至少单个线程内的指令是顺序执行的,这意味着,如果线程 2 执行到了 method3(),此前就一定已经执行完了 method2()

因此,无论线程 1 是否执行了 method2(),至少能保证线程 2 的 method2() 已执行,list 中至少放入了一个元素,从而 method3() 不可能是从空的 list 中移除元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class TestThreadSafe {

static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + (i+1)).start();
}
}
}

class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
method2();
method3();
}
}

private void method2() {
list.add("1");
}

private void method3() {
list.remove(0);
}
}

既然不是老师提到的原因,那又是什么原因呢?后续老师在视频评论区贴出了正确的原因,整理如下:

1
2
3
4
5
6
7
8
9
new Thread(() -> {
list.add("1"); // 时间 1. 会让内部 size ++
list.remove(0); // 时间 3. 再次 remove size-- 出现角标越界
}, "t1").start();

new Thread(() -> {
list.add("2"); // 时间 1(并发/并行发生). 会让内部 size ++,但由于 size 的操作非原子性, size 本该是 2,但结果可能出现 1
list.remove(0); // 时间 2. 第一次 remove 能成功, 这时 size 已经是 0
}, "t2").start();

问题的关键就在于,ArrayList 中对 size 的操作并非是原子性的。

1
2
3
4
5
6
// list.add
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}

list.add("1") 方法源码中,存在 size++ 的自增操作,该操作编译为字节码时,对应多条指令,因此在多线程切换的情况下,存在指令交错的可能,从而导致部分线程的自增丢失。

一旦自增丢失,那么后续还需要进行两次 list.remove(0) 操作,显然第一次还会成功,第二次就会因为 size 已经为 0 而抛出 IndexOutOfBoundsException

局部变量的线程安全分析

对于之前的代码,将成员变量 list 移动到 method1() 方法的内部作为一个局部变量存在,然后该局部变量以方法参数的形式传递给 method2()method3(),修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ThreadSafe {
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}

private void method2(ArrayList<String> list) {
list.add("1");
}

private void method3(ArrayList<String> list) {
list.remove(0);
}
}

这样修改后,类就从原先的线程不安全,转变为线程安全了!当多个线程同时来调用 method1() 时,每个线程都会创建一份自己私有的 list 变量,没有共享自然也就没有伤害。

接下来,老师对于暴露引用的局部变量的线程安全分析的讲解,讲得不是很好,这里做一个补充。

局部变量如果引用了一个堆中的对象,且该对象还逃离了方法的作用域,就有可能存在线程安全问题。一般比较常见的是,方法存在返回值,以 return 的方式返回了该局部变量所引用的对象,后续所返回的堆中的对象,再被多个线程去操作,就存在线程安全问题了。

但这里老师讲解另外一种非常冷门的暴露的情况,通过子类继承来暴露局部变量引用的对象:

  1. 假设 ThreadSafe 类的 method2()method3()public 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class 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);
    }
    }
  2. 新增一个子类 ThreadSafeSubClass 来继承 ThreadSafe,重写 method3() 方法,方法内新开一个线程去操作传入的局部变量 list

    1
    2
    3
    4
    5
    6
    7
    8
    class ThreadSafeSubClass extends ThreadSafe{
    @Override
    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class TestThreadSafe {
static final int LOOP_NUMBER = 2000;
public static void main(String[] args) {
ThreadSafeSubClass test = new ThreadSafeSubClass();
test.method1(LOOP_NUMBER);
}
}

class 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);
}
}

class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}

反复运行以上代码时,有几率会抛出 IndexOutOfBoundsException 异常。

虽然 method3() 是启动一个新线程来执行 list.remove(0) 操作,但每次创建该新线程之前,都会调用 method2() 执行 list.add("1") 来添加一个元素。

直觉上,对于每个新开启的线程,都能确保 list 中必然存在至少一个元素,能够让其执行 list.remove(0) 操作,而不是从空 list 中移除元素,为什么还会抛出 IndexOutOfBoundsException 异常呢?

其实问题和最开始提到的成员变量的线程安全问题是一样的,关键同样在于:ArrayList 中对 size 的操作并非是原子性的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// list.remove
public E remove(int index) {
rangeCheck(index);

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

int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work

return oldValue;
}

list.remove(0) 方法源码中,存在 --size 的自减操作,该操作在编译为字节码时,同样对应多条指令,因此,在多线程切换的情况下,存在指令交错的可能。

下面我们就给出一条问题发生的路径:

  1. 首先主线程中,创建 ThreadSafeSubClass 实例,并调用该实例的 method1() 方法
  2. 主线程调用 method2() 方法添加了一个元素,listsize 值递增为 1
  3. 主线程调用 method3() 方法,方法内部开启了新线程 Thread A 来执行 list.remove(0)
  4. Thread A 在执行 remove 方法中的 --size 操作相关的多条字节码时,发生线程的上下文切换,递减后的值 0 没有及时更新到 size 中,主内存中 size 的值依然为 1
  5. 切换到主线程,开始下一次循环
  6. 主线程第二次调用 method2() 方法添加了一个元素,size 值递增为 2
  7. 切换到 Thread A
  8. Thread A 继续执行上次的 --size 操作剩余的字节码,将递减后的值 0 更新到 size 中,使得主内存中的 size 的值从 2 变为 0
  9. Thread A 执行完毕进入终止状态,切换到主线程
  10. 主线程第二次调用 method3() 方法,方法内部开启了新线程 Thread B 来执行 list.remove(0)
  11. Thread B 在执行 remove 方法时,发现 size=0 这个异常值,因此抛出异常。

复习:动态绑定

我们都知道,如果 ThreadSafemethod3()public 的,那么我子类重写 method3(),并且通过子类对象实例去调用 method1(),底层会动态绑定到子类实现的 method3() 版本。

但如果 ThreadSafemethod3()private 的,子类编写一个同名的 method3() 方法,此时通过子类对象实例去调用 method1(),实际会执行哪个版本的 method3() 呢?

用以下代码做个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class A {
public void method1() {
method2();
method3();
}

public void method2() {
System.out.println("m2-A");
}

private void method3() {
System.out.println("m3-A");
}

public static void main(String[] args) {
new B().method1();
}
}

class B extends A {
public void method3() {
System.out.println("m3-B");
}
}

测试发现,实际打印的是 m3-A 而非 m3-B

这里做一个复习:动态绑定并不适用于所有的方法。

在本例中,子类 B 的对象实例在调用 method1() 时,实际执行的是父类 A 中的版本,然后在 method1() 内部执行到 method3() 时,由于父类 A 内部的方法 method3()private 的,不可能被重写,因此会 JVM 直接调用父类 A 版本的 method3(),而不会查找子类中是否有同名方法,更不用说动态绑定。