跳到正文
hello world

6. Runnable:线程和任务到底有什么区别

发布于阅读量 0

前面几节我一直在用 Thread 写代码。

比如:

Thread thread = new Thread(() -> {
    // 处理 PDF
}, "pdf-thread-1");

thread.start();

这种写法已经能跑起来了,但这里其实有一个更重要的概念:线程和任务不是一回事

刚开始看代码时,很容易把它们混在一起。

我以前也会下意识觉得,Thread 里面写了代码,那 Thread 就是任务。后来写线程池的时候才发现,线程池真正接收的并不是线程,而是任务。

这个任务,最常见的表示方式就是 Runnable


Thread 是执行者,Runnable 是任务

我现在更愿意这样理解:

Thread:负责执行任务的线程;
Runnable:描述这个线程要执行什么任务。

比如我要处理一个 PDF。

“处理 PDF”这件事本身是任务。

而“由哪个线程去处理”,这是执行问题。

如果用生活里的说法,可以理解成:

Runnable 是活;
Thread 是干活的人。

代码里也是一样。

Runnable task = () -> {
    System.out.println("开始处理 PDF:" + Thread.currentThread().getName());
};

这里的 task 只是描述了一段要执行的逻辑。

它自己不会自动执行。

真正让它跑起来,需要交给线程:

Thread thread = new Thread(task, "pdf-thread-1");
thread.start();

这时 pdf-thread-1 才会去执行 task 里面的代码。


先把任务单独拆出来

新建一个类:

com.succos.thread.RunnableDemo

代码如下:

package com.succos.thread;

public class RunnableDemo {

    public static void main(String[] args) {

        System.out.println("main 开始:" + Thread.currentThread().getName());

        Runnable task = () -> {
            System.out.println("任务开始执行:" + Thread.currentThread().getName());

            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }

            System.out.println("任务执行完成:" + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "pdf-thread-1");

        thread.start();

        System.out.println("main 继续执行:" + Thread.currentThread().getName());
    }
}

这段代码和之前直接写 new Thread(() -> {...}) 的效果差不多。

区别只是我把任务先单独定义成了:

Runnable task = () -> {
    // 任务内容
};

然后再交给线程:

Thread thread = new Thread(task, "pdf-thread-1");

这样写以后,任务和线程的边界会更清楚。


Runnable 不等于马上执行

这里有一个点要注意。

当我写下:

Runnable task = () -> {
    System.out.println("任务开始执行");
};

这段代码并不会马上执行。

它只是创建了一个任务对象。

真正执行是在这里:

thread.start();

start() 启动线程以后,新线程会去执行 Runnable 里的 run() 方法。

所以这段代码的执行流程大概是:

main 线程创建 Runnable 任务;
main 线程创建 Thread,并把 Runnable 交给 Thread;
main 线程调用 thread.start();
pdf-thread-1 启动;
pdf-thread-1 执行 Runnable 里的代码。

这就是为什么我说:

Runnable 是任务描述,不是任务执行。

这个点后面看线程池的时候很重要。

因为线程池里的 execute() 方法接收的也是 Runnable

executor.execute(task);

它的意思不是“创建一个线程”,而是“提交一个任务,让线程池里的线程去执行”。


Lambda 写法还原一下

前面的写法用了 Lambda:

Runnable task = () -> {
    System.out.println("任务开始执行");
};

如果不写 Lambda,它其实等价于匿名内部类:

Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("任务开始执行");
    }
};

也就是说,Runnable 本质上就是一个接口:

public interface Runnable {
    void run();
}

它里面只有一个方法:

void run();

所以 Lambda 才能这样写。

我现在理解 Lambda 时,一般不会把它想得太神秘。它只是把“实现一个只有一个方法的接口”写得更短一点。

下面这两种写法,本质上是在表达同一件事:

Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("处理 PDF");
    }
};

和:

Runnable task = () -> {
    System.out.println("处理 PDF");
};

只是第二种更简洁。


用 Runnable 处理一个 PDF

现在把任务换成真正的 PDF 处理逻辑。

package com.succos.thread;

import com.succos.dto.FileItemContext;
import com.succos.service.PdfWatermarkService;

public class RunnablePdfDemo {

    public static void main(String[] args) {

        Runnable pdfTask = () -> {
            System.out.println("开始处理 PDF:" + Thread.currentThread().getName());

            try {
                FileItemContext fileItemContext = new FileItemContext();

                fileItemContext.setSourcePath("input/test1.pdf");
                fileItemContext.setTargetPath("output/test1-watermark.pdf");
                fileItemContext.setWaterMakeText("上下文网");

                PdfWatermarkService service = new PdfWatermarkService();

                service.addWaterMakerOfPDF(fileItemContext);

                System.out.println("PDF 处理完成:" + Thread.currentThread().getName());

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

        Thread thread = new Thread(pdfTask, "pdf-thread-1");

        thread.start();

        System.out.println("main 继续执行:" + Thread.currentThread().getName());
    }
}

这里的结构就比较清楚了:

pdfTask:描述 PDF 怎么处理;
pdf-thread-1:负责执行这个任务。

这个拆分看起来只是写法上的变化,但对后面理解线程池很关键。

因为线程池的核心不是“你自己创建线程”,而是:

你提交任务;
线程池安排线程执行任务。

同一个 Runnable 可以交给多个 Thread 吗?

可以。

比如:

Runnable task = () -> {
    System.out.println("执行任务:" + Thread.currentThread().getName());
};

Thread thread1 = new Thread(task, "pdf-thread-1");
Thread thread2 = new Thread(task, "pdf-thread-2");

thread1.start();
thread2.start();

这表示同一个任务逻辑,被两个线程各执行一遍。

运行后可能输出:

执行任务:pdf-thread-1
执行任务:pdf-thread-2

不过这里要注意:如果 Runnable 里面访问了共享变量,就可能出现线程安全问题。

比如:

private static int count = 0;

Runnable task = () -> {
    count++;
};

如果多个线程同时执行这个任务,count++ 就可能出问题。

所以 Runnable 可以复用,但复用时要看里面有没有共享状态。

如果每个线程处理的是不同 PDF,最好每个任务里用自己的局部变量,不要把任务状态都放到共享成员变量里。


Runnable 没有返回值

Runnable 有一个限制:它没有返回值。

它的方法签名是:

void run();

所以如果我只是想执行一个任务,比如处理 PDF、写日志、调用接口,用 Runnable 很自然。

但是如果我想拿到任务结果,比如:

PDF 是否处理成功;
生成后的文件路径是什么;
失败原因是什么;
处理耗时是多少。

Runnable 就不太方便了。

后面会学到 CallableFuture

CallableRunnable 的区别可以先简单记一下:

Runnable:执行任务,没有返回值;
Callable:执行任务,有返回值。

比如线程池里:

executor.execute(runnableTask);

适合只执行任务。

而:

Future<String> future = executor.submit(callableTask);

适合执行完以后还要拿结果。

这一节先不用展开,后面到 Future 时再细说。


Runnable 不能直接抛受检异常

还有一个细节。

Runnablerun() 方法没有声明 throws Exception

public interface Runnable {
    void run();
}

所以在 Lambda 里如果调用了会抛受检异常的方法,就需要自己处理。

比如 PDF 水印方法如果是:

service.addWaterMakerOfPDF(fileItemContext);

并且它会抛异常,那在 Runnable 里通常要这样写:

Runnable task = () -> {
    try {
        service.addWaterMakerOfPDF(fileItemContext);
    } catch (Exception e) {
        e.printStackTrace();
    }
};

不能直接写成:

Runnable task = () -> {
    service.addWaterMakerOfPDF(fileItemContext);
};

如果这个方法抛的是受检异常,编译就过不了。

这个限制也是后面 Callable 更方便的原因之一。

Callablecall() 方法可以抛异常,也可以返回结果。


为什么线程池接收 Runnable

前面这些内容,其实是在给线程池做铺垫。

线程池的基本用法是:

executor.execute(() -> {
    System.out.println("处理 PDF");
});

这里传进去的 Lambda,本质上就是一个 Runnable

线程池内部会有一批线程,这些线程不需要我们每次手动创建。

我们只需要不断提交任务:

~~~text
提交任务1;
提交任务2;
提交任务3;