Java数据集合性能优化的几点设计启示

404 阅读4分钟
java包为我们提供了很多线程安全(部分安全)的数据集合,涉及线程安全就必然会遇到锁争用和效率上的平衡,其实就是读和写的平衡,读是没有问题的,问题在于写,具体就是如何在写的时候,既不影响读的操作,又能安全地写。

concurrent、Collections、android.util等,为我们提供了很多设计思路。

加锁处理

实现线程安全,容易想到的就是加锁,例如Hashtable加函数锁,Collections.synchronizedList加对象锁,这种方式的读写性能相对平衡。

这几种加锁并不能保证绝对的线程安全,比如

if(map.containsKey("key")){
   map.remove("key");
}

在多线程中,虽然两个函数都加了锁,但是整个“检查-删除”操作并没有加锁,不是原子操作,可能出现线程A和线程B都通过了检查,都做删除操作导致出错(ConcurrentModificationException)的问题。这需要我们在编写代码时自己视情况加锁。

完全锁在性能上最大的问题是无法并行处理,只能串行处理,效率低下。

部分加锁

为了优化并行处理效率,有一种思路是把锁的粒度变小,只锁数据集合的一部分,比如ConcurrentHashMap。

ConcurrentHashMap也是数组+链表的组合,和HashMap不同的是,ConcurrentHashMap有两级数组,第一级数组是16个分段锁,每个分段segment其实是个ReentrantLock可重入锁,相当于16个桶,每个桶里有一个小型的HashMap。

这样,在操作部分数据时,只需要锁一个分段,这样可以并行处理16个分段,只有在全局处理所有数据时,才需要锁定所有16个segment。

因为ConcurrentHashMap的结构更复杂,所以需要使用三个HashKey,根据Key生成第一个key1,根据key1的高位hash出key2,key2决定在哪个segment;根据key1的全值hash出key3,key3决定在哪个HashEntry。
读写分离

除了缩小锁的粒度,还有一种优化方法是从操作维度上,把读操作和写操作分开。

在很多场景下,数据集合主要用来读,很少用来写(比如缓存),这时候可以做一个副本,在副本里写,在原集合里读,读和写不是同一个对象,就是读写分离。

读操作的对象是不变的,不需要加锁,不影响并行效率;

写操作虽然需要加锁,但是使用频率很低,所以综合起来能达到一个较好的平衡。

集合对象不需要加锁,虽然制作副本集合需要复制整个数组,会消耗大量时间,但是只需要对写函数加锁即可(同时把集合做成volatile对象,写之前先刷新一次);而在写回原集合时,只需要修改原集合的引用,改为指向副本集合,做一个=赋值操作即可,=赋值是个原子操作,也不用加锁。

读写分离的一种典型做法是COW,在Concurrent包里就是CopyOnWriteArrayList和CopyOnWriteArraySet。

这种方式有很明显的读性能好,写性能差的问题,仅适用特定场景。
负载因子

任何数据集合都要考虑读和写的平衡问题,HashMap在写的时候,内存越紧凑越好,但是读的时候最好能直接从数组中取到,这两者很难平衡,有时候需要我们根据自己的业务区动态设置,为此,HashMap提供了一个参数load factor负载因子,默认0.75,就是默认在容量达到75%时进行扩容,这其实给我们自己调整要时间还是要内存,留下了操作入口。
数组提速

数组提速其实和并行处理没有关系。

android提供了内存更紧凑的数据集合,如SparseArray、ArrayMap等,这些数据集合其实就是用数组代替HashMap实现了键值对的读写,但是数组最大的问题是插入和删除会导致大范围的移动,为尽量提速,android采用了假删除的设计,就是用空对象去替换被删除的对象,这样能避免移动数据带来的开销。