多线程

一、多线程概述

1.1 进程和线程概述

  • 进程:操作系统中的应用程序,一个进程就是一个应用程序。进程A和进程B的内存独立不共享资源。
  • 线程:CPU调度的最小单元,进程的一个执行流/指定单元,一个进程可以有多个线程。

PS:Java程序启动的时候,JVM就是一个进程,JVM会执行main方法,main方法就是主线程,同时会再启动一个垃圾回收线程(守护线程)GC进行垃圾回收。即:Java最少有两个线程并发,主线程main方法和守护线程GC

1.2 线程之间的关系

Java语言中,堆内存方法区内存共享。但是栈内存独立,一个线程一个栈。假设启动10个线程,会有10个栈空间,每个栈和每个栈之间,互不干扰,各自执行各自的,这就是多线程并发。Java中之所以有多线程机制,目的就是为了提高程序的处理效率。

PS:火车站,可以看做是一个进程。火车站中的每一个售票窗口可以看做是一个线程。我在窗口1购票,你可以在窗口2购票,你不需要等我,我也不需要等你。所以多线程并发可以提高效率。

1.3 实现多线程的条件

多核CPU的可以真正的是实现多线程并发,例如4CPU表示同一个时间点上,可以真正的有4个进程并发执行。

单核的CPU不能够做到真正的多线程并发,但是可以做到给人一种“多线程并发”的感觉,原因是CPU的运行速度很快。对于单核的CPU来说,在某一个时间点上实际上只能处理一件事情,但是由于CPU的处理速度极快,多个线程之间频繁切换执行,给别人的感觉是:多个事情同时在做!!

同时,多线程程序并不是同时进行的,由于CPU的执行速度太快,CPU会在不同的线程之间快速的切换执行,这个现象就是上下文切换,即:CPU从一个线程或进程切换到另一个线程或进程。

二、线程特性

2.1 共享性

数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。

但是,在多线程编程中,数据共享是不可避免的。最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中数据,即使是在主从的情况下,访问的也同一份数据,主从只是为了访问的效率和数据安全,而对同一份数据做的副本。

我们现在,通过一个简单的示例来演示多线程下共享数据导致的问题:

代码段一:

public class ShareData {
    public static int count = 0;

    public static void main(String[] args) {
        final ShareData data = new ShareData();
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        //进入的时候暂停1毫秒,增加并发问题出现的几率
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    for (int j = 0; j < 100; j++) {
                        data.addCount();
                    }
                    System.out.print(count + " ");
                }
            }).start();
        }
        
        try {
            //主程序暂停3秒,以保证上面的程序执行完成
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count=" + count);
    }

    public void addCount() {
        count++;
    }
}

上述代码的目的是对count进行加一操作,执行1000次,不过这里是通过10个线程来实现的,每个线程执行100次,正常情况下,应该输出1000。不过,如果你运行上面的程序,你会发现结果却不是这样。下面是某次的执行结果(每次运行的结果不一定相同,有时候也可能获取到正确的结果):

100 200 300 400 500 600 735 853 953 753 count=953

可以看出,对共享变量操作,在多线程环境下很容易出现各种意想不到的的结果。

2.2 互斥性

资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。

所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。

例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。但是对共享数据的写操作,一般就需要保证互斥性,上述例子中就是因为没有保证互斥性才导致数据的修改产生问题。

Java中提供多种机制来保证互斥性,最简单的方式是使用Synchronized。现在我们在上面程序中加上Synchronized再执行:

代码段二:

public class ShareData {
    public static int count = 0;

    public static void main(String[] args) {
        final ShareData data = new ShareData();
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        //进入的时候暂停1毫秒,增加并发问题出现的几率
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    for (int j = 0; j < 100; j++) {
                        data.addCount();
                    }
                    System.out.print(count + " ");
                }
            }).start();
        }
        try {
            //主程序暂停3秒,以保证上面的程序执行完成
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count=" + count);
    }

    /**
     * 增加synchronized关键字
     */
    public synchronized void addCount() {
        count++;
    }
}

现在再执行上述代码,会发现无论执行多少次,返回的最终结果都是1000。

2.3 原子性

原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。

但是很多操作不能通过一条指令就完成。例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,我们经常使用的整数i++的操作,其实需要分成三个步骤:

  1. 读取整数i的值;
  2. i进行加一操作;
  3. 将结果写回内存。

这个过程在多线程下就可能出现如下现象:

831081790.png

这也是代码段一执行的结果为什么不正确的原因。对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的SynchronizedLock都可以实现,代码段二就是通过Synchronized实现的。

除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。

2.4 可见性

要理解可见性,需要先对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似,如图所示:

61482019.png

从这个图中我们可以看出,每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,这么做的目的还是在于进一步缩小存储系统与CPU之间速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。通过下面这段程序我们可以演示一下不可见的问题:

public class VisibilityTest {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (!ready) {
                System.out.println(ready);
            }
            System.out.println(number);
        }
    }

    private static class WriterThread extends Thread {
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            number = 100;
            ready = true;
        }
    }

    public static void main(String[] args) {
        new WriterThread().start();
        new ReaderThread().start();
    }
}

从直观上理解,这段程序应该只会输出100,ready的值是不会打印出来的。实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,下面是我运行出来的某两次的结果:

false
100
-----
true
100

当然,这个结果也只能说是有可能是可见性造成的,当写线程(WriterThread)设置ready=true后,读线程(ReaderThread)看不到修改后的结果,所以会打印false,对于第二个结果,也就是执行if(!ready)时还没有读取到写线程的结果,但执行System.out.println(ready)时读取到了写线程执行的结果。不过,这个结果也有可能是线程的交替执行所造成的。Java中可通过SynchronizedVolatile来保证可见性。

2.5 有序性

为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

我们可以直接参考一下JSR 133中对重排序问题的描述:

706312098.png

先看上图中的源码左部分,从源码来看,要么指令1先执行要么指令3先执行。如果指令1先执行,r2不应该能看到指令4中写入的值。如果指令3先执行,r1不应该能看到指令2写的值。但是运行结果却可能出现r2==2r1==1的情况,这就是“重排序”导致的结果。上图右部分即是一种可能出现的合法的编译结果,编译后,指令1和指令2的顺序可能就互换了。因此,才会出现r2==2r1==1的结果。Java中也可通过SynchronizedVolatile来保证顺序性。

三、线程的实现方法

3.1 继承Thread类

public class ThreadTest {
    
    public static void main(String[] args) {
        // 启动线程
        new MyThread().start();
        // 直接调用run()方法
        // new MyThread().run();

        // 主线程运行的程序
        for (int i = 0; i < 1000; i++) {
            System.out.println("主线程--->" + i);
        }
    }
}

class MyThread extends Thread {
    
    @Override
    public void run() {
        // 编写程序,这段程序运行在分支线程中(分支栈)。
        for (int i = 0; i < 1000; i++) {
            System.out.println("分支线程--->" + i);
        }
    }
}
  • run()方法不会启动线程,只是普通的调用方法而已,不会分配新的分支栈(这种方式就是单线程)。
  • start()方法的作用是:启动一个分支线程,在JVM中开辟一个新的栈空间,这段代码任务完成之后,瞬间就结束了。因此start()方法只是为了开启一个新的栈空间,只要新的栈空间开出来,start()方法就结束了,线程就启动成功了。

启动成功的线程会自动调用run()方法,并且run()方法在分支栈的栈底部(压栈)。run方法在分支栈的栈底部,main方法在主栈的栈底部。

runmain是平级的。

调用run()方法内存图如下

调用start()方法内存图如下

3.2 实现Runnable接口

这种方式相对于第一种方式,只是多了一个线程对象进行初始化,因为Thread的有参构造可以实现,其他的地方没有过多的变化。

/**
 * 1. 创建类实现Runnable接口
 */
class MyRunnable implements Runnable {
    
    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println("分支线程->" + i);
        }
    }
}

public class CreateThread {
    
    public static void main(String[] args) {
        // 启动线程
        new Thread(new MyRunnable()).start();
        // 主线程程序
        for (int i = 0; i < 100; i++) {
            System.out.println("主线程->" + i);
        }

        /**
         * 2. 通过匿名内部类实现
         */
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                System.out.println("分支线程->" + i);
            }
        }).start();
    }
}

3.3 实现Callable接口

Callable接口类似于Runnable,但是Runnable不会返回结果,并且无法抛出经过检查的异常,而Callable在不使用线程池的时候依赖FutureTask类获取返回结果。

单个线程池:使用ExecutorServiceCallableFuture实现有返回结果的线程。

ExecutorService提供了submit()方法,传递一个Callable,或Runnable,返回Future。如果Executor后台线程池还没有完成Callable的计算,这调用返回Future对象的get()方法,会阻塞直到计算完成。

3.3.1 不使用线程池实现

/**
 * 实现Callable接口
 */
class MyCallable implements Callable<Integer> {
    
    @Override
    public Integer call() {
        int sum = 0;
        for (int i = 0; i < 100; i++) {
            sum += i;
        }
        return sum;
    }
}

public class CreateThread {
    
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 启动线程
        new Thread(new FutureTask<>(new MyCallable()), "方式一").start();
        // FutureTask futureTask = new FutureTask<>(new MyCallable());
        // new Thread(futureTask).start();
        // 通过futureTask.get()获取返回值
        System.out.println(futureTask.get());

        /**
         * 通过匿名内部类实现
         */
        new Thread(new FutureTask<>(() -> {
            int sum = 0;
            for (int i = 0; i < 100; i++) {
                sum += i;
            }
            return sum;
        }));

        // 主线程程序
        for (int i = 0; i < 100; i++) {
            System.out.println("主线程->" + i);
        }
    }
}

3.3.2 使用单个线程池实现

/**
 * 实现Callable接口
 */
class MyCallable implements Callable<Integer> {
    
    @Override
    public Integer call() {
        int sum = 0;
        for (int i = 0; i < 100; i++) {
            sum += i;
        }
        return sum;
    }
}

public class CreateThread {
    
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 主线程程序
        for (int i = 0; i < 100; i++) {
            System.out.println("主线程->" + i);
        }

        /**
         * 使用单线程池实现
         */
        // 1. 创建固定大小的线程池对象
        ExecutorService executorService = Executors.newFixedThreadPool(1);
        // 2. 提交线程任务,通过Future接口接受返回的结果
        Future<Integer> submit = executorService.submit(new MyCallable());
        // 3. 关闭线程池
        executorService.shutdown();
        // 4. 调用future.get()获取callable执行完成的返回结果
        System.out.println(submit.get());
    }
}

3.4 三种方式的优缺点总结如下:

  • 集成Thread
    • 优点:代码书写比较简单(实际也没有简单多少)
    • 缺点:由于Java的单继承性,导致后期无法继承其他的类,同时代码的耦合度比较高
  • 实现Runnable接口和Callable接口
    • 优点:适合多个相同的程序代码的线程去处理同一个资源,避免了Java单继承的限制,代码可以被多个线程共享,代码和数据独立,同时线程池只能放入实现RunnableCallable类线程,不能直接放入继承Thread的类。
    • 缺点:通过匿名内部类进行实现,虽然代码书写简单一点,但是只适合线程使用一次的时候

四、线程调度和状态装换

线程的生命周期

4.1 线程的状态转换

  • New(新建状态):新创建了一个线程对象。
  • Runnable(就绪状态):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
  • Running(运行状态):就绪状态的线程获取了CPU,执行程序代码。
  • Blocked(阻塞状态):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    • 等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的锁)
    • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
    • 其他阻塞:运行的线程执行sleep()join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意sleep是不会释放持有的锁)
  • Dead(死亡状态):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

4.2 线程调度

Java线程有优先级,优先级高的线程会获得较多的运行机会,因此通过Thread类的setPriority()getPriority()方法分别用来设置和获取线程的优先级。

JVM提供了10个线程优先级,但与常见的操作系统都不能很好的映射。如果希望程序能移植到各个操作系统中,应该仅仅使用Thread类有以下三个静态常量作为优先级,这样能保证同样的优先级采用了同样的调度方式。其中,主程序使用的是NORM_PRIORITY,即5,同时还有MAX_PRIORITY=10MIN_PRIORITY=1的静态优先级常量。

  • 线程睡眠:Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为Runnable(就绪状态)。sleep()平台移植性好。
  • 线程等待:Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的notify()方法或notifyAll()唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用wait(0)一样。
  • 线程让步:Thread.yield()方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
  • 线程加入:join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
  • 线程唤醒:Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个wait方法,在对象的监视器上等待。直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如,唤醒的线程在作为锁定此对象的下一个线程方面没有可靠的特权或劣势。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。

4.2.1 线程终止

线程在正常的程序中启动和停止,不需要额外的停止方式,会自动停止。但是有些情况下,有一些伺服线程还在运行,他们运行时间较长,只有当外部条件满足时,他们才会停止。针对这样的情况,提供了如下几种停止线程的方式:

使用标志位(推荐使用)

public class ThreadStopUse {
    
    public static void main(String[] args) {
        FlagStop flagStop = new FlagStop();
        new Thread(flagStop).start();

        for (int i = 0; i < 100; i++) {
            System.out.println("主线程运行的第" + i + "次");
            if (i == 90) {
                // 调用自己的stop方法切换标志位,停止线程
                flagStop.stop();
                System.out.println("分支线程该停止了");
            }
        }
    }
}

class FlagStop implements Runnable {
    
    /**
     * 定义标志
     */
    private volatile boolean exitFlag = true;

    /**
     * 标志转换
     */
    public void stop() {
        this.exitFlag = false;
    }

    @Override
    public void run() {
        int i = 0;
        while (exitFlag) {
            System.out.println("分支线程运行的第" + i + "次");
        }
    }
}

使用interrupted()方法(不推荐)

使用interrupted()方法来中断线程有两种情况:

  • 线程处于阻塞状态时,如线程中使用了sleep(),同步锁wait()socketreceiveraccept方法时,会使线程进入到阻塞状态,当程序调用interrupted()方法时,会抛出InterruptedException异常。阻塞中的那个方法抛出异常,通过捕获该异常,然后break跳出循环,从而结束该线程。注:不是调用了interrupted()方法就会结束线程,是捕获到了interruptedException异常后,break跳出循环后才能结束此线程。
  • 线程未处于阻塞状态,调用interrupted()方法时,实际上是通过判断线程的中断标记来退出循环。
class InterruptedStop implements Runnable {
    
    @Override
    public void run() {
        for (int i = 0; i <= 200; i++) {
            // 判断是否被中断,通过检查标志位
            if (Thread.currentThread().isInterrupted()) {
                // 处理中断逻辑
                break;
            }
            System.out.println("i=" + i);
        }
    }
}
public class ThreadStopUse {
    
    public static void main(String[] args) throws InterruptedException {
        InterruptedStop interruptedStop = new InterruptedStop();
        Thread thread = new Thread(interruptedStop);
        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

注意:在上面这段代码中,我们增加了Thread.isInterrupted()来判断当前线程是否被中断了,如果是,则退出for循环,结束线程。

这种方式看起来与之前介绍的“使用标志位终止线程”非常类似,但是在遇到sleep()或者wait()这样的操作,我们只能通过中断来处理了。

使用stop()方法停止(强烈不推荐)

使用Thread.stop()方法来结束线程的运行是很危险的,主要因为在程序调用Thread.stop()后会抛出ThreadDeathError()错误,并释放子线程所持有的所有锁,会导致被保护数据呈现不一致性,此过程不可控。

4.2.2 线程休眠

线程休眠是Thread.sleep(ms)方法,它的作用是让当前线程进入休眠,进入“阻塞状态”,放弃占有CPU时间片,让给其它线程使用。执行效果就是间隔特定的时间,去执行一段特定的代码,每隔多久执行一次。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为Runnable(就绪状态)。

注意:每个对象都有一个锁,sleep()方法不会释放锁。

public class ThreadSleepUse {
    public static void main(String[] args) {
        while (true) {
            try {
                Thread.sleep(1000);
                // 每隔一秒打印一下系统当前时间
                System.out.println(new SimpleDateFormat("HH:mm:ss")
                        .format(new Date(System.currentTimeMillis())));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

4.2.3 线程礼让

暂停当前正在执行的线程对象,但不阻塞,将线程从运行状态转为就绪状态,把执行机会让给相同或者更高优先级的线程。让CPU重新调度,但是礼让不一定成功,因为当前线程和其他线程一同竞争CPU,使得所有线程回到同一起点,优先级高的线程获得的运行机会会多一点,这个过程不会释放锁。

public class ThreadYieldUse {
    
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            System.out.println("主线程执行了第" + i);
        }

        new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                System.out.println(Thread.currentThread().getName() +  "执行了" + i + "次");
                if (i % 5 == 0) {
                    Thread.yield();
                    System.out.println("线程礼让,重新争抢CPU");
                }
            }
        }, "线程礼让").start();
    }
}

4.2.4 线程加入

线程加入就是在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。

将一个线程合并到当前线程中,当前线程受阻塞,加入的线程执行直到结束,这个是无参join()方法的作用,使用join(long millis)方法则等待该线程终止的时间最长为millis毫秒;使用join(long millis, int nanos)方法则等待该线程终止的时间最长为millis毫秒 + nanos纳秒。

作用:一个执行完的线程需要另一个正在执行的线程的运行结果时

public class ThreadJoinUse {
    
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            for (int j = 0; j < 50; j++) {
                System.out.println("VIP线程-" + Thread.currentThread().getName() + "执行了" + j + "次");
            }
        }, "线程加入");
        thread.start();

        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + "执行了" + i + "次");
            if (i == 50) {
                try {
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

五、线程同步

5.1 线程安全和线程同步概述

5.1.1 线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

问题:通常情况下,一个进程中的比较耗时的操作(如长循环、文件上传下载、网络资源获取等),往往会采用多线程来解决。又比如实际生活中,银行取钱问题、火车票多个售票窗口的问题,通常会涉及到并发的问题,从而需要多线程的技术。

当进程中有多个并发线程进入一个重要数据的代码块时,在修改数据的过程中,很有可能引发线程安全问题,从而造成数据异常。例如,正常逻辑下,同一个编号的火车票只能售出一次,却由于线程安全问题而被多次售出,从而引起实际业务异常。

线程安全问题产生的原因——共享内存数据,当多个线程同时操作同一共享数据时,导致共享数据出错。

线程、主内存、工作内存三者的关系如图:

Java内存模型中,分为主内存和线程工作内存。每条线程有自己的工作内存,线程使用共享数据时,都是先从主内存中拷贝到工作内存,线程对该变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,线程使用完成之后再写入主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

在多线程环境下,不同线程对同一份数据操作,就可能会产生不同线程中数据状态不一致的情况,这就是线程安全问题的原因。

5.1.2 线程同步

多线程并发的环境下,有共享数据,并且这个数据还会被修改,此时就存在线程安全问题,怎么解决这个问题?

要实现线程安全,需要保证数据操作的两个特性:

  1. 原子性:对数据的操作不会受其他线程打断,意味着一个线程操作数据过程中不会插入其他线程对数据的操作。
  2. 可见性:当线程修改了数据的状态时,能够立即被其他线程知晓,即数据修改后会立即写入主内存,后续其他线程读取时就能得知数据的变化。

以上两个特性结合起来,其实就相当于同一时刻只能有一个线程去进行数据操作并将结果写入主存,这样就保证了线程安全,这种机制称为线程同步

线程同步就是线程不能并发,线程必须排队执行,因此线程同步会牺牲一部分的效率,来提升安全性。

线程排队执行(不能并发),用排队执行解决线程安全问题。

实现方式:

  1. 通过Synchronized关键字修饰代码块或者方法,一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。
  2. Lock锁,支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括hand-over-hand和锁重排算法)中使用这些规则。

5.2 线程同步的实现方式

5.2.1 synchronized锁

synchronizedJava中的关键字,是一种同步锁。它修饰的对象有以下几种:

  • 修饰一个代码块,被修饰的代码块称为实例代码块,其作用的范围是大括号{}括起来的代码,锁是synchronized括号里配置的对象;如果作用在静态方法中,则称为静态代码块,锁对象是当前类的字节码文件;
  • 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,锁这个方法所在的当前实例对象;
  • 修改一个静态的方法,其作用的范围是整个静态方法,锁是这个类的所有对象;
  • 修改一个类,其作用的范围是synchronized后面括号括起来的部分,锁是这个类的所有对象。

修饰一个代码块

一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。

public class ThreadSafety {
    
    public static void main(String[] args) {
        System.out.println("使用关键字synchronized");
        SyncThread syncThread = new SyncThread();
        new Thread(syncThread, "SyncThread1").start();
        new Thread(syncThread, "SyncThread2").start();
    }
}

class SyncThread implements Runnable {
    
    private static int count = 0;

    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println("线程名:" + Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
        // 其他逻辑
    }
}

当两个并发线程(thread1thread2)访问同一个对象(syncThread)中的synchronized代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。Thread1thread2是互斥的,因为在执行synchronized代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。

注:synchronized只锁定对象,多个线程要实现同步,所以线程必须以同一个Runnable对象为运行对象,即:()中的对象要是同一个

这时如果创建了两个SyncThread的对象syncThread1syncThread2,线程thread1执行的是syncThread1对象中的synchronized代码(run),而线程thread2执行的是syncThread2对象中的synchronized代码(run);我们知道synchronized锁定的是对象,这时会有两把锁分别锁定syncThread1对象和syncThread2对象,而这两把锁是互不干扰的,不形成互斥,所以两个线程可以同时执行。

当一个线程访问对象的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该对象中的非synchronized(this)同步代码块。

如果synchronized作用在静态方法中,修饰一块代码,则称为静态代码块,锁对象是当前类的字节码文件。

class SyncThread implements Runnable {
    
    private static int count = 0;
	
    /**
     * synchronized作用在静态方法中,锁对象实当前类的字节码文件
     */
    public static void save() {
        synchronized (SyncThread.class) {
            count++;
        }
        // 其他操作
    }
}

修饰一个方法

Synchronized修饰一个方法很简单,就是在方法的前面加synchronizedsynchronized修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。

class SyncThread implements Runnable {
    
    private static int account = 100;

    /**
     * synchronized修饰一个方法,被修饰的方法称为同步方法,
     * 其作用的范围是整个方法,锁对象为这个方法所在的当前实例对象
     * @param money
     */
    public synchronized void draw(Integer money) {
        account -= money;
    }
}

在用synchronized修饰方法时要注意以下几点:

  1. synchronized关键字不能继承。虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。
  2. 当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。
  3. 在定义接口方法时不能使用synchronized关键字。
  4. 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。

修饰静态方法

静态方法是属于类的而不属于对象的,synchronized修饰的静态方法锁定的是这个类的所有对象,该类的所有对象用synchronized修饰的静态方法的用的是同一把锁。

修饰一个类

效果和synchronized修饰静态方法是一样的,synchronized作用于一个类时,是给这个类加锁,该类的所有对象用的是同一把锁。

5.2.2 Lock锁

Lock和ReadWriteLock锁简介

JDK5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步,LockReadWriteLock是两大锁的根接口,Lock代表实现类是ReentrantLock(可重入锁)ReadWriteLock(读写锁)的代表实现类是ReentrantReadWriteLock

  • Lock接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括hand-over-hand和锁重排算法)中使用这些规则,是控制多个线程对共享资源进行访问的工具。Lock锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。主要的实现是ReentrantLockReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。
  • ReadWriteLock接口以类似方式定义了一些读取者可以共享而写入者独占的锁。此包只提供了一个实现,即ReentrantReadWriteLock。但程序员可以创建自己的、适用于非标准要求的实现。

Lock锁与synchronized锁比较

  • synchronized是隐式锁,出了作用域自动释放,锁的控制和释放是在synchronized同步代码块的开始和结束位置。而Lock是显示锁,锁的开启和关闭都是手动的,实现同步时,锁的获取和释放可以在不同的代码块、不同的方法中。
  • Lock只有代码块锁,而synchronized有代码块锁和方法锁。
  • Lock接口提供了试图获取锁的tryLock()方法,在调用tryLock()获取锁失败时返回false,这样线程可以执行其它的操作而不至于使线程进入休眠。tryLock()方法可传入一个long型的时间参数,允许在一定的时间内来获取锁。
  • Lock接口的实现类ReentrantReadWriteLock提供了读锁和写锁,允许多个线程获得读锁、而只能有一个线程获得写锁,读锁和写锁不能同时获得。实现了读和写的分离,这一点在需要并发读的应用中非常重要,如lucene允许多个线程读取索引数据进行查询但只能有一个线程负责索引数据的构建。
  • 基于以上几点,使用lock锁,JVM会花费更少的时候来调度线程,因此性能较好,同时有更好的可扩展性(提供更多的子类)。

Lock独有特征

  • 尝试非阻塞的获取锁:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁。
  • 能被中断的获取锁:获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放。
  • 超时获取锁:在指定的截止时间之前获取锁,超过截止时间后仍旧无法获取则返回。

Lock锁的使用场景

如果一个代码块被synchronized关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。

事实上,占有锁的线程释放锁一般会是以下三种情况之一:

  • 占有锁的线程执行完了该代码块,然后释放对锁的占有;
  • 占有锁线程执行发生异常,此时JVM会让线程自动释放锁;
  • 占有锁线程进入waiting状态从而释放锁,例如在该线程中调用wait()方法等。

以下三种场景只能用Lock:

  1. 使用synchronized关键字的情形下,假如占有锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间(解决方案:tryLock(longtime, TimeUnit unit))或者能够响应中断(解决方案:lockInterruptibly())),这种情况可以通过Lock解决。
  2. 当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用synchronized关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突(解决方案:ReentrantReadWriteLock)。
  3. 我们可以通过Lock得知线程有没有成功获取到锁(解决方案:ReentrantLock),但这个是synchronized无法办到的。

Lock锁的简单使用

public class LockUse {
    
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(ticket).start();
        new Thread(ticket).start();
        new Thread(ticket).start();
    }
}

class Ticket implements Runnable {
    
    private static Integer ticketNums = 10;
    
    /**
     * 声明可重入锁
     */
    private final ReentrantLock lock = new ReentrantLock();

    /**
     * 不加锁的情况下,线程不安全,
     * 因此可以使用Lock进行显示的加锁和解锁,锁lock必须紧跟try代码块,且unlock要放到finally第一行。
     */
    @Override
    public void run() {
        while (true) {
            // 加锁,锁lock必须紧跟try代码块,且unlock要放到finally第一行。
            lock.lock();
            try {
                // lock.lock(); 可以出现在这个位置,但是不建议,
                // 因为如果在获取锁时发生了异常,异常抛出的同时也会导致锁无法被释放;
                if (ticketNums > 0) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 模拟买票,票自减
                    System.out.println(ticketNums--);
                }
            } finally {
                // 必须放到第一行
                lock.unlock();
            }
        }
    }
}

六、线程通信

6.1 生产者和消费者问题

场景:两个共享固定大小缓冲区的线程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。

分析:

  • 对于生产者,没有生产产品前,要通知消费者等待,生产产品后,通知消费者消费。
  • 对于消费者,消费后,通知生产者生产新的产品消费。

Java提供的解决线程通信问题的方法,即:等待/唤醒机制

  • wait(): 表示线程一直等待,直到其他线程通知,与sleep不同,会释放锁。
  • notify(): 唤醒一个处于等待状态的线程
  • notifyAll(): 唤醒同一个对象上所有调用wait()方法的线程,优先级高的线程优先调度

注意:以上方法只能在同步方法或者同步代码块中使用,否则抛出异常,IlleagalMonitorStateException

方式:

  1. 管道法:采用并发协作模型,加入“缓冲区”,生产者将生产好的数据放入缓冲区,消费者从缓冲区拿出数据。
  2. 信号灯:在生产者与消费者之间传递信号的一个标志。如当生产者或消费者线程完成自己的工作,等待另一个线程进行时,通过修改信号值来通知对方:我的事情做完了,该你了。另一者获取信号的变化后便会做出对应的行为。在这个过程中,信号值一直被反复更改,直到所有线程均执行完毕。

6.2 管道法

6.2.1 产品、生产者和消费者

class Product {
    
    /**
     * 产品编号
     */
    Integer productId;

    public Product(Integer productId) {
        this.productId = productId;
    }
}

/**
 * 生产者
 */
class Production extends Thread {
    
    Buffers buffer;

    public Production(Buffers buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                buffer.push(new Product(i));
                System.out.println("生产了" + i + "个商品");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

/**
 * 消费者
 */
class Customer extends Thread {
    
    Buffers buffer;

    public Customer(Buffers buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            try {
                System.out.println("消费了-->" + buffer.commodity().productId + "个产品");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

6.2.2 缓冲区

缓冲区的大小设定之后,设定一个计数器,生产者和消费者通过计数器去进行产品的生产和消费。生产者生产产品时,首先根据计数器去判断缓冲区是否已经满了,满了的话就等待,然后通知消费者进行消费,如果没有满的话,就继续往里面生产产品。消费者消费的时候,也是通过判断缓冲区中是否有产品存在,如果存在的话就消费,否则等待生产者进行生产,整个生产和消费的过程都是针对缓冲区进行的。

class Buffers {
    /**
     * 设置容器大小,产品最大数量
     */
    Product[] product = new Product[10];

    /**
     * 计数器
     */
    private int count = 0;

    /**
     * 生产者生产品
     *
     * @param products
     * @throws InterruptedException
     */
    public synchronized void push(Product products) throws InterruptedException {
        // 如果容器满了,就等待消费者消费
        if (count == product.length) {
            // 通知消费者消费,生产者等待,wait(),表示线程一直等待,直到其它线程通知,与sleep不同,会释放锁
            this.wait();
        }
        // 如果没满,就丢入产品
        product[count] = products;
        count++;

        // 通知消费者进行消费,notify(),唤醒同一个对象上所有调用wait()方法的线程,优先级高的线程优先调度
        this.notifyAll();
    }

    /**
     * 消费者消费产品
     *
     * @return
     * @throws InterruptedException
     */
    public synchronized Product commodity() throws InterruptedException {
        
        // 判断是否有产品可以消费
        if (count == 0) {
            // 消费者等待,等待生产者生产
            this.wait();
        }
        // 消费者进行消费
        count--;
        Product products = product[count];

        // 消费完后,通知生产者生产
        this.notifyAll();

        return products;
    }
}

6.3 信号灯

6.3.1 厨师和顾客

/**
 * 生产者:厨师
 */
class Cook extends Thread {
    
    Food food;

    public Cook(Food food) {
        this.food = food;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            try {
                food.make("凉皮");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

/**
 * 消费者:顾客
 */
class Judge extends Thread {
    
    Food food;

    public Judge(Food food) {
        this.food = food;
    }

    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            try {
                food.eat();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

6.3.2 信号处理

使用信号法时,需要设置一个标志位,通过修改标志位的方式,使得生产者和消费者进行协同工作。当标志位为true时,生产者进行生产,消费者等待,所以在制作食物的方法中,首先让消费者(顾客)进行等待,等待生产者(厨师)进行食物制作,厨师制作完成之后,通知顾客吃饭,同时修改标志位。顾客吃饭的时候,厨师等待,顾客吃完后,通知厨师继续做饭,同时修改标志位,使得两个线程有序的进行协同工作。

class Food {
    
    /**
     * 设置标志位,true为厨师烹饪食物,顾客等待,false为厨师等待,顾客吃饭
     */
    boolean flag = true;

    /**
     * 食物
     */
    String foodName;

    /**
     * 烹饪食物
     *
     * @param foodName 食物
     * @throws InterruptedException
     */
    public synchronized void make(String foodName) throws InterruptedException {
        // 如果flag为false则厨师等待顾客吃饭,生产者厨师等待,消费者顾客进行吃饭
        if (!flag) {
            this.wait();
        }
        System.out.println("厨师做了一道" + foodName);

        // 唤醒消费者消费
        this.notifyAll();
        // 将厨师做的菜传递给总的菜类
        this.foodName = foodName;
        // 让flag为false,则消费者消费
        this.flag = !this.flag;
    }

    /**
     * 消费者吃饭
     *
     * @throws InterruptedException
     */
    public synchronized void eat() throws InterruptedException {
        // flag为true则顾客等待厨师做饭,消费者等待,生产者生产
        if (flag) {
            this.wait();
        }
        System.out.println("顾客吃了" + foodName);
        // 唤醒,唤醒生产者(厨师)做菜
        this.notifyAll();
        // 使flag为true,让生产者继续生产
        this.flag = !this.flag;
    }
}

七、线程池

7.1 线程池概述

7.1.1 线程池的基本概念

在面向对象编程中,创建和销毁对象是很费时间的,对于线程来说也是如此,尤其是当线程中执行的是简单任务的话,则大部分的时间都花费在线程的创建和销毁上。

因此为了解决这种资源浪费的情况,使用池化技术——线程池,本质上是一种对象池,用于管理线程资源,对线程进行复用,一个线程执行完当前任务后并不马上销毁,而是从任务队列中取出一个任务继续运行。即在任务执行前,需要从线程池中拿出线程来执行,在任务执行完成之后,需要把线程放回线程池。通过线程的这种反复利用机制,可以有效地避免直接创建线程所带来的坏处。这种做法提高了线程的利用率,也减少了系统开销。

线程池作用就是限制系统中执行线程的数量。根据系统的环境情况,可以自动或手动设置线程数量,达到运行的最佳效果;少了浪费了系统资源,多了造成系统拥挤效率不高。用线程池控制线程数量,其他线程排队等候。一个任务执行完毕,再从队列的中取最前面的任务开始执行。若队列中没有等待进程,线程池的这一资源处于等待。当一个新任务需要运行时,如果线程池中有等待的工作线程,就可以开始运行了;否则进入等待队列。

7.1.2 线程池的优缺点

优点

  • 降低资源消耗:重用存在的线程,减少对象创建销毁的开销。
  • 提高响应速度:可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
  • 附加功能:提供定时执行、定期执行、单线程、并发数控制等功能。

缺点

  • 频繁的线程创建和销毁会占用更多的CPU和内存,对GC产生比较大的压力
  • 线程太多,线程切换带来的开销将不可忽视。同时线程太少,多核CPU得不到充分利用,是一种浪费

7.1.3 线程池的状态

线程池的5种状态:running、shutdown、stop、tidying、terminated

  • running:-1 << count_bits,即高三位为111,该状态的线程池会接收新任务,并处理阻塞队列中的任务;running也是线程池的初始状态。
  • shutdown:0 << count_bits,即高三位为000,该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;调用shutdown()方法将状态转换至shutdown
  • stop:1 << count_bits,即高3位为001,该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;
  • tidying:2 << count_bits,即高3为010,表示所有任务已经终止,workerCount0,线程过度到tidying状态需要执行terminated()方法;线程池在shutdown状态且阻塞队列为空并且线程池中执行的任务也为空时,就会由shutdown转换为tidying
  • terminated:3 << count_bits,即高3位为011,表示terminated()方法完成后的状态。

7.1.4 线程池的实现原理


通过上图,我们看到了线程池的主要处理流程。我们的关注点在于,任务提交之后是怎么执行的。大致如下:

  1. 判断核心线程池是否已满,如果不是,则创建线程执行任务。
  2. 如果核心线程池满了,判断队列是否满了,如果队列没满,将任务放在队列中。
  3. 如果队列满了,则判断线程池是否已满,如果没满,创建线程执行任务。
  4. 如果线程池也满了,则按照拒绝策略对任务进行处理。

7.2 线程池的使用

7.2.1 Executors工具类(不推荐使用)

Executors是一个线程池工厂,提供了很多的工厂方法,我们来看看它大概能创建哪些线程池。

  • 创建单一线程的线程池:ExecutorService newSingleThreadExecutor();这是一个始终都只有一个线程的池子,所有的任务都通过一个线程来执行,若多个任务被提交到此线程池,那么会被缓存到队列(队列长度为Integer.MAX_VALUE),当线程空闲的时候,按照FIFO的方式进行处理。
  • 创建固定数量的线程池:ExecutorService newFixedThreadPool(int nThreads);创建一个具有固定线程数的线程池,当所有线程都在执行任务时,新提交的任务会一直提交到阻塞队列中。若多个任务被提交到此线程池,则会有如下处理过程:
    • 如果线程的数量未达到指定数量,则创建线程来执行任务
    • 如果线程池的数量达到了指定数量,并且有线程是空闲的,则取出空闲线程执行任务
    • 如果没有线程是空闲的,则将任务缓存到队列(队列长度为Integer.MAX_VALUE)。当线程空闲的时候,按照FIFO的方式进行处理
  • 创建带缓存的线程池:ExecutorService newCachedThreadPool();这种方式创建的线程池,核心线程池的长度为0,线程池最大长度为Integer.MAX_VALUE。由于本身使用SynchronousQueue作为等待队列的缘故,导致往队列里面每插入一个元素,必须等待另一个线程从这个队列删除一个元素,会根据线程任务的数量来进行线程的创建和释放。
  • 创建定时调度的线程池:ScheduledExecutorService newScheduledThreadPool(int corePoolSize);和上面3个工厂方法返回的线程池类型有所不同,它返回的是ScheduledThreadPoolExecutor类型的线程池。平时我们实现定时调度功能的时候,可能更多的是使用第三方类库,比如:quartz等。但是对于更底层的功能,我们仍然需要了解。
    1. scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit),定时调度,每个调度任务会至少等待period的时间,如果任务执行的时间超过period,则等待的时间为任务执行的时间。
    2. scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit),定时调度,第二个任务执行的时间 = 第一个任务执行时间 + delay。
    3. schedule(Runnable command, long delay, TimeUnit unit),定时调度,延迟delay后执行,且只执行一次。

我们写一个例子来看看如何使用定时调度:

public class ThreadPoolTest {
    
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);

        // 定时调度,每个调度任务会至少等待period的时间,如果任务执行的时间超过period,则等待的时间为任务执行的时间
        executor.scheduleAtFixedRate(() -> {
            try {
                Thread.sleep(10000);
                System.out.println(System.currentTimeMillis() / 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 0, 2, TimeUnit.SECONDS);

        // 定时调度,第二个任务执行的时间 = 第一个任务执行时间 + delay
        executor.scheduleWithFixedDelay(() -> {
            try {
                Thread.sleep(5000);
                System.out.println(System.currentTimeMillis() / 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, 0, 2, TimeUnit.SECONDS);

        // 定时调度,延迟delay后执行,且只执行一次
        executor.schedule(() -> System.out.println("5 秒之后执行 schedule"), 5, TimeUnit.SECONDS);
    }
}

注意:通过阅读底层源码可以看出,四种常见的线程池都直接或间接的继承自ThreadPoolExecutor类,而《阿里巴巴Java开发手册》中则强制线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式则必须更加明确线程池的运行规则,从而规避资源耗尽的风险。

7.2.2 ThreadPoolExecutor手动配置

理论上我们可以通过Executors来创建线程池,这种方式非常简单。但正是因为简单,所以限制了线程池的功能。比如:无长度限制的队列,可能因为任务堆积导致OOM,这是非常严重的bug,应尽可能地避免。同时,根据《阿里巴巴Java开发手册》中则 强制线程池不允许使用Executors去创建 ,而是通过ThreadPoolExecutor的方式,因此归根结底,还是需要我们通过更底层的方式来创建线程池。

Executors的底层实现上不难看出,其中的几个方法都使用了ThreadPoolExecutor的默认配置,抛开定时调度的线程池不管,ThreadPoolExecutor最底层的构造方法却只有一个。那么,我们就从这个构造方法着手分析。

public ThreadPoolExecutor(int corePoolSize,                     // 核心线程数
                          int maximumPoolSize,                  // 最大线程数
                          long keepAliveTime,                   // 最长存活时间
                          TimeUnit unit,                        // 存活时间单位
                          BlockingQueue<Runnable> workQueue,    // 阻塞队列
                          ThreadFactory threadFactory,          // 线程工厂
                          RejectedExecutionHandler handler) {   // 饱和策略
    /*
     * 使用两个if语句进行参数合法性判断
     */
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)                  
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

由构造方法可知,ThreadPoolExecutor类的构造参数总共有7个,我们逐一进行分析。

  • corePoolSize:线程池中的核心线程数。当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize,即使有其他空闲线程能够执行新来的任务,也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行。
  • maximumPoolSize:线程池中的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,直到当前线程数等于maximumPoolSize则停止创建;当阻塞队列是无界队列时,maximumPoolSize则不起作用,因为无法提交至核心线程池的线程会一直持续地放入workQueue(阻塞队列)。
  • keepAliveTime:空闲时间,当线程池数量超过核心线程数时,多余的空闲线程存活的时间,即:这些线程多久被销毁。默认情况下,该参数只在线程数大于corePoolSize(核心线程数)时才有用,超过这个时间的空闲线程将被终止。
  • unit:空闲时间的单位,可以是毫秒、秒、分钟、小时和天等等。
  • workQueue:等待(阻塞)队列,线程池中的线程数超过核心线程数时,任务将放在等待队列,等待队列默认是BlockingQueue类型的,同时JDK内部自带的主要有以下几种:
    • ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO(First Input First Output,即先进先出、先来先服务)排序任务;
    • BlockingQueue:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQueue
    • LinkedBlockingQueue:基于链表实现的阻塞队列,队列可以有界,也可以无界;
    • SynchronousQueue:一个不存储元素的阻塞队列(即只有一个位置),每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue
    • PriorityBlockingQueue:具有优先级的无界阻塞队列;
  • threadFactory:线程工厂,我们可以使用它来创建一个线程,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactoryExecutors的实现使用了默认的线程工厂DefaultThreadFactory
  • handler:拒绝策略(饱和策略),当线程池和等待队列都满了之后,需要通过该对象的回调函数进行回调处理。即:如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
    • AbortPolicy:直接抛出异常,默认策略;
    • CallerRunsPolicy:用调用者所在的线程来执行任务;
    • DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    • DiscardPolicy:直接丢弃任务;

通常情况下,我们需要指定阻塞队列的上界(比如1024)。另外,如果执行的任务很多,我们可能需要将任务进行分类,然后将不同分类的任务放到不同的线程池中执行。

四种拒绝策略各有优劣,比较常用的是DiscardPolicy,但是这种策略有一个弊端就是任务执行的轨迹不会被记录下来。所以,我们往往需要实现自定义的拒绝策略,通过实现RejectedExecutionHandler接口的方式。

简单实现

public class ThreadPool {
    public static void main(String[] args) {
        /**
         * 初始化一个指定的线程池,核心线程2个,最大线程5个,销毁时间1秒,阻塞队列使用ArrayBlockingQueue
         */
        ExecutorService executor = new ThreadPoolExecutor(2, 5, 1, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1)) {
            @Override
            protected void beforeExecute(Thread t, Runnable r) {
                System.out.println("beforeExecute is called:调用执行之前被调用");
            }

            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                System.out.println("afterExecute is called:调用执行之后被调用");
            }

            @Override
            protected void terminated() {
                System.out.println("terminated is called:终止调用");
            }
        };

        // 提交任务
        executor.submit(() -> System.out.println("this is a task"));
        // 关闭线程池
        executor.shutdown();
    }
}

7.2.3 提交任务

ExecutorService总共提供了两种任务提交的方法,分别是execute()方法和submit()方法,主要区别如下:

  • execute()方法提交的任务,必须实现Runnable接口,该方式提交的任务不能获取返回值,因此无法判断任务是否执行成功。而submit()方法既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null
  • execute()方法如果遇到异常会直接抛出,而submit()方法不会直接抛出,只有在使用Futureget方法获取返回值时,才会抛出异常。
  • 如果提交的任务不需要一个结果的话直接用execute()会提升很多性能。如果你需要的是一个空结果,那么submit(yourRunnable)submit(yourRunnable, null)是等价的!
public class TaskSubmit {
    public static void main(String[] args) {
        // 创建线程池
        ExecutorService executor = new ThreadPoolExecutor(2, 5, 1, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1));

        // 只能提交Runnable任务
        executor.execute(() -> System.out.println("execute()方法只能提交Runnable任务"));

        // 既可以提交Runnable任务,又可以提交Callable任务,只是前者返回null,后者返回值
        Future<Integer> callableFuture = executor.submit(() -> 1 + 1);
        Future<?> runnableFuture = executor.submit(() -> System.out.println("Runnable任务会返回null"));
        try {
            // 只有获取返回值的时候才需要处理异常
            System.out.println(callableFuture.get());
            System.out.println(runnableFuture.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        executor.shutdown();
    }
}

7.2.4 关闭线程池

ExecutorService提供了shutDown()shutDownNow()两个函数来关闭线程池,底层还是通过逐个调用线程的interrupt()函数来实现中断线程从而关闭线程池的。

  • shutdown函数会把线程池的状态则立刻变成shutdown状态。此时,则不能再往线程池中添加任何任务,否则将会抛出RejectedExecutionException异常。但是,此时线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。(即将当前所有线程任务执行完毕再销毁线程池)
  • shutdownNow方法会先将线程池状态修改为stop,然后调用线程池里的所有线程的interrupt方法,并把工作队列中尚未来得及执行的任务清空到一个List中返回,getTask()方法返回null,从而线程退出。但是ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。(即直接销毁线程池,不会考虑是否有线程任务再执行)

八、拓展

8.1 Thread.sleep(0)的作用

Thread.sleep(0)可以让线程进入Safepoint,从而触发GC

参考

posted @ 2023-01-05 21:51  夏尔_717  阅读(50)  评论(0编辑  收藏  举报