三种线程安全的单例模式(哪些集合是线程安全的)

三种线程安全的单例模式(哪些集合是线程安全的)在单线程开发环境中,我们经常使用ArrayList作容器来存储我们的数据,但它不是线程安全的,在多线程环境中使用它可能会出现意想不到的结果。多线程中的ArrayList:我们可以从一段代码了解并发环境下使用ArrayList的情况:publicclassConcurrentArrayList{publicstaticvoidmain(String[]args)throwsInterruptedException{List<Integer>l

大家好,又见面了,我是你们的朋友全栈君。

在单线程开发环境中,我们经常使用ArrayList作容器来存储我们的数据,但它不是线程安全的,在多线程环境中使用它可能会出现意想不到的结果。

多线程中的ArrayList:

我们可以从一段代码了解并发环境下使用ArrayList的情况:

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

        Runnable runnable = () -> { 
   
            for (int i = 0; i < 10000; i++) { 
   
                list.add(i);
            }
        };
        
        for (int i = 0; i < 2; i++) { 
   
            new Thread(runnable).start();
        }
        
        Thread.sleep(500);
        System.out.println(list.size());
    }
}

代码中循环创建了两个线程,这两个线程都执行10000次数组的添加操作,理论上最后输出的结果应该为20000,但经过多次尝试,最后只出现了两种结果:

  1. 数组索引越界异常
Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 10
	at java.util.ArrayList.add(ArrayList.java:463)
	at ConcurrentArrayList.lambda$main$0(ConcurrentArrayList.java:14)
	at java.lang.Thread.run(Thread.java:748)
10007
  1. 输出结果小于20000
16093

虽然仍有可能得到20000的结果,但概率非常低。我们要从ArrayList的源码中去分析为什么会出现这种结果。
ArrayList数组默认初始化大小:

// 默认初始大小
private static final int DEFAULT_CAPACITY = 10;
...
// 数组size
private int size;

ArrayList的add方法:


public boolean add(E e) { 
   
    //确定集合的大小是否足够,如果不够则会进行扩容
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

以上面错误1:ArrayIndexOutOfBoundsException: 10为例,出现错误的步骤如下:

  1. 假设某时刻Thread-0和Thread-1都执行到了elementData[size++] = e; 这步,获取的size大小都为9,此时轮到Thread-1执行
  2. Thread-1执行elementData[9] = e,空间刚刚好够用,赋值完后size变为10。接着轮到Thread-0执行
  3. 因为Thread-0已经跳过了ensureCapacityInternal(size + 1); 这步判断容量的检查步骤,因此它执行elementData[10] = e,而数组容量刚好为10!此时就出现了数组越界的错误。

另外,size++本身就是非原子性的,多个线程之间访问冲突,这时两个线程可能对同一个位置赋值,这就出现了出现size小于期望值的错误2结果。

线程安全的List

目前比较常用的构建线程安全的List有三种方法:

  1. 使用Vector容器
  2. 使用Collections的静态方法synchronizedList(List< T> list)
  3. 采用CopyOnWriteArrayList容器

1.使用Vector容器

Vector类实现了可扩展的对象数组,并且它是线程安全的。它和ArrayList在常用方法的实现上很相似,不同的只是它采用了同步关键词synchronized修饰方法。
ArrayList中的add方法:

public void add(int index, E element) { 
   
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

Vector中的add方法:

public void add(int index, E element) { 
   
    insertElementAt(element, index);
}
...
// 使用了synchronized关键词修饰
public synchronized void insertElementAt(E obj, int index) { 
   
        modCount++;
        if (index > elementCount) { 
   
            throw new ArrayIndexOutOfBoundsException(index
                                                     + " > " + elementCount);
        }
        ensureCapacityHelper(elementCount + 1);
        System.arraycopy(elementData, index, elementData, index + 1, elementCount - index);
        elementData[index] = obj;
        elementCount++;
    }

可以看出,Vector在通用方法的实现上ArrayList并没有什么区别(这里不比较扩容方式等细节)

2. Collections.synchronizedList(List< T> list)

使用这种方法我们可以获得线程安全的List容器,它和Vector的区别在于它采用了同步代码块实现线程间的同步。通过分析源码,它的底层使用了新的容器包装原始的List。
下图是新容器的继承关系图:
在这里插入图片描述
synchronizedList方法:

public static <T> List<T> synchronizedList(List<T> list) { 
   
        return (list instanceof RandomAccess ?
                new SynchronizedRandomAccessList<>(list) :
                new SynchronizedList<>(list));
    }

因为ArrayList实现了RandomAccess接口,因此该方法返回一个SynchronizedRandomAccessList实例。
该类的add实现:

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

其中,mutex是final修饰的一个对象:

final Object mutex;

我们可以看到,这种线程安全容器是通过同步代码块来实现的,基础的add方法任然是由ArrayList实现。

我们再来看看它的读方法:

public E get(int index) { 
   
    synchronized (mutex) { 
   return list.get(index);}
}

和写方法没什么区别,同样是使用了同步代码块。线程同步的实现原理非常简单!

通过上面的分析可以看出,无论是读操作还是写操作,它都会进行加锁,当线程的并发级别非常高时就会浪费掉大量的资源,因此某些情况下它并不是一个好的选择。针对这个问题,我们引出第三种线程安全容器的实现。

3. CopyOnWriteArrayList

顾名思义,它的意思就是在写操作的时候复制数组。为了将读取的性能发挥到极致,在该类的使用过程中,读读操作和读写操作都不互斥,这是一个很神奇的操作,接下来我们看看它如何实现。

    public boolean add(E e) { 
   
        final ReentrantLock lock = this.lock;
        lock.lock();
        try { 
   
            Object[] elements = getArray();
            int len = elements.length;
            // 复制数组
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            // 赋值
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally { 
   
            lock.unlock();
        }
    }

从CopyOnWriteArrayList的add实现方式可以看出它是通过lock来实现线程间的同步的,这是一个标准的lock写法。那么它是怎么做到读写互斥的呢?

// 复制数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 赋值
newElements[len] = e;

真实实现读写互斥的细节就在这两行代码上。在面临写操作的时候,CopyOnWriteArrayList会先复制原来的数组并且在新数组上进行修改,最后再将原数组覆盖。如果写操作的过程中发生了线程切换,并且切换到读线程,因为此时数组并未发生覆盖,读操作读取的还是原数组。

换句话说,就是读操作和写操作位于不同的数组上,因此它们不会发生安全问题。

另外,数组定义private transient volatile Object[] array,其中采用volatile修饰,保证内存可见性,读取线程可以马上知道这个修改。

private transient volatile Object[] array;

三种方式的性能比较

1. 首先我们来看看三种方式在写操作的情况:

public class ConcurrentList { 
   
    public static void main(String[] args) { 
   
        testVector();
        testSynchronizedList();
        testCopyOnWriteArrayList();
    }

    public static void testVector(){ 
   
        Vector vector = new Vector();
        long time1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) { 
   
            vector.add(i);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("vector: "+(time2-time1));
    }

    public static void testSynchronizedList(){ 
   
        List<Integer> list = Collections.synchronizedList(new ArrayList<Integer>());
        long time1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) { 
   
            list.add(i);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("synchronizedList: "+(time2-time1));
    }

    public static void testCopyOnWriteArrayList(){ 
   
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        long time1 = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) { 
   
            list.add(i);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("copyOnWriteArrayList: "+(time2-time1));
    }
}

在代码中我让Vector和SynchronizedList两种实现方式进行写操作10000000次,而CopyOnWriteArrayList仅仅只有100000次,与前两种方式少了100倍!
而结果却出乎意料:

vector: 3202
synchronizedList: 1795
copyOnWriteArrayList: 8159

第三种方式使用的时间远大于前两种,写操作越多,时间差就越明显。

看似出乎意料,实则意料之中,copyOnWriteArrayList每进行一次写操作都会复制一次数组,这是非常耗时的操作,因此在面临巨大的写操作量时才会差异这么大。

不过前两种方式之间为什么差异也很明显?可能因为同步代码块比同步方法效率更高?但是同步代码块是直接包含ArrayList的add方法,理论上两种同步方式应该差异不大,欢迎大佬指点。

我们再来看看三种方式在读操作的情况:

2. 我们再来看看三种方式在读操作的情况:

public class ConcurrentList { 
   
    public static void main(String[] args) { 
   
        testVector();
        testSynchronizedList();
        testCopyOnWriteArrayList();
    }

    public static void testVector(){ 
   
        Vector<Integer> vector = new Vector<>();
        vector.add(0);
        long time1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) { 
   
            vector.get(0);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("vector: "+(time2-time1));
    }

    public static void testSynchronizedList(){ 
   
        List<Integer> list = Collections.synchronizedList(new ArrayList<Integer>());
        list.add(0);
        long time1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) { 
   
            list.get(0);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("synchronizedList: "+(time2-time1));
    }

    public static void testCopyOnWriteArrayList(){ 
   
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        list.add(0);
        long time1 = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) { 
   
            list.get(0);
        }
        long time2 = System.currentTimeMillis();
        System.out.println("copyOnWriteArrayList: "+(time2-time1));
    }
}

这一次三种方式都进行了10000000次读操作,结果如下:

vector: 217
synchronizedList: 224
copyOnWriteArrayList: 12

这次copyOnWriteArrayList的优势就显示出来了,它的读操作没有实现同步,因此加快了多线程的读操作。其他两种方式的差别不大。

总结

  1. 获取线程安全的List我们可以通过Vector、Collections.synchronizedList()方法和CopyOnWriteArrayList三种方式
  2. 读多写少的情况下,推荐使用CopyOnWriteArrayList方式
  3. 读少写多的情况下,推荐使用Collections.synchronizedList()的方式

参考:

  1. 并发容器(二)—线程安全的List
  2. SynchronizedList和Vector的区别
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。

发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/125519.html原文链接:https://javaforall.net

(0)
上一篇 2022年4月18日 下午7:00
下一篇 2022年4月18日 下午7:00


相关推荐

  • HashMap底层数据结构原理解析[通俗易懂]

    HashMap底层数据结构原理解析[通俗易懂]老师:JDK中我们最常用的一个数据类是HashMap。那么,谁可以回答一下HashMap的底层数据结构原理是什么呢?小明:老师,我知道。众所周知,HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。HashMap数组每一个元素的初始值都是Null。…

    2022年5月19日
    40
  • android listview添加headview

    android listview添加headviewlistview添加headview后的点击事件1、头部view:设置view的点击时间,用于屏蔽listview的item的点击事件 ViewheaderView=getLayoutInflater().inflate(R.layout.listhead,null); Buttonbtn=(Button)headerView.findViewById(R.id.bu

    2022年7月22日
    10
  • Python/Pycharm的缩进规则 及快捷键

    Python/Pycharm的缩进规则 及快捷键1 pycharm 使多行代码同时缩进鼠标选中多行代码后 按下 Tab 键 一次缩进四个字符 2 pycharm 使多行代码同时左移鼠标选中多行代码后 同时按住 shift Tab 键 一次左移四个字符 Python 语言是一款对缩进非常敏感的语言 给很多初学者带来了困惑 即便是很有经验的 Python 程序员 也可能陷入陷阱当中 最常见的情况是 tab 和空格的混用会导致错误 或者缩进不对 而这是用肉眼无法分别的

    2026年3月19日
    2
  • matplotlib绘图太单调 | 我们可以自定义背景图片

    matplotlib绘图太单调 | 我们可以自定义背景图片读者朋友们好 今天分享 matplotlib 自定义背景图片 本文讲一下如何使用 matplotlib 在自定义背景上进行图像绘制 比如下方的图片就是在找好背景图之后 使用 matplotlib 绘制而成其实呢 使用自定义背景是非常简单的事情 找好你要的背景图 之后使用下面三行代码就能将这张图设置为背景 img plt imread 背景 png fig ax plt subplots figsiz

    2026年3月18日
    2
  • Java学习路线图(如何快速学Java)

    Java学习路线图(如何快速学Java)不知不觉从初学Java到现在已经8年了,今天在这里给刚入门和入门不久的小伙伴们一些建议。可能总结的不是很详细,但给出了一个大概的学习路线。希望对大家有帮助哈~如何快速学Java这里我以JavaEE(JakartaEE)/JavaWeb的经验来说哦。(都把你们看做是零基础入门的了)学习JavaEE(JakartaEE)总体来说会有以下三大模块:Java 数据库 We…

    2022年5月17日
    38
  • MP算法和OMP算法及其思想

    MP算法和OMP算法及其思想

    2021年11月28日
    41

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注全栈程序员社区公众号