为什么ArrayList是线程不安全的

1.概述

ArrayList除了需要掌握基本的api调用,还要熟悉他是线程不安全的,下面从源码的角度探索

2.源码分析

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    /**
     * 列表元素集合数组
     * 如果新建ArrayList对象时没有指定大小,那么会将DEFAULTCAPACITY_EMPTY_ELEMENTDATA赋值给elementData,
     * 并在第一次添加元素时,将列表容量设置为DEFAULT_CAPACITY 
     */
    transient Object[] elementData; 

    /**
     * 列表大小,elementData中存储的元素个数
     */
    private int size;
}

所以通过这两个字段我们可以看出,ArrayList的实现主要就是用了一个Object的数组,用来保存所有的元素,以及一个size变量用来保存当前数组中已经添加了多少元素。

接下来查看add方法源码:

 /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return <tt>true</tt> (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

进行add操作时,首先要使用ensureCapacityInternal(size + 1);方法来检查elementData数组的容量是否足够,如果进行size+1操作没有越界,那么将当前元素加入到数组中,否则将数组进行扩容。

因为进行add操作需要进行两步:

  1. 判断elementData数组容量是否满足需求
  2. 在elementData对应位置上设置值

**所以在多线程情况下,如果不能保证原子性,那么就会出现线程安全问题,**举个栗子:🐤

例子一:

假设当前数组容量为5,实际已经存在4个元素,

  • 当前线程进行add操作时,先检查数组的容量,ensureCapacityInternal(size + 1),这时size+1刚好等于数组容量,没有问题,
  • 但是这时当前线程的时间片到了,
  • 另外一个进行进来的时候,数组中实际的元素还是4个,因为上一个线程还没有进行实际的插入操作,这时这个后进来的线程执行完两步操作,数组的实际元素个数整好等于数组容量,
  • 这时时间片交还给第一个线程,再进行elementData[size++] = e;操作,此时size已经为5,elementData[size++]报出数组越界ArrayIndexOutOfBoundsException异常。

例子二:

初次以外,elementData[size++] = e操作也不具备原子性,主要由以下步骤组成:

1.elementData[size] = e;

2.size++

前面已经提到,在进行赋值操作时,只有常数值赋值给变量时才是一个原子操作,类似于上面的变量给变量赋值,和size++操作,都不具备原子性

在单线程情况不会出现问题,但是上面的操作在多线程情况下会由于不能保证原子性而出现各种各样的问题,例如:

package java1;

import java.util.ArrayList;
import java.util.List;

public class Solution {
    public static void main(String[] args) throws InterruptedException {
        final List<Integer> list = new ArrayList<Integer>();

        // 线程A将0-1000添加到list
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 1000 ; i++) {
                    list.add(i);

                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        // 线程B将1000-2000添加到列表
        new Thread(new Runnable() {
            public void run() {
                for (int i = 1000; i < 2000 ; i++) {
                    list.add(i);

                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        Thread.sleep(1000);

        // 打印所有结果
        for (int i = 0; i < list.size(); i++) {
            System.out.println("第" + (i + 1) + "个元素为:" + list.get(i));
        }
    }
}

在最终的执行结果中,存在这样的null值:

Annotation 2020-03-13 131307

我们分析以下,结果为什么会出现null值呢?

可能存在这样的执行顺序:

线程1对数组的第一个元素进行赋值elementData[size] = a;这时时间片已到,切换到线程2

由于两个线程的工作空间中,arrayList对象的elementData字段都是从主内存读取的,两个线程分别在各自的工作内存中进行赋值,互补影响,此时线程2也进行赋值操作,elementData[size] = b;这时时间片已到,线程1进行size操作,这时下标加1,然后线程2也进行size操作这时下标加到2,注意,这里需要立即理解,线程2工作内存中的size这时是从主内存中刚刚读取的,而不是之前一股脑的读入工作内存中,这一点在《深入理解Java虚拟机》中说到:

假设线程中访问一个10MB的对象,也会把这10MB的内存复制一份拷贝出来吗?事实上并不会如此,这个对象的引用,对象中在某个线程访问到的字段是有可能存在拷贝的,但不会有虚拟机实现把整个对象拷贝一次。

这样困惑终于解决了,线程1执行完size操作之后,需要进行Thread.sleep(1);,工作内存中的内存会写到主内存中,这时线程2读取主内存中已经被线程1修改过的size,最后的结果就是线程2中第一个元素的值把线程1的覆盖了,但是size进行了两次,第二个元素的位置,即elementData[1] 为null

3.总结

并发内容真是太难搞了,一不小心就会想的走火入魔,但是不想清楚,这一部分的知识总是觉得不踏实,有一些比较细致的知识,在网上已经找不到答案了,最终在周志明的书籍中找到了,启发就是《深入理解Java虚拟机》这本书真的非常牛逼,每一章都是重点,后面会把每一章的内容重新过一遍并整理笔记。

参考文献:

https://blog.csdn.net/u012859681/article/details/78206494

《深入理解Java虚拟机》