JAVA多线程核心知识总结

564 阅读25分钟

概述

本文将围绕JAVA多线程开发过程中的重要知识点:线程状态、线程中止、内存屏障和CPU缓存、线程通信、线程封闭、线程池原理等知识点来展开讲解,并辅以相关的示例代码加深理解。点此下载示例代码

线程状态

JAVA线程状态

在java中,java.lang.Thread.State这个类明确地定义了线程的六种状态。

  1. New: 线程尚未启动,表示线程只是在代码层面创建好了
  2. Runnable: 等待CPU调度,处于随时可以执行的状态
  3. Blocked: 一个线程因为等待临界区的锁被阻塞产生的状态,比如Lock 或者synchronize关键字产生的状态
  4. Waiting: 线程处于(不带超时的)等待的状态。比如使用Object.wait、Thread.join、LockSupport.park等方法会使线程处于(不带超时的)等待的状态(前提是这个线程已经拥有锁了)
  5. Timed Waiting: 线程处于(指定等待时间的)的等待的状态。比如使用Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil等带时间参数的超时等待方法会使线程处于(指定等待时间的)的等待的状(前提是这个线程已经拥有锁了)
  6. Terminated: 终止线程的线程状态。线程正常完成执行或者出现异常。

代码演示

public class ThreadStatusDemo {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("1. new -> runnable -> terminated");
        test1();
        System.out.println();

        System.out.println("2. new -> runnable -> waiting -> runnable -> terminated");
        test2();
        System.out.println();

        System.out.println("3. new -> runnable -> blocked -> runnable -> terminated");
        test3();
        System.out.println();
    }
    
    public static void test1() throws InterruptedException {

        Thread thread = new Thread(() -> {
            System.out.println("thread1当前的状态:" + Thread.currentThread().getState().toString());
            System.out.println("thread1执行了");
        });

        System.out.println("还没有调用start方法,thread1的状态:"+thread.getState().toString());
        thread.start();
        System.out.println("调用了start方法,thread1的状态:"+thread.getState().toString());
        //这里睡眠2秒,注意,睡眠的是主线程,thread这个线程不受影响,主要是为了等待thread执行结束
        Thread.sleep(2000L);
        System.out.println("等待两秒后,thread1的状态:"+thread.getState().toString());
    }

    public static void test2() throws InterruptedException {

        Thread thread = new Thread(() -> {
            try {
                //当前线程先休眠1.5秒,1.5秒之后自动唤醒
                Thread.sleep(1500L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("thread2当前状态:"+Thread.currentThread().getState().toString());
            System.out.println("thread2执行了");
        });

        System.out.println("还没有调用start方法,thread2的状态:"+thread.getState().toString());
        thread.start();
        System.out.println("调用了start方法,thread2的状态:"+thread.getState().toString());
        Thread.sleep(200L);
        System.out.println("等待200毫秒后,thread2的状态:"+thread.getState().toString());
        Thread.sleep(3000L); //等待3000毫秒,确保thread2线程已经执行完了
        System.out.println("等待3000毫秒后,thread2的状态:"+thread.getState().toString());
    }

    public static void test3() throws InterruptedException {

        Thread thread = new Thread(() -> {
            synchronized (ThreadStatusDemo.class) {
                System.out.println("thread3当前状态:" + Thread.currentThread().getState().toString());
                System.out.println("thread3执行了");
            }
        });

        synchronized (ThreadStatusDemo.class){
            System.out.println("还没有调用start方法,thread3的状态:"+thread.getState().toString());
            thread.start();
            System.out.println("调用了start方法,thread3的状态:"+thread.getState().toString());
            Thread.sleep(200L);
            System.out.println("等待200毫秒后,thread3的状态:"+thread.getState().toString());
        }
        Thread.sleep(3000L);
        System.out.println("等待3000毫秒后,thread3的状态:"+thread.getState().toString());

    }
}

运行结果如下:

1. new -> runnable -> terminated
还没有调用start方法,thread1的状态:NEW
调用了start方法,thread1的状态:RUNNABLE
thread1当前的状态:RUNNABLE
thread1执行了
等待两秒后,thread1的状态:TERMINATED

2. new -> runnable -> waiting -> runnable -> terminated
还没有调用start方法,thread2的状态:NEW
调用了start方法,thread2的状态:RUNNABLE
等待200毫秒后,thread2的状态:TIMED_WAITING
thread2当前状态:RUNNABLE
thread2执行了
等待3000毫秒后,thread2的状态:TERMINATED

3. new -> runnable -> blocked -> runnable -> terminated
还没有调用start方法,thread3的状态:NEW
调用了start方法,thread3的状态:RUNNABLE
等待200毫秒后,thread3的状态:BLOCKED
thread3当前状态:RUNNABLE
thread3执行了
等待3000毫秒后,thread3的状态:TERMINATED

线程中止

不正确的线程中止方法-stop与destroy

stop是jdk的一个线程中止的方法。但是jdk并不建议使用,原因是stop方法在中止线程的同时,会清除监控器锁的信息,可能导致线程安全问题,比如死锁等,所以并不建议使用。而destroy方法,jdk并没有提供对应的实现。代码演示如下:

public class ThreadStopDemo {

    public static void main(String[] args) throws InterruptedException {
        StopThread stopThread = new StopThread();
        stopThread.start();
        //休眠一秒,确保i自增成功
        Thread.sleep(1000);
        //使用错误的方式中止线程
        stopThread.stop();;
        while(stopThread.isAlive()){
            //空循环,主要用于确保线程已经终止
        }
        //输出结果
        stopThread.print();
    }

}

class StopThread extends Thread{
    private int i,j =0;

    @Override
    public void run() {
        //同步锁,确保线程安全
        synchronized (this){
            ++i;
            try {
                //休眠10秒,模拟操作
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ++j;
        }
    }

    public void print(){
        System.out.println("i="+i+",j="+j);
    }
}

在这里,我们在线程中使用同步块,同时自增变量i和j。我们希望,即使线程被中止时,i和j的值也应该相同。但是实际上,当我们调用stop方法中止线程时,打印出来的i的值是1,而j的值还是0。违反了线程安全的原子性原则。所以也这就是stop方法不建议使用的原因。

正确的线程中止方法-interrupt

使用interrupt中止线程是一种线程安全的方式。它的作用其实就是设置当前线程的中断状态位,即设置为ture,中断的结果线程是死亡、等待新任务,还是继续运行下一步,取决于程序本身。线程会时不时地检测这个中断标志位,以判断线程是否应该被中断(中断值是否为true)。也就是说,interrupt方法只是改变中断状态,不会中断一个正在运行的线程。需要用户自己去监视线程的状态并做处理。相关的方法和异常如下:

  1. interrupt():只是改变中断状态,不会中断一个正在运行的线程
  2. Thread.interrupted():测试当前线程是否已经中断,如果连续调用该方法,则从第二次开始调用将返回false
  3. isInterrupted():测试线程是否中断,但是不能清除状态标识
  4. InterruptedException:中断异常。如果线程被Object.wait, Thread.join和Thread.sleep方法阻塞(其实在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,我们通常统称为阻塞)时,这时候调用该线程的interrupt()方法,该线程将抛出一个InterruptedException中断异常,从而提早地终结被阻塞状态,我们可以依据这个特点,手动编写中止线程的逻辑。如果线程没有被阻塞,那么调用interrupt()将不起作用,线程还将继续执行,直到执行到Object.wait, Thread.join和Thread.sleep等方法时,才马上抛出InterruptedException中断异常

代码演示

  1. 使用interrupt() + InterruptedException来中断线程。在这里我们将上面的使用stop方法中断线程的案例修改下。
public class ThreadInterruptDemo {

    public static void main(String[] args) throws InterruptedException {
        InterruptThread interruptThread = new InterruptThread();
        interruptThread.start();
        //休眠一秒,确保i自增成功
        Thread.sleep(1000);
        //使用错误的方式中止线程
        interruptThread.interrupt();
        while(interruptThread.isAlive()){
            //空循环,主要用于确保线程已经终止
        }
        //输出结果
        interruptThread.print();
    }

}

class InterruptThread extends Thread{
    private int i,j =0;

    @Override
    public void run() {
        //同步锁,确保线程安全
        synchronized (this){
            ++i;
            try {
                //休眠10秒,模拟操作
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ++j;
        }
    }

    public void print(){
        System.out.println("i="+i+",j="+j);
    }
}

运行结果如下:

java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at com.hejianlin.thread.InterruptThread.run(ThreadInterruptDemo.java:36)
i=1,j=1

可以看出,当线程调用了Thread.sleep方法被阻塞时,调用线程对象的interrupt方法,能够结束阻塞状态,抛出InterruptedException异常。在这里我们捕获了InterruptedException异常,所以代码还能继续走下去,直到结束。所在在同步块中,i和j的值都是1,保证了线程安全。从这里我们也可以看出,interrupt方法只是改变中断状态,不会中断一个正在运行的线程。另外,如果将synchronized (this)替换成while(true),执行interrupt方法时,也会抛出InterruptedException异常,但程序会无限地执行下去,因为这里是死循环,所以也从侧面说明了interrupt方法并不会真正地中断线程。

  1. 使用interrupt() + isInterrupted()来中断线程

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            System.out.println("线程启动了");
            while (!Thread.currentThread().isInterrupted()){
                System.out.println("线程中断状态:"+Thread.currentThread().isInterrupted());
            }

            Thread.interrupted();
            System.out.println("调用Thread.interrupted()之后的线程状态:"+Thread.currentThread().isInterrupted());
            Thread.interrupted();
            System.out.println("再次调用Thread.interrupted()之后的线程状态:"+Thread.currentThread().isInterrupted());
            Thread.interrupted();
            System.out.println("第三次调用Thread.interrupted()之后的线程状态:"+Thread.currentThread().isInterrupted());
            System.out.println("线程结束了");
        });
        thread.start();


        try {
            //主线程休眠0.5秒,先等待线程thread打印自己的线程状态
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //中断线程thread
        thread.interrupt();
        System.out.println("thread线程是否被中断:"+thread.isInterrupted());
    }
}

执行结果如下:

线程中断状态:false
……
线程中断状态:false
调用Thread.interrupted()之后的线程状态:false
再次调用Thread.interrupted()之后的线程状态:false
thread线程是否被中断:true
第三次调用Thread.interrupted()之后的线程状态:false
线程结束了

在这里解释一下执行结果。

  1. 在调用interrupt方法中断线程thread之前,我们先让主线程休眠了0.5秒,给出时间让线程thread打印出自己中断之前的状态(使用isInterrupted方法)。如果线程没有被中断,就不断地循环执行。
  2. 主线程休眠结束后,调用了interrupt方法中断线程thread。接着主线程再通过System.out.println("thread线程是否被中断:"+thread.isInterrupted());这一句打印线程thread的状态
  3. 如果线程thread内部没有调用Thread.interrupted方法,则System.out.println("thread线程是否被中断:"+thread.isInterrupted());会打印为true。因为线程的中断状态没有被清除
  4. 但我们在线程thread内部多次调用了Thread.interrupted方法。我们知道,Thread.interrupted方法第一次调用时,能显示线程当前的中断状态,但是再次调用后,中断状态会被清除,无论经过多少次
  5. 所以我们可以看到,我们在线程thread内部调用了三次Thread.interrupted方法,打印的都是false。第二次和第三次都为false可以理解,但是第一次为什么会是false,不是应该为true吗?在这里,我们可以看下Thread.interrupted方法的源码。
    public static boolean interrupted() {
        return currentThread().isInterrupted(true);
    }

它其实底层也是调用了isInterrupted方法。因为interrupted方法具有清除线程中断状态的功能,而前面,我们又通过isInterrupted方法得到了线程的中断状态。所以这里第一次调用interrupted方法,就直接打印false了。

  1. 还有另外一个问题。有些小伙伴可能在运行demo案例时,会发现System.out.println("thread线程是否被中断:"+thread.isInterrupted());这一句有时会打印为true,有时会打印为false。其实这就是线程先后执行的结果。如果主线程先在运行这一句代码时,线程thread还没有调用interrupted方法清除线程中断状态,打印出来的就为true,反之就为false

正确的线程中止方法-标志位

在代码逻辑中,增加一个标志位,由外部线程来修改标志位的值,从而控制线程执行的中断。

代码演示

public class ThreadFlagDemo {

    public volatile static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(() -> {
            //为true,在循环执行
            try {
                while (flag) {
                    System.out.println("线程执行中……");
                    //休眠0.5秒
                    Thread.sleep(500L);
                }
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }
        });
        thread.start();

        //主线程休眠2秒后,将flag修改为false
        Thread.sleep(2000L);
        flag = false;
        System.out.println("主线程运行结束");
        while(thread.isAlive()){
            //空循环,等待子线程运行结束
        }
        System.out.println("子线程运行结束");
    }
}

运行结果如下:

线程执行中……
线程执行中……
线程执行中……
线程执行中……
主线程运行结束
子线程运行结束

在这里,我们定义了flag变量用于控制子线程的运行,当flag为true时,子线程会一直循环执行,直到主线程将flag变量值修改为false。子线程读取到最新的flag值为false时,跳出循环,结束执行。在这里我们用volatile来修饰flag变量,就是为了禁用缓存与编译优化。保证子线程能够及时地获取到最新的flag变量的值。

内存屏障和CPU缓存

CPU性能优化手段-缓存

CPU通常会采用三级缓存,尽可能地避免处理器访问主内存的时间开销。

  1. L1 Cache(一级缓存):分为数据缓存和指令缓存。一般缓存的容量通常为32-4096KB
  2. L2 Cache(二级缓存): 在CPU外部放置一高速存储器,即二级缓存
  3. L3 Cache(三级缓存): 一般是内置的。主要用于提供更有效的文件系统缓存行为以及较短的消息和处理器队列长度。一般是多核共享一个L3缓存

CPU读取数据的优先级

L1 > L2 > L3 > 内存 > 外存储器

缓存同步协议

多CPU针对相同的数据进行缓存和计算,它们需要遵循MESI协议,控制自己的读写操作,同时还要监听其他CPU发出的通知,从而保证最终一致性,这方面的知识了解即可。

CPU性能优化手段-运行时指令重排

指令重排的场景:当CPU写缓存发现缓存区块正在被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行。举个例子,针对如下的代码:

x = 100;
y = z;

正常的执行步骤如下:

1.将100写入x
2.读取z的值
3.将z值写入y

指令重排后的可能执行步骤如下:

1.读取z的值
2.将z值写入y
3.将100写入x

但是这里要注意的是,指令重排并不是任意的,必须遵循as-if-serial语义。编译器和处理器不会对存在数据依赖关系的操作做重排序。即不管怎么重排,(单线程)程序的执行结果不能被改变。

CPU缓存带来的问题

虽然CPU缓存能够提高数据读取的效率,但在并发场景下,却会带来以下两个问题:

  1. 主内存与线程间缓存数据同步不及时,即同一个时间点,各个CPU所看到的同一内存地址的数据的值可能是不一致的
  2. 指令重排可能导致乱序执行,虽然在单线程场景下,as-if-serial语义能够保证运行结果正确,但在并发场景下,指令的逻辑关联关系无法分辨,所以可能导致乱序执行

内存屏障

针对上面的问题,处理器提供了两个内存屏障指令,作为解决方案:

  • 写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,强制让写入缓存中的最新数据写入主内存中,防止CPU基于性能优化的考虑而进行指令重排
  • 读内存屏障(Load Memory Barrier): 在指令前插入Load Barrier,强制让高速缓存中的数据失效,重新从主内存中加载数据,避免了缓存导致的一致性问题

线程通信

涉及到线程之间相互通信,主要分为以下四个方面:

  1. 文件共享
  2. 网络共享
  3. 变量共享
  4. JDK提供的线程协调API

在这里我们来讲解一下JDK提供的线程协调API。JDK中主要有三种线程协作通信的方式。它们分别为:suspend/resume、wait/notify、park/unpark。下面我们来一一讲解。

我们现在通过一个简单的案例来分别演示上面的三种API。假设我们现在有一个包子店,消费者需要买包子,生产者需要生产包子。只有包子被生产之后才能被消费,这就是是典型的生产者-消费者模型。现在有两个线程。线程1去买包子,没有包子,则不再执行;线程2生产出包子,通知线程1继续执行。

API-被弃用的suspend和resume

public class ThreadSuspendResumeDemo {
        //包子数量
    public static volatile int sum = 0;
    public static void main(String[] args) throws InterruptedException {
        //测试生产者消费者
        suspendResumeTest();
    }

    private static void suspendResumeTest() throws InterruptedException {
        Thread consumerThread = new Thread(() -> {
            while (sum <= 0) {
                System.out.println("1.没有包子,进入等待");
                Thread.currentThread().suspend();
            }
            System.out.println("2.买到包子,回家");
        });
        //启动消费者线程
        consumerThread.start();

        //3秒之后,生产一个包子
        Thread.sleep(3000L);
        sum++;
        //唤醒消费者线程
        consumerThread.resume();
        System.out.println("3.通知消费者");
    }
}

运行结果如下:

1.没有包子,进入等待
3.通知消费者
2.买到包子,回家

根据运行结果,我们可以知道,suspend方法确实可以使当前线程进入等待,resume方法也确实可以唤醒当前线程。但为什么suspend和resume会被弃用了?原因就是suspend不会释放锁,容易写出死锁的代码。我们可以将上面的方法稍微修改一下,即在调用suspend方法和resume方法时,都使用synchronized加锁,然后再main方法中调用这个方法。

    private static void suspendResumeDeadLockTest() throws InterruptedException {
        Thread consumerThread = new Thread(() -> {
            while (sum <= 0) {
                System.out.println("1.没有包子,进入等待");
                synchronized (ThreadSuspendResumeDemo.class){
                    Thread.currentThread().suspend();
                }

            }
            System.out.println("2.买到包子,回家");
        });
        //启动消费者线程
        consumerThread.start();

        //3秒之后,生产一个包子
        Thread.sleep(3000L);
        sum++;
        //唤醒消费者线程
        synchronized (ThreadSuspendResumeDemo.class){
            consumerThread.resume();
            System.out.println("3.通知消费者");
        }
    }

运行main方法,这时候你就会发现,程序一直停留在打印1.没有包子,进入等待这个阶段。因为调用了suspend方法之后,synchronized持有的锁没有被释放,而调用resume方法有需要等待获取相同的锁,因此程序无法正常结束。另外,如果resume方法比suspend方法先被调用,也会导致程序被永久挂起。如下,我们休眠5秒之后,才调用suspend方法方法,但是在3秒之后,resume方法已经先于suspend方法执行了。所以2.买到包子,回家这就话就永远打印不出来,因为程序已经被永久挂起了。

    //测试线程唤醒比等待先执行造成的问题:程序永远挂起
    private static void suspendResumeTest2() throws InterruptedException {
        Thread consumerThread = new Thread(() -> {
            while (sum <= 0) {
                System.out.println("1.没有包子,进入等待");
                try {
                    //延迟5秒
                    Thread.sleep(5000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //执行挂起操作
                Thread.currentThread().suspend();
            }
            System.out.println("2.买到包子,回家");
        });
        //启动消费者线程
        consumerThread.start();

        //3秒之后,生产一个包子
        Thread.sleep(3000L);
        sum++;
        //唤醒消费者线程
        consumerThread.resume();
        System.out.println("3.通知消费者");
    }

运行结果:

1.没有包子,进入等待
3.通知消费者

综上,这就是为什么suspend/resume被弃用的原因了。

API-wait/notify

  • wait:使当前线程进入等待,并且释放当前持有的对象锁
  • notify/notifyAll: 唤醒一个或所有的正在等待当前对象锁的线程

注意:这组方法只能由同一个对象锁的持有者线程调用,也就是必须写在synchronized同步块或方法中,否则将会抛出IllegalMonitorStateException异常。wait方法会自动释放锁,但是如果在notify/notifyAll被调用之后,才调用wait方法,则线程也会永久处于等待状态。

public class ThreadWaitNotifyDemo {

    //包子数量
    public volatile int sum = 0;
    public static void main(String[] args) throws Exception {
        ThreadWaitNotifyDemo demo = new ThreadWaitNotifyDemo();
        demo.waitNotifyTest();
    }

    private  void waitNotifyTest() throws  Exception{
        new Thread(() ->{
            while (sum <= 0){
                synchronized (this){
                    System.out.println("1.没有包子,进入等待");
                    try {
                        this.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
            System.out.println("2.买到包子了,回家");
        }).start();

        //3秒之后,生产一个包子
        Thread.sleep(3000L);
        sum++;
        synchronized (this){
            this.notifyAll();
            System.out.println("3.通知消费者");
        }
    }
}

运行结果:

1.没有包子,进入等待
3.通知消费者
2.买到包子了,回家

同理,跟suspend/resume一样,如果先运行notify/notifyAll,再运行wait,也会使线程永久挂起,这里就不再进行代码演示了。

API-park/unpark

  • park: 等待“许可”,挂起对应的线程
  • unpark: 为线程提供“许可”,唤醒线程

值得一提的是:

  1. park和unpark的调用顺序可以颠倒,即调用多次unpark之后,再调用park,线程会直接运行
  2. 不会叠加,即连续调用多次park,如果在之前已经调用了unpark,即获取到了“许可”,线程就会直接运行,即使后面再调用多次park,也不会进入等待
  3. unpark/park是不会释放锁的,所以如果在同步块或方法中使用它们,可能会导致最终无法释放锁的问题

演示代码如下:

public class ThreadParkUnParkDemo {

    //包子数量
    public volatile int sum = 0;
    public static void main(String[] args) throws Exception {
        ThreadParkUnParkDemo demo = new ThreadParkUnParkDemo();
        demo.parkUnParkTest();
    }

    private  void parkUnParkTest() throws  Exception{

        Thread thread = new Thread(() -> {
            while (sum <= 0) {
                System.out.println("1.没有包子,进入等待");
                LockSupport.park();
            }
            System.out.println("2.买到包子了,回家");
        });
        thread.start();

        //3秒之后,生产一个包子
        Thread.sleep(3000L);
        sum++;
        LockSupport.unpark(thread);
        System.out.println("3.通知消费者");
    }
}

小结

api是否释放锁执行顺序是否固定是否建议使用
suspend/resume
wait/notify
park/unpark

特别注意

在并发编程中,官方建议应该在循环中检测等待条件,而不用if判断。因为处于等待状态的线程可能会收到错误警报和伪唤醒,如果用if而不用循环检查,程序就可能在没有满足结束条件的情况下退出。

线程封闭

所谓的线程封闭,其实指的就是如果某些数据,不希望被其他线程共享和同步,就可以将这些数据封闭在各自的线程之中,从而避免同步。

java中针对线程封闭的具体的体现有:ThreadLocal、局部变量

ThreadLocal

它是一个线程级别的变量。每个ThreadLocal对应的变量为各自的线程所独有,竞争条件被彻底消除了,所以在并发模式下是绝对安全的。

用法

ThreadLocal var = new ThreadLocal();

实现原理

我们查看ThreadLocal的源码,就可以很容易地看出来:ThreadLocal底层其实是维护了一个ThreadLocalMap类的对象,它类似于一个HashMap。它的key就是当前的线程对象,value就是当前线程所设置的变量。当线程想要获取变量的值时,就通过当前线程去获取,从而避免了变量共享,所以是线程安全的。

代码演示:

public class ThreadLocalDemo {

    //定义ThreadLocal变量
    public static ThreadLocal<String> value = new ThreadLocal<String>();

    public static void main(String[] args) throws InterruptedException {
        //主线程设置值
        value.set("这是主线程的值");
        String s = value.get();
        System.out.println("主线程获取到的值:"+s);

        new Thread(() ->{
            String v = value.get();
            System.out.println("子线程获取到的值:"+v);
            value.set("这是子线程的值");

            v = value.get();
            System.out.println("重新设置后,子线程获取到的值:"+v);
            System.out.println("子线程执行结束");
        }).start();

        //主线程睡眠3秒,等待子线程执行结束
        Thread.sleep(2000L);
        s = value.get();
        System.out.println("重新设置后,主线程获取到的值:"+s);
    }
}

运行结果如下:

主线程获取到的值:这是主线程的值
子线程获取到的值:null
重新设置后,子线程获取到的值:这是子线程的值
子线程执行结束
重新设置后,主线程获取到的值:这是主线程的值

可以看出,对于同一个ThreadLocal变量来说,各个线程的对变量的操作,对其他线程都没有影响,这就达到了线程封闭的要求。

局部变量

局部变量的固有属性之一就是封闭在线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。

线程池原理

为什么要使用线程池?

  1. 创建和销毁一个java线程对象的开销很大。如果创建时间+销毁时间>执行任务时间,就得不偿失
  2. 线程对象会占用堆内存。在jvm规范中,一个线程默认最大栈大小为1m,线程过多,会消耗很多的内存
  3. 操作系统会频繁地切换线程上下文,线程过多,也会影响性能

线程池的作用

  1. 提供创建线程、消耗线程、添加任务的能力
  2. 线程复用,防止频繁创建线程
  3. 提供缓存机制,任务队列存放等待处理的任务
  4. 其他功能,比如通过延时队列,支持任务周期性调度等功能

线程池的基本概念

  1. 线程池管理器:提供创建线程池、销毁线程池、添加任务的能力
  2. 工作线程:线程池中的线程,没有任务则处于等待状态,有任务则可以被循环地执行
  3. 任务接口:每个任务都必须实现的接口,以供工作线程调度任务的执行。比如Runnable、Callable等接口
  4. 任务队列:用于存放没有处理的任务,提供一种缓存机制

线程池的工作流程

  1. 如果有配置核心线程的数量,则线程池会保证至少有对应数量的核心线程存活
  2. 当有任务进来时,如果有某个线程空闲时,则这个线程会被分配去执行对应的任务
  3. 当发现线程池中没有空闲线程时,则线程池会先判断当前的任务队列是否已满,如果未满,则将当前任务加入队列中,直到有空闲线程,才出队执行任务
  4. 当发现任务队列已满时,则判断当前线程数是否已经到达了最大值,如果没有超过,则新建线程执行
  5. 如果当前线程数已经到达了最大值,则不会直接分配线程执行,而是执行具体的拒绝策略(拒绝策略可以自定义,比如可以抛出异常,或者直接新建额外的线程执行等)
  6. 另外,如果某个线程空闲超过规定的时间,而线程总量又大于核心线程数量,则当前线程将被会回收

线程池重要API讲解

线程池的通用构造方法——ThreadPoolExecutor

使用ThreadPoolExecutor构造线程池,是构造线程池最通用的方式,同时也最建议使用这种方式构造线程池,因为除了能够自定义线程池的具体参数,还能避免由于一些默认配置,带来的隐患。比如,某些构造线程池方法会默认使用无界队列,就有OOM的风险。

上图是ThreadPoolExecutor的最基本的构造方法,其他的重载的构造方法,最终也是调用了这个方法。代码演示如下:

public class CommonThreadPoolDemo {

    public static void main(String[] args) throws Exception {

        CommonThreadPoolDemo demo = new CommonThreadPoolDemo();
        demo.test();
    }

    /**
     * 测试提交15个执行时间需要3秒的任务
     * 由于我们使用的是无界队列,所以我们这里最大线程数10的配置其实是无效的,因为线程没有空闲时,优先将任务放在任务队列之中,而不是新建线程执行
     * 所以我们可以看到,同一时间,最多只有5个线程在执行
     * @param
     */
    private void test() throws Exception {

        //使用标准的线程池构造方法构造线程池
        //核心线程数5,最大线程数10,空闲线程存活时间5秒,任务队列为没有指定数量的LinkedBlockingDeque队列,即无界队列,使用默认的拒绝策略
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 5, TimeUnit.SECONDS,
                new LinkedBlockingDeque<>());

        for (int i = 0; i < 15; i++) {
            int n = i;
            threadPoolExecutor.submit(() -> {
                try {
                    System.out.println("开始执行:"+n);
                    Thread.sleep(3000L);
                    System.out.println("执行结束:"+n);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            System.out.println("任务提交成功:"+n);
        }

        //查看线程数量,查看队列等待数量
        Thread.sleep(500L);
        System.out.println("当前线程池数量:"+threadPoolExecutor.getPoolSize());
        System.out.println("当前线程池等待的数量:"+threadPoolExecutor.getQueue().size());

        //等待15秒,这时候任务应该全部执行完了,查看依然存活的线程数量是否等于核心线程数量
        Thread.sleep(15000L);
        System.out.println("当前线程池数量:"+threadPoolExecutor.getPoolSize());
        System.out.println("当前线程池等待的数量:"+threadPoolExecutor.getQueue().size());
    }
}

运行这个代码,我们可以看出,无论何时,存活的线程数量都是5,这是因为我们使用了无界队列,导致任务一直堆积,无法开辟新的线程去执行。而如果在生产环境上使用无界队列,任务的一直堆积,无法执行,就很容易出现OOM问题了。

我们还可以自定义拒绝策略,当任务队列满时,又没有线程可以执行时,执行自定义的业务逻辑。相关的案例在源码中,这里就不太赘述了。

线程池的奇技淫巧——Executors工具类

Executors工具类其实也是对ThreadPoolExecutor相关构造方法的封装。它有以下几个常用的方法。

  1. newFiexedThreadPool(int nThreads):创建一个固定大小,任务队列无界的线程池,及核心线程数等于最大线程数
  2. newCachedThreadPool():创建一个大小无界的缓冲线程池,即核心线程数为0,最大线程数等于Integer.MAX_VALUE, 如果有空闲线程,则执行,反之,则新建空闲线程执行,线程空闲时间为60秒,适用于执行耗时较小的异步任务
  3. newSingleThreadExecutor():创建一个只有一个线程的无界任务队列的单一线程池。确保任务能够按照加入的顺序依次执行,当唯一的线程因为异常中止时,将创建新的线程执行后续的任务
  4. newScheduledThreadPool(int cordPoolSize):能够定时执行任务的线程池,该线程池的核心线程数由参数指定,最大线程数=Integer.MAX_VALUE

在这里我们着重讲解newScheduledThreadPool(int cordPoolSize)相关的使用,其他的使用案例可以在分享的源码中找到。

定时任务线程池——定时任务的构建和执行

首先我们来看看如何简单地启用一个定时任务:

    private static void scheduledThreadPoolTest() throws Exception {
        // 其实Executors.newScheduledThreadPool()底层就使用了new ScheduledThreadPoolExecutor构造方法
        ScheduledThreadPoolExecutor threadPoolExecutor = new ScheduledThreadPoolExecutor(5);
        threadPoolExecutor.schedule(() ->{
            System.out.println("任务被执行,现在时间:" + System.currentTimeMillis());
        }, 3000, TimeUnit.MILLISECONDS);
        System.out.println(
                "定时任务,提交成功,时间是:" + System.currentTimeMillis() + ", 当前线程池中线程数量:" + threadPoolExecutor.getPoolSize());
        // 预计结果:任务在3秒后被执行一次
    }

我们在一个方法中,通过new ScheduledThreadPoolExecutor(5)的方式创建了一个核心线程数为5的定时任务线程池,然后我们定时延迟执行的时间为3秒,所以在main方法中调用这个方法,3秒之后就能正确地打印相关的语句。

接着介绍一下ScheduledThreadPoolExecutor定时任务线程池的两种调度方式:

  1. 延迟若干秒之后开始执行,之后间隔指定时间,执行一次,如果这时候发现上次没有执行完,则等待上次执行完之后,立即执行(调用scheduleAtFixedRate方法)
  2. 延迟若干秒之后开始执行,之后间隔指定时间,执行一次,如果这时候发现上次没有执行完,则等待上次执行完之后,重新开始计时,之后才开始执行(调用scheduleWithFixedDelay方法)

代码演示如下:

    private static void scheduledThreadPoolRateOrDelayTest(){
        //周期性地执行任务,有两种调度方式
        //方式1:延迟若干秒之后开始执行,之后间隔指定时间,执行一次,如果这时候发现上次没有执行完,则等待上次执行完之后,立即执行
        //方式2:延迟若干秒之后开始执行,之后间隔指定时间,执行一次,如果这时候发现上次没有执行完,则等待上次执行完之后,重新开始计时,之后才开始执行

        ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(5);

        //方式1的演示: 使用scheduleAtFixedRate方法
        //提交后,2秒之后开始执行,之后每间隔1秒固定执行一次,但是这个任务要执行3秒
        //也就是说变成了3秒执行一次任务,执行完之后,下个任务立即执行
        scheduledThreadPoolExecutor.scheduleAtFixedRate(() ->{
            try {
                Thread.sleep(3000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("任务-1被执行,现在时间:"+System.currentTimeMillis());
        },2000,1000,TimeUnit.MILLISECONDS);


        //方式2的演示:使用scheduleWithFixedDelay方法
        //提交后,2秒之后开始执行,之后每间隔1秒执行一次,但是这个任务要执行3秒,所以需要等待当前任务执行完之后,再重新计时去执行
        //也就是说变成了4秒执行一次任务
        scheduledThreadPoolExecutor.scheduleWithFixedDelay(() ->{
            try {
                Thread.sleep(3000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("任务-2被执行,现在时间:"+System.currentTimeMillis());
        },2000,1000,TimeUnit.MILLISECONDS);

    }

在main方法中执行这个方法,就可以很容易地看出,任务-1确实是每隔3秒执行了一次,任务-2则是每隔4秒执行了一次。

线程池的关闭——shutdown()和shutdownNow()

  1. shutdown(): 调用这个方法之后,不接受新的任务,等待执行中的任务执行完成
  2. shutdownNow(): 调用这个方法之后,不接受新的任务,正在执行中的任务也会被终止

代码演示如下:

首先构造一个线程池:

    private static ThreadPoolExecutor getThreadPoolExecutor() {
        //核心线程数5,最大线程数10,空闲时间5秒,大小为3的有界队列,所以同一时间,最多容纳13个任务
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 10, 5, TimeUnit.SECONDS, new LinkedBlockingDeque<>(3),
                (r, executor) -> System.err.println("有任务被拒绝执行了"));
        //测试:提交15个执行时间需要3秒的任务,1秒后终止线程池,看超过大小的2个,对应的处理情况
        for(int i=0;i<15;i++){
            int n=i;
            threadPool.submit(() ->{
                try {
                    System.out.println("开始执行任务:"+n);
                    Thread.sleep(3000L);
                    System.out.println("执行结束:"+n);
                } catch (InterruptedException e) {
                    System.out.println("异常信息:"+e.getMessage());
                }
            });
            System.out.println("任务提交成功:"+i);
        }
        return threadPool;
    }

在main方法中通过调用testShutdown()测试shutdown()方法,运行结果见注释中的“结果分析”:

    private static void testShutdown() throws InterruptedException {
        ThreadPoolExecutor threadPool = getThreadPoolExecutor();
        //1秒后终止线程池
        Thread.sleep(1000L);
        threadPool.shutdown();
        // 再次提交提示失败
        threadPool.submit(() ->{
            System.out.println("追加一个任务");
        });
        //结果分析:
        /**
         * 1、10个任务被执行,3个任务进入等待队列,2个任务被拒绝执行
         * 2、调用shutdown后,不接受新的任务,等待13个任务执行结束
         * 3、追加的任务在线程池关闭后,无法再提交,会被拒绝执行
         */
    }

在main方法中通过调用testShutdownNown()测试shutdownNow()方法,运行结果见注释中的“结果分析”:

    private static void testShutdownNow() throws InterruptedException {
        ThreadPoolExecutor threadPool = getThreadPoolExecutor();
        //1秒后终止线程池
        Thread.sleep(1000L);
        //返回的是处于等待队列的任务数
        List<Runnable> shutdownNow = threadPool.shutdownNow();
        // 再次提交提示失败
        threadPool.submit(() ->{
            System.out.println("追加一个任务");
        });
        System.out.println("等待队列中的任务数:"+shutdownNow.size());
        //结果分析:
        /**
         * 1、10个任务被执行,3个任务进入等待队列,2个任务被拒绝执行
         * 2、调用shutdownNow后,不接受新的任务,已执行的任务会被中止
         * 3、追加的任务在线程池关闭后,无法再提交,会被拒绝执行
         */
    }

线程池——如何确定合适的线程数量

线程池中线程数量需要根据实际情况来合理设置的。如果线程数过多,可能会增加很多系统开销,而且由于cpu核数是固定的,同一时刻执行的线程数量也是有限的。反之,如果线程数过少,则起不到充分利用cpu计算能力的作用。因此,我们可以从任务的类型或实际需求来确定线程数量的合适范围:

  1. 计算型任务:即普通的代码执行,线程数量可以设置为cpu数量的1-2倍
  2. IO型任务:比如有网络操作、数据库操作、文件操作等。相比计算型任务,需要多一些线程,要根据具体的IO阻塞时长进行考量决定
  3. 实际需要线程数量的任务:可以设置一个最小数量和一个最大数量,在这个范围内自动增减线程数(比如通过Executor.newCachedThreadPool()开启一个缓存线程池)
  4. 还可以监控cpu的使用情况,如果cpu的使用率在80%左右,则说明线程数量是比较合理的