Hashtable是一种能提供快速插入和查询的数据结构,无论其包含多少Item(条目),执行查询和插入操作的平均时间复杂度总是接近O(1)。
ConcurrentHashMap是Java5中支持高并发、高吞吐量的线程安全HashMap实现。它由Segment数组结构和HashEntry数组结构组成。Segment数组在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键-值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构;一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素;每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
Hashtable和ConcurrentHashMap存储的内容为键-值对(key-value),且它们都是线程安全的容器,下面简要介绍它们的实现方式并对比它们的不同点。
Hashtable所有的方法都是同步的,因此,它是线程安全的。它的定义如下:
public class Hashtable
extends Dictionary
implements Map, Cloneable, java.io.Serializable
Hashtable是通过“拉链法”实现的散列表,因此,它使用数组+链表的方式来存储实际的元素,如图1所示。
如图,最顶部标数字的部分是一个Entry数组,而Entry又是一个链表。当向Hashtable中插入数据的时候,首先通过键的hashCode和Entry数组的长度来计算这个值应该存放在数组中的位置index。如果index对应的位置没有存放值,则直接存放到数组的index位置即可,当index有冲突的时候,则采用“拉链法”来解决冲突。假如往Hashtable中插入“aaa”“bbb”“eee”“fff”,如果“aaa”和“fff”所得到的index是相同的,则插入后Hashtable的结构如图2所示。
Hashtable的实现类图如图3所示。
为了使Hashtable拥有比较好的性能,数组的大小也需要根据实际插入数据的多少来进行动态地调整。Hashtable类中定义了一个rehash方法,该方法可以用来动态地扩容Hashtable的容量,该方法被调用的时机为:Hashtable中的键-值对超过某一阈值。默认情况下,该阈值等于Hashtable中Entry数组的长度*0.75。Hashtable默认的大小为11,当达到阈值后,每次按照下面的公式对容量进行扩充:newCapacity = oldCapacity * 2 + 1。
Hashtable通过使用synchronized修饰方法的方式来实现多线程同步,因此,Hashtable的同步会锁住整个数组。在高并发的情况下,性能会非常差,Java5中引入了java.util.concurrent.ConcurrentHashMap作为高吞吐量的线程安全HashMap实现,它采用了锁分离的技术允许多个修改操作并发进行。它们在多线程锁的使用方式如图4所示。
ConcurrentHashMap采用了更细粒度的锁来提高在并发情况下的效率。ConcurrentHashMap将Hash表默认分为16个桶(每一个桶可以被看作是一个Hashtable),大部分操作都没有用到锁,而对应的put、remove等操作也只需要锁住当前线程需要用到的桶,而不需要锁住整个数据。采用这种设计方式以后,在大并发的情况下,同时可以有16个线程来访问数据。显然,大大提高了并发性。
只有个别方法(例如size()方法和containsValue()方法)可能需要锁定整个表而不是某个桶,在实现的时候,需要按照顺序锁定所有桶,操作完毕后,又“按顺序”释放所有桶,“按顺序”的好处是能防止死锁的发生。
假设一个线程在读取数据的时候,另外一个线程在Hash链的中间添加或删除元素或者修改某一个结点的值,此时必定会读取到不一致的数据。那么如何才能实现在读取的时候不加锁而又不会读取到不一致的数据呢?ConcurrentHashMap使用不变量的方式来实现,它通过把Hash链中的结点HashEntry设计成几乎不可变的方式来实现,HashEntry的定义如下:
static final class HashEntry {
final K key;
final int hash;
volatile V value;
final HashEntry next;
}
从以上这个定义可以看出,除了变量value以外,其他的变量都被定义为final类型。因此,增加结点(put方法)的操作只能在Hash链的头部增加。对于删除操作,则无法直接从Hash链的中间删除结点,因为next也被定义为不可变量。因此,remove操作的实现方式如下:把需要删除的结点前面所有的结点都复制一遍,然后把复制后的Hash链的最后一个结点指向待删除结点的后继结点,由此可以看出,ConcurrentHashMap删除操作是比较耗时的。此外,使用volatile修饰value的方式使这个值被修改后对所有线程都可见(编译器不会进行优化),采用这种方式的好处如下:一方面,避免了加锁;另一方面,如果把value也设计为不可变量(用final修饰),那么每次修改value的操作都必须删除已有结点,然后插入新的结点,显然,此时的效率会非常低下。
由于volatile只能保证变量所有的写操作都能立即反映到其他线程中,也就是说,volatile变量在各个线程中是一致的,但是由于volatile不能保证操作的原子性,因此它不是线程安全的。如下例所示:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
class TestTask implements Runnable {
private ConcurrentHashMap map;
public TestTask(ConcurrentHashMap map) {
this.map = map;
}
@Override
public void run() {
for(int i = 0; i < 100; i++) {
map.put(1, this.map.get(1)+1);
}
}
}
public class Test {
public static void main(String[] args) {
int threadNumber = 1;
System.out.println("单线程运行结果:");
for(int i= 0; i < 5; i++) {
System.out.println("第" + (i+1) + "次运行结果:" + testAdd(threadNumber));
}
threadNumber = 5;
System.out.println("多线程运行结果:");
for(int i = 0; i < 5; i++) {
System.out.println("第" + (i+1) + "次运行结果:" + testAdd(5));
}
}
private static int testAdd(int threadNumber) {
ConcurrentHashMap map = new ConcurrentHashMap();
map.put(1, 0);
ExecutorService pool = Executors.newCachedThreadPool();
for(int i = 0; i < threadNumber; i++) {
pool.execute(new TestTask(map));
}
pool.shutdown();
try {
pool.awaitTermination(20, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return map.get(1);
}
}
程序运行结果为:
单线程运行结果:
第1次运行结果:100
第2次运行结果:100
第3次运行结果:100
第4次运行结果:100
第5次运行结果:100
多线程运行结果:
第1次运行结果:332
第2次运行结果:416
第3次运行结果:424
第4次运行结果:500
第5次运行结果:400
从上述运行结果可以看出,单线程运行时map.put(1,map.get(1)+1);会被执行100次,因此运行结果是100。当使用多线程运行时,在上述代码中使用了5个线程,也就是说map.put(1,map.get(1)+1);会被调用500次,如果使用这个容器是多线程安全的,那么运行结果应该是500,但实际的运行结果并不都是500。说明ConcurrentHashMap在某种情况下还是线程不安全的,这个例子中导致线程不安全的主要原因为:map.put(1,map.get(1)+1);不是一个原子操作,而是包含了下面三个操作:
假设map中的值为<1, 5>。线程1在执行map.put(1,map.get(1)+1);的时候首先通过get操作读取到map中的值为5,此时线程2也执行map.put(1,map.get(1)+1);从map中读取到的值也是5,接着线程1执行+1操作,然后把运算结果通过put操作放入map中,此时map中的值为<1, 6>;接着线程2执行+1操作,然后把运算结果通过put操作放入map中,此时map中的值还是<1, 6>。由此可以看出,两个线程分别执行了一次map.put(1,map.get(1)+1);,map中的值却增加了1。
因此在访问ConcurrentHashMap中value的时候,为了保证多线程安全,最好使用一些原子操作。如果要使用类似map.put(1,map.get(1)+1);的非原子操作,则需要通过加锁来实现多线程安全。
在上例中,为了保证多线程安全,可以把run方法改为:
public void run() {
for(int i = 0; i < 100; i++) {
synchronized(map) {
map.put(1, map.get(1));
}
}
}
Synchronized容器和Concurrent容器有什么区别?
在Java语言中,多线程安全的容器主要分为两种:Synchronized和Concurrent,虽然它们都是线程安全的,但是它们在性能方面差距比较大。
Synchronized容器(同步容器)主要通过synchronized关键字来实现线程安全,在使用的时候会对所有的数据加锁。需要注意的是,由于同步容器将所有对容器状态的访问都串行化了,这样虽然保证了线程的安全性,但是这种方法的代价就是严重降低了并发性,当多个线程竞争容器时,吞吐量会严重降低。于是引入了Concurrent容器(并发容器),Concurrent容器采用了更加智能的方案,该方案不是对整个数据加锁,而是采取了更加细粒度的锁机制,因此,在大并发量的情况下,拥有更高的效率。
参考资料 《Java程序员面试笔试真题与分析》 猿媛之家 编著