HashTable、ConcurrentHashMap实现线程安全对比

HashTable如何实现线程安全

HashTable实现线程安全的方法就是每个方法上加上synchronized关键字。

但,HashTable本身是个容器,这也就说明了HashTable本身可以不断的放大,试想一下,HashTable如果本身如果存在1000个元素,
那么在get()方法中就会将这1000个元素完全锁住,期间其他任何线程都得等待。这样就会造成容器越大,对容器数据操作的效率将越低。

除此之外,包括同步包装器(Synchronized Wrapper),我们可以调用Collections工具类提供的包装方法,
来获取一个同步的包装容器(如Collections.synchronizedMap),但是它们都是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。

但是ConcurrentHashMap不同,为了避免这种情况,它使用了锁分段技术。

ConcurrentHashMap如何实现线程安全

ConcurrentHashMap是线程安全的。可以在多线程中对ConcurrentHashMap进行操作。
而HashMap线程不安全,ConcurrentHashMap的key和value都不允许为null。而HashMap则允许。

jdk1.7中用的是锁分段技术Segment。数据结构是数组+链表。
比如容器HashMap中存在1000个元素,各个元素都放置到HashMap数组的链表或者红黑数中,最后得到的数组大小可能只有128,ConcurrentHashMap会根据这128个数组,对其分段,比如以16个数组为一段,可以分为8段。在实际获取元素,添加元素时,会根据元素的索引找到该元素所处的段位,然后只将该段位锁住,并不影响其他段位的数据操作。
这样如果按照HashTable的效率为基本单位来计算,ConcurrentHashMap在jdk1.7及以前的效率会提高8倍,当然数据量越大,提高的效率将越多。

jdk1.8中oncurrentHashMap主要使用了CAS(compareAndSwap)、volatile、synchronized锁。

跟jdk1.8中的HashMap一样,数据结构是数组+链表+红黑树。当链表长度过长时,会转变为红黑树。HashMap数据结构如下:

ConcurrentHashMap依旧使用分段锁的思想来实现线程安全,不同于jdk1.7及以前,jdk1.8将锁的粒度更加细分化,以每个数组索引为锁来进行实现。
比如HashMap数组中长度有128,那么就会存在128个锁将每个索引锁住。这样相比于jdk1.7之前在效率上有了很大的改进。

什么是Unsafe和CAS

ConcurrentHashMap的大部分操作和HashMap是相同的,例如初始化,扩容和链表向红黑树的转变等。但是,在ConcurrentHashMap中,大量使用U.compareAndSwapXXX的方法。

这个方法是利用一个CAS算法实现无锁化的修改值的操作,他可以大大降低锁代理的性能消耗。

其基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。
因为当前线程中的值已经不是最新的值,你的修改很可能会覆盖掉其他线程修改的结果。这一点与乐观锁,SVN的思想是比较类似的。

static {
    try {
        U = sun.misc.Unsafe.getUnsafe();
        Class<?> k = ConcurrentHashMap.class;
        SIZECTL = U.objectFieldOffset
            (k.getDeclaredField("sizeCtl"));
        TRANSFERINDEX = U.objectFieldOffset
            (k.getDeclaredField("transferIndex"));
        BASECOUNT = U.objectFieldOffset
            (k.getDeclaredField("baseCount"));
        CELLSBUSY = U.objectFieldOffset
            (k.getDeclaredField("cellsBusy"));
        Class<?> ck = CounterCell.class;
        CELLVALUE = U.objectFieldOffset
            (ck.getDeclaredField("value"));
        Class<?> ak = Node[].class;
        ABASE = U.arrayBaseOffset(ak);
        int scale = U.arrayIndexScale(ak);
        if ((scale & (scale - 1)) != 0)
            throw new Error("data type scale not a power of two");
        ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
    } catch (Exception e) {
        throw new Error(e);
    }
}

同时,在ConcurrentHashMap中还定义了三个原子操作,用于对指定位置的节点进行操作。

这三种原子操作被广泛的使用在ConcurrentHashMap的get和put等方法中,正是这些原子操作保证了ConcurrentHashMap的线程安全。

// 获取tab数组的第i个node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// 利用CAS算法设置i位置上的node节点。在CAS中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// 利用volatile方法设置第i个节点的值,这个操作一定是成功的。
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

ConcurrentHashMap的put方法

ConcurrentHashMap中最主要的put方法的实现,在put方法中调用了putVal方法,其源码如下:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 计算hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable(); // table是在首次插入元素的时候初始化,lazy
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, // 如果这个位置没有值,直接放进去,由CAS保证线程安全,不需要加锁
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {  // 节点上锁,这里的节点可以理解为hash值相同组成的链表的头节点,锁的粒度为头节点。
                if (tabAt(tab, i) == f) {
                    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;
                            }
                        }
                    }
                    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;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

ConcurrentHashMap的put方法的主要流程如下:

总结

jdk1.8就是将jdk1.7中segment缩小为1了(表面上)。1.7中Segment的数量由所谓的concurrentcyLevel决定,默认是16,也可以在相应构造函数直接指定。注意,Java需要它是2的幂数值,如果输入是类似15这种非幂值,会被自动调整到16之类2的幂数值。而1.8中粒度更加细,直接对Node数组中的单个元素进行上锁。

原文链接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章