JUC学习笔记 - 06并发容器

写在前面: Java容器内容很多,本文只是简单列举了并发容器以及使用方式,并没有针对源码进行细致地解析。可以把本文作为一个索引,对比了解不同容器之间的异同,再选择使用哪些容器,或详细了解底层原理。

容器简介

容器.png

最开始的jdk中的容器只有两个VectorHashtable,他们俩的实现都默认加了synchronized,且当时的同步锁是重量级锁,显然有很多的问题。然后慢慢发展到现在的容器主要分为两大接口MapCollectionCollection又分三大类ListSetQueue。接下来会对涉及到并发的容器一一做简单描述。

HashTableConcurrentHashMap

下面会基于这样一个场景来描述4种Map:让100个线程往Map中扔100万个值,记录Mapcount值和写入时间;再让100个线程去读Map中的值,并记录读取时间。

Hashtable

public class TestHashtable {

    static Hashtable<UUID, UUID> m = new Hashtable<>();

    static int count = 1000000;
    static UUID[] keys = new UUID[count];
    static UUID[] values = new UUID[count];
    static final int THREAD_COUNT = 100;

    static {
        for (int i = 0; i < count; i++) {
            keys[i] = UUID.randomUUID();
            values[i] = UUID.randomUUID();
        }
    }

    static class MyThread extends Thread {
        int start;
        int gap = count / THREAD_COUNT;

        public MyThread(int start) {
            this.start = start;
        }

        @Override
        public void run() {
            for (int i = start; i < start + gap; i++) {
                m.put(keys[i], values[i]);
            }
        }
    }

    public static void main(String[] args) {
        // write
        long start = System.currentTimeMillis();
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new MyThread(i * (count / THREAD_COUNT));
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
        System.out.println(m.size());

        // read
        start = System.currentTimeMillis();
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000000; j++) {
                    m.get(keys[10]);
                }
            });
        }
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        end = System.currentTimeMillis();
        System.out.println(end - start);
    }
}
复制代码

最终输出内容为:

237
1000000
32578
复制代码

HashMap

这个就没什么测试的意义了,很显然count值到达不了1000000,毕竟不是线程安全的。

SynchronizedHashMap

使用Collections.synchronizedMap(new HashMap<>())试一下;

274
1000000
35984
复制代码

发现效率差不多,再简单看下源码。SynchronizedMap作为HashMap的包装类,相比之下多加了个锁final Object mutex;,然后涉及线程安全的方法会锁定该锁。相比Hashtable锁定class对象而言效率上没什么区别。

ConcurrentHashMap

使用ConcurrentHashMap试一下:

425
1000000
592
复制代码

ConcurrentHashMap对于读操作的效率有了很大的提升。jdk1.7是对每个分片进行锁定,jdk1.8是用sychronized + CAS代替了分片,进一步减少了锁的粒度,相比原先锁定整个Map效率肯定高很多。详细的源码可以自己阅读或查询其他资料。

VectorQueue

假设有这样的场景,10个线程消费装满10000条数据的容器,并且当数据消费完毕后自动退出线程。

ArrayListLinkedList

public class TestArrayList {
    static List<String> list = new ArrayList<>();

    static {
        for (int i = 0; i < 10000; i++) {
            list.add(i + "");
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (list.size() > 0) {
                    System.out.println("消费了" + list.remove(0));
                }
            }).start();
        }
    }
}
复制代码

最后会输出几个null值,这是因为最后几个线程在判断list.size() > 0后其他线程已经把最后几个消费了,那么该线程只能消费null了。

ArrayListLinkedList加锁

本质和Vector没区别,都是锁住整个数组或链表,效率上会比较低。

public class TestLock {
    static List<String> list = new ArrayList<>();

    static {
        for (int i = 0; i < 10000; i++) {
            list.add(i + "");
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    synchronized (list) {
                        if (list.size() <= 0) {
                            break;
                        }
                        System.out.println("消费了" + list.remove(0));
                    }
                }
            }).start();
        }
    }
}
复制代码

Vector

现在使用Vector,由于内部方法很多都加上了synchronized,所以它是线程安全的。最终输出结果也没什么问题。

public class TestVector {
	static Vector<String> vector = new Vector<>();

	static {
		for (int i = 0; i < 10000; i++) {
			vector.add(i + "");
		}
	}

	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(() -> {
				while (vector.size() > 0) {
					System.out.println("消费了" + vector.remove(0));
				}
			}).start();
		}
	}
}
复制代码

Queue

Queue主要就是为了多线程高并发用的,这里以ConcurrentLinkedQueue为例替换前面代码的list,相比前面的容器队列的效率是最高的:

public class TestQueue {
    static Queue<String> queue = new ConcurrentLinkedQueue<>();

    static {
        for (int i = 0; i < 10000; i++) {
            queue.add(i + "");
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    String s = queue.poll();
                    if (s == null) {
                        break;
                    }
                    System.out.println("消费了" + s);
                }
            }).start();
        }
    }
}
复制代码

并发容器

ConcurrentMap

Map经常使用的并发容器:

  • ConcurrentHashMap:用哈希表实现的高并发容器。如果需要可以用于排序的高并发Map自然而然会想到ConcurrentTreeMap。但是由于用CAS操作树时非常复杂,所以并没有实现ConcurrentTreeMap。排序的需求就由基于跳表的ConcurrentSkipListMap来实现了。

  • ConcurrentSkipListMap:通过跳表来实现的高并发容器并且这个Map是有序的;

CopyOnWrite

CopyOnWrite即写时复制,主要有两个容器分别是CopyOnWriteListCopyOnWriteSetCopyOnWriteSet是基于CopyOnWriteList实现的。CopyOnWriteList在jdk8用的是ReentrantLock,在jdk11中用的是synchronized(具体哪个版本改的没有考证)。

jdk8:

    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();
        }
    }
复制代码

jdk11:

    public boolean add(E e) {
        synchronized (lock) {
            Object[] es = getArray();
            int len = es.length;
            es = Arrays.copyOf(es, len + 1);
            es[len] = e;
            setArray(es);
            return true;
        }
    }
复制代码

可以看到在写的时候会把原先数组复制出来在尾部添加新元素,然后让容器指向新数组。因为每次写都要复制效率较低,最好在读多写少的情况下使用。另外需要注意的是,CopyOnWrite只能保证最终一致性,无法保证实时的一致性。因为在addremove还没完成时,拿到的数据还是老数据。

BlockingQueue

BlockingQueue即阻塞队列,提供了一系列方法可以让线程实现自动地阻塞。首先Queue提供了一系列接口,例如:

  • add:向队列中加入元素,加入失败则抛异常
  • offer:向队列中加入元素,返回布尔值表示是否加入成功
  • peek:取值,不remove
  • poll:取值,并remove

BlockingQueue又添加了两个方法:

  • put:向队列加入元素,队列满了会阻塞
  • take:取值,队列空了会阻塞

BlockingQueue的几个实现:

  • LinkedBlockingQueue:用链表实现了阻塞队列,是个无界队列。说是无界,其实上限是Integer.MAX_VALUE
  • ArrayBlockingQueue:有界且需要指定容量大小。该方法的特点就是队列容量存在上限,一旦满了put就会阻塞。
  • DelayQueue:实现了时间上的排序。存放在该队列的元素需要实现Delayed接口,即compareTogetDelay方法,用于指定排序规则。该队列本质使用的是PriorityQueue
  • PriorityQueue:内部维护了一个二叉树,存放数据时会先进行排序,将最小的优先放在头部。
  • SynchronousQueue:容量为0,调用put会阻塞地等待另一个线程take,相当于需要手把手将数据传递给另一个线程。该队列在线程调度中应用比较多。
  • TransferQueue:相比SynchronousQueue只能传一个值,该队列可以传多个队列。并且提供了新的方法transfer,可以阻塞地等待对方拿到数据后再进行后续的工作。

猜你喜欢

转载自juejin.im/post/7068211147735826445
今日推荐