Java并发编程学习之路(六)ThreadLocal、BlockingQueue、CopyOnWriteArrayList、ConcurrentHashmap

本系列文章:
  Java并发编程学习之路(一)并发编程三要素、Thread、Runnable、interrupted、join、sleep、yield
  Java并发编程学习之路(二)线程同步机制、synchronized、CAS、volatile、final、Lock、AQS
  Java并发编程学习之路(三)ReentrantLock、ReentrantReadWriteLock、死锁、原子类
  Java并发编程学习之路(四)线程池、FutureTask
  Java并发编程学习之路(五)wait/notify/notifyAll、await/signal/signalAll、生产者消费者问题
  Java并发编程学习之路(六)ThreadLocal、BlockingQueue、CopyOnWriteArrayList、ConcurrentHashmap
  Java并发编程学习之路(七)CountDownLatch、CyclicBarrier、Semaphore、Exchanger、Phaser
  Java并发编程学习之路(八)多线程编程例子

一、ThreadLocal

1.1 ThreadLocal的实现原理

  线程安全问题的核心在于多个线程会对同一个临界区共享资源进行操作。锁的解决思路是将对共享资源同一时刻的操作由并发改为串行,但这样会导致效率出现下降,并且这只是一种解决并发问题的方式,不是唯一方式。
  另外一种思路是:如果每个线程都使用自己的“共享资源”,各自使用各自的,又互相不影响到彼此即让多个线程间达到隔离的状态,这样也不会出现线程安全的问题。这是一种“空间换时间”的方案,ThreadLocal就是使用这种方式的。
  ThreadLocal表示线程的“本地变量”,即每个线程都拥有该变量副本,各用各的,从而避免共享资源的竞争。

  要想学习到 ThreadLocal 的实现原理,就必须了解它的几个核心方法,包括怎样存怎样取等。

  • 1、void set(T value)
      set方法用于设置在当前线程中threadLocal变量的值,源码:
	public void set(T value) {
    
    
		//1. 获取当前线程实例对象
	    Thread t = Thread.currentThread();
		//2. 通过当前线程实例获取到ThreadLocalMap对象
	    ThreadLocalMap map = getMap(t);
	    if (map != null)
			//3. 如果Map不为null,则以当前threadLocl实例为key,值为value进行存入
	        map.set(this, value);
	    else
			//4.map为null,则新建ThreadLocalMap并存入value
	        createMap(t, value);
	}

  可以看出:value是存放在ThreadLocalMap里,以当前threadLocal实例为 key。
  ThreadLocalMap getMap(Thread t)方法用于获取ThreadLocalMap实例:

	ThreadLocalMap getMap(Thread t) {
    
    
	    return t.threadLocals;
	}

  threadLocals变量定义在Thread类中:

	ThreadLocal.ThreadLocalMap threadLocals = null;

  也就是说ThreadLocalMap的引用是作为Thread的一个成员变量,被Thread进行维护的。
  在set方法中,当map为空时,会调用createMap(t,value)方法来创建ThreadLocalMap:

	void createMap(Thread t, T firstValue) {
    
    
  	    t.threadLocals = new ThreadLocalMap(this, firstValue);
	}

  对 set 方法进行总结:

  • 1、通过当前线程对象thread获取该thread所维护的成员变量threadLocalMap;
  • 2、若threadLocalMap不为 null,则以threadLocal实例为 key,值为value的键值对存入threadLocalMap;
  • 3、若 threadLocalMap为 null 的话,就新建 threadLocalMap,然后再以threadLocal为键,值为 value 的键值对存入即可。
  • 2、T get()
      get 方法是获取当前线程中threadLocal变量的值,源码:
	public T get() {
    
    
		//1. 获取当前线程的实例对象
	    Thread t = Thread.currentThread();
		//2. 获取当前线程的threadLocalMap
	    ThreadLocalMap map = getMap(t);
	    if (map != null) {
    
    
			//3. 获取map中当前threadLocal实例为key的值的entry
	        ThreadLocalMap.Entry e = map.getEntry(this);
	        if (e != null) {
    
    
	            @SuppressWarnings("unchecked")
				//4. 当前entitiy不为null的话,就返回相应的值value
	            T result = (T)e.value;
	            return result;
	        }
	    }
		//5. 若map为null或者entry为null的话通过该方法初始化,并返回该方法返回的value
	    return setInitialValue();
	}

  get方法的逻辑,和set方法是相反的。setInitialValue源码:

	private T setInitialValue() {
    
    
	    T value = initialValue();
	    Thread t = Thread.currentThread();
	    ThreadLocalMap map = getMap(t);
	    if (map != null)
	        map.set(this, value);
	    else
	        createMap(t, value);
	    return value;
	}

  initialValue源码:

	protected T initialValue() {
    
    
	    return null;
	}

  此处表明:继承ThreadLocal的子类可以重写该方法,实现赋予初始值的逻辑。
  get方法的逻辑:

  • 1、通过当前线程 thread 实例获取到它所维护的 threadLocalMap,以当前 threadLocal 实例为 key 获取该 map 中的键值对(Entry);
  • 2、若 Entry 不为 null 则返回 Entry 的 value;
  • 3、如果获取 threadLocalMap 为 null 或者 Entry 为 null 的话,就以当前 threadLocal 为 Key,value 为 null 存入 map 后,并返回 null。
  • 3、void remove()
      删除数据,源码:
	public void remove() {
    
    
		//1. 获取当前线程的threadLocalMap
		ThreadLocalMap m = getMap(Thread.currentThread());
	 	if (m != null)
			//2. 从map中删除以当前threadLocal实例为key的键值对
			m.remove(this);
	}

1.2 ThreadLocalMap详解

1.2.1 Entry数据结构

  ThreadLocalMap是threadLocal的一个静态内部类,threadLocalMap 内部维护了一个Entry类型的table数组:

	private Entry[] table;

  table数组的长度为2的幂次方。接下来看下Entry:

        static class Entry extends WeakReference<ThreadLocal<?>> {
    
    
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
    
    
                super(k);
                value = v;
            }
        }

  Entry 是一个以 ThreadLocal 为 key,Object 为 value 的键值对。
  这里的threadLocal是弱引用,因为Entry继承了 WeakReference,在 Entry 的构造方法中,调用了 super(k)方法就会将 threadLocal 实例包装成一个 WeakReferenece。
  Thread,ThreadLocal,ThreadLocalMap,Entry之间的关系:

  上图中的实线表示强引用,虚线表示弱引用。
  每个线程实例中可以通过threadLocals获取到threadLocalMap,而threadLocalMap实际上就是一个以threadLocal实例为key,任意对象为value的Entry数组。
  当为 threadLocal 变量赋值,实际上就是以当前 threadLocal 实例为 key,值为 value 的 Entry 往这个 threadLocalMap 中存放。需要注意的是Entry 中的 key 是弱引用,当 threadLocal 外部强引用被置为 null(threadLocalInstance=null),那么系统 GC 的时候,根据可达性分析,这个 threadLocal 实例就没有任何一条链路能够引用到它,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄漏。
  如果当前 thread 运行结束,threadLocal,threadLocalMap,Entry 没有引用链可达,在垃圾回收的时候都会被系统进行回收。在实际开发中,会使用线程池去维护线程的创建和复用,比如固定大小的线程池,线程为了复用是不会主动结束的。

1.2.2 set方法

  ThreadLocalMap底层是用散列表(哈希表)进行实现的。
  ThreadLocalMap类中的set方法:

        private void set(ThreadLocal<?> key, Object value) {
    
    

            Entry[] tab = table;
            int len = tab.length;
            //根据threadLocal的hashCode确定Entry应该存放的位置
            int i = key.threadLocalHashCode & (len-1);
			//采用开放地址法,hash冲突的时候使用线性探测
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
    
    
                ThreadLocal<?> k = e.get();
				//覆盖旧Entry
                if (k == key) {
    
    
                    e.value = value;
                    return;
                }
				//当key为null时,说明threadLocal强引用已经被释放掉,那么就无法再通过
				//这个key获取threadLocalMap中对应的entry,这里就存在内存泄漏的可能性
                if (k == null) {
    
    
                	//用当前插入的值替换掉这个key为null的“脏”entry
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
			//新建entry并插入table中i处
            tab[i] = new Entry(key, value);
            int sz = ++size;
            //插入后再次清除一些key为null的“脏”entry,如果大于阈值就需要扩容
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }
  • 1、ThreadLocal的hashcode
	private final int threadLocalHashCode = nextHashCode();
	private static final int HASH_INCREMENT = 0x61c88647;
	private static AtomicInteger nextHashCode =new AtomicInteger();

	private static int nextHashCode() {
    
    
	    return nextHashCode.getAndAdd(HASH_INCREMENT);
	}

  0x61c88647 这个数是有特殊意义的,它能够保证哈希表的每个散列桶能够均匀的分布。也正是能够均匀分布,所以ThreadLocal选择使用开放地址法来解决hash冲突的问题。

  • 2、新值插入到哈希表中的位置
      源码为:key.threadLocalHashCode & (len-1),因为哈希表大小总是为 2 的幂次方,所以与运算等同于一个取模,这样就可以通过 Key 分配到具体的哈希桶中去。为什么取模要通过位与运算?因为位运算的执行效率远远高于了取模运算。
  • 3、怎样解决 hash 冲突?
      通过nextIndex(i, len)方法解决 hash 冲突的问题,该方法为:
        private static int nextIndex(int i, int len) {
    
    
            return ((i + 1 < len) ? i + 1 : 0);
        }

  也就是不断往后线性探测,当到哈希表末尾的时候再从 0 开始,成环形。

  • 4、怎样解决“脏”Entry?
      ThreadLocal有可能存在内存泄漏(对象创建出来后,在之后的逻辑一直没有使用该对象,但是垃圾回收器无法回收这个部分的内存),在源码中针对这种 key 为 null 的 Entry 称之为“stale entry”,直译为不新鲜的 entry,可以理解为“脏 entry”。
      在set方法的 for 循环中寻找和当前 Key 相同的可覆盖 entry 的过程中,通过replaceStaleEntry方法解决脏 entry 的问题。
  • 5、扩容
      在第一次为ThreadLocal进行赋值时,会创建初始大小为16的ThreadLocalMap,并且通过 setThreshold 方法设置 threshold,其值为当前哈希数组长度乘以(2/3),也就是说加载因子为 2/3(加载因子是衡量哈希表密集程度的一个参数,如果加载因子越大的话,说明哈希表被装载的越多,出现 hash 冲突的可能性越大,反之,则被装载的越少,出现 hash 冲突的可能性越小。同时如果过小,很显然内存使用率不高,该值取值应该考虑到内存使用率和 hash 冲突概率的一个平衡,如 hashMap,concurrentHashMap 的加载因子都为 0.75)。
      ThreadLocalMap 初始大小为 16,加载因子为 2/3,所以哈希表可用大小为:16*2/3=10,即哈希表可用容量为 10。
      从 set 方法中可以看出当 hash 表的 size 大于 threshold 的时候,会通过 resize 方法进行扩容。
        private void resize() {
    
    
            Entry[] oldTab = table;
            int oldLen = oldTab.length;
            //新数组为原数组的2倍
            int newLen = oldLen * 2;
            Entry[] newTab = new Entry[newLen];
            int count = 0;

            for (int j = 0; j < oldLen; ++j) {
    
    
                Entry e = oldTab[j];
                if (e != null) {
    
    
                    ThreadLocal<?> k = e.get();
                    if (k == null) {
    
    
                        e.value = null; // Help the GC
                    } else {
    
    
                    	//重新确定entry在新数组的位置,然后进行插入
                        int h = k.threadLocalHashCode & (newLen - 1);
                        while (newTab[h] != null)
                            h = nextIndex(h, newLen);
                        newTab[h] = e;
                        count++;
                    }
                }
            }
			//设置新哈希表的threshHold和size属性
            setThreshold(newLen);
            size = count;
            table = newTab;
        }

  resize方法的逻辑:新建一个大小为原来数组长度的两倍的数组,然后遍历旧数组中的 entry 并将其插入到新的 hash 数组中,主要注意的是,在扩容的过程中针对脏 entry 的话会令 value 为 null,以便能够被垃圾回收器能够回收,解决隐藏的内存泄漏的问题。

1.2.3 getEntry方法

        private Entry getEntry(ThreadLocal<?> key) {
    
    
        	//1. 确定在散列数组中的位置
            int i = key.threadLocalHashCode & (table.length - 1);
            //2. 根据索引i获取entry
            Entry e = table[i];
            //3. 满足条件则返回该entry
            if (e != null && e.get() == key)
                return e;
            else
            	//4. 未查找到满足条件的entry,额外处理
                return getEntryAfterMiss(key, i, e);
        }

  getEntryAfterMiss源码:

        private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    
    
            Entry[] tab = table;
            int len = tab.length;

            while (e != null) {
    
    
                ThreadLocal<?> k = e.get();
                if (k == key)
                	//找到和查询的key相同的entry则返回
                    return e;
                if (k == null)
                	//解决脏entry的问题
                    expungeStaleEntry(i);
                else
                	//继续向后环形查找
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }

  getEntryAfterMiss的逻辑:通过 nextIndex 往后环形查找,如果找到和查询的 key 相同的 entry 的话就直接返回,如果在查找过程中遇到脏 entry 的话使用 expungeStaleEntry 方法进行处理。

1.2.4 remove方法

  remove方法源码:

        private void remove(ThreadLocal<?> key) {
    
    
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
    
    
                if (e.get() == key) {
    
    
                	//将entry的key置为null
                    e.clear();
                    //将该entry的value也置为null
                    expungeStaleEntry(i);
                    return;
                }
            }
        }

  remove方法的逻辑:通过往后环形查找到与指定 key 相同的 entry 后,先通过 clear 方法将 key 置为 null 后,使其转换为一个脏 entry,然后调用 expungeStaleEntry 方法将其 value 置为 null,以便垃圾回收时能够清理,同时将 table[i]置为 null。

1.3 ThreadLocal的使用场景

  ThreadLocal适用于共享对象会造成线程安全 的业务场景。ThreadLocal经典的使用场景是为每个线程分配一个JDBC连接Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection。
  ThreadLocal的使用示例:

public class ThreadLocalTest {
    
    
    private static ThreadLocal<SimpleDateFormat> sdf = new ThreadLocal<>();

    public static void main(String[] args) {
    
    
    	ExecutorService executorService = Executors.newFixedThreadPool(2);
    	for (int i = 0; i<10; i++) {
    
    
    		executorService.submit(new DateUtil("2021-10-" + (i%10+1)));
    	}
    }

	static class DateUtil implements Runnable {
    
    
	    private String date;
	
	    public DateUtil(String date) {
    
    
	        this.date = date;
	    }
	
	    @Override
	    public void run() {
    
    
	        if (sdf.get() == null) {
    
    
	            sdf.set(new SimpleDateFormat("yyyy-MM-dd"));
	        } else {
    
    
	            try {
    
    
	                Date date = sdf.get().parse(this.date);
	                System.out.println(date);
	            } catch (ParseException e) {
    
    
	                e.printStackTrace();
	            } finally {
    
    
	            	sdf.remove();
				}
	        }
	    }
	}
}

  结果示例:

Mon Oct 04 00:00:00 CST 2021
Wed Oct 06 00:00:00 CST 2021
Sun Oct 03 00:00:00 CST 2021
Sun Oct 10 00:00:00 CST 2021
Sat Oct 09 00:00:00 CST 2021

  代码逻辑很简单:如果当前线程不持有 SimpleDateformat 对象实例,那么就新建一个并把它设置到当前线程中;如果已经持有,就直接使用。

1.4 造成内存泄漏的原因

  ThreadLocal是为了解决对象不能被多线程共享访问的问题,通过threadLocal.set方法将对象实例保存在每个线程自己所拥有的ThreadLocalMap中,这样每个线程使用自己的对象实例,彼此不会影响达到隔离的作用,从而就解决了对象在被共享访问带来线程安全问题

  • 同步机制
      由于每个线程在同一时刻共享对象只能被一个线程访问造成整体上响应时间增加,但是对象只占有一份内存,牺牲了时间效率换来了空间效率即“时间换空间”。
  • ThreadLocal
      为每个线程都分配了一份对象,自然而然内存使用率增加,每个线程各用各的,整体上时间效率要好很多,即“空间换时间”。

  再看一下ThreadLocal,ThreadLocalMap,Entry之间的关系:

  实线代表强引用,虚线代表的是弱引用,如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条引用链路可达,很显然在gc(垃圾回收)的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value。同时,就存在了这样一条引用链:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就存在了内存泄漏。

1.5 为什么使用弱引用

  通过threadLocal,threadLocalMap,entry的引用关系看起来threadLocal存在内存泄漏的问题似乎是因为threadLocal是被弱引用修饰的。那为什么要使用弱引用呢?
  假设threadLocal使用的是强引用,在业务代码中执行threadLocalInstance==null操作,以清理掉threadLocal实例的目的,但是因为threadLocalMap的Entry强引用threadLocal,因此在gc的时候进行可达性分析,threadLocal依然可达,对threadLocal并不会进行垃圾回收,这样就无法真正达到业务逻辑的目的,出现逻辑错误

  因为堆中的ThreadLocal实例到Entry之间的引用是弱引用,所以当断开ThreadLocalRef到ThreadLocal实例之间的引用(threadLocalInstance = null)时,Entry和ThreadLocal实例之间的引用就是孤零零的弱引用,这样就可以被GC(一旦JVM发现某个弱引用,就会将其回收)。
  假设Entry弱引用threadLocal,尽管会出现内存泄漏的问题,但是在threadLocal的生命周期里(set,getEntry,remove)里,都会针对key为null的脏entry进行处理。

1.6 Thread.exit()

  当线程退出时会执行exit方法:

	private void exit() {
    
    
	    if (group != null) {
    
    
	        group.threadTerminated(this);
	        group = null;
	    }
	    /* Aggressively null out all reference fields: see bug 4006245 */
	    target = null;
	    /* Speed the release of some of these resources */
	    threadLocals = null;
	    inheritableThreadLocals = null;
	    inheritedAccessControlContext = null;
	    blocker = null;
	    uncaughtExceptionHandler = null;
	}

  从源码可以看出当线程结束时,会令threadLocals=null,也就意味着GC的时候就可以将threadLocalMap进行垃圾回收,换句话说threadLocalMap生命周期实际上thread的生命周期相同。

1.7 ThreadLocal最佳实践

  1. 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。示例:
        ThreadLocal<M> tl = new ThreadLocal<>();
        tl.set(new M());
        tl.remove();
  1. 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理

二、BlockingQueue

  Collection集合框架中的各种容器类,如实现ArrayList、HashSet、HashMap等,这些容器类基本上不是线程安全的。除了使用 Collections工具类可以将其转换为线程安全的容器,JDK中还有对应的线程安全的容器,如实现 List 接口的 CopyOnWriteArrayList,实现 Map 接口的 ConcurrentHashMap,实现 Queue 接口的 ConcurrentLinkedQueue等。
  在经典的生产者消费者问题中,阻塞队列常常被用到。因为BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。

  阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:

  • 在队列空时,获取元素的线程会阻塞;
  • 当队列满时,存储元素的线程会阻塞。

  阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

2.1 阻塞队列的基本操作

  BlockingQueue基本操作:

抛异常 特殊值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e,time,unit)
删除 remove() poll() take() poll(time,unit)
获取 element() peek()

  BlockingQueue继承于Queue接口,对数据元素的基本操作有:

  • 1、插入元素

add(E e) :往队列插入数据,当队列满时,插入元素时会抛出IllegalStateException异常;
offer(E e):当往队列插入数据时,插入成功返回true,否则则返回false。

  • 2、删除元素

remove(Object o):从队列中删除数据,成功则返回true,否则为false;
poll():删除数据,当队列为空时,返回null。

  • 3、查看元素

element():获取队头元素,如果队列为空时则抛出NoSuchElementException异常;
peek():获取队头元素,如果队列为空则抛出NoSuchElementException异常。

  BlockingQueue的特有接口:

  • 1、插入数据

put(e):当阻塞队列容量已经满时,往阻塞队列插入数据的线程会被阻塞,直至阻塞队列已经有空余的容量可供使用;
offer(E e, long timeout, TimeUnit unit):若阻塞队列已经满时,同样会阻塞插入数据的线程,直至阻塞队列已经有空余的地方,与put方法不同的是,该方法会有一个超时时间,若超过当前给定的超时时间,插入数据的线程会退出。

  • 2、删除数据

take():当阻塞队列为空时,获取队头数据的线程会被阻塞;
poll(long timeout, TimeUnit unit):当阻塞队列为空时,获取数据的线程会被阻塞;如果被阻塞的线程超过了指定时间,该线程会退出。

2.2 常见的BlockingQueue实现类

  • 1、ArrayBlockingQueue
      ArrayBlockingQueue是由数组实现的有界阻塞队列。该队列命令元素FIFO(先进先出)。因此,队头元素时队列中存在时间最长的数据元素,而队尾数据则是当前队列最新的数据元素。ArrayBlockingQueue可作为“有界数据缓冲区”,生产者插入数据到队列容器中,并由消费者提取。ArrayBlockingQueue一旦创建,容量不能改变。
      当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
      ArrayBlockingQueue默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到ArrayBlockingQueue。而非公平性则是指访问ArrayBlockingQueue的顺序不是遵守严格的时间顺序,有可能存在,一旦ArrayBlockingQueue可以被访问时,长时间阻塞的线程依然无法访问到ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的ArrayBlockingQueue,可采用如下代码:
private static ArrayBlockingQueue<Integer> blockingQueue = 
	new ArrayBlockingQueue<Integer>(10,true);
  • 2、LinkedBlockingQueue
      LinkedBlockingQueue是用链表实现的有界阻塞队列,同样满足FIFO的特性,与ArrayBlockingQueue相比起来具有更高的吞吐量,为了防止LinkedBlockingQueue容量迅速增,损耗大量内存。通常在创建LinkedBlockingQueue对象时,会指定其大小,如果未指定,容量等于Integer.MAX_VALUE
  • 3、PriorityBlockingQueue
      PriorityBlockingQueue是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现compareTo()方法来指定元素排序规则,或者初始化时通过构造器参数Comparator来指定排序规则。
  • 4、SynchronousQueue
      SynchronousQueue每个插入操作必须等待另一个线程进行相应的删除操作,因此,SynchronousQueue实际上没有存储任何数据元素,因为只有线程在删除数据时,其他线程才能插入数据,同样的,如果当前有线程在插入数据时,线程才能删除数据。SynchronousQueue也可以通过构造器参数来为其指定公平性。
  • 5、LinkedBlockingDeque
      LinkedBlockingDeque是基于链表数据结构的有界阻塞双端队列,如果在创建对象时为指定大小时,其默认大小为Integer.MAX_VALUE。与LinkedBlockingQueue相比,主要的不同点在于,LinkedBlockingDeque具有双端队列的特性。
  • 6、DelayQueue
      DelayQueue是一个存放实现Delayed接口的数据的无界阻塞队列,只有当数据对象的延时时间达到时才能插入到队列进行存储。如果当前所有的数据都还没有达到创建时所指定的延时期,则队列没有队头,并且线程通过poll等方法获取数据元素则返回null。所谓数据延时期满时,则是通过Delayed接口的getDelay(TimeUnit.NANOSECONDS)来进行判定,如果该方法返回的是小于等于0则说明该数据元素的延时期已满。

2.3 ArrayBlockingQueue实现原理

  阻塞队列最核心的功能是,能够可阻塞式的插入和删除队列元素

  当前队列为空时,会阻塞消费数据的线程,直至队列非空时,通知被阻塞的线程;当队列满时,会阻塞插入数据的线程,直至队列未满时,通知插入数据的线程(生产者线程)。

  ArrayBlockingQueue的主要属性:

	/** The queued items */
	final Object[] items;

	/** items index for next take, poll, peek or remove */
	int takeIndex;

	/** items index for next put, offer, or add */
	int putIndex;

	/** Number of elements in the queue */
	int count;

	/** Main lock guarding all access */
	final ReentrantLock lock;

	/** Condition for waiting takes */
	private final Condition notEmpty;

	/** Condition for waiting puts */
	private final Condition notFull;

  可以看出ArrayBlockingQueue内部是采用数组(items)进行数据存储的,为了保证线程安全,采用的是ReentrantLock。为了保证可阻塞式的插入删除数据利用的是Condition,当获取数据的消费者线程被阻塞时会将该线程放置到notEmpty等待队列中,当插入数据的生产者线程被阻塞时,会将该线程放置到notFull等待队列中。
  notEmpty和notFull等重要属性在构造方法中进行创建:

	public ArrayBlockingQueue(int capacity, boolean fair) {
    
    
	    if (capacity <= 0)
	        throw new IllegalArgumentException();
	    this.items = new Object[capacity];
	    lock = new ReentrantLock(fair);
	    notEmpty = lock.newCondition();
	    notFull =  lock.newCondition();
	}
  • put方法
      put(E e)方法源码:
	public void put(E e) throws InterruptedException {
    
    
	    checkNotNull(e);
	    final ReentrantLock lock = this.lock;
	    lock.lockInterruptibly();
	    try {
    
    
			//如果当前队列已满,将线程移入到notFull等待队列中
	        while (count == items.length)
	            notFull.await();
			//满足插入数据的要求,直接进行入队操作
	        enqueue(e);
	    } finally {
    
    
	        lock.unlock();
	    }
	}

  该方法的逻辑很简单,当队列已满时(count == items.length)将线程移入到notFull等待队列中,如果当前满足插入数据的条件,就可以直接调用enqueue(e)插入数据元素。
  enqueue方法源码:

private void enqueue(E x) {
    
    
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
	//插入数据
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
	//通知消费者线程,当前队列中有数据可供消费
    notEmpty.signal();
}

  enqueue方法的逻辑同样也很简单,先完成插入数据,即往数组中添加数据(items[putIndex] = x),然后通知被阻塞的消费者线程,当前队列中有数据可供消费(notEmpty.signal())。

  • take方法
      take方法源码:
	public E take() throws InterruptedException {
    
    
	    final ReentrantLock lock = this.lock;
	    lock.lockInterruptibly();
	    try {
    
    
			//如果队列为空,没有数据,将消费者线程移入等待队列中
	        while (count == 0)
	            notEmpty.await();
			//获取数据
	        return dequeue();
	    } finally {
    
    
	        lock.unlock();
	    }
	}

  take方法也主要做了两步:1. 如果当前队列为空的话,则将获取数据的消费者线程移入到等待队列中;2. 若队列不为空则获取数据,即完成出队操作dequeue。
  dequeue方法源码为:

	private E dequeue() {
    
    
	    // assert lock.getHoldCount() == 1;
	    // assert items[takeIndex] != null;
	    final Object[] items = this.items;
	    @SuppressWarnings("unchecked")
		//获取数据
	    E x = (E) items[takeIndex];
	    items[takeIndex] = null;
	    if (++takeIndex == items.length)
	        takeIndex = 0;
	    count--;
	    if (itrs != null)
	        itrs.elementDequeued();
	    //通知被阻塞的生产者线程
		notFull.signal();
	    return x;
	}

  dequeue方法也主要做了两件事情:

  1. 获取队列中的数据,即获取数组中的数据元素((E) items[takeIndex]);
  2. 通知notFull等待队列中的线程,使其由等待队列移入到同步队列中,使其能够有机会获得lock,并执行完成功退出。

  从以上分析,可以看出put和take方法主要是通过condition的通知机制来完成可阻塞式的插入数据和获取数据。

2.4 LinkedBlockingQueue实现原理

  LinkedBlockingQueue是用链表实现的有界阻塞队列,当构造对象时未指定队列大小时,队列默认大小为Integer.MAX_VALUE。从它的构造方法可以看出:

	public LinkedBlockingQueue() {
    
    
	    this(Integer.MAX_VALUE);
	}

  LinkedBlockingQueue的主要属性:

	/** Current number of elements */
	private final AtomicInteger count = new AtomicInteger();
	
	/**
	 * Head of linked list.
	 * Invariant: head.item == null
	 */
	transient Node<E> head;
	
	/**
	 * Tail of linked list.
	 * Invariant: last.next == null
	 */
	private transient Node<E> last;
	
	/** Lock held by take, poll, etc */
	private final ReentrantLock takeLock = new ReentrantLock();
	
	/** Wait queue for waiting takes */
	private final Condition notEmpty = takeLock.newCondition();
	
	/** Lock held by put, offer, etc */
	private final ReentrantLock putLock = new ReentrantLock();
	
	/** Wait queue for waiting puts */
	private final Condition notFull = putLock.newCondition();

  可以看出与ArrayBlockingQueue主要的区别是,LinkedBlockingQueue在插入数据和删除数据时分别是由两个不同的lock(takeLock和putLock)来控制线程安全的,因此,也由这两个lock生成了两个对应的condition(notEmpty和notFull)来实现可阻塞的插入和删除数据。并且,采用了链表的数据结构来实现队列,Node结点的定义为:

	static class Node<E> {
    
    
	    E item;
	
	    Node<E> next;
	
	    Node(E x) {
    
     item = x; }
	}
  • put方法
      put方法源码:
	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();
	    }
	    if (c == 0)
	        signalNotEmpty();
	}

  put方法的逻辑也同样很容易理解,可见注释。基本上和ArrayBlockingQueue的put方法一样。

  • take方法
      take方法源码:
	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();
	    }
	    if (c == capacity)
	        signalNotFull();
	    return x;
	}

2.5 ArrayBlockingQueue与LinkedBlockingQueue的比较

  相同点:ArrayBlockingQueue和LinkedBlockingQueue都是通过condition通知机制来实现可阻塞式插入和删除元素,并满足线程安全的特性。
  不同点:

  1. ArrayBlockingQueue底层是采用的数组进行实现,而LinkedBlockingQueue则是采用链表数据结构;
  2. ArrayBlockingQueue插入和删除数据,只采用了一个lock,而LinkedBlockingQueue则是在插入和删除分别采用了putLock和takeLock,这样可以降低线程由于线程无法获取到lock而进入WAITING状态的可能性,从而提高了线程并发执行的效率

三、CopyOnWriteArrayList

  CopyOnWriteArrayList可以保证线程安全,保证读读线程之间不会被阻塞,因此CopyOnWriteArrayList被广泛应用于很多读多写少的场景中。

3.1 COW的设计思想

  如果简单的使用读写锁来保证线程安全的话,在写锁被获取之后,读写线程被阻塞,只有当写锁被释放后读线程才有机会获取到锁从而读到最新的数据。站在读线程的角度来看,即读线程任何时候都是获取到最新的数据,满足数据实时性。
  要进行优化,一种思路是牺牲数据实时性满足数据的最终一致性即可,CopyOnWriteArrayList 就是具备了这种方式的容器。CopyOnWriteArrayList通过Copy-On-Write(COW),即写时复制的思想来通过延时更新的策略来实现数据的最终一致性,并且能够保证读线程间不阻塞。
  COW通俗的理解是:当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
  对COW容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性达到数据的最终一致性。

3.2 CopyOnWriteArrayList 的实现原理

  CopyOnWriteArrayList内部维护了一个数组:

	/** The array, accessed only via getArray/setArray. */
	private transient volatile Object[] array;
  • 1、get方法
      get方法源码:
	public E get(int index) {
    
    
	    return get(getArray(), index);
	}

	final Object[] getArray() {
    
    
	    return array;
	}
	
	private E get(Object[] a, int index) {
    
    
	    return (E) a[index];
	}

  get方法实现非常简单:所有的读线程只是会读取数据容器中的数据,并不会进行修改。

  • 2、add方法
      get方法源码:
public boolean add(E e) {
    
    
    final ReentrantLock lock = this.lock;
	//1. 使用Lock,保证写线程在同一时刻只有一个
    lock.lock();
    try {
    
    
		//2. 获取旧数组引用
        Object[] elements = getArray();
        int len = elements.length;
		//3. 创建新的数组,并将旧数组的数据复制到新数组中
        Object[] newElements = Arrays.copyOf(elements, len + 1);
		//4. 往新数组中添加新的数据
		newElements[len] = e;
		//5. 将旧数组引用指向新的数组
        setArray(newElements);
        return true;
    } finally {
    
    
        lock.unlock();
    }
}

  add 方法的逻辑也比较容易理解,需要注意这么几点:

  1. 采用 ReentrantLock,保证同一时刻只有一个写线程正在进行数组的复制,否则的话内存中会有多份被复制的数据;
  2. 数组引用是volatile修饰的,因此将旧的数组引用指向新的数组,根据volatile的 happens-before 规则,写线程对数组引用的修改对读线程是可见的;
  3. 由于在写数据的时候,是在新的数组中插入数据的,从而保证读写实在两个不同的数据容器中进行操作。

3.3 总结

  • 1、COW vs 读写锁
      COW和ReentrantReadWriteLock两者的相同点:
  1. 两者都是通过读写分离的思想实现;
    2.读线程间是互不阻塞的。

  不同点:使用ReentrantReadWriteLock时,对读线程而言,为了实现数据实时性,在写锁被获取后,读线程会等待或者当读锁被获取后,写线程会等待,从而解决“脏读”等问题。也就是说如果使用读写锁依然会出现读线程阻塞等待的情况。
  COW则完全放开了牺牲数据实时性而保证数据最终一致性,即读线程对数据的更新是延时感知的,因此读线程不会存在等待的情况。

  • 2、COW的缺点
      CopyOnWrite容器有很多优点,但是同时也存在两个问题:内存占用问题和数据一致性问题。
  1. 内存占用问题
      因为 CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对 象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对 象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比 如说 200M 左右,那么再写入 100M 数据进去,内存就会占用 300M,那么这个时候很有可能造成频繁的 minor GC 和 major GC。
  2. 数据一致性问题
      CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。

四、ConcurrentHashmap

  ConcurrentHashMap是线程安全的Map,利用了锁分段的思想提高了并发度。
  JDK1.6版本ConcurrentHashMap的关键要素:

  1. segment 继承了 ReentrantLock 充当锁的角色,为每一个 segment 提供了线程安全的保障;
  2. segment 维护了哈希散列表的若干个桶,每个桶由 HashEntry 构成的链表。

  JDK1.8的ConcurrentHashMap舍弃了 segment,并且大量使用了synchronized以及CAS无锁操作以保证 ConcurrentHashMap 操作的线程安全性。至于为什么不用 ReentrantLock 而是 Synchronzied 呢?因为synchronzied 做了很多的优化。因此,使用 synchronized 相较于 ReentrantLock 的性能会持平甚至在某些情况更优。同时,底层数据结构改变为采用数组+链表+红黑树的数据形式。

4.1 关键属性和类

4.1.1 ConcurrentHashMap的关键属性

	//装载Node的数组,采用懒加载的方式,直到第一次插入数据的时候才会进行初始化
	//操作,数组的大小总是为 2 的幂次方。
	transient volatile Node<K,V>[] table;
	//扩容时使用
	private transient volatile Node<K,V>[] nextTable;
	//该属性用来控制 table 数组的大小
	private transient volatile int sizeCtl;
	//在ConcurrentHashMapde的实现中可以看到大量的U.compareAndSwapXXXX的方法
	//去修改ConcurrentHashMap的一些属性
	private static final sun.misc.Unsafe U;

  U的获取是在静态代码块中:

	static {
    
    
	    try {
    
    
	        U = sun.misc.Unsafe.getUnsafe();
			.......
	    } catch (Exception e) {
    
    
	        throw new Error(e);
	    }
	}

4.1.2 关键内部类

  • Node类
      Node类实现了Map.Entry接口,主要存放键值对,并且具有next域:
	static class Node<K,V> implements Map.Entry<K,V> {
    
    
	        final int hash;
	        final K key;
	        volatile V val;
	        volatile Node<K,V> next;
			......
	}

  Node类中很多属性都是用 volatile 进行修饰的,也就是为了保证内存可见性。

  • TreeNode类
      树节点,继承Node类:
	static final class TreeNode<K,V> extends Node<K,V> {
    
    
	        TreeNode<K,V> parent;  // red-black tree links
	        TreeNode<K,V> left;
	        TreeNode<K,V> right;
	        TreeNode<K,V> prev;    // needed to unlink next upon deletion
	        boolean red;
			......
	}
  • TreeBin类
      TreeBin类包装的很多 TreeNode 节点。ConcurrentHashMap“数组”中,存放的是TreeBin对象:
	static final class TreeBin<K,V> extends Node<K,V> {
    
    
	        TreeNode<K,V> root;
	        volatile TreeNode<K,V> first;
	        volatile Thread waiter;
	        volatile int lockState;
	        // values for lockState
	        static final int WRITER = 1; // set while holding write lock
	        static final int WAITER = 2; // set when waiting for write lock
	        static final int READER = 4; // increment value for setting read lock
			......
	}
  • ForwardingNode类
      在扩容时才会出现的特殊节点,其 key,value,hash 全部为 null。并拥有 nextTable 指针引用新的 table 数组。
	static final class ForwardingNode<K,V> extends Node<K,V> {
    
    
	    final Node<K,V>[] nextTable;
	    ForwardingNode(Node<K,V>[] tab) {
    
    
	        super(MOVED, null, null, null);
	        this.nextTable = tab;
	    }
	   .....
	}

4.1.3 CAS操作

  以下是几个常用的利用 CAS 算法来保障线程安全的操作。

  • tabAt
	//获取table数组中索引为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);
	}
  • casTabAt
	//利用CAS操作设置table数组中索引为i的元素
	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);
	}
  • setTabAt
	//设置table数组中索引为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);
	}

4.2 重点方法

4.2.1 构造方法

  ConcurrentHashMap的构造方法:

	//构造一个空的map,即table数组还未初始化,初始化放在第一次插入数据时,默认大小为16
	ConcurrentHashMap()
	//指定map的初始容量
	ConcurrentHashMap(int initialCapacity)
	//用指定Map的元素填充ConcurrentHashMap
	ConcurrentHashMap(Map<? extends K, ? extends V> m)
	//指定map的初始容量以及加载因子
	ConcurrentHashMap(int initialCapacity, float loadFactor)
	//给定map大小,加载因子以及并发度(预计同时操作数据的线程)
	ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
  • ConcurrentHashMap(int initialCapacity)
      第 2 种构造器源码:
	public ConcurrentHashMap(int initialCapacity) {
    
    
		//小于0直接抛异常
	    if (initialCapacity < 0)
	        throw new IllegalArgumentException();
		//判断是否超过了允许的最大值,超过了话则取最大值,否则再对该值进一步处理
	    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
	               MAXIMUM_CAPACITY :
	               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
		//赋值给sizeCtl
	    this.sizeCtl = cap;
	}

  当调用构造器方法之后,sizeCtl 的大小应该就代表了 ConcurrentHashMap 的大小,即 table 数组长度。

  • tableSizeFor(int c)
      该方法会将调用构造器方法时指定的大小转换成一个 2 的幂次方数,也就是说 ConcurrentHashMap 的大小一定是 2 的幂次方,比如,当指定大小为 18 时,为了满足 2 的幂次方特性,实际上 concurrentHashMapd 的大小为 25
      同时,调用构造器方法的时候并未构造出 table 数组,只是算出 table 数组的长度,当第一次向 ConcurrentHashMap 插入数据的时候才真正的完成初始化创建 table 数组的工作。

4.2.2 initTable方法

  initTable方法源码:

	private final Node<K,V>[] initTable() {
    
    
	    Node<K,V>[] tab; int sc;
	    while ((tab = table) == null || tab.length == 0) {
    
    
	        if ((sc = sizeCtl) < 0)
				//保证只有一个线程正在进行初始化操作
	            Thread.yield(); // lost initialization race; just spin
	        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
    
    
	            try {
    
    
	                if ((tab = table) == null || tab.length == 0) {
    
    
						// 2. 得出数组的大小
	                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
	                    @SuppressWarnings("unchecked")
						// 3. 这里才真正的初始化数组
	                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
	                    table = tab = nt;
						// 4. 计算数组中可用的大小:实际大小n*0.75(加载因子)
	                    sc = n - (n >>> 2);
	                }
	            } finally {
    
    
	                sizeCtl = sc;
	            }
	            break;
	        }
	    }
	    return tab;
	}

  可能存在一个情况是多个线程同时走到这个方法中,为了保证能够正确初始化,在第 1 步中会先通过 if 进行判断,若当前已经有一个线程正在初始化即 sizeCtl 值变为-1,这个时候其他线程在 If 判断为 true 从而调用 Thread.yield()让出 CPU 时间片。
  正在进行初始化的线程会调用 U.compareAndSwapInt 方法将 sizeCtl 改为-1 ,即正在初始化的状态。另外还需要注意的事情是,在第四步中会进一步计算数组中可用的大小即为数组实际大小 n 乘以加载因子 0.75。如果选择是无参的构造器的话,这里在 new Node 数组的时候会使用默认大小为DEFAULT_CAPACITY(16),然后乘以加载因子 0.75 为 12,也就是说数组的可用大小为 12。

4.2.3 put方法

  put方法源码:

    public V put(K key, V value) {
    
    
        return putVal(key, value, false);
    }
	
	final V putVal(K key, V value, boolean onlyIfAbsent) {
    
    
	    if (key == null || value == null) throw new NullPointerException();
		//1. 计算key的hash值
	    int hash = spread(key.hashCode());
	    int binCount = 0;
	    for (Node<K,V>[] tab = table;;) {
    
    
	        Node<K,V> f; int n, i, fh;
			//2. 如果当前table还没有初始化先调用initTable方法将tab进行初始化
	        if (tab == null || (n = tab.length) == 0)
	            tab = initTable();
			//3. tab中索引为i的位置的元素为null,则直接使用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
	        }
			//4. 当前正在扩容
	        else if ((fh = f.hash) == MOVED)
	            tab = helpTransfer(tab, f);
	        else {
    
    
	            V oldVal = null;
	            synchronized (f) {
    
    
	                if (tabAt(tab, i) == f) {
    
    
						//5. 当前为链表,在链表中插入新的键值对
	                    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;
	                            }
	                        }
	                    }
						// 6.当前为红黑树,将新的键值对插入到红黑树中
	                    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;
	                        }
	                    }
	                }
	            }
				// 7.插入完键值对后再根据实际大小看是否需要转换成红黑树
	            if (binCount != 0) {
    
    
	                if (binCount >= TREEIFY_THRESHOLD)
	                    treeifyBin(tab, i);
	                if (oldVal != null)
	                    return oldVal;
	                break;
	            }
	        }
	    }
		//8.对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容
	    addCount(1L, binCount);
	    return null;
	}

  从整体而言,为了解决线程安全的问题,ConcurrentHashMap 使用了 synchronzied 和 CAS 的方式。
  ConcurrentHashMap 是一个哈希桶数组,如果不出现哈希冲突的时候,每个元素均匀的分布在哈希桶数组中。当出现哈希冲突的时候,是标准的链地址的解决方式,将 hash 值相同的节点构成链表的形式,称为“拉链法”,另外,在JDK1.8中为了防止拉链过长,当链表的长度大于 8 的时候会将链表转换成红黑树。table 数组中的每个元素实际上是单链表的头结点或者红黑树的根节点。

  • 1、spread()
      spread源码:
	static final int spread(int h) {
    
    
	    return (h ^ (h >>> 16)) & HASH_BITS;
	}

  spread 方法进行了一次重 hash 从而大大减小哈希冲突的可能性。该方法主要是将 key 的 hashCode 的低16位和高16位进行异或运算,这样不仅能够使得 hash 值能够分散能够均匀减小 hash 冲突的概率,另外只用到了异或运算,性能也还好。

  • 2、初始化table
      第 2 步,会判断当前 table 数组是否初始化了,没有的话就调用 initTable 进行初始化。
  • 3、能否直接将新值插入到table数组中
      如果插入值待插入的位置刚好所在的 table 数组为 null 的话就可以直接将值插入即可。
      确定好数组的索引i后,就可以调用tabAt()方法获取该位置上的元素。如果当前 Node f 为 null 的话,就可以直接用 casTabAt 方法将新值插入即可。
  • 4、当前是否正在扩容
      如果当前节点不为 null,且该节点为特殊节点(forwardingNode)的话,就说明当前 concurrentHashMap 正在进行扩容操作,关于扩容操作,下面会作为一个具体的方法进行讲解。那么怎样确定当前的这个 Node 是不是特殊的节点了?是通过判断该节点的 hash 值是不是等于-1(MOVED),代码为(fh = f.hash) == MOVED
  • 5、当 table[i]为链表的头结点,在链表中插入新值
      在 table[i]不为 null 并且不为 forwardingNode 时,并且当前 Node f 的 hash 值大于 0(fh >= 0)的话说明当前节点 f 为当前桶的所有的节点组成的链表的头结点。那么接下来,要想向 ConcurrentHashMap 插入新值的话就是向这个链表插入新值。通过 synchronized (f)的方式进行加锁以实现线程安全性。

      两种情况:
  1. 在链表中如果找到了与待插入的键值对的 key 相同的节点,就直接覆盖即可;
  2. 如果直到找到了链表的末尾都没有找到的话,就直接将待插入的键值对追加到链表的末尾。
  • 6、当 table[i]为红黑树的根节点,在红黑树中插入新值
      当链表长度太长(默认超过 8)时,链表就转换为红黑树。当 table[i]为红黑树的树节点时的操作为:
	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;
	    }
	}

  这段代码的作用:调用 putTreeVal 方法完成向红黑树插入新节点,同样的逻辑,如果在红黑树中存在于待插入键值对的 Key 相同(hash 值相等并且 equals 方法判断为 true)的节点的话,就覆盖旧值,否则就向红黑树追加新节点。

  • 7、根据当前节点个数进行调整
      当完成数据新节点插入之后,会进一步对当前链表大小进行调整:
	if (binCount != 0) {
    
    
	    if (binCount >= TREEIFY_THRESHOLD)
	        treeifyBin(tab, i);
	    if (oldVal != null)
	        return oldVal;
	    break;
	}

  如果当前链表节点个数大于等于 8(TREEIFY_THRESHOLD)的时候,就会调用 treeifyBin 方法将 tabel[i](第 i 个散列桶)拉链转换成红黑树。

  • 整体流程
  1. 首先对于每一个放入的值,首先利用 spread 方法对 key 的 hashcode 进行一次 hash 计算,由此来确定这个值在 table 中的位置;
  2. 如果当前 table 数组还未初始化,先将 table 数组进行初始化操作;
  3. 如果这个位置是 null 的,那么使用 CAS 操作直接放入;
  4. 如果这个位置存在结点,说明发生了 hash 碰撞,首先判断这个节点的类型。如果该节点 fh==MOVED(代表 forwardingNode,数组正在进行扩容)的话,说明正在进行扩容;
  5. 如果是链表节点(fh>0),则得到的结点就是 hash 值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到 key 相同的节点,则只需要覆盖该结点的 value 值即可。否则依次向后遍历,直到链表尾插入这个结点;
  6. 如果这个节点的类型是 TreeBin 的话,直接调用红黑树的插入方法进行插入新的节点;
  7. 插入完节点之后再次检查链表长度,如果长度大于 8,就把这个链表转换成红黑树;
  8. 对当前容量大小进行检查,如果超过了临界值(实际大小*加载因子)就需要扩容。

4.2.4 get方法

  get 方法源码:

	public V get(Object key) {
    
    
	    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
		// 1. 重hash
	    int h = spread(key.hashCode());
	    if ((tab = table) != null && (n = tab.length) > 0 &&
	        (e = tabAt(tab, (n - 1) & h)) != null) {
    
    
	        // 2. table[i]桶节点的key与查找的key相同,则直接返回
			if ((eh = e.hash) == h) {
    
    
	            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
	                return e.val;
	        }
			// 3. 当前节点hash小于0说明为树节点,在红黑树中查找即可
	        else if (eh < 0)
	            return (p = e.find(h, key)) != null ? p.val : null;
	        while ((e = e.next) != null) {
    
    
			//4. 从链表中查找,查找到则返回该节点的value,否则就返回null即可
	            if (e.hash == h &&
	                ((ek = e.key) == key || (ek != null && key.equals(ek))))
	                return e.val;
	        }
	    }
	    return null;
	}

  首先先看当前的 hash 桶数组节点即 table[i]是否为查找的节点,若是则直接返回;若不是,则继续再看当前是不是树节点?通过看节点的 hash 值是否为小于 0,如果小于 0 则为树节点。如果是树节点在红黑树中查找节点;如果不是树节点,那就只剩下为链表的形式的一种可能性了,就向后遍历查找节点,若查找到则返回节点的 value 即可,若没有找到就返回 null。

4.3 总结

  JDK1.6、JDK1.7中的 ConcurrentHashmap 主要使用 Segment 来实现减小锁粒度,分割成若干个 Segment,在 put 的时候需要锁住 Segment,get 时候不加锁,使用 volatile 来保证可见性,当要统计全局变量(比如 size)时,首先会尝试多次计算 modcount 来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回 size。如果有,则需要依次锁住所有的 Segment 来计算。
  JDK1.8 之前 put 定位节点时要先定位到具体的 segment,然后再在 segment 中定位到具体的桶。而在 1.8 的时候摒弃了 segment的设计,直接针对的是 Node[] tale 数组中的每一个桶,进一步减小了锁粒度。并且防止拉链过长导致性能下降,当链表长度大于 8 的时候采用红黑树的设计。
  JDK1.8主要设计上的变化有以下几点:

  1. 不采用 segment 而采用 node,锁住 node 来实现减小锁粒度。
  2. 设计了 MOVED 状态 当 resize 的中过程中 线程 2 还在 put 数据,线程 2 会帮助 resize。
  3. 使用 3 个 CAS 操作来确保 node 的一些操作的原子性,这种方式代替了锁。
  4. sizeCtl 的不同值来代表不同含义,起到了控制的作用。
  5. 采用 synchronized 而不是 ReentrantLock。

4.4 SynchronizedMap 和 ConcurrentHashMap 有什么区别?

  SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map;ConcurrentHashMap 使用分段锁(JDK1.6)来保证在多线程下的性能
  ConcurrentHashMap(JDK1.6)中则是一次锁住一个桶。ConcurrentHashMap 默认将hash表分为16个桶,诸如 get、put、remove 等常用操作只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。
  同时,ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当iterator 被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。

猜你喜欢

转载自blog.csdn.net/m0_37741420/article/details/120936999
今日推荐