CopyOnWriteArrayList源码阅读
在本篇博客中我们将深入阅读 CopyOnWriteArrayList 的关键源码。在此之前我们需要先思考一个问题我们都知道 ArrayList 不是线程安全的在多线程环境下多个线程同时修改它很容易就会抛出异常。为了规避这个问题我们通常会退而求其次使用 Vector或者用Collections.synchronizedList把 ArrayList 包装。但在分析CopyOnWriteArrayList 的优势之前我们先要看清传统方案的短板。 Vector和synchronizedList之所以能保证线程安全是因为它们在底层的每一个公开方法上都加了synchronized关键字。这意味着整个列表实例内持有一把互斥锁无论是增加元素、删除元素都需要先竞争这把锁它的读写功能是串行化的。那么在实际开发中很多场景里“读”操作的频率是远远高于“写”操作的——比如系统里的配置信息、敏感词黑名单、监听器回调列表这时候如果还让“读”等“写”性能就大打折扣了。那么有没有一种办法让“读”和“写”彻底分离互不打扰呢这就要引出我们今天讨论的主题——CopyOnWriteArrayList。接下来让我们来梳理它内部的关键方法。(1) 锁的定义在 CopyOnWriteArrayList的成员变量里定义了一个名叫lock的ReentrantLock对象。这个锁的作用是只用来管写操作。当一个线程想要往列表里加东西、删东西或者改东西时必须先拿到这把锁。这保证了同一时刻只有一个线程在修改底层的数组防止出现数据错乱。(2) add() 方法先复制再写入最后替换我们先来看最核心的add()方法当线程调用add()添加元素时第一步就是“获取锁”。拿到锁之后它并不会直接在原数组上追加元素而是执行以下三步操作首先它获取当前底层数组的引用并记录原数组的长度。接着它创建一个新数组长度是原数组的长度加一。然后它把原数组中的所有元素原封不动地复制到这个新数组中再把待添加的新元素放到新数组的最后一个位置。最后一步它把类内部持有的数组引用从指向原数组替换为指向这个新数组最后释放锁。在整个复制与替换的过程中外面的读线程读到的自然是修改前的旧数据。直到写线程完成替换、释放锁之后新进入的读线程才能看到新数组中的内容。不过这种方式的代价也很直观每次写入都要复制整个数组数组越大复制的内存开销和时间开销就越大。因此它只适合写操作少、数据量不大的场景。3remove() 方法同样复制跳过被删元素当我们理解了add()的逻辑之后remove()的实现思路就可以以此类推了remove()同样需要“先获取锁”确保删除操作的原子性。拿到锁后它依然遵循“复制一份新数组”的原则。但与add()不同的是新数组的长度是原长度减一并且在复制过程中它会跳过那个被指定删除的下标。具体来说它将原数组中被删下标之前的元素复制到新数组的前半段再将被删下标之后的元素紧接着复制到新数组的后半段中间那个被删的元素就这样被略过了。复制完成后同样执行数组引用的替换操作最后释放锁。在这个过程中读线程依然可以访问原数组不会感知到有元素正在被删除。等引用替换完成之后后续的读操作就能看到删除后的新数组了。(4) set() 方法修改单个位置复制全量接下来是set()方法它的功能是替换指定下标处的元素。它像前两个方法一样依旧需要复制数组“只是修改单个下标的位置元素为什么需要复制数组呢”原因在于如果直接在原数组上修改某个下标的值而此时恰好有一个读线程正在遍历这个数组它可能会读到修改了一半的不一致状态甚至在某些极端情况下触发异常。因此为了保证读线程在遍历时始终面对的是一个稳定的、不会发生变化的数组快照即便是修改单个下标的值也必须要进行复制数组的流程。具体的说set()方法执行时同样需要“先获取锁确保修改操作的原子性。拿到锁之后它首先获取当前底层数组的引用并记录下指定下标位置的旧值。接着它会创建一个与原数组长度完全相同的新数组然后将原数组中的所有元素原封不动地复制到新数组中。复制完成后它在新数组的指定下标位置写入新值最后将类内部的数组引用从指向原数组替换为指向这个新数组并释放锁。那么在这个过程中外面的读线程依然持有着原数组的引用读到的自然还是修改前的旧值。直到引用替换完成、锁释放之后新进入的读线程才能看到修改后的内容。(5) get() 方法无锁读取最后我们来看get()方法get()方法的实现“完全不加锁”。它直接通过volatile修饰的底层数组引用获取当前的数组对象然后返回对应下标的值。由于读线程没有进入任何同步块因此即便此时写线程正拿着锁在复制新数组读线程依然可以从旧数组中无障碍地读取数据。当然这种无障碍也有它的弱点读到的数据可能存在滞后性。由于读线程持有的是写操作发生前的数组它无法感知到正在进行的修改。读完这几个关键方法的内部实现我们对CopyOnWriteArrayList便有了一个清晰的定位。它的底层是一个Object[]数组线程安全通过一把ReentrantLock锁来保障但这把锁只作用于写操作——add()、remove()、set()在执行时都需要先获取锁然后复制一份新数组在新数组上完成修改最后替换引用。而get()方法完全不加锁直接读取当前数组引用因此读到的可能是一个稍显滞后的数据。这种“写时复制、读写分离”的设计让它非常适合读多写少、数据量不大的场景。希望我们的总结能帮你理清CopyOnWriteArrayList的底层脉络对它有一个更深入的了解。