周会材料:高并发程序设计<二>

第三章 JDK并发包https://www.cnblogs.com/sean-zeng/p/11957569.html

JDK内部提供了大量实用的API和框架。本章主要介绍这些JDK内部功能,主要分为3大部分:

首先,介绍有关同步控制的工具,之前介绍的synchronized就是一种同步控制手段,将介绍更加丰富的多线程控制方法。

其次,将详细介绍JDK对线程池的支持,使用线程池,将很大程度提高线程调度的性能。

第三,介绍JDK的一些并发容器。这些容器专为并行访问所设计,绝对是高效、安全、稳定的实用工具。

其结构如下:

多线程团队协作:同步控制

之前提到的synchronized是最简单的同步控制的方法。本节中,首先介绍synchronized、Object.wait()、Object.notify()方法的替代品(或者说增强版)——重入锁

简而言之, 就是自由度更高的synchronized, 主要具备以下优点.

  • 可重入: 单线程可以重复进入,但要重复退出(锁两次释放两次)
  • 可中断: lock.lockInterruptibly()
  • 可限时: lock.tryLock()超时不能获得锁,就返回false,不会永久等待构成死锁
  • 公平锁: 先来先得, public ReentrantLock(boolean fair), 默认锁不公平的, 根据线程优先级竞争.

方法整理

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,返回true,失败返回false。不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
  • unlock():释放锁。

重入锁(ReentrantLock):synchronized的功能扩展

jdk6.0之前,重入锁的性能远远好于synchronized,但是之后,两者差距并不大。

下面是一段重入锁使用案例:

public class ReenterLock implements Runnable{ public static ReentrantLock lock=new ReentrantLock(); public static int i=0; @Override public void run() { for(int j=0;j<10000000;j++){ lock.lock(); //1 try{ i++; }finally{ lock.unlock(); //2 } } } public static void main(String[] args) throws InterruptedException { ReenterLock tl=new ReenterLock(); Thread t1=new Thread(tl); Thread t2=new Thread(tl); t1.start();t2.start(); t1.join();t2.join(); System.out.println(i); } }

1处加锁,2处释放锁。

重入锁是可以让线程反复进入的,这里的反复仅仅局限于一个线程。可以写成下面的形式:

lock.lock();
lock.lock(); try{ i++; }finally{ lock.unlock(); lock.unlock(); }

这种情况下,一个线程连续两次获得同一把锁,这是允许的!同时,释放也必须释放两次,释放次数多了,抛出异常,次数少了,相当于线程还持有当前锁,其他线程无法进入临界区。

重入锁除了灵活,还提供了中断处理的能力:

中断响应

对于synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它保持等待。而重入锁提供了另一种可能,那就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做很有必要。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无需再等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的。

下面代码产生了一个死锁,但得益于锁中断,我们可以轻松解决这个死锁:

public class IntLock implements Runnable { //重入锁ReentrantLock public static ReentrantLock lock1 = new ReentrantLock(); public static ReentrantLock lock2 = new ReentrantLock(); int lock; public IntLock(int lock) { this.lock = lock; } @Override public void run() { // TODO Auto-generated method stub try { if (lock == 1) { lock1.lockInterruptibly(); //1 Thread.sleep(500); lock2.lockInterruptibly(); System.out.println("lock1 is working...."); } else { lock2.lockInterruptibly(); Thread.sleep(500); lock1.lockInterruptibly(); System.out.println("lock2 is working...."); } } catch (Exception e) { e.printStackTrace(); } finally { if (lock1.isHeldByCurrentThread()) { lock1.unlock(); //释放锁 } if (lock2.isHeldByCurrentThread()) { lock2.unlock(); } System.out.println(Thread.currentThread().getId() + ":线程退出"); } } public static void main(String[] args) throws InterruptedException { IntLock r1 = new IntLock(1); IntLock r2 = new IntLock(2); Thread t1 = new Thread(r1); Thread t2 = new Thread(r2); t1.start(); t2.start(); Thread.sleep(1000); t2.interrupt(); //2 } }

线程t1和t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。这很容易照成t1、t2互相等待,形成死锁。这里,统一使用1处的lockInterruptibly()方法,这是一个可以对中断进行相应的锁申请动作,即在等待锁的过程中,可以响应中断。

在2处,t2线程被中断,放弃对lock1的锁申请,同时释放已获得的lock2。这时t1就能顺利执行完剩余程序

锁申请等待限时

除了外部通知之外,避免死锁还有另外一种方法,就是限时等待。我们可以使用tryLock()方法进行一次限时的等待。

复制public class TimeLock implements Runnable { public static ReentrantLock lock = new ReentrantLock(); @Override public void run() { try { if (lock.tryLock(5, TimeUnit.SECONDS)) { //2 Thread.sleep(6000); //1 } else { System.out.println("get lock failed"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { if(lock.isHeldByCurrentThread()) lock.unlock(); } } public static void main(String[] args) { TimeLock tl = new TimeLock(); Thread t1 = new Thread(tl); Thread t2 = new Thread(tl); t1.start(); t2.start(); } }

在这里2处,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示计时单位。没个进入临界区的线程需要占用6秒的锁(1处),而t2由于等待5秒没有等到想要的锁(2处),便返回false。若等待时间改为比5秒大,将返回true,并获得锁。

公平锁

在大多数情况下,锁的申请是非公平的。系统知识随机挑选一个,不保证其公平性。公平的锁,会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是:不会产生饥饿现象。我们使用synchronized关键字得到的就是非公平锁,而重入锁可以对公平性设置。它有一个构造函数:

复制 public ReentrantLock(boolean fair)     //为true时是公平锁

实现公平锁要维护一个有序队列,因此实现公平锁的成本较高,性能相对低下,因此,默认情况下,锁时非公平的。

复制public class FairLock implements Runnable{ //创建公平锁 private static ReentrantLock lock=new ReentrantLock(true); //1 public void run() { while(true){ lock.lock(); try{ System.out.println(Thread.currentThread().getName()+"获得锁"); }finally{ lock.unlock(); } } } public static void main(String[] args) { FairLock lft=new FairLock(); Thread th1=new Thread(lft); Thread th2=new Thread(lft); th1.start(); th2.start(); } }/** Thread-0获得锁 Thread-1获得锁 Thread-0获得锁 Thread-1获得锁 Thread-0获得锁 Thread-1获得锁 */

你运行上面的程序,会看到结果很有规律。

如果不使用公平锁,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的。但是无公平性可言,将上面1中的true改成false即可。

对ReentrantLock的几个重要方法整理如下:

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,返回true,失败返回false。不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
  • unlock():释放锁。

重入锁的实现

就重入锁的实现来看,它主要集中在java层面。主要包含三个要素:

  • 第一,是原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有。
  • 第二,是等待队列。
  • 第三,是阻塞语句park()和unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。有关park()和unpark()的详细介绍,可以参考线程阻塞工具类:LockSupport。

重入锁的好搭档:Condition条件

Condition的作用和wait()和notify()方法的作用是大致相同的。不同的是wait()和notify()方法是和synchronized关键字合作使用的,而Condition是与重入锁合作的。通过Lock接口(重入锁实现了该接口)的newCondition()方法可以生成一个与当前重入锁绑定的Condition实例。

Condition接口提供的基本方法:

  • await():使当前线程等待,同时释放当前锁,当其他线程使用signal()或者signalAll()方法时,线程会重新获得锁并继续执行。当线程中断时,也能跳出等待,和Object.wait()非常相似。
  • awaitUninterruptibly():与await()基本相同,但是不会响应等待过程中的中断。
  • signal():唤醒一个等待中的线程,signalAll()会唤醒所有等待中的线程。

下面是Condition的演示:

复制public class ReenterLockCondition implements Runnable { public static ReentrantLock lock = new ReentrantLock(); public static Condition condition = lock.newCondition(); @Override public void run() { try { lock.lock(); System.out.println("Thread is start..."); condition.await(); System.out.println("Thread is going on"); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { ReenterLockCondition tl = new ReenterLockCondition(); Thread t1 = new Thread(tl); t1.start(); Thread.sleep(2000); //通知线程t1继续执行 lock.lock(); //1 condition.signal(); lock.unlock(); } }

和Object.wait()和notify()方法一样,当线程使用Condition.await()时,要求线程持有相关的重入锁,在Condition.await()调用后,这个线程会释放这把锁。同理,在Condition.signal()方法调用时,也要求线程先获得相关的锁。在siganl()方法调用后,系统会从当前Condition对象的等待队列中,唤醒一个线程,一旦线程被唤醒,它会重新尝试获得与之绑定的重入锁,一旦成功获得,就可以继续执行了。因此,一般调用完condition.signal()后,都需要释放重入锁。

允许多个线程同时访问:信号量(Semaphore)

广义上讲,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentrantLock,一次都只允许一个线程访问一个资源,而信号量可以指定多个线程,同时访问某个资源

主要提供了两个构造函数:

复制public Semaphore(int permits)
public Semaphore(int permits, boolean fair) //第二个参数可以指定是否公平

在构造信号量对象时,必须要指定信号量的准入数,即同时能申请多少个许可。信号量的主要逻辑方法有:

复制public void acquire()
public void acquireUninterruptibly() public boolean tryAcquire() public boolean tryAcquire(long, TimeUnit unit) public void release()
  • acquire():尝试获得一个准入的许可。若无法获得,则线程会等待,直到有线程释放一个许可或当前线程被中断。
  • acquireUninterruptibly():和acquire()方法类似,但是不响应中断。
  • tryAcquire():尝试获得一个许可,立即返回结果
  • release():释放一个许可。
复制public class SemapDemo implements Runnable{ final Semaphore semp = new Semaphore(5); //3 @Override public void run() { try { semp.acquire(); //1 //模拟耗时操作 Thread.sleep(2000); System.out.println(Thread.currentThread().getId()+":done!"); //2 semp.release(); //4 } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) { ExecutorService exec = Executors.newFixedThreadPool(20); final SemapDemo demo=new SemapDemo(); for(int i=0;i<20;i++){ exec.submit(demo); } } }

上述代码中,1处到2处为临界区管理代码,程序会限制这段代码的线程数。在第3处,申明了一个包含5个许可的信号量。这意味着1~2处只能同时有5个线程进入。线程在使用完acquire(),在离开时,务必使用release()释放信号量。这和释放锁是一个道理。

读写锁:ReadWriteLock

ReadWriteLock是JDK5中提供的读写分离锁。读写分离锁可以有效地减少锁竞争,以提升系统性能。用锁分离的机制来提升性能很容易理解,如果使用重入锁或内部锁,理论上所有读—读、读—写、写—写都是串行操作。而读写锁,允许多个线程同时读

比如A1、A2、A3进行写操作,B1、B2、B3进行读操作。读写锁允许B1、B2、B3之间并行。但是,考虑数据完整性,写写操作和读写操作间依然是需要相互等待和持有锁的。总结如下:

  • 读-读不互斥:可并行;

  • 读-写互斥;

  • 写-写互斥;

    复制public class ReadWriteLockDemo { private static Lock lock = new ReentrantLock(); private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private static Lock readLock = readWriteLock.readLock(); private static Lock writeLock = readWriteLock.writeLock(); private int value; public Object handleRead(Lock lock) throws InterruptedException { try { lock.lock(); // 模拟读操作 Thread.sleep(1000); // 读操作的耗时越多,读写锁的优势越明显 System.out.println(Thread.currentThread().getName()+" read end!"); return value; } finally { lock.unlock(); } } public void handleWrite(Lock lock, int index) throws InterruptedException { try { lock.lock(); // 模拟写操作 Thread.sleep(1000); System.out.println(Thread.currentThread().getName()+" wrait end!"); value = index; } finally { lock.unlock(); System.out.println(value); } } public static void main(String[] args) { // TODO Auto-generated method stub final ReadWriteLockDemo demo = new ReadWriteLockDemo(); Runnable readRunnable = () -> { // TODO Auto-generated method stub try { demo.handleRead(readLock); // demo.handleRead(lock); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }; Runnable writeRunnable = () -> { // TODO Auto-generated method stub try { demo.handleWrite(writeLock, new Random().nextInt()); // demo.handleWrite(lock, new Random().nextInt()); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }; for (int i = 0; i < 18; i++) { new Thread(readRunnable).start(); //1 } for (int i = 18; i < 20; i++) { new Thread(writeRunnable).start(); //2 } } }

上面代码中,读和写的线程使用耗时的操作来模拟,在1处开启同时读的线程,可以从结果看出读的速度可以是并行的,而2处则不行。

倒计时锁:CountDownLatch

这个工具称为倒计数器:通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

使用场景:比如火箭就很适合使用CountDownLatch。火箭发射前,往往要进行各项设备、仪器检查,只有检查完毕后,引擎才能点火。

CountDownLatch的构造函数接收一个整数,即当前这个计数器的计数个数:

复制public CountDownLatch(int count)

演示:

复制public class CountDownLatchDemo implements Runnable { static final CountDownLatch end = new CountDownLatch(10); //1 static final CountDownLatchDemo demo = new CountDownLatchDemo(); @Override public void run() { try { //模拟检查任务 Thread.sleep(new Random().nextInt(10) * 1000); System.out.println("check complete"); end.countDown(); //2 } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) throws InterruptedException { ExecutorService exec = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { exec.submit(demo); } //等待检查 end.await(); //3 //发射火箭 System.out.println("Fire!"); exec.shutdown(); } }

在1处,生成一个CountDownLatch,计数数量为10,表示需要10个线程完成任务,等待在CountDownLatch上的线程才能继续执行。2处表示一个线程已完成,计数器减一。在3处,要求主线程等待10个线程全部完成任务后,主线程才继续执行。

111_2019-08-26_15-58-26

主线程在CountDownLatch上等待,当所有检查任务全部完成后,主线程方能继续执行。

循环栅栏:CyclicBarrier

循环栅栏(CyclicBarrier)和倒计时锁(CountDownLatch)非常类似:只是循环栅栏的计数器可以反复使用。比如假设我们将计数器设置为10,那么凑齐第一批10个线程后,计数器就会归零,然后接着凑齐下一批10个线程,这就是循环栅栏的内在含义。

使用场景:比如司令下达命令,要10个士兵去完成一项任务,士兵要先集合报道完,接着去执行任务。当10个士兵把手头任务都执行完成了,司令才能对象宣布,任务完成!

这里有两步:1,士兵集合报道;2,士兵把任务完成。当这两步先后完成,司令才认为任务完成。

构造函数:比CountDownLatch稍微强大一些。CyclicBarrier可以接收一个参数作为barrierAction(系统当计数器一次计数完成后,系统会执行的动作):

复制public class CyclicBarrierDemo { public static class Soldier implements Runnable { private String soldier; private final CyclicBarrier cyclic; Soldier(CyclicBarrier cyclic, String soldierName) { this.cyclic = cyclic; this.soldier = soldierName; } public void run() { try { //士兵报道 System.out.println(soldier + " 报道"); //等待所有士兵到齐 cyclic.await(); //2 doWork(); //等待所有士兵完成任务 cyclic.await(); //3 } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } void doWork() { try { Thread.sleep(Math.abs(new Random().nextInt() % 10000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(soldier + ":任务完成"); } } public static class BarrierRun implements Runnable { boolean flag; int N; public BarrierRun(boolean flag, int N) { this.flag = flag; this.N = N; } public void run() { if (flag) { System.out.println("司令:[士兵" + N + "个,任务完成!]"); } else { System.out.println("司令:[士兵" + N + "集合完毕]"); flag = true; } } } public static void main(String args[]) { final int N = 10; Thread[] allSoldier = new Thread[N]; boolean flag = false; CyclicBarrier cyclic = new CyclicBarrier(N, new BarrierRun(flag, N)); //1 //设置屏障点,主要是为了执行这个方法 System.out.println("队伍集合"); for (int i = 0; i < N; ++i) { allSoldier[i] = new Thread(new Soldier(cyclic, "士兵 " + i)); allSoldier[i].start(); //4 } } }

在1处,创建了CyclicBarrier实例,并将计数器设置为10,并要求在计数器达到指标时,执行BarrierRun。在2处,每一个士兵线程都会等待,知道所有士兵集合完毕,集合完毕后,意味着CyclicBarrier的一次计数完成,当再一次调用CyclicBarrier()时,会进行下一次计数。在3处,会等待所有士兵完成任务。还可以第三次第四次调用 cyclic.await();

整个工作过程图示:

2222_2019-08-26_17-02-53

CyclicBarrier.await可能会抛出两个异常,第一是中断异常,可以响应外部紧急事件。大部分迫使线程等待的方法都可能抛出这个异常。第二是它特有的BrokenBarrierException,这个异常说明当前的CyclicBarrier已经破损了,可能没有办法等待所有线程到齐了。如果继续等待,就白等了。

可以在4处上方插入:

复制if (i == 5)
    allSoldier[0].interrupt();

这样做,我们可以得到1个中断异常和9个BrokenBarrierException,1个士兵处于中断,其他9个需要等待这个线程,抛出BrokenBarrierException可以避免其他9个线程进行永久的,无谓的等待。

线程阻塞工具类:LockSupport

LockSupport是一个很实用的线程阻塞工具,可以在线程的任何位置让线程阻塞。

和Thread.suspend()相比,它尼补了由于resume()在前生成的导致线程无法继续执行的问题。和Object.wait()相比,它不需要先获得某个对象锁,也不会抛出InterruptedException。

复制public class LockSupportDemo { public static Object u = new Object(); static ChangeObjectThread t1 = new ChangeObjectThread("t1"); static ChangeObjectThread t2 = new ChangeObjectThread("t2"); public static class ChangeObjectThread extends Thread { public ChangeObjectThread(String name){ super.setName(name); } @Override public void run() { synchronized (u) { System.out.println("in "+getName()); LockSupport.park(this); //1 } } } public static void main(String[] args) throws InterruptedException { t1.start(); Thread.sleep(1000); t2.start(); LockSupport.unpark(t1); //2 LockSupport.unpark(t2); //3 t1.join(); t2.join(); } }

我们将原来的suspend和resume方法用park()和unpark()代替,在1处,我们挂起了当前线程,在2处,我们分别继续执行t1和t2,从结果可以看出,它不会因为unpark在park执行前而导致线程永久挂起。

为什么LockSupport不会导致线程永久挂起?

因为LockSupport使用了类似信号量的机制(不同的是不能累加),它为每个线程准备了一个许可。

  • 若许可可用—>park()会立即返回,将许可变为不可用—>线程阻塞;
  • 调用unpark()—>使许可变为可用

这个特点使得:即使unpark()操作发生在park()之前,它也可以使下一次park()操作立即返回。这就是不会导致线程永久挂起的原因。

同时,处于park()挂起状态的线程不会像suspend()那样给出令人费解的Runnable状态,它会非常明确的给出一个WAITING状态,甚至会标注是park()引起的。这让问题很容易分析。

1111_2019-08-27_10-11-12

LockSupport除了阻塞功能外,还支持中断响应。但是和其他接收中断的函数不一样,它不抛出中断异常,而是默默返回,但可以从Thread.interrupted()等方法获得中断标记。

复制public class LockSupportIntDemo { public static Object u = new Object(); static ChangeObjectThread t1 = new ChangeObjectThread("t1"); static ChangeObjectThread t2 = new ChangeObjectThread("t2"); public static class ChangeObjectThread extends Thread { public ChangeObjectThread(String name) { super.setName(name); } public void run() { synchronized (u) { System.out.println("in " + getName()); LockSupport.park(); if (Thread.interrupted()) { System.out.println(getName() + " 被中断了!"); } } System.out.println(getName() + " 执行结束"); } } public static void main(String[] args) throws InterruptedException { t1.start(); Thread.sleep(100); t2.start(); t1.interrupt(); LockSupport.unpark(t2); } }

线程复用:线程池

多线程在多核的处理下有助于性能,但如果不加控制的使用线程,反而会对系统性能产生不利影响。

为什么会造成不利影响?

  1. 线程创建和关闭需要花费时间,少数不要紧,系统级别的(线程很多的)就很耗时了。
  2. 线程本身需要占用内存。有抛出out of memory的危险

在实际生产环境中,线程的数量必须得到控制。

什么是线程池

联想一下数据库连接池,就知道线程池是啥了。

在线程池中,总有那么几个活跃的线程。当需要时,就从池中取出空闲线程,当完成工作后,再还回去,方便其他人使用。

JDK对线程池的支持

JDK提供了一套Executor框架,用来对线程池的支持。它的核心成员如下:

Executor框架结构_2019-08-27_11-14-53

上面是jdk并发包的核心类。其中ThreadPoolExecutor表示一个线程池。Excecutor扮演线程池工厂的角色,通过它可以取得一个拥有特定功能的线程池。

Executors提供了各种类型的线程池:

线程池类型 作用
newFixedThreadPool() 返回固定线程数量的线程池。线程池中线程数量保持不变。有新任务时,有空闲线程,则执行。若没有则暂存一个任务队列,等到有空闲线程,再执行
newSingleThreadExecutor() 返回只有一个线程的线程池。若有多余任务提交线程池,则存入任务队列,待线程空闲,按先入先出的顺序执行任务队列中的任务。
newCachedThreadPool() 返回一个可根据实际情况调整线程数量的线程池。若有空闲线程可用,则用空闲线程。若没有,创建新的线程处理任务。所有线程完成任务后,将返回线程池进行复用。
newSingleThreadScheduledExecutor() 返回一个ScheduleExecutorService对象,线程池大小为1。它会在给定时间执行某任务。如在固定延时之后,或周期性执行某个任务
newScheduledThreadPool() 返回ScheduleExecutorService对象,但可以指定线程池数量

1、固定大小的线程池

以newFixedThreadPool()为例,简单展示线程池的使用:

复制public class ThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { MyTask task = new MyTask(); ExecutorService es = Executors.newFixedThreadPool(5); //1 for (int i = 0; i < 10; i++) { es.submit(task); //2 } } }

在1处,创建了一个固定大小的线程池,内有5个线程。在2处,依次向线程池提交了10个任务。

上面程序,前5个任务和后5个任务的执行时间正好相差1秒。

2、计划任务

newScheduledThreadPool()。它返回一个ScheduledExecutorService对象,可以根据时间需要进行调度,它其实起到了计划任务的作用。它的一些主要方法如下:

复制public ScheduledFuture<?> schedule(Runnable command, long delay, 
          TimeUnit unit);
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
          long initialDelay, long period, TimeUnit unit); public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);

scheduleAtFixedRate()和scheduleWithFixedDelay()都会对任务进行周期性调度,不过它们有小小区别:

FixedRate和FixedDelay的区别_2019-08-27_14-28-23

下面是scheduleAtFixedRate()的例子,任务执行1秒,调用周期2秒。即每2秒,任务会被执行一次。

复制public class ScheduledExecutorServiceDemo { public static void main(String[] args) { ScheduledExecutorService ses = Executors.newScheduledThreadPool(10); ses.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { Thread.sleep(1000); System.out.println(System.currentTimeMillis()/1000); } catch (InterruptedException e) { e.printStackTrace(); } } }, 0, 2, TimeUnit.SECONDS); } }

上面的执行结果是每次打印时间间隔为2秒。

那如果任务的执行时间超过调度时间,会发生什么呢?会出现堆叠的情况吗,不会,若出现这种情况,任务的周期将变成8秒,即任务完成那一刻才开始下一次任务的调度。

如果采用scheduleWithFixedDelay(),任务的实际间隔将是10秒。

刨根究底:核心线程池的内部实现

对于核心的几个线程池,其内部都使用了ThreadPoolExecutor实现。这里就不给出它们的实现方式了。

下面是ThreadPoolExecutor最重要的构造函数:

复制public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

参数含义:

  • corePoolSize:指定线程池中的线程数量。
  • maximumPoolSize:指定了线程池中的最大线程数量。
  • keepAliveTime:超过corePoolSize的空闲线程,在多长时间内,会被销毁。
  • unit:keepAliveTime的单位。
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:线程工厂,用来创建线程,用默认的即可。
  • handler:拒绝策略。太多任务而处理不及时,如何拒绝任务。

参数workQueue:被提交但未被执行的任务队列,它是一个BlockingQueue接口的对象,仅用于存放Runnable对象。ThreadPoolExecutor的构造函数中可以使用下面几种BlockingQueue:

  • 直接提交的队列:该功能由SynchronousQueue提供。SynchronousQueue没有容量,若使用它,提交的任务不会被真实的保存,而总是将任务提交给线程执行。若没有空线程,则创建;若线程数量达到顶峰,则执行拒绝策略。通常,使用SynchronousQueue需要很大的maximumPoolSize值,否则很容易执行拒绝策略。

  • 有界的任务队列:可以使用ArrayBlockingQueue实现。它的构造函数必须带一个容量参数,表示最大容量。若实际线程数小于corePoolSize,则优先创建新线程,若大于corePoolSize,则将新任务加入等待队列。若等待队列已满,在总线程数<=maximumPoolSize的前提下,创建新的进行执行任务。若>maximumPoolSize,执行拒绝策略。

    可见,有界队列仅在任务队列装满时,才肯呢过将线程数提升至corePoolSize以上。换句话说,除非系统非常繁忙,否则核心线程数维持在corePoolSize。

  • 无界的任务队列:可通过LinkedBlockingQueue实现。与有界队列相比,除非系统资源耗尽,否则无界队列不存在任务入队失败的情况。

    当系统线程数<corePoolSize,创建新线程;

    若>=corePoolSize,不增加新线程,加入等待队列,若任务创建和处理速度差异太大,无界队列会保持快速增长,知道耗尽系统内存。

  • 优先任务队列(带有执行优先级的队列):通过PriorityBlockingQueue实现,可以控制任务的执行前后顺序。高优先级的任务线执行。

回顾一下:

newFixedThreadPool()方法:它的corePoolSize和maximumPoolSize大小一样,因为固定大小的线程池不存在线程数量的动态变化。同时,它使用无界队列存放任务列表,从而在任务提交频繁的情况下有可能耗尽系统资源。

newSingleThreadExecutor()返回单线程线程池,是newFixedThreadPool()的退化,只是简单将线程数设为1。

newCachedThreadPool()方法返回corePoolSize为0,maximumPoolSize无穷大的线程池。刚开始该线程池无线程,它会将提交的线程加入SynchronousQueue,这是一种立即提交的队列,它会迫使线程池增加新的线程执行任务。当任务执行完毕,在60秒内将线程池不用的线程回收(不留任何空闲线程)。因此,当同时有大量任务提交时,任务执行又不快,那么系统便会开启灯亮线程处理,很快就会耗尽系统资源。

超负载了?使用拒绝策略

JDK内置的拒绝策略:

  • AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作。
  • CallerRunsPolicy策略:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的线程。显然,不会真正丢弃任务,但是,任务提交线程的性能可能急剧下降。
  • DiscardOledestPolicy策略:该策略将丢弃最老的一个请求,即将要执行的一个任务,并尝试再次提交当前任务。
  • DiscardPolicy策略:该策略默默丢弃无法处理的任务,不予任何处理。(若允许丢失,可能是最好的一种方式了)

可以自己扩展RejectedExecutionHandle接口实现自己的拒绝策略,下面代码简单演示了自定义线程池和拒绝策略的使用:

复制public class RejectThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { MyTask task = new MyTask(); ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), Executors.defaultThreadFactory(), new RejectedExecutionHandler(){ @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { System.out.println(r.toString()+" is discard"); } }); //1 for (int i = 0; i < Integer.MAX_VALUE; i++) { es.submit(task); Thread.sleep(10); } } }

上述1处自定义了一个线程池。该线程池有5个常驻线程,并且最大线程数量也是5个。这和固定大小的线程池是一样的。但是它却拥有一个只有10个容器的等待队列。在这里,我们自定义了拒绝策略,只是比DiscardPolicy高级一点点,把拒绝的信息打印出来,在实际应用中,我们可以将其记录到日志上。用来分析系统的负载和任务丢失情况。

自定义线程创建:ThreadFactory

线程池的主要作用是为了线程复用,也就是避免了线程的频繁创建。但是,线程池最开始的线程从何而来呢?答案就是ThreadFactory。

ThreadFactory是一个接口,它只有一个方法,用来创建线程:

复制Thread newThread(Runnable r);

当线程池需要新建线程时,就会调用这个方法。

我们使用自定义线程可以更自由地设置池子中所有线程的状态,甚至可以设置为守护线程:

复制public class TFThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { MyTask task = new MyTask(); ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), new ThreadFactory(){ @Override public Thread newThread(Runnable r) { Thread t= new Thread(r); t.setDaemon(true); System.out.println("create "+t); return t; } } ); for (int i = 0; i < 5; i++) { es.submit(task); } Thread.sleep(2000); } }

扩展线程池

虽然JDK已经帮我们实现了这个稳定的高性能线程池。但如果我们需要对线程池进行一些扩展。比如,想监控每个任务执行的开始和结束时间,或者其他一些自定义增强功能,怎么办呢?

ThreadPoolExecutor:它也是一个可以扩展的线程池。它提供了beforeExecute()、afterExecute()和terminated()三个接口对线程池进行控制。

在默认的ThreadPoolExecutor实现中,提供了空的beforeExecute()、afterExecute()实现。在实际引用中,可以对其扩展实现对线程池运行状态的跟踪,输出一些有用的调试信息,用以帮助系统故障诊断。下面演示对线程池的扩展,在这个扩展中,将记录每一个任务的执行日志:

复制public class ExtThreadPool { public static class MyTask implements Runnable { public String name; public MyTask(String name) { this.name = name; } @Override public void run() { System.out.println("正在执行" + ":Thread ID:" + Thread.currentThread().getId() + ",Task Name=" + name); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()) { @Override protected void beforeExecute(Thread t, Runnable r) { System.out.println("准备执行:" + ((MyTask) r).name); } @Override protected void afterExecute(Runnable r, Throwable t) { System.out.println("执行完成:" + ((MyTask) r).name); } @Override protected void terminated() { System.out.println("线程池退出"); } }; for (int i = 0; i < 5; i++) { MyTask task = new MyTask("TASK-GEYM-" + i); es.execute(task); Thread.sleep(10); } es.shutdown(); } }/** ...... 正在执行:Thread ID:13,Task Name=TASK-GEYM-2 准备执行:TASK-GEYM-3 正在执行:Thread ID:14,Task Name=TASK-GEYM-3 准备执行:TASK-GEYM-4 正在执行:Thread ID:15,Task Name=TASK-GEYM-4 执行完成:TASK-GEYM-0 执行完成:TASK-GEYM-1 执行完成:TASK-GEYM-2 执行完成:TASK-GEYM-3 执行完成:TASK-GEYM-4 线程池退出 */

可以看到,所有任务执行前后都捕获到了。这对于应用的调试和诊断是非常有帮助的。

合理的选择:优化线程池线程数量

线程池的大小对系统的性能有一定的影响。过大或过小的线程数量都无法发挥最优的系统性能。但是也不用做得非常精确,只要避免极大和极小两种情况即可。

最优池的大小计算公式_2019-08-27_19-16-27

堆栈去哪里了:挖出线程池中被淹没的异常堆栈

先说明一下要解决的问题!

复制public class DivTask implements Runnable { int a, b; public DivTask(int a, int b) { this.a = a; this.b = b; } @Override public void run() { double re = a / b; //1 System.out.println(re); } public static void main(String[] args) { ThreadPoolExecutor pools = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); for(int i=0; i<5; i++) pools.submit(new DivTask(100, i)); // pools.execute(new DivTask(100, i)); } }/** 100.0 25.0 50.0 33.0 */

在上面程序中,只有四个输出结果,少了一个,然而没有报错信息。使用submit会出现这样的情况(execute会抛出异常,具体原因后面再看吧)。

再说下解决方案!

对于程序员来说,没有异常堆栈是最头疼的事。我们可以通过两种方法来讨回异常堆栈:

1 是放弃submit()改用execute(),如注释所示;

复制pools.execute(new DivTask(100, i));

2 是改造submit():

复制 Future<?> submit = pools.submit(new DivTask(100, i));
            submit.get();

以上两种都可以得到部分堆栈信息:

复制Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zero at java.util.concurrent.FutureTask.report(FutureTask.java:122) at java.util.concurrent.FutureTask.get(FutureTask.java:192) at geym.ch3.DivTask.main(DivTask.java:21) Caused by: java.lang.ArithmeticException: / by zero at geym.ch3.DivTask.run(DivTask.java:13) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at java.lang.Thread.run(Thread.java:745)

注意,上面说的是部分!我们只知道异常是在哪里抛出的,也就是代码的1处,但是不确定线程是在哪里提交的,任务的具体提交的位置被淹没了!

3、自己动手,扩展ThreadPoolExecutor线程池(彻底解决的办法)

为了少加班!我们还是自己动手,把堆栈的信息彻底挖出来吧!扩展我们的ThreadPoolExecutor线程池,让它在调度任务之前,先保存一下提交任务线程的堆栈信息。

复制public class TraceThreadPoolExecutor extends ThreadPoolExecutor { public TraceThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { super(corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue); } @Override public void execute(Runnable task) { // TODO Auto-generated method stub super.execute(wrap(task, clientTrace(), Thread.currentThread().getName())); //包装器 } @Override public Future<?> submit(Runnable task) { // TODO Auto-generated method stub return super.submit(wrap(task, clientTrace(), Thread.currentThread().getName())); } private Exception clientTrace() { return new Exception("Client stack trace"); } private Runnable wrap(final Runnable task,final Exception clientStack, String clientThreadName) { //1 return new Runnable() { @Override public void run() { try { task.run(); } catch (Exception e) { clientStack.printStackTrace(); try { throw e; } catch (Exception e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } } }; } public static class DivTask implements Runnable { int a,b; public DivTask(int a,int b) { this.a = a; this.b = b; } @Override public void run() { double re = a/b; System.out.println(re); } } public static void main(String[] args) { ThreadPoolExecutor pools = new TraceThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); for(int i=0; i<5; i++) pools.execute(new DivTask(100, i)); } }

在wrap()(1处)方法的第2个参数为一个异常,里面保存着提交任务的线程的堆栈信息。该方法将我们传入的Runnable任务进行一层包装,使之能处理异常信息。当任务发生异常时,这个异常会被打印。

分而治之:Fork/Join框架

fork()用来开启分支线程来处理任务,一般会提交给ForkJoinPool线程池进行处理,以节省系统资源。

Join()用来等待fork()的执行分支执行结束。

使用Fork/Join进行数据处理时的总体结构如图所示:

ForkJoin执行逻辑_2019-08-28_15-06-28

由于线程池的优化,提交的任务和线程数量不是一对一的关系。通常是一个线程处理多个任务,每个线程都有一个任务队列。当线程A把任务完成,而线程B还在有一堆任务处理时,线程A会帮助B。B从任务队列顶部拿数据,而A则是从任务队列的底部拿数据,这样有利于避免数据竞争。

互相帮助的线程_2019-08-28_15-13-28

ForkJoinPool的一个重要的接口,可以提交一个ForkJoinTask,ForkJoinTask支持fork()分解以及join()等待的任务,它有两个重要子类:RecursiveAction(无返回值)和RecursiveTask(返回v类型)。

复制public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task);

使用:

复制public class CountTask extends RecursiveTask<Long> { private static final int THRESHOLD = 10000; private long start; private long end; public CountTask(long start, long end) { this.start = start; this.end = end; } public Long compute() { long sum = 0; boolean canCompute = (end - start) < THRESHOLD; if (canCompute) { //求和总数小于THRESHOLD,直接求和 for (long i = start; i <= end; i++) { sum += i; } } else { //分成100个小任务 // 比如start=0,end=100,则每一小步计算2个数 //i=0,lastOne=0+2=2, pos=2+1=3 //i=1,lastOne=2+2=4, pos=4+1=5 //... //i=100 long step = (start + end) / 100; // long pos = start; for (int i = 0; i < 100; i++) { long lastOne = pos + step; if (lastOne > end) lastOne = end; CountTask subTask = new CountTask(pos, lastOne); pos = lastOne + 1; subTask.fork(); sum += subTask.join(); } } return sum; } public static void main(String[] args) { ForkJoinPool forkJoinPool = new ForkJoinPool(); CountTask task = new CountTask(0, 200000L); ForkJoinTask<Long> result = forkJoinPool.submit(task); //1 try { long res = result.get(); System.out.println("sum=" + res); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }

在1处,使用forkJoinPool提交了CountTask,CountTask构造一个计算1到200000求和的任务。在compute()方法中,遵循了下面的逻辑:

复制if (canCompute) {
    //求和总数够小,直接求和
} else {
    //分成若干个小任务 }

在使用ForkJoinPool时需要注意,如果任务的划分层次很深,一直没有返回,可能出现两种情况:

  1. 系统内线程数量越积越多,导致性能严重下降。
  2. 函数调用层次变得很深,导致栈溢出。

JDK的并发容器

JDK提供了好用的并发容器类,使用也很方便,这里主要讲讲这些工具的具体实现。

并发集合介绍

先简单认识一下并发集合:

  • ConcurrentHashMap:高效的并发HashMap。即线程安全的HashMap
  • CopyOnWriteArrayList:属于List,和ArrayList是一族的。在读多少写的场合性能非常好,远远好于Vector。
  • ConcurrentLinkedQueue:线程安全的LinkedList。
  • BlockingQueue:这是一个接口,JDK内部通过链表、数组等方式实现这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap:跳表的实现。这是一个Map,使用跳表的数据结构进行快速查找。
  • Vector也是线程安全的,另外Collections工具类可以帮助我们将任意集合包装成线程安全的工具类

线程安全的HashMap:ConcurrentHashMap

如果获得一个线程安全的HashMap?

第一种方法是:使用Collections.synchronizedMap()方法来包装HashMap

复制static Map<String, String> map = Collections.synchronizedMap(new HashMap<String, String>());

Collections.synchronizedMap()会生成一个名为SynchronizedMap的Map。它使用委托,将自己所有Map相关的功能交给传入的HashMap实现,自己则主要负责保证线程安全。

第二种方法是使用ConcurrentHashMap代替HashMap,这种方式更专业,更适合并发场合。

线程安全的List

Vector是线程安全的List,也可以使用Collections.synchronizedList()方法来包装任意List。

高效读写队列:ConcurrentLinkedQueue

ConcurrentLinkedQueue算是高并发中性能最好的队列了。

具体实现:

1、节点

作为一个链表,自然需要定义一个节点:

复制private static class Node<E>{ volatile E item; volatile Node<E> next;

item用来表示目标元素,比如:放入String,item就是String元素。next表示Node的下一个元素。这样Node就环环相扣,串在一起了。

2、CAS操作

首先,说明一下CAS操作的原理:CAS操作包含三个操作数—— 内存位置的值(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

CAS是一种乐观锁。乐观锁在少写的情况下适用,若是多写的情况,会导致CAS算法不断的进行retry,反而降低了系统性能,多写的情况适合适用悲观锁。

CAS操作_2019-08-29_09-49-50

casItem()表示设置当前Node的item值。cmp为期望值,第二个参数val为目标值。当当前值等于cmp期望值时,就会将目标值设置为val。第二个方法类似。只是它用来设置next字段。

ConcurrentLinkedQueue内部有两个重要的字段,head和tail,分别表示头部和尾部。tail的更新不是及时的,而是有延迟,每次更新会跳跃两个元素。如下图:

Snipaste_2019-09-05_19-09-42

原书中的源码分析我没怎么看懂,有看懂的童鞋欢迎在评论中分享心得

u=3405433144,4098183174&fm=26&gp=0

高效读取:不变模式下的CopyOnWriteArrayList

在很多应用场景中,读操作往往会远远大于写操作。所以这种情况下,我们希望读的性能好些,而写的性能差些也无所谓。

我们知道:在读写锁ReadWriteLock中,读读不互斥,而读写,写写是互斥的。

而现在,JDK还提供了另外一个读写工具类,将读取性能发挥到极致CopyOnWriteArrayList,它的读读不阻塞,读写也不会互相阻塞,只有写写需要同步等待。

它是怎么做到读写不阻塞的?

CopyOnWrite在写入操作时,对原有的数据进行复制成一个副本(而不修改原来的数据),将修改的内容写入复制后的副本中。写完后,再用副本替换原来的数据,这样就不会影响读了。

读取的实现:

Snipaste_2019-09-06_11-15-40

读取没有任何同步和锁的操作,理由是内部数组array不会发生修改,只会被另外一个array替换。

写人的实现:

复制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); //1 newElements[len] = e;//2 setArray(newElements);//3 return true; } finally { lock.unlock(); } }

首先,写入操作使用锁,这个锁仅限于控制写-写的情况。重点在1处,进行了内部元素的复制,而生成一个新数组newElements。2处,将新元素增加到数组末尾,然后,将新数组替换老数组,修改就完成了。整个过程不会影响读取,并且读取线程会实时查看到这个修改(array变量是volatile的)

数据共享通道:BlockingQueue

如何实现多个线程间的数据共享呢?比如,线程A希望给线程B发一个消息,用什么方式好呢?

我们希望线程A能够通知到线程B,又希望线程A不知道线程B的存在。这样对于以后线程B的升级或维护,而不用再修改线程A有帮助。为了实现这一点,我们可以使用一个中间件BlockingQueue来实现。它就相当于一个意见箱,用来作为发表意见者与接收意见者沟通的桥梁。

BlockingQueue和之前提到的ConcurrentLinkedQueue和CopyOnWriteArrayList不同,它是一个接口,而不是具体的实现。它的主要实现如下图:

Snipaste_2019-09-06_13-38-28

ArrayBlockingQueue:基于数组实现。更适合做有界队列,扩展比较不方便

LinkedBlockingQueue:基于链表。更适合做无界队列,因为其内部元素可动态增加。

BlockingQueue为什么适合作为数据共享的通道呢?原因在于Blocking(阻塞)。

当服务线程(指获取队列中消息并进行处理的线程)处理完队列中所有的消息后,服务线程是如何知道下一条消息的到来的?BlockingQueue会让服务线程在队列为空时,进行等待,当有新的消息进入队列后,自动将线程唤醒。

它是如何工作的?以ArrayBlockingQueue为例说明:

写入数据:

它有一个items,items就是用来存放数据的队列。offer()在列队满时,返回false。我们关注的是put()方法,put()也是将元素压入队列队尾,但队列满了,它会一直等待,直到队列中有空闲位置。

读取数据:

poll()、take()两个方法都能从队列中的头部弹出一个元素。不同的是:如果队列为空poll()方法直接返回null。而take()方法会等待,直到队列内有可用元素。

从上面可以看出,put()take()方法才是Blocking的关键。为了做好通知和等待两件事,ArrayBlockingQueue定义了三个字段:

Snipaste_2019-09-06_14-18-02

take()操作:

当队列为空时,让当前线程等待在notEmpty,新元素入队时,则进行一次notEmpty上的通知。

复制public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); //1 return dequeue(); } finally { lock.unlock(); } }

在1处,要求线程在notEmpty对象中等待。下面是元素入队的一段代码:

复制/**
 * 在当前put位置插入元素、进给和信号。
 * 只有在持有锁时才调用。
 */
private void enqueue(E x) { final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); //1 }

在1处,当新元素入列后,需要通知等待在notEmpty上的线程,让它们继续工作。

put()操作:

当队列满时,需要让 压入线程 等待:

复制public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); //1 enqueue(e); } finally { lock.unlock(); } }

在1处,队列满时,在notFull对象中等待。

当然,当元素从队列中挪走时,队列中有空位时,自然也要通知等待入队的线程:

复制private E dequeue() { 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(); //1 return x; }

我们还会在“5.3 生产者消费者”一节中,看到他们的身影。在那里,我们可以更清楚地看到如何使用BlockingQueue解耦生产者和消费者。

随机数据结构—跳表:SkipList

介绍跳表

除了常用的哈希表外,还有一种有趣的数据结构:跳表。跳表的本质是同时维护了多个链表,并且链表是分层的。跳表的查询性能要比哈希表好。如下图

跳表结构_2019-09-06_16-48-36

最低层的链表维护了跳表中所有的元素,每上面一层都是下面一层的子集,一个元素插入哪一层完全随机,运气不好可能得到性能最差的结构。但是实际工作中,它还是表现得很好的。

跳表内所有元素都是排序的。查找时,从顶级链表开始找,一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续查找。比如要查找上面跳表结构中的7。查找过程如下图所示:

跳表的查找过程_2019-09-06_16-55-07

跳表显然是一种空间换时间的算法。

使用跳表实现的Map和使用哈希算法实现的Map的另一个不同之处是:跳表实现的Map是会排序的,而哈希实现的Map不排序。若需要一个有序的Map,那就选择跳表。

使用:ConcurrentSkipListMap

实现这一数据结构的类是ConcurrentSkipListMap。简单使用:

复制Map<Integer, Integer> map = new ConcurrentSkipListMap<>();
for (int i = 0; i<30; i++) map.put(i,i); for (Map.Entry<Integer, Integer> entry: map.entrySet() ) { System.out.println(entry.getKey()); }

跳表有三个关键的数据结构组成:

  • Node<K,V>:(节点,含有key、value、next元素,对Node的所有操作,都使用CAS方法)
  • Index<K,V>:(表示索引,它的内部包装了node,同时增加了向下和向右的引用),整个跳表就是根据Index进行全网的组织的。
  • HeadIndex:表示链表头部的第一个Index。它继承自Index。

下面是三种数据结构的代码:

Node_2019-09-06_17-20-04

Index_2019-09-06_17-20-23

HeadIndex_2019-09-06_17-20-38

 
 

JDK内部提供了大量实用的API和框架。本章主要介绍这些JDK内部功能,主要分为3大部分:

首先,介绍有关同步控制的工具,之前介绍的synchronized就是一种同步控制手段,将介绍更加丰富的多线程控制方法。

其次,将详细介绍JDK对线程池的支持,使用线程池,将很大程度提高线程调度的性能。

第三,介绍JDK的一些并发容器。这些容器专为并行访问所设计,绝对是高效、安全、稳定的实用工具。

其结构如下:

多线程团队协作:同步控制

之前提到的synchronized是最简单的同步控制的方法。本节中,首先介绍synchronized、Object.wait()、Object.notify()方法的替代品(或者说增强版)——重入锁

简而言之, 就是自由度更高的synchronized, 主要具备以下优点.

  • 可重入: 单线程可以重复进入,但要重复退出(锁两次释放两次)
  • 可中断: lock.lockInterruptibly()
  • 可限时: lock.tryLock()超时不能获得锁,就返回false,不会永久等待构成死锁
  • 公平锁: 先来先得, public ReentrantLock(boolean fair), 默认锁不公平的, 根据线程优先级竞争.

方法整理

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,返回true,失败返回false。不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
  • unlock():释放锁。

重入锁(ReentrantLock):synchronized的功能扩展

jdk6.0之前,重入锁的性能远远好于synchronized,但是之后,两者差距并不大。

下面是一段重入锁使用案例:

public class ReenterLock implements Runnable{ public static ReentrantLock lock=new ReentrantLock(); public static int i=0; @Override public void run() { for(int j=0;j<10000000;j++){ lock.lock(); //1 try{ i++; }finally{ lock.unlock(); //2 } } } public static void main(String[] args) throws InterruptedException { ReenterLock tl=new ReenterLock(); Thread t1=new Thread(tl); Thread t2=new Thread(tl); t1.start();t2.start(); t1.join();t2.join(); System.out.println(i); } }

1处加锁,2处释放锁。

重入锁是可以让线程反复进入的,这里的反复仅仅局限于一个线程。可以写成下面的形式:

lock.lock();
lock.lock(); try{ i++; }finally{ lock.unlock(); lock.unlock(); }

这种情况下,一个线程连续两次获得同一把锁,这是允许的!同时,释放也必须释放两次,释放次数多了,抛出异常,次数少了,相当于线程还持有当前锁,其他线程无法进入临界区。

重入锁除了灵活,还提供了中断处理的能力:

中断响应

对于synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它保持等待。而重入锁提供了另一种可能,那就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做很有必要。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无需再等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的。

下面代码产生了一个死锁,但得益于锁中断,我们可以轻松解决这个死锁:

public class IntLock implements Runnable { //重入锁ReentrantLock public static ReentrantLock lock1 = new ReentrantLock(); public static ReentrantLock lock2 = new ReentrantLock(); int lock; public IntLock(int lock) { this.lock = lock; } @Override public void run() { // TODO Auto-generated method stub try { if (lock == 1) { lock1.lockInterruptibly(); //1 Thread.sleep(500); lock2.lockInterruptibly(); System.out.println("lock1 is working...."); } else { lock2.lockInterruptibly(); Thread.sleep(500); lock1.lockInterruptibly(); System.out.println("lock2 is working...."); } } catch (Exception e) { e.printStackTrace(); } finally { if (lock1.isHeldByCurrentThread()) { lock1.unlock(); //释放锁 } if (lock2.isHeldByCurrentThread()) { lock2.unlock(); } System.out.println(Thread.currentThread().getId() + ":线程退出"); } } public static void main(String[] args) throws InterruptedException { IntLock r1 = new IntLock(1); IntLock r2 = new IntLock(2); Thread t1 = new Thread(r1); Thread t2 = new Thread(r2); t1.start(); t2.start(); Thread.sleep(1000); t2.interrupt(); //2 } }

线程t1和t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。这很容易照成t1、t2互相等待,形成死锁。这里,统一使用1处的lockInterruptibly()方法,这是一个可以对中断进行相应的锁申请动作,即在等待锁的过程中,可以响应中断。

在2处,t2线程被中断,放弃对lock1的锁申请,同时释放已获得的lock2。这时t1就能顺利执行完剩余程序

锁申请等待限时

除了外部通知之外,避免死锁还有另外一种方法,就是限时等待。我们可以使用tryLock()方法进行一次限时的等待。

复制public class TimeLock implements Runnable { public static ReentrantLock lock = new ReentrantLock(); @Override public void run() { try { if (lock.tryLock(5, TimeUnit.SECONDS)) { //2 Thread.sleep(6000); //1 } else { System.out.println("get lock failed"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { if(lock.isHeldByCurrentThread()) lock.unlock(); } } public static void main(String[] args) { TimeLock tl = new TimeLock(); Thread t1 = new Thread(tl); Thread t2 = new Thread(tl); t1.start(); t2.start(); } }

在这里2处,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示计时单位。没个进入临界区的线程需要占用6秒的锁(1处),而t2由于等待5秒没有等到想要的锁(2处),便返回false。若等待时间改为比5秒大,将返回true,并获得锁。

公平锁

在大多数情况下,锁的申请是非公平的。系统知识随机挑选一个,不保证其公平性。公平的锁,会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是:不会产生饥饿现象。我们使用synchronized关键字得到的就是非公平锁,而重入锁可以对公平性设置。它有一个构造函数:

复制 public ReentrantLock(boolean fair)     //为true时是公平锁

实现公平锁要维护一个有序队列,因此实现公平锁的成本较高,性能相对低下,因此,默认情况下,锁时非公平的。

复制public class FairLock implements Runnable{ //创建公平锁 private static ReentrantLock lock=new ReentrantLock(true); //1 public void run() { while(true){ lock.lock(); try{ System.out.println(Thread.currentThread().getName()+"获得锁"); }finally{ lock.unlock(); } } } public static void main(String[] args) { FairLock lft=new FairLock(); Thread th1=new Thread(lft); Thread th2=new Thread(lft); th1.start(); th2.start(); } }/** Thread-0获得锁 Thread-1获得锁 Thread-0获得锁 Thread-1获得锁 Thread-0获得锁 Thread-1获得锁 */

你运行上面的程序,会看到结果很有规律。

如果不使用公平锁,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的。但是无公平性可言,将上面1中的true改成false即可。

对ReentrantLock的几个重要方法整理如下:

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,返回true,失败返回false。不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
  • unlock():释放锁。

重入锁的实现

就重入锁的实现来看,它主要集中在java层面。主要包含三个要素:

  • 第一,是原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有。
  • 第二,是等待队列。
  • 第三,是阻塞语句park()和unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。有关park()和unpark()的详细介绍,可以参考线程阻塞工具类:LockSupport。

重入锁的好搭档:Condition条件

Condition的作用和wait()和notify()方法的作用是大致相同的。不同的是wait()和notify()方法是和synchronized关键字合作使用的,而Condition是与重入锁合作的。通过Lock接口(重入锁实现了该接口)的newCondition()方法可以生成一个与当前重入锁绑定的Condition实例。

Condition接口提供的基本方法:

  • await():使当前线程等待,同时释放当前锁,当其他线程使用signal()或者signalAll()方法时,线程会重新获得锁并继续执行。当线程中断时,也能跳出等待,和Object.wait()非常相似。
  • awaitUninterruptibly():与await()基本相同,但是不会响应等待过程中的中断。
  • signal():唤醒一个等待中的线程,signalAll()会唤醒所有等待中的线程。

下面是Condition的演示:

复制public class ReenterLockCondition implements Runnable { public static ReentrantLock lock = new ReentrantLock(); public static Condition condition = lock.newCondition(); @Override public void run() { try { lock.lock(); System.out.println("Thread is start..."); condition.await(); System.out.println("Thread is going on"); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { ReenterLockCondition tl = new ReenterLockCondition(); Thread t1 = new Thread(tl); t1.start(); Thread.sleep(2000); //通知线程t1继续执行 lock.lock(); //1 condition.signal(); lock.unlock(); } }

和Object.wait()和notify()方法一样,当线程使用Condition.await()时,要求线程持有相关的重入锁,在Condition.await()调用后,这个线程会释放这把锁。同理,在Condition.signal()方法调用时,也要求线程先获得相关的锁。在siganl()方法调用后,系统会从当前Condition对象的等待队列中,唤醒一个线程,一旦线程被唤醒,它会重新尝试获得与之绑定的重入锁,一旦成功获得,就可以继续执行了。因此,一般调用完condition.signal()后,都需要释放重入锁。

允许多个线程同时访问:信号量(Semaphore)

广义上讲,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentrantLock,一次都只允许一个线程访问一个资源,而信号量可以指定多个线程,同时访问某个资源

主要提供了两个构造函数:

复制public Semaphore(int permits)
public Semaphore(int permits, boolean fair) //第二个参数可以指定是否公平

在构造信号量对象时,必须要指定信号量的准入数,即同时能申请多少个许可。信号量的主要逻辑方法有:

复制public void acquire()
public void acquireUninterruptibly() public boolean tryAcquire() public boolean tryAcquire(long, TimeUnit unit) public void release()
  • acquire():尝试获得一个准入的许可。若无法获得,则线程会等待,直到有线程释放一个许可或当前线程被中断。
  • acquireUninterruptibly():和acquire()方法类似,但是不响应中断。
  • tryAcquire():尝试获得一个许可,立即返回结果
  • release():释放一个许可。
复制public class SemapDemo implements Runnable{ final Semaphore semp = new Semaphore(5); //3 @Override public void run() { try { semp.acquire(); //1 //模拟耗时操作 Thread.sleep(2000); System.out.println(Thread.currentThread().getId()+":done!"); //2 semp.release(); //4 } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) { ExecutorService exec = Executors.newFixedThreadPool(20); final SemapDemo demo=new SemapDemo(); for(int i=0;i<20;i++){ exec.submit(demo); } } }

上述代码中,1处到2处为临界区管理代码,程序会限制这段代码的线程数。在第3处,申明了一个包含5个许可的信号量。这意味着1~2处只能同时有5个线程进入。线程在使用完acquire(),在离开时,务必使用release()释放信号量。这和释放锁是一个道理。

读写锁:ReadWriteLock

ReadWriteLock是JDK5中提供的读写分离锁。读写分离锁可以有效地减少锁竞争,以提升系统性能。用锁分离的机制来提升性能很容易理解,如果使用重入锁或内部锁,理论上所有读—读、读—写、写—写都是串行操作。而读写锁,允许多个线程同时读

比如A1、A2、A3进行写操作,B1、B2、B3进行读操作。读写锁允许B1、B2、B3之间并行。但是,考虑数据完整性,写写操作和读写操作间依然是需要相互等待和持有锁的。总结如下:

  • 读-读不互斥:可并行;

  • 读-写互斥;

  • 写-写互斥;

    复制public class ReadWriteLockDemo { private static Lock lock = new ReentrantLock(); private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private static Lock readLock = readWriteLock.readLock(); private static Lock writeLock = readWriteLock.writeLock(); private int value; public Object handleRead(Lock lock) throws InterruptedException { try { lock.lock(); // 模拟读操作 Thread.sleep(1000); // 读操作的耗时越多,读写锁的优势越明显 System.out.println(Thread.currentThread().getName()+" read end!"); return value; } finally { lock.unlock(); } } public void handleWrite(Lock lock, int index) throws InterruptedException { try { lock.lock(); // 模拟写操作 Thread.sleep(1000); System.out.println(Thread.currentThread().getName()+" wrait end!"); value = index; } finally { lock.unlock(); System.out.println(value); } } public static void main(String[] args) { // TODO Auto-generated method stub final ReadWriteLockDemo demo = new ReadWriteLockDemo(); Runnable readRunnable = () -> { // TODO Auto-generated method stub try { demo.handleRead(readLock); // demo.handleRead(lock); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }; Runnable writeRunnable = () -> { // TODO Auto-generated method stub try { demo.handleWrite(writeLock, new Random().nextInt()); // demo.handleWrite(lock, new Random().nextInt()); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }; for (int i = 0; i < 18; i++) { new Thread(readRunnable).start(); //1 } for (int i = 18; i < 20; i++) { new Thread(writeRunnable).start(); //2 } } }

上面代码中,读和写的线程使用耗时的操作来模拟,在1处开启同时读的线程,可以从结果看出读的速度可以是并行的,而2处则不行。

倒计时锁:CountDownLatch

这个工具称为倒计数器:通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

使用场景:比如火箭就很适合使用CountDownLatch。火箭发射前,往往要进行各项设备、仪器检查,只有检查完毕后,引擎才能点火。

CountDownLatch的构造函数接收一个整数,即当前这个计数器的计数个数:

复制public CountDownLatch(int count)

演示:

复制public class CountDownLatchDemo implements Runnable { static final CountDownLatch end = new CountDownLatch(10); //1 static final CountDownLatchDemo demo = new CountDownLatchDemo(); @Override public void run() { try { //模拟检查任务 Thread.sleep(new Random().nextInt(10) * 1000); System.out.println("check complete"); end.countDown(); //2 } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) throws InterruptedException { ExecutorService exec = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { exec.submit(demo); } //等待检查 end.await(); //3 //发射火箭 System.out.println("Fire!"); exec.shutdown(); } }

在1处,生成一个CountDownLatch,计数数量为10,表示需要10个线程完成任务,等待在CountDownLatch上的线程才能继续执行。2处表示一个线程已完成,计数器减一。在3处,要求主线程等待10个线程全部完成任务后,主线程才继续执行。

111_2019-08-26_15-58-26

主线程在CountDownLatch上等待,当所有检查任务全部完成后,主线程方能继续执行。

循环栅栏:CyclicBarrier

循环栅栏(CyclicBarrier)和倒计时锁(CountDownLatch)非常类似:只是循环栅栏的计数器可以反复使用。比如假设我们将计数器设置为10,那么凑齐第一批10个线程后,计数器就会归零,然后接着凑齐下一批10个线程,这就是循环栅栏的内在含义。

使用场景:比如司令下达命令,要10个士兵去完成一项任务,士兵要先集合报道完,接着去执行任务。当10个士兵把手头任务都执行完成了,司令才能对象宣布,任务完成!

这里有两步:1,士兵集合报道;2,士兵把任务完成。当这两步先后完成,司令才认为任务完成。

构造函数:比CountDownLatch稍微强大一些。CyclicBarrier可以接收一个参数作为barrierAction(系统当计数器一次计数完成后,系统会执行的动作):

复制public class CyclicBarrierDemo { public static class Soldier implements Runnable { private String soldier; private final CyclicBarrier cyclic; Soldier(CyclicBarrier cyclic, String soldierName) { this.cyclic = cyclic; this.soldier = soldierName; } public void run() { try { //士兵报道 System.out.println(soldier + " 报道"); //等待所有士兵到齐 cyclic.await(); //2 doWork(); //等待所有士兵完成任务 cyclic.await(); //3 } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } void doWork() { try { Thread.sleep(Math.abs(new Random().nextInt() % 10000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(soldier + ":任务完成"); } } public static class BarrierRun implements Runnable { boolean flag; int N; public BarrierRun(boolean flag, int N) { this.flag = flag; this.N = N; } public void run() { if (flag) { System.out.println("司令:[士兵" + N + "个,任务完成!]"); } else { System.out.println("司令:[士兵" + N + "集合完毕]"); flag = true; } } } public static void main(String args[]) { final int N = 10; Thread[] allSoldier = new Thread[N]; boolean flag = false; CyclicBarrier cyclic = new CyclicBarrier(N, new BarrierRun(flag, N)); //1 //设置屏障点,主要是为了执行这个方法 System.out.println("队伍集合"); for (int i = 0; i < N; ++i) { allSoldier[i] = new Thread(new Soldier(cyclic, "士兵 " + i)); allSoldier[i].start(); //4 } } }

在1处,创建了CyclicBarrier实例,并将计数器设置为10,并要求在计数器达到指标时,执行BarrierRun。在2处,每一个士兵线程都会等待,知道所有士兵集合完毕,集合完毕后,意味着CyclicBarrier的一次计数完成,当再一次调用CyclicBarrier()时,会进行下一次计数。在3处,会等待所有士兵完成任务。还可以第三次第四次调用 cyclic.await();

整个工作过程图示:

2222_2019-08-26_17-02-53

CyclicBarrier.await可能会抛出两个异常,第一是中断异常,可以响应外部紧急事件。大部分迫使线程等待的方法都可能抛出这个异常。第二是它特有的BrokenBarrierException,这个异常说明当前的CyclicBarrier已经破损了,可能没有办法等待所有线程到齐了。如果继续等待,就白等了。

可以在4处上方插入:

复制if (i == 5)
    allSoldier[0].interrupt();

这样做,我们可以得到1个中断异常和9个BrokenBarrierException,1个士兵处于中断,其他9个需要等待这个线程,抛出BrokenBarrierException可以避免其他9个线程进行永久的,无谓的等待。

线程阻塞工具类:LockSupport

LockSupport是一个很实用的线程阻塞工具,可以在线程的任何位置让线程阻塞。

和Thread.suspend()相比,它尼补了由于resume()在前生成的导致线程无法继续执行的问题。和Object.wait()相比,它不需要先获得某个对象锁,也不会抛出InterruptedException。

复制public class LockSupportDemo { public static Object u = new Object(); static ChangeObjectThread t1 = new ChangeObjectThread("t1"); static ChangeObjectThread t2 = new ChangeObjectThread("t2"); public static class ChangeObjectThread extends Thread { public ChangeObjectThread(String name){ super.setName(name); } @Override public void run() { synchronized (u) { System.out.println("in "+getName()); LockSupport.park(this); //1 } } } public static void main(String[] args) throws InterruptedException { t1.start(); Thread.sleep(1000); t2.start(); LockSupport.unpark(t1); //2 LockSupport.unpark(t2); //3 t1.join(); t2.join(); } }

我们将原来的suspend和resume方法用park()和unpark()代替,在1处,我们挂起了当前线程,在2处,我们分别继续执行t1和t2,从结果可以看出,它不会因为unpark在park执行前而导致线程永久挂起。

为什么LockSupport不会导致线程永久挂起?

因为LockSupport使用了类似信号量的机制(不同的是不能累加),它为每个线程准备了一个许可。

  • 若许可可用—>park()会立即返回,将许可变为不可用—>线程阻塞;
  • 调用unpark()—>使许可变为可用

这个特点使得:即使unpark()操作发生在park()之前,它也可以使下一次park()操作立即返回。这就是不会导致线程永久挂起的原因。

同时,处于park()挂起状态的线程不会像suspend()那样给出令人费解的Runnable状态,它会非常明确的给出一个WAITING状态,甚至会标注是park()引起的。这让问题很容易分析。

1111_2019-08-27_10-11-12

LockSupport除了阻塞功能外,还支持中断响应。但是和其他接收中断的函数不一样,它不抛出中断异常,而是默默返回,但可以从Thread.interrupted()等方法获得中断标记。

复制public class LockSupportIntDemo { public static Object u = new Object(); static ChangeObjectThread t1 = new ChangeObjectThread("t1"); static ChangeObjectThread t2 = new ChangeObjectThread("t2"); public static class ChangeObjectThread extends Thread { public ChangeObjectThread(String name) { super.setName(name); } public void run() { synchronized (u) { System.out.println("in " + getName()); LockSupport.park(); if (Thread.interrupted()) { System.out.println(getName() + " 被中断了!"); } } System.out.println(getName() + " 执行结束"); } } public static void main(String[] args) throws InterruptedException { t1.start(); Thread.sleep(100); t2.start(); t1.interrupt(); LockSupport.unpark(t2); } }

线程复用:线程池

多线程在多核的处理下有助于性能,但如果不加控制的使用线程,反而会对系统性能产生不利影响。

为什么会造成不利影响?

  1. 线程创建和关闭需要花费时间,少数不要紧,系统级别的(线程很多的)就很耗时了。
  2. 线程本身需要占用内存。有抛出out of memory的危险

在实际生产环境中,线程的数量必须得到控制。

什么是线程池

联想一下数据库连接池,就知道线程池是啥了。

在线程池中,总有那么几个活跃的线程。当需要时,就从池中取出空闲线程,当完成工作后,再还回去,方便其他人使用。

JDK对线程池的支持

JDK提供了一套Executor框架,用来对线程池的支持。它的核心成员如下:

Executor框架结构_2019-08-27_11-14-53

上面是jdk并发包的核心类。其中ThreadPoolExecutor表示一个线程池。Excecutor扮演线程池工厂的角色,通过它可以取得一个拥有特定功能的线程池。

Executors提供了各种类型的线程池:

线程池类型 作用
newFixedThreadPool() 返回固定线程数量的线程池。线程池中线程数量保持不变。有新任务时,有空闲线程,则执行。若没有则暂存一个任务队列,等到有空闲线程,再执行
newSingleThreadExecutor() 返回只有一个线程的线程池。若有多余任务提交线程池,则存入任务队列,待线程空闲,按先入先出的顺序执行任务队列中的任务。
newCachedThreadPool() 返回一个可根据实际情况调整线程数量的线程池。若有空闲线程可用,则用空闲线程。若没有,创建新的线程处理任务。所有线程完成任务后,将返回线程池进行复用。
newSingleThreadScheduledExecutor() 返回一个ScheduleExecutorService对象,线程池大小为1。它会在给定时间执行某任务。如在固定延时之后,或周期性执行某个任务
newScheduledThreadPool() 返回ScheduleExecutorService对象,但可以指定线程池数量

1、固定大小的线程池

以newFixedThreadPool()为例,简单展示线程池的使用:

复制public class ThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { MyTask task = new MyTask(); ExecutorService es = Executors.newFixedThreadPool(5); //1 for (int i = 0; i < 10; i++) { es.submit(task); //2 } } }

在1处,创建了一个固定大小的线程池,内有5个线程。在2处,依次向线程池提交了10个任务。

上面程序,前5个任务和后5个任务的执行时间正好相差1秒。

2、计划任务

newScheduledThreadPool()。它返回一个ScheduledExecutorService对象,可以根据时间需要进行调度,它其实起到了计划任务的作用。它的一些主要方法如下:

复制public ScheduledFuture<?> schedule(Runnable command, long delay, 
          TimeUnit unit);
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
          long initialDelay, long period, TimeUnit unit); public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);

scheduleAtFixedRate()和scheduleWithFixedDelay()都会对任务进行周期性调度,不过它们有小小区别:

FixedRate和FixedDelay的区别_2019-08-27_14-28-23

下面是scheduleAtFixedRate()的例子,任务执行1秒,调用周期2秒。即每2秒,任务会被执行一次。

复制public class ScheduledExecutorServiceDemo { public static void main(String[] args) { ScheduledExecutorService ses = Executors.newScheduledThreadPool(10); ses.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { Thread.sleep(1000); System.out.println(System.currentTimeMillis()/1000); } catch (InterruptedException e) { e.printStackTrace(); } } }, 0, 2, TimeUnit.SECONDS); } }

上面的执行结果是每次打印时间间隔为2秒。

那如果任务的执行时间超过调度时间,会发生什么呢?会出现堆叠的情况吗,不会,若出现这种情况,任务的周期将变成8秒,即任务完成那一刻才开始下一次任务的调度。

如果采用scheduleWithFixedDelay(),任务的实际间隔将是10秒。

刨根究底:核心线程池的内部实现

对于核心的几个线程池,其内部都使用了ThreadPoolExecutor实现。这里就不给出它们的实现方式了。

下面是ThreadPoolExecutor最重要的构造函数:

复制public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

参数含义:

  • corePoolSize:指定线程池中的线程数量。
  • maximumPoolSize:指定了线程池中的最大线程数量。
  • keepAliveTime:超过corePoolSize的空闲线程,在多长时间内,会被销毁。
  • unit:keepAliveTime的单位。
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:线程工厂,用来创建线程,用默认的即可。
  • handler:拒绝策略。太多任务而处理不及时,如何拒绝任务。

参数workQueue:被提交但未被执行的任务队列,它是一个BlockingQueue接口的对象,仅用于存放Runnable对象。ThreadPoolExecutor的构造函数中可以使用下面几种BlockingQueue:

  • 直接提交的队列:该功能由SynchronousQueue提供。SynchronousQueue没有容量,若使用它,提交的任务不会被真实的保存,而总是将任务提交给线程执行。若没有空线程,则创建;若线程数量达到顶峰,则执行拒绝策略。通常,使用SynchronousQueue需要很大的maximumPoolSize值,否则很容易执行拒绝策略。

  • 有界的任务队列:可以使用ArrayBlockingQueue实现。它的构造函数必须带一个容量参数,表示最大容量。若实际线程数小于corePoolSize,则优先创建新线程,若大于corePoolSize,则将新任务加入等待队列。若等待队列已满,在总线程数<=maximumPoolSize的前提下,创建新的进行执行任务。若>maximumPoolSize,执行拒绝策略。

    可见,有界队列仅在任务队列装满时,才肯呢过将线程数提升至corePoolSize以上。换句话说,除非系统非常繁忙,否则核心线程数维持在corePoolSize。

  • 无界的任务队列:可通过LinkedBlockingQueue实现。与有界队列相比,除非系统资源耗尽,否则无界队列不存在任务入队失败的情况。

    当系统线程数<corePoolSize,创建新线程;

    若>=corePoolSize,不增加新线程,加入等待队列,若任务创建和处理速度差异太大,无界队列会保持快速增长,知道耗尽系统内存。

  • 优先任务队列(带有执行优先级的队列):通过PriorityBlockingQueue实现,可以控制任务的执行前后顺序。高优先级的任务线执行。

回顾一下:

newFixedThreadPool()方法:它的corePoolSize和maximumPoolSize大小一样,因为固定大小的线程池不存在线程数量的动态变化。同时,它使用无界队列存放任务列表,从而在任务提交频繁的情况下有可能耗尽系统资源。

newSingleThreadExecutor()返回单线程线程池,是newFixedThreadPool()的退化,只是简单将线程数设为1。

newCachedThreadPool()方法返回corePoolSize为0,maximumPoolSize无穷大的线程池。刚开始该线程池无线程,它会将提交的线程加入SynchronousQueue,这是一种立即提交的队列,它会迫使线程池增加新的线程执行任务。当任务执行完毕,在60秒内将线程池不用的线程回收(不留任何空闲线程)。因此,当同时有大量任务提交时,任务执行又不快,那么系统便会开启灯亮线程处理,很快就会耗尽系统资源。

超负载了?使用拒绝策略

JDK内置的拒绝策略:

  • AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作。
  • CallerRunsPolicy策略:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的线程。显然,不会真正丢弃任务,但是,任务提交线程的性能可能急剧下降。
  • DiscardOledestPolicy策略:该策略将丢弃最老的一个请求,即将要执行的一个任务,并尝试再次提交当前任务。
  • DiscardPolicy策略:该策略默默丢弃无法处理的任务,不予任何处理。(若允许丢失,可能是最好的一种方式了)

可以自己扩展RejectedExecutionHandle接口实现自己的拒绝策略,下面代码简单演示了自定义线程池和拒绝策略的使用:

复制public class RejectThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { MyTask task = new MyTask(); ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), Executors.defaultThreadFactory(), new RejectedExecutionHandler(){ @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { System.out.println(r.toString()+" is discard"); } }); //1 for (int i = 0; i < Integer.MAX_VALUE; i++) { es.submit(task); Thread.sleep(10); } } }

上述1处自定义了一个线程池。该线程池有5个常驻线程,并且最大线程数量也是5个。这和固定大小的线程池是一样的。但是它却拥有一个只有10个容器的等待队列。在这里,我们自定义了拒绝策略,只是比DiscardPolicy高级一点点,把拒绝的信息打印出来,在实际应用中,我们可以将其记录到日志上。用来分析系统的负载和任务丢失情况。

自定义线程创建:ThreadFactory

线程池的主要作用是为了线程复用,也就是避免了线程的频繁创建。但是,线程池最开始的线程从何而来呢?答案就是ThreadFactory。

ThreadFactory是一个接口,它只有一个方法,用来创建线程:

复制Thread newThread(Runnable r);

当线程池需要新建线程时,就会调用这个方法。

我们使用自定义线程可以更自由地设置池子中所有线程的状态,甚至可以设置为守护线程:

复制public class TFThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { MyTask task = new MyTask(); ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), new ThreadFactory(){ @Override public Thread newThread(Runnable r) { Thread t= new Thread(r); t.setDaemon(true); System.out.println("create "+t); return t; } } ); for (int i = 0; i < 5; i++) { es.submit(task); } Thread.sleep(2000); } }

扩展线程池

虽然JDK已经帮我们实现了这个稳定的高性能线程池。但如果我们需要对线程池进行一些扩展。比如,想监控每个任务执行的开始和结束时间,或者其他一些自定义增强功能,怎么办呢?

ThreadPoolExecutor:它也是一个可以扩展的线程池。它提供了beforeExecute()、afterExecute()和terminated()三个接口对线程池进行控制。

在默认的ThreadPoolExecutor实现中,提供了空的beforeExecute()、afterExecute()实现。在实际引用中,可以对其扩展实现对线程池运行状态的跟踪,输出一些有用的调试信息,用以帮助系统故障诊断。下面演示对线程池的扩展,在这个扩展中,将记录每一个任务的执行日志:

复制public class ExtThreadPool { public static class MyTask implements Runnable { public String name; public MyTask(String name) { this.name = name; } @Override public void run() { System.out.println("正在执行" + ":Thread ID:" + Thread.currentThread().getId() + ",Task Name=" + name); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()) { @Override protected void beforeExecute(Thread t, Runnable r) { System.out.println("准备执行:" + ((MyTask) r).name); } @Override protected void afterExecute(Runnable r, Throwable t) { System.out.println("执行完成:" + ((MyTask) r).name); } @Override protected void terminated() { System.out.println("线程池退出"); } }; for (int i = 0; i < 5; i++) { MyTask task = new MyTask("TASK-GEYM-" + i); es.execute(task); Thread.sleep(10); } es.shutdown(); } }/** ...... 正在执行:Thread ID:13,Task Name=TASK-GEYM-2 准备执行:TASK-GEYM-3 正在执行:Thread ID:14,Task Name=TASK-GEYM-3 准备执行:TASK-GEYM-4 正在执行:Thread ID:15,Task Name=TASK-GEYM-4 执行完成:TASK-GEYM-0 执行完成:TASK-GEYM-1 执行完成:TASK-GEYM-2 执行完成:TASK-GEYM-3 执行完成:TASK-GEYM-4 线程池退出 */

可以看到,所有任务执行前后都捕获到了。这对于应用的调试和诊断是非常有帮助的。

合理的选择:优化线程池线程数量

线程池的大小对系统的性能有一定的影响。过大或过小的线程数量都无法发挥最优的系统性能。但是也不用做得非常精确,只要避免极大和极小两种情况即可。

最优池的大小计算公式_2019-08-27_19-16-27

堆栈去哪里了:挖出线程池中被淹没的异常堆栈

先说明一下要解决的问题!

复制public class DivTask implements Runnable { int a, b; public DivTask(int a, int b) { this.a = a; this.b = b; } @Override public void run() { double re = a / b; //1 System.out.println(re); } public static void main(String[] args) { ThreadPoolExecutor pools = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); for(int i=0; i<5; i++) pools.submit(new DivTask(100, i)); // pools.execute(new DivTask(100, i)); } }/** 100.0 25.0 50.0 33.0 */

在上面程序中,只有四个输出结果,少了一个,然而没有报错信息。使用submit会出现这样的情况(execute会抛出异常,具体原因后面再看吧)。

再说下解决方案!

对于程序员来说,没有异常堆栈是最头疼的事。我们可以通过两种方法来讨回异常堆栈:

1 是放弃submit()改用execute(),如注释所示;

复制pools.execute(new DivTask(100, i));

2 是改造submit():

复制 Future<?> submit = pools.submit(new DivTask(100, i));
            submit.get();

以上两种都可以得到部分堆栈信息:

复制Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zero at java.util.concurrent.FutureTask.report(FutureTask.java:122) at java.util.concurrent.FutureTask.get(FutureTask.java:192) at geym.ch3.DivTask.main(DivTask.java:21) Caused by: java.lang.ArithmeticException: / by zero at geym.ch3.DivTask.run(DivTask.java:13) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at java.lang.Thread.run(Thread.java:745)

注意,上面说的是部分!我们只知道异常是在哪里抛出的,也就是代码的1处,但是不确定线程是在哪里提交的,任务的具体提交的位置被淹没了!

3、自己动手,扩展ThreadPoolExecutor线程池(彻底解决的办法)

为了少加班!我们还是自己动手,把堆栈的信息彻底挖出来吧!扩展我们的ThreadPoolExecutor线程池,让它在调度任务之前,先保存一下提交任务线程的堆栈信息。

复制public class TraceThreadPoolExecutor extends ThreadPoolExecutor { public TraceThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { super(corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue); } @Override public void execute(Runnable task) { // TODO Auto-generated method stub super.execute(wrap(task, clientTrace(), Thread.currentThread().getName())); //包装器 } @Override public Future<?> submit(Runnable task) { // TODO Auto-generated method stub return super.submit(wrap(task, clientTrace(), Thread.currentThread().getName())); } private Exception clientTrace() { return new Exception("Client stack trace"); } private Runnable wrap(final Runnable task,final Exception clientStack, String clientThreadName) { //1 return new Runnable() { @Override public void run() { try { task.run(); } catch (Exception e) { clientStack.printStackTrace(); try { throw e; } catch (Exception e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } } }; } public static class DivTask implements Runnable { int a,b; public DivTask(int a,int b) { this.a = a; this.b = b; } @Override public void run() { double re = a/b; System.out.println(re); } } public static void main(String[] args) { ThreadPoolExecutor pools = new TraceThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); for(int i=0; i<5; i++) pools.execute(new DivTask(100, i)); } }

在wrap()(1处)方法的第2个参数为一个异常,里面保存着提交任务的线程的堆栈信息。该方法将我们传入的Runnable任务进行一层包装,使之能处理异常信息。当任务发生异常时,这个异常会被打印。

分而治之:Fork/Join框架

fork()用来开启分支线程来处理任务,一般会提交给ForkJoinPool线程池进行处理,以节省系统资源。

Join()用来等待fork()的执行分支执行结束。

使用Fork/Join进行数据处理时的总体结构如图所示:

ForkJoin执行逻辑_2019-08-28_15-06-28

由于线程池的优化,提交的任务和线程数量不是一对一的关系。通常是一个线程处理多个任务,每个线程都有一个任务队列。当线程A把任务完成,而线程B还在有一堆任务处理时,线程A会帮助B。B从任务队列顶部拿数据,而A则是从任务队列的底部拿数据,这样有利于避免数据竞争。

互相帮助的线程_2019-08-28_15-13-28

ForkJoinPool的一个重要的接口,可以提交一个ForkJoinTask,ForkJoinTask支持fork()分解以及join()等待的任务,它有两个重要子类:RecursiveAction(无返回值)和RecursiveTask(返回v类型)。

复制public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task);

使用:

复制public class CountTask extends RecursiveTask<Long> { private static final int THRESHOLD = 10000; private long start; private long end; public CountTask(long start, long end) { this.start = start; this.end = end; } public Long compute() { long sum = 0; boolean canCompute = (end - start) < THRESHOLD; if (canCompute) { //求和总数小于THRESHOLD,直接求和 for (long i = start; i <= end; i++) { sum += i; } } else { //分成100个小任务 // 比如start=0,end=100,则每一小步计算2个数 //i=0,lastOne=0+2=2, pos=2+1=3 //i=1,lastOne=2+2=4, pos=4+1=5 //... //i=100 long step = (start + end) / 100; // long pos = start; for (int i = 0; i < 100; i++) { long lastOne = pos + step; if (lastOne > end) lastOne = end; CountTask subTask = new CountTask(pos, lastOne); pos = lastOne + 1; subTask.fork(); sum += subTask.join(); } } return sum; } public static void main(String[] args) { ForkJoinPool forkJoinPool = new ForkJoinPool(); CountTask task = new CountTask(0, 200000L); ForkJoinTask<Long> result = forkJoinPool.submit(task); //1 try { long res = result.get(); System.out.println("sum=" + res); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }

在1处,使用forkJoinPool提交了CountTask,CountTask构造一个计算1到200000求和的任务。在compute()方法中,遵循了下面的逻辑:

复制if (canCompute) {
    //求和总数够小,直接求和
} else {
    //分成若干个小任务 }

在使用ForkJoinPool时需要注意,如果任务的划分层次很深,一直没有返回,可能出现两种情况:

  1. 系统内线程数量越积越多,导致性能严重下降。
  2. 函数调用层次变得很深,导致栈溢出。

JDK的并发容器

JDK提供了好用的并发容器类,使用也很方便,这里主要讲讲这些工具的具体实现。

并发集合介绍

先简单认识一下并发集合:

  • ConcurrentHashMap:高效的并发HashMap。即线程安全的HashMap
  • CopyOnWriteArrayList:属于List,和ArrayList是一族的。在读多少写的场合性能非常好,远远好于Vector。
  • ConcurrentLinkedQueue:线程安全的LinkedList。
  • BlockingQueue:这是一个接口,JDK内部通过链表、数组等方式实现这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap:跳表的实现。这是一个Map,使用跳表的数据结构进行快速查找。
  • Vector也是线程安全的,另外Collections工具类可以帮助我们将任意集合包装成线程安全的工具类

线程安全的HashMap:ConcurrentHashMap

如果获得一个线程安全的HashMap?

第一种方法是:使用Collections.synchronizedMap()方法来包装HashMap

复制static Map<String, String> map = Collections.synchronizedMap(new HashMap<String, String>());

Collections.synchronizedMap()会生成一个名为SynchronizedMap的Map。它使用委托,将自己所有Map相关的功能交给传入的HashMap实现,自己则主要负责保证线程安全。

第二种方法是使用ConcurrentHashMap代替HashMap,这种方式更专业,更适合并发场合。

线程安全的List

Vector是线程安全的List,也可以使用Collections.synchronizedList()方法来包装任意List。

高效读写队列:ConcurrentLinkedQueue

ConcurrentLinkedQueue算是高并发中性能最好的队列了。

具体实现:

1、节点

作为一个链表,自然需要定义一个节点:

复制private static class Node<E>{ volatile E item; volatile Node<E> next;

item用来表示目标元素,比如:放入String,item就是String元素。next表示Node的下一个元素。这样Node就环环相扣,串在一起了。

2、CAS操作

首先,说明一下CAS操作的原理:CAS操作包含三个操作数—— 内存位置的值(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

CAS是一种乐观锁。乐观锁在少写的情况下适用,若是多写的情况,会导致CAS算法不断的进行retry,反而降低了系统性能,多写的情况适合适用悲观锁。

CAS操作_2019-08-29_09-49-50

casItem()表示设置当前Node的item值。cmp为期望值,第二个参数val为目标值。当当前值等于cmp期望值时,就会将目标值设置为val。第二个方法类似。只是它用来设置next字段。

ConcurrentLinkedQueue内部有两个重要的字段,head和tail,分别表示头部和尾部。tail的更新不是及时的,而是有延迟,每次更新会跳跃两个元素。如下图:

Snipaste_2019-09-05_19-09-42

原书中的源码分析我没怎么看懂,有看懂的童鞋欢迎在评论中分享心得

u=3405433144,4098183174&fm=26&gp=0

高效读取:不变模式下的CopyOnWriteArrayList

在很多应用场景中,读操作往往会远远大于写操作。所以这种情况下,我们希望读的性能好些,而写的性能差些也无所谓。

我们知道:在读写锁ReadWriteLock中,读读不互斥,而读写,写写是互斥的。

而现在,JDK还提供了另外一个读写工具类,将读取性能发挥到极致CopyOnWriteArrayList,它的读读不阻塞,读写也不会互相阻塞,只有写写需要同步等待。

它是怎么做到读写不阻塞的?

CopyOnWrite在写入操作时,对原有的数据进行复制成一个副本(而不修改原来的数据),将修改的内容写入复制后的副本中。写完后,再用副本替换原来的数据,这样就不会影响读了。

读取的实现:

Snipaste_2019-09-06_11-15-40

读取没有任何同步和锁的操作,理由是内部数组array不会发生修改,只会被另外一个array替换。

写人的实现:

复制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); //1 newElements[len] = e;//2 setArray(newElements);//3 return true; } finally { lock.unlock(); } }

首先,写入操作使用锁,这个锁仅限于控制写-写的情况。重点在1处,进行了内部元素的复制,而生成一个新数组newElements。2处,将新元素增加到数组末尾,然后,将新数组替换老数组,修改就完成了。整个过程不会影响读取,并且读取线程会实时查看到这个修改(array变量是volatile的)

数据共享通道:BlockingQueue

如何实现多个线程间的数据共享呢?比如,线程A希望给线程B发一个消息,用什么方式好呢?

我们希望线程A能够通知到线程B,又希望线程A不知道线程B的存在。这样对于以后线程B的升级或维护,而不用再修改线程A有帮助。为了实现这一点,我们可以使用一个中间件BlockingQueue来实现。它就相当于一个意见箱,用来作为发表意见者与接收意见者沟通的桥梁。

BlockingQueue和之前提到的ConcurrentLinkedQueue和CopyOnWriteArrayList不同,它是一个接口,而不是具体的实现。它的主要实现如下图:

Snipaste_2019-09-06_13-38-28

ArrayBlockingQueue:基于数组实现。更适合做有界队列,扩展比较不方便

LinkedBlockingQueue:基于链表。更适合做无界队列,因为其内部元素可动态增加。

BlockingQueue为什么适合作为数据共享的通道呢?原因在于Blocking(阻塞)。

当服务线程(指获取队列中消息并进行处理的线程)处理完队列中所有的消息后,服务线程是如何知道下一条消息的到来的?BlockingQueue会让服务线程在队列为空时,进行等待,当有新的消息进入队列后,自动将线程唤醒。

它是如何工作的?以ArrayBlockingQueue为例说明:

写入数据:

它有一个items,items就是用来存放数据的队列。offer()在列队满时,返回false。我们关注的是put()方法,put()也是将元素压入队列队尾,但队列满了,它会一直等待,直到队列中有空闲位置。

读取数据:

poll()、take()两个方法都能从队列中的头部弹出一个元素。不同的是:如果队列为空poll()方法直接返回null。而take()方法会等待,直到队列内有可用元素。

从上面可以看出,put()take()方法才是Blocking的关键。为了做好通知和等待两件事,ArrayBlockingQueue定义了三个字段:

Snipaste_2019-09-06_14-18-02

take()操作:

当队列为空时,让当前线程等待在notEmpty,新元素入队时,则进行一次notEmpty上的通知。

复制public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); //1 return dequeue(); } finally { lock.unlock(); } }

在1处,要求线程在notEmpty对象中等待。下面是元素入队的一段代码:

复制/**
 * 在当前put位置插入元素、进给和信号。
 * 只有在持有锁时才调用。
 */
private void enqueue(E x) { final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); //1 }

在1处,当新元素入列后,需要通知等待在notEmpty上的线程,让它们继续工作。

put()操作:

当队列满时,需要让 压入线程 等待:

复制public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); //1 enqueue(e); } finally { lock.unlock(); } }

在1处,队列满时,在notFull对象中等待。

当然,当元素从队列中挪走时,队列中有空位时,自然也要通知等待入队的线程:

复制private E dequeue() { 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(); //1 return x; }

我们还会在“5.3 生产者消费者”一节中,看到他们的身影。在那里,我们可以更清楚地看到如何使用BlockingQueue解耦生产者和消费者。

随机数据结构—跳表:SkipList

介绍跳表

除了常用的哈希表外,还有一种有趣的数据结构:跳表。跳表的本质是同时维护了多个链表,并且链表是分层的。跳表的查询性能要比哈希表好。如下图

跳表结构_2019-09-06_16-48-36

最低层的链表维护了跳表中所有的元素,每上面一层都是下面一层的子集,一个元素插入哪一层完全随机,运气不好可能得到性能最差的结构。但是实际工作中,它还是表现得很好的。

跳表内所有元素都是排序的。查找时,从顶级链表开始找,一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续查找。比如要查找上面跳表结构中的7。查找过程如下图所示:

跳表的查找过程_2019-09-06_16-55-07

跳表显然是一种空间换时间的算法。

使用跳表实现的Map和使用哈希算法实现的Map的另一个不同之处是:跳表实现的Map是会排序的,而哈希实现的Map不排序。若需要一个有序的Map,那就选择跳表。

使用:ConcurrentSkipListMap

实现这一数据结构的类是ConcurrentSkipListMap。简单使用:

复制Map<Integer, Integer> map = new ConcurrentSkipListMap<>();
for (int i = 0; i<30; i++) map.put(i,i); for (Map.Entry<Integer, Integer> entry: map.entrySet() ) { System.out.println(entry.getKey()); }

跳表有三个关键的数据结构组成:

  • Node<K,V>:(节点,含有key、value、next元素,对Node的所有操作,都使用CAS方法)
  • Index<K,V>:(表示索引,它的内部包装了node,同时增加了向下和向右的引用),整个跳表就是根据Index进行全网的组织的。
  • HeadIndex:表示链表头部的第一个Index。它继承自Index。

下面是三种数据结构的代码:

Node_2019-09-06_17-20-04

Index_2019-09-06_17-20-23

HeadIndex_2019-09-06_17-20-38

 

重入锁(ReentrantLock):synchronized的功能扩展

jdk6.0之前,重入锁的性能远远好于synchronized,但是之后,两者差距并不大。

下面是一段重入锁使用案例:

public class ReenterLock implements Runnable{ public static ReentrantLock lock=new ReentrantLock(); public static int i=0; @Override public void run() { for(int j=0;j<10000000;j++){ lock.lock(); //1 try{ i++; }finally{ lock.unlock(); //2 } } } public static void main(String[] args) throws InterruptedException { ReenterLock tl=new ReenterLock(); Thread t1=new Thread(tl); Thread t2=new Thread(tl); t1.start();t2.start(); t1.join();t2.join(); System.out.println(i); } }

1处加锁,2处释放锁。

重入锁是可以让线程反复进入的,这里的反复仅仅局限于一个线程。可以写成下面的形式:

lock.lock();
lock.lock(); try{ i++; }finally{ lock.unlock(); lock.unlock(); }

这种情况下,一个线程连续两次获得同一把锁,这是允许的!同时,释放也必须释放两次,释放次数多了,抛出异常,次数少了,相当于线程还持有当前锁,其他线程无法进入临界区。

重入锁除了灵活,还提供了中断处理的能力:

中断响应

对于synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它保持等待。而重入锁提供了另一种可能,那就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做很有必要。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无需再等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的。

下面代码产生了一个死锁,但得益于锁中断,我们可以轻松解决这个死锁:

public class IntLock implements Runnable { //重入锁ReentrantLock public static ReentrantLock lock1 = new ReentrantLock(); public static ReentrantLock lock2 = new ReentrantLock(); int lock; public IntLock(int lock) { this.lock = lock; } @Override public void run() { // TODO Auto-generated method stub try { if (lock == 1) { lock1.lockInterruptibly(); //1 Thread.sleep(500); lock2.lockInterruptibly(); System.out.println("lock1 is working...."); } else { lock2.lockInterruptibly(); Thread.sleep(500); lock1.lockInterruptibly(); System.out.println("lock2 is working...."); } } catch (Exception e) { e.printStackTrace(); } finally { if (lock1.isHeldByCurrentThread()) { lock1.unlock(); //释放锁 } if (lock2.isHeldByCurrentThread()) { lock2.unlock(); } System.out.println(Thread.currentThread().getId() + ":线程退出"); } } public static void main(String[] args) throws InterruptedException { IntLock r1 = new IntLock(1); IntLock r2 = new IntLock(2); Thread t1 = new Thread(r1); Thread t2 = new Thread(r2); t1.start(); t2.start(); Thread.sleep(1000); t2.interrupt(); //2 } }

线程t1和t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。这很容易照成t1、t2互相等待,形成死锁。这里,统一使用1处的lockInterruptibly()方法,这是一个可以对中断进行相应的锁申请动作,即在等待锁的过程中,可以响应中断。

在2处,t2线程被中断,放弃对lock1的锁申请,同时释放已获得的lock2。这时t1就能顺利执行完剩余程序

锁申请等待限时

除了外部通知之外,避免死锁还有另外一种方法,就是限时等待。我们可以使用tryLock()方法进行一次限时的等待。

复制public class TimeLock implements Runnable { public static ReentrantLock lock = new ReentrantLock(); @Override public void run() { try { if (lock.tryLock(5, TimeUnit.SECONDS)) { //2 Thread.sleep(6000); //1 } else { System.out.println("get lock failed"); } } catch (InterruptedException e) { e.printStackTrace(); } finally { if(lock.isHeldByCurrentThread()) lock.unlock(); } } public static void main(String[] args) { TimeLock tl = new TimeLock(); Thread t1 = new Thread(tl); Thread t2 = new Thread(tl); t1.start(); t2.start(); } }

在这里2处,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示计时单位。没个进入临界区的线程需要占用6秒的锁(1处),而t2由于等待5秒没有等到想要的锁(2处),便返回false。若等待时间改为比5秒大,将返回true,并获得锁。

公平锁

在大多数情况下,锁的申请是非公平的。系统知识随机挑选一个,不保证其公平性。公平的锁,会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是:不会产生饥饿现象。我们使用synchronized关键字得到的就是非公平锁,而重入锁可以对公平性设置。它有一个构造函数:

复制 public ReentrantLock(boolean fair)     //为true时是公平锁

实现公平锁要维护一个有序队列,因此实现公平锁的成本较高,性能相对低下,因此,默认情况下,锁时非公平的。

复制public class FairLock implements Runnable{ //创建公平锁 private static ReentrantLock lock=new ReentrantLock(true); //1 public void run() { while(true){ lock.lock(); try{ System.out.println(Thread.currentThread().getName()+"获得锁"); }finally{ lock.unlock(); } } } public static void main(String[] args) { FairLock lft=new FairLock(); Thread th1=new Thread(lft); Thread th2=new Thread(lft); th1.start(); th2.start(); } }/** Thread-0获得锁 Thread-1获得锁 Thread-0获得锁 Thread-1获得锁 Thread-0获得锁 Thread-1获得锁 */

你运行上面的程序,会看到结果很有规律。

如果不使用公平锁,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的。但是无公平性可言,将上面1中的true改成false即可。

对ReentrantLock的几个重要方法整理如下:

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,返回true,失败返回false。不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
  • unlock():释放锁。

重入锁的实现

就重入锁的实现来看,它主要集中在java层面。主要包含三个要素:

  • 第一,是原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有。
  • 第二,是等待队列。
  • 第三,是阻塞语句park()和unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。有关park()和unpark()的详细介绍,可以参考线程阻塞工具类:LockSupport。

重入锁的好搭档:Condition条件

Condition的作用和wait()和notify()方法的作用是大致相同的。不同的是wait()和notify()方法是和synchronized关键字合作使用的,而Condition是与重入锁合作的。通过Lock接口(重入锁实现了该接口)的newCondition()方法可以生成一个与当前重入锁绑定的Condition实例。

Condition接口提供的基本方法:

  • await():使当前线程等待,同时释放当前锁,当其他线程使用signal()或者signalAll()方法时,线程会重新获得锁并继续执行。当线程中断时,也能跳出等待,和Object.wait()非常相似。
  • awaitUninterruptibly():与await()基本相同,但是不会响应等待过程中的中断。
  • signal():唤醒一个等待中的线程,signalAll()会唤醒所有等待中的线程。

下面是Condition的演示:

复制public class ReenterLockCondition implements Runnable { public static ReentrantLock lock = new ReentrantLock(); public static Condition condition = lock.newCondition(); @Override public void run() { try { lock.lock(); System.out.println("Thread is start..."); condition.await(); System.out.println("Thread is going on"); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } public static void main(String[] args) throws InterruptedException { ReenterLockCondition tl = new ReenterLockCondition(); Thread t1 = new Thread(tl); t1.start(); Thread.sleep(2000); //通知线程t1继续执行 lock.lock(); //1 condition.signal(); lock.unlock(); } }

和Object.wait()和notify()方法一样,当线程使用Condition.await()时,要求线程持有相关的重入锁,在Condition.await()调用后,这个线程会释放这把锁。同理,在Condition.signal()方法调用时,也要求线程先获得相关的锁。在siganl()方法调用后,系统会从当前Condition对象的等待队列中,唤醒一个线程,一旦线程被唤醒,它会重新尝试获得与之绑定的重入锁,一旦成功获得,就可以继续执行了。因此,一般调用完condition.signal()后,都需要释放重入锁。

允许多个线程同时访问:信号量(Semaphore)

广义上讲,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentrantLock,一次都只允许一个线程访问一个资源,而信号量可以指定多个线程,同时访问某个资源

主要提供了两个构造函数:

复制public Semaphore(int permits)
public Semaphore(int permits, boolean fair) //第二个参数可以指定是否公平

在构造信号量对象时,必须要指定信号量的准入数,即同时能申请多少个许可。信号量的主要逻辑方法有:

复制public void acquire()
public void acquireUninterruptibly() public boolean tryAcquire() public boolean tryAcquire(long, TimeUnit unit) public void release()
  • acquire():尝试获得一个准入的许可。若无法获得,则线程会等待,直到有线程释放一个许可或当前线程被中断。
  • acquireUninterruptibly():和acquire()方法类似,但是不响应中断。
  • tryAcquire():尝试获得一个许可,立即返回结果
  • release():释放一个许可。
复制public class SemapDemo implements Runnable{ final Semaphore semp = new Semaphore(5); //3 @Override public void run() { try { semp.acquire(); //1 //模拟耗时操作 Thread.sleep(2000); System.out.println(Thread.currentThread().getId()+":done!"); //2 semp.release(); //4 } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) { ExecutorService exec = Executors.newFixedThreadPool(20); final SemapDemo demo=new SemapDemo(); for(int i=0;i<20;i++){ exec.submit(demo); } } }

上述代码中,1处到2处为临界区管理代码,程序会限制这段代码的线程数。在第3处,申明了一个包含5个许可的信号量。这意味着1~2处只能同时有5个线程进入。线程在使用完acquire(),在离开时,务必使用release()释放信号量。这和释放锁是一个道理。

读写锁:ReadWriteLock

ReadWriteLock是JDK5中提供的读写分离锁。读写分离锁可以有效地减少锁竞争,以提升系统性能。用锁分离的机制来提升性能很容易理解,如果使用重入锁或内部锁,理论上所有读—读、读—写、写—写都是串行操作。而读写锁,允许多个线程同时读

比如A1、A2、A3进行写操作,B1、B2、B3进行读操作。读写锁允许B1、B2、B3之间并行。但是,考虑数据完整性,写写操作和读写操作间依然是需要相互等待和持有锁的。总结如下:

  • 读-读不互斥:可并行;

  • 读-写互斥;

  • 写-写互斥;

    复制public class ReadWriteLockDemo { private static Lock lock = new ReentrantLock(); private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); private static Lock readLock = readWriteLock.readLock(); private static Lock writeLock = readWriteLock.writeLock(); private int value; public Object handleRead(Lock lock) throws InterruptedException { try { lock.lock(); // 模拟读操作 Thread.sleep(1000); // 读操作的耗时越多,读写锁的优势越明显 System.out.println(Thread.currentThread().getName()+" read end!"); return value; } finally { lock.unlock(); } } public void handleWrite(Lock lock, int index) throws InterruptedException { try { lock.lock(); // 模拟写操作 Thread.sleep(1000); System.out.println(Thread.currentThread().getName()+" wrait end!"); value = index; } finally { lock.unlock(); System.out.println(value); } } public static void main(String[] args) { // TODO Auto-generated method stub final ReadWriteLockDemo demo = new ReadWriteLockDemo(); Runnable readRunnable = () -> { // TODO Auto-generated method stub try { demo.handleRead(readLock); // demo.handleRead(lock); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }; Runnable writeRunnable = () -> { // TODO Auto-generated method stub try { demo.handleWrite(writeLock, new Random().nextInt()); // demo.handleWrite(lock, new Random().nextInt()); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }; for (int i = 0; i < 18; i++) { new Thread(readRunnable).start(); //1 } for (int i = 18; i < 20; i++) { new Thread(writeRunnable).start(); //2 } } }

上面代码中,读和写的线程使用耗时的操作来模拟,在1处开启同时读的线程,可以从结果看出读的速度可以是并行的,而2处则不行。

倒计时锁:CountDownLatch

这个工具称为倒计数器:通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

使用场景:比如火箭就很适合使用CountDownLatch。火箭发射前,往往要进行各项设备、仪器检查,只有检查完毕后,引擎才能点火。

CountDownLatch的构造函数接收一个整数,即当前这个计数器的计数个数:

复制public CountDownLatch(int count)

演示:

复制public class CountDownLatchDemo implements Runnable { static final CountDownLatch end = new CountDownLatch(10); //1 static final CountDownLatchDemo demo = new CountDownLatchDemo(); @Override public void run() { try { //模拟检查任务 Thread.sleep(new Random().nextInt(10) * 1000); System.out.println("check complete"); end.countDown(); //2 } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) throws InterruptedException { ExecutorService exec = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { exec.submit(demo); } //等待检查 end.await(); //3 //发射火箭 System.out.println("Fire!"); exec.shutdown(); } }

在1处,生成一个CountDownLatch,计数数量为10,表示需要10个线程完成任务,等待在CountDownLatch上的线程才能继续执行。2处表示一个线程已完成,计数器减一。在3处,要求主线程等待10个线程全部完成任务后,主线程才继续执行。

111_2019-08-26_15-58-26

主线程在CountDownLatch上等待,当所有检查任务全部完成后,主线程方能继续执行。

循环栅栏:CyclicBarrier

循环栅栏(CyclicBarrier)和倒计时锁(CountDownLatch)非常类似:只是循环栅栏的计数器可以反复使用。比如假设我们将计数器设置为10,那么凑齐第一批10个线程后,计数器就会归零,然后接着凑齐下一批10个线程,这就是循环栅栏的内在含义。

使用场景:比如司令下达命令,要10个士兵去完成一项任务,士兵要先集合报道完,接着去执行任务。当10个士兵把手头任务都执行完成了,司令才能对象宣布,任务完成!

这里有两步:1,士兵集合报道;2,士兵把任务完成。当这两步先后完成,司令才认为任务完成。

构造函数:比CountDownLatch稍微强大一些。CyclicBarrier可以接收一个参数作为barrierAction(系统当计数器一次计数完成后,系统会执行的动作):

复制public class CyclicBarrierDemo { public static class Soldier implements Runnable { private String soldier; private final CyclicBarrier cyclic; Soldier(CyclicBarrier cyclic, String soldierName) { this.cyclic = cyclic; this.soldier = soldierName; } public void run() { try { //士兵报道 System.out.println(soldier + " 报道"); //等待所有士兵到齐 cyclic.await(); //2 doWork(); //等待所有士兵完成任务 cyclic.await(); //3 } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } void doWork() { try { Thread.sleep(Math.abs(new Random().nextInt() % 10000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(soldier + ":任务完成"); } } public static class BarrierRun implements Runnable { boolean flag; int N; public BarrierRun(boolean flag, int N) { this.flag = flag; this.N = N; } public void run() { if (flag) { System.out.println("司令:[士兵" + N + "个,任务完成!]"); } else { System.out.println("司令:[士兵" + N + "集合完毕]"); flag = true; } } } public static void main(String args[]) { final int N = 10; Thread[] allSoldier = new Thread[N]; boolean flag = false; CyclicBarrier cyclic = new CyclicBarrier(N, new BarrierRun(flag, N)); //1 //设置屏障点,主要是为了执行这个方法 System.out.println("队伍集合"); for (int i = 0; i < N; ++i) { allSoldier[i] = new Thread(new Soldier(cyclic, "士兵 " + i)); allSoldier[i].start(); //4 } } }

在1处,创建了CyclicBarrier实例,并将计数器设置为10,并要求在计数器达到指标时,执行BarrierRun。在2处,每一个士兵线程都会等待,知道所有士兵集合完毕,集合完毕后,意味着CyclicBarrier的一次计数完成,当再一次调用CyclicBarrier()时,会进行下一次计数。在3处,会等待所有士兵完成任务。还可以第三次第四次调用 cyclic.await();

整个工作过程图示:

2222_2019-08-26_17-02-53

CyclicBarrier.await可能会抛出两个异常,第一是中断异常,可以响应外部紧急事件。大部分迫使线程等待的方法都可能抛出这个异常。第二是它特有的BrokenBarrierException,这个异常说明当前的CyclicBarrier已经破损了,可能没有办法等待所有线程到齐了。如果继续等待,就白等了。

可以在4处上方插入:

复制if (i == 5)
    allSoldier[0].interrupt();

这样做,我们可以得到1个中断异常和9个BrokenBarrierException,1个士兵处于中断,其他9个需要等待这个线程,抛出BrokenBarrierException可以避免其他9个线程进行永久的,无谓的等待。

线程阻塞工具类:LockSupport

LockSupport是一个很实用的线程阻塞工具,可以在线程的任何位置让线程阻塞。

和Thread.suspend()相比,它尼补了由于resume()在前生成的导致线程无法继续执行的问题。和Object.wait()相比,它不需要先获得某个对象锁,也不会抛出InterruptedException。

复制public class LockSupportDemo { public static Object u = new Object(); static ChangeObjectThread t1 = new ChangeObjectThread("t1"); static ChangeObjectThread t2 = new ChangeObjectThread("t2"); public static class ChangeObjectThread extends Thread { public ChangeObjectThread(String name){ super.setName(name); } @Override public void run() { synchronized (u) { System.out.println("in "+getName()); LockSupport.park(this); //1 } } } public static void main(String[] args) throws InterruptedException { t1.start(); Thread.sleep(1000); t2.start(); LockSupport.unpark(t1); //2 LockSupport.unpark(t2); //3 t1.join(); t2.join(); } }

我们将原来的suspend和resume方法用park()和unpark()代替,在1处,我们挂起了当前线程,在2处,我们分别继续执行t1和t2,从结果可以看出,它不会因为unpark在park执行前而导致线程永久挂起。

为什么LockSupport不会导致线程永久挂起?

因为LockSupport使用了类似信号量的机制(不同的是不能累加),它为每个线程准备了一个许可。

  • 若许可可用—>park()会立即返回,将许可变为不可用—>线程阻塞;
  • 调用unpark()—>使许可变为可用

这个特点使得:即使unpark()操作发生在park()之前,它也可以使下一次park()操作立即返回。这就是不会导致线程永久挂起的原因。

同时,处于park()挂起状态的线程不会像suspend()那样给出令人费解的Runnable状态,它会非常明确的给出一个WAITING状态,甚至会标注是park()引起的。这让问题很容易分析。

1111_2019-08-27_10-11-12

LockSupport除了阻塞功能外,还支持中断响应。但是和其他接收中断的函数不一样,它不抛出中断异常,而是默默返回,但可以从Thread.interrupted()等方法获得中断标记。

复制public class LockSupportIntDemo { public static Object u = new Object(); static ChangeObjectThread t1 = new ChangeObjectThread("t1"); static ChangeObjectThread t2 = new ChangeObjectThread("t2"); public static class ChangeObjectThread extends Thread { public ChangeObjectThread(String name) { super.setName(name); } public void run() { synchronized (u) { System.out.println("in " + getName()); LockSupport.park(); if (Thread.interrupted()) { System.out.println(getName() + " 被中断了!"); } } System.out.println(getName() + " 执行结束"); } } public static void main(String[] args) throws InterruptedException { t1.start(); Thread.sleep(100); t2.start(); t1.interrupt(); LockSupport.unpark(t2); } }

线程复用:线程池

多线程在多核的处理下有助于性能,但如果不加控制的使用线程,反而会对系统性能产生不利影响。

为什么会造成不利影响?

  1. 线程创建和关闭需要花费时间,少数不要紧,系统级别的(线程很多的)就很耗时了。
  2. 线程本身需要占用内存。有抛出out of memory的危险

在实际生产环境中,线程的数量必须得到控制。

什么是线程池

联想一下数据库连接池,就知道线程池是啥了。

在线程池中,总有那么几个活跃的线程。当需要时,就从池中取出空闲线程,当完成工作后,再还回去,方便其他人使用。

JDK对线程池的支持

JDK提供了一套Executor框架,用来对线程池的支持。它的核心成员如下:

Executor框架结构_2019-08-27_11-14-53

上面是jdk并发包的核心类。其中ThreadPoolExecutor表示一个线程池。Excecutor扮演线程池工厂的角色,通过它可以取得一个拥有特定功能的线程池。

Executors提供了各种类型的线程池:

线程池类型 作用
newFixedThreadPool() 返回固定线程数量的线程池。线程池中线程数量保持不变。有新任务时,有空闲线程,则执行。若没有则暂存一个任务队列,等到有空闲线程,再执行
newSingleThreadExecutor() 返回只有一个线程的线程池。若有多余任务提交线程池,则存入任务队列,待线程空闲,按先入先出的顺序执行任务队列中的任务。
newCachedThreadPool() 返回一个可根据实际情况调整线程数量的线程池。若有空闲线程可用,则用空闲线程。若没有,创建新的线程处理任务。所有线程完成任务后,将返回线程池进行复用。
newSingleThreadScheduledExecutor() 返回一个ScheduleExecutorService对象,线程池大小为1。它会在给定时间执行某任务。如在固定延时之后,或周期性执行某个任务
newScheduledThreadPool() 返回ScheduleExecutorService对象,但可以指定线程池数量

1、固定大小的线程池

以newFixedThreadPool()为例,简单展示线程池的使用:

复制public class ThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { MyTask task = new MyTask(); ExecutorService es = Executors.newFixedThreadPool(5); //1 for (int i = 0; i < 10; i++) { es.submit(task); //2 } } }

在1处,创建了一个固定大小的线程池,内有5个线程。在2处,依次向线程池提交了10个任务。

上面程序,前5个任务和后5个任务的执行时间正好相差1秒。

2、计划任务

newScheduledThreadPool()。它返回一个ScheduledExecutorService对象,可以根据时间需要进行调度,它其实起到了计划任务的作用。它的一些主要方法如下:

复制public ScheduledFuture<?> schedule(Runnable command, long delay, 
          TimeUnit unit);
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
          long initialDelay, long period, TimeUnit unit); public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);

scheduleAtFixedRate()和scheduleWithFixedDelay()都会对任务进行周期性调度,不过它们有小小区别:

FixedRate和FixedDelay的区别_2019-08-27_14-28-23

下面是scheduleAtFixedRate()的例子,任务执行1秒,调用周期2秒。即每2秒,任务会被执行一次。

复制public class ScheduledExecutorServiceDemo { public static void main(String[] args) { ScheduledExecutorService ses = Executors.newScheduledThreadPool(10); ses.scheduleAtFixedRate(new Runnable() { @Override public void run() { try { Thread.sleep(1000); System.out.println(System.currentTimeMillis()/1000); } catch (InterruptedException e) { e.printStackTrace(); } } }, 0, 2, TimeUnit.SECONDS); } }

上面的执行结果是每次打印时间间隔为2秒。

那如果任务的执行时间超过调度时间,会发生什么呢?会出现堆叠的情况吗,不会,若出现这种情况,任务的周期将变成8秒,即任务完成那一刻才开始下一次任务的调度。

如果采用scheduleWithFixedDelay(),任务的实际间隔将是10秒。

刨根究底:核心线程池的内部实现

对于核心的几个线程池,其内部都使用了ThreadPoolExecutor实现。这里就不给出它们的实现方式了。

下面是ThreadPoolExecutor最重要的构造函数:

复制public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)

参数含义:

  • corePoolSize:指定线程池中的线程数量。
  • maximumPoolSize:指定了线程池中的最大线程数量。
  • keepAliveTime:超过corePoolSize的空闲线程,在多长时间内,会被销毁。
  • unit:keepAliveTime的单位。
  • workQueue:任务队列,被提交但尚未被执行的任务。
  • threadFactory:线程工厂,用来创建线程,用默认的即可。
  • handler:拒绝策略。太多任务而处理不及时,如何拒绝任务。

参数workQueue:被提交但未被执行的任务队列,它是一个BlockingQueue接口的对象,仅用于存放Runnable对象。ThreadPoolExecutor的构造函数中可以使用下面几种BlockingQueue:

  • 直接提交的队列:该功能由SynchronousQueue提供。SynchronousQueue没有容量,若使用它,提交的任务不会被真实的保存,而总是将任务提交给线程执行。若没有空线程,则创建;若线程数量达到顶峰,则执行拒绝策略。通常,使用SynchronousQueue需要很大的maximumPoolSize值,否则很容易执行拒绝策略。

  • 有界的任务队列:可以使用ArrayBlockingQueue实现。它的构造函数必须带一个容量参数,表示最大容量。若实际线程数小于corePoolSize,则优先创建新线程,若大于corePoolSize,则将新任务加入等待队列。若等待队列已满,在总线程数<=maximumPoolSize的前提下,创建新的进行执行任务。若>maximumPoolSize,执行拒绝策略。

    可见,有界队列仅在任务队列装满时,才肯呢过将线程数提升至corePoolSize以上。换句话说,除非系统非常繁忙,否则核心线程数维持在corePoolSize。

  • 无界的任务队列:可通过LinkedBlockingQueue实现。与有界队列相比,除非系统资源耗尽,否则无界队列不存在任务入队失败的情况。

    当系统线程数<corePoolSize,创建新线程;

    若>=corePoolSize,不增加新线程,加入等待队列,若任务创建和处理速度差异太大,无界队列会保持快速增长,知道耗尽系统内存。

  • 优先任务队列(带有执行优先级的队列):通过PriorityBlockingQueue实现,可以控制任务的执行前后顺序。高优先级的任务线执行。

回顾一下:

newFixedThreadPool()方法:它的corePoolSize和maximumPoolSize大小一样,因为固定大小的线程池不存在线程数量的动态变化。同时,它使用无界队列存放任务列表,从而在任务提交频繁的情况下有可能耗尽系统资源。

newSingleThreadExecutor()返回单线程线程池,是newFixedThreadPool()的退化,只是简单将线程数设为1。

newCachedThreadPool()方法返回corePoolSize为0,maximumPoolSize无穷大的线程池。刚开始该线程池无线程,它会将提交的线程加入SynchronousQueue,这是一种立即提交的队列,它会迫使线程池增加新的线程执行任务。当任务执行完毕,在60秒内将线程池不用的线程回收(不留任何空闲线程)。因此,当同时有大量任务提交时,任务执行又不快,那么系统便会开启灯亮线程处理,很快就会耗尽系统资源。

超负载了?使用拒绝策略

JDK内置的拒绝策略:

  • AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作。
  • CallerRunsPolicy策略:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的线程。显然,不会真正丢弃任务,但是,任务提交线程的性能可能急剧下降。
  • DiscardOledestPolicy策略:该策略将丢弃最老的一个请求,即将要执行的一个任务,并尝试再次提交当前任务。
  • DiscardPolicy策略:该策略默默丢弃无法处理的任务,不予任何处理。(若允许丢失,可能是最好的一种方式了)

可以自己扩展RejectedExecutionHandle接口实现自己的拒绝策略,下面代码简单演示了自定义线程池和拒绝策略的使用:

复制public class RejectThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { MyTask task = new MyTask(); ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), Executors.defaultThreadFactory(), new RejectedExecutionHandler(){ @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { System.out.println(r.toString()+" is discard"); } }); //1 for (int i = 0; i < Integer.MAX_VALUE; i++) { es.submit(task); Thread.sleep(10); } } }

上述1处自定义了一个线程池。该线程池有5个常驻线程,并且最大线程数量也是5个。这和固定大小的线程池是一样的。但是它却拥有一个只有10个容器的等待队列。在这里,我们自定义了拒绝策略,只是比DiscardPolicy高级一点点,把拒绝的信息打印出来,在实际应用中,我们可以将其记录到日志上。用来分析系统的负载和任务丢失情况。

自定义线程创建:ThreadFactory

线程池的主要作用是为了线程复用,也就是避免了线程的频繁创建。但是,线程池最开始的线程从何而来呢?答案就是ThreadFactory。

ThreadFactory是一个接口,它只有一个方法,用来创建线程:

复制Thread newThread(Runnable r);

当线程池需要新建线程时,就会调用这个方法。

我们使用自定义线程可以更自由地设置池子中所有线程的状态,甚至可以设置为守护线程:

复制public class TFThreadPoolDemo { public static class MyTask implements Runnable { @Override public void run() { System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { MyTask task = new MyTask(); ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<Runnable>(), new ThreadFactory(){ @Override public Thread newThread(Runnable r) { Thread t= new Thread(r); t.setDaemon(true); System.out.println("create "+t); return t; } } ); for (int i = 0; i < 5; i++) { es.submit(task); } Thread.sleep(2000); } }

扩展线程池

虽然JDK已经帮我们实现了这个稳定的高性能线程池。但如果我们需要对线程池进行一些扩展。比如,想监控每个任务执行的开始和结束时间,或者其他一些自定义增强功能,怎么办呢?

ThreadPoolExecutor:它也是一个可以扩展的线程池。它提供了beforeExecute()、afterExecute()和terminated()三个接口对线程池进行控制。

在默认的ThreadPoolExecutor实现中,提供了空的beforeExecute()、afterExecute()实现。在实际引用中,可以对其扩展实现对线程池运行状态的跟踪,输出一些有用的调试信息,用以帮助系统故障诊断。下面演示对线程池的扩展,在这个扩展中,将记录每一个任务的执行日志:

复制public class ExtThreadPool { public static class MyTask implements Runnable { public String name; public MyTask(String name) { this.name = name; } @Override public void run() { System.out.println("正在执行" + ":Thread ID:" + Thread.currentThread().getId() + ",Task Name=" + name); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { ExecutorService es = new ThreadPoolExecutor(5, 5, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()) { @Override protected void beforeExecute(Thread t, Runnable r) { System.out.println("准备执行:" + ((MyTask) r).name); } @Override protected void afterExecute(Runnable r, Throwable t) { System.out.println("执行完成:" + ((MyTask) r).name); } @Override protected void terminated() { System.out.println("线程池退出"); } }; for (int i = 0; i < 5; i++) { MyTask task = new MyTask("TASK-GEYM-" + i); es.execute(task); Thread.sleep(10); } es.shutdown(); } }/** ...... 正在执行:Thread ID:13,Task Name=TASK-GEYM-2 准备执行:TASK-GEYM-3 正在执行:Thread ID:14,Task Name=TASK-GEYM-3 准备执行:TASK-GEYM-4 正在执行:Thread ID:15,Task Name=TASK-GEYM-4 执行完成:TASK-GEYM-0 执行完成:TASK-GEYM-1 执行完成:TASK-GEYM-2 执行完成:TASK-GEYM-3 执行完成:TASK-GEYM-4 线程池退出 */

可以看到,所有任务执行前后都捕获到了。这对于应用的调试和诊断是非常有帮助的。

合理的选择:优化线程池线程数量

线程池的大小对系统的性能有一定的影响。过大或过小的线程数量都无法发挥最优的系统性能。但是也不用做得非常精确,只要避免极大和极小两种情况即可。

最优池的大小计算公式_2019-08-27_19-16-27

堆栈去哪里了:挖出线程池中被淹没的异常堆栈

先说明一下要解决的问题!

复制public class DivTask implements Runnable { int a, b; public DivTask(int a, int b) { this.a = a; this.b = b; } @Override public void run() { double re = a / b; //1 System.out.println(re); } public static void main(String[] args) { ThreadPoolExecutor pools = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); for(int i=0; i<5; i++) pools.submit(new DivTask(100, i)); // pools.execute(new DivTask(100, i)); } }/** 100.0 25.0 50.0 33.0 */

在上面程序中,只有四个输出结果,少了一个,然而没有报错信息。使用submit会出现这样的情况(execute会抛出异常,具体原因后面再看吧)。

再说下解决方案!

对于程序员来说,没有异常堆栈是最头疼的事。我们可以通过两种方法来讨回异常堆栈:

1 是放弃submit()改用execute(),如注释所示;

复制pools.execute(new DivTask(100, i));

2 是改造submit():

复制 Future<?> submit = pools.submit(new DivTask(100, i));
            submit.get();

以上两种都可以得到部分堆栈信息:

复制Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.ArithmeticException: / by zero at java.util.concurrent.FutureTask.report(FutureTask.java:122) at java.util.concurrent.FutureTask.get(FutureTask.java:192) at geym.ch3.DivTask.main(DivTask.java:21) Caused by: java.lang.ArithmeticException: / by zero at geym.ch3.DivTask.run(DivTask.java:13) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) at java.util.concurrent.FutureTask.run(FutureTask.java:266) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) at java.lang.Thread.run(Thread.java:745)

注意,上面说的是部分!我们只知道异常是在哪里抛出的,也就是代码的1处,但是不确定线程是在哪里提交的,任务的具体提交的位置被淹没了!

3、自己动手,扩展ThreadPoolExecutor线程池(彻底解决的办法)

为了少加班!我们还是自己动手,把堆栈的信息彻底挖出来吧!扩展我们的ThreadPoolExecutor线程池,让它在调度任务之前,先保存一下提交任务线程的堆栈信息。

复制public class TraceThreadPoolExecutor extends ThreadPoolExecutor { public TraceThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { super(corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue); } @Override public void execute(Runnable task) { // TODO Auto-generated method stub super.execute(wrap(task, clientTrace(), Thread.currentThread().getName())); //包装器 } @Override public Future<?> submit(Runnable task) { // TODO Auto-generated method stub return super.submit(wrap(task, clientTrace(), Thread.currentThread().getName())); } private Exception clientTrace() { return new Exception("Client stack trace"); } private Runnable wrap(final Runnable task,final Exception clientStack, String clientThreadName) { //1 return new Runnable() { @Override public void run() { try { task.run(); } catch (Exception e) { clientStack.printStackTrace(); try { throw e; } catch (Exception e1) { // TODO Auto-generated catch block e1.printStackTrace(); } } } }; } public static class DivTask implements Runnable { int a,b; public DivTask(int a,int b) { this.a = a; this.b = b; } @Override public void run() { double re = a/b; System.out.println(re); } } public static void main(String[] args) { ThreadPoolExecutor pools = new TraceThreadPoolExecutor(0, Integer.MAX_VALUE, 0L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); for(int i=0; i<5; i++) pools.execute(new DivTask(100, i)); } }

在wrap()(1处)方法的第2个参数为一个异常,里面保存着提交任务的线程的堆栈信息。该方法将我们传入的Runnable任务进行一层包装,使之能处理异常信息。当任务发生异常时,这个异常会被打印。

分而治之:Fork/Join框架

fork()用来开启分支线程来处理任务,一般会提交给ForkJoinPool线程池进行处理,以节省系统资源。

Join()用来等待fork()的执行分支执行结束。

使用Fork/Join进行数据处理时的总体结构如图所示:

ForkJoin执行逻辑_2019-08-28_15-06-28

由于线程池的优化,提交的任务和线程数量不是一对一的关系。通常是一个线程处理多个任务,每个线程都有一个任务队列。当线程A把任务完成,而线程B还在有一堆任务处理时,线程A会帮助B。B从任务队列顶部拿数据,而A则是从任务队列的底部拿数据,这样有利于避免数据竞争。

互相帮助的线程_2019-08-28_15-13-28

ForkJoinPool的一个重要的接口,可以提交一个ForkJoinTask,ForkJoinTask支持fork()分解以及join()等待的任务,它有两个重要子类:RecursiveAction(无返回值)和RecursiveTask(返回v类型)。

复制public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task);

使用:

复制public class CountTask extends RecursiveTask<Long> { private static final int THRESHOLD = 10000; private long start; private long end; public CountTask(long start, long end) { this.start = start; this.end = end; } public Long compute() { long sum = 0; boolean canCompute = (end - start) < THRESHOLD; if (canCompute) { //求和总数小于THRESHOLD,直接求和 for (long i = start; i <= end; i++) { sum += i; } } else { //分成100个小任务 // 比如start=0,end=100,则每一小步计算2个数 //i=0,lastOne=0+2=2, pos=2+1=3 //i=1,lastOne=2+2=4, pos=4+1=5 //... //i=100 long step = (start + end) / 100; // long pos = start; for (int i = 0; i < 100; i++) { long lastOne = pos + step; if (lastOne > end) lastOne = end; CountTask subTask = new CountTask(pos, lastOne); pos = lastOne + 1; subTask.fork(); sum += subTask.join(); } } return sum; } public static void main(String[] args) { ForkJoinPool forkJoinPool = new ForkJoinPool(); CountTask task = new CountTask(0, 200000L); ForkJoinTask<Long> result = forkJoinPool.submit(task); //1 try { long res = result.get(); System.out.println("sum=" + res); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }

在1处,使用forkJoinPool提交了CountTask,CountTask构造一个计算1到200000求和的任务。在compute()方法中,遵循了下面的逻辑:

复制if (canCompute) {
    //求和总数够小,直接求和
} else {
    //分成若干个小任务 }

在使用ForkJoinPool时需要注意,如果任务的划分层次很深,一直没有返回,可能出现两种情况:

  1. 系统内线程数量越积越多,导致性能严重下降。
  2. 函数调用层次变得很深,导致栈溢出。

JDK的并发容器

JDK提供了好用的并发容器类,使用也很方便,这里主要讲讲这些工具的具体实现。

并发集合介绍

先简单认识一下并发集合:

  • ConcurrentHashMap:高效的并发HashMap。即线程安全的HashMap
  • CopyOnWriteArrayList:属于List,和ArrayList是一族的。在读多少写的场合性能非常好,远远好于Vector。
  • ConcurrentLinkedQueue:线程安全的LinkedList。
  • BlockingQueue:这是一个接口,JDK内部通过链表、数组等方式实现这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap:跳表的实现。这是一个Map,使用跳表的数据结构进行快速查找。
  • Vector也是线程安全的,另外Collections工具类可以帮助我们将任意集合包装成线程安全的工具类

线程安全的HashMap:ConcurrentHashMap

如果获得一个线程安全的HashMap?

第一种方法是:使用Collections.synchronizedMap()方法来包装HashMap

复制static Map<String, String> map = Collections.synchronizedMap(new HashMap<String, String>());

Collections.synchronizedMap()会生成一个名为SynchronizedMap的Map。它使用委托,将自己所有Map相关的功能交给传入的HashMap实现,自己则主要负责保证线程安全。

第二种方法是使用ConcurrentHashMap代替HashMap,这种方式更专业,更适合并发场合。

线程安全的List

Vector是线程安全的List,也可以使用Collections.synchronizedList()方法来包装任意List。

高效读写队列:ConcurrentLinkedQueue

ConcurrentLinkedQueue算是高并发中性能最好的队列了。

具体实现:

1、节点

作为一个链表,自然需要定义一个节点:

复制private static class Node<E>{ volatile E item; volatile Node<E> next;

item用来表示目标元素,比如:放入String,item就是String元素。next表示Node的下一个元素。这样Node就环环相扣,串在一起了。

2、CAS操作

首先,说明一下CAS操作的原理:CAS操作包含三个操作数—— 内存位置的值(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

CAS是一种乐观锁。乐观锁在少写的情况下适用,若是多写的情况,会导致CAS算法不断的进行retry,反而降低了系统性能,多写的情况适合适用悲观锁。

CAS操作_2019-08-29_09-49-50

casItem()表示设置当前Node的item值。cmp为期望值,第二个参数val为目标值。当当前值等于cmp期望值时,就会将目标值设置为val。第二个方法类似。只是它用来设置next字段。

ConcurrentLinkedQueue内部有两个重要的字段,head和tail,分别表示头部和尾部。tail的更新不是及时的,而是有延迟,每次更新会跳跃两个元素。如下图:

Snipaste_2019-09-05_19-09-42

原书中的源码分析我没怎么看懂,有看懂的童鞋欢迎在评论中分享心得

u=3405433144,4098183174&fm=26&gp=0

高效读取:不变模式下的CopyOnWriteArrayList

在很多应用场景中,读操作往往会远远大于写操作。所以这种情况下,我们希望读的性能好些,而写的性能差些也无所谓。

我们知道:在读写锁ReadWriteLock中,读读不互斥,而读写,写写是互斥的。

而现在,JDK还提供了另外一个读写工具类,将读取性能发挥到极致CopyOnWriteArrayList,它的读读不阻塞,读写也不会互相阻塞,只有写写需要同步等待。

它是怎么做到读写不阻塞的?

CopyOnWrite在写入操作时,对原有的数据进行复制成一个副本(而不修改原来的数据),将修改的内容写入复制后的副本中。写完后,再用副本替换原来的数据,这样就不会影响读了。

读取的实现:

Snipaste_2019-09-06_11-15-40

读取没有任何同步和锁的操作,理由是内部数组array不会发生修改,只会被另外一个array替换。

写人的实现:

复制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); //1 newElements[len] = e;//2 setArray(newElements);//3 return true; } finally { lock.unlock(); } }

首先,写入操作使用锁,这个锁仅限于控制写-写的情况。重点在1处,进行了内部元素的复制,而生成一个新数组newElements。2处,将新元素增加到数组末尾,然后,将新数组替换老数组,修改就完成了。整个过程不会影响读取,并且读取线程会实时查看到这个修改(array变量是volatile的)

数据共享通道:BlockingQueue

如何实现多个线程间的数据共享呢?比如,线程A希望给线程B发一个消息,用什么方式好呢?

我们希望线程A能够通知到线程B,又希望线程A不知道线程B的存在。这样对于以后线程B的升级或维护,而不用再修改线程A有帮助。为了实现这一点,我们可以使用一个中间件BlockingQueue来实现。它就相当于一个意见箱,用来作为发表意见者与接收意见者沟通的桥梁。

BlockingQueue和之前提到的ConcurrentLinkedQueue和CopyOnWriteArrayList不同,它是一个接口,而不是具体的实现。它的主要实现如下图:

Snipaste_2019-09-06_13-38-28

ArrayBlockingQueue:基于数组实现。更适合做有界队列,扩展比较不方便

LinkedBlockingQueue:基于链表。更适合做无界队列,因为其内部元素可动态增加。

BlockingQueue为什么适合作为数据共享的通道呢?原因在于Blocking(阻塞)。

当服务线程(指获取队列中消息并进行处理的线程)处理完队列中所有的消息后,服务线程是如何知道下一条消息的到来的?BlockingQueue会让服务线程在队列为空时,进行等待,当有新的消息进入队列后,自动将线程唤醒。

它是如何工作的?以ArrayBlockingQueue为例说明:

写入数据:

它有一个items,items就是用来存放数据的队列。offer()在列队满时,返回false。我们关注的是put()方法,put()也是将元素压入队列队尾,但队列满了,它会一直等待,直到队列中有空闲位置。

读取数据:

poll()、take()两个方法都能从队列中的头部弹出一个元素。不同的是:如果队列为空poll()方法直接返回null。而take()方法会等待,直到队列内有可用元素。

从上面可以看出,put()take()方法才是Blocking的关键。为了做好通知和等待两件事,ArrayBlockingQueue定义了三个字段:

Snipaste_2019-09-06_14-18-02

take()操作:

当队列为空时,让当前线程等待在notEmpty,新元素入队时,则进行一次notEmpty上的通知。

复制public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); //1 return dequeue(); } finally { lock.unlock(); } }

在1处,要求线程在notEmpty对象中等待。下面是元素入队的一段代码:

复制/**
 * 在当前put位置插入元素、进给和信号。
 * 只有在持有锁时才调用。
 */
private void enqueue(E x) { final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); //1 }

在1处,当新元素入列后,需要通知等待在notEmpty上的线程,让它们继续工作。

put()操作:

当队列满时,需要让 压入线程 等待:

复制public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); //1 enqueue(e); } finally { lock.unlock(); } }

在1处,队列满时,在notFull对象中等待。

当然,当元素从队列中挪走时,队列中有空位时,自然也要通知等待入队的线程:

复制private E dequeue() { 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(); //1 return x; }

我们还会在“5.3 生产者消费者”一节中,看到他们的身影。在那里,我们可以更清楚地看到如何使用BlockingQueue解耦生产者和消费者。

随机数据结构—跳表:SkipList

介绍跳表

除了常用的哈希表外,还有一种有趣的数据结构:跳表。跳表的本质是同时维护了多个链表,并且链表是分层的。跳表的查询性能要比哈希表好。如下图

跳表结构_2019-09-06_16-48-36

最低层的链表维护了跳表中所有的元素,每上面一层都是下面一层的子集,一个元素插入哪一层完全随机,运气不好可能得到性能最差的结构。但是实际工作中,它还是表现得很好的。

跳表内所有元素都是排序的。查找时,从顶级链表开始找,一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续查找。比如要查找上面跳表结构中的7。查找过程如下图所示:

跳表的查找过程_2019-09-06_16-55-07

跳表显然是一种空间换时间的算法。

使用跳表实现的Map和使用哈希算法实现的Map的另一个不同之处是:跳表实现的Map是会排序的,而哈希实现的Map不排序。若需要一个有序的Map,那就选择跳表。

使用:ConcurrentSkipListMap

实现这一数据结构的类是ConcurrentSkipListMap。简单使用:

复制Map<Integer, Integer> map = new ConcurrentSkipListMap<>();
for (int i = 0; i<30; i++) map.put(i,i); for (Map.Entry<Integer, Integer> entry: map.entrySet() ) { System.out.println(entry.getKey()); }

跳表有三个关键的数据结构组成:

  • Node<K,V>:(节点,含有key、value、next元素,对Node的所有操作,都使用CAS方法)
  • Index<K,V>:(表示索引,它的内部包装了node,同时增加了向下和向右的引用),整个跳表就是根据Index进行全网的组织的。
  • HeadIndex:表示链表头部的第一个Index。它继承自Index。

下面是三种数据结构的代码:

Node_2019-09-06_17-20-04

Index_2019-09-06_17-20-23

HeadIndex_2019-09-06_17-20-38

猜你喜欢

转载自www.cnblogs.com/lvoooop/p/12070828.html