Java并发编程—结合源码分析ConcurrenthashMap与CopyOnWriteArrayList的原理

文章主要部分分析了两个并发容器的代表:ConcurrenthashMap和CopyOnWriteArrayList

  1. 并发容器
    1.1 常用的并发容器

ConcurrentHashMap:线程安全的HashMap
CopyOnWriteArrayList:线程安全的List
BlockingQueue:阻塞队列(实现类:ArrayBlockingQueue、LinkedBlockingQueue等)
ConcurrentLinkedQueue:非阻塞并发队列,链表实现,可看做线程安全的LinkedList
ConcurrentSkipListMap:使用跳跃链表实现的Map

1.2 早期的并发集合

Vector和HashTable

内部方法全部用synchronized做同步,并发性能差得一批!

HashMap和ArrayList

虽然这两个类不是线程安全的,但是可以用Collections的synchronizedList(list)和synchronizedMap(map)包装使之变成线程安全的

但是实质上,里面还是用synchronized实现, 以map为例:看源码:

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
    return new SynchronizedMap<>(m);
}

复制代码
private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable {

SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}

public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}

复制代码
我们可以看到源码中,虽然synchronized不是加在方法上,但是其实每个方法都是在调用对应map的方法并在外层包裹synchronized

1.3 现在的并发集合

ConcurrenthashMap和CopyOnWriteArrayList

用于取代同步的HashMap和ArrayList以及HashTable和Vector这些容器
多数条件下,性能优于以前的容器。
注:CopyOnWriteArrayList适合于读多写少的场景,每次写入都会复制整个列表,而原有的比较适合频繁修改

  1. ConcurrentHashMap源码原理分析
    2.1 关于HashMap

HashMap是一个线程不安全的类也不能在多线程下使用
并发下的问题:

同时put时如果hash碰撞,会丢失数据
同时put触发扩容的时候也会导致数据丢失
死循环造成CPU100%(JDK1.7及以前版本多线程同时触发扩容可能引发)

问题详解:coolshell.cn/articles/96…

JDK1.7结构:数组+链表(采用拉链法)
JDK1.8结构:数组+链表/红黑树(链表长度要大于阈值8,还要满足 数组的长度table.length>=MIN_TREEIFY_CAPACITY 这个值是64)

2.1 JDK1.7的ConcurrentHashMap的实现

JDK7中,ConcurrentHashMap最外层是多个segment,每个segment的底层数据结构与HashMap类似,任然是数组+链表组成的拉链法
每个Segment独立上ReentrantLock锁,每个Segment之间互不影响,提高了并发效率(Segment继承自ReentrantLock)
ConcurrentHashMap默认有16个segment,所以最多支持16个线程并发写(操作在不同的segment上时)。默认值在初始化的时候可以指定,但是一旦初始化过后,就不可以扩容。但是每个Segment内部是可以扩容的

2.2 JDK1.8的ConcurrentHashMap的源码分析

根本没有借鉴JDK1.7,而是重写了一遍。。。
JDK1.8中的ConcurrentHashMap结构和1.8中的HashMap结构是相似的,也是数组+链表/红黑树(阈值也是8不过还要满足table.length>=MIN_TREEIFY_CAPACITY 这个值是64)

put方法

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
  	// key-value的值不能为空
    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;
      	// table如果为空,或者长度为零就执行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
      	// 找出节点需要放置的位置如果为空,然后用CAS来赋值
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
      	// 如果处于MOVED状态 就帮助转换
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
      	// 如果table上要放的位置不为空就执行下列操作
        else {
            V oldVal = null;
          	// 锁住当前table上的位置
            synchronized (f) {
        				
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                          	//key相同就替换
                            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)
                  	// 满足时就把链表转成红黑树 
                  	// 注意此方法里面还有一个判断tab.length小于64的不转化
                    treeifyBin(tab, i);
              	// 如果老值不为空就返回
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

复制代码
get方法

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
  	// 计算hash值
    int h = spread(key.hashCode());
  	//	 排除为空的情况,并找到对应位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
      	// 如果相等就直接在table上取值
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
      	// 在红黑树中找值
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
      	// 在链表中找值
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

复制代码2.3 对比JDK1.7与1.8

首先是数据结构上:

1.7是segment数组,+Segment(类似HashMap的结构)
1.8是数据+链表/红黑树与HashMap类似

并发上:

1.7是使用ReentrantLock锁住每个Segment
1.8是使用CAS+synchronized

为什么超过8要使用红黑树

首先链表的结构存储要比红黑树存储节省空间
而链表在查询上又没有红黑树块
这个时候就需要一个边界,源码作者做了一个泊松分布运算,在链表达到8时的概率已经非常小了。而链表长度为8时,查找费时也不大。概率只有千万分之几

2.4 线程安全问题
ConcurrenthashMap并发下单独操作的确是安全的,但是组合操作就未必了。所以如果在多线程情况下,有多步操作ConcurrenthashMap的时候需要额外留心

如:如果要修改一个值:可以使用boolean replace(key, oldValue, newValue)来修改,而不是先get然后put, 这个方法类似于CAS的思想
此外还有putIfAbsent(key, value) ,先判断有没有这个值,如果没有就put,有就取出来给你

  1. CopyOnWriteArrayList源码原理分析
    3.1 使用场景

是用于替代Vector和SynchronizedList的,相较于Vector和SynchronizedList有更好的并发性能
Copy-on-Write并发容器还包括CopyOnWriteArraySet,用来替代同步Set
主要适用于:对于读操作有快速要求的,即是:读快写慢

3.2 读写规则

我们都知道读写锁的规则是:读写互斥,写写互斥
而CopyOnWrite则做了一个升级:读取是完全不加锁的,并且写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待。
此外,我们可以在迭代中可以进行删改元素,看一个案例

/**

  • CopyOnWriteArrayList可以在迭代中修改数组内容,而ArrayList不行

  • @author yiren
    */
    public class CopyOnWriteArrayListExample01 {
    public static void main(String[] args) {
    // ArrayList list = new ArrayList<>();
    CopyOnWriteArrayList list = new CopyOnWriteArrayList<>();

     list.add("1");
     list.add("2");
     list.add("3");
     list.add("4");
     list.add("5");
    
     Iterator<String> iterator = list.iterator();
     while (iterator.hasNext()) {
         System.out.println(list);
         String next = iterator.next();
         System.out.println(next);
    
         if (next.equals("2")) {
             list.remove("3");
         }
    
         if (next.equals("4")) {
             list.add("3 add");
         }
     }
    

    }
    }
    复制代码[1, 2, 3, 4, 5]
    1
    [1, 2, 3, 4, 5]
    2
    [1, 2, 4, 5]
    3
    [1, 2, 4, 5]
    4
    [1, 2, 4, 5, 3 add]
    5

Process finished with exit code 0
复制代码

我们可以看到结果输出。这似乎和list中的元素不对应啊。

其实这时没毛病的,CopyOnWriteArrayList就是这个思想,迭代你虽然可以改,但是你改你的,我迭代我的,它内部是有副本机制的

而ArrayList的迭代器是有一个modCount值来判断你迭代过程中是否修改的
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
复制代码
这个expectedModCount是在迭代器创建前,从ArrayList对象中获取的,原有ArrayList对象左右删改,那么modCount就会和expectedModCount不一值,此时就会快速失败了。

3.3 实现原理

CopyOnWrite:在写入操作的时候,它会先copy一份到新内存上,然后再修改,修改完成,再把原来的指针指过去,就OK。

这个过程就导致了,你在迭代的时候,迭代的内存还是老内存上的值,而不是修改过后的值

所以注意:每次修改或添加都会创建新副本,使之读写分离,而旧的内存数据是不会变的。

我们再看一个案例

/**

  • @author yiren
    */
    public class CopyOnWriteArrayListExample02 {
    public static void main(String[] args) {
    CopyOnWriteArrayList list = new CopyOnWriteArrayList<>();

     list.add("1");
     list.add("2");
     list.add("3");
     Iterator<String> itr1 = list.iterator();
     list.add("4");
     Iterator<String> itr2 = list.iterator();
    
     itr1.forEachRemaining(System.out::print);
     System.out.println();
     itr2.forEachRemaining(System.out::print);
    

    }
    }
    复制代码123
    1234
    Process finished with exit code 0
    复制代码

在CopyOnWrite的迭代器使用上,即使你修改了,它的迭代内容也只取决于他创建时候的集合的数据内容。而不取决于实际list是否修改。

所以迭代过程可能会出现数据过期问题

3.4 存在的缺点

数据一致性问题:也就是上面所提到的,它只能保证最终数据一致性,而不保证数据实时一致性。如果对写入实时响应的需求,不推荐使用。
内存浪费:CopyOnWrite的写是复制的机制,写操作的时候就一定会复制一份。这会很浪费内存

3.5 源码分析

首先CopyOnWriteArrayList是一个数组的列表集合,它的根本存储就是数组

private transient volatile Object[] array;

复制代码
多线程同时写入的时候是ReentrantLock。

/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();

复制代码
它的创建,构造函数可想而知,也就是给一个空数组。

public CopyOnWriteArrayList() {
    setArray(new Object[0]);
}

复制代码
但是它提供了一个可以直接放集合的构造函数,把数据先放入数组,然后直接指过去

public CopyOnWriteArrayList(Collection<? extends E> c) {
    Object[] elements;
    if (c.getClass() == CopyOnWriteArrayList.class)
        elements = ((CopyOnWriteArrayList<?>)c).getArray();
    else {
        elements = c.toArray();
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elements.getClass() != Object[].class)
            elements = Arrays.copyOf(elements, elements.length, Object[].class);
    }
    setArray(elements);
}

复制代码
add方法

添加的时候先上锁
然后先获取到当前数组,copy一份到新数组,数组长度+1
然后把新值放到末尾,把指针指过去
最后返回true 并释放锁

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

复制代码
get方法

没有任何加锁,直接返回对应的值

@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}

/**
 * {@inheritDoc}
 *
 * @throws IndexOutOfBoundsException {@inheritDoc}
 */
public E get(int index) {
    return get(getArray(), index);
}
发布了70 篇原创文章 · 获赞 10 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/a59612/article/details/104431042