java并发之CopyOnWriteList 传承于linux的写时复制

352 阅读4分钟

CopyOnWriteList是线程安全的List实现,其底层数据存储结构为数组(Object[] array),它在读操作远远多于写操作的场景下表现良好,这其中的原因在于其读操作(get(),indexOf(),isEmpty(),contains())不加任何锁,而写操作(set(),add(),remove())通过Arrays.copyOf()操作拷贝当前底层数据结构(array).

CopyAndWirteList在构造器中初始化时,将自己本身类中的数组暴露,写入另外一个的数组

     /**
     * Creates a list containing the elements of the specified
     * collection, in the order they are returned by the collection's
     * iterator.
     *
     * @param c the collection of initially held elements
     * @throws NullPointerException if the specified collection is null
     */
    public CopyOnWriteArrayList(Collection<? extends E> c) {
        Object[] elements;
        if (c.getClass() == CopyOnWriteArrayList.class)
            elements = ((CopyOnWriteArrayList<?>)c).getArray();
        else {
            elements = c.toArray();
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elements.getClass() != Object[].class)
                elements = Arrays.copyOf(elements, elements.length, Object[].class);
        }
        setArray(elements);
    }

CopyOnWriteList在这些写操作上通过一个ReetranLock进行并发控制。在从原数组复制到数组镜像之后,CopyOnWriteList所实现的迭代器其数据也是底层数组镜像,所以在CopyOnWriteList进行迭代器遍历,那么并发场景下CopyOnWriteList里的进行遍历就不会抛异常,当然在迭代器上做remove,add,set也是无效的(抛UnsupportedOperationExcetion),因为迭代器上的数据只是当前List的数据数组的一个拷贝而已。

 /**
     * Replaces the element at the specified position in this list with the
     * specified element.
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);
            //如果从源数组中取出的值不等于需要设置的元素,则开辟一个新的数组存入
            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                //如果新值等于旧值,则更新源数组,由于JMM可见性并发读
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

一脉相承的linux 写时复制

COW技术初窥:

  • 在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

这看起来有点像java中的ThreadLocal线程副本类,实际上也是用到写时复制的原理,只不过TreadLocal不是直接保存线程的上下文,在复制之前,是从InheritableThreadLocal进行副本的读取

言归正传,看到以下代码

string str1="hello world";
string srr2=str1;
str1[1]='a';
str2[1]='b';

在c++中这几行代码的意义是 此时str1的地址会发生变化,而str2的地址还是原来的。即在复制对象时,并不真正为新对象开辟内存空间,而是在新对象的内存映射表中设立一个指针,指向源对象,在jvm中的虚拟机栈中的方法引用也是设置在栈帧中一个引用,每当有堆区有新元素实例化申请地址,则把引用指向这个地址

jvm mmap映射定义

  • 有限数量的mmap句柄

每个进程的mmap句柄数量有限(Linux上为64K),如果使用太多句柄,JVM进程将无法mmap新文件。通常,您的代码将失败,但异常:

Caused by: java.lang.OutOfMemoryError: Map failed at sun.nio.ch.FileChannelImpl.map0(Native Method) at sun.nio.ch.FileChannelImpl.map(FileChannelImpl.java:937) 但是这种失败可能发生在JVM的任何地方(例如,当它加载新的lib时),甚至可能导致JVM崩溃。

可以使用此命令计算进程正在使用的mmap句柄数: sudo cat /proc/$PID/maps | wc -l

还可以增加mmap句柄的数量/proc/sys/vm/max_map_count。

没有取消映射

每个映射ByteBuffer使用一个mmap句柄。但是没有正式的方法来释放这个句柄。 没有正式的方法来关闭内存映射缓冲区。只有在ByteBuffer收集垃圾后,JVM才会释放文件句柄。如果GC未被调用一段时间(大型可用堆),则将耗尽文件句柄。

解决方法是在ByteBuffer收集垃圾之前,它使用反射来访问释放mmap文件句柄的未记录的API。但是,在ByteBuffer取消映射后无法使用它,对它的任何访问都会导致无效的内存操作并导致JVM崩溃。