JUC并发编程(13)——线程安全集合类

一、概述

线程安全集合类可以分为三大类 :
① 遗留的安全集合如Hashtable,Vector。
② 使用Collections装饰的线程安全类如Collections.synchronizedList、Collections.synchronizedMap等。
③ JUC包下的线程安全集合如Blocking类、CopyOnWrite类、Concurrent类等。

①是直接在方法上加上了synchronized锁,②是方法内部加上了synchronized锁。这里主要介绍的是JUC包下的线程安全集合类


二、HashMap问题

jdk1.7之前HashMap采用数组+单向链表,jdk1.8增加了红黑树结构。

1. 静态常量

/**
  * 默认初始大小,值为16,要求必须为2的幂
  */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 
 /**
  * 最大容量,必须不大于2^30
  */
 static final int MAXIMUM_CAPACITY = 1 << 30;
 
/**
 * 默认加载因子,值为0.75
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
/**
 * hash冲突默认采用单链表存储,当单链表节点个数大于等于8时,会转化为红黑树存储
 */
static final int TREEIFY_THRESHOLD = 8;
 
/**
 * hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化为红黑树存储。
 * 当红黑树中节点少于6时,则转化为单链表存储
 */
static final int UNTREEIFY_THRESHOLD = 6;
 
/**
 * hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化为红黑树存储。
 * 但是有一个前提:要求数组长度大于64,否则不会进行转化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

2. 构造方法

通过上面的静态常量可以知道,无参构造时初始大小为16,默认负载因子是0.75。


 public HashMap(int initialCapacity, float loadFactor) {
     if (initialCapacity < 0)
         throw new IllegalArgumentException("Illegal initial capacity: " +
                                            initialCapacity);
     if (initialCapacity > MAXIMUM_CAPACITY)
         initialCapacity = MAXIMUM_CAPACITY;
     if (loadFactor <= 0 || Float.isNaN(loadFactor))
         throw new IllegalArgumentException("Illegal load factor: " +
                                            loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity)//通过后面扩容的方法知道,该值就是初始创建数组时的长度
}
 
//返回initialCapacity最小二次幂。例如initialCapacity为7,该函数返回8.
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

问题:
① 为什么数组长度要是2的n次方?
应为HashMap要将数据放入底层数组中,使用的是hash &(length - 1)。这种位运算的效率高于普通的取余运算,但是这种位运算必须是length 必须是2的幂次。
并且如果length 不为2的幂次,那么length -1的最后一位一定为0,所以HashMap上的数组元素就分布不均匀,有些位置永远也利用不到。

3. put源码

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    static final int hash(Object key) {
        int h;
        // 将key的hash值的高16位和低16位异或,增加随机性
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 底层数组为懒加载,第一次操作map的时候才创建
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 计算key在数组中的索引位置
        // 如果当前索引位置无元素,则创建Node对象,存入数组该位置。
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
        	// 进到这里说明索引位置有其他元素
            Node<K,V> e; K k;
            // 判断hash和key是否相等,只要有一个不相同就进入下面的else判断
            // 这里的key可以是引用地址相等,也可以是equals()方法相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 如果hash值或者key不同,且索引位置元素为红黑树结果,则插入到红黑树中    
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
            	// 否则插入到链表中
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                    	// 加入到链表末尾,jdk1.8尾插法,jdk1.9头插法
                        p.next = newNode(hash, key, value, null);
                        
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 达到树化阀值则将该链表树化
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 比较链表中每个node,看是否有key和hash相同的,有就break
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 通过上面的判断,如果e为空,那么说明链表中有node的hash和key和当前需要插入的相同
            if (e != null) {
            	// 进行替换
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

4. 并发死链

在这里插入图片描述

并发死链在jdk1.7中存在,原因是hashmap多线程同时扩容并且底层链表采用头插法导致的。

① 现在假设hashmap底层数组下标i的位置存在一个链表:
table[i] -> a -> b -> c -> null
② 当数组内元素个数达到阈值时,两个线程都进入到扩容操作。
③ 扩容会遍历原数组table的每一个元素,thread1第一次循环:
e = a -> b -> c -> null
next = b -> c -> null
运行到这里thread1停下,thread2完成了扩容,扩容完后新数组链表为:
newtable[i] -> c -> b- > a-> null
thread1中的引用链会被改变为:
e = a -> null
next = b -> a -> null
④ thread1继续运行,在第一遍循环中会将e加入到新数组中,注意这里的新数组是局部变量和thread2中的newtable不是一个。
newtable[i] -> a-> null
⑤ 第二次循环
e = b -> a -> null
next = a -> null
然后将e加入新数组:
newtable[i] -> b -> a-> null
⑥ 第三次循环
e = a -> null
next = null
然后将e加入新数组:
newtable[i] -> a -> b -> a-> null
此时下一次循环中e为null,退出循环,但是死链已成。

5. 数据丢失

同死链一样,假如一个线程A正处于扩容循环:
e = a -> b -> c -> null
next = b -> c -> null
这是另一个线程B已经扩容好了,同时b应为扩容,求得的下标位置已经不在当前下标了,扩容后的新table为:
newtable[i] -> c -> a-> null
newtable[j] -> b -> null
此时线程A继续执行,线程A的新table为:
newtable[i] -> a-> null
然后进入下一次循环:
e = b -> null
next = null
此时next为null,这次循环完就结束了,此时线程A的新table为:
newtable[i] -> b -> a -> null
节点c就丢失了,数据丢失不仅JDK7,JDK8也会发生


三、ConcurrentHashMap

1. 重要属性和内部类

// 默认值时0
// 当初始化时,为-1
// 当扩容时,为-(1 + 线程数)
// 当初始化或扩容完成后,为下一次的扩容的阀值大小
private transient volatile int sizeCtl;

static class Node<K,V> implements Map.Entry<K,V> {}

// 底层数组
transient volatile Node<K,V>[] table;

// 扩容时新数组
private transient volatile Node<K,V>[] nextTable;

// 扩容时如果某个bin迁移完毕,用ForwardingNode作为旧table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}

// 用在compute以及computeIfAbsent时,用来占位,计算完成后替换为普通的Node
static final class ReservationNode<K,V> extends Node<K,V> {}

//作为treebin的头结点,存储root和first
static final class TreeBin<K,V> extends Node<K,V> {}

//作为treebin的结点,存储parent,left,right
static final class TreeNode<K,V> extends Node<K,V> {}

2. 构造方法

    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
            // 初始容量必须大于等于并发度
        if (initialCapacity < concurrencyLevel) 
            initialCapacity = concurrencyLevel;   
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        // 转化为2的n次方
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        // 初始化数组时使用 sizeCtl 作为数组初始化大小
        this.sizeCtl = cap;
    }

3. get方法

	public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        // 计算key的hashcode,spread()方法确保返回结果是正数
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            // 查看头结点是否是要查找的key
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            // 头结点的hashcode为负数表示该bin在扩容中或是treebin,此时用find方法来查找
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            // 正常遍历链表,用equals比较
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

4. 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();
        // 计算所存的key的hash
        int hash = spread(key.hashCode());
        // 链表长度
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
        	// f是链表头结点,fh是链表头结点的hash,i是链表在table数组的位置
            Node<K,V> f; int n, i, fh;
            // 如果table未初始化就进行初始化
            if (tab == null || (n = tab.length) == 0)
            	// 使用cas
                tab = initTable();
           // 所存的下标位置链表是否有头结点
            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
            }
            // 如果头结点的hash为-1,说明当前table正在扩容
            else if ((fh = f.hash) == MOVED)
            	// 帮忙扩容
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
               // 锁住当前链表
                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)
                    // 如果链表长度 >= 树化阈值,则将链表转为红黑树
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

5. 特点

内部使用cas优化,即保证线程并发的安全性,又保证吞吐量。
遍历时的弱一致性,即当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续遍历,但是内容是旧的。普通的hashmap则直接抛出异常


四、LinkedBlockingQueue

1. 主要源码

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {

	static class Node<E> {
        E item;
        /**
         * 有三种情况
         * - 真正的后继节点
         * - 自己,发生在出队时
         * - null, 表示没有后继节点
         */
        Node<E> next;
        Node(E x) { item = x; }
    }
    private final int capacity;
    private final AtomicInteger count = new AtomicInteger();
    transient Node<E> head;
    private transient Node<E> last;
    // 用于take阻塞
    private final ReentrantLock takeLock = new ReentrantLock();
    private final Condition notEmpty = takeLock.newCondition();
    // 用于put阻塞
    private final ReentrantLock putLock = new ReentrantLock();
    private final Condition notFull = putLock.newCondition();
}

2. 基本流程

① 初始化链表last = head = new Node<E>(null);Dummy节点为头结点用来占位
请添加图片描述
② 当一个节点入队时,last = last.next = node;
请添加图片描述
③ 出队

		// 将头结点赋值给h
		Node<E> h = head;
		// 将头结点下一个节点赋值给first
        Node<E> first = h.next;
        // 让头结点自己指向自己
        h.next = h; // help GC
        // 将下一个节点赋为头结点
        head = first;
        // 取出头结点中的值
        E x = first.item;
        // 将头结点的值设置为null
        first.item = null;
        // 返回头结点的值
        // 其实就是取出头结点下一个节点的值,并将头结点下一个节点变成头结点
        return x;

3. 加锁分析

用了两把锁

  • 如果用一把锁,同一时刻最多只允许有一个线程(生产者或消费者二选一)执行
  • 如果用两把锁,同一时刻,可以允许两个线程(一个生产者与一个消费者)同时执行

当节点总数大于2时,putLock保证last节点的线程安全,takelock保证的是head节点的线程安全,两把锁保证入队和出队没有竞争。
当节点总数等于2时,也没有竞争。
当节点总数等于1时,这时候入队出队有竞争,会产生阻塞。

	public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        int c = -1;
        Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        // 计数
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
        	// 如果队列中元素满了,则进入队列中等待
            while (count.get() == capacity) {
                notFull.await();
            }
            // 有空位,则入队加一
            enqueue(node);
            c = count.getAndIncrement();
            // 如果还有空位,则唤醒一个其他等待的生产者
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        // 队列中有一个元素,就叫醒take线程
        if (c == 0)
            signalNotEmpty();
    }
    public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
        	// 当队列为空时,阻塞
            while (count.get() == 0) {
                notEmpty.await();
            }
            // 出队
            x = dequeue();
            c = count.getAndDecrement();
            // 唤醒其他消费者
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        // 当容量不满时,则唤醒put线程
        if (c == capacity)
            signalNotFull();
        return x;
    }

4. 对比ArrayBlockingQueue

① Linked支持有界,Array强制有界
② Linked实现是链表,Array实现是数组
③ Linked是懒惰的,而Array需要提前初始化Node数组
④ Linked每次入队会生成新Node,而Array的Node是提前创建好的
⑤ Linked两把锁,Array一把锁

五、CopyOnWriteArrayList

CopyOnWriteArraySet是它的马甲,CopyOnWriteArraySet类中的方法功能都是调用CopyOnWriteArrayList来实现的。
底层采用写入时拷贝的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行。不影响其他线程的并发读。读写分离。

传统的ArrayList存在的问题:
① 多线程同时add会导致有的下标并未赋值的问题。
② ArrayList在遍历时数据发生改变就会抛出异常。
③ 如果一个线程读一个线程写,读到的数据可能有问题。例如读最后一个数据,结果最后一个数据正好被另一个线程给删了。就会发生数组下标越界的问题。
CopyOnWriteArrayList解决了ArrayList的并发问题。适合读多写少。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值