java多线程环境下数据结构的安全问题

前言

日常开发中,我们经常要和数据打交道,一旦涉及到数据,那肯定要使用一些数据结构,如ArrayList、HashMap、Stack等都是常见的数据结构。这些数据结构封装得很好,使用简单,而且提供很多API给我们实现各种功能,深受广大开发者的喜爱。然而,事物都不可能是完美的,当这些数据结构在多线程的环境下,它们是安全的吗?哪些是安全的,哪些不安全?如果不安全,是怎样引起的呢?本篇文章介绍一些线程不安全的数据结构以及解决方案,为你在多线程环境下安全地使用数据结构提供一些思路。

线程安全的集合

  • Vector :比ArrayList多了一个同步机制(线程安全)
  • HashTable : 比HashMap多了个线程安全
  • ConcurrentHashMap : 是一种高效而且线程安全的集合
  • Stack:继承Vector,也是线程安全。

上面这些集合的内部都通过synchronized来实现线程安全,这是它们的优点,有优点当然有缺点,它们的效率相对都会比较低(多了获取锁和释放锁的操作)。

线程不安全的集合

主要分析两个常用的集合ArrayList和HashMap。

  • ArrayList
    ArrayList线程不安全主要是由它的add()方法引起的,看一下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;
    }

场景一:数组越界异常 ArrayIndexOutOfBoundsException
从源码可以看出,ArrayList的add()方法主要有两步,ensureCapacityInternal(size + 1);elementData[size++] = e;,具体的作用注解有说明。
单线程下是完全没有问题的,但在多线程中,有可能出现一种情况:假设当前ArrayList的长度是9(源码中ArrayList的初始容量是10),线程A、B同时执行add()方法。当线程A执行ensureCapacityInternal(size + 1);时,因为size等于9,容量为10,线程A判断不需要扩容,此时CPU调度线程B执行,同样执行ensureCapacityInternal(size + 1);,此时size还是等于9(线程A没有改变size的值),线程B也判断不需要扩容,然后线程B继续执行elementData[size++] = e;将元素e放到elementData[9]中,再执行size++并返回,此时size的值等于10。线程B执行完毕后,线程A继续往下执行elementData[size++] = e;,此时size的值是10,相当于线程A将元素e放到elementData[10]中,但ArrayList的容量也只是10(下标只有0-9),这样就会抛出越界异常。

场景二:元素值覆盖和为空问题
这个场景主要是与elementData[size++] = e;这句代码有关,因为它不是一个原子操作,有可能被其他线程中断,这句代码至少分为两步:

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

当多线程执行这段代码时,有可能出现这种场景:假设当前size=1,线程A、B执行这段代码,线程A执行elementData[size] = e;后,e被放在elementData[1]中,此时线程A中断,CPU调度线程B,线程B执行elementData[size] = e;,此时size的值还是1(线程A没来得及执行size+1操作就被中断),所以线程B还是把元素放在elementData[1]中,覆盖了线程A已经存放的值,然后对size进行+1操作并返回,此时size=2。线程B执行完后,CPU调度线程A继续执行,线程A对size+1,此时size就变成3。最终,两个线程执行完后,期待的结果是elementData[1]、elementData[2]各有一个元素,现在却是两个线程都把元素放在elementData[1]上,且线程B覆盖了线程A的元素,而elementData[2]为null。

如何解决线程不安全问题
1.Collections.synchronizedList
最常用的方法是通过 Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。

List<Object> list = Collections.synchronizedList(new ArrayList<Object>);

这是Collections类中的静态方法,返回的是一个用synchronized包装的ArrayList。看看其部分源码:

		public boolean equals(Object o) {
            if (this == o)
                return true;
            synchronized (mutex) {return list.equals(o);}
        }
        public int hashCode() {
            synchronized (mutex) {return list.hashCode();}
        }

        public E get(int index) {
            synchronized (mutex) {return list.get(index);}
        }
        public E set(int index, E element) {
            synchronized (mutex) {return list.set(index, element);}
        }
        public void add(int index, E element) {
            synchronized (mutex) {list.add(index, element);}
        }
        public E remove(int index) {
            synchronized (mutex) {return list.remove(index);}
        }

        public int indexOf(Object o) {
            synchronized (mutex) {return list.indexOf(o);}
        }
        public int lastIndexOf(Object o) {
            synchronized (mutex) {return list.lastIndexOf(o);}
        }

        public boolean addAll(int index, Collection<? extends E> c) {
            synchronized (mutex) {return list.addAll(index, c);}
        }

可以看到它只是把用synchronized关键字包装了普通的ArrayList,使其成为线程安全的数据结构。值得注意的是,synchronizedList并非绝对线程安全的,因为有一些方法没有加synchronized关键字,需要手动同步。例如迭代器、分割器、流等。

		public Iterator<E> iterator() {
            return c.iterator(); // Must be manually synched by user!
        }
		@Override
        public Spliterator<E> spliterator() {
            return c.spliterator(); // Must be manually synched by user!
        }
        @Override
        public Stream<E> stream() {
            return c.stream(); // Must be manually synched by user!
        }
        @Override
        public Stream<E> parallelStream() {
            return c.parallelStream(); // Must be manually synched by user!
        }

这种解决方案可以应对绝大多数的多线程场景,但是有一个对性能影响比较大的因素,就是读取的效率会很低。我们知道多线程的读取是不涉及线程安全问题的,因为没有线程修改数据,这里给get()方法也加上了synchronized关键字,主要是为了读写的同步,这是正确的;然而在一些读操作比较频繁的场景下,使用这种数据结构每次读都要同步一次,而且这种同步是没有必要的,导致性能大幅下降。
2.使用CopyOnWriteArrayList

List<E> list1 = new CopyOnWriteArrayList<E>(new ArrayList<E>());  

源码:

    public boolean add(E e) {
        synchronized (lock) {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        }
    }

    public E remove(int index) {
        synchronized (lock) {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        }
    }

我们可以看出当我们向容器添加或删除元素的时候,不直接往当前容器添加删除,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加删除元素,添加删除完元素之后,再将原容器的引用指向新的容器,整个过程加锁,保证了写的线程安全。

    public E get(int index) {
        return get(getArray(), index);
    }

而因为写操作的时候不会对当前容器做任何处理,所以我们可以对容器进行并发的读,而不需要加锁,也就是读写分离。

一般来讲我们使用时,会用一个线程向容器中添加元素,一个线程来读取元素,而读取的操作往往更加频繁。写操作加锁保证了线程安全,读写分离保证了读操作的效率。

CopyOnWriteArrayList是绝对的线程安全吗?答案并不是,当涉及到remove和get的多线程操作时,有可能出现数组越界的情况。想象一下如果一个线程进行删除元素操作,另一个线程去读取容器中最后一个元素,读之前的时候容器大小为i,当去读的时候删除线程突然删除了一个元素,这个时候容器大小变为了i-1,读线程仍然去读取第i个元素,这时候就会发生数组越界。所以涉及remove操作时,一定要谨慎。

3.Collections.synchronizedList VS CopyOnWriteArrayList
CopyOnWriteArrayList的优点很明显,在读写分离,既保证读写的线程安全,又提高读的效率,缺点也很明显,每次写操作都要备份,当写的频率增加时,效率会急速下降,适用于读操作比较频繁,写操作少的场景;而Collections.synchronizedList对元素的增加、删除、读取操作都是线程安全的,但是对读操作增加了synchronized关键字,当读取频率较高时,效率也会降低。

以上的方法虽然一定程度上解决了线程安全问题,但性能都有一定程度的下降,然而在某些场景下,安全性比性能更重要,而且根据不同的场景,应该选用不同的实现方式。

  • HashMap
    HashMap的线程安全问题比较复杂,这里贴一下其他人的博客。

HashMap之线程安全
为什么HashMap非线程安全
这里可能有点绕,特别是第二篇博客,建议仔细看。那如何解决HashMap的线程安全问题?java给我们提供了一个高效且线程安全的集合ConcurrentHashMap,可以用它代替HashMap在多线程下的应用。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值