为了账号安全,请及时绑定邮箱和手机立即绑定

多线程基础入门

标签:
Java

什么是线程

说到线程,不得不提进程,对于进程相信大家都不陌生。比如当我们启动qq的时候,操作系统会给qq程序创建一个进程,启动桌面版微信,操作系统也会给微信创建一个进程,同理,java程序启动后,也会创建一个进程。

image-20210226165255786

根据狭义的定义,进程就是正在运行程序的抽象

话说回来,那什么是线程呢?

在某些进程内部,还需要同时执行一些子任务,比如在一个Java进程中,后台除了执行正常用户代码之外,可能还需要线程在后台执行垃圾回收,即时编译等,我们称这些子任务为线程。我们可以理解线程为一种轻量级的进程,它们有自己的程序计数器、栈以及局部变量等,可以被操作系统进行调度,而且相比进程而言,线程创建、上下文切换的代价都更小,所以现代操作系统都是以线程作为调度的最小单位。

进程和线程的关系

  • 进程是系统进行资源分配基本单位,线程是系统调度的基本单位

  • 一个进程可以包含一个或多个线程。

  • 同一个进程所有线程可共享该进程的资源,如内存空间等。

  • 进程之间通信较为复杂

    • 同一台计算机内部的进程通信,称为IPC(Inter-Process Communication)

    • 不同计算机之间进程通信,需要通过网络,遵循共同的协议,如TCP/IP

  • 线程之间通信比较方便,因为同一个进程中所有线程共享内存,比如多个线程可以访问同一个共享变量。

​ 我们可以把进程理解成一个营业的酒店,而线程就是酒店的老板及工作人员如大堂经理、保洁阿姨、保安、厨师等。酒店的老板及工作人员,都能共用酒店的资源。一个酒店再怎么样,就算没有任何工作人员,也必须有一个老板才行。

并行和并发

cpu执行代码是一条一条顺序执行的,但是,即便是单核CPU,也可以同时运行多个任务。这是因为操作系统会让多个任务轮流交替执行,每个任务执行若干时间,执行完后切换到下一个任务执行,这个过程非常快,造成一种同时执行的假象,这种在宏观上同时执行,微观上交替执行的现象,我们称之为***并发(Concurrency)***。

当然,对于拥有多核CPU的计算机而言,是可以允许多个CPU同时执行不同任务的,比如在CPU1执行Word,在CPU2上执行QQ音乐听歌,这种真正意义上的同时执行,我们称为***并行(Parallelism)***。

引用golang语言创造者Rob Pike的一段话:

  • 并发是同一时间,应对(dealing with)多件事情的能力。
  • 并行,是同一时间动手做(doing)多件事情的能力。

并发和并行的区别,有点类似于以下场景:

高速公路上设有收费站,假设有两条道路,但是收费通道只有一个,这时两个道路的车,就需要交替排队进入同一个收费入口进行收费,这种情况类似于并发,假如有两个收费入口,两条道的车都在自己的收费入口收费,这种情况类似于并行。

并行与并发

创建线程的方式

在java中创建并使用线程非常简单,只需要创建一个线程对象,并调用其start方法,就可以了,比如下面这样:

public class CreateThread {
    public static void main(String[] args) {
        Thread t1 = new Thread();
        t1.start();
    }
}

当我们执行这段程序的时候,jvm实际首先会创建一个主线程,用来执行main()方法,然后在执行main()方法的第3行代码时,会创建再次创建线程t1,在第4行通过start(),启动线程t1。不过这个线程启动后,实际并没有执行任何代码就结束了,如果我们希望线程启动后能执行指定代码,可以通过以下两种方式:

继承Thread类并重写run方法

public class ExtendThread {

    public static void main(String[] args) {
        Thread t1 = new MyThread();
        t1.start();
    }

    public static class MyThread extends Thread{
        @Override
        public void run() {
            Debug.debug("我是线程t1");
        }
    }
}

上述方法可以简写成匿名内部类的形式:

public static void main(String[] args) {
  Thread t1 = new Thread(){
    @Override
    public void run() {
      Debug.debug("我是线程t1");
    }
  };
  t1.start();
}

创建线程时传入Runnable对象

public class RunnableThread {
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                Debug.debug("我是线程t2");
            }
        };
        Thread t2 = new Thread(runnable);
        t2.start();
    }
}

从java8开始,这种方式可以使用lamda表达式简写

public class LambdaRunnableThread {
    public static void main(String[] args) {
        Thread t2 = new Thread(() -> Debug.debug("我是线程t2"));
        t2.start();
    }
}

那么,这两种写法更推荐哪种呢?一般而言下,更建议使用第二种方法,因为

  • java里面类是单继承的,使用接口的方式,可以避开这种限制。
  • 有利于任务拆分。如果一个任务需要拆分成很多小任务,不必为每个任务创建一个线程。
  • 将任务的创建和执行解耦,一个线程生产任务,可以交给其他线程去执行。

run()和start()的区别

需要特别注意的是,run()start()的区别,线程创建完成后,执行start()方法,才会真正启动线程去并发执行任务,而run()只是一个普通的实例方法,没有启动线程的作用。

public class StartAndRunTest {
    public static void main(String[] args) {
        Debug.debug("我是线程:{}",Thread.currentThread().getName());
        Thread t2 = new Thread(() -> Debug.debug("我是线程:{}",Thread.currentThread().getName()),"t2");
        t2.run();
    }
}

以上代码中Thread.currentThread().getName()会打印当前执行线程的名字。这段代码首先会打印主线程的名字,然后创建线程t2,接着启动线程t2,t2线程启动后执行其任务代码,会打印出正在执行该代码的线程名字也就是t2,其执行结果如下:

2021-03-07 20:07:05 [main] 我是线程:main
2021-03-07 20:07:05 [t2] 我是线程:t2

如果我们把代码第5行改成: t2.run(),就会输出下面的结果了:

2021-03-07 20:07:19 [main] 我是线程:main
2021-03-07 20:07:19 [main] 我是线程:main

因为run()方法并没有真正启动线程t1,只是在主线程中调用了t2线程的一个普通方法。

线程的常用api

名称 类型 作用
sleep(long millis) 静态方法 使当前线程休眠millis毫秒
yield 静态方法 当前线程让出cpu
join 线程实例方法 等待直到某个线程执行完毕再执行后续代码
join(long millis) 线程实例方法 等待某个线程执行完毕再执行后续代码,最多等待millis毫秒

sleep

sleep(long millis)是一个静态方法,其作用是使当前线程进入休眠millis毫秒,比如下面这个例子,我们让线程休眠5s再执行

public class SleepTest {

    public static void main(String[] args) {
        new Thread(() -> {
            Debug.debug("开始执行");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Debug.debug("执行结束");
        },"t1").start();
    }
}

执行结果如下:

2021-03-07 20:09:24 [t1] 开始执行
2021-03-07 20:09:29 [t1] 执行结束

需要注意的是,sleep()会抛出InterruptedException异常,这个异常的作用是让我们可以中断正在休眠中的线程。

yield

yield的翻译过来是屈服、让步的意思,由此可以看出,yield()方法的作用是让当前线程主动让出cpu,从运行状态变成就绪状态,相当于是把执行机会让给其他线程,但不一定能成功让出。打个简单的比方,就像是你在排队买车票,本来轮到你了,这时后面有个人因为时间比较赶,于是你非常绅士的把位置让给他。那么这个方法的应用场景是什么呢?看了该方法的注释,发现原来这个方法实际上很少有机会用到,主要用于代码调试,复现bug。

/**
  * A hint to the scheduler that the current thread is willing to yield
  * its current use of a processor. The scheduler is free to ignore this
  * hint.
  *
  * <p> Yield is a heuristic attempt to improve relative progression
  * between threads that would otherwise over-utilise a CPU. Its use
  * should be combined with detailed profiling and benchmarking to
  * ensure that it actually has the desired effect.
  *
  * </p><p> It is rarely appropriate to use this method. It may be useful
  * for debugging or testing purposes, where it may help to reproduce
  * bugs due to race conditions. It may also be useful when designing
  * concurrency control constructs such as the ones in the
  * {@link java.util.concurrent.locks} package.
  */
  public static native void yield();

join

在多线程应用中,假如线程A的输入依赖于线程B的输出结果,此时,线程A就需要等待线程B执行完毕再继续执行,我们可以使用jdk提供的join()方法来实现这种线程之间的协作。如下所示有两个join方法:

public final void join() throws InterruptedException 
public final synchronized void join(long millis) throws InterruptedException

无参的join表示A线程会无限等待直到线程B执行完毕,而有参的join方法,会等待直到最大超时时间,超出这个时间后哪怕线程B还在执行,就不再继续等待。通过下面这个简单的例子,我们来验证一下join()的作用:

public class JoinMain {

    public static void main(String[] args) throws InterruptedException {
        MyTask myTask = new MyTask();
        Thread t = new Thread(myTask);
        t.start();
        //t.join();
        System.out.println(myTask.result);
    }

    private static class MyTask implements Runnable{
        private int result;

        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            result = 100;
        }
    }
}

这段代码的执行结果是0,而不是100,因为main线程执行第7行代码的时候,线程t还处于休眠状态,此时result还是等于0,当我们去掉第7行的注释,main线程等待t线程执行完毕,设置了result的值,才执行第8行代码,结果就是100了。

守护线程

java线程可以分为守护线程和非守护线程,在java进程中,一旦非守护线程全部执行完毕,即便守护线程还没执行完,该进程也会强行终止。顾名思义,守护线程和它的名字一样,就是在后台默默的做一些系统工作,比如java进程中的垃圾回收线程、JIT线程就是守护线程,正因为如此,当系统中没有其他线程后,守护线程也就失去了存在的意义,无事可做,整个进程自然也就应该结束了。守护线程可以在创建线程后通过setDeamon()进行设置。举个例子:

public class DaemonThreadMain {
    public static void main(String[] args) {
        Thread t = new Thread(()-&gt;{
            Debug.debug("开始执行");
            Sleep.seconds(3);//睡眠3s
            Debug.debug("执行结束");
        },"t1");
//        t.setDaemon(true);
        t.start();
        Sleep.seconds(1);
        Debug.debug("执行结束");
    }
}

执行结果如下:

2021-03-06 20:47:59 [t1] 开始执行
2021-03-06 20:48:00 [main] 执行结束
2021-03-06 20:48:02 [t1] 执行结束

可以看出,线程t1执行耗时3s,main线程耗时1s,main线程1s后就执行完毕退出了,而t1线程作为非守护线程,在主线程结束后,依然是过了3s后才执行完毕。现在我们把第8行t.setDaemon(true)这行代码去掉注释,最终执行结果如下:

2021-03-06 20:52:29 [t1] 开始执行
2021-03-06 20:52:30 [main] 执行结束

可以看出,由于t1是守护线程,主线程退出后,线程t1就退出而没有往下执行了。

需要注意的是,setDameon()方法必须在线程开始也就是调用start()执行之前调用,否则会报以下异常:

Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.setDaemon(Thread.java:1359)
	at com.taoge.demos.DaemonThreadMain.main(DaemonThreadMain.java:20)

附录-工具类 Sleep & Debug

sleep

/**
 * Sleep 是对Thread.sleep的简单封装
 *
 * @author chentao
 * @date 2021/3/6
 */
public final class Sleep {
    public static void sleep(TimeUnit unit, long duration){
        try {
            unit.sleep(duration);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    public static void sleepInterruptibly(TimeUnit unit, long duration) throws InterruptedException{
        unit.sleep(duration);
    }
    
    public static void millis(long millis){
        sleep(TimeUnit.MILLISECONDS, millis);
    }
    
    public static void seconds(long seconds){
        sleep(TimeUnit.SECONDS, seconds);
    }

}

Debug

/**
 * Debug类是对System.out.println的简单封装,便于打印出类似这样格式化的日志:
 * 2021-03-07 20:11:06 [t1] 这是测试
 * 包含了日期时间、线程名称和自定义的打印内容
 * @author chentao
 * @date 2021/3/4
 */
public class Debug {
    private static SimpleDateFormat format = new SimpleDateFormat();

    static {
        format.applyPattern("yyyy-MM-dd HH:mm:ss");
    }

    public static void debug(String msg, Object... params){
        for (Object param : params) {
            msg = msg.replaceFirst("\\{\\}",param.toString());
        }
        System.out.println(format.format(new Date())+" ["+Thread.currentThread().getName()+"] "+msg);
    }
}

源码地址

> 文章所有代码都放在github上
>
> github.com/ThomasChant/jucDemos

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
JAVA开发工程师
手记
粉丝
0
获赞与收藏
0

关注作者,订阅最新文章

阅读免费教程

  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消