【并发专题】从0开始深入理解并发、线程与等待通知机制(中——认识JAVA里的线程)

不管现实多么惨不忍睹,都要持之以恒地相信,这只是黎明前短暂的黑暗而已。不要惶恐眼前的难关迈不过去,不要担心此刻的付出没有回报,别再花时间等待天降好运。真诚做人,努力做事!你想要的,岁月都会给你。【并发专题】从0开始深入理解并发、线程与等待通知机制(中——认识JAVA里的线程),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

课程内容

一、Java程序天生就是多线程

一个 Java 程序从 main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上 Java 程序天生就是多线程程序,因为执行 main()方法的是一个名称为 main 的线程。
而一个 Java 程序的运行就算是没有用户自己开启的线程,实际也有有很多JVM 自行启动的线程,一般来说有:
[6] Monitor Ctrl-Break //监控 Ctrl-Break 中断信号的
[5] Attach Listener //内存 dump,线程 dump,类信息统计,获取系统属性等
[4] Signal Dispatcher // 分发处理发送给 JVM 信号的线程
[3] Finalizer // 调用对象 finalize 方法的线程
[2] Reference Handler//清除 Reference 的线程
[1] main //main 线程,用户程序入口
尽管这些线程根据不同的 JDK 版本会有差异,但是依然证明了 Java 程序天生就是多线程的。

public class TestJava {
    public static void main(String[] args) {

        // Java虚拟机线程系统的管理接口
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();

        // 不需要获取同步的monitori和synchronizer信息,仅仅获取线程和线程堆栈信息
        ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);

        // 通历线程信息,仅打印线程ID和线程名称信息
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println("[" + threadInfo.getThreadId() + "]"
                    + threadInfo.getThreadName());
        }
    }
}

// 输出如下
//                [1]main
//                [2]Reference Handler
//                [3]Finalizer
//                [4]Signal Dispatcher
//                [5]Attach Listener
//                [6]Monitor Ctrl-Break

二、线程的启动

在上面的代码演示中,我们看到的线程都是 JVM 启动的系统线程,我们学习并发编程是希望的自己能操控线程,所以我们总该得知道如何启动一个线程。(大家应该都还记得如何启动一个线程吧,在面向面试编程的时候,多多少少都会接触到才对)

public class TestJava {
    public static void main(String[] args) {

        // 第一种方案,继承一个Thread
        MyThread myThread = new MyThread() {
            @Override
            public void run() {
                super.run();
            }
        };
        myThread.start();

        // 第二种方案,实现一个Runnable接口
        Thread myRunnable = new Thread(new MyRunnable());
        myRunnable.start();
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        super.run();
        System.out.println("my thread run");
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("my runnable run");
    }
}
  • 方式1:继承一个Thread类,如上述代码的MyThread
  • 方式2:实现一个Runnable接口,如上述代码MyRunnable

Thread 和 Runnable 区别

严格来说,Thread 才是 Java 里对线程的唯一抽象,Runnable接口只是对【执行任务】(业务逻辑)的抽象。Thread 可以接受任意一个 Runnable 的实例并执行。

Callable、Future 和 FutureTask

Runnable 是一个接口,在它里面只声明了一个 run()方法,由于 run()方法返回值为 void 类型,所以在执行完任务之后无法返回任何结果
在这里插入图片描述

Callable 位于 java.util.concurrent 包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做 call(),这是一个泛型接口,call()函数返回的类型就是传递进来的 V 类型
在这里插入图片描述
Future 就是对于具体的 Runnable 或者 Callable 任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过 get 方法获取执行结果,该方法会阻塞直到任务返回结果
在这里插入图片描述
因为 Future 只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的 FutureTask
在这里插入图片描述
在这里插入图片描述
FutureTask 类实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable接口和 Future 接口,。所以它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值
在这里插入图片描述
因此我们通过一个线程运行 Callable,但是 Thread 不支持构造方法中传递Callable 的实例,所以我们需要通过 FutureTask 把一个 Callable 包装成 Runnable,然后再通过这个 FutureTask 拿到 Callable 运行后的返回值。要 new 一个 FutureTask 的实例,有两种方法
在这里插入图片描述
Callble的简单示例:

public class TestJava {
    public static void main(String[] args) {

        FutureTask task = new FutureTask<Integer>(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                System.out.println("开始进入future task了");
                int a = 1;
                int b = 2;
                Thread.sleep(1000 * 3);
                return a + b;
            }
        });

        Thread thread = new Thread(task);
        thread.start();

        try {
            System.out.println("开始调用future task的值");
            System.out.println(task.get());
            System.out.println("试验结束");
        } catch (InterruptedException e) {

        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

// 输出内容
//        开始调用future task的值
//        开始进入future task了
//        3
//        试验结束

面试题:新启线程有几种方式?

这个问题的答案其实众说纷纭,有 2 种,3 种,4 种等等答案,建议比较好的回答是:2种。为啥?因为这是Java源码里面自己说的。
在这里插入图片描述
这也是能理解的,本质上来说,什么Callable,什么FutureTask都是要包装成Runnable接口再交给Thread执行嘛。而线程池的方式,本质上是池化技术,是资源的复用,和新启线程没什么关系

三、线程的中止

线程的中止,总的来说分为以下几种:

  • 自然中止:自然终止,要么是run方法运行完成了,要么就是抛出异常停止了
  • 调用线程的stop()方法:这是一种极其强制、暴力的方式,无法保证持有资源的正常释放,也会导致工作在不正常状态下终止
  • 中断==interrupt()==方法:一种比较优雅的,停止线程的方式。但是他不代表就会立刻终止工作。更多的像是一种“打招呼”,告诉线程“你该收拾东西走人了”,所以,线程甚至还会不听话,不理会你的招呼,“你说走人就走人我不是很没面子”。中断的线程通过检查自身的中断标志位是否被置为 true 来进行响应。

线程通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()来进行判断当前线程是否被中断,不过Thread.interrupted()会同时将中断标识位改写为 false
如果一个线程处于了阻塞状态(如线程调用了 thread.sleep、thread.join、thread.wait 等),则在线程在检查中断标示时如果发现中断标示为 true,则会在这些阻塞方法调用处抛出 InterruptedException 异常,并且在抛出异常后会立即将线程的中断标示位清除,即重新设置为 false。
不建议自定义一个取消标志位来中止线程的运行。因为 run 方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。这种情况下,使用中断会更好,因为一般的阻塞方法,如 sleep 等本身就支持中断的检查;另外检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可以避免声明取消标志位,减少资源的消耗
注意:处于死锁状态的线程无法被中断

interrupt()、interrupted()、isinterrupted()傻傻分不清?

上源码

	private volatile boolean interrupted;

	// 这是一个动词,它会把interrupted字段置为true
    public void interrupt() {
        if (this != Thread.currentThread()) {
            checkAccess();

            // thread may be blocked in an I/O operation
            synchronized (blockerLock) {
                Interruptible b = blocker;
                if (b != null) {
                    interrupted = true;
                    interrupt0();  // inform VM of interrupt
                    b.interrupt(this);
                    return;
                }
            }
        }
        interrupted = true;
        // inform VM of interrupt
        interrupt0();
    }

	// 动词的过去分词,可以做形容词
	// 这里其实也是返回interrupted,只不过,它读完interrupted,如果判断为true
	// 它还要把interrupted置为false,即:重置interrupted状态
   public static boolean interrupted() {
       Thread t = currentThread();
       boolean interrupted = t.interrupted;
       // We may have been interrupted the moment after we read the field,
       // so only clear the field if we saw that it was set and will return
       // true; otherwise we could lose an interrupt.
       if (interrupted) {
           t.interrupted = false;
           clearInterruptEvent();
       }
       return interrupted;
    }
    
	// 只是获取字段
	public boolean isInterrupted() {
	      return interrupted;
	}

总结一下:

  • interrupt():这是一个动词,它会把interrupted字段置为true
  • interrupted():动词的过去分词,可以做形容词。这里其实也是返回interrupted字段,只不过,它读完interrupted字段,如果判断为true,它还要把interrupted置为false,即:重置interrupted状态
  • isInterrupted():只是简单的返回interrupted字段

四、深入理解 run()和 start()

Thread类,只是我们Java源码里,设计的一个对线程概念的抽象,可以这样理解:我们通过new Thread()其实只是 new 出一个我们自己的Java Thread 的实例而已,还没有操作系统中真正的线程挂起钩来。只有执行了 start()方法后,才实现了真正意义上的启动线程

    public synchronized void start() {
        /**
         * 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.
         *
         * 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 */
            }
        }
    }

    private native void start0();

PS:看上面的代码,过程中调用了一个native void start0(),native本地方法,用来连接系统资源的
start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常(注意,此处可能有面试题:多次调用一个线程的 start 方法会怎么样?)

public class TestJava {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
		
		// 第一次调用run
        myThread.run();

        // 第二次调用run
        myThread.run();
        
		// 第一次调用
        myThread.start();
        
        // 第二次调用
        myThread.start();
    }
}

class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("线程id" + Thread.currentThread().getId());
    }
}

// 输出内容
// 线程id1
// 线程id1
// Exception in thread "main" java.lang.IllegalThreadStateException
// 	 at java.base/java.lang.Thread.start(Thread.java:793)
//	 at com.once.game.TestJava.main(TestJava.java:36)
//线程id16

而 run 方法是业务逻辑实现的地方,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,也可以被单独调用

五、Java线程沉浸式学习

线程的状态/生命周期

在这里插入图片描述

Java中线程的状态分为6种:初始(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、超时等待(TIMED_WAITING)、终止(TERMINATED)。我们可以通过Jstack pid命令来看看
值得注意的一点:线程,只有在【可运行(RUNNABLE)】状态的时候才会去竞争、持有CPU资源,其余时刻都不会竞争或者持有

初始(NEW)

新创建了一个线程对象,但是还没有调用start()方法的时候。此时仅由 JVM 为其分配内存,并初始化其成员变量的值

Thread myThread = new Thread();
可运行(RUNNABLE)

这个运行状态其实严格来说有两个,分别为:就绪状态(ready)和运行中(running)两种状态。 在线程对象创建并调用start()方法后,该线程就位于可运行线程池中等待调度选中了。如果此时未获得CPU使用权,那么线程此时处于就绪状态(ready);就绪状态的线程一旦获得了CPU时间片,就变为运行中(running)状态了

  • 特点:就绪状态没有CPU使用权;运行状态获得了CPU使用权
阻塞(BLOCKED)

表示线程阻塞住了,阻塞状态通常是因为某种原因放弃了CPU使用权,即让出了CPU Timeslice(CPU时间片),暂时停止运行。直到线程再次进入可运行状态,才有机会获得CPU使用权。阻塞的原因通常是因为锁

  • 特点:没有CPU使用权
等待(WAITING)

进入该状态的线程需要等待其他线程做出一些特定的动作,才能被唤醒(通知或中断)

超时等待(TIMED_WAITING)

该状态不同于 WAITING,它可以在指定的时间后自行返回(从代码层面来说,它跟WAITING状态几乎没啥区别,只不过等待是无休止的等待,而超时等待一般都是在指定时间内结束的

终止(TERMINATED)

表示该线程已经执行完毕,线程终止。终止的情况也会有三种:

  • 正常结束:run()或 call()方法执行完成,线程正常结束
  • 异常结束:线程抛出一个未捕获的 Exception 或 Error
  • 调用stop():直接调用该线程的 stop()方法来暴力结束该线程—该方法,不推荐使用。
状态流转图

PS:掌握这些状态可以让我们在进行 Java 程序调优时可以提供很大的帮助
在这里插入图片描述
值得注意的一点:线程,只有在【可运行(RUNNABLE)】状态的时候才会去竞争、持有CPU资源,其余时刻都不会竞争或者持有

线程的优先级

在 Java 线程中,通过一个整型成员变量 priority 来控制优先级,优先级的范围从 1~10,在线程构建的时候可以通过 setPriority(int)方法来修改优先级,默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时的一般原则:

  • 针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级;
  • 而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。

需要注意的是,在不同的 JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。所以有的人说,Java的线程优先级显得有点粗糙

线程的调度

线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种:

  • 协同式线程调度(Cooperative Threads-Scheduling):使用协同式线程调度的多线程系统,线程执行的时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系统进行线程切换,所以没有线程同步的问题,但是坏处也很明显,如果一个线程出了问题,则程序就会一直阻塞一句话概括:我用完了,再由我指定谁来使用(选择继承人)
  • 抢占式线程调度(Preemptive Threads-Scheduling)(大部分系统采取的策略):使用抢占式线程调度的多线程系统,每个线程执行的时间以及是否切换都由系统决定。在这种情况下,线程的执行时间不可控,所以不会有【一个线程导致整个进程阻塞】的问题出现。一句话概括:系统决定

Java 线程调度就是抢占式调度,为什么呢?我们需要先了解下面的知识。

线程和协程

其实任何语言,实现线程的方式主要就3种,分别是:使用内核线程实现(1:1 实现),使用用户线程实现(1:N 实现),使用用户线程加轻量级进程混合实现(N:M 实现)。

使用内核线程实现(1:1 实现)
  • 介绍:使用内核线程实现的方式也被称为 1: 1 实现。内核线程(Kernel-LevelThread, KLT) 就是直接由操作系统内核(Kernel, 下称内核) 支持的线程,这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将线程的任务映射到各个处理器上。由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。
  • 局限性:由于是基于内核线程实现的,所以各种线程操作,如:创建、析构、同步,都需要系统来调度。而系统调用的代价相对较高的,需要在【用户态】和【内核态】中来回切换。其次,每个语言层面的线程都需要由一个内核线程的支持,所以会一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的(不同的系统差异不同,几千上万不等)
  • 典型应用:JVM(JDK1.8,甚至在JDK19之前的时期)

上面提到【用户态】、【内核态】,那这俩是什么?有什么区别呢?

  • 内核态:系统划分给【内核进程】专门使用的内存空间,且只能由内核进程访问。内核进程可以访问所有内存,我们称这些【内核进程】在【内核态】执行
  • 用户态:系统专门划分给【用户进程】使用的内存空间,由用户进程自己访问,但是内核进程也可以访问。我们称这些【用户进程】在做【用户态】执行

如果用户态程序需要执行系统调用,就需要切换到内核态执行。如下图所示:
在这里插入图片描述

用户线程实现(1:N 实现)
  • 介绍:严格意义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。 如果程序实现得当, 这种线程不需要切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。
  • 优势:用户线程的优势在于管理开销小,毕竟创建、销毁不需要系统调用了;切换成本低,用户空间程序可以自己维护,不需要走操作系统调度
  • 劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难, 甚至有些是不可能实现的。 因为使用用户线程实现的程序通常都比较复杂,所以一般的应用程序都不倾向使用用户线程
  • 典型应用:Java 语言曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如 Golang
混合实现(N:M 实现)

不出意外,当左右为难的时候,总有一个人会出一个折中的方案

  • 介绍:线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式, 被称为 N:M 实现。 在这种混合实现下, 既存在用户线程,也存在内核线程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发,同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的数量比是不定的,是N:M的关系
Java线程的实现选择

Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的,但从 JDK 1.3 起,主流商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1的线程模型
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理
所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,比如Java的线程优先级划分为10级,但是Linux操作系统是3级。双方的粒度都不一样,如何对应?所以Java 优先级并不是特别靠谱

Java的守护线程

Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候,Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。
Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理资源的逻辑。

六、协程

协程,其实是以前留下来的概念。旧解释:采用协程式调度算法的线程。但是现在仅仅是保留了名字,实际采用的算法还是:抢占式。

协程出现的背景

随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。比如,互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不同机器上的大量服务共同协作来实现,也就是我们常说的微服务,这种服务细
分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。(我们上面说过,不同系统的内核线程数量是有限的, 几千上万不等,但是我们Kafka,那是甚至要支持百万级并发的,通常一个请求一条线程,这个数量级差的不是一丁半点)
Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1 的内核线程模型是如今 Java 虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费
另外我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来,但其中的切换损耗也是相当不乐观的
基于上述情形,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。其次,Go 语言等支持用户线程等的新型语言给 Java 带来了巨大的压力,也使得 Java 引入用户线程成为了一个绕不开的话题

协程简介

为什么用户线程又被称为协程呢?我们知道,内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是,一旦把保护、恢复现场及调度的工作从操作系
统交到程序员手上,则可以通过很多手段来缩减这些开销
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine) 完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)
协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在 64 位 Linux上 HotSpot 的线程栈容量默认是 1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计
协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在
总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)

纤程——Java的【协程】实现版

在 JVM 的实现上,以 HotSpot 为例,协程的实现会有些额外的限制,Java调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正常切换协程而不影响整个线程? 另外,如果协程中遇传统的线程同步措施会怎
样? 譬如 Kotlin 提供的协程实现, 一旦遭遇 synchronize 关键字, 那挂起来的仍将是整个线程。所以 Java 开发组就 Java 中协程的实现也做了很多努力,OpenJDK 在 2018 年创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber)”这个名字
Loom 项目背后的意图是重新提供对用户线程的支持,但这些新功能不是为了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型
据 Loom 团队在 2018 年公布的他们对 Jetty 基于纤程改造后的测试结果,同样在 5000QPS 的压力下, 以容量为 400 的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比, 前者的请求响应延迟在 10000 至 20000毫秒之间, 而后者的延迟普遍在 200 毫秒以下
目前 Java 中比较出名的协程库是 Quasar[ ˈkweɪzɑː® ](Loom 项目的 Leader 就是 Quasar 的作者 Ron Pressler), Quasar 的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚拟机的现场保护虽然能够工作,但影响性能

Quasar实战

本实战的代码是单独的项目 quasar,Quasar 的使用其实并不复杂,首先引入 Maven 依赖

        <dependency>
            <groupId>co.paralleluniverse</groupId>
            <artifactId>quasar-core</artifactId>
            <version>0.7.9</version>
        </dependency>

下面是Quasar的代码示例:

   public static void main(String[] args) throws InterruptedException {
        CountDownLatch count = new CountDownLatch(10000);
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        IntStream.range(0, 10000).forEach(i -> new Fiber() {
            @Override
            protected String run() throws SuspendExecution, InterruptedException {
                //Quasar中Thread和Fiber:都被称为Strand,Fiber不能调用Thread.sleep休
                Strand.sleep(1000);
                count.countDown();
                return "aa";
            }
        }.start());

        count.await();
        stopWatch.stop();
        System.out.println("结束了:" + stopWatch.prettyPrint());
    }

下面是普通Thread的代码示例:

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch count = new CountDownLatch(10000);
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        ExecutorService executorService = Executors.newCachedThreadPool();
        IntStream.range(0, 10000).forEach(i -> executorService.submit(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (Exception e) {}
            count.countDown();
        }));

        count.await();
        stopWatch.stop();
        System.out.println("结束了:" + stopWatch.prettyPrint());
    }

从代码层面来看,两者的代码高度相似,忽略两者的公共部分,代码不同的地方也就 2、3 行。其中的 Fiber 就是 Quasar 为我们提供的协程相关的类,可以类比为 Java 中的 Thread 类。
在执行 Quasar 的代码前,还需要配置 VM 参数(Quasar 的实现原理是字节码注入,所以,在运行应用前,需要配置好 quasar-core 的 java agent 地址)

-javaagent:C:\Users\shen\.m2\repository\co\paralleluniverse\quasar-core\0.7.9\quasar-core-0.7.9.jar

在这里插入图片描述
看图,运行效率快了3倍以上!你以为就是这样么简单吗?让我们再次看看线程实现方式用的什么东西。Executors.newCachedThreadPool();这个线程池什么特点?无限扩大,来任务,如果没有可用的我就新建线程处理。生产环境会让你这么肆无忌惮的使用吗?不可能的,撑破就200条线程了。200个的时候,这个结果相差可能就是50倍了!牛逼吧
PS:JDK19的时候,推出了一个非LTS(Long Time Support)版本,这里就不介绍了,感兴趣的自己百度吧

七、线程间的通信、协调和协作

很多的时候,孤零零的一个线程工作并没有什么太多用处,更多的时候,我们是很多线程一起工作,而且是这些线程间进行通信,或者配合着完成某项工作,这就离不开线程间的通信和协调、协作。

管道输入输出流

我们已经知道,进程间有好几种通信机制,其中包括了管道,其实 Java 的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存
设想这么一个应用场景:通过 Java 应用生成文件,然后需要将文件上传到云端,比如:

  1. 页面点击导出后,后台触发导出任务,然后将 mysql 中的数据根据导出条件查询出来,生成 Excel 文件,然后将文件上传到 oss,最后发步一个下载文件的链接
  2. 和银行以及金融机构对接时,从本地某个数据源查询数据后,上报 xml 格式的数据,给到指定的 ftp、或是 oss 的某个目录下也是类似的

我们一般的做法是,先将文件写入到本地磁盘,然后从文件磁盘读出来上传到云盘,但是通过 Java 中的管道输入输出流一步到位,则可以避免写入磁盘这一步。Java 中的管道输入/输出流主要包括了如下 4 种具体实现:PipedOutputStream、PipedInputStream、PipedReader 和 PipedWriter,前两种面向字节,而后两种面向字符

Join()方法——线程间的协调和协作

现在有 T1、T2、T3 三个线程,你怎样保证 T2 在 T1 执行完后执行,T3 在 T2执行完后执行?
答:用 Thread#join 方法即可,在 T3 中调用 T2.join,在 T2 中调用 T1.join
join():把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程 B 中调用了线程 A 的 Join()方法,直到线程 A 执行完毕后,才会继续执行线程 B 剩下的代码
代码示例如下:

public static void main(String[] args) {
     Thread t1 = new Thread(()->{
         System.out.println("本来我是第一步");
     });

     Thread t2 = new Thread(()->{
         System.out.println("本来我是第二步");
     });

     Thread t3 = new Thread(()->{
         System.out.println("本来我是第三步");
     });

	 t3.start();
	 t2.start();
     t1.start();
 }

// 第一次执行输入如下
//		本来我是第二步
//		本来我是第三步
//		本来我是第一步

// 第二次执行输入如下
//		本来我是第一步
//		本来我是第三步
//		本来我是第二步

瞧,输出结果是无序的,每次执行结果可能都不一样。接下来展示一下用join()将他们协调起来:

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            System.out.println("本来我是第一步");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t2 = new Thread(()->{
            try {
                t1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("本来我是第二步");
        });

        Thread t3 = new Thread(()->{
            try {
                t2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("本来我是第三步");
        });

        t3.start();
        t2.start();
        t1.start();
    }
    // 第一次执行输入如下
//		本来我是第一步
//		本来我是第二步
//		本来我是第三步

学习总结

  1. 重温了线程创建的两种方式,以及Callable和Future接口,以及他们的实现类FutureTask
  2. 学习了线程中止的三个关键方法的区别,即:interupt()、interupted()、isInterupted()
  3. 加深了线程间状态流转变化
  4. 对Java线程的深层次原理有了进一步了解
  5. 学习了新的高并发Api,纤程——Fiber
  6. 加深了对join()方法的理解

感谢

感谢【作者:张俊杰1994】的《用户态和内核态:用户态线程和内核态线程有什么区别?》文章

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/180538.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!