构造安全的并发应用程序

想要设计线程安全的模块是需要考虑许多情况的,一般先设计出一些比较小的,线程安全的模块,然后通过一些组合模式,将现有的线程安全组件组合为规模更大的组件或者程序。

除此之外,还可以通过使用 Java 类库中现有的并发基础构建模块构建线程安全的应用程序,将在 "常用的并发基础构建模块" 中对其进行介绍。最后,将介绍最省心的使用线程的方式,就是使用线程池。

如何构造线程安全类

实例封闭

通过封装来简化线程安全类的实现过程,因为封装之后,能够访问被封装对象的所有代码路径都是已知的,即通过封装限制对象被访问的方式。

在Java类库中的应用

  • ArrayList -> Collections.SynchronizedList
  • HashMap -> Collections.SynchronizedMap

这些类把线程不安全类封装到自己内部,然后将所有的接口方法都实现为同步方法(加上synchronized关键字修饰),并将调用请求转发到底层容器上(就是调用它封装进去的线程不安全类的相应方法),相当于给线程不安全类所有暴露在外的线程不安全方法都加上了synchronized修饰,是装饰者模式的一种应用。

Java监视器模式

就是把类中所有能访问对象可变状态的方法都加上 synchronized 修饰(简单粗暴),虽然简单,但是一旦被加锁的方法是一个费时操作,会影响应用程序的性能甚至出现错误。以下是一个 Java 监视器模式的典型例子:

arduino 复制代码
/* Java监视器模式的典型例子 */
public class Counter {
    private long value = 0;

    public synchronized long getValue() {
        return value;
    }

    public synchronized long increment() {
        if (value == Long.MAX_VALUE) {
            throw new IllegalStateException("counter overflow");
        }
        return ++value;
    }
}

线程的安全委托

示例:构建线程安全的车辆追踪器及优化

首先,有一个线程不安全的Point类,用来表示车辆的坐标。

csharp 复制代码
public class MutablePoint {
    public int x, y;

    public MutablePoint() {
        x = 0;
        y = 0;
    }

    public MutablePoint(MutablePoint p) {
        this(p.x, p.y);
    }

    public MutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

先使用Java监视器模式,即简单粗暴的在所有会改变MonitorVehicleTracker的locations域的方法上都加上synchronized修饰,来达到线程安全的目的。

typescript 复制代码
public class MonitorVehicleTracker {
    private final Map<String, MutablePoint> locations;

    public MonitorVehicleTracker(Map<String, MutablePoint> locations) {
        this.locations = deepCopy(locations);
    }

    public synchronized Map<String, MutablePoint> getLocations() {
        // 当locations比较大时,这步是一个耗时操作,会长时间占用锁
        // 会出现车辆位置已变,但返回信息保持不变的错误
        return deepCopy(locations);
    }

    public synchronized MutablePoint getLocation(String id) {
        MutablePoint loc = locations.get(id);
        return loc == null ? null : new MutablePoint(loc);
    }

    public synchronized void setLocation(String id, int x, int y) throws IllegalAccessException {
        MutablePoint loc = locations.get(id);
        if (loc == null) {
            throw new IllegalAccessException("No such ID: " + id);
        }
        loc.x = x;
        loc.y = y;
    }

    // 当locations.size()比较大时,这个方法将会是一个十分费时的操作
    public static Map<String, MutablePoint> deepCopy(Map<String, MutablePoint> m) {
        Map<String, MutablePoint> result = new HashMap<String, MutablePoint>();
        for (String id : m.keySet()) {
            result.put(id, m.get(id));
        }
        return result;
    }
}

这个车辆追踪器最大的问题就是Point类是一个易变的线程不安全类,这导致不得不在MonitorVehicleTracker中加入大量的同步代码,所以考虑从修改Point类入手(所以说,构建大的线程安全模块,应该从构建小的线程安全模块入手),对于这个错误,有两种解决思路:

  • 直接把Point变为一个不可变对象。
  • 构建一个可变但是线程安全的Point类,即给Point类中的get和set方法上加上同步,然后在MonitorVehicleTracker中就不用再使用同步了,相当于缩小了同步代码块的大小。

第一种思路:把Point变为一个不可变对象。

arduino 复制代码
修改Point类如下:
public class ImmutablePoint {
    public final int x, y;

    public ImmutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

在车辆追踪器中这样使用:

typescript 复制代码
public class DelegatingVehicleTracker {
    private final Map<String, ImmutablePoint> locations;
    private final Map<String, ImmutablePoint> unmodifiableMap;

    public DelegatingVehicleTracker(Map<String, ImmutablePoint> pointMap) {
        // 通过使用ConrentHashMap来保证locations的读写安全
        locations = new ConcurrentHashMap<String, ImmutablePoint>(pointMap);
        unmodifiableMap = Collections.unmodifiableMap(locations);
    }

    public Map<String, ImmutablePoint> getLocations() {
        return unmodifiableMap;
    }

    public ImmutablePoint getLocation(String id) {
        return locations.get(id);
    }

    public void setLocation(String id, int x, int y) throws IllegalAccessException { // 不同!
        // 这里直接new一个新的ImmutablePoint对象替代原理的对象
        if (locations.replace(id, new ImmutablePoint(x, y)) == null) {
            throw new IllegalAccessException("No such ID: " + id);
        }
    }
}

看源码补充: Collections.unmodifiableMap(Map<? extend K, ? extend V> m)

  • 返回一个不可修改的Map,这个 Map 的实现是Collections.unmodifiableMap(Map<? extend K, ? extend V> m)
  • 这个类是 Map 的线程安全装饰类,具体实现为把传入的 Map m 保存在自己的域中,然后把所有的能修改该 Map 的方法的实现改成:throw new UnsupportedOperationException();

第二种思路:构建一个可变但线程安全的Point类

把Point变成是线程安全的可变类:

csharp 复制代码
public class SafePoint {
    private int x, y;

    public SafePoint(int[] a) {
        this(a[0], a[1]);
    }

    public SafePoint(SafePoint point) {
        this(point.get());
    }

    public SafePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public synchronized int[] get() {
        return new int[]{x, y};
    }

    public synchronized void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

PublishingVehicleTracker 实现:

typescript 复制代码
public class PublishingVehicleTracker {
    private final Map<String, SafePoint> locations;
    private final Map<String, SafePoint> unmodifiableMap;

    public PublishingVehicleTracker(Map<String, SafePoint> pointMap) {
        locations = new ConcurrentHashMap<String, SafePoint>(pointMap);
        unmodifiableMap = Collections.unmodifiableMap(locations);
    }

    public Map<String, SafePoint> getLocations() {
        return unmodifiableMap;
    }

    public SafePoint getLocation(String id) {
        return locations.get(id);
    }

    public void setLocation(String id, int x, int y) throws IllegalAccessException { // 不同!
        // 因为Point已经改成线程安全的了,我们可以通过Point自己的set和get方法放心大胆的修改它
        SafePoint loc = locations.get(id);
        if (loc == null) {
            throw new IllegalAccessException("No such ID: " + id);
        }
        loc.set(x, y);
    }
}

如何在现有线程安全类中添加功能

  1. 继承

不好,因为有的状态不对子类公开。

  1. 修饰类

在修饰类里放个线程安全的List,然后再写个加锁的扩展方法putIfAbsent,注意要用list当锁,不然锁不一致

java 复制代码
public class ListHelper<E> {
    public List<E> list = Collections.synchronizedList(new ArrayList<E>());

    public boolean putIfAbsent(E x) {
        synchronized (list) {
            boolean contains = list.contains(x);
            if (!contains) {
                list.add(x);
            }
            return !contains;
        }
    }
}

缺点: 通过添加一个原子操作的扩展类是脆弱的,因为它将类的加锁代码分布到了多个类中。

  • List原有方法的加锁代码在 Collections.SynchronizedList 的代码中。
  • 新加的 putIfAbsent 方法的加锁代码在 ListHelper 中。
  1. 组合

将List的操作委托给底层的list实例,并把这些方法都实现为 synchronized 的,然后添加一个新的 synchronized 方法putIfAbsent,然后客户代码不会再直接使用 list 对象,而是通过 ImproveList 来操纵它,这样加锁代码就都在一个类中了,同时底层的 list 实现也不用必须是线程安全的。

java 复制代码
public class ImproveList<E> implements List<E> {
    private final List<E> list;

    public ImproveList(List<E> list) {
        this.list = list;
    }

    public synchronized boolean putIfAbsent(E x) {
        boolean contains = list.contains(x);
        if (!contains) {
            list.add(x);
        }
        return !contains;
    }

    /* 剩下的是List本来的方法,都加上synchronized,然后内部调用底层list实现 */
    @Override
    public synchronized int size() {
        return list.size();
    }
    // ...
}

常用的并发基础构建模块

同步容器类

是一类将对应容器的状态都封装起来,并对每个共有方法都进行同步(加synchronized关键字修饰)的类,相当于让所有对容器的状态的访问串行化,虽然安全但是并发性差。

  • Vector
  • HashTable

对容器进行迭代操作时,要考虑它是不是会被其他的线程修改,如果是自己写代码,可以考虑如下方式对容器的迭代操作加锁:

arduino 复制代码
synchronized (vector) {
    for (int i = 0; i < vector.size(); i++)
        doSomething(vector.get(i));
}

不过Java自己的同步容器类并没有考虑并发修改问题,它主要采用类一种及时失败的方法,即一旦容器被其他线程修改,它就会抛出异常,例如Vector类,它的内部实现是这样的:

Vector#iterator()会返回一个 Vector 的内部类Itr implement Iterator<E>,在 Itr 的next()remove()方法中有如下代码:

scss 复制代码
synchronized (Vector.this) { // 类名.this:在内部类中,要用到外围类的this对象,使用"外围类名.this"
    checkForComodification();        // 在进行next和remove操作前,会先检查以下容器是否被修改
    ...
}

/* checkForComodification()方法 */
final void checkForComodification() {
    if (modCount != expectedModCount) // 在Itr的成员变量中有一个:int exceptedModCount = modCount;
        throw new ConcurrentModificationException(); // 如果容器被修改了,modCount会变
}

因此,在调用 Vector 的如下方法时,要小心,因为它们会隐式的调用 Vector 的迭代操作。

  • toString
  • hashCode
  • equals
  • containsAll
  • removeAll
  • retainAll
  • 容器作为参数的构造函数

并发容器类

上一小节,发现同步容器类的性能实在太差,所以可以通过并发容器类代替同步容器类,来提高系统的可伸缩性。主要介绍ConcurrentHashMapCopyOnWriteArrayList

ConcurrentHashMap

特点

  • ConcurrentHashMap实现了ConcurrentMap接口,能在并发环境实现更高的吞吐量,而在单线程环境中只损失很小的性能;

  • 采用分段锁,使得任意数量的读取线程可以并发地访问Map,一定数量的写入线程可以并发地修改Map;

  • 不会抛出ConcurrentModificationException,它返回迭代器具有"弱一致性",即可以容忍并发修改,但不保证将修改操作反应给容器;

  • size()的返回结果可能已经过期,只是一个估计值,不过size()和isEmpty()方法在并发环境中用的也不多;

  • 提供了许多原子的复合操作:

    • V putIfAbsent(K key, V value);:K 没有相应映射才插入
    • boolean remove(K key, V value);:K 被映射到 V 才移除
    • boolean replace(K key, V oldValue, V newValue);:K 被映射到 oldValue 时才替换为 newValue

ConcurrentHashMap内部操作:

  • 在构造的时候,Segment的数量由所谓的concurrentcyLevel决定,默认是16。
  • Segment是基于ReentrantLock的扩展实现的,在put的时候,会对修改的区域加锁。

锁分段的实现原理

锁分段:不同线程在同一数据的不同部分上不会互相干扰,例如,ConcurrentHashMap支持16个并发的写入器,是用16个锁来实现的。它的实现原理如下:

  • 使用一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N%16)个锁来保护;
  • 这大约能把对于锁的请求减少到原来的1/16,也是ConcurrentHashMap最多支持16个线程同时写入的原因;
  • 对于ConcurrentHashMap的size()操作,为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值,而不是维护一个全局计数。
  • 代码示例:
java 复制代码
 public class StripedMap {
     // 同步策略:buckets[n]由locks[n % N_LOCKS]保护
     private static final int N_LOCKS = 16;
     private final Node[] buckets;
     private final Object[] locks; // N_LOCKS个锁
     private static class Node {
         Node next;
         Object key;
         Object value;
     }
     public StripedMap(int numBuckets) {
         buckets = new Node[numBuckets];
         locks = new Object[N_LOCKS];
         for (int i = 0; i < N_LOCKS; i++)
             locks[i] = new Object();
     }
     private final int hash(Object key) {
         return Math.abs(key.hashCode() % buckets.length);
     }
     public Object get(Object key) {
         int hash = hash(key);
         synchronized (locks[hash % N_LOCKS]) { // 分段加锁
             for (Node m = buckets[hash]; m != null; m = m.next)
                 if (m.key.equals(key))
                     return m.value;
         }
         return null;
     }
     public void clear() {
         for (int i = 0; i < buckets.length; i++) {
             synchronized (locks[i % N_LOCKS]) { // 分段加锁
                 buckets[i] = null;
             }        
         }
     }
 }

注意

  • 关于 put 操作:

    • 是否需要扩容

      • 在插入元素前判断是否需要扩容,
      • 比 HashMap 的插入元素后判断是否需要扩容要好,因为可以插入元素后,Map 扩容,之后不再有新的元素插入,Map就进行了一次无效的扩容
    • 如何扩容

      • 先创建一个容量是原来的2倍的数组,然后将原数组中的元素进行再散列后插入新数组中
      • 为了高效,ConcurrentHashMap 只对某个 segment 进行扩容
  • 关于 size 操作:

    • 存在问题:如果不进行同步,只是计算所有 Segment 维护区域的 size 总和,那么在计算的过程中,可能有新的元素 put 进来,导致结果不准确,但如果对所有的 Segment 加锁,代价又过高。
    • 解决方法:重试机制,通过获取两次来试图获取 size 的可靠值,如果没有监控到发生变化,即 Segment.modCount 没有变化,就直接返回,否则获取锁进行操作。

CopyOnWriteArrayList

  • 只要正确发布了这个 list,它就是不可变的了,所以随便并发访问,当需要修改时,就创建一个新的容器副本替代原来的,以实现可变性;
  • 应用于迭代操作远多于修改操作的情形,如:事件通知系统,分发通知时需要迭代已注册监听器链表,并调用每一个监听器,一般注册和注销事件监听器的操作远少于接收事件通知的操作。

并发工具类

可以根据自身状态协调线程的控制流:

  • 生产者消费者模式:阻塞队列(BlockingQueue)

  • 并发流控制:

    • 闭锁(CountDawnLatch)
    • 栅栏(Barrier)
    • 信号量(Semaphore)
  • 线程间的数据交换:交换者(Exchanger)

BlockingQueue

BlockingQueue提供了可阻塞的put和take方法(都是阻塞方法,会抛出InterruptException异常)

  • 如果队列为空,take方法一直被阻塞,直到队列中出现一个可用元素。
  • 如果队列已满,put方法一直被阻塞,直到队列中出现可用空间。

是设计 "生产者 -- 消费者模式" 的利器!

Java 中支持的阻塞队列

阻塞队列类 结构 特点
ArrayBlockingQueue 数组 FIFO
LinkedBlockingQueue 链表 FIFO
PriorityBlockingQueue 优先队列 按优先级先后出队
DelayQueue 使用优先队列实现 向队列中 put 元素时指定多久才能从队列中获取当前元素,只有当延时时间到了,才能从队列中获取该元素,队列元素要实现 Delayed 接口,可以用来设计缓存系统
SynchronousQueue 不存储元素的阻塞队列 每一个 put 操作等待一个 take 操作,否则无法继续添加元素
LinkedTransferQueue 链表 transfer():如果当前有在等待接收元素的消费者,可以把新元素直接给消费者,没有则阻塞;tryTransfer():如果没有消费者等待会返回 false;它们的区别就在于会不会立即返回
LinkedBlockDeque 链表(双向队列) 双向队列可用来实现工作密取模式,即如果一个消费者完成了自己的 Deque 中的全部任务,它可以偷偷的去其他消费者的 Deque 的尾部获取工作,以保证所有线程都处于忙碌状态,可应用于爬虫。

阻塞队列的实现原理

对于阻塞队列的实现原理,最关注的是其通知模式的实现,即BlockingQueue是如何在队列满时通知put操作等待,和如何在队列空时通知take操作等待的。

通过阅读 ArrayBlockingQueue 的源码得知:

  • ArrayBlockingQueue 中有一个 ReentrantLock lock
  • 这个 lock 提供了两个 Condition:notEmpty 和 notFull
  • take 操作中 ,会以 while 循环的方式轮询 count == items.length,如果为 true,就 notFull.await() ,这个阻塞状态需要通过 dequeue 方法中notFull.signal() 来解除;
  • put 操作中 ,会以 while 循环的方式轮询 count == 0,如果为 true,就 notEmpty.await() ,这个阻塞状态需要通过 enqueue 方法中notEmpty.signal() 来解除。
ini 复制代码
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
                implements BlockingQueue<E>, java.io.Serializable {
        int count;  // 队列中元素的个数
        final ReentrantLock lock;  // 下面的两个Condition绑定在这个锁上
        private final Condition notEmpty;  // 用来等待take的条件
        private final Condition notFull;  // 用来等待put的条件
        
        public ArrayBlockingQueue(int capacity, boolean fair) {
        // 省略...
        lock = new ReentrantLock(fair);
        notEmpty = lock.newCondition();
        notFull =  lock.newCondition();
    }
    
    public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();  // 加可中断锁
        try {
            while (count == items.length)
                notFull.await();  // 轮询count值,等待count < items.length
            enqueue(e);  // 包含notFull.signal();
        } finally {
            lock.unlock();
        }
    }
    
    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();  // 轮询count值,等待count > 0
            return dequeue();  // 包含notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }
    
    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();  // 会唤醒在等待的take操作
    }
    
    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();  // 会唤醒在等待的put操作
        return x;
    }
}

CountDownLatch

可以让一个或多个线程等待其他线程操作完成在继续执行,不可以循环使用,只能使用一次。

API

java 复制代码
public CountDownLatch(int count);  // 参数count为计数值

// 调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行,或等待中线程中断
public void await() throws InterruptedException;

// 和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException;

public void countDown();  // 将count值减1

使用CountDownLatch代替join()

csharp 复制代码
public class CountDownLatchAndJoin {
    static CountDownLatch countDownLatch = new CountDownLatch(2);

    public static void main(String[] args) throws InterruptedException {
        new Thread() {
            @Override
            public void run() {
                System.out.println(1);
                countDownLatch.countDown();
                System.out.println(2);
                countDownLatch.countDown();
            }
        }.start();
        countDownLatch.await();
        System.out.println("Main Finished");
    }
}
/*
输出:
1
2
Main Finished  // main线程会等待main启动的线程执行完再结束
*/

CyclicBarrier

可以让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,让所有线程通过,并且这个屏障可以循环使用。

API

csharp 复制代码
/**
 * parties指让多少个线程或者任务等待至barrier状态
 * barrierAction为当这些线程都达到barrier状态时会执行的内容
 */
public CyclicBarrier(int parties, Runnable barrierAction);  // 常用
public CyclicBarrier(int parties);

public int await()
        throws InterruptedException, BrokenBarrierException;
public int await(long timeout, TimeUnit unit)
        throws InterruptedException, BrokenBarrierException, TimeoutException;

Demo

csharp 复制代码
public class CyclicBarrierDemo2 {
    static CyclicBarrier barrier = new CyclicBarrier(2, new After());

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                System.out.println("In thread");
                try {
                    barrier.await();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }.start();

        System.out.println("In main");
        try {
            barrier.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("Finish.");
    }

    static class After implements Runnable {
        @Override
        public void run() {
            System.out.println("All reach barrier.");
        }
    }
}
/*
输出:
In main  // main线程到达屏障之后会被阻塞
In thread
All reach barrier.  // thread到达屏障之后会执行After的run
Main finish  // 然后被阻塞的main线程和thread线程才会继续执行下去
Thread finish
*/

Semaphore

用来控制同时访问特点资源的线程数量。

API

java 复制代码
// 参数permits表示许可数目,即同时可以允许多少线程进行访问,默认是非公平的
public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

// 这个多了一个参数fair表示是否是公平的,即等待时间越久的越先获取许可
public Semaphore(int permits, boolean fair) {
    sync = (fair) ? new FairSync(permits) : new NonfairSync(permits);
}

/* 会阻塞等待的acquire方法 */
public void acquire() throws InterruptedException;  // 获取一个许可
public void acquire(int permits) throws InterruptedException;  // 获取permits个许可
public void release();  // 释放一个许可
public void release(int permits);  // 释放permits个许可

/* 会阻塞但不等待,立即返回的acquire方法 */
// 尝试获取一个许可,若获取成功,则立即返回true,若获取失败,则立即返回false
public boolean tryAcquire() { }

// 尝试获取一个许可,若在指定的时间内获取成功,则立即返回true,否则则立即返回false
public boolean tryAcquire(long timeout, TimeUnit unit)
        throws InterruptedException { }

// 尝试获取permits个许可,若获取成功,则立即返回true,若获取失败,则立即返回false
public boolean tryAcquire(int permits) { }

// 尝试获取permits个许可,若在指定的时间内获取成功,则立即返回true,否则则立即返回false
public boolean tryAcquire(int permits, long timeout, TimeUnit unit)
        throws InterruptedException { }

Demo

java 复制代码
public class SemaphoreDemo2 {
    private static final int THREAD_COUNT = 10;

    private static ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);

    private static Semaphore semaphore = new Semaphore(2);

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        semaphore.acquire();
                        System.out.println("save data");
                        Thread.sleep(1000);
                        semaphore.release();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}
/*
结果会两个两个的蹦出:save data,说明同时只有两个线程能拿到资源
*/

Exchanger

一个用于两个线程间交换数据的工具类。如果第一个线程先执行类exchange(V)方法,它就会阻塞在那里,等待第二个线程执行exchange(V)方法,exchange(V)会返回另一个线程传入的数据。

API

java 复制代码
public Exchanger();

public V exchange(V x)
           throws InterruptedException;

public V exchange(V x, long timeout, TimeUnit unit)
           throws InterruptedException, TimeoutException;

Demo

typescript 复制代码
public class ExchangeDemo {
    private static Exchanger<String> exch = new Exchanger<>();

    private static ExecutorService pool = Executors.newFixedThreadPool(2);

    // 用来保证线程池在两个线程执行完之后再关闭
    private static CountDownLatch latch = new CountDownLatch(2);

    public static void main(String[] args) {
        pool.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    String data = "第一个线程的结果";
                    Thread.sleep(100);
                    String res = exch.exchange(data);
                    System.out.println("我是第一个线程,我收到另一个线程的结果为:" + res);
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        pool.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    String data = "第二个线程的结果";
                    Thread.sleep(1000);
                    String res = exch.exchange(data);
                    System.out.println("我是第二个线程,我收到另一个线程的结果为:" + res);
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        try {
            latch.await();  // 等待两线程执行完,然后关闭线程池
        } catch (Exception e) {
            e.printStackTrace();
        }
        pool.shutdown();
    }
}

线程池的使用

为什么要使用线程池

希望应用程序是这样的:

  • 在正常的负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应;
  • 当负荷过载时,应用程序的性能应该是逐渐降低,而不是直接失败。

如果不使用线程池,为每一个任务都创建一个线程来执行,将会遇到如下问题:

  • 线程的创建和销毁都需要时间,会延迟请求的执行,并且消耗系统资源;

    • 尤其是内存,还会增加GC的压力;同时在系统崩溃的临界点,如果多创建一个线程,就会导致系统崩溃,而不是性能的缓慢下降。
  • 如果线程数超过了CPU数,增加线程反而降低性能,因为会出现频繁的上下文切换。

合理使用线程池的好处:

  • 降低资源消耗:可以重复使用已经创建好的线程。
  • 提高响应速度:任务达到时,可以不需要等待线程创建的时间。
  • 提高线程的可管理性。

Executor 框架概述

通过Executor框架,可以将工作单元(Runnable & Callable)与执行机制(Executor)分离,即将任务的提交和任务的执行分离。

Executor 框架是由3大部分组成。

  • 任务:实现接口:Runnable接口或Callable接口。

  • 任务的执行:包括任务执行机制的核心接口Executor,以及继承自Executor的ExecutorService接口。

    • Executor 框架有两个关键类实现了ExecutorService接口

      • ThreadPoolExecutor
      • ScheduledThreadPoolExecutor
  • 异步计算的结果:接口 Future 和实现 Future 接口的 FutureTask 类。

Executor 是基于生产者------消费者模式的

  • 提交任务的操作相当于生产者(生成待完成的工作单元)
  • 执行任务的线程相当于消费者(执行完这些工作单元)

Executor 框架的主要类和接口

Executor 接口: 框架的基础,线程池都是实现自它的子接口的。

csharp 复制代码
public interface Executor {
    void execute(Runnable command);
}

Executor 框架的主要成员:

  • ThreadPoolExecutor
  • ScheduledThreadPoolExecutor
  • Future 接口 & FutureTask 实现类
  • Executors 工厂类

接下来,将一一介绍这些 Executor 框架的组件。在介绍这些组件之前,先来看一下线程池要如何使用。

线程池的基本使用方法

csharp 复制代码
public class ThreadPoolDemo {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(15);  // 用来判断线程池是否可以关闭

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println("线程开始");
                    Thread.sleep(1000);
                    System.out.println("线程结束");
                    latch.countDown();
                } catch (InterruptedException e) {
                }
            }
        };

        for (int i = 0; i < 15; i++) {
            pool.execute(runnable);
        }
        latch.await();  // 等待线程池中的线程运行完毕
        System.out.println("finish");
        pool.shutdown();
    }
}

以上 Demo 运行之后,15 个线程不会一下执行完,而是会 5 个 5 个的往外蹦。

ThreadPoolExecutor

ThreadPoolExecutor 是线程池的核心实现类,用来执行被提交的任务。一般通过 Executors 工具类创建,我们可以通过 Executor 创建如下三种 ThreadPoolExecutor:

  • FixedThreadPool
  • CacheThreadPool
  • SingleThreadExecutor

接下来将分别介绍它们。

首先,需要介绍一下 ThreadPoolExecutor 的构造方法,因为以上三种 ThreadPoolExecutor 其实都是被赋予了不同的构造参数的 ThreadPoolExecutor 对象。

arduino 复制代码
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) { ... }

参数说明:

参数 描述
corePoolSize 核心线程池大小,即没有执行任务时的线程池大小,只有在工作队列满了的情况下才会创建超出这个数量的线程
maximumPoolSize 最大线程池的大小
keepAliveTime 某个线程的空闲时间超过了存活时间,那么将被标记为可回收的
BlockingQueue 用来暂时保存任务的工作队列
RejectedExecutionHandler 当 ThreadPoolExecutor 已经关闭或者达到了最大线程池大小并且工作队列已满时,调用 execute() 方法会调用 RejectedExecutionHandler handler 的 rejectedExecution(Runnable r, ThreadPoolExecutor executor); 方法

FixedThreadPool

特点: 固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,如果某个线程由于发生了未预期的 Exception 而结束,那么线程池会补充一个新的线程。

创建方法:

arduino 复制代码
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,  // 线程池大小不可扩展
                                  0L, TimeUnit.MILLISECONDS,  // 多余线程会被立即终止
                                  new LinkedBlockingQueue<Runnable>());  
                                  // 使用容量为 Integer.MAX_VALUE 的工作队列
                                  // 由于使用了无界队列,不会拒绝任务,所以不会调用 handler
}

CacheThreadPool

特点: 可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。

创建方法:

csharp 复制代码
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,  // 初始为0,线程池中的线程数是无界的
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());  
}

注意:

  • 池中不会有空闲线程,也不会有等待的线程
  • 一旦任务到达的速度大于线程池处理任务的速度,就会创建一个新的线程给任务
  • 与另外两个线程池不同的地方在于,这个工作队列并不是用来放还没有执行的任务的,而是用来放执行过任务后空闲下的线程的,空闲下来的线程会被:SynchronousQueue#poll(keepAliveTime, TimeUnit.NANOSECONDS) poll 到工作队列中等待 60s,如果这 60s 有新的任务到达了,这个线程就被派出去执行任务,如果没有,就销毁。

SingleThreadPool

特点: 单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。能确保依照任务在队列中的顺序来串行执行。

创建方法:

csharp 复制代码
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,  // 线程池的大小固定为1
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
                                // 使用容量为 Integer.MAX_VALUE 的工作队列
}

Remarks

  • 在创建 ThreadPoolExecutor 初期,线程并不会立即启动,而是等到有任务提交时才会启动,除非调用 prestartAllCoreThreads

  • 将线程池的 corePoolSize 设置为 0 且不使用 SynchronousQueue 作为工作队列会产生的奇怪行为:只有当线程池的工作队列被填满后,才会开始执行任务

    • 产生原因:如果线程池中的线程数量等于线程池的基本大小,那么仅当在工作队列已满的情况下ThreadPoolExecutor才会创建新的线程,如果线程池的基本大小为零并且其工作队列有一定的容量,那么当把任务提交给该线程池时,只有当线程池的工作队列被填满后,才会开始执行任务,因为这个时候才会创建新的线程,在此之前,线程池只有在工作队列中等待任务,没有执行任务的线程。

ScheduledThreadPoolExecutor

特点: 可以在给定的延迟后运行命令,或者定期执行命令。比Timer更灵活,功能更强大。

创建方法: 也是通过Executor工具创建。

arduino 复制代码
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
    return new ScheduledThreadPoolExecutor(corePoolSize);
}

然后通过schedule方法提交线程到线程池:

arduino 复制代码
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
                                       long delay,
                                       TimeUnit unit)

实现原理:

  • 使用 DelayWorkQueue 作为工作队列,ScheduledThreadPoolExecutor 会把待执行的任务 ScheduledFutureTask 放到工作队列中

  • ScheduledFutureTask 中有以下 3 个主要的成员变量:

    • long time:表示该任务将要被执行的具体时间;
    • long sequenceNumber:表示任务被添加到 ScheduledThreadPoolExecutor 中的序号;
    • long period:表示任务执行的间隔周期。
  • 任务执行的过程:

    • 线程从 DelayWorkQueue 中获取到期的任务;
    • 执行这个任务;
    • 修改这个任务的 time 为下一次的执行时间;
    • 将该任务再次 add 进 DelayWorkQueue。

对比Timer(Timer的缺陷)

  • Timer 在执行所有定时任务时只会创建一个线程。如果有一个任务执行时间太长导致它后面的任务超时,那么后面超时的任务会立即执行,从而破坏了其他 TimerTask 的准时执行。线程池能弥补这个缺陷,因为它可以提供多个线程来执行延时任务和周期任务。
  • 线程泄漏:Timer 线程并不捕获未检查异常,当 TimerTask 抛出未检查的异常时将终止定时线程。这种情况下,整个 Timer都会被取消,将导致已经被调度但尚未执行的 TimerTask 将不会再执行,新的任务也不能被调度。

ThreadPoolExecutor 补充内容

BlockingQueue<Runnable> workQueue 的设置

  • 无界队列

    • 使用无界队列的线程池

      • newFixedThreadPool
      • newSingleThreadExecutor
    • BlockingQueue 选择

      • 无界的 LinkedBlockingQueue
  • 有界队列(可以避免资源耗尽,队列满了的处理方法请看下小节:RejectedExecutionHandler handler 的设置)

    • 只有当任务相互独立时,为线程池或工作队列设置界限才是合理的。如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程"饥饿"死锁问题

      • BlockingQueue 选择

        • ArrayBlockingQueue
        • 有界的 LinkedBlockingQueue
        • PriorityBlockingQueue
  • 同步移交

    • newCachedThreadPool 中使用
    • 对于非常大的或者无界的线程池,可以通过使用 SynchronousQueue 来避免任务排队
    • SynchronousQueue 不是一个真正的队列,而是一种在线程之间进行移交的机制
    • 要将一个元素放入 SynchronousQueue 中,必须有另一个线程正在等待接受这个元素。如果没有线程正在等待,并且线程池的当前大小小于最大值,那么 ThreadPoolExecutor 将创建一个新的线程,否则这个任务将被拒绝。

RejectedExecutionHandler handler 的设置

JDK 提供了 4 种 RejectedExecutionHandler 接口的实现,它们都是以 ThreadPoolExecutor 类的静态内部类的形式定义的,它们的具体实现以及拒绝策略如下:

  • AbortPolicy (默认)(Abort:流产)

    • 抛出未检查的 RejectedExecutionException,调用者自己捕获处理
    • 实现:
    csharp 复制代码
    public static class AbortPolicy implements RejectedExecutionHandler {
        public AbortPolicy() { }
    
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            throw new RejectedExecutionException("Task " + r.toString() + " rejected from " +
                                                 e.toString()); // 抛异常!
        }
    }
    • 这个是 ThreadPoolExecutor 的默认的 RejectedExecutionHandle handler,ThreadPoolExecutor 中有一个:
    java 复制代码
    private static final RejectedExecutionHandler defaultHandler = new AbortPolicy();

如果调用 ThreadPoolExecutor 的构造方法时没有给出 RejectedExecutionHandle 参数的话,它就会将上面的 defaultHandler 作为参数构造 ThreadPoolExecutor 对象,像这样:

csharp 复制代码
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), defaultHandler); // 默认传入了 defaultHandler
}
  • DiscardPolicy (Discard:抛弃)

    • 抛弃新提交的任务
    • 实现:它的 rejectedExecution 方法啥都没干......
    csharp 复制代码
    public static class DiscardPolicy implements RejectedExecutionHandler {
        public DiscardPolicy() { }
    
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        }
    }
  • DiscardOldestPolicy

    • 抛弃下一个被执行的任务,然后重新尝试提交任务
    • 实现:
    csharp 复制代码
    public static class DiscardOldestPolicy implements RejectedExecutionHandler {
        public DiscardOldestPolicy() { }
        
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) { // 先判断线程池关没
                e.getQueue().poll(); // 丢到等待队列中下一个要被执行的任务
                e.execute(r); // 重新尝试提交新来的任务
            }
        }
    }
    • 不要和 PriorityBlockingQueue 一起使用,会丢失优先级最高的任务
  • CallerRunsPolicy (既不抛出异常,也不抛弃任务)

    • 它不会在线程池中执行该任务,而是在调用 execute 提交这个任务的线程执行
    • 如当主线程提交了任务时,任务队列已满,此时该任务会在主线程中执行。这样主线程在一段时间内不会提交任务给线程池,使得工作者线程有时间来处理完正在执行的任务
    • 可以实现服务器在高负载下的性能缓慢降低
    • 提交任务的应用程序被拿去执行任务了,不会返回 accept,TCP 层的请求队列会被填满而抛弃请求,客户端才会反应过来,即可以通过 TCP 层来缓冲一下
    • 实现:
    csharp 复制代码
    public static class CallerRunsPolicy implements RejectedExecutionHandler {
        public CallerRunsPolicy() { }
    
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                // 直接在把它提交来的线程调用它的 run 方法,相当于没有新建一个线程来执行它,
                // 而是直接在提交它的线程执行它,这样负责提交任务的线程一段时间内不会提交新的任务来
                r.run(); 
            }
        }
    }

ThreadPoolExecutor 的饱和策略可以通过调用 setRejectedExecutionHandler 来修改。

Future 接口 & FutureTask实现类

Future 接口 & FutureTask 实现类表示异步运算的结果,截至至 Java 8,FutureTask 是 Future 接口唯一的实现类。

FutureTask 的状态迁移:

FutureTask 的 get 和 cancel 执行效果:

Future 的 get 方法对于任务的状态的不同表现:

  • 任务已完成:立即返回结果或抛出异常。
  • 任务未完成:阻塞直到任务完成。
  • 任务抛出异常:将异常封装为 ExecutionException 后再次抛出,ExecutionException 异常可以通过 getCause() 方法获取被封装的初始异常。
  • 任务被取消:抛出 CancallationException 异常,这是个 RuntimeException,需要显式 catch。

Runnable 接口 & Callable接口

Java 创建线程的三种方式:

  • extends Thread
  • implements Runnable
  • implements Callable

Callable 接口是比 Runnable 更好的基本任务表示形式,它任务主入口点 call 将返回一个值或者抛出一个异常。

csharp 复制代码
public interface Callable<V> {
    V call() throws Exception;
}
// 如果想要使用无返回值的 Callable,可以使用 Callable<Void>

Executor 的生命周期

Executor 的生命周期方法:

java 复制代码
public interface ExecutorService extends Executor {
    // shutdown方法将执行平缓的关闭过程:
    // 不再接受新的任务,同时等待已经提交的任务执行完成(包括那些还未开始执行的任务)
    void shutdown();

    // 执行粗暴的关闭过程:
    // 它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务
    List<Runnable> shutdownNow();

    boolean isshutdown();

    // 返回ExecutorService是否已经终止
    boolean isTerminated();

    // 等待ExecutorService到达终止状态,一般调用完它之后立即调用shutdown
    boolean awaitTermination(long timeout,TimeUnit unit)
        throws InterruptedException;
    // ...
}

ExecutorService的生命周期的3种状态

  • 运行:初始创建的时候

  • 关闭:调用shutdown和shutdownNow,关闭任务

    • 在ExecutorService关闭后提交的任务将由"拒绝执行处理器(RejectedExecutionHandler)"来处理,它会抛弃任务,或者使得execute方法抛出一个未检查的RejectedExecutionException(RuntimeException异常)
  • 已终止:等所有任务都完成后,ExecutorService将转入终止状态

    • 可以调用awaitTermination等待ExecutorService到达终止状态
    • 可以通过调用isTerminated来轮询ExecutorService是否已经终止

设置线程池的大小

线程池过大过小的缺点

  • 过大

    • 大量线程将在很少的CPU资源上发生竞争
    • 大量空闲线程会耗费内存,导致资源耗尽
  • 过小

    • CPU闲置,系统吞吐量下降

线程池大小的设置

  • 计算密集型任务:N=N_cpu+1

    • 加1的原因:当有一个线程偶尔故障时,额外的那个线程可以立即补上,保证CPU时钟不会被浪费
  • 包含I/O或其他阻塞操作:N=N_cpuU_cpu(1+W/C)

    • N_cpu:CPU 的个数
    • U_cpu:目标 CPU 利用率
    • W / C:等待时间 (Wait) / 计算时间 (Compute)
    • 获取 CPU 数目的方法:int N_CPUS = Runtime.getRuntime().availableProcessors();

安全取消线程

简单的任务取消

最简单的任务取消方法:自己设置一个取消标记:private volatile boolean cancelled;,然后在运行任务过程中不断的循环判断这个标记,然后用另一个线程改变这个标记,一旦当前线程检测到这个取消标记发生变化,就退出停止执行。

示例:

java 复制代码
public class PrimeGenerator implements Runnable {
    private static ExecutorService exec = Executors.newCachedThreadPool();

    private final List<BigInteger> primes = new ArrayList<BigInteger>();
    private volatile boolean cancelled;

    public void run() {
        BigInteger p = BigInteger.ONE;
        while (!cancelled) {            // 这里循环判断这个标记
            p = p.nextProbablePrime();
            synchronized (this) {
                primes.add(p);
            }
        }
    }

    public void cancel() {
        cancelled = true;        // 用另一个线程设置这个取消标记
    }

    public synchronized List<BigInteger> get() {
        return new ArrayList<BigInteger>(primes);
    }
}

不过这种方法是有缺陷的,一旦正在执行的任务发生了阻塞,并且该阻塞状态一直没有解除,那么它将不再有机会判断取消标记,这样即使令 cancelled = true 了,需要被取消的线程也检测不到。就像下面这段代码这样:

arduino 复制代码
class BrokenPrimeProducer extends Thread {
    private final BlockingQueue<BigInteger> queue;
    private volatile boolean cancelled = false;

    BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
        this.queue = queue;
    }

    public void run() {
        try {
            BigInteger p = BigInteger.ONE;
            while (!cancelled)
                // 如果队列已经满了,而且也没有消费者从队列中take元素了
                // 这个线程将一直卡在这里,没有机会去判断cancelled的状态
                queue.put(p = p.nextProbablePrime());
        } catch (InterruptedException consumed) {}
    }

    public void cancel() {
        cancelled = true;
    }
}

中断

自己设置一个标记用来判断线程是否要被取消实在不太好用,所以就不自己定义一个 boolean 表示线程的状态了,而是直接用 Java 给我们提供的中断机制,其实,每个线程都有一个 boolean 类型的中断状态。当中断线程时,这个线程的中断状态将被设置为 true,也就是说: 中断并不会真正地停止一个正在运行的线程,只是将被中断线程的中断标记设置为 true,然后由线程自己在一个合适的时刻检查自己的中断标记中断自己(这些时刻也被称为取消点),这样可以防止线程在不应该被中断的地方强制停止执行。

中断方法

方法 说明
public void interrupt() 中断目标线程(将当前线程的中断标记设置为 true)
public boolean isInterrupted() 返回目标线程的中断状态,执行后中断标记还保持它原来的值
public static boolean interrupted() 返回目标线程的中断状态,执行后将中断标记设置为为 false

注意:

  • interrupted() 方法是能清除中断状态的唯一方法;

  • 在调用 interrupted() 返回值为 true 时,除非我们想要屏蔽这个中断,否则必须对它进行处理。有以下两种处理方式:(这两种方法也是正确的中断处理方法!)

    • 抛出 InterruptedException
    • 再次调用 interrupt() 方法,将中断标记恢复为 true

中断是如何解决简单的任务取消方法中的阻塞问题的?

阻塞方法一般都会 throws InterruptedException ,它们在中断标记被修改并且被它们检测到后会:

  • 清除中断标记,即把中断标记设置为 false;
  • 抛出 InterruptedException 异常,停止线程执行。

注意: JVM 并不保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。

ThreadPoolExecutor 拥有的线程检测到中断时的操作

检查线程池是否正在关闭:

  • 如果是,在结束之前执行一些线程池清理工作;
  • 如果不是,创建一个新线程将线程池恢复到合理的规模。

Future实现计时运行

需要:给一个Runnable r 和时间long timeout,解决"最多花timeout分钟运行Runnable,没运行完就取消"这种要求

java 复制代码
private static final ExecutorService cancelExec = Executors.newCachedThreadPool();
public static void timedRun(Runnable r, long timeout, TimeUnit unit) {
    Future<?> task = cancelExec.submit(r);
    try {
        task.get(timeout, unit);
    } catch (TimeoutException e) {
        // 如果超时,抛出超时异常
    } catch (ExecutionException e) {
        // 如果任务运行出现了异常,抛出任务的异常
        throw launderThrowable(e.getCause());
    } finally {
        // 如果任务已经结束,这句没影响
        // 如果任务还在运行,这句会中断任务
        task.cancel(true);
    }
}

安全停止基于线程的服务

线程的所有权

线程的所有者: 创建这个线程的类(线程池是其工作者线程的所有者,如果要中断这些线程,要通过线程池)

线程的所有权是不可传递的:

  • 应用程序可以拥有服务,服务也可以拥有工作者线程,但应用并不能拥有工作者线程,因此应用程序不能直接停止工作者线程,而是要通过服务来停止。
  • 对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。服务可以通过生命周期方法关闭它所拥有的线程。

一个有问题的日志服务

arduino 复制代码
public class LogWriter {
    private final BlockingQueue<String> queue;
    private final LoggerThread logger;
    private final PrintWriter writer;
    private boolean isShutdown;  // 新加一个关闭判断
    private static final int CAPACITY = 1000;

    public LogWriter(Writer writer) {
        this.queue = new LinkedBlockingQueue<String>(CAPACITY);
        this.logger = new LoggerThread(writer);
    }

    public void start() {
        logger.start();
    }

    public void log(String msg) throws InterruptedException {
        if (!isShutdown) {  // 如果关了,就抛异常
            queue.put(msg); // 但是这一行和上一行不是一个原子操作,有可能会丢失日志
                                       // 我们又不能给这个方法加synchronized,因为给一个阻塞方法加锁是很危险的!
        } else {
            throw new IllegalStateException("logger is shut down");
        }
    }

    private class LoggerThread extends Thread {
        public LoggerThread(Writer writer) {
            this.writer = new PrintWriter(writer, true); // autoflush
        }

        public void run() {
            try {
                while (true)
                    writer.println(queue.take());
            } catch (InterruptedException ignored) {
            } finally {
                writer.close();
            }
        }
    }
}

方法一:BlockingQueue + isShutdown + count

java 复制代码
public class LogService {
    private final BlockingQueue<String> queue;
    private final LoggerThread loggerThread;
    private final PrintWriter writer;
    private boolean isShutdown;
    // 这个计数器的作用是:如果queue满了,有线程阻塞着,
    // 它可以保证所有在关日志前添加的日志都记录了再真正关闭日志服务
    private int reservations;

    public LogService(Writer writer) {
        this.queue = new LinkedBlockingQueue<String>();
        this.loggerThread = new LoggerThread();
        this.writer = new PrintWriter(writer);
    }

    public void start() {
        loggerThread.start();
    }

    public void stop() {
        synchronized (this) {
            isShutdown = true;
        }
        loggerThread.interrupt();
    }

    public void log(String msg) throws InterruptedException {
        synchronized (this) {
            if (isShutdown)
                throw new IllegalStateException(/*...*/);
            ++reservations; // 记录待处理的日志数量
        }
        queue.put(msg);
    }

    private class LoggerThread extends Thread {
        public void run() {
            try {
                while (true) {
                    try {
                        synchronized (LogService.this) {
                            if (isShutdown && reservations == 0)
                                break; // 只有当isShutdown == true并且没有待处理的日志时才能关闭日志服务
                        }
                        String msg = queue.take();
                        synchronized (LogService.this) {
                            --reservations; // 处理完一个,待处理的日志数就-1
                        }
                        writer.println(msg);
                    } catch (InterruptedException e) { /* retry */
                    }
                }
            } finally {
                writer.close();
            }
        }
    }
}

方法二:线程池 ExecutorService

ExecutorService 的关闭方法:

  • shutdown

    • 会把当前执行的和尚未启动的任务清单中的程序执行完再关闭
    • 关闭速度慢但安全
  • shutdownNow

    • 首先关闭当前正在执行的任务,然后任何返回任务清单中尚未执行的任务

使用 SingleThreadExecutor 线程池构建日志服务:

typescript 复制代码
public class LogService {
    private final ExecutorService exec = Executors.newSingleThreadExecutor();
    private final PrintWriter writer;
    public void start() {}
    public void stop() {
        try {
            // 下两行经常一起用!
            exec.shutdown();
            exec.awaitTermination(TIMEOUT, UNIT);
        } finally {
            write.close();
        }
    }
    public void log(String msg) {
        try {
            exec.execute(new WriteTask(msg));
        } catch (RejectedExecutionException e) {}
    }
}

毒丸对象

另一种关闭生产者------消费者服务的方式就是使用"毒丸(Poison Pill)"对象。"毒丸"是指一个放在队列上的对象,其含义是:当得到这个对象时,立即停止。在FIFO队列中,"毒丸"对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交"毒丸"对象之前提交的所有工作都会被处理,而生产者在提交"毒丸"对象后,将不会再提交任何工作。

注意:

  • 只有在无界队列中,"毒丸"对象才能可靠地工作,否则可能无法将毒丸对象 put 到队列中去。
  • 只有在生产者和消费者的数量都已知的情况下,才可以使用"毒丸"对象,否则无法判断应该使用几个毒丸对象。
  • 扩展到多个生产者:每个生产者都向队列中放入一个"毒丸"对象,并且消费者仅当在接收到生产者数量个"毒丸"对象时才停止。
  • 扩展到多个消费者的情况:生产者将消费者数量个"毒丸"对象放入队列。

处理RuntimeException

RuntimeException 通常不会被捕捉,是导致线程死亡的最主要原因。

处理方法

方法一:在线程池内部构建一个工作者线程。如果任务抛出了一个未检查异常,那么它将使线程终结,但会首先通知框架该线程已经终结。

csharp 复制代码
public void run() {
    Throwable thrown = null;
    try {
        while (!isInterrupted()) {
            runTask(getTaskFromWorkQueue());
        }
    } catch (Throwable e) {
        thrown = e;
    } finally {
        threadExited(this, thrown);
    }
}

方法二: implements Thread.UncaughtException 接口

csharp 复制代码
public interface UncaughtExceptionHandler {
    void uncaughtException(Thread t, Throwable e);
}

令会抛出 RuntimeException 异常的的类实现 UncaughtException 接口,这样当该类抛出未捕获的异常时,会执行 uncaughtException(Thread t, Throwable e) 方法,我们可以在这个方法中将错误信息写入日志,或者尝试重新启动线程,关闭应用程序等。

注意: 只有通过 execute 提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过 submit 提交的任务,无论是抛出的未检查异常还是已检查异常,都将被认为是任务返回状态的一部分。如果一个由 submit 提交的任务由于抛出了异常而结束,那么这个异常将被 Future.get 封装在 ExecutionException 中重新抛出。

JVM关闭

关闭钩子

JVM 关闭流程:

  • 在正常关闭中,JVM 首先调用所有已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过 Runtime.addShutdownHook(Thread) 注册的但尚未开始的线程。JVM并不能保证关闭钩子的调用顺序。
  • 当被强行关闭时,只是关闭JVM,而不会运行关闭钩子。

应用:通过注册一个关闭钩子来停止日志服务

csharp 复制代码
public void start(){
    Runtime.getRuntime().addshutdownHook(new Thread() {
        public void run() {
            try { Logservice. this. stop(); }
            catch (InterruptedException ignored) {}
        }
    });
}

注意:Runtime 是个单例类

守护线程Daemon

定义: 一种特殊的线程,当进程中不存在非守护线程了,守护线程自动销毁。

应用: 垃圾回收线程,当进程中没有非守护线程了,垃圾回收线程就没有存在的必要了,会自动销毁。

设置方法: thread.setDaemon(true);

补充

对比HashMap&HashTable&TreeMap

基本区别

  • HashTable

    • 线程安全,不支持 null 作为键或值,它的线程安全是通过在所有方法 public 方法上加 synchronized 实现的,所以性能很差,很少使用。
  • HashMap

    • 不是线程安全的,但是支持 null 作为键或值,是绝大部分利用键值对存取场景的首选,put 和 get 基本可以达到常数级别的时间复杂度。
  • TreeMap

    • 基于红黑是的一种提供顺序访问的 Map,它的 get,put,remove 等操作是 O(log(n)) 级别的时间复杂度的(因为要保证顺序),具体的排序规则可以由 Comparator 指定:public TreeMap(Comparator<? super K> comparator)

在对 Map 的顺序没有要求的情况下,HashMap 基本是最好的选择,不过 HashMap 的性能十分依赖于 hashCode 的有效性,所以必须满足:

  • equals 判断相等的对象的 hashCode 一定相等
  • 重写了 hashCode 必须重写 equals

我们注意到,除了 TreeMap,LinkedHashMap 也可以保证某种顺序,它们的 区别 如下:

  • LinkedHashMap:提供的遍历顺序符合插入顺序,是通过为 HashEntry 维护一个双向链表实现的。
  • TreeMap:顺序由键的顺序决定,依赖于 Comparator。

HashMap源码分析

HashMap内部结构、

内部结构如下:

解决哈希冲突的常用方法:

  • 开放地址法:出现冲突时,以当前哈希值为基础,产生另一个哈希值。
  • 再哈希法:同时构造多个不同的哈希函数,发生冲突就换一个哈希方法。
  • 链地址法:将哈希地址相同的元素放在一个链表中,然后把这个链表的表头放在哈希表的对应位置。
  • 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

HashMap 采用的是链表地址法,不过如果由一个位置的链表比较长了(超过阈值 8 了),链表会被改造为树形结构以提高查找性能。

这个桶数组并没有在 HashMap 的构造函数中初始化好,只是设置了容量(默认初始容量为 16),应该是采用了 lazy-load 原则。

arduino 复制代码
public HashMap(int initialCapacity, float loadFactor){  
    // ... 
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
vbnet 复制代码
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

可以看到,put 方法调用了 putVal 方法:

scss 复制代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // lazy-load,tab要是空的,用resize初始化它
    // resize既要负责初始化,又要负责在容量不够时扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 无哈希冲突,直接new一个节点放到tab[i]就行
    // 具体键值对在哈希表中的位置:i = (n - 1) & hash
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 该key存在,直接修改value就行
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 当前hashCode下面挂的已经是颗树了,用树的插入方式插入新节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 当前hashCode下面挂的还是个链表,不过保不齐会变成颗树
        else {
            // ...
            if (binCount >= TREEIFY_THRESHOLD - 1) // 链表要变树啦!
                treeifyBin(tab, hash);
            // ...
        }
    }
    ++modCount;
    if (++size > threshold) // 容量不够了,扩容
        resize();
}

分析:

  • key 的 hashCode 用的并不是 key 自己的 hashCode,而是通过 HashMap 内部的一个 hash 方法另算的。那么为什么要另算一个 hashCode 呢?这是因为: 有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,这种处理可以有效避免这种情况下的哈希碰撞。
java 复制代码
 static final int hash(Object key) {
     int h;
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }
  • resize 方法: (重点!这个方法和以前写的有点不一样了......)现在的写法不会出现链表扩容时发生死循环了,以前的写法相当于将 oldTab 上的 Node 一个一个卸下来,然用头插法的方式插入到 newTab 的对应位置,因为用的是头插法,会给链表倒序,这种倒序导致了在多线程时,链表的两个 Node 的 next 可能会互相指向对方,出现死循环(详见此文)。现在的方法是使用尾插法,即不会改变链表原来在 oldTab 挂着的时候的相对顺序,在 oldTab[j] 处的链表会根据 hash 值分成 lo 和 hi 两个链表,然后分别挂在 newTab 的 newTab[j]newTab[j + oldCap] 两个不同的位置。
ini 复制代码
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    // oldTab 的长度,一定是 2 的幂,也就是说,二进制只有一位为 1
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // MAXIMUM_CAPACITY = 1 << 30,如果超过这个容量就扩不了容了
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // newCap = oldCap << 1,容量变成原来的 2 倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 把 oldTab 中的数据移到 newTab 中,这里是要进行 rehash 的
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) { // 把 oldTab 中的非 null 元素放到 newTab 去
                oldTab[j] = null; // 把链表从 oldTab[j] 上取下来
                if (e.next == null) // oldTab[j] 处只有一个元素
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) // oldTab[j] 处是一颗树
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // oldTab[j] 处是一个长度不超过 8 链表
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        /* 重点!!!
                        下面将根据 (e.hash & oldCap) == 0 将原来 oldTab[j] 处的链表分成
                        lo 和 hi 两个链表,为什么要这么分呢?
                        因为挂在 oldTab[j] 处的节点都是 hash % oldCap == j 的,但是现在,
                        hash % newCap 的结果有了以下两种可能:
                        - hash % newCap == j;
                        - hash % newCap == j + oldCap。
                        如何区分这两种情况呢?就是通过 (e.hash & oldCap) == 0 来区分的,
                        - 如果 (e.hash & oldCap) == 0,为 hash % newCap == j;
                        - 如果 (e.hash & oldCap) != 0,为 hash % newCap == j + oldCap。
                        */
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null) // 第一次执行 do-while 循环
                                loHead = e; // 用 loHead 记录 oldTab[j] 处链表的第一个 Node
                            else // 非第一次执行 do-while 循环
                                loTail.next = e; // 把当前节点 e 挂到 lo 链表上
                            loTail = e; // 移动 lo 链表的尾结点指针到当前节点 e
                        }
                        else { // hi 链表的处理方式和上面的 lo 链表一样
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) { // 如果 lo 链表不为空
                        loTail.next = null;
                        newTab[j] = loHead; // 把 lo 链表挂到 newTab[j] 上
                    }
                    if (hiTail != null) { // 如果 hi 链表不为空
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead; // 把 hi 链表挂到 newTab[j + oldCap] 上
                    }
                }
            }
        }
    }
    return newTab;
}

容量、负载因子和树化

容量和负载因子决定了桶数组中的桶数量,如果桶太多了会浪费空间,但桶太少又会影响性能。我们要保证:

负载因子 * 容量 > 元素数量 && 容量要是 2 的倍数

对于负载因子:

  • 如果没有特别需求,不要轻易更改;
  • 如果需要调整,不要超过 0.75,否则会显著增加冲突;
  • 如果使用太小的负载因子,也要同时调整容量,否则可能会频繁扩容,影响性能。

那么为什么要树化呢?

这本质上是一个安全问题,如果同一个哈希值对应位置的链表太长,会极大的影响性能,而在现实世界中,构造哈希冲突的数据并不是十分复杂的事情,恶意代码可以利用这些数据与服务端进行交互,会导致服务端 CPU 大量占用,形成哈希碰撞拒绝服务攻击。

相关推荐
所待.3835 分钟前
JavaEE之线程初阶(上)
java·java-ee
Winston Wood8 分钟前
Java线程池详解
java·线程池·多线程·性能
手握风云-13 分钟前
数据结构(Java版)第二期:包装类和泛型
java·开发语言·数据结构
喵叔哟32 分钟前
重构代码中引入外部方法和引入本地扩展的区别
java·开发语言·重构
尘浮生38 分钟前
Java项目实战II基于微信小程序的电影院买票选座系统(开发文档+数据库+源码)
java·开发语言·数据库·微信小程序·小程序·maven·intellij-idea
不是二师兄的八戒1 小时前
本地 PHP 和 Java 开发环境 Docker 化与配置开机自启
java·docker·php
爱编程的小生1 小时前
Easyexcel(2-文件读取)
java·excel
带多刺的玫瑰2 小时前
Leecode刷题C语言之统计不是特殊数字的数字数量
java·c语言·算法
计算机毕设指导62 小时前
基于 SpringBoot 的作业管理系统【附源码】
java·vue.js·spring boot·后端·mysql·spring·intellij-idea
Gu Gu Study2 小时前
枚举与lambda表达式,枚举实现单例模式为什么是安全的,lambda表达式与函数式接口的小九九~
java·开发语言