Java并发知识点(三)ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList

本文主要是介绍Java并发知识点(三)ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

文章目录

    • 一、ConcurrentHashMap
      • 1.1 ConcurrentHashMap的关键属性
      • 1.2 ConcurrentHashMap的关键内部类
      • 1.3 构造方法
      • 1.4 initTable方法
      • 1.5 put方法
      • 1.6 get方法
      • 1.7 transfer方法
      • 1.8 总结
    • 二、ConcurrentLinkedQueue
      • 2.1 Node
      • 2.2 操作Node的几个CAS操作
      • 2.3 offer方法
      • 2.4 poll方法
      • 2.5 offer方法中部分线程offer部分线程poll
      • 2.6 HOPS的设计
    • 三、CopyOnWriteArrayList
      • 3.1 COW的设计思想
      • 3.2 CopyOnWriteArrayList的实现原理
        • 3.2.1 get方法实现原理
        • 3.2.2 add方法实现原理
      • 3.3 总结

一、ConcurrentHashMap

  相对于hashmap来说,ConcurrentHashMap就是线程安全的map,其中利用了锁分段的思想提高了并发度。
  ConcurrentHashMap在JDK 1.6版本关键要素:

  1. segment继承了ReentrantLock充当锁的角色,为每一个segment提供了线程安全的保障;
  2. segment维护了哈希散列表的若干个桶,每个桶由HashEntry构成的链表。

  到了JDK 1.8,舍弃了segment,并且大量使用了synchronized和CAS无锁操作,以保证ConcurrentHashMap操作的线程安全性。至于为什么不用ReentrantLock而是Synchronzied呢?实际上,synchronzied做了很多的优化,包括偏向锁,轻量级锁,重量级锁,可以依次向上升级锁状态,但不能降级,因此,使用synchronized相较于ReentrantLock的性能会持平甚至在某些情况更优。

ConcurrentHashMap插入时效率未必提升很多,读时效率提升多。

  接下来以JDK1.8中的ConcurrentHashMap为例,介绍下其源码。

1.1 ConcurrentHashMap的关键属性

  1. table
      volatile Node<K,V>[] table;//装载Node的数组,作为ConcurrentHashMap的数据容器,采用懒加载的方式,直到第一次插入数据的时候才会进行初始化操作,数组的大小总是为2的幂次方。
  2. nextTable
      volatile Node<K,V>[] nextTable;//扩容时使用,平时为null,只有在扩容的时候才为非null。
  3. sizeCtl
      volatile int sizeCtl;
      该属性用来控制table数组的大小,根据是否初始化和是否正在扩容有几种情况:
       1>当值为负数时:如果为-1表示正在初始化,如果为-N则表示当前正有N-1个线程进行扩容操作;
       2>当值为正数时:如果当前数组为null的话表示table在初始化过程中,sizeCtl表示为需要新建数组的长度;
       3>若已经初始化了,表示当前数据容器(table数组)可用容量也可以理解成临界值(插入节点数超过了该临界值就需要扩容),具体指为数组的长度n 乘以 加载因子loadFactor;
       4>当值为0时,即数组长度为默认初始值。
  4. sun.misc.Unsafe U
      在ConcurrentHashMapde的实现中可以看到大量的U.compareAndSwapXXXX的方法去修改ConcurrentHashMap的一些属性。这些方法实际上是利用了CAS算法保证了线程安全性,这是一种乐观策略,假设每一次操作都不会产生冲突,当且仅当冲突发生的时候再去尝试。而CAS操作依赖于现代处理器指令集,通过底层CMPXCHG指令实现。在大量的同步组件和并发容器的实现中使用CAS是通过sun.misc.Unsafe类实现的,该类提供了一些可以直接操控内存和线程的底层操作,可以理解为java中的“指针”。该成员变量的获取是在静态代码块中:
static {try {U = sun.misc.Unsafe.getUnsafe();.......} catch (Exception e) {throw new Error(e);}
}

1.2 ConcurrentHashMap的关键内部类

  1. Node
      Node类实现了Map.Entry接口,主要存放key-value对,并且具有next域:
static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;volatile V val;volatile Node<K,V> next;......
}

  可以看出很多属性都是用volatile进行修饰的,也就是为了保证内存可见性。
2. TreeNode
  树节点,继承于承载数据的Node类。而红黑树的操作是针对TreeBin类的,从该类的注释也可以看出,也就是TreeBin会将TreeNode进行再一次封装:

static final class TreeNode<K,V> extends Node<K,V> {TreeNode<K,V> parent;  // red-black tree linksTreeNode<K,V> left;TreeNode<K,V> right;TreeNode<K,V> prev;    // needed to unlink next upon deletionboolean red;......
}
  1. TreeBin
      这个类并不负责包装用户的key、value信息,而是包装的很多TreeNode节点。实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象。
static final class TreeBin<K,V> extends Node<K,V> {TreeNode<K,V> root;volatile TreeNode<K,V> first;volatile Thread waiter;volatile int lockState;// values for lockStatestatic final int WRITER = 1; // set while holding write lockstatic final int WAITER = 2; // set when waiting for write lockstatic final int READER = 4; // increment value for setting read lock......
}
  1. ForwardingNode
      在扩容时才会出现的特殊节点,其key,value,hash全部为null。并拥有nextTable指针引用新的table数组。
static final class ForwardingNode<K,V> extends Node<K,V> {final Node<K,V>[] nextTable;ForwardingNode(Node<K,V>[] tab) {super(MOVED, null, null, null);this.nextTable = tab;}.....
}

1.3 构造方法

  先看代码:

// 1. 构造一个空的map,即table数组还未初始化,初始化放在第一次插入数据时,默认大小为16
ConcurrentHashMap()
// 2. 给定map的大小
ConcurrentHashMap(int initialCapacity) 
// 3. 给定一个map
ConcurrentHashMap(Map<? extends K, ? extends V> m)
// 4. 给定map的大小以及加载因子
ConcurrentHashMap(int initialCapacity, float loadFactor)
// 5. 给定map大小,加载因子以及并发度(预计同时操作数据的线程)
ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)

  ConcurrentHashMap一共给我们提供了5种构造器方法看下第2种构造器,传入指定大小时的情况,该构造器源码为:

public ConcurrentHashMap(int initialCapacity) {//1. 小于0直接抛异常if (initialCapacity < 0)throw new IllegalArgumentException();//2. 判断是否超过了允许的最大值,超过了话则取最大值,否则再对该值进一步处理int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?MAXIMUM_CAPACITY :tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));//3. 赋值给sizeCtlthis.sizeCtl = cap;
}

  如果小于0就直接抛出异常,如果指定值大于了所允许的最大值的话就取最大值,否则,在对指定值做进一步处理。最后将cap赋值给sizeCtl,关于sizeCtl的说明请看上面的说明,当调用构造器方法之后,sizeCtl的大小应该就代表了ConcurrentHashMap的大小,即table数组长度。tableSizeFor源码为:

/*** Returns a power of two table size for the given desired capacity.* See Hackers Delight, sec 3.2*/
private static final int tableSizeFor(int c) {int n = c - 1;n |= n >>> 1;n |= n >>> 2;n |= n >>> 4;n |= n >>> 8;n |= n >>> 16;return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

  该方法会将调用构造器方法时指定的大小转换成一个2的幂次方数,也就是说ConcurrentHashMap的大小一定是2的幂次方,比如,当指定大小为18时,为了满足2的幂次方特性,实际上concurrentHashMapd的大小为2的5次方(32)。另外,需要注意的是,调用构造器方法的时候并未构造出table数组(可以理解为ConcurrentHashMap的数据容器),只是算出table数组的长度,当第一次向ConcurrentHashMap插入数据的时候才真正的完成初始化创建table数组的工作。

1.4 initTable方法

  先看代码:

private final Node<K,V>[] initTable() {Node<K,V>[] tab; int sc;while ((tab = table) == null || tab.length == 0) {if ((sc = sizeCtl) < 0)// 1. 保证只有一个线程正在进行初始化操作Thread.yield(); // lost initialization race; just spinelse if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {try {if ((tab = table) == null || tab.length == 0) {// 2. 得出数组的大小int n = (sc > 0) ? sc : DEFAULT_CAPACITY;@SuppressWarnings("unchecked")// 3. 这里才真正的初始化数组Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];table = tab = nt;// 4. 计算数组中可用的大小:实际大小n*0.75(加载因子)sc = n - (n >>> 2);}} finally {sizeCtl = sc;}break;}}return tab;
}

  有可能存在一个情况是多个线程同时走到这个方法中,为了保证能够正确初始化,在第1步中会先通过if进行判断,若当前已经有一个线程正在初始化即sizeCtl值变为-1,这个时候其他线程在If判断为true从而调用Thread.yield()让出CPU时间片。正在进行初始化的线程会调用U.compareAndSwapInt方法将sizeCtl改为-1即正在初始化的状态。另外还需要注意的事情是,在第四步中会进一步计算数组中可用的大小即为数组实际大小n乘以加载因子0.75,可以看看这里乘以0.75是怎么算的,0.75为四分之三,这里n - (n >>> 2)是不是刚好是n-(1/4)n=(3/4)n,挺有意思的吧。如果选择是无参的构造器的话,这里在new Node数组的时候会使用默认大小为DEFAULT_CAPACITY(16),然后乘以加载因子0.75为12,也就是说数组的可用大小为12。

1.5 put方法

  调用put方法时实际具体实现是putVal方法,源码:

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {if (key == null || value == null) throw new NullPointerException();//1. 计算key的hash值int hash = spread(key.hashCode());int binCount = 0;for (Node<K,V>[] tab = table;;) {Node<K,V> f; int n, i, fh;//2. 如果当前table还没有初始化先调用initTable方法将tab进行初始化if (tab == null || (n = tab.length) == 0)tab = initTable();//3. tab中索引为i的位置的元素为null,则直接使用CAS将值插入即可else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))break;                   // no lock when adding to empty bin}//4. 当前正在扩容else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f);else {V oldVal = null;synchronized (f) {if (tabAt(tab, i) == f) {//5. 当前为链表,在链表中插入新的键值对if (fh >= 0) {binCount = 1;for (Node<K,V> e = f;; ++binCount) {K ek;if (e.hash == hash &&((ek = e.key) == key ||(ek != null && key.equals(ek)))) {oldVal = e.val;if (!onlyIfAbsent)e.val = value;break;}Node<K,V> pred = e;if ((e = e.next) == null) {pred.next = new Node<K,V>(hash, key,value, null);break;}}}// 6.当前为红黑树,将新的键值对插入到红黑树中else if (f instanceof TreeBin) {Node<K,V> p;binCount = 2;if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,value)) != null) {oldVal = p.val;if (!onlyIfAbsent)p.val = value;}}}}// 7.插入完键值对后再根据实际大小看是否需要转换成红黑树if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD)treeifyBin(tab, i);if (oldVal != null)return oldVal;break;}}}//8.对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容 addCount(1L, binCount);return null;
}

  从整体而言,为了解决线程安全的问题,ConcurrentHashMap使用了synchronzied和CAS的方式。ConcurrentHashMap结构图:

  ConcurrentHashMap是一个哈希桶数组,如果不出现哈希冲突的时候,每个元素均匀的分布在哈希桶数组中。当出现哈希冲突的时候,是标准的链地址的解决方式,将hash值相同的节点构成链表的形式,称为“拉链法”,另外,在1.8版本中为了防止拉链过长,当链表的长度大于8的时候会将链表转换成红黑树。table数组中的每个元素实际上是单链表的头结点或者红黑树的根节点。当插入键值对时首先应该定位到要插入的桶,即插入table数组的索引i处。那么,怎样计算得出索引i呢?当然是根据key的hashCode值。
  该方法可按注释分为8步:

  • 1、spread()重哈希,以减小Hash冲突
      对于一个hash表来说,hash值分散的不够均匀的话会大大增加哈希冲突的概率,从而影响到hash表的性能。因此通过spread方法进行了一次重hash从而大大减小哈希冲突的可能性。spread方法为:
static final int spread(int h) {return (h ^ (h >>> 16)) & HASH_BITS;
}

  该方法主要是将key的hashCode的低16位和高16位进行异或运算,这样不仅能够使得hash值能够分散能够均匀减小hash冲突的概率,另外只用到了异或运算,在性能开销上也能兼顾。

  • 2、初始化table
      判断当前table数组是否初始化,没有的话就调用initTable进行初始化。
  • 3、能否直接将新值插入到table数组中
      如果插入值待插入的位置刚好所在的table数组为null的话就可以直接将值插入即可。那么怎样根据hash确定在table中待插入的索引i呢?很显然可以通过hash值与数组的长度取模操作,从而确定新值插入到数组的哪个位置。而之前我们提过ConcurrentHashMap的大小总是2的幂次方,(n - 1) & hash运算等价于对长度n取模,也就是hash%n,但是位运算比取模运算的效率要高很多。
      确定好数组的索引i后,就可以调用tabAt()方法获取该位置上的元素,如果当前Node f为null的话,就可以直接用casTabAt方法将新值插入即可。
  • 4、当前是否正在扩容
      如果当前节点不为null,且该节点为特殊节点(forwardingNode)的话,就说明当前concurrentHashMap正在进行扩容操作,关于扩容操作,下面会作为一个具体的方法进行讲解。那么怎样确定当前的这个Node是不是特殊的节点了?是通过判断该节点的hash值是不是等于-1(MOVED),代码为(fh = f.hash) == MOVED。
  • 5、当table[i]为链表的头结点,在链表中插入新值
      在table[i]不为null并且不为forwardingNode时,并且当前Node f的hash值大于0(fh >= 0)的话说明当前节点f为当前桶的所有的节点组成的链表的头结点。那么接下来,要想向ConcurrentHashMap插入新值的话就是向这个链表插入新值。通过synchronized (f)的方式进行加锁以实现线程安全性。往链表中插入节点的部分代码为:
if (fh >= 0) {binCount = 1;for (Node<K,V> e = f;; ++binCount) {K ek;// 找到hash值相同的key,覆盖旧值即可if (e.hash == hash &&((ek = e.key) == key ||(ek != null && key.equals(ek)))) {oldVal = e.val;if (!onlyIfAbsent)e.val = value;break;}Node<K,V> pred = e;if ((e = e.next) == null) {//如果到链表末尾仍未找到,则直接将新值插入到链表末尾即可pred.next = new Node<K,V>(hash, key,value, null);break;}}
}

  这部分代码表示两种情况:1. 在链表中如果找到了与待插入的键值对的key相同的节点,就直接覆盖即可;2. 如果直到找到了链表的末尾都没有找到的话,就直接将待插入的键值对追加到链表的末尾即可。

  • 6、当table[i]为红黑树的根节点,在红黑树中插入新值
      按照之前的数组+链表的设计方案,这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,甚至在极端情况下,查找一个节点会出现时间复杂度为O(n)的情况,则会严重影响ConcurrentHashMap的性能,于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高ConcurrentHashMap的性能,其中会用到红黑树的插入、删除、查找等算法。当table[i]为红黑树的树节点时的操作为:
if (f instanceof TreeBin) {Node<K,V> p;binCount = 2;if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,value)) != null) {oldVal = p.val;if (!onlyIfAbsent)p.val = value;}
}

  首先在if中通过f instanceof TreeBin判断当前table[i]是否是树节点,这下也正好验证了我们在最上面介绍时说的TreeBin会对TreeNode做进一步封装,对红黑树进行操作的时候针对的是TreeBin而不是TreeNode。这段代码很简单,调用putTreeVal方法完成向红黑树插入新节点,同样的逻辑,如果在红黑树中存在于待插入键值对的Key相同(hash值相等并且equals方法判断为true)的节点的话,就覆盖旧值,否则就向红黑树追加新节点。

  • 7、根据当前节点个数进行调整
      当完成数据新节点插入之后,会进一步对当前链表大小进行调整,这部分代码为:
if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD)treeifyBin(tab, i);if (oldVal != null)return oldVal;break;
}

  如果当前链表节点个数大于等于8(TREEIFY_THRESHOLD)的时候,就会调用treeifyBin方法将tabel[i](第i个散列桶)拉链转换成红黑树。

  将上面的步骤串起来,整体流程:

  1. 首先对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在 table中的位置;
  2. 如果当前table数组还未初始化,先将table数组进行初始化操作;
  3. 如果这个位置是null的,那么使用CAS操作直接放入;
  4. 如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。如果该节点fh==MOVED(代表forwardingNode,数组正在进行扩容)的话,说明正在进行扩容;
  5. 如果是链表节点(fh>0),则得到的结点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到key相同的节点,则只需要覆盖该结点的value值即可。否则依次向后遍历,直到链表尾插入这个结点;
  6. 如果这个节点的类型是TreeBin的话,直接调用红黑树的插入方法进行插入新的节点;
  7. 插入完节点之后再次检查链表长度,如果长度大于8,就把这个链表转换成红黑树;
  8. 对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容。

1.6 get方法

  源码为:

public V get(Object key) {Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;// 1. 重hashint h = spread(key.hashCode());if ((tab = table) != null && (n = tab.length) > 0 &&(e = tabAt(tab, (n - 1) & h)) != null) {// 2. table[i]桶节点的key与查找的key相同,则直接返回if ((eh = e.hash) == h) {if ((ek = e.key) == key || (ek != null && key.equals(ek)))return e.val;}// 3. 当前节点hash小于0说明为树节点,在红黑树中查找即可else if (eh < 0)return (p = e.find(h, key)) != null ? p.val : null;while ((e = e.next) != null) {//4. 从链表中查找,查找到则返回该节点的value,否则就返回null即可if (e.hash == h &&((ek = e.key) == key || (ek != null && key.equals(ek))))return e.val;}}return null;
}

  先看当前的hash桶数组节点即table[i]是否为查找的节点,若是则直接返回;若不是,则继续再看当前是不是树节点?通过看节点的hash值是否为小于0,如果小于0则为树节点。如果是树节点在红黑树中查找节点;如果不是树节点,那就只剩下为链表的形式的一种可能性了,就向后遍历查找节点,若查找到则返回节点的value即可,若没有找到就返回null。

1.7 transfer方法

  当ConcurrentHashMap容量不足的时候,需要对table进行扩容。这个方法的基本思想跟HashMap是很像的,但是由于它是支持并发扩容的,所以要复杂的多。原因是它支持多线程进行扩容操作,而并没有加锁。这样做的目的不仅仅是为了满足concurrent的要求,而是希望利用并发处理去减少扩容带来的时间影响。transfer方法源码:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {int n = tab.length, stride;if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)stride = MIN_TRANSFER_STRIDE; // subdivide range//1. 新建Node数组,容量为之前的两倍if (nextTab == null) {            // initiatingtry {@SuppressWarnings("unchecked")Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];nextTab = nt;} catch (Throwable ex) {      // try to cope with OOMEsizeCtl = Integer.MAX_VALUE;return;}nextTable = nextTab;transferIndex = n;}int nextn = nextTab.length;//2. 新建forwardingNode引用,在之后会用到ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);boolean advance = true;boolean finishing = false; // to ensure sweep before committing nextTabfor (int i = 0, bound = 0;;) {Node<K,V> f; int fh;// 3. 确定遍历中的索引iwhile (advance) {int nextIndex, nextBound;if (--i >= bound || finishing)advance = false;else if ((nextIndex = transferIndex) <= 0) {i = -1;advance = false;}else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,nextBound = (nextIndex > stride ?nextIndex - stride : 0))) {bound = nextBound;i = nextIndex - 1;advance = false;}}//4.将原数组中的元素复制到新数组中去//4.5 for循环退出,扩容结束修改sizeCtl属性if (i < 0 || i >= n || i + n >= nextn) {int sc;if (finishing) {nextTable = null;table = nextTab;sizeCtl = (n << 1) - (n >>> 1);return;}if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)return;finishing = advance = true;i = n; // recheck before commit}}//4.1 当前数组中第i个元素为null,用CAS设置成特殊节点forwardingNode(可以理解成占位符)else if ((f = tabAt(tab, i)) == null)advance = casTabAt(tab, i, null, fwd);//4.2 如果遍历到ForwardingNode节点  说明这个点已经被处理过了 直接跳过  这里是控制并发扩容的核心else if ((fh = f.hash) == MOVED)advance = true; // already processedelse {synchronized (f) {if (tabAt(tab, i) == f) {Node<K,V> ln, hn;if (fh >= 0) {//4.3 处理当前节点为链表的头结点的情况,构造两个链表,一个是原链表  另一个是原链表的反序排列int runBit = fh & n;Node<K,V> lastRun = f;for (Node<K,V> p = f.next; p != null; p = p.next) {int b = p.hash & n;if (b != runBit) {runBit = b;lastRun = p;}}if (runBit == 0) {ln = lastRun;hn = null;}else {hn = lastRun;ln = null;}for (Node<K,V> p = f; p != lastRun; p = p.next) {int ph = p.hash; K pk = p.key; V pv = p.val;if ((ph & n) == 0)ln = new Node<K,V>(ph, pk, pv, ln);elsehn = new Node<K,V>(ph, pk, pv, hn);}//在nextTable的i位置上插入一个链表setTabAt(nextTab, i, ln);//在nextTable的i+n的位置上插入另一个链表setTabAt(nextTab, i + n, hn);//在table的i位置上插入forwardNode节点  表示已经处理过该节点setTabAt(tab, i, fwd);//设置advance为true 返回到上面的while循环中 就可以执行i--操作advance = true;}//4.4 处理当前节点是TreeBin时的情况,操作和上面的类似else if (f instanceof TreeBin) {TreeBin<K,V> t = (TreeBin<K,V>)f;TreeNode<K,V> lo = null, loTail = null;TreeNode<K,V> hi = null, hiTail = null;int lc = 0, hc = 0;for (Node<K,V> e = t.first; e != null; e = e.next) {int h = e.hash;TreeNode<K,V> p = new TreeNode<K,V>(h, e.key, e.val, null, null);if ((h & n) == 0) {if ((p.prev = loTail) == null)lo = p;elseloTail.next = p;loTail = p;++lc;}else {if ((p.prev = hiTail) == null)hi = p;elsehiTail.next = p;hiTail = p;++hc;}}ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :(hc != 0) ? new TreeBin<K,V>(lo) : t;hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :(lc != 0) ? new TreeBin<K,V>(hi) : t;setTabAt(nextTab, i, ln);setTabAt(nextTab, i + n, hn);setTabAt(tab, i, fwd);advance = true;}}}}}
}

  整个扩容操作分为两个部分:
  第一部分是构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。新建table数组的代码为:Node<K,V>[ ] nt = (Node<K,V>[ ])new Node<?,?>[n << 1],在原容量大小的基础上右移一位。
  第二个部分就是将原来table中的元素复制到nextTable中,主要是遍历复制的过程。根据运算得到当前遍历的数组的位置i,然后利用tabAt方法获得i位置的元素再进行判断:

  1. 如果这个位置为空,就在原table中的i位置放入forwardNode节点,这个也是触发并发扩容的关键点;
  2. 如果这个位置是Node节点(fh>=0),如果它是一个链表的头节点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上;
  3. 如果这个位置是TreeBin节点(fh<0),也做一个反序处理,并且判断是否需要untreefi,把处理的结果分别放在nextTable的i和i+n的位置上;
  4. 遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为新容量的0.75倍 ,完成扩容。设置为新容量的0.75倍代码为 sizeCtl = (n << 1) - (n >>> 1),仔细体会下是不是很巧妙,n<<1相当于n右移一位表示n的两倍即2n,n>>>1左右一位相当于n除以2即0.5n,然后两者相减为2n-0.5n=1.5n,是不是刚好等于新容量的0.75倍即2n*0.75=1.5n。最后用一个示意图来进行总结:
    在这里插入图片描述

1.8 总结

  JDK6、7中的ConcurrentHashmap主要使用Segment来实现减小锁粒度,分割成若干个Segment,在put的时候需要锁住Segment,get时候不加锁,使用volatile来保证可见性,当要统计全局时(比如size),首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size。如果有,则需要依次锁住所有的Segment来计算
  1.8之前put定位节点时要先定位到具体的segment,然后再在segment中定位到具体的桶。而在1.8的时候摒弃了segment臃肿的设计,直接针对的是Node[] tale数组中的每一个桶,进一步减小了锁粒度。并且防止拉链过长导致性能下降,当链表长度大于8的时候采用红黑树的设计。
  主要设计上的变化有以下几点:

  1. 不采用segment而采用node,锁住node来实现减小锁粒度
  2. 设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize
  3. 使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁
  4. sizeCtl的不同值来代表不同含义,起到了控制的作用
  5. 采用synchronized而不是ReentrantLock

二、ConcurrentLinkedQueue

  ConcurrentLinkedQueue是线程安全的队列,从类名就可以看的出来实现队列的数据结构是链式存储。

2.1 Node

  要想先学习ConcurrentLinkedQueue自然而然得先从它的节点类看起,明白它的底层数据结构。Node类的源码为:

private static class Node<E> {volatile E item;volatile Node<E> next;.......
}

  Node节点主要包含了两个域:一个是数据域item,另一个是next指针,用于指向下一个节点从而构成链式队列。并且都是用volatile进行修饰的,以保证内存可见性。另外ConcurrentLinkedQueue含有这样两个成员变量:

private transient volatile Node<E> head;
private transient volatile Node<E> tail;

  说明ConcurrentLinkedQueue通过持有头尾指针进行管理队列。当我们调用无参构造器时,其源码为:

public ConcurrentLinkedQueue() {head = tail = new Node<E>(null);
}

  head和tail指针会指向一个item域为null的节点,此时ConcurrentLinkedQueue状态如下图所示:head和tail指向同一个节点Node0,该节点item域为null,next域为null。
在这里插入图片描述

2.2 操作Node的几个CAS操作

  在队列进行出队入队的时候免不了对节点需要进行操作,在多线程就很容易出现线程安全的问题。可以看出在处理器指令集能够支持CMPXCHG指令后,在java源码中涉及到并发处理都会使用CAS操作(关于CAS操作可以看这篇文章),那么在ConcurrentLinkedQueue对Node的CAS操作有这样几个:

//更改Node中的数据域item	
boolean casItem(E cmp, E val) {return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
//更改Node中的指针域next
void lazySetNext(Node<E> val) {UNSAFE.putOrderedObject(this, nextOffset, val);
}
//更改Node中的指针域next
boolean casNext(Node<E> cmp, Node<E> val) {return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

  可以看出这些方法实际上是通过调用UNSAFE实例的方法,UNSAFE为sun.misc.Unsafe类,该类是hotspot底层方法,目前为止了解即可,知道CAS的操作归根结底是由该类提供就好。

2.3 offer方法

  对一个队列来说,插入满足FIFO特性,插入元素总是在队列最末尾的地方进行插入,而取(移除)元素总是从队列的队头。所有要想能够彻底弄懂ConcurrentLinkedQueue自然而然是从offer方法和poll方法开始。那么为了能够理解offer方法,采用debug的方式来一行一行的看代码走。另外,在看多线程的代码时,可采用这样的思维方式:

单个线程offer
多个线程offer
部分线程offer,部分线程poll
----offer的速度快于poll
--------队列长度会越来越长,由于offer节点总是在对队列队尾,而poll节点总是在队列对头,也就是说offer线程和poll线程两者并无“交集”,也就是说两类线程间并不会相互影响,这种情况站在相对速率的角度来看,也就是一个"单线程offer"
----offer的速度慢于poll
--------poll的相对速率快于offer,也就是队头删的速度要快于队尾添加节点的速度,导致的结果就是队列长度会越来越短,而offer线程和poll线程就会出现“交集”,即那一时刻就可以称之为offer线程和poll线程同时操作的节点为 临界点 ,且在该节点offer线程和poll线程必定相互影响。根据在临界点时offer和poll发生的相对顺序又可从两个角度去思考:1. 执行顺序为offer–>poll–>offer,即表现为当offer线程在Node1后插入Node2时,此时poll线程已经将Node1删除,这种情况很显然需要在offer方法中考虑; 2.执行顺序可能为:poll–>offer–>poll,即表现为当poll线程准备删除的节点为null时(队列为空队列),此时offer线程插入一个节点使得队列变为非空队列

  先看这么一段代码:

ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();
queue.offer(1);
queue.offer(2);

  创建一个ConcurrentLinkedQueue实例,先offer 1,然后再offer 2。offer的源码为:

public boolean offer(E e) {checkNotNull(e);                                              // 1 final Node<E> newNode = new Node<E>(e);                       // 2 // for (Node<E> t = tail, p = t;;) {                             // 3 Node<E> q = p.next;                                       // 4 if (q == null) {                                          // 5 // p is last node                                     // 6 if (p.casNext(null, newNode)) {                       // 7 // Successful CAS is the linearization point        //    // for e to become an element of this queue,        //    // and for newNode to become "live".                 //    if (p != t) // hop two nodes at a time            // 8casTail(t, newNode);  // Failure is OK.       // 9return true;                                     // 10}                                                       //    // Lost CAS race to another thread; re-read next        //    }                                                           //    else if (p == q)                                         // 11// We have fallen off list.  If tail is unchanged, it   //    // will also be off-list, in which case we need to      //    // jump to head, from which all live nodes are always   //    // reachable.  Else the new tail is a better bet.       //    p = (t != (t = tail)) ? t : head;                    // 12else                                                     //    // Check for tail updates after two hops.               //    p = (p != t && t != (t = tail)) ? t : q;             // 13}
}

  先从单线程执行的角度看起,分析offer 1的过程。第1行代码会对是否为null进行判断,为null的话就直接抛出空指针异常,第2行代码将e包装成一个Node类,第3行为for循环,只有初始化条件没有循环结束条件,这很符合CAS的“套路”,在循环体CAS操作成功会直接return返回,如果CAS操作失败的话就在for循环中不断重试直至成功。这里实例变量t被初始化为tail,p被初始化为t即tail。为了方便下面的理解,p被认为队列真正的尾节点,tail不一定指向对象真正的尾节点,因为在ConcurrentLinkedQueue中tail是被延迟更新的,具体原因我们慢慢来看。代码走到第3行的时候,t和p都分别指向初始化时创建的item域为null,next域为null的Node0。第4行变量q被赋值为null,第5行if判断为true,在第7行使用casNext将插入的Node设置成当前队列尾节点p的next节点,如果CAS操作失败,此次循环结束在下次循环中进行重试。CAS操作成功走到第8行,此时p==t,if判断为false,直接return true返回。如果成功插入1的话,此时ConcurrentLinkedQueue的状态如下图所示:
在这里插入图片描述
  如图,此时队列的尾节点应该为Node1,而tail指向的节点依然还是Node0,因此可以说明tail是延迟更新的。那么我们继续来看offer 2的时候的情况,很显然此时第4行q指向的节点不为null了,而是指向Node1,第5行if判断为false,第11行if判断为false,代码会走到第13行。好了,再插入节点的时候我们会问自己这样一个问题?上面已经解释了tail并不是指向队列真正的尾节点,那么在插入节点的时候,我们是不是应该最开始做的就是找到队列当前的尾节点在哪里才能插入?那么第13行代码就是找出队列真正的尾节点。
定位队列真正的对尾节点

p = (p != t && t != (t = tail)) ? t : q;

  我们来分析一下这行代码,如果这段代码在单线程环境执行时,很显然由于p==t,此时p会被赋值为q,而q等于Node q = p.next,即Node1。在第一次循环中指针p指向了队列真正的队尾节点Node1,那么在下一次循环中第4行q指向的节点为null,那么在第5行中if判断为true,那么在第7行依然通过casNext方法设置p节点的next为当前新增的Node,接下来走到第8行,这个时候p!=t,第8行if判断为true,会通过casTail(t, newNode)将当前节点Node设置为队列的队尾节点,此时的队列状态示意图如下图所示:
在这里插入图片描述
  tail指向的节点由Node0改变为Node2,这里的casTail失败不需要重试的原因是,offer代码中主要是通过p的next节点q(Node q = p.next)决定后面的逻辑走向的,当casTail失败时状态示意图如下:
在这里插入图片描述
  如图,如果这里casTail设置tail失败即tail还是指向Node0节点的话,无非就是多循环几次通过13行代码定位到队尾节点。
  通过对单线程执行角度进行分析,我们可以了解到poll的执行逻辑为:

  1. 如果tail指向的节点的下一个节点(next域)为null的话,说明tail指向的节点即为队列真正的队尾节点,因此可以通过casNext插入当前待插入的节点,但此时tail并未变化
  2. 如果tail指向的节点的下一个节点(next域)不为null的话,说明tail指向的节点不是队列的真正队尾节点。通过q(Node q = p.next)指针往前递进去找到队尾节点,然后通过casNext插入当前待插入的节点,并通过casTail方式更改tail。

  我们回过头再来看p = (p != t && t != (t = tail)) ? t : q;这行代码在单线程中,这段代码永远不会将p赋值为t,那么这么写就不会有任何作用,那我们试着在多线程的情况下进行分析。
  在多线程环境下这行代码很有意思的。 t != (t = tail)这个操作并非一个原子操作,有这样一种情况:
在这里插入图片描述
  如图,假设线程A此时读取了变量t,线程B刚好在这个时候offer一个Node后,此时会修改tail指针,那么这个时候线程A再次执行t=tail时t会指向另外一个节点,很显然线程A前后两次读取的变量t指向的节点不相同,即t != (t = tail)为true,并且由于t指向节点的变化p != t也为true,此时该行代码的执行结果为p和t最新的t指针指向了同一个节点,并且此时t也是队列真正的对尾节点。那么,现在已经定位到队列真正的队尾节点,就可以执行offer操作了。
  那么还剩下第11行的代码我们没有分析,大致可以猜想到应该就是回答一部分线程offer,一部分poll的这种情况。当if (p == q)为true时,说明p指向的节点的next也指向它自己,这种节点称之为哨兵节点,这种节点在队列中存在的价值不大,一般表示为要删除的节点或者是空节点。为了能够很好的理解这种情况,我们先看看poll方法的执行过程后,再回过头来看,总之这是一个很有意思的事情。

2.4 poll方法

  poll方法源码如下:

public E poll() {restartFromHead:for (; ; ) {                                                         // 1.for (Node<E> h = head, p = h, q; ; ) {                            // 2.E item = p.item;                                            // 3.//if (item != null && p.casItem(item, null)) {                // 4.// Successful CAS is the linearization point              //// for item to be removed from this queue.                //if (p != h) // hop two nodes at a time                  // 5.updateHead(h, ((q = p.next) != null) ? q : p);      // 6.return item;                                            // 7.}                                                             //else if ((q = p.next) == null) {                            // 8.updateHead(h, p);                                       // 9.return null;                                           // 10.}                                                             //else if (p == q)                                           // 11.continue restartFromHead;                              // 12.else                                                          //p = q;                                                 // 13.}}
}

  我们还是先站在单线程的角度去理清该方法的基本逻辑。假设ConcurrentLinkedQueue初始状态如下图所示:
在这里插入图片描述
  参数offer时的定义,我们还是先将变量p作为队列要删除真正的队头节点,h(head)指向的节点并不一定是队列的队头节点。先来看poll出Node1时的情况,由于p=h=head,参照上图,很显然此时p指向的Node1的数据域不为null,在第4行代码中item!=null判断为true后接下来通过casItem将Node1的数据域设置为null。如果CAS设置失败则此次循环结束等待下一次循环进行重试。若第4行执行成功进入到第5行代码,此时p和h都指向Node1,第5行if判断为false,然后直接到第7行return回Node1的数据域1,方法运行结束,此时的队列状态如下图。
在这里插入图片描述
  下面继续从队列中poll,很显然当前h和p指向的Node1的数据域为null,那么第一件事就是要定位准备删除的队头节点(找到数据域不为null的节点)。
  继续看,第三行代码item为null,第4行代码if判断为false,走到第8行代码(q = p.next)if也为false,由于q指向了Node2,在第11行的if判断也为false,因此代码走到了第13行,这个时候p和q共同指向了Node2,也就找到了要删除的真正的队头节点。可以总结出,定位待删除的队头节点的过程为:如果当前节点的数据域为null,很显然该节点不是待删除的节点,就用当前节点的下一个节点去试探。在经过第一次循环后,此时状态图为下图:
在这里插入图片描述
  进行下一次循环,第4行的操作同上述,当前假设第4行中casItem设置成功,由于p已经指向了Node2,而h还依旧指向Node1,此时第5行的if判断为true,然后执行updateHead(h, ((q = p.next) != null) ? q : p),此时q指向的Node3,所有传入updateHead方法的分别是指向Node1的h引用和指向Node3的q引用。updateHead方法的源码为:

final void updateHead(Node<E> h, Node<E> p) {if (h != p && casHead(h, p))h.lazySetNext(h);
}

  该方法主要是通过casHead将队列的head指向Node3,并且通过 h.lazySetNext将Node1的next域指向它自己。最后在第7行代码中返回Node2的值。此时队列的状态如下图所示:
在这里插入图片描述
  Node1的next域指向它自己,head指向了Node3。如果队列为空队列的话,就会执行到代码的第8行(q = p.next) == null,if判断为true,因此在第10行中直接返回null。以上的分析是从单线程执行的角度去看,也可以让我们了解poll的整体思路,现在来做一个总结:

  1. 如果当前head,h和p指向的节点的Item不为null的话,说明该节点即为真正的队头节点(待删除节点),只需要通过casItem方法将item域设置为null,然后将原来的item直接返回即可。
  2. 如果当前head,h和p指向的节点的item为null的话,则说明该节点不是真正的待删除节点,那么应该做的就是寻找item不为null的节点。通过让q指向p的下一个节点(q = p.next)进行试探,若找到则通过updateHead方法更新head指向的节点以及构造哨兵节点(通过updateHead方法的h.lazySetNext(h))。

  按照上面分析offer的思维方式,下面来分析一下多线程的情况,第一种情况是多个线程poll。
  poll方法的源码,有这样一部分:

else if (p == q)continue restartFromHead;

  这一部分就是处理多个线程poll的情况,q = p.next也就是说q永远指向的是p的下一个节点,那么什么情况下会使得p,q指向同一个节点呢?根据上面我们的分析,只有p指向的节点在poll的时候转变成了哨兵节点(通过updateHead方法中的h.lazySetNext)。当线程A在判断p==q时,线程B已经将执行完poll方法将p指向的节点转换为哨兵节点并且head指向的节点已经发生了改变,所以就需要从restartFromHead处执行,保证用到的是最新的head。
  试想,还有这样一种情况,如果当前队列为空队列,线程A进行poll操作,同时线程B执行offer,然后线程A在执行poll,那么此时线程A返回的是null还是线程B刚插入的最新的那个节点呢?我们来写一代demo:

public static void main(String[] args) {Thread thread1 = new Thread(() -> {Integer value = queue.poll();System.out.println(Thread.currentThread().getName() + " poll 的值为:" + value);System.out.println("queue当前是否为空队列:" + queue.isEmpty());});thread1.start();Thread thread2 = new Thread(() -> {queue.offer(1);});thread2.start();
}

  结果为:

Thread-0 poll 的值为:null
queue当前是否为空队列:false

  通过debug控制线程thread1和线程thread2的执行顺序,thread1先执行到第8行代码if ((q = p.next) == null),由于此时队列为空队列if判断为true,进入if块,此时先让thread1暂停,然后thread2进行offer插入值为1的节点后,thread2执行结束。再让thread1执行,这时thread1并没有进行重试,而是代码继续往下走,返回null,尽管此时队列由于thread2已经插入了值为1的新的节点。所以输出结果为thread0 poll的为null,然队列不为空队列。因此,在判断队列是否为空队列的时候是不能通过线程在poll的时候返回为null进行判断的,可以通过isEmpty方法进行判断。

2.5 offer方法中部分线程offer部分线程poll

  在分析offer方法的时候我们还留下了一个问题,即对offer方法中第11行代码的理解。
  在offer方法的第11行代码if (p == q),能够让if判断为true的情况为p指向的节点为哨兵节点,而什么时候会构造哨兵节点呢?在对poll方法的讨论中,我们已经找到了答案,即当head指向的节点的item域为null时会寻找真正的队头节点,等到待插入的节点插入之后,会更新head,并且将原来head指向的节点设置为哨兵节点。假设队列初始状态如下图所示:
在这里插入图片描述
  因此在线程A执行offer时,线程B执行poll就会存在如下一种情况:
在这里插入图片描述
  如图,线程A的tail节点存在next节点Node1,因此会通过引用q往前寻找队列真正的队尾节点,当执行到判断if (p == q)时,此时线程B执行poll操作,在对线程B来说,head和p指向Node0,由于Node0的item域为null,同样会往前递进找到队列真正的队头节点Node1,在线程B执行完poll之后,Node0就会转换为哨兵节点,也就意味着队列的head发生了改变,此时队列状态为下图。
在这里插入图片描述
  此时线程A在执行判断if (p == q)时就为true,会继续执行p = (t != (t = tail)) ? t : head;,由于tail指针没有发生改变所以p被赋值为head,重新从head开始完成插入操作。

2.6 HOPS的设计

  通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:

  1. tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。
  2. head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。

  并且在更新操作时,源码中会有注释为:hop two nodes at a time。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个(猜的),从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢?
  如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。

三、CopyOnWriteArrayList

  ArrayList并不是线程安全的,在读线程在读取ArrayList的时候如果有写线程在写数据的时候,基于fast-fail机制,会抛出ConcurrentModificationException异常,也就是说ArrayList并不是一个线程安全的容器,当然您可以用Vector,或者使用Collections的静态方法将ArrayList包装成一个线程安全的类,但是这些方式都是采用Java关键字synchronzied对方法进行修饰,利用独占式锁来保证线程安全的。但是,由于独占式锁在同一时刻只有一个线程能够获取到对象监视器,很显然这种方式效率并不是太高。
  回到业务场景中,有很多业务往往是读多写少的。如果在这种情况用到上述的方法,使用Vector,Collections转换的这些方式是不合理的,因为尽管多个读线程从同一个数据容器中读取数据,但是读线程对数据容器的数据并不会发生发生修改。很自然而然的我们会联想到ReenTrantReadWriteLock,通过读写分离的思想,使得读读之间不会阻塞,无疑如果一个list能够做到被多个读线程读取的话,性能会大大提升不少。但是,如果仅仅是将list通过读写锁(ReentrantReadWriteLock)进行再一次封装的话,由于读写锁的特性,当写锁被写线程获取后,读写线程都会被阻塞。如果仅仅使用读写锁对list进行封装的话,这里仍然存在读线程在读数据的时候被阻塞的情况,如果想list的读效率更高的话,这里就是我们的突破口,如果我们保证读线程无论什么时候都不被阻塞,效率岂不是会更高?
  CopyOnWriteArrayList容器可以保证线程安全,保证读写之间在任何时候都不会被阻塞

3.1 COW的设计思想

  回到上面所说的,如果简单的使用读写锁的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据,站在读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性。既然我们说到要进行优化,必然有trade-off,我们就可以牺牲数据实时性满足数据的最终一致性即可。而CopyOnWriteArrayList就是通过Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。
  COW通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器
  对CopyOnWrite容器进行并发的读的时候,不需要加锁,写的时候才加锁。因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。

3.2 CopyOnWriteArrayList的实现原理

  实际上CopyOnWriteArrayList内部维护的就是一个数组:

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

  并且该数组引用是被volatile修饰,注意这里仅仅是修饰的是数组引用,其中另有玄机,稍后揭晓。关于volatile很重要的一条性质是它能够够保证可见性。对list来说,我们自然而然最关心的就是读写的时候,分别为get和add方法的实现。

3.2.1 get方法实现原理

  get方法的源码为:

public E get(int index) {return get(getArray(), index);
}
/*** Gets the array.  Non-private so as to also be accessible* from CopyOnWriteArraySet class.*/
final Object[] getArray() {return array;
}
private E get(Object[] a, int index) {return (E) a[index];
}

  可以看出来get方法实现非常简单,几乎就是一个“单线程”程序,没有对多线程添加任何的线程安全控制,也没有加锁也没有CAS操作等等,原因是,所有的读线程只是会读取数据容器中的数据,并不会进行修改。

3.2.2 add方法实现原理

  add方法的源码为:

public boolean add(E e) {final ReentrantLock lock = this.lock;//1. 使用Lock,保证写线程在同一时刻只有一个lock.lock();try {//2. 获取旧数组引用Object[] elements = getArray();int len = elements.length;//3. 创建新的数组,并将旧数组的数据复制到新数组中Object[] newElements = Arrays.copyOf(elements, len + 1);//4. 往新数组中添加新的数据	        newElements[len] = e;//5. 将旧数组引用指向新的数组setArray(newElements);return true;} finally {lock.unlock();}
}

  add方法的逻辑也比较容易理解,请看上面的注释。需要注意这么几点:

  1. 采用ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据
  2. 前面说过数组引用是volatile修饰的,因此将旧的数组引用指向新的数组,根据volatile的happens-before规则,写线程对数组引用的修改对读线程是可见的
  3. 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作

3.3 总结

  COW和读写锁都是通过读写分离的思想实现的,但两者还是有些不同。
  相同点:1. 两者都是通过读写分离的思想实现;2.读线程间是互不阻塞的
  不同点:对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。而COW则完全放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。
  对这一点从文字上还是很难理解,我们来通过debug看一下,add方法核心代码为:

Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);

  假设COW的变化如下图所示:
在这里插入图片描述
  数组中已有数据1,2,3,现在写线程想往数组中添加数据4,我们在第5行处打上断点,让写线程暂停。读线程依然会“不受影响”的能从数组中读取数据,可是还是只能读到1,2,3。如果读线程能够立即读到新添加的数据的话就叫做能保证数据实时性。当对第5行的断点放开后,读线程才能感知到数据变化,读到完整的数据1,2,3,4,而保证数据最终一致性,尽管有可能中间间隔了好几秒才感知到。
  这里还有这样一个问题: 为什么需要复制呢? 如果将array 数组设定为volitile的, 对volatile变量写happens-before读,读线程不是能够感知到volatile变量的变化。
  原因是,这里volatile的修饰的仅仅只是数组引用,数组中的元素的修改是不能保证可见性的。因此COW采用的是新旧两个数据容器,通过第5行代码将数组引用指向新的数组。这也是为什么concurrentHashMap只具有弱一致性的原因。
  CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题:

  1. 内存占用问题:因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的minor GC和major GC。
  2. 数据一致性问题CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

这篇关于Java并发知识点(三)ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/385502

相关文章

Java编译生成多个.class文件的原理和作用

《Java编译生成多个.class文件的原理和作用》作为一名经验丰富的开发者,在Java项目中执行编译后,可能会发现一个.java源文件有时会产生多个.class文件,从技术实现层面详细剖析这一现象... 目录一、内部类机制与.class文件生成成员内部类(常规内部类)局部内部类(方法内部类)匿名内部类二、

SpringBoot实现数据库读写分离的3种方法小结

《SpringBoot实现数据库读写分离的3种方法小结》为了提高系统的读写性能和可用性,读写分离是一种经典的数据库架构模式,在SpringBoot应用中,有多种方式可以实现数据库读写分离,本文将介绍三... 目录一、数据库读写分离概述二、方案一:基于AbstractRoutingDataSource实现动态

Springboot @Autowired和@Resource的区别解析

《Springboot@Autowired和@Resource的区别解析》@Resource是JDK提供的注解,只是Spring在实现上提供了这个注解的功能支持,本文给大家介绍Springboot@... 目录【一】定义【1】@Autowired【2】@Resource【二】区别【1】包含的属性不同【2】@

springboot循环依赖问题案例代码及解决办法

《springboot循环依赖问题案例代码及解决办法》在SpringBoot中,如果两个或多个Bean之间存在循环依赖(即BeanA依赖BeanB,而BeanB又依赖BeanA),会导致Spring的... 目录1. 什么是循环依赖?2. 循环依赖的场景案例3. 解决循环依赖的常见方法方法 1:使用 @La

Java枚举类实现Key-Value映射的多种实现方式

《Java枚举类实现Key-Value映射的多种实现方式》在Java开发中,枚举(Enum)是一种特殊的类,本文将详细介绍Java枚举类实现key-value映射的多种方式,有需要的小伙伴可以根据需要... 目录前言一、基础实现方式1.1 为枚举添加属性和构造方法二、http://www.cppcns.co

Elasticsearch 在 Java 中的使用教程

《Elasticsearch在Java中的使用教程》Elasticsearch是一个分布式搜索和分析引擎,基于ApacheLucene构建,能够实现实时数据的存储、搜索、和分析,它广泛应用于全文... 目录1. Elasticsearch 简介2. 环境准备2.1 安装 Elasticsearch2.2 J

Java中的String.valueOf()和toString()方法区别小结

《Java中的String.valueOf()和toString()方法区别小结》字符串操作是开发者日常编程任务中不可或缺的一部分,转换为字符串是一种常见需求,其中最常见的就是String.value... 目录String.valueOf()方法方法定义方法实现使用示例使用场景toString()方法方法

Java中List的contains()方法的使用小结

《Java中List的contains()方法的使用小结》List的contains()方法用于检查列表中是否包含指定的元素,借助equals()方法进行判断,下面就来介绍Java中List的c... 目录详细展开1. 方法签名2. 工作原理3. 使用示例4. 注意事项总结结论:List 的 contain

Java实现文件图片的预览和下载功能

《Java实现文件图片的预览和下载功能》这篇文章主要为大家详细介绍了如何使用Java实现文件图片的预览和下载功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... Java实现文件(图片)的预览和下载 @ApiOperation("访问文件") @GetMapping("

Spring Boot + MyBatis Plus 高效开发实战从入门到进阶优化(推荐)

《SpringBoot+MyBatisPlus高效开发实战从入门到进阶优化(推荐)》本文将详细介绍SpringBoot+MyBatisPlus的完整开发流程,并深入剖析分页查询、批量操作、动... 目录Spring Boot + MyBATis Plus 高效开发实战:从入门到进阶优化1. MyBatis