前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >基础送分题,why哥只说这一次。

基础送分题,why哥只说这一次。

作者头像
why技术
发布2021-02-26 15:10:28
3560
发布2021-02-26 15:10:28
举报
文章被收录于专栏:why技术

这是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 送给你这个锦鲤!

最后说一句(求关注)

好了,看到了这里安排个“一键三连”(转发、在看、点赞)吧,周更很累的,不要白嫖我,需要一点正反馈。

才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在后台提出来,我对其加以修改。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-02-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 why技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 万恶之源
  • 数组越界
  • 值覆盖的情况
  • 通过接口引用对象
  • 荒腔走板
  • 最后说一句(求关注)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档