「有点收获」三种基本方法创建线程

共 6064字,需浏览 13分钟

 ·

2021-05-13 17:17

挺基础的知识,一开始不是很愿意写,毕竟这种简单的知识大家不一定愿意看,而且容易写的大众化,不过还好梳理一遍下来还算是有点收获,比如我看了 Thread 类重写的 run 方法,才明白为什么可以把任务(Runnable)和线程本身(Thread)分开来。

创建线程的三种方法

线程英译是 Thread,这也是 Java 中线程对应的类名,在 java.lang 包下。

注意下它实现了 Runnable 接口,下文会详细解释。

线程与任务合并 — 直接继承 Thread 类

线程创建出来自然是需要执行一些特定的任务的,一个线程需要执行的任务、或者说需要做的事情就在 Thread 类的 run 方法里面定义。

这个 run 方法是哪里来的呢?

事实上,它并不是 Thread 类自己的。Thread 实现了 Runnable 接口,run 方法正是在这个接口中被定义为了抽象方法,而 Thread 实现了这个方法。

所以,我们把这个 Runnable 接口称为任务类可能更好理解。

如下,就是通过集成 Thread 类创建一个自定义线程 Thread1 的示例:

// 自定义线程对象
class Thread1 extends Thread {
    @Override
 public void run() {
  // 线程需要执行的任务
  ......
   }
}

// 创建线程对象
Thread1 t1 = new Thread1();

看这里,Thread 类提供了一个构造函数,可以为某个线程指定名字:

所以,我们可以这样:

// 创建线程对象
Thread1 t1 = new Thread1("t1");

这样,控制台打印的时候就比较明了,一眼就能知道是哪个线程输出的。

当然了,一般来说,我们写的代码都是下面这种匿名内部类简化版本的:

// 创建线程对象
Thread t1 = new Thread("t1") {
 @Override
 // run 方法内实现了要执行的任务
 public void run() {
  // 线程需要执行的任务
     ......
  }
};

线程与任务分离 — Thread + 实现 Runnable 接口

假如有多个线程,这些线程执行的任务都是一样的,那按照上述方法一的话我们岂不是就得写很多重复代码?

所以,我们考虑把线程执行的任务与线程本身分离开来。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程需要执行的任务
     ......
    }
}

// 创建任务类对象
MyRunnable runnable = new MyRunnable();
// 创建线程对象
Thread t2 = new Thread(runnable);

除了避免了重复代码,使用实现 Runnable 接口的方式也比方法一的单继承 Thread 类更具灵活性,毕竟一个类只能继承一个父类,如果这个类本身已经继承了其它类,就不能使用第一种方法了。另外,用这种方式,也更容易与线程池等高级 API 相结合。

因此,一般来说,更推荐使用这种方式去创建线程。也就是说,不推荐直接操作线程对象,推荐操作任务对象。

上述代码使用匿名内部类的简化版本如下:

// 创建任务类对象
Runnable runnable = new Runnable() {
    public void run(){
        // 要执行的任务
        ......
    }
};

// 创建线程对象
Thread t2 = new Thread(runnable);

同样的,我们也可以为其指定线程名字:

Thread t2 = new Thread(runnable, "t2");

以上两个 Thread 的构造函数如图所示:

可以发现,Thread 类的构造函数无一例外全部调用了 init 方法,这个方法到底做了啥?我们点进去看看:

它将构造函数传进来的 Runnable 对象传给了一个成员变量 target。

target 就是 Thread 类中定义的 Runnable 对象,代表着需要执行的任务(What will be run)。

这个变量的存在,就是我们能够把任务(Runnable)和线程本身(Thread)分开的原因所在。看下面这段代码:

没错,这就是 Thread 类默认实现的 run 方法。

在使用第一种方法创建线程的时候,我们定义了一个 Thread 子类并重写了其父类的 run 方法,所以这个父类实现的 run 方法不会被执行,执行的是我们自定义的子类中的 run 方法。

而在使用第二种方法创建线程的时候,我们并没有在 Thread 子类中重写 run 方法,所以父类默认实现的 run 方法就会被执行。

而这段 run 方法代码的意思就是说,如果 taget != null,也就是说如果 Thread 构造函数中传入了 Runnable 对象,那就执行这个 Runnable 对象的 run 方法。

线程与任务分离 — Thread + 实现 Callable 接口

虽然 Runnable 挺不错的,但是仍然有个缺点,那就是没办法获取任务的执行结果,因为它的 run 方法返回值是 void。

这样,对于需要获取任务执行结果的线程来说,Callable 就成为了一个完美的选择。

Callable 和 Runnable 基本差不多:

和 Runnbale 比起来,Callable 不过就是把 run 改成了 call。当然,最重要的是!和 void run 不同,这个 call 方法是拥有返回值的,而且能够抛出异常。

这样,一个很自然的想法,就是把 Callable 作为任务对象传给 Thread,然后 Thread 重写 call 方法就完事儿。

But,遗憾的是,Thread 类的构造函数里并不接收 Callable 类型的参数。

所以,我们需要把 Callable 包装一下,包装成 Runnable 类型,这样就能传给 Thread 构造函数了。

为此,FutureTask 成为了最好的选择。

可以看到 FutureTask 间接继承了 Runnable 接口,因此它也可以看作是一个 Runnable 对象,可以作为参数传入 Thread 类的构造函数。

另外,FutureTask 还间接继承了 Future 接口,并且,这个 Future 接口定义了可以获取 call() 返回值的方法 get:

看下面这段代码,使用 Callable 定义一个任务对象,然后把 Callable 包装成 FutureTask,然后把 FutureTask 传给 Thread 构造函数,从而创建出一个线程对象。

另外,Callable 和 FutureTask 的泛型填的就是 Callable 任务返回的结果类型(就是 call 方法的返回类型)。

class MyCallable implements Callable<Integer{
    @Override
    public Integer call() throws Exception {
        // 要执行的任务
        ......
        return 100;
    }
}
// 将 Callable 包装成 FutureTask,FutureTask也是一种Runnable
MyCallable callable = new MyCallable();
FutureTask<Integer> task = new FutureTask<>(callable);
// 创建线程对象
Thread t3 = new Thread(task);

当线程运行起来后,可以通过 FutureTask 的 get 方法获取任务运行结果:

Integer result = task.get();

不过,需要注意的是,get 方法会阻塞住当前调用这个方法的线程。比如说我们在主线程中调用了 get 方法去获取 t3 线程的任务运行结果,那么只有这个 call 方法成功返回了,主线程才能够继续往下执行。

换句话说,如果 call 方法一直得不到结果,那么主线程也就一直无法向下运行。

启动线程

OK,综上,我们已经把线程成功创建出来了,那么怎么把它启动起来呢?

以第一种创建线程的方法为例:

// 创建线程
Thread t1 = new Thread("t1") {
 @Override
 // run 方法内实现了要执行的任务
 public void run() {
  // 线程需要执行的任务
     ......
  }
};

// 启动线程
t1.start();

这里涉及一道经典的面试题,即为什么使用 start 启动线程,而不使用 run 方法启动线程

使用 run 方法启动线程看起来好像并没啥问题,对吧,run 方法内定义了要执行的任务,调用 run 方法不就执行了这个任务了?

这确实没错,任务确实能够被正确执行,但是并不是以多线程的方式,当我们使用 t1.run() 的时候,程序仍然是在创建 t1 线程的 main 线程下运行的,并没有创建出一个新的 t1 线程。

举个例子:

// 创建线程
Thread t1 = new Thread("t1") {
    @Override
    public void run() {
      // 线程需要执行的任务
      System.out.println("开始执行");
      FileReader.read(文件地址); // 读文件
    }
};

t1.run();
System.out.println("执行完毕");

如果使用 run 方法启动线程,"执行完毕" 这句话需要在文件读取完毕后才能够输出,也就是说读文件这个操作仍然是同步的。假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 CPU 什么都做不了,其它代码都得暂停。

而如果使用 start 方法启动线程,"执行完毕" 这句话在文件读取完毕之前就会被很快地输出,因为多线程让方法执行变成了异步的,读取文件这个操作是 t1 线程在做,而 main 线程并没有被阻塞。



  • 博主小硕在读,深耕 Java,目前在维护一个教程类仓库 CS-Wiki「Gitee 官方推荐项目,现已 1.7k+ star,仓库地址:https://gitee.com/veal98/CS-Wiki」,公众号上的文章也会在此同步更新,欢迎各位前来交流学习。
  • 准备春招秋招的小伙伴可以参考我的这个论坛项目 Echo「Gitee 官方推荐项目,现已 700+ star,仓库地址:https://gitee.com/veal98/Echo」。配套教程正在同步更新中,公众号后台回复 "Echo" 即可免费获取。
  • 另外,虽然现在本号仍然很小,不过我还是建了一个交流群『小牛肉和它的小伙伴们』,感兴趣的各位可以下方扫码加我微信回复 "进群",我拉你进群:

浏览 9
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报