Java多线程:同步集合与同步锁

同步集合

同步集合在多线程开发中扮演非常重要的角色,本文介绍些常用但被忽略的同步集合。

CopyOnWriteArrayList

Copy-On-Write是一种用于程序设计中的优化策略,基本思路是多个线程共享同一个列表,当某个线程想要修改这个列表的元素时会把列表中的元素Copy一份,然后进行修改,修改完后再讲新的元素设置给这个列表,是一种延时懒惰策略。好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加、移除任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。使用Copy-On-Write机制实现的并发容器有两个分别是:CopyOnWriteArrayList和CopyOnWriteArraySet。

下面来分析下CopyOnWriteArrayList的核心源码,首先看下add方法:

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();
        }
    }

可以看到在添加的时候进行了加锁操作,否则多线程写的时候会Copy出N个副本出来。复制一份之后将新的元素设置到元素数组的len位置,然后再把最新的元素设置给该列表。

get方法:

public E get(int index) {
        return get(getArray(), index);
    }

读不需要加锁,如果读的时候多个线程正在向容器内添加数据,还是会读到旧数据,因为写的时候不会锁住旧的元素数组。

这种写时拷贝的原理优点是读写分离,并发场景下操作效率会提高,缺点是写操作时占用的内存空间翻了一倍,因此是以空间换时间。

ConcurrentHashMap

HashTable是HashMap的线程安全实现,但是HashTable使用synchronized来保证线程安全,这就会导致它的效率非常低下,因为当线程1使用put添加元素,线程2不但不能使用put添加元素,同时也不能使用get获取元素,竞争越激烈效率越低。

因此替代HashTable的ConcurrentHashMap就出现了,ConcurrentHashMap的优点在于容器里有多把锁,每一把锁用于锁容器其中一部分数据,当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率。它的原理是将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。有些方法需要跨段,如size()和containsValue(),他们可能需要锁定整个表而不仅是某个段,这需要按顺序锁定所有段,操作完毕后又按顺序释放所有段的锁。

BlockingQueue

阻塞队列是生产者-消费者的一个实现,当队列满了时,再次调用put函数添加元素,那么调用线程将会阻塞,直到队列不再是填满状态。避免了手动判断以及同步操作。

函数名

作用

add(e)

把元素e添加到BlockingQueue里,如果BlockingQueue可以容纳,则返回true,否则抛异常

offer(e)

把元素e添加到BlockingQueue里,如果BlockingQueue可以容纳,则返回true,否则返回false

offer(e,time,unit)

把元素e添加到BlockingQueue里,如果BlockingQueue可以容纳,则返回true,否则在等待指定的时间之后继续尝试添加,如果失败则返回false

put(e)

把元素e添加到BlockingQueue里,如果BlockingQueue不能容纳,则调用此方法的线程被阻塞直到BlockingQueue里面有空间再继续添加

take()

取走BlockingQueue里排在队首的对象,若BlockingQueue为空,则进入等待状态直到BlockingQueue有新的对象被加入为止

poll(time,unit)

取出并移除队列中的队首元素,如果设定的阻塞时间内还没有获得数据,那么返回null

element()

获取队首元素,如果队列为空,那么抛出NoSuchElementException异常

peek()

获取队首元素,如果队列为空,那么返回null

remove()

获取并移除队首元素,如果队列为空,那么抛出NoSuchElementException异常

BlockingQueue多种常用实现:

  • ArrayBlockingQueue 数组实现的、线程安全的、有界的阻塞队列
    按FIFO(先进先出)原则对元素进行排序,元素从尾部插入到队列,从头部开始返回。
  • LinkedBlockingQueue 单向链表实现的队列
    按FIFO(先进先出)原则对元素进行排序,元素从尾部插入到队列,从头部开始返回。吞吐量高于ArrayBlockingQueue,但是在大多数并发应用程序中其可预知的性能要低,功能类似的有ConcurrentLinkedQueue
  • LinkedBlockingDeque 双向链表实现的双向并发阻塞队列
    同时支持FIFO和FILO,即可以从队列的头和尾同时操作(插入/删除),支持线程安全。可以指定队列容量(默认容量大小等于Integer.MAX_VALUE)

同步锁

线程安全就是必须通过各种锁机制来进行同步,防止某个对象或者值在多个线程中被修改导致的不一致问题。为了保证数据的一致性,需要通过同步机制保证在同一时刻只有一个线程能够访问到该对象或者数据,修改完毕之后再将最新数据同步到主存中,使其他线程能够得到最新数据。

synchronized

Java中最常用的同步机制就是synchronized关键字,它是一种基于语言的粗略锁,能够作用于对象、函数、类。每个对象都只有一个锁,谁拿到锁就得到了访问权限。

public class SynchronizedDemo {

    // 只对SynchronizedDemo当前对象生效
    public synchronized void syncMethod() {
    }

    public void syncThis() {
        // 只对SynchronizedDemo当前对象生效
        synchronized (this) {
        }
    }

    // 对SynchronizedDemo所有对象生效
    public void syncClassMethod() {
        synchronized (SynchronizedDemo.class) {
        }
    }

    // 对SynchronizedDemo所有对象生效
    public synchronized static void syncStaticMethod() {
    }
}

上面例子分别演示了同步方法、同步块、同步class对象、同步静态方法。前两种锁的是对象,作用是防止其他线程同时访问同一个对象中的synchronized代码块或者函数。后两种锁的是class对象,作用是防止其他线程同时访问所有对象中的synchronized锁的代码块,因为Class锁对类的所有对象实例起作用。

ReentrantLock与Condition

Java5之前协调共享对象访问时,只有synchronized和volatile,Java6增加了ReentrantLock,与synchronized相比,实现了相同的语义,但具有更高的灵活性,并可以提供轮训锁和定时锁,同时可以提供公平锁或非公平锁。

函数

作用

lock()

获取锁

tryLock()

尝试获取锁

tryLock(long timeout,TimeUnit unit)

尝试获取锁,如果到了指定的时间还获取不到,那么超时

unLock()

释放锁

newCondition()

获取锁的Condition

lock、tryLock与unlock一般成对出现,用法如下:

Lock lock = new ReentrantLock();
    public int doSth() {
        lock.lock();
        try {
            // do some thing
        } finally {
            lock.unlock();
        }
    }

需要注意的是必须在finally块中释放lock,否则如果代码抛出异常就永远释放不了锁。而使用synchronized锁,JVM将确保锁会自动释放,并且当JVM使用synchronized管理锁定请求和释放时,JVM在生成线程转储时能够包括锁定信息,这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。而Lock类只是普通的类,JVM不知道具体哪个线程拥有Lock对象,这也是Lock没有完全替代掉synchronized的原因。

ReentrantLock中还有一个重要函数newCondition(),用于获取Lock上的Condition,Condition是用于实现线程间的通讯,解决Object.wait()、notify()、nofityAll()难以使用的问题。

常用方法如下:

函数

作用

await()

线程等待

await(int time,TimeUnit unit)

线程等待特定时间,超过时间则为超时

signal()

随机唤醒某个等待线程

signalAll()

唤醒所有等待中的线程

下面通过ReentrantLock与Condition实现一个简单的阻塞队列,实现代码如下:

public class MyArrayBlockingQueue<T> {
    // 数据数组
    private final T[] items;
    // 锁
    private final Lock lock = new ReentrantLock();
    // 队满的条件
    private Condition notFull = lock.newCondition();
    // 队空条件
    private Condition notEmpty = lock.newCondition();
    // 头部索引
    private int head;
    // 尾部索引
    private int tail;
    // 数据的个数
    private int count;

    public MyArrayBlockingQueue(int maxSize) {
        items = (T[]) new Object[maxSize];
    }

    public MyArrayBlockingQueue() {
        this(10);
    }

    public void put(T t) {
        lock.lock();
        try {
            while (count == getCapacity()) {
                System.out.println("数据已满,等待");
                notFull.await();
            }
            items[tail] = t;
            if (++tail == getCapacity()) {
                tail = 0;
            }
            ++count;
            notEmpty.signalAll(); // 唤醒等待数据的线程
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public T take() {
        lock.lock();
        try {
            while (count == 0) {
                System.out.println("还没有数据,请等待");
                notEmpty.await();
            }
            T ret = items[head];
            items[head] = null;
            if (++head == getCapacity()) {
                head = 0;
            }
            --count;
            notFull.signalAll(); // 唤醒添加数据的线程
            return ret;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
        return null;
    }

    public int getCapacity() {
        return items.length;
    }

    public int size() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        MyArrayBlockingQueue<Integer> aQueue = new MyArrayBlockingQueue<Integer>();
        aQueue.put(3);
        aQueue.put(24);
        for (int i = 0; i < 5; i++) {
            System.out.println(aQueue.take());
        }
    }

}

上面代码模拟了一个有界队列阻塞队列,阻塞条件分别使用notfull与notEmpty,当调用put函数时集合元素已满那么会调用notFull.await()堵塞调用线程,直到其他线程调用了take()方法,由于take会在队列中取出一个元素后调用notFull.signalAll()唤醒等待线程,使得put可以继续。同理take函数是当元素数量为0时调用notEmpty.await()进行等待,当其他线程调用put方法执行notEmpty.signalAll()才唤醒take函数的线程,使之能够取得元素。

Semaphore

Semaphore是一个计数信号量,本质上是一个"共享锁"。信号量维护了一个信号量许可集,线程可以通过调用acquire()来获取信号量的许可。当信号量中有可用的许可时,线程能获取该许可;否则线程必须等待,直到有可用的许可为止。线程可以通过release()来释放它所持有的信号量许可。

Semaphore的使用示例:

final ExecutorService executorService = Executors.newFixedThreadPool(3);
        final Semaphore semaphore = new Semaphore(3);
        for (int i = 0; i < 5; i++) {
            executorService.submit(new Runnable() {

                @Override
                public void run() {
                    try {
                        semaphore.acquire();
                        System.out.println(" 剩余许可 : "
                                + semaphore.availablePermits());
                        Thread.sleep(2000);
                        semaphore.release();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
        }

可以看到在创建Semaphore时创建了3个信号量许可集,而消费线程却是5个,因此前3个线程获取了许可之后,信号量的许可就为0。此时后面的线程再调用acquire()就会阻塞,知道前3个线程执行完释放许可后剩余2个线程才能获取许可并继续执行。

CyclicBarrier

CyclicBarrier是一个同步辅助类,允许一组线程相互等待,直到到达某个公共屏障点。因为该barrier在释放等待线程后可以重用,所以称它为循环的barrier。

CyclicBarrier使用示例:

public class CyclicBarrierDemo {

    private static int BARRIER_SIZE = 5;
    private static int THREAD_SIZE = 10;
    private static CyclicBarrier mCyclicBarrier;

    public static void main(String[] args) {

        mCyclicBarrier = new CyclicBarrier(BARRIER_SIZE, new Runnable() {
            
            @Override
            public void run() {
                System.out.println(" ---> 满足条件,执行特定操作。 参与者: " + mCyclicBarrier.getParties());
            }
        });

        // 新建5个任务
        for (int i = 0; i < THREAD_SIZE; i++) {
            new WorkerThread().start();
        }
    }

    static class WorkerThread extends Thread {

        @Override
        public void run() {
            try {
                System.out.println(Thread.currentThread().getName() + " 等待 CyclicBarrier.");

                // 将mCyclicBarrier的参与者数量加1
                mCyclicBarrier.await();
                // mCyclicBarrier的参与者数量等于5时,才继续往后执行
                System.out.println(Thread.currentThread().getName() + " 继续执行.");
            } catch (BrokenBarrierException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

可以看到创建CyclicBarrier时第一个参数是5表示公共屏障点是5,第二个参数是一个runnable,表示达到屏障点时运行该runnable,因此该示例执行结果就是5个线程执行CyclicBarrier的await()函数后CyclicBarrier到达屏障点后执行runnable,剩下5个线程继续执行CyclicBarrier的await()函数后再次执行runnable。CyclicBarrier实际上可用于多个线程等待,直到某个条件被满足后执行一段逻辑。

CountDownLatch

CountDownLatch也是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待,直到条件被满足。

与CyclicBarrier相似点在于,都需要设置一个公共屏障计数点。

与CyclicBarrier区别在于:

  1. CountDownLatch允许1个或N个线程调用await()阻塞,等待其他线程调用countDown()到达屏障点后释放阻塞,而CyclicBarrier则是允许N个线程调用await阻塞,直到阻塞线程数量到达屏障点后释放所有阻塞的线程。
  2. CountDownLatch的计数器无法重置,CyclicBarrier的计数器可以被重置后使用,因此被称为是循环的barrier。

CountDownLatch示例代码如下:

public class CountDownLatchDemo {

    private static int LATCH_SIZE = 5;

    public static void main(String[] args) {

        try {
            CountDownLatch latch = new CountDownLatch(LATCH_SIZE);

            // 新建5个任务
            for (int i = 0; i < LATCH_SIZE; i++) {
                new InnerThread(latch).start();
            }

            System.out.println("主线程等待.");
            // "主线程"等待线程池中5个任务的完成
            latch.await();
            System.out.println("主线程继续执行");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    static class InnerThread extends Thread {

        CountDownLatch mLatch;

        public InnerThread(CountDownLatch latch) {
            mLatch = latch;
        }

        @Override
        public void run() {
            try {
                Thread.sleep(1000);
                System.out.println(Thread.currentThread().getName() + " 执行操作.");
                // 将CountDownLatch的数值减1
                mLatch.countDown();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

可以看到创建了一个屏障点数量为5的CountDownLatch对象,启动5个子线程后调用CountDownLatch的await()函数使主线程进入等待状态。子线程最后会执行CountDownLatch的countDown()函数,当5个子线程都调用countDown()之后主线程就会被唤醒。

demo代码:https://github.com/GavinAndre/JTMMultiThread