如何解决Java集合线程不安全的问题

编码之外

共 5372字,需浏览 11分钟

 ·

2021-07-14 02:25

先从一个问题开始:Java中的ArrayList是线程安全的吗?

大家都知道是线程不安全的,那么问题来了,你如何去证明它是线程不安全的呢?那好,写个例子吧:


public static void main(String[] args) {
        List list = new ArrayList();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        list.add(5);
        System.out.println(list);
    }
OK,以上是热个身,既然要证明线程不安全,那就得需要多个线程去操作啊,那继续:


public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            },"线程"+i).start();
        }
    }
OK,代码改写完成,那这样会出现什么样的问题呢?运行看看呗:
没啥问题啊?再执行一遍看看:
是不是结果不一样了?再执行看看:
咋样,结果是不是很是丰富多彩啊,为什么?给你看个图:
另外有个很重要的知识点,请问,多线程start开启之后,他们是顺序执行还是乱序执行?

答案是乱序执行,而且执行速度很快,那这就产生问题了,比如1线程还没有写,2线程就读了,或者1和3线程都写了,2线程才读……所以,结果是丰富多彩的!

那么问题来了:


public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            },"线程"+i).start();
        }
    }
看到区别没?这里开启30个线程,请问,程序会报错吗?
程序竟然报错了,什么错误呢?
为什么?
⚠️这是因为ArrayList是线程不安全的,当比较多的线程去同时对其进行快速读写的时候,它就会发生崩溃导致并发修改异常
那咋办?

解决方案一:替换vector

这个时候你能想到怎么做吗?加锁?可以,但是不用你自己加锁,因为有vector,还记得这货吗?


public static void main(String[] args) {
        List<String> list = new Vector<>();
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            },"线程"+i).start();
        }
    }
看下它的add方法:
看到没,它的add上增加了synchronized,所以Vector是线程安全的,但是不要用它,为啥?

⚠️记住一句话就行“性能令人不满意”
虽然不推荐用这种,但是使用Vector的确可以解决并发修改的异常,程序运行看下:
异常消失了!

解决方案二:Collections

那还有没有其他的方法呢?还有如下这种:


public static void main(String[] args) {
        List<String> list = Collections.synchronizedList(new ArrayList<>());
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            },"线程"+i).start();
        }
    }
也就是使用Java集合的工具类:


Collections.synchronizedList(new ArrayList<>());
将线程不安全的list变成线程安全的list,但是这样的方式依然不推荐,原因还是一样的,就是性能受到影响!

所以,尽管此种方式可以解决并发修改异常,但是依然不推荐!

我们上面说的使用Vector或者集合工具类的形式,这些都是同步容器,就是通过加锁的形式,比如Vector的add方法:
这里是加上了synchronized,如此一来,同一个时间只能有一个线程来访问,那这样的话,的确保证了数据的读取一致性,但是效率也就下降了!

那对于使用集合工具类的形式来说,其实也可以用,为什么不推荐使用呢?因为作为一个看起来很牛逼的程序员,我们有更好的选择,那就使用到并发容器!

解决方案三:写时复制

这是个啥,简单,看代码:


public static void main(String[] args) {
        List<String> list = new CopyOnWriteArrayList<>();
        for (int i = 0; i < 30; i++) {
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            },"线程"+i).start();
        }
    }
就是这个CopyOnWriteArrayList,记住了,以后就用这个!

首先运行一下,肯定是没问题的:

那为什么这个就可以用呢?
看到没,首先人家就是Java并发包里面的,所以人家是专门针对并发的,那么在效率和性能上绝对比其他的强!

那这个CopyOnWriteArrayList为什么就那么强呢?原理是啥?我们从字面意思去看啥是CopyOnWrite,是不是“复制在写”???

好啦,人家其实叫做“写时复制”,是读写分离思想的一种,高就高在这,我们来看看具体是咋回事!

原理初探

啥叫做写时复制,啥又是CopyOnWrite,说实话,看起来很高大上,其实思想很简单,首先明确这里要达成的一个效果:
一个线程去执行写操作,多个线程执行读操作
如此一来读写就是被分离开来的,保证了数据一致性的同时也保证的效率,那是怎么做的,重点就在这个“copy”上,啥?复制啊,也就是说,在对一个资源进行读写的时候,假如一个线程在进行写操作,那么这个时候它就获得相应的锁,此时是不允许其他线程去进行写操作的,但是其他线程可以进行读操作,也就是你该写写,不耽误别人的读操作,而且重点就是,这个线程在写的这个数据不是原数据而是拷贝的原数据,也就是把原数据拷贝一份拿来进行写操作,而原数据还在供其他线程读!

此时,读写就分离开来了,一旦这个线程的写操作完成,那此时这个数据就是最新的数据,这时就会把原来的那个原数据给干掉,只保留写之后的这个数据!

这就是写时复制,就是CopyOnWriteArrayList神秘面纱之后的真相!

源代码

明白了简单的原理之后,我们看看源代码!首先看下其底层数据类型:
看到没,依旧是一个Object数组,但是容量为0!接下里去看add的方法:
重点就在这里了,看到没,这里:


 Object[] newElements = Arrays.copyOf(elements, len + 1); 
当你增加一个元素的时候,底层数据就扩容1来容纳你增加的这个元素,然后会得到新的数组并覆盖掉原来的数组!

而在我进行写操作的时候,这里是加锁的:
保证了写操作只能有一个线程来完成!

小总结

OK,以上就是写时复制CopyOnWriteArrayList的一个介绍,下面进行简单的小总结,所谓的CopyOnWriteArrayList我们一般叫它为写时复制的容器,既然是容器那就是装载数据的,通过后面的ArrayList我们也不应该觉得很陌生,这家伙就是对我们熟知的ArrayList进行增强!

当你往CopyOnWriteArrayList中去添加容器的时候,不是立马就往其底层数组中去添加,而是先把底层的数组复制一份,往复制的这份里面去添加,添加完成之后就把原有的数组给覆盖掉,这样一来,就可以实现单个写,多个读,在进行数据添加的时候因为是对原数组的拷贝的数组进行写操作,这个是加锁的,但是对原数组的读是不加锁的,可以实现并发的读,这样,性能效率就都有了!

是不是很巧妙,这就是一种读写分离思想!
浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报