本篇我们来看看集合在多线程环境下的新变化
在多线程中我们需要考虑任何数据对象的同步问题,使用率很高的集合类型对象也不例外,更是重中之重。集合是存储容器的,多线程环境下是线程访问的重点,那么就可能造成并发的问题。java 传统的集合中只有 Vector 、HashTable、StringBuffer 是线程安全的,但只能做到 synchronized 的效果,限制集合在同一时间只能游一个线程来操作,对于其他的对象到是没问题,但是上面说过了,集合是访问重点,是会频繁使用的,这样一次只能游一个线程操作,其他的都得在后面等着(阻塞),在并发量大的情况下会产生巨大的性能问题。那么有什么办法吗
当然有啦,要不我怎么会写这篇文章啊,JAVA 提供了可以在多线程环境下使用的新的集合容器,既能保证数据同步,也能提高访问效率
这几个新的集合容器就是:
- CopyOnWriteArrayList
读写分离的 list 集合 - ConcurrentHashMap
多锁结构的 map 集合
我找到的比较好的描述:
JDK5中添加了新的concurrent包,相对同步容器而言,并发容器通过一些机制改进了并发性能。因为同步容器将所有对容器状态的访问都
串行化了,这样保证了线程的安全性,所以这种方法的代价就是严重降低了并发性,当多个线程竞争容器时,吞吐量严重降低。因此Java5.0开
始针对多线程并发访问设计,提供了并发性能较好的并发容器,引入了java.util.concurrent包。与Vector和Hashtable、
Collections.synchronizedXxx()同步容器等相比,util.concurrent中引入的并发容器主要解决了两个问题:
- 根据具体场景进行设计,尽量避免synchronized,提供并发性。
- 定义了一些并发安全的复合操作,并且保证并发环境下的迭代操作不会出错。
CopyOnWriteArrayList
CopyOnWriteArrayList 名字看着可能会有点懵逼,见名知意的话是个啥意思。其他很简单的,就是读写分离的 list ,多线程环境下对集合的读操作是不加锁的,允许多个线程同时读取集合内容;对集合写的操作是加锁的。
这样把读和写操作分离,一个线程同步,一个线程不同步,根据线程安全性区分操作,无疑可以大大提高不影响线程安全操作的多线程效率
大家想啊,读操作只是取数据,不会对数据造成影响,天然的是可以允许并发的
写操作是要改变数据的,是会别的线程造成影响的,肯定是要保证线程安全的。
CopyOnWriteArrayList 读写分离的做法体现了多线程优化的一个思路,把关乎线程安全与否的操作分离,会大大提供不影响线程安全的操作的效率。
这里解释下 CopyOnWriteArrayList 的原理,为啥读和写可以分离。CopyOnWriteArrayList 在写操作时,先把集合数据 copy 于一份出来,然后在这个副本上对集合进行操作,计算结速后再把用副本数据覆盖原始数据,写操作是线程安全的,是同步的,同一时刻只能有一个线程操作。在写操作的同时因为我们不直接修改原始数据,而是用的副本,对原始数据没有任何影响,所以读的操作可以不受写操作的干扰,可以并发操作。
这让我想起 realm 数据库来了,realm 就是在每一个线程都存在一个数据库的副本,我们在这个线程中操作的是副本,然后 realm 自己决定何时同步副本数据到主数据库中。也是用的是副本的套路来支持多线程并发的。任何线程的对数据的操作都不影响别的线程
我们来看下 CopyOnWriteArrayList 的写入方法就更清楚了,源码很简单的,不要有压力
public boolean add(E e) {
synchronized (lock) {
// 取出数据
Object[] elements = getArray();
int len = elements.length;
// 创建副本
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 用副本复杂原始数据
setArray(newElements);
return true;
}
}
CopyOnWrite的缺点
CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。
针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。
数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
ConcurrentHashMap
HashMap 是根据散列值分段存储的,同步 Map 在同步的时候锁住了所有的段,而ConcurrentHashMap 给每个散列值分段都加了一把锁,这样 ConcurrentHashMap 能允许对不同散列值分段的并发操作,当然同散列值分段的操作还是只能有一个线程的,但是这样能大大提高了并发性能
ConcurrentHashMap为了提高本身的并发能力,在内部采用了一个叫做Segment的结构,一个Segment其实就是一个类Hash Table的结构,Segment内部维护了一个链表数组,我们用下面这一幅图来看下ConcurrentHashMap的内部结构:
从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部,因此,这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长,但是带来的好处是写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上),所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
另外 ConcurrentHashMap 也是读写分离的,get() 是不加锁的,put 加锁
最后
阻塞队列其实也算是并发容器的,但是这个我想和线程池一起说。并发集合容器就说到这里了,我也是做 android 的,对于多线程也是个门外汉,没啥实战经验,对基础理解页很浅薄。这里我简单介绍了2种并发容器的概念和原理,剩下的使用其实和集合没却别,更多的内容请大家自己去找更详细的资料吧