Java——多线程

八、多线程编程

1、多线程基础

首先我们需要知道现代的操作系统,不管是windows、macOS、还是Linux,都是可以执行多任务的。否则你也不能边打开网页、边听着音乐,还登录着QQ。

在我们感觉上,cpu好像是在同时执行所有的任务,那是因为我们被计算机给“欺骗”了,在操作系统中,多个程序是被cpu交替执行的,也就是说,cpu会让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,只是这个时间太短,我们感觉不出来罢了。

即使对于今天的多核CPU,因为你的任务通常也是多于核数,所以任务仍然是交替执行。

1.1 进程

要学习线程,就必须得提到 进程 了,在计算机中,我们把一个任务称为一个进程,像你使用的浏览器就是一个进程。

然而在进程中又需要执行多个字任务。例如,我们在使用word时,可以一边打字,Word还会一边进行拼写检查,甚至还在后台打印,这种字任务就称为线程。

1.2 进程与线程

进程与线程的关系:

  • 进程是分配和调度资源的独立单位,而线程是CPU调度的基本单元。

  • 线程 依存于 进程 而存在,没有单独存在的线程。所以进程结束后它拥有的所有线程也会被销毁,而线程的结束不会影响进程的存在。

  • 一个进程可以有多个线程,但只会有一个线程。多个线程共享进程的资源。

1.3 工作模式

一个应用程序,既可以有多进程,可以多线程工作,因此实现多任务,就有以下几种方法:

  • 多进程模式(每个进程只有一个线程)
  • 多线程模式(一个进程有多个线程)
  • 多进程 + 多线程模式(复杂度最高)

对于选择上面的哪种模式,就要考虑进程和线程的优缺点:

  • 多进程的缺点:
    • 创建进程比创建线程开销更大;
    • 进程之间的通信比线程的通信要慢。
  • 多进程的优点:
    • 多进程更加稳定,因为一个进程崩溃,不会影响到其他的进程。而在多线程的情况下,任何一个线程崩溃会导致整个进程崩溃。

2、创建线程

在 Java 中创建一个新线程是非常容易的,有以下方法:

  • 方法一:从Thread派生一个自定义类,然后覆写run()方法:

    /*方法一*/
    /*MyThread01.java*/
    public class MyThread01 extends Thread {
        @Override
        public void run() {
            System.out.println("启动了线程MyThread01");
        }
    }
    
    @Test
    public void m0() {
      Thread myThread01 = new MyThread01();
      myThread01.start();	//启动新线程,`start()` 方法会自动调用覆写的 `run()` 方法
    }
    
  • 创建一个 Thread 实例,传入一个 Runnable 实例:

    /*MyThread02.java*/
    public class MyThread02 implements Runnable {
        @Override
        public void run() {
            System.out.println("启动了线程MyThread02");
        }
    }
    
    @Test
    public void m1() {
      Thread myThread01 = new MyThread01();
      Thread myThread02= new Thread(new MyThread02());
      myThread01.start();
      myThread02.start();
    }
    

Thread.java中的 start() 方法通知 “线程管理器” 此线程已经准备就绪,等待调用该线程的 run() 方法。具体什么时候调用,需要看系统的安排,有了异步执行的效果。因此不能直接调用 thread.run() 方法,否则就变成了同步,相当于调用了一个普通的方法。

上面的代码可能还看不出多线程和单线程的区别,我们可以再写一个例子:

/*创建自定义线程类*/
public class MyThread02 implements Runnable {
    @Override
    public void run() {
        for(int i=0; i < 10; ++i) {
            System.out.println(Thread.currentThread().getName()+ "-" + i);
            int time = (int) (Math.random() * 100);
            try {
                Thread.sleep(time);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
/*测试*/
@Test
public void m0() {
  Thread myThread01 = new Thread(new MyThread02());
  myThread01.start();
  for(int i=0; i < 10; ++i) {
    System.out.println(Thread.currentThread().getName()+ "-" + i);
    int time = (int) (Math.random() * 100);
    try {
      Thread.sleep(time);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}
/*输出结果*/
main-0
Thread-0-0
main-1
main-2
main-3
Thread-0-1
main-4
main-5
Thread-0-2
Thread-0-3
main-6
Thread-0-4
main-7
main-8
Thread-0-5
main-9

2.1 Runnable接口

打开Thread.java ,我们可以发现,Thread 类实际上就是继承了 Runnable 接口,它们之间具有多态关系。

public class Thread implements Runnable {

但是继承 Thread 类创建新线程的最大缺陷就是:Java不支持多继承,如果想要创建的线程类已经有了一个父类,这时就需要实现 Runnable 接口来对应。

2.2 优先级

可以对线程设定优先级,设定优先级的方法是:

Thread.setPriority(int n) // 1~10, 默认值5

优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。

3、线程的状态

在Java程序中,一个线程对象只能调用一次 start() 方法启动新线程,并在新线程中执行 run() 方法。一旦 run() 方法执行完毕,线程也就结束了。

Java线程的状态有以下几种:

  • New:新创建的线程,还没有执行;

  • Runnable:执行中的线程,正在执行 run() 方法中的代码;

  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;

  • Waiting:运行中的线程,因为某些操作在等待中;

  • Timed Waiting:运行中的线程,因为 sleep() 方法正在计时等待;

  • Terminated:因为run()方法执行完毕,线程已终止。

当线程启动之后,可以在RunnableBlockedWaitingTimed Waiting这几个状态之间切换,直到最后变成 Terminated 状态,线程终止。而终止的原因也有多种:

  • 正常终止:run() 方法执行到 return 语句返回;
  • 意外终止:run() 方法因为未捕获到异常而终止;
  • 对某个线程对象调用了 stop() 方法而终止(强烈不推荐使用)。

一个线程A 可以等待另外一个线程B直到B执行结束才开始执行。例如,main 线程等待 myThread01 线程结束才再运行:

@Test
public void m0() throws InterruptedException {
  Thread myThread01 = new Thread(new MyThread02());
  myThread01.start();
  myThread01.join();
  System.out.println("end");
}

join(long) 的重载方法可以指定一个等待时间,超过等待时间之后就不再继续等待。

4、中断线程

线程的使用中,经常需要中断线程,例如:用户正在下载一个100MB的文件,但是突然又不想要了,就会在下载过程中点击“取消”,这时,程序就需要中断下载线程的执行。

中断线程有两种方法:

  • 在其他线程中对目标线程调用 interrupt() 方法;
  • 设置标志位,通过running 标志位判断线程是否应该继续执行。

方法一:调用 interrupt() 方法

我们先来看一下刚开始写测试代码时,出现的一个java.lang.InterruptedException: sleep interrupted问题代码

/*MyThread03.java*/
public class MyThread03 extends Thread {

    private int n = 0;

    @Override
    public void run() {
        while (!this.isInterrupted()) {
            n++;
            System.out.println(n);
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
/*测试*/
@Test
public void m1() throws InterruptedException {
  Thread myThread03 = new MyThread03();
  myThread03.start();
  Thread.sleep(5);
  myThread03.interrupt();
  myThread03.join();
  System.out.println("end");
}

上面的输出结果如下:

//输出结果
1
2
3
4
5
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at thread.MyThread03.run(MyThread03.java:19)
6
7
8
9
10
11
12
.....

我们发现在对 myThread03 线程调用 myThread03.interrupt() 之后,由于该线程处于等待状态,所以会立刻抛出 InterruptedException ,但此时线程仍然没有中断,因此会一直执行。

处理方法是:在线程捕捉到 InterruptedException 后,就知道是有其他线程对其调用了中断方法,这时需要立刻结束运行该线程。

/*修改MyThread03.java*/
public class MyThread03 extends Thread {

    private int n = 0;

    @Override
    public void run() {
        while (!this.isInterrupted()) {
            n++;
            System.out.println(n);
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
              	/*在捕获到异常后,跳出循环*/
                System.out.println("============interrupted==============");
                break;
            }
        }
    }
}

方法二:通过设置running 标志位

/*MyThread04.java*/
public class MyThread04 extends Thread {
		//使用volatile关键字标记
    public volatile boolean running = true;
    private int n = 0;

    @Override
    public void run() {
        while (running) {
            n++;
            System.out.println(n);
        }
    }
}
/*测试*/
@Test
public void m1() throws InterruptedException {
  MyThread04 myThread04 = new MyThread04();
  myThread04.start();

  Thread.sleep(1);
  //中断线程
  myThread04.running = false;
  myThread04.join();
  System.out.println("================end==============");
}

为什么MyThread04 的标志位 running 需要使用 volatile 标记?

MyThread04 类中的 running ,我们把它作为了一个线程间共享的变量,在其他线程将 running 赋值为false 后,需要及时结束 运行中的线程。

但是在Java虚拟机中,变量的值保存在主内存中,当线程访问变量时,它只会获取到一个副本,修改该变量后也会先保存在自己的工作区,当然修改后的值需要保存下来,不靠谱的是虚拟机会在某个不确定的时刻把值写回主内存。

上述的问题就造成了,多线程之间共享的变量不一致。在其他线程都已经 running = false 之后,主内存中的running值还是 true ,不能及时结束线程。

标示volatile 关键字的目的就是告诉虚拟机:

  • 我每次都要访问该变量的最新值;
  • 该变量修改后,要立刻写回到主内存。

5、守护线程

Java程序的入口就是main 线程,在main 线程中又可以启动其他线程。在所有的线程结束之后,JVM才会退出。

意思就是说:只要有一个线程没有退出,JVM进程就不会退出。

但是就有一种线程,它的目的就是无限循环,例如,一个执行定时任务的线程:

class TimerThread extends Thread {
    @Override
    public void run() {
        while (true) {
            //执行任务
          	System.out.println(“定时任务执行”);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

根据代码可以知道上面这个线程只要启动了之后,就不会结束。那问题就是:谁负责结束这个线程呢?

事实上,不需要任何线程来负责它们的结束,我们可以使用守护线程(Daemon Thread)。

守护线程就是为其他线程服务的线程。在其他线程都执行结束后,虚拟机是不管守护线程是否结束的,会自动退出。

5.1 创建守护线程

方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

Thread t = new MyThread();
t.setDaemon(true);	//标记为守护线程
t.start();

需要注意的是:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

6、线程同步

在多线程同时运行时,线程的调度是由操作系统决定的,程序根本不能决定。就时候问题就来了:如果多个线程读、修改共享变量,就会出现数据不一致的问题。

下面的例子,一个线程相当于一个售票窗口,总共有5张票,每个窗口一次会卖出5张,正常来说,应该是有一个窗口会买不到票,但是如下程序结束后,2个窗口都卖出了票,余票变成了-5。

public class Ticket {
    public static void main(String[] args) throws InterruptedException {
        SellThread t1 = new SellThread();
        t1.start();

        SellThread t2 = new SellThread();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(Count.total);
    }
}

class Count {
    public static int total = 5;
}

class SellThread extends Thread {
    @Override
    public void run() {
        int total = Count.total;
        if (total > 0) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Count.total = Count.total - 5;
            System.out.println("卖出5张票");
        }else {
            System.out.println("没有票了");
        }
    }
}
//输出结果
卖出5张票
卖出5张票
余票为-5

上面的错误在于:

在这里插入图片描述

要想解决对共享变量的读写问题,必须保证以原子的方法执行售票,即某一线程在执行时,其他线程必须等待:

在这里插入图片描述

通过加锁和解锁的方式保证了一段代码的原子性,从而解决了共享变量的问题。在Java中,是使用 synchronized 关键字对一个对象进行加锁:

synchronized(lock) {
    n = n + 1;
}
/*用 synchronized 改写上面的代码*/
class SellThread extends Thread {
    @Override
    public void run() {
        synchronized (Count.lock) {	//获取锁
            int total = Count.total;
            if (total > 0) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Count.total = Count.total - 5;
                System.out.println("卖出5张票");
            }else {
                System.out.println("没有票了");
            }
        }	//释放锁
    }
}

两个线程在执行各自的 synchronized(Counter.lock) { ... } 代码块时,必须先获得锁,才能开始售票。

使用 synchronized 解决了多线程同步访问共享变量的问题。但是,它的缺点是带来了性能下降的问题。因此 synchronized 代码块不能并发执行,加锁和解锁也需要消耗时间。

在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁:

public void add(int m) {
    synchronized (obj) {
        if (m < 0) {
            throw new RuntimeException();
        }
        this.value += m;
    } // 无论有无异常,都会在此释放锁
}

6.1 不需要synchronized的操作

JVM规范定义了几种原子操作:

  • 基本类型(longdouble除外)赋值,例如:int n = m
  • 引用类型赋值,例如:List list = anotherList

例如:

public void set(String s) {
    this.value = s;
}

但如果是多行赋值语句,就必须保证是同步操作。

7、同步方法

8、死锁

对于多线程来说,必须要面临死锁的问题。看下面的场景:

  • 线程1获得了资源A;
  • 线程2获得了资源B。

随后(假设只有一个资源A、一个资源B,并且此时线程1并没有释放资源A,线程2也没有释放资源B),

  • 线程1想要获得资源B,失败,等待。
  • 线程2想要获得资源A,失败,等待。

这时候就发生了死锁,2个线程都在无限等待着。

对于死锁的解决,可以规定获取资源的顺序,即严格按照先获取资源A,再获取资源B的顺序。

9、使用wait和notify

synchronized 解决了多线程的竞争问题。多个线程同时向队列中添加任务,可以用 synchronized 加锁解决:

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }
}

但是 synchronized 并没有解决多线程协调的问题。例如,我们在编写一个getTask() 方法:

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
    }

    public synchronized String getTask() {
        while (queue.isEmpty()) {
        }
        return queue.remove();
    }
}

上面的代码的问题是:在线程执行 getTash() 时,如果为空,就会一直循环,也根本不会释放 this 锁,而其他线程因为获取不到 this 锁,不能添加任务到队列。

我们想要执行的效果是:

  • 线程1可以调用 addTask() 不断添加任务到队列;
  • 线程2可以调用 getTask() 获取任务。如果队列为空,就应该等待,同时释放锁,知道队列中至少有一个任务。

因此,多线程协调运行的原则就是:当条件不满足,线程就等待;当满足时,线程被唤醒。

/*修改 TaskQueue,并测试*/
public class Main {
    public static void main(String[] args) throws InterruptedException {
        var q = new TaskQueue();
        var ts = new ArrayList<Thread>();
        for (int i=0; i<5; i++) {
            var t = new Thread() {
                public void run() {
                    // 执行task:
                    while (true) {
                        try {
                            String s = q.getTask();
                            System.out.println("execute task: " + s);
                        } catch (InterruptedException e) {
                            return;
                        }
                    }
                }
            };
            t.start();
            ts.add(t);
        }
        var add = new Thread(() -> {
            for (int i=0; i<10; i++) {
                // 放入task:
                String s = "t-" + Math.random();
                System.out.println("add task: " + s);
                q.addTask(s);
                try { Thread.sleep(100); } catch(InterruptedException e) {}
            }
        });
        add.start();
        add.join();
        Thread.sleep(100);
        for (var t : ts) {
            t.interrupt();
        }
    }
}

class TaskQueue {
    Queue<String> queue = new LinkedList<>();

    public synchronized void addTask(String s) {
        this.queue.add(s);
      	//唤醒在等待的线程
        this.notifyAll();
    }

    public synchronized String getTask() throws InterruptedException {
      	//当条件不满足时,线程就等待
        while (queue.isEmpty()) {
          	//释放 this 锁
            this.wait();
          	//重新获取 this 锁
        }
        return queue.remove();
    }
}

对于 wait() 需要注意的是:

  • 该方法必须在当前获取的锁对象上调用,上面获取的锁是 this 锁,因此调用 this.wait()
  • wait() 方法调用时,放释放线程获得的锁,wait() 方法返回后,会重新试图获取锁。

对于 notifyAll()notify() 需要注意的是:

  • 使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个。通常使用 notifyAll() 更加安全,因为我们我们考虑不周,会有些线程永远没有被唤醒。

  • 假设现在有3个线程被唤醒,但是也只有一个能获取到 this 锁,剩下两个将继续等待。再次被唤醒时,需要再判断队列是否为空,所以下面的写法是错误的:

    /*错误写法*/
    public synchronized String getTask() throws InterruptedException {
        if (queue.isEmpty()) {
            this.wait();
        }
        return queue.remove();
    }
    

10、使用ReentrantLock

从Java 5开始,引入了一个高级的处理并发的java.util.concurrent包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。

虽然Java语言直接提供的 synchronized 关键字能用于加锁,但是这种锁一是重,而是获取的时候必须一直等待,没有额外的尝试机制。

java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁。

public class Counter {
    private final Lock lock = new ReentrantLock();
    private int count;

    public void add(int n) {
        lock.lock();
        try {
            count += n;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。

10.1 和synchronized不同

synchronized不同的是,ReentrantLock 可以尝试获取锁:

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        ...
    } finally {
        lock.unlock();
    }
}

上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。

所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。

11、使用Condition

从上节知道,使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized进行线程同步。但是在处理多线程协调问题时,我们就需要使用 Conditon 来实现 waitnotify 的功能。

下面通过 ReentrantLockCondition 来实现 TaskQueue

public class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例。

类似的,Condition提供了await()signal()signalAll()

await()可以在等待指定时间后,如果还没有被唤醒,可以自己醒来:

if (condition.await(1, TimeUnit.SECOND)) {
    // 被其他线程唤醒
} else {
    // 指定时间内没有被其他线程唤醒
}

通过使用Condition配合Lock,我们可以实现更灵活的线程同步。

12、使用ReadWriteLock

前面的那些,不管是 synchronized,还是 ReentrantLock,都保证了只有一个线程能够执行临界区代码。但有时候这种保护有点过头。例如,在add() 方法中因为要修改变量,所以应该加锁,但是在 get() 方法中只读取数据,不会修改数据,此时允许多个线程同时调用不会有问题。

我们想要实现的状态如下:

允许不允许
不允许不允许

使用 ReadWriteLock 可以实现该状态,保证了:

  • 只允许一个线程写入——其他线程既不允许读,也不允许写
  • 允许多个线程同时读(提高性能)
public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();	//获取读锁
    private final Lock wlock = rwlock.writeLock();	//获取写锁
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}

允许多个线程同时获得读锁,提高了并发读得效率。

13、使用StampedLock

在前面的 ReadWriteLock 中,可以发现:如果有线程在读,写线程需要等待读线程释放锁之后才能获取写锁,因为读的过程中不允许写,这是一种悲观的读锁

为了进一步提高并发执行效率,Java8引入了新的写锁:StampedLock

StampedLock :在读的过程中允许获取写锁写入,但是这样以来,读取到的数据就有可能不一致,所以,还需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁

  • 乐观锁:估计读的过程中不会写,十分的乐观。
  • 悲观锁:认为读的过程中总会发生写。
public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 释放写锁
        }
    }

    public double distance() throws InterruptedException {
        long stamp = stampedLock.tryOptimisticRead();
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        Thread.sleep(5);
        if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
            System.out.println("有线程写入");
            stamp = stampedLock.readLock(); // 获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);

    }

}

在读取时,我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果不成功,说明有数据写入,需要重新读取。由于写入的概率不高,在大多数情况下通过乐观锁就可以获取数据,极少使用悲观锁。

StampedLock把读锁细分为乐观读和悲观读,优点是能进一步提升并发效率。缺点是:

  • 代码更加复杂;
  • StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。

14、使用Concurrent集合

我们前面已经通过 ReentrantLockCondition 实现了 TaskQueue,可以看 11、使用Condition

TaskQueueBlockingQueue :当一个线程调用其中的 getTash() 时,如果队列为空会让线程等待,直到队列不为空时被唤醒。

在Java标准库的 java.util.concurrent 包下提供了线程安全的集合: ArrayBlockingQueue

除此之外,针对ListMapSetDeque等,java.util.concurrent包也提供了对应的并发集合类。

interfacenon-thread-safe线程安全
ListArrayListCopyOnWriteArrayList
MapHashMapConcurrentHashMap
SetHashSet / TreeSetCopyOnWriteArraySet
QueueArrayDeque / LinkedListArrayBlockingQueue / LinkedBlockingQueue
DequeArrayDeque / LinkedListLinkedBlockingDeque

线程安全集合和非线程安全集合类的用法相同。例如:

Map<String, String> map = ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");

java.util.Collections工具类还提供了一个旧的线程安全集合转换器,可以这么用:

Map unsafeMap = new HashMap();
Map threadSafeMap = Collections.synchronizedMap(unsafeMap);

但是它实际上是用一个包装类包装了非线程安全的Map,然后对所有读写方法都用synchronized加锁,这样获得的线程安全集合的性能比java.util.concurrent集合要低很多,所以不推荐使用。

15、使用Atomic

Java的 java.util.concurrent 包除了提供底层锁、并发集合,还提供了一组原子操作的封装类,它们位于 java.util.concurrent.atomic 包。

AtomicInteger 为例,它的主要操作有:

  • 增加值并返回新值:int addAndGet(int delta)
  • 加1后返回新值:int incrementAndGet()
  • 获取当前值:int get()
  • 用CAS方式设置:int compareAndSet(int expect, int update)

Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。

如果我们自己通过CAS编写incrementAndGet(),它大概长这样:

public int incrementAndGet(AtomicInteger var) {
  int pre, next;
  do {
    prev = var.get();
    next = prev + 1;
  } while(!var.compareAndSet(prev, next));
  return next;
}

CAS是指,在 var.compareAndSet(prev, next) 操作中,如果var的当前值是prev,那么就更新为next,返回true。如果不是prev ,说明值发生了修改,就什么也不干,返回 false,然后重新获取值,修改。

通过CAS操作并配合 do...while 循环,即使其他线程修改了 AtomicInteger 的值,最终的结果也正确。

通常情况下,我们不需要自己实现复杂的并发操作,而是用 incrementAndGet() 这样封装好的方法。

在高度竞争的情况下,还可以使用Java8提供的LongAdderLongAccumulator

16、使用线程池

使用线程池的原因是:创建线程需要操作系统资源(线程资源、栈空间等),频繁创建和销毁大量线程需要消耗大量时间。

我们希望能够把很多的小任务让一组线程执行,而不是一个任务用一个线程执行。这一组线程就是线程池。

在线程池内维护了若干线程,没有任务时,这些线程就出了等待状态。如果有新任务,就分配一个空闲线程执行。如果这时所有线程都在忙碌,有两种处理方法:

  • 让新任务等待;
  • 在创建一个新线程处理。

在Java标准库中提供了 ExecutorService 接口表示线程池,它的常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。

线程池的典型用法如下:

// 创建固定大小的线程池:
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
//关闭线程池
executor.shutdown();

线程池在程序结束的时候要关闭。关闭的方法有3种:

  • shutdown():等正在执行的任务完成后,再关闭线程池。
  • shutdownNow() :立刻停止正在执行的任务。
  • awaitTermination() :等待指定的时间关闭线程池。

下面我们以FixedThreadPool为例,看看线程池的执行逻辑:

public class Main {
    public static void main(String[] args) {
        // 创建一个固定大小为4的线程池:
        ExecutorService es = Executors.newFixedThreadPool(4);
        for (int i = 0; i < 8; i++) {
          	//一次性放入8个任务,先会执行4个,在线程空闲后执行后面4个
            es.submit(new Task("" + i));
        }
        // 关闭线程池:
        es.shutdown();
    }
}

class Task extends Thread {

    private String name;

    public Task(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        System.out.println("启动任务:" + name);

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("执行结束:" + name);
    }
}

如果我们把线程池控制在 4~10个之间动态调整该怎么办?打开 Executors.newCachedThreadPool()方法的源码:

public static ExecutorService newCachedThreadPool() {
  return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                60L, TimeUnit.SECONDS,
                                new SynchronousQueue<Runnable>());
}

因此,我们可以这样写:

ExecutorService es = new ThreadPoolExecutor(4, 10,
                                60L, TimeUnit.SECONDS,
                                new SynchronousQueue<Runnable>());

16.1 ScheduledThreadPool

ScheduledThreadPool 是针对需要定期的、反复执行的任务。例如,每5分钟报时。

创建一个 ScheduledThreadPool仍然是通过Executors类:

ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);

虽然是定期重复执行,但也有这3种执行状态:

  • 特定延迟后只执行一次:

    // 1秒后执行一次性任务:
    ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);
    
  • 以固定的每3秒执行:

    // 2秒后开始执行定时任务,每3秒执行:
    ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);
    
  • 以固定的3秒为间隔执行:

    // 2秒后开始执行定时任务,以3秒为间隔执行:
    ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);
    

后两种方式怎么看着没有区别,我们通过一张图就很容易理解:

在这里插入图片描述

Java标准库还提供了一个java.util.Timer类,这个类也可以定期执行任务,但是,一个Timer只会对应一个Thread,所以,一个Timer只能定期执行一个任务,多个定时任务必须启动多个Timer,而一个ScheduledThreadPool就可以调度多个定时任务,所以,我们完全可以用ScheduledThreadPool取代旧的Timer

17、使用Future

在使用线程池执行任务时,我们提交的任务只需要实现 Runnable 接口就可以。

如果现在我们想要方法给返回值,就只能保存在变量中,再通过额外的方法读取,非常不便。所以,Java提供了一个 Callable 接口:

public class Task implements Callable<String> {
    @Override
    public String call() throws Exception {
        return "";
    }
}

那么我们现在怎么才能获取到异步执行的结果呢,线程提交任务的 ExecutorService.submit() 方法,会返回一个 Future 实例,它代表了一个未来能获取结果的对象。

ExecutorService executor = Executors.newFixedThreadPool(4); 
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞

在调用 Future 对象的 get() 方法时,如果异步任务完成,就直接获得到了结果。否则, get() 会阻塞,执行任务结束才返回结果。

Future 接口定义的方法有:

  • get():获取结果(可能会等待)
  • get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
  • cancel(boolean mayInterruptIfRunning):取消当前任务;
  • isDone():判断任务是否已完成。

18、使用CompletableFuture

使用 Future 可以获得异步执行的结果,但是在调用 get() 方法时,如果还没有执行结束,会被迫等待。

因此,在Java8中引入了 CompletableFuture ,它是对 Future 的改进,可以传入回调对象,当异步任务完成或者发生异常时,自动的调用回调对象的回调方法。

public class Main {
    public static void main(String[] args) throws Exception {
        // 创建异步执行任务:
        CompletableFuture<Double> cf = CompletableFuture.supplyAsync(new FetchPrice());
        // 如果执行成功:
        cf.thenAccept((result) -> {
            System.out.println("price: " + result);
        });
        // 如果执行异常:
        cf.exceptionally((e) -> {
            e.printStackTrace();
            System.out.println("发生异常");
            return null;
        });
        // 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
        Thread.sleep(2000);
    }
}

class FetchPrice implements Supplier<Double> {
    @Override
    public Double get() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        if (Math.random() < 0.3) {
            throw new RuntimeException("fetch price failed!");
        }
        return 5 + Math.random() * 20;
    }
}

创建一个 CompletableFuture 是通过 CompletableFuture.supplyAsync 实现的,它需要一个实现了 Supplier 接口的对象。

接着,CompletableFuture 就会被提交给默认的线程池执行,我们需要定义的就是 CompletableFuture 执行完成或者异常时需要回调的实例。

在完成时,CompletableFuture会调用Consumer对象:

public interface Consumer<T> {
    void accept(T t);
}

在异常时,CompletableFuture会调用Function对象:

public interface Function<T, R> {
    R apply(T t);
}

这里我们都用lambda语法简化了代码。

CompletableFuture 的优点是:

  • 异步任务结束时,自动回调方法;
  • 异步任务出错时,自动回调方法;
  • 主线程只用设置好回调,就不用关心异步任务的执行。

18.1 多个CompletableFuture串行执行

18.2 多个CompletableFuture并行执行

这两个等用到的时候再看吧。

19、使用Fork/Join

Java7开始引入了一种新的 Fork/Join 线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。例如,我们需要计算一个超大数组的和,如果通过 for 循环执行需要的时间较多,这时,我们可以把数组拆分成两部分,用两个线程分别计算,最后加起来就是最终结果。当然,如果两部分还是太大,还可以继续拆分,用4个线程来执行。

在这里插入图片描述

这就是 Fork/Join 的原理:判断一个任务是否足够小,如果是,就直接计算,否则,就拆分为几个小任务分别计算。

public class Main {
    public static void main(String[] args) throws Exception {
        // 创建2000个随机数组成的数组:
        long[] array = new long[2000];
        long expectedSum = 0;
        for (int i = 0; i < array.length; i++) {
            array[i] = random();
            expectedSum += array[i];
        }
        System.out.println("Expected sum: " + expectedSum);

        // fork/join:
        ForkJoinTask<Long> task = new SumTask(array, 0, array.length);
        long startTime = System.currentTimeMillis();
        Long result = ForkJoinPool.commonPool().invoke(task);
        long endTime = System.currentTimeMillis();
        System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
    }

    static Random random = new Random(0);

    static long random() {
        return random.nextInt(10000);
    }
}
/*核心代码,SumTask继承了RecursiveTask,并覆写了compute()方法。*/
class SumTask extends RecursiveTask<Long> {
    static final int THRESHOLD = 500;
    long[] array;
    int start;
    int end;

    SumTask(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }


    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // 如果任务足够小,直接计算:
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += this.array[i];
                // 故意放慢计算速度:
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                }
            }
            return sum;
        }
        // 任务太大,一分为二:
        int middle = (end + start) / 2;
        System.out.println(String.format("split %d~%d ==> %d~%d, %d~%d", start, end, start, middle, middle, end));
        SumTask subtask1 = new SumTask(this.array, start, middle);
        SumTask subtask2 = new SumTask(this.array, middle, end);
        invokeAll(subtask1, subtask2);
        Long subresult1 = subtask1.join();
        Long subresult2 = subtask2.join();
        Long result = subresult1 + subresult2;
        System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result);
        return result;
    }
}

Fork/Join线程池在Java标准库中就有应用。Java标准库提供的java.util.Arrays.parallelSort(array)可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。

20、使用ThreadLocal

在Web应用中,每一个用户请求页面时,我们都会创建一个任务,类似:

public void process(User user) {
    checkPermission();
    doWork();
    saveStatus();
    sendResponse();
}

然后让线程池去执行这些任务,但是我们想怎么在一个线程中传递状态呢?总不能把 User 实例传到每个方法中吧。在Java中,提供了一个特殊的 ThreadLocal ,它可以在一个线程中横跨多个方法传递需要的对象。而这个对象我们称为上下文(Context),它是一种状态,可以是用户信息等。

下面我们看一个例子:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author : 
 * @date : 2020-03-12 22:28
 * @description :
 */

public class Main {

    public static void main(String[] args) throws Exception {
        ExecutorService es = Executors.newFixedThreadPool(3);
        String[] users = new String[] { "Bob", "Alice", "Tim", "Mike", "Lily", "Jack", "Bush" };
        for (String user : users) {
            es.submit(new Task(user));
        }
        es.shutdown();
    }

}

/*为了保证 ThreadLocal 的释放,可以通过实现 AutoCloseable接口配合 try(resource){...}*/
class UserContext implements AutoCloseable {

  	//ThreadLocal实例通常设置为静态字段
    private static final ThreadLocal<String> ctx = new ThreadLocal<>();

    public UserContext(String user) {
        ctx.set(user);
        System.out.printf("[%s] init user %s...\n", Thread.currentThread().getName(), UserContext.getUser());
    }

    public static String getUser() {
        return ctx.get();
    }

    @Override
    public void close() throws Exception {
        System.out.printf("[%s] cleanup for user %s...\n", Thread.currentThread().getName(), getUser());
        ctx.remove();
    }
}

class Task implements Runnable {

    private String name;

    public Task(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        try (UserContext uc = new UserContext(this.name)) {
            new Task1().process();
            new Task2().process();
            new Task3().process();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class Task1 {
    public void process() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.printf("[%s] check user %s...\n", Thread.currentThread().getName(), UserContext.getUser());
    }
}

class Task2 {
    public void process() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.printf("[%s] %s registered ok.\n", Thread.currentThread().getName(), UserContext.getUser());
    }
}

class Task3 {
    public void process() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        System.out.printf("[%s] work of %s has done.\n", Thread.currentThread().getName(),
                UserContext.getUser());
    }
}

注意:普通的方法调用一定是在同一个线程中执行的,所以,Task1Task2以及Task3UserContext.getUser()获取的User对象是同一个实例。

我们可以把ThreadLocal 看成是一个全局的Map<Thread, Object>:每个线程在获取上下文时,通过Thread 自身最为key去获取。不同线程的 ThreadLocal 互不干扰。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值