侧边栏壁纸
博主头像
why

一个主要敲代码,经常怼文章,偶尔拍视频的成都人。

  • 累计撰写 197 篇文章
  • 累计创建 11 个标签
  • 累计收到 101 条评论

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

why
why
2021-02-09 / 0 评论 / 0 点赞 / 414 阅读 / 5,308 字
温馨提示:
关注公众号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。

最后,输出集合的内容和大小。

程序就是下面这样的:

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 方法的这两行代码之间,搞事情,加一个停顿:

public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        //在这一行搞事情
        elementData[size++] = e;
        return true;
    }

比如调用一下我写的这个方法:

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 方法变成了这样:

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

它是不是等价于:

elementData[size] = e;
size=size+1;

两行代码,那么问题又出现了。我们又可以在这两行之间搞事情了。

比如我们把 add 方法改成这样的:

由于我们不需要模拟扩容的场景了,所以修改一下 main 方法:

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 条,我就不赘述了。有兴趣的可以看一下:

最后说一句(求关注)

好了,看到了这里了,关注一下我的公众号[why技术]吧,文章写好后第一时间会先发布在公众号里面。

写文章很累的,需要一点正反馈。你的关注,就是强有力的正反馈!

才疏学浅,难免会有纰漏,如果你发现了错误的地方,还请你留言给我指出来,我对其加以修改。

抱拳了,铁子!

0

评论区