Java并发——并发容器

767 阅读18分钟

通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。

同步容器类

        同步容器类包括Vector和Hashtable,二者是早期JDK的一部分,这两个容器的实现和早期的ArrayList和HashMap代码实现基本一样,此外还包括在JDK1.2中添加的一些功能相似的类,这些同步的封装器类是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是:在每个方法上都添加了synchronized关键字来保证同一个实例同时只有一个线程能访问。

同步容器类的问题

         同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代(反复访问元素,直到遍历完容器中所有元素)、跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算,例如“若没有则添加”(检查在Map中是否存在键值K,如果没有,就加入二元组(K,V))。在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会表现出意料之外的行为。

public static Object getLast(Vector list) {
	int lastIndex = list.size() - 1;
	return list.get(lastIndex);
}

public static void deleteLast(Vector list) {
	int lastIndex = list.size() - 1;
	list.remove(lastIndex);
}

         上面这些方法看似没有任何问题,无论多少个线程同时调用它们,也不破坏Vector。如果线程A在包含10个元素的Vector 上调用getLast,同时线程B在同一个Vector 上调用deleteLast,getLast 将抛出ArrayIndexOutOfBoundsException异常。在调用 size与调用getLast这两个操作之间,Vector变小了,因此在调用size时得到的索引值将不再有效。

       同步容器类通过其自身的锁来保护它的每个方法。通过给得容器类的锁,我们可以使getLast和deleteLast成为原子操作,并确保Vector的大小在调用size和get之间不会发生变化

public static Object getLast(Vector list) {
	synchronized (list){
		int lastIndex = list.size() - 1;
		return list.get(lastIndex);
	}
}

public static void deleteLast(Vector list) {
	synchronized (list){
		int lastIndex = list.size() - 1;
		list.remove(lastIndex); 
	}  
}

很明显,尽管这里使用到的Vector的get()、remove()和size()方法都是同步的, 但是在多线程的环境中,如果不在方法调用端做额外的同步措施的话,仍然不是线程安全的。

         然而,有时候对容器加锁,如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待。长时间地对容器加锁也会降低程序的可伸缩性。持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大地降低吞吐量和CPU的利用。

阻塞队列

          阻塞队列(BlockingQueue)提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法也永远不会阻塞。

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。

JDK 提供了7个阻塞队列,如下

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。 
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

数组结构ArrayBlockingQueue 

        基于数组结构实现的有界FIFO(先进先出)阻塞队列, 与ArrayList类似,但比同步List拥有更好的并发性能。 默认非公平的访问队列,所谓公平访问队列是指阻塞的线程,可以按照阻塞的先后顺序访问队列,即先阻塞线程先访问队列。非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格,有可能先阻塞的线程最后才访问队列。为了保证公平性,通常会降低吞吐量。

//创建一个公平的阻塞队列
ArrayBlockingQueue fairQueue = new ArrayBlockingQueue(1000,true); 

访问者的公平性是使用独占锁ReentrantLock实现

public ArrayBlockingQueue(int capacity, boolean fair) {
	if (capacity <= 0)
		throw new IllegalArgumentException();
	this.items = new Object[capacity];
	lock = new ReentrantLock(fair);
	notEmpty = lock.newCondition();
	notFull =  lock.newCondition();
}

         

        ArrayBlockingQueue通过使用全局独占锁实现了同时只能有一个线程进行入队或者出队操作。其中offer和poll操作通过简单的加锁进行入队、出队操作,而put、take操作则使用条件变量实现,如果队列满则等待,如果队列空则等待,然后分别在出队和入队操作中发送信号激活等待线程实现同步。另外,相比LinkedBlockingQueue,ArrayBlockingQueue的size操作的结果是精确的,因为计算前加了全局锁。

链表结构LinkedBlockingQueue

        基于链表结构实现的有界FIFO(先进先出)阻塞队列,与LinkedList类似。队列的默认和最大长度为 Integer.MAX_VALUE(2147483647)。

       通过单向链表实现的,使用头、尾节点来进行入队和出队操作,也就是入队操作都是对尾节点进行操作,出队操作都是对头节点进行操作。 对头、尾节点的操作分别使用了单独的独占锁从而保证了原子性,所以出队和入队操作是可以同时进行的。另外对头、尾节点的独占锁都配备了一个条件队列,用来存放被阻塞的线程,并结合入队、出队操作实现了一个生产消费模型。

优先级排序PriorityBlockingQueue

         支持优先级的无界阻塞队列。默认情况下元素采取自然顺序升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则,或者初始化 PriorityBlockingQueue时,指定构造参数Comparator来对元素进行排序。需要注意的是不能保证同优先级元素的顺序。

       PriorityBlockingQueue队列在内部使用二叉树堆维护元素优先级,使用数组作为元素存储的数据结构,这个数组是可扩容的。当当前元素个数>=最大容量时会通过CAS算法扩容,出队时始终保证出队的元素是堆树的根节点,而不是在队列里面停留时间最长的元素。类似于ArrayBlockingQueue,在内部使用独占锁来控制同时只有一个线程可以进行入队和出队操作。另外,前者只使用了一个notEmpty条件变量而没有使用notFull,这是因为前者是无界队列,执行put操作时永远不会处于await状态,所以也不需要被唤醒。而take方法是阻塞方法,并且是可被中断的。 当需要存放有优先级的元素时该队列比较有用。

延时获取DelayQueue

      一个支持延时获取元素的无界阻塞队列。队列中的每个元素都有个过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最快要过期的元素。

应用场景:

  • 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询 DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。
  • 定时任务调度:使用DelayQueue保存当天将会执行的任务和执行时间,一旦从 DelayQueue中获取到任务就开始执行,比如TimerQueue就是使用DelayQueue实现的。

       DelayQueue内部使用PriorityQueue存放数据,使用ReentrantLock实现线程同步。另外,队列里面的元素要实现Delayed接口,由于每个元素都有一个过期时间,所以要实现获知当前元素还剩下多少时间就过期了的接口,由于内部使用优先级队列来实现,所以要实现Comparable接口。

不存储元素SynchronousQueue

     一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作, 否则不能继续添加元素。支持公平访问队列。默认情况下线程采用非公平性策略访问队列。负责把生产者线程处理的数据直接传递给消费者线程。

       SynchronousQueue 使用两种数据结构:队列(实现公平策略)和栈(实现非公平策略),队列与栈都是通过链表来实现的,通过CAS和自旋来实现并发。队列本身并不存储任何元素,非常适合传递性场景。且吞吐量高于 LinkedBlockingQueue和ArrayBlockingQueue。

链表结构LinkedTransferQueue

         一个由链表结构组成的无界阻塞队列。相对于其他阻塞队列,多了tryTransfer和transfer方法。

transfer方法

        如果当前有消费者正在等待接收元素(消费者使用take()方法或带时间限制的poll()方法时),transfer方法可以把生产者传入的元素立刻transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者消费了才返 回。

tryTransfer方法

       tryTransfer方法是用来试探生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回false。和transfer方法的区别是tryTransfer方法无论消费者是否接收,方法立即返回,而transfer方法是必须等到消费者消费了才返回。带有时间限制的tryTransfer(E e,long timeout,TimeUnit unit)方法,把生产者传入的元素直接传给消费者,但是如果没有消费者消费该元素则等待指定的时间再返回,如果超时还没消费元素,则返回false,如果在超时时间内消费了元素,则返回true。

LinkedTransferQueue的功能与实现类似于SynchronousQueue,但是LinkedTransferQueue内部可以存放多条数据。

双向队列LinkedBlockingDeque

       一个由链表结构组成的双向阻塞队列。所谓双向队列指的是可以 从队列的两端插入和移出元素。双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争 

          正如阻塞队列适用于生产者一消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing)。在生产者一消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者一消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。

并发容器

        Java5.0后提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。

ConcurrentHashMap(并发HashMap)

       并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非 常低下,而ConcurrentHashMap一个线程安全且高效的HashMap。

线程不安全的HashMap

         在多线程环境下JDK7版本,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。JDK8之后HashMap采用红黑树数据结构通过栈封闭的链表替换,解决了死循环的问题。但是还是会出现数据丢失,不一致等问题。

效率低下的HashTable

         HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable 的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。

在JDK7中ConcurrentHashMap

       ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

//Segment的结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
	 //加载因子默认0.75
	 final float loadFactor;

	 //阙值:达到多少个元素的时候需要扩容
	 transient int threshold;

	 //内部的哈希表,节点就是HashEntry
	 transient volatile HashEntry<K,V>[] table;

	 //添加键值对到内部数组+链表中
	 final V put(K key, int hash, V value, boolean onlyIfAbsent){..};
}

 由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;

static final class HashEntry<K,V> {
	//此节点的hash值
	final int hash;
	//此节点的键
	final K key;
	//此节点的值
	volatile V value;
	volatile HashEntry<K,V> next;

	HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
		this.hash = hash;
		this.key = key;
		this.value = value;
		this.next = next;
	}

	//设置下一个节点的引用
	final void setNext(HashEntry<K,V> n) {
		UNSAFE.putOrderedObject(this, nextOffset, n);
	}
}	

HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时, 必须首先获得与它对应的Segment锁。

//初始化segments数组的源代码
if (concurrencyLevel > MAX_SEGMENTS)
	concurrencyLevel = MAX_SEGMENTS;
	int sshift = 0;
	int ssize = 1;
	while (ssize < concurrencyLevel) {
		++sshift;
		ssize <<= 1;
	}
	segmentShift = 32 - sshift;
	segmentMask = ssize - 1;
	this.segments = Segment.newArray(ssize);
}

segments数组的长度ssize是通过concurrencyLevel计算得出的。为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方 (power-of-two size),所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值 来作为segments数组的长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里锁的个数也是16。

public V get(Object key) {
	int hash = hash(key.hashCode());
	return segmentFor(hash).get(key, hash);
}

Segment的get操作先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素。整个get过程不需要加锁,除非读到的值是空才会加锁重读。在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。根据Java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。

public V put(K key, V value) {
	Segment<K,V> s;
	//ConcurrentHashMap的key和value都不能为null
	if (value == null)
		throw new NullPointerException();

	//这里对key求hash值,并确定应该放到segment数组的索引位置
	int hash = hash(key);
	int j = (hash >>> segmentShift) & segmentMask;
	if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
		 (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
		s = ensureSegment(j);
	//这里很关键,找到了对应的Segment,则把元素放到Segment中去
	return s.put(key, hash, value, false);
}

put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位 置,然后将其放在HashEntry数组里。

size方法()统计整个ConcurrentHashMap里元素的大小,ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。使用modCount 变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size 前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

在JDK8中ConcurrentHashMap

          ConcurrentHashmapJDK8的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。

static class Node<K,V> implements Map.Entry<K,V> {
	final int hash;
	final K key;
	volatile V val;
	volatile Node<K, V> next;

	Node(int hash, K key, V val, Node<K, V> next) {
		this.hash = hash;
		this.key = key;
		this.val = val;
		this.next = next;
	}
	......
}

Node是ConcurrentHashMap存储结构的基本单元,类似于JDK7中存放数据的 HashEntry ,数据结构很简单,一个链表,但是只允许对数据进行查找,不允许进行修改,继承于HashMap中的Entry,用于存储数据。

static final class TreeNode<K,V> extends Node<K,V> {
	TreeNode<K, V> parent;  // red-black tree links
	TreeNode<K, V> left;
	TreeNode<K, V> right;
	TreeNode<K, V> prev;    // needed to unlink next upon deletion
	boolean red;

	TreeNode(int hash, K key, V val, Node<K, V> next,
			 TreeNode<K, V> parent) {
		super(hash, key, val, next);
		this.parent = parent;
	}
	........
}  

TreeNode继承与Node,数据结构是二叉树,用于红黑树中存储数据,当链表的节点数大于8时会转换成红黑树的结构,他就是通过TreeNode作为存储结构代替Node来转换成黑红树。

ConcurrentLinkedQueue(无界非阻塞队列)

       一个线程安全的无界非阻塞队列,其底层数据结构使用单向链表实现,对于入队和出队操作使用CAS来实现线程安全。

         由head节点和tail节点组成,每个节点(Node)由节点元素(item)和 指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一 张链表结构的队列。默认情况下head节点存储的元素为空,tail节点等于head节点。

入队列

将入队节点添加到队列的尾部。

在多个线程同时进行入队的情况,因为可能会出现其他线程插队的情况。如果有一个线程正在 入队,那么它必须先获取尾节点,然后设置尾节点的下一个节点为入队节点,但这时可能有另 外一个线程插队了,那么队列的尾节点就会发生变化,这时当前线程要暂停入队操作,然后重 新获取尾节点。

public boolean offer(E e) {
	//为NULL抛出空指针异常
	checkNotNull(e);
	// 入队前,创建一个入队节点
	final Node<E> newNode = new Node<E>(e);
	// 死循环,入队不成功反复入队。
	// 创建一个指向tail节点的引用
	// p用来表示队列的尾节点,默认情况下等于tail节点
	for (Node<E> t = tail, p = t;;) {
		// 获得p节点的下一个节点。
		Node<E> q = p.next;
		if (q == null) {
			// 说明p是尾节点,则使用Cas设置p节点的next节点为入队节点。
			if (p.casNext(null, newNode)) {
			  //Cas成功设置尾部节点
				if (p != t) // hop two nodes at a time
					casTail(t, newNode);  // Failure is OK.
				return true;
			}
			// Lost CAS race to another thread; re-read next
		}
		else if (p == q)
			//多线程操作时,由于po11操作移除元素后可能会把head变为自引用,
                        //也就是head的next变成了head
			//所以这里需要重新找新的head
			p = (t != (t = tail)) ? t : head;
		else
			//寻找尾部节点
			// Check for tail updates after two hops.
			p = (p != t && t != (t = tail)) ? t : q;
	}
}

整个入队过程主要做两件事情:第一是定位出尾节点;第二是使用 CAS算法将入队节点设置成尾节点的next节点,如不成功则重试。

出队列

       出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用,如果队列为空则返回null。

每次出队时都更新head节点,当head节点里有元素时,直接弹出head 节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。

public E poll() {
	//continue标记
	restartFromHead:
	//无线循环
	for (;;) {
		// p表示头节点,需要出队的节点
		for (Node<E> h = head, p = h, q;;) {
			//获取p的元素
			E item = p.item;
		   // 如果p节点的元素不为空,使用CAS设置p节点引用的元素为null,
			if (item != null && p.casItem(item, null)) {
				if (p != h) // hop two nodes at a time
					//// 将p节点下一个节点设置成head节点
					updateHead(h, ((q = p.next) != null) ? q : p);
				// 返回p节点的元素。
				return item;
			}
			//当前队列为空返回null
			else if ((q = p.next) == null) {
				updateHead(h, p);
				return null;
			}
			else if (p == q)
				continue restartFromHead;
			else
				p = q;
		}
	}
}

首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经 进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。

ConcurrentLinkedDeque(无界非阻塞双向队列)

       基于双向链表实现的双向队列和ConcurrentLinkedQueue一样,采用了无锁算法,底层基于自旋+CAS方式实现。

ConcurrentSkipListMap(并发TreeMap)

       提供了类似与TreeMap的功能,不过ConcurrentSkipListMap是线程安全的,通过跳表来实现的,而TreeMap是通过红黑树实现的跳表是一个链表,但是通过使用“跳跃式”查找的方式使得插入、读取数据时复杂度变成了O(logn)。

ConcurrentSkipListSet(并发TreeSet)

ConcurrentSkipListSet是一个有序的集合,通过ConcurrentSkipListMap实现的,提供与TreeSet类似的功能。

CopyOnWriteArrayList(并发ArrayList)

        一个线程安全的ArrayList,对其进行的修改操作都是在底层的一个复制的数组(快照)上进行的,也就是使用了写时复制策略。每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。

添加元素

public boolean add(E e) {
	//获取独占锁
	final ReentrantLock lock = this.lock;
	lock.lock();
	try {
		//获取array
		Object[] elements = getArray();
		int len = elements.length;
		//复制array到新数组
		Object[] newElements = Arrays.copyOf(elements, len + 1);
		//添加元素到新数组
		newElements[len] = e;
		//使用新数组替换添加前数组
		setArray(newElements);
		return true;
	} finally {
                //释放锁
		lock.unlock();
	}
}

调用add方法的线程会首先去获取独占锁,如果多个线程都调用add方法则只有一个线程会获取到该锁,其他线程会被阻塞挂起直到锁被释放。 所以一个线程获取到锁后,就保证了在该线程添加元素的过程中其他线程不会对array 进行修改。 线程获取锁后获取array,然后复制array到一个新数组,并把新增的元素添加到新数组。使用新数组替换原数组,并在返回前释放锁。由于加了锁,所以整个add过程是个原子性操作。需要注意的是,在添加元素时,首先复制了一个快照,然后在快照上进行添加,而不是直接在原来数组上进行。

修改元素

使用E set(int index,E element)修改list中指定元素的值,如果指定位置的元素不存在则抛出IndexOutOfBoundsException异常

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 {
			//一样的话重新设置值
			// Not quite a no-op; ensures volatile write semantics
			setArray(elements);
		}
		return oldValue;
	} finally {
		lock.unlock();
	}
}

调用set方法的首先获取了独占锁,从而阻止其他线程对array数组进行修改,然后获取当前数组,并调用get方法获取指定位置的元素,如果指定位置的元素值与新值不一致则创建新数组并复制元素,然后在新数组上修改指定位置的元素值并设置新数组到array。如果指定位置的元素值与新值一样,则为了保证volatile语义,还是需要重新设置array,虽然array的内容并没有改变。

CopyOnWriteArraySet(并发HashSet)

      是HashSet的并发实现的底层还是通过CopyOnWriteArrayList来实现的

JDK中提供了一系列场景的线程安全的容器类,虽然牺牲了一些效率,但却得到了安全。

参考

Java并发编程实战
Java并发编程艺术
Java并发编程之美