快速掌握并发编程---基础篇

    科技2023-11-17  86

    关注“Java后端技术全栈”

    回复“面试”获取全套面试资料

    进程与线程

    进程

    进程的本质是一个正在执行的程序,程序运行时系统会创建一个进程,并且给每个进程分配独立的内存地址空间保证每个进程地址不会相互干扰。同时,在 CPU 对进程做时间片的切换时,保证进程切换过程中仍然要从进程切换之前运行的位置出开始执行。所以进程通常还会包括程序计数器、堆栈指针。

    相对好理解点的解释:电脑上开启QQ就是开启一个进程、打开IDEA就是开启一个进程、打开浏览器也是开启一个进程…..

    当电脑开启太多的应用(QQ,微信,浏览器、PDF、word、IDEA等)后,很容易出现卡顿,甚至死机的情况,最主要的原因是因为CPU在一直不停地切换。下图展示了单核CPU情况下,多进程之间的切换过程。

    有了进程以后,可以让操作系统从宏观层面实现多应用并发。而并发的实现是通过 CPU 时间片不断切换执行的。对于单核 CPU 来说,在任意一个时刻只会有一个进程在被CPU 调度。

    线程

    已经有了进程,为什么还要搞个线程呢?难道是为了为难我们程序员?非也非也…下面来说说线程。

    计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

    有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。线程是程序中一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派 CPU 的基本单位指运行中的程序的调度单位。在单个程序中同时运行多个线程完成不同的工作,称为多线程。

    既然有了进程,那么线程出现有何意义呢?

    在多核 CPU 中,利用多线程可以实现真正意义上的并行执行

    在一个应用进程中,会存在多个同时执行的任务,如果其中一个任务被阻塞,将会引起不依赖该任务的任务也被阻塞。通过对不同任务创建不同的线程去处理,可以提升程序处理的实时性

    线程可以认为是轻量级的进程,所以线程的创建、销毁比进程更快

    进程VS线程

    这也是面试中很容易被问到的,请大家用心领会。下面就来说说进程与线程的区别

    进程是资源分配的最小单位,线程是程序执行的最小单位。

    进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多,线程的上下文切换的性能消耗要小于进程。

    线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。

    但是多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

    线程的应用

    线程的创建方式有哪些?

    在 Java 中,有多种方式来实现多线程。和古代练习武功一样,万变不离其宗,所有的线程启动方式都是基于Thread来做文章的。所以线程的根还是Thread这个类。常用创建线程的方式有下几种方式:

    继承java.lang.Thread类

    实现 java.lang.Runnable

    使用java.util.concurrent.ExecutorService、Callable、Future实现带返回结果的多线程。

    继承` Thread`类创建线程
    public class ThreadDemo extends Thread{     @Override     public void run() {         System.out.println("start thread");     }     public static void main(String[] args) {         ThreadDemo threadDemo1=new ThreadDemo();         ThreadDemo threadDemo2=new ThreadDemo();         threadDemo1.start();         threadDemo2.start();     } }

    Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。

    1public class Thread implements Runnable { 2 public synchronized void start() {  3        if (threadStatus != 0) 4            throw new IllegalThreadStateException();  5        group.add(this);  6        boolean started = false; 7        try { 8            //调用start0方法 9            start0(); 10            started = true; 11        } finally { 12            try { 13                if (!started) { 14                    group.threadStartFailed(this); 15                } 16            } catch (Throwable ignore) { 17                /* do nothing. If start0 threw a Throwable then 18                  it will be passed up the call stack */ 19            } 20        } 21    } 22    //native修饰的方法,只有观看虚拟机源码才知道这里面是怎么玩的, 23    //个人认为看虚拟机源码的必要性不是很强,如果有精力的话也不妨去瞧瞧 24    private native void start0(); 25}

    启动线程的唯一方法就是通过 Thread类的 start()实例方法。start()方法是一个 native 方法,它会启动一个新线程,并执行 run()方法。这种方式实现多线程很简单,通过自己的类直接 extend Thread,并复写 run()方法,就可以启动新线程并执行自己定义的 run()方法。

    面试的时候时长会被问start方法和run方法的区别,笔试中遇到的概率是非常高的。start方法是启动一个线程,而run方法是普通的实例方法调用而已。

    实现 Runnable 接口创建线程

    定义一个类实现Runnable接口

    创建该类的实例对象obj

    将obj作为构造器参数传入Thread类实例对象,这个对象才是真正的线程对象

    调用线程对象的start()方法启动该线程

    public class User {     //.... } public class Zhangsan extends User implements Runnable{     @Override     public void run() {         System.out.println("实现Runnable接口创建线程");     }     public static void main(String[] args) {          new Thread(new Zhangsan() ).start();     } }

    如果需要继承某个业务类,那么此时就不能再使用继承Thread的方式创建线程了。本环境JDK版本为1.8+,所以可以看到Runnable接口使用注解@FunctionlInterface修饰,也就是说Runnable接口是函数式接口,可使用lambda表达式创建对象,使用lambda表达式就可以不像上述代码一样还要创建一个实现Runnable接口的类,然后再创建类的实例。

    @FunctionalInterface public interface Runnable {     public abstract void run(); }

    相比前面的继承Thread,这种实现Runnable接口的方式更加灵活,主要原因是Java中只支持单继承。

    使用` ExecutorService、Callable、Future `实现带返回结果的多线程

    从继承Thread类和实现Runnable接口可以看出,上述两种方法都不能有返回值,且不能声明抛出异常。而Callable接口则实现了此两点,Callable接口如同Runable接口的升级版,其提供的call()方法将作为线程的执行体,同时允许有返回值。

    但是Callable对象不能直接作为Thread对象的target,因为Callable接口是 Java 5 新增的接口,不是Runnable接口的子接口。对于这个问题的解决方案,就引入 Future接口,此接口可以接受call() 的返回值,RunnableFuture接口是Future接口和Runnable接口的子接口,可以作为Thread对象的target 。通过future.get()获取返回值。

    public class CallableDemo  implements Callable<String> {     @Override     public String call() throws Exception {         //业务代码...         return "老田写的demo";     }     public static void main(String[] args) throws ExecutionException, InterruptedException {         ExecutorService executorService=Executors. newFixedThreadPool (1);         CallableDemo callableDemo=new CallableDemo();         Future<String> future=executorService.submit(callableDemo);         System. out .println(future.get());         executorService.shutdown();     } }

    线程的生命周期

    既然是生命周期,那么就很有可能会有阶段性的或者状态的,比如人的一生一样:

    精子和卵子结合---> 婴儿---> 小孩--> 成年--> 中年--> 老年-->去世

    线程状态

    关于线程的生命周期网上有不一样的答案,有说五种也有说六种。Java中线程确实有6种,这是有理有据的,可以看看java.lang.Thread类中有个这么一个枚举。

    public enum State {         NEW,         RUNNABLE,         BLOCKED,          WAITING,          TIMED_WAITING,          TERMINATED; }

    这就是Java线程对应的状态,组合起来就是Java中一个线程的生命周期。下面是这个枚举的注释

    每种状态简单说明:

    NEW(初始):线程被创建后尚未启动。

    RUNNABLE(运行):包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程可能正在运行,也可能正在等待系统资源,如等待CPU为它分配时间片。

    BLOCKED(阻塞):线程阻塞于锁。

    WAITING(等待):线程需要等待其他线程做出一些特定动作(通知或中断)。

    TIME_WAITING(超时等待):该状态不同于WAITING,它可以在指定的时间内自行返回。

    TERMINATED(终止):该线程已经执行完毕。

    线程生命周期

    借用网上的这张图,这张图描述的很清楚了,这里就不用在啰嗦了。

    线程的启动

    前面的案列中。我们演示了线程的启动,也就是调用start()方法去启动一个线程,当 run 方法中的代码执行完毕以后,线程的生命周期也将终止。调用 start 方法的语义是当前线程告诉 JVM,启动调用 start 方法的线程。

    线程启动原理

    我身边很多小伙伴对于线程的启动,还是处于朦胧的状态,说不懂但是知道调用start方法,就启动了线程。但是为什么不是调用run方法呢?这可能是很多人都不知道的。再回到Thread的源码中

       /**      * start方法将导致this thread开始执行。由JVM调用this thread的run方法。      * Causes this thread to begin execution; the Java Virtual Machine      * calls the <code>run</code> method of this thread.      * <p>      * 结果是 调用start方法的当前线程 和 执行run方法的另一个线程 同时运行。        * The result is that two threads are running concurrently: the      * current thread (which returns from the call to the      * <code>start</code> method) and the other thread (which executes its      * <code>run</code> method).      * <p>      *  多次启动线程永远不合法。 特别是,线程一旦完成执行就不会重新启动。      * It is never legal to start a thread more than once.      * In particular, a thread may not be restarted once it has completed      * execution.      *      * @exception  IllegalThreadStateException  if the thread was already      *               started.      * @see        #run()      * @see        #stop()      */ public class Thread implements Runnable {     /* Make sure registerNatives is the first thing <clinit> does. */     private static native void registerNatives();     static {         registerNatives();     }     //start方法用synchronized修饰,为同步方法     public synchronized void start() {         /**          * 对于由VM创建/设置的main方法线程或“system”组线程,不会调用此方法。           * 未来添加到此方法的任何新功能可能也必须添加到VM中。          * This method is not invoked for the main method thread or "system"          * group threads created/set up by the VM. Any new functionality added          * to this method in the future may have to also be added to the VM.          * status=0 代表是 status 是 "NEW"。          * A zero status value corresponds to state "NEW".          */         if (threadStatus != 0)             throw new IllegalThreadStateException();         /*           * 通知组该线程即将启动,以便将其添加到线程组的列表中,并且减少线程组的未启动线程数递减。          * Notify the group that this thread is about to be started          * so that it can be added to the group's list of threads          * and the group's unstarted count can be decremented.          */         group.add(this);         boolean started = false;         try {             //通知组该线程即将启动,以便将其添加到线程组的列表中,并且减少线程组的未启动线程数递减。             start0();             started = true;         } finally {             try {                 if (!started) {                     group.threadStartFailed(this);                 }             } catch (Throwable ignore) {                 /* do nothing. If start0 threw a Throwable then                   it will be passed up the call stack */             }         }     }     //native方法,JVM创建并启动线程,并调用run方法     private native void start0(); }   

    我们看到调用 start 方法实际上是调用一个 native 方法start0()来启动一个线程,首先 start0()这个方法是在Thread 的静态块中来注册的。

    对应虚拟机源码中 

    其他部分JVM源码这里就没必要一一道来。

    线程的终止

    上面说完线程的启动原理,下面来说说线程的终止方式和原理。

    这也是3到5年工作经验的人面试被问频率非常高的一个知识点。

    线程的终止,并不是简单的调用 stop 命令去。虽然 api 仍然可以调用,但是和其他的线程控制方法如 suspend、resume 一样都是过期了的不建议使用,就拿 stop 来说,top 方法在结束一个线程时并不会保证线程的资源正常释放,因此会导致程序可能出现一些不确定的状态。

    停止一个线程通常意味着在线程处理任务完成之前停掉正在做的操作,也就是放弃当前的操作。

    在 Java 中有以下 3 种方法可以终止正在运行的线程:

    使用退出标志,使线程正常退出,也就是当 run() 方法完成后线程中止。

    使用 stop() 方法强行终止线程,但是不推荐使用这个方法,该方法已被弃用。

    使用 interrupt 方法中断线程。

    第一种方式就是定义一个变量,比如说boolean类型的,通过不改变这个变量的值,然后再run方法里判断这个变量的值是否有变化,有变化则终止该线程。

    第二种已经被弃用了,为什么弃用stop:

    调用 stop() 方法会立刻停止 run() 方法中剩余的全部工作,包括在 catch 或 finally 语句中的,并抛出ThreadDeath异常(通常情况下此异常不需要显示的捕获),因此可能会导致一些清理性的工作的得不到完成,如文件,数据库等的关闭。

    调用 stop() 方法会立即释放该线程所持有的所有的锁,导致数据得不到同步,出现数据不一致的问题。

    第三种interrupt() 方法并不像第一种那样在 for 循环语句中使用 break 语句那样干脆,马上就停止循环。调用 interrupt() 方法仅仅是在当前线程中打一个停止的标记,并不是真的停止线程。也就是说,线程中断并不会立即终止线程,而是通知目标线程,有人希望你终止。至于目标线程收到通知后会如何处理,则完全由目标线程自行决定。这一点很重要,如果中断后,线程立即无条件退出,那么我们又会遇到 stop() 方法的老问题。事实上,如果一个线程不能被 interrupt,那么 stop 方法也不会起作用。

    这里面试最常被问

    如何优雅的终止一个线程

    要优雅的去中断一个线程,在线程中提供了一个 interrupt方法。当其他线程通过调用当前线程的 interrupt 方法,表示向当前线程打个招呼,告诉他可以中断线程的执行了,至于什么时候中断,取决于当前线程自己。线程通过检查自身是否被中断来进行相应,可以通过isInterrupted()来判断是否被中断。

    下面一个案例

    public class InterruptDemo {     private static int  i ;     public static void main(String[] args) throws InterruptedException {         Thread thread=new Thread(()->{             //默认情况下 isInterrupted 返回 false、通过 thread.interrupt 变成了 true             while(!Thread. currentThread ().isInterrupted()){                 i ++;             }             System. out .println("Num:"+ i );         },"interruptDemo");         thread.start();         TimeUnit. SECONDS .sleep(1);         //重点         thread.interrupt();     } }

    这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程停止,因此这种终止线程的做法显得更加安全和优雅。

    推荐阅读

    为什么 Redis 单线程却能支撑高并发?

    《Java虚拟机并发编程》.pdf

    《Java并发编程的艺术》.pdf

    Processed: 0.017, SQL: 8