这是why哥的第 87 篇原创文章
你看上面的便签截图,这是一篇一年前就想写的文章,但是后面我觉得这个东西有点太简单了,写着没啥意思。
但是回想这几次面试的场景,也有少数的候选者没有回答上来。
场景基本上是这样的:
- 面试官:我看你简历上写了熟悉 Java 基础,可以选哪一块,我们聊一下吗?
- 面试者:可以的,在平时的工作中集合类用的很多,比较熟悉。(内心OS:快,快问我,HashMap,我什么都知道。)
- 面试官:好的,那么 ArrayList 是线程安全的吗?(内心OS:别想套路我问你 HashMap,我也背过。)
- 面试者:ArrayList 不是线程安全的,HashMap 也不是线程安全的。(内心OS:快,快问我,HashMap,我什么都知道)
- 面试官:那么 ArrayList 的线程不安全体现在什么地方呢?有什么样的表现呢?(内心OS:好家伙,开始明示了,反正我就是不问 HashMap。)
- 面试者:额...就是,那啥...多线程的时候...会线程不安全。好吧,记不太清楚了。
就觉得很奇怪。
倒不是质疑面试者的能力,至少知道它是线程不安全的。
你能说他的回答有毛病吗?
没毛病呀。
只是不知道为什么不安全而已。
知其然,能用,很好。
知其所以然,放心的用,更好。
我奇怪的是,这不也是标准的面试八股文吗?
我记得我几年前刚毕业,面试的时候就背过这题啊。
HashMap 背的滚瓜烂熟,连 JDK 8 版本源码一共有 2397 行都知道。
为什么同样是平时工作中使用频率非常高的 ArrayList,就不背了呢?
你让 ArrayList 怎么想:呸,你个渣男,只会去揣摩 HashMap 的小心思,都不愿意看一眼我的心。
好了,本文简单的说一下,ArrayList 的线程不安全体现在什么地方,具体是哪些表现。
先直接说答案吧:
ArrayList 的线程不安全体现在多线程调用 add 方法的时候。
具体有两个表现:
1.当在需要进行数组扩容的临界点时,如果有两个线程同时来进行插入,可能会导致数组下标越界异常。 2.由于往数组中添加元素不是原子操作,所以可能会导致元素覆盖的情况发生。
然后下面的文章给大家分析一波。
必须得用骚操作分析一波。
一目了然的那种。
万恶之源
首先带你看看万恶之源 add 方法吧:
就三行代码,主要看前两行。
首先第一行:ensureCapacityInternal(size 1);
这行代码就是进行容量初始化、扩容操作。
你知道 ArrayList 的默认容量是多少吗?
默认容量是 10。
你知道 ArrayList 什么时候扩容吗?
很简单,数组放不下的时候,扩容。
然后第二行:elementData[size ] = e
额。。。
这行代码,就是往数组的指定下标放值,我就不过多解释了。
但需要注意的是:size 是数组的下标,数组的下标是从 0 开始的。
说人话就是:size 初始值为 0。
数组越界
首先,我们来模拟一下扩容临界点导致的线程不安全问题。
扩容临界点:是不是数组里面已经放了 10 了,再放一个进去就该扩容的那个时间点?
而且线程不安全,那高低至少得整两个线程吧?
假设,数组里面已经有 9 个元素了,线程 why1 和线程 why2 同时进来了。
一看:咦,巴适,当前数组大小为 9,位置还够。
线程 why1 想插入元素 9,发现当前数组大小为 9,即 size=9。
线程 why2 想插入元素 10,也发现当前数组大小为 9,即 size=9。
此时它们都认为 size=9,然后它们各自执行这行代码:
ensureCapacityInternal(size 1);
都发现 size 1=10,不满足条件:
if (minCapacity - elementData.length > 0)
所以不会触发扩容机制。
于是都准备插入,也就是执行这行代码:
多线程的情况下,线程 why1 和线程 why2 总得是有一个线程先执行了这行代码吧?
假设线程 why1 先执行完成,把元素 9 放了进去:
线程 why1 执行完成之后,经过 size 代码后,size 就变成 10 了。
于是问题就从这个时候开始了。
你想啊,线程 why2 已经判断过自己可以插入了,接下来就只管往数组里面放就行了。
它哪里知道,等它放数据的时候,size 已经变成 10 了呢?
已经不是上面示意图线程 why2 中的 size=9 了。
变成了这样:
size=10,意味着什么?
我前面强调过,size 就是数组的下标,默认从 0 开始。数组默认长度为10,所以最后一个下标为 9。
现在你要往下标为 10 的地方放值?
对不起,没有这个选项。数组越界异常来一波。
这是 add 方法线程不安全的体现之一。
理论分析完了,我们来实操一把。
怎么实操呢?
多搞几个线程调用 add 方法就行了。
但是这样看的不够真切。常规套路不够吸引,而且有一定的运气成分。
我这里抛出异常了,你跑同一份代码的时候可能就跑的好好的。
你就会说:你是不是环境问题啊,在我这跑的都是好好的?能不能给我稳定的复现。
不能稳定的复现,一律不当作 bug 处理。
所以我肯定要给你搞个必现的代码,怎么搞呢?
很简单,改源码就行。
但是改源码的成本有点大啊。
于是我转念一想,想到了程序员的基本功:cv 大法。
我可以把 ArrayList 的代码原模原样的复制粘贴一份出来不就行了?
代码一样,功能一样,还能直接修改,搞起。
上面这个 WhyArrayList 就是我 cv 出来的一份源码。
你搞的时候你会发现其中 subList 的部分会报错,但是我们用不上 sublist,删除掉这部分源码就行了。
代码就绪之后,我们要在 add 方法里面搞事情。
按照上面的画图分析,我们首先得往数组里面放 9 个元素。
然后,开两个线程:
- 线程 why1 往集合里面放元素 9。
- 线程 why2 往集合里面放元素 10。
最后,输出集合的内容和大小。
程序就是下面这样的:
代码语言:javascript复制public class ArrayListTest {
public static void main(String[] args) {
List<Integer> arrayList = new WhyArrayList<>();
for (int i = 0; i < 9; i ) {
arrayList.add(i);
}
new Thread(() -> arrayList.add(9), "why1").start();
new Thread(() -> arrayList.add(10), "why2").start();
System.out.println("arrayList = " arrayList ",size=" arrayList.size());
}
}
大概情况下,直接运行起来也是正常的:
我怎么模拟线程不安全的场景呢?
就是在 add 方法的这两行代码之间,搞事情,加一个停顿:
代码语言:javascript复制public boolean add(E e) {
ensureCapacityInternal(size 1); // Increments modCount!!
//在这一行搞事情
elementData[size ] = e;
return true;
}
比如调用一下我写的这个方法:
代码语言:javascript复制private void specialThread() {
String name = Thread.currentThread().getName();
if (name.startsWith("why")) {
try {
int i = ThreadLocalRandom.current().nextInt(1000);
System.out.println("指定线程到这里啦 = " name ",size=" size ",睡眠时间=" i "ms");
TimeUnit.MILLISECONDS.sleep(i);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
System.out.println("线程到这里啦 = " name ",size=" size);
}
方法的意思是:如果是指定线程,也就是 why1 和 why2 ,那么随机睡眠一下,然后接着执行。
这样就可以保证,在执行 ensureCapacityInternal(size 1)
这行代码的时候,它们看到的 size 是 9。
之后,就看谁先被唤醒了。
先唤醒的插入成功,后唤醒的数组异常。
此时,我们的 add 方法变成了这样:
代码语言:javascript复制public boolean add(E e) {
ensureCapacityInternal(size 1); // Increments modCount!!
specialThread(); //在这一行搞事情
elementData[size ] = e;
return true;
}
再次执行 main 方法输出如下:
抛出了两个异常,我们先只关注 ArrayIndexOutOfBoundsException
异常吧。
从输出日志可以很清楚的看到:
- 指定线程到这里啦 = why1,size=9,睡眠时间=742ms
- 指定线程到这里啦 = why2,size=9,睡眠时间=830ms
两个线程在执行 elementData[size ] = e
方法之前,size 都是 9。符合我们前面的分析。
742ms 之后,线程 why1 继续执行,往数组的下标为 9 的位置成功放入元素 9 之后,执行 size ,然后 size 变成 10。
线程 why2 再来执行的时候,size 变成 10 了,往数组的下标为 10 的位置放元素。对不起,没有下标为 10 的位置。
模拟成功。
然后,我们把目光放到第二个异常上去:
这玩意,太熟悉了。
之前的文章讲过的,原理是一样的。就不多说了,不熟悉的,看看这两篇文章吧。
这道Java基础题真的有坑!我求求你,认真思考后再回答。
这道Java基础题真的有坑!我也没想到还有续集。
值覆盖的情况
接着我们看第二种线程不安全的情况,值覆盖。
把目光聚焦到 add 的这行代码上:elementData[size ] = e
它是不是等价于:
代码语言:javascript复制elementData[size] = e;
size=size 1;
两行代码,那么问题又出现了。我们又可以在这两行之间搞事情了。
比如我们把 add 方法改成这样的:
由于我们不需要模拟扩容的场景了,所以修改一下 main 方法:
代码语言:javascript复制public class ArrayListTest {
public static void main(String[] args) throws InterruptedException {
List<Integer> arrayList = new WhyArrayList<>();
new Thread(() -> arrayList.add(9), "why1").start();
new Thread(() -> arrayList.add(10), "why2").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("arrayList = " arrayList ",size=" arrayList.size());
}
}
正常情况下,我们的预期是 list 里面放了两个元素,分别是 9,10。list 的大小为 2。
但是,实际情况是这样的:
集合大小确实是 2,但是里面放的是 [10,null]。
你说咋回事呢?
原因很简单,线程 why1 和 why2 同时往下标为 0 的位置来放值。
线程 why2 把自己的元素 9 放进去之后,也就是执行了这行代码 elementData[size] = e
之后,还没来得及执行 size=size 1
。
就被挂起来了。
然后线程 why1 也把自己的元素 10 放到了下标为 0 的位置,这个时候就发生了值覆盖的情况。
最终就出现了 [10,null] 这样的情况。
那我们怎么解决这个问题呢?
是不是把成员变量 size 用 volatile 修饰一下就行了呢?
就像这样的:
这样不就行了吗,对不对?
好的,说对的同学请回去等通知啊。
肯定是不对的。
volatile 保证的是可见性、有序性,并不保证原子性。
正确的做法之一是在 add 方法上加 synchronized 关键字:
这样就能保证 add 方法的线程安全了:
一并把前面的数组越界异常也给解决了:
通过接口引用对象
另外,附送一个知识点吧。
你看我这个地方, new 的时候是具体的实现类,但是接受的对象却是 list 接口。
这样的写法,程序会更加灵活一点。
比如我要把集合换成 ArrayList,只需要把 new WhyArrayList() 修改为 new ArrayList() 即可。其他没有任何地方需要改动。
其实这个编码建议是我在看《Effective Java》中看到的。
比如我手中的第二版,就是第 52 条,我就不赘述了。有兴趣的可以看一下:
荒腔走板
分享个事儿,挺有意思的。
昨天晚上路过成都天府三街这边的银泰城,想着就在这里把晚饭吃了。
于是找到这家店,我也是第一次去这家吃。
结果老板说:对不起,我们今天卖完了。
我刚要转身走。
他突然又把我叫住:兄弟,我们还有一碗牛肉面,但是有这么一个情况。就是这个面是之前煮多了的,就放在厨房了。但是我保证绝对没有超过10分钟。你要是不嫌弃,你就吃这碗,我免费送你吃。我没有其它的意思,我就是怕浪费了。倒掉挺不好的。
我心想:还有这好事呢?
于是我就免费吃了一碗牛肉面。有一说一,我第一次吃,即使老板说不是刚出锅的,口感不好了,但是我觉得味道还挺好的,牛肉好几大坨。
吃完之后,我强行去找老板付钱,老板强行不收我的钱。也许老板是在暗示我:
新的一年,从白嫖开始?
一碗牛肉面,吃的我还挺感动的,是怎么回事。
我也免费帮老板宣传一下,算是另外一种类型的一键三连。
也算是另外一种付费,毕竟我高低也是一个小号主,在这里宣传是得收费的。可以吃好多碗牛肉面呢。
用一句成语来说,就是:投桃报李。
谢谢,老板。
新年快乐。
另外,之前发起的一个抽奖活动,大家快去看看,万一这个锦鲤就是你呢?
AirPods Pro 送给你这个锦鲤!
最后说一句(求关注)
好了,看到了这里安排个“一键三连”(转发、在看、点赞)吧,周更很累的,不要白嫖我,需要一点正反馈。
才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在后台提出来,我对其加以修改。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。