多线程基础知识点梳理

打印 上一主题 下一主题

主题 863|帖子 863|积分 2589

基础概念


  • 进程(process):进程是计算机中的一个任务,比如打开浏览器、IntelliJ IDEA。
  • 线程(thread):进程内部有多个子任务,叫线程。比如IDEA在敲代码的同时还能自动保存、自动导包,都是子线程做的。
进程和线程的关系就是一个进程包含一个或多个线程。
线程是操作系统调度的最小任务单位。线程自己不能决定什么时候执行,由操作系统决定什么时候调度。因此多线程编程中,代码的先后顺序不代表代码的执行顺序。
多线程有什么好处?

  • 提高应用程序的性能。异步编程让程序更快的响应。
  • 提高CPU利用率。一个线程阻塞,另一个线程继续执行,充分利用CPU。
同时多线程也会带来安全问题,比如多个线程读写一个共享变量,会出现数据不一致的问题。
什么时候考虑用多线程?

  • 高并发。系统在同一时间要处理多个任务时,需要用多线程。
  • 很耗时的操作。如文件读写,异步执行不让进程阻塞。
  • 不影响方法主流程逻辑,但又影响接口性能的操作,如数据同步,使用异步方式能提高接口性能。
创建线程的方式

多线程的创建方法基本有四种:

  • 继承Thread类
  • 实现Runnalble接口
  • 实现Callable接口
  • 线程池
1.继承Thread类
  1. public class ThreadTest extends Thread {
  2.     @Override
  3.     public void run() {
  4.         System.out.println("新线程开始...");
  5.     }
  6.     public static void main(String[] args) {
  7.         ThreadTest t = new ThreadTest();
  8.             t.start();
  9.         System.out.println("main线程结束...");
  10.     }
  11. }
复制代码
  1. main线程结束...
  2. 新线程开始...
复制代码
启动一个新线程总是调用它的start()方法,而不是run()方法;ThreadTest子线程启动后,它跟main就开始同时运行了,谁先执行谁后执行由操作系统调度。所以多线程代码的执行顺序跟代码顺序无关。
2.实现Runnable接口

实现Runnable接口,重写run()方法,作为构造器参数传给Thread,调用start()方法启动线程。
  1. public class Test {
  2.     public static void main(String[] args) {
  3.         RunnableThread r = new RunnableThread();
  4.         new Thread(r).start();
  5.         new Thread(r).start();
  6.     }
  7. }
  8. class RunnableThread implements Runnable {
  9.     @Override
  10.     public void run() {
  11.         System.out.println("新线程开始...");
  12.     }
  13. }
复制代码
一般推荐使用实现Runnable的方式来创建新线程,它的优点有:

  • Java中只有单继承,接口则可以多实现。如果一个类已经有父类,它就不能再继承Thread类了,继承了Thread类就不能再继承其他类,有局限性。实现Runnable接口则没有局限性。
  • 实现Runnable接口的类具有共享数据的特性,它可以同时作为多个线程的执行单位(target),此时多个线程操作的是同一个对象的run方法,这个对象所有变量在这几个线程间是共享的。而继承Thread的方式做不到,比如A extends Thread,每次启动线程都是new A().start(),每次的A对象都不同。
3. 实现Callable接口

Callable区别于Runnable接口的点在于,Callable的方法有返回值,还能抛出异常。
  1. public interface Callable<V> {
  2.     V call() throws Exception;
  3. }
复制代码
Callable的用法:

  • 配合FutureTask一起使用,FutureTask是RunnableFuture接口的典型实现,RunnableFuture接口从名字来看,它同时具有Runnable和Future接口的的能力。FutureTask提供2个构造器,同时支持Callable方式和Runnable方式的任务。FutureTask可作为任务传给Thread的构造器。
  • 使用线程池时,调用ExecutorService#submit方法,返回一个Future对象。
  • Future对象的get()方法能返回异步执行的结果。调用get()方法时,如果异步任务已经完成,就直接返回结果。如果异步任务还没完成,那么get()方法会阻塞,一直等待任务完成才返回结果,这一点也是FutureTask的缺点。
Callable和FutureTask一起使用的例子:
  1. public class CallableTest {
  2.     public static void main(String[] args) {
  3.         // 创建Callable接口实现类的对象
  4.         CallableThread sumThread = new CallableThread();
  5.         // 创建FutureTask对象
  6.         FutureTask<Integer> futureTask = new FutureTask<>(sumThread);
  7.         // 将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
  8.         new Thread(futureTask).start();
  9.         try {
  10.             // 获取Callable中call方法的返回值
  11.             Integer sum = futureTask.get();
  12.             System.out.println("总和为" + sum);
  13.         } catch (InterruptedException | ExecutionException e) {
  14.             e.printStackTrace();
  15.         }
  16.         System.out.println("main线程结束");
  17.     }
  18. }
  19. class CallableThread implements Callable<Integer> {
  20.     @Override
  21.     public Integer call() throws Exception {
  22.         int sum = 0;
  23.         for (int i = 1; i <= 100; i++) {
  24.             sum += i;
  25.         }
  26.         Thread.sleep(2000); // 等待2s验证futureTask.get()是否等待
  27.         return sum;
  28.     }
  29. }
复制代码
ThreadPoolExecutor的核心构造器有7个参数,我们来分析一下每个参数的含义:
  1. 总和为5050
  2. main线程结束
复制代码

  • corePoolSize:线程池的核心线程数。线程池中的线程数小于corePoolSize时,直接创建新的线程来执行任务。
  • workQueue:阻塞队列。当线程池中的线程数超过corePoolSize,新任务会被放到队列中,等待执行。
  • maximumPoolSize:线程池的最大线程数量。
  • keepAliveTime:非核心线程空闲时的存活时间。非核心线程即workQueue满了之后,再提交任务时创建的线程。非核心线程如果空闲了,超过keepAliveTime后会被回收。
  • unit:keepAliveTime的时间单位。
  • threadFactory:创建线程的工厂。默认的线程工厂会把提交的任务包装成一个新的任务。
  • handler:拒绝策略。当线程池的workQueue已满且线程数达到最大线程数时,新提交的任务执行对应的拒绝策略。
JDK也提供了一个快速创建线程池的工具类Executors,它提供了多种创建线程池的方法,但通常不建议使用Executors来创建线程池,因为它提供的很多工具方法,要么使用的阻塞队列没有设置边界,要么是没有设置最大线程的上限。任务一多容易发生OOM。实际开发应该根据业务自定义线程池。
线程池的原理

execute

线程池的核心运行机制在于execute方法,所有的任务调度都是通过execute方法完成的。
  1. public V get() throws InterruptedException, ExecutionException {
  2.     int s = state;
  3.     if (s <= COMPLETING) // 如果未完成,则等待完成
  4.         s = awaitDone(false, 0L);
  5.     return report(s);
  6. }
  7. private int awaitDone(boolean timed, long nanos) throws InterruptedException {
  8.     // ...
  9.     for (; ; ) { // 无线循环,直到任务完成
  10.         // ...
  11.         int s = state;
  12.         if (s > COMPLETING) {
  13.             if (q != null)
  14.                 q.thread = null;
  15.             return s;
  16.         }
  17.         // ...
  18.     }
  19. }
复制代码
透过execute方法的3个if判断,可以把它的逻辑梳理为3个部分:

  • 第一个if:如果线程数量小于核心线程数,则创建一个线程来执行新提交的任务。
  • 第二个if:如果线程数量大于等于核心线程数,则将任务添加到该阻塞队列中。
  • else if:线程池状态不对,或者添加到队列失败即队列满了,则创建一个非核心线程执行新提交的任务。如果非核心线程创建失败就执行拒绝策略。
addWorker

execute中的核心逻辑要看addWoker方法,它承担了核心线程和非核心线程的创建。addWorker方法前半部分代码用一个双重for循环确保线程池状态正确,后半部分的逻辑是创建一个线程对象Worker,开启新线程执行任务的过程。
Worker是对提交进来的线程的封装,创建的worker会被添加到一个HashSet,线程池中的线程都维护在这个名为workers的HashSet中并被线程池所管理。
前面说到,Worker本身也是一个线程对象,它实现了Runnable接口,在addWorker中会启动一个新的任务,所以我们要看它的run方法,而run方法的核心逻辑是runWorker方法。
  1. public class ThreadPoolTest {
  2.     private static ThreadPoolExecutor poolExecutor =
  3.             new ThreadPoolExecutor(1, 1, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1));
  4.     public static void main(String[] args) throws ExecutionException, InterruptedException {
  5.         Runnable runnableTask = () -> System.out.println("runnable task end");
  6.         poolExecutor.execute(runnableTask);
  7.         Callable<String> callableTask = () -> "callable task end";
  8.         Future<String> future = poolExecutor.submit(callableTask);
  9.         System.out.println(future.get());
  10.     }
  11. }
复制代码
可以看到runWorker方法中有一个while循环,循环执行task的run方法,这里的task就是提交到线程池的任务,它对当成了普通的对象,执行完task.run(),最后会把task设置为null。
再看循环的条件,已知task是有可能为空的,所以我们再看看(task = getTask()) != null这个条件,如果getTask() == null则跳出循环执行processWorkerExit方法,processWorkerExit方法的作用是回收空闲线程。
getTask

很多答案都在getTask()方法中。
  1. public ThreadPoolExecutor(int corePoolSize,
  2.                           int maximumPoolSize,
  3.                           long keepAliveTime,
  4.                           TimeUnit unit,
  5.                           BlockingQueue<Runnable> workQueue,
  6.                           ThreadFactory threadFactory,
  7.                           RejectedExecutionHandler handler) {
  8.         // 省略...
  9. }
复制代码
结合(1)、(3)这两个地方可以看出,getTask()方法是一个无限循环,不断从阻塞队列中取任务,取到了任务就返回,到外层runWorker方法中,执行这个任务的run方法,即线程池通过启动一个Worker子线程来执行提交进来的任务,并且一个Worker线程会执行多个任务
我们再看看getTask()何时返回null,因为返回null才可以看下一步的processWorkerExit方法。
getTask()返回null主要看timed && timedOut这个条件。变量值timed为true的条件是:允许核心线程超时或者线程数大于核心线程数。timedOut变量为true的条件是从workQueue为空了,取不到任务了,但是这个前提是timed == true,执行workQueue.poll的时候,因为workQueue.poll方法获取任务最多等待keepAliveTime的时间,超过这个时间获取不到就返回null,而workQueue.take()方法获取不到任务会一直等待!
因此,在核心线程不会超时的情况下,如果池中的线程数小于核心线程数,这个getTask()会一直循环下去,这就是在这种情况下线程池不会自动关闭的原因!反之,在核心线程不会超时的情况下,如果池中的线程数超过核心线程数,才会对多余的线程回收。如果allowCoreThreadTimeOut == true,即核心线程也能超时,当阻塞队列为空,所有Worker线程都会被回收。
ThreadPoolExecutor的注释说,当池中没有剩余线程,线程池会自动关闭。
A pool that is no longer referenced in a program AND has no remaining threads will be shutdown automatically
但我也没找到证据,没看到哪里显式调用shutdown(),但确实会自动关闭。
processWorkerExit

getTask()获取不到任务后,会执行processWorkerExit方法回收线程。在这里,Worker线程集合随机删除一个线程对象,然后再随机中断一个workers中的线程。可见线程销毁线程的方式时删除线程引用,让JVM自动回收。
  1. public void execute(Runnable command) {
  2.     // ...
  3.     int c = ctl.get();
  4.     if (workerCountOf(c) < corePoolSize) { // (1)
  5.         if (addWorker(command, true))
  6.             return;
  7.         c = ctl.get();
  8.     }
  9.         if (isRunning(c) && workQueue.offer(command)) { // (2)
  10.         int recheck = ctl.get();
  11.         // 重新检查状态,如果是非运行状态,接着执行队列删除操作,然后执行拒绝策略
  12.         if (! isRunning(recheck) && remove(command))
  13.             reject(command);
  14.         // 如果是因为remove(command)删除队列元素失败,再判断池中线程数量
  15.         // 如果池中线程数为0则新增一个任务为null的非核心线程
  16.         else if (workerCountOf(recheck) == 0)
  17.             addWorker(null, false);
  18.     }
  19.     else if (!addWorker(command, false)) // (3)
  20.         reject(command);
  21. }
复制代码
线程池原理总结

最后我们回到最初的问题,线程池的原理是什么,线程池怎么做到重复利用线程的?
线程池通过维护一组叫Worker的线程对象来处理任务。在线程数不超过核心线程数的情况下,一个任务对应一个Worker线程,超过核心线程数,新的任务会提交到阻塞队列。一个Worker线程在启动后,除了执行第一次任务之外,还会不断向阻塞队列中消费任务。如果队列里没任务了,Worker线程会一直轮询,不会退出;只有在池中线程数超过核心线程数时才退出轮询,然后回收多余的空闲线程。即一个Worker线程会处理多个任务,且Worker线程受线程池管理,不会随意回收。
线程池的拒绝策略

拒绝策略的目的是保护线程池,避免无节制新增任务。JDK使用RejectedExecutionHandler接口代表拒绝策略,并提供了4个实现类。线程池的默认拒绝策略是AbortPolicy,丢弃任务并抛出异常。实际开发中用户可以通过实现这个接口去定制拒绝策略。

线程的状态


  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。
当线程启动后,它可以在Runnable、Blocked、Waiting和Timed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。
线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(过时方法,不推荐使用)。
Thread类的常用方法


  • start():启动当前线程
  • currentThread():返回当前代码执行的线程
  • yield(): 释放当前CPU的执行权
  • join():join()方法可以让其他线程等待,直到自己执行完了,其他线程才继续执行。
  • setDaemon(boolean on):设置守护线程,也叫后台线程。JVM退出时,不必关心守护线程是否已结束。
  • interrupt():中断线程。
  • sleep(long millis):让线程睡眠指定的毫秒数,在指定时间内,线程是阻塞状态
  • isAlive():判断当前线程是否存活。
  1. final void runWorker(Worker w) {
  2.     // ...
  3.     try {
  4.         while (task != null || (task = getTask()) != null) {
  5.             // ...
  6.             try {
  7.                 try {
  8.                     task.run(); // 执行普通的run方法
  9.                 } finally {
  10.                     task = null; // task置空
  11.                 }
  12.             }
  13.         }
  14.     } finally {
  15.         processWorkerExit(w, completedAbruptly); // 回收空闲线程
  16.     }
  17. }
复制代码
  1. private Runnable getTask() {
  2.     boolean timedOut = false; // Did the last poll() time out?
  3.     for (; ; ) { // (1)
  4.         // 校验线程池状态的代码,先省略...
  5.         
  6.         int wc = workerCountOf(c);
  7.         // Are workers subject to culling?
  8.         boolean timed = allowCoreThreadTimeOut || wc > corePoolSize; // (2)
  9.         if ((wc > maximumPoolSize || (timed && timedOut))
  10.                 && (wc > 1 || workQueue.isEmpty())) {
  11.             if (compareAndDecrementWorkerCount(c)) // 线程数减1
  12.                 return null; // 这里时中断外层while循环的时机
  13.             continue;
  14.         }
  15.         try {
  16.             Runnable r = timed ?
  17.                     workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
  18.                     workQueue.take(); // (3)
  19.             if (r != null)
  20.                 return r; // 取到值了就在外层的while循环中执行任务
  21.             timedOut = true; // 否则就标记为获取队列任务超时
  22.         } catch (InterruptedException retry) {
  23.             timedOut = false;
  24.         }
  25.     }
  26. }
复制代码
volatile

线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型(JMM)。

类变量、实例变量是共享变量,方法局部变量是私有变量。共享变量的值保存在主内存中,每个线程都有自己的工作内存,私有变量就保存在工作内存。
在Java虚拟机中,共享变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!
这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。
因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。
volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
但是volatile不能保证原子性,原子性问题需要根据实际情况做同步处理。
线程同步

什么叫线程同步?对于多线程的程序来说,同步指的是在一定的时间内只允许某一个线程访问某个资源。
在Java中,最常见的方法是用synchronized关键字实现同步效果。
synchronized

synchronized可以修饰实例方法、静态方法、代码块。
synchronized的底层是使用操作系统的互斥锁(mutex lock)实现的,它的特点是保证内存可见性、操作原子性。

  • 内存可见性:可见性的原理还要回到Java内存模型(上面JMM的那张图)。 synchronized上锁时,会清空工作内存中变量的值,去主内存中获取该变量的值;解锁时,会把工作内存中变量的值同步回主内存中
  • 操作原子性:持有同一个锁的两个同步块只能串行地执行。
使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。
不需要synchronized的操作

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

  • 基本类型(long和double除外)赋值,例如:int n = 1;
  • 引用类型赋值,例如:List list = anotherList。
long和double是64位(8字节)数据,在32位和64位操作系统上是不一样的。JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

曹旭辉

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表