高并发编程从入门到精通(三)

1,898 阅读12分钟

面试中最常被虐的地方一定有并发编程这块知识点,无论你是刚刚入门的大四萌新还是2-3年经验的CRUD怪,也就是说这类问题你最起码会被问3年,何不花时间死磕到底。消除恐惧最好的办法就是面对他,奥利给!(这一系列是本人学习过程中的笔记和总结,并提供调试代码供大家玩耍

上章回顾

1.java线程生命周期和java线程状态都有哪些?

2.java线程生命周期之间是如何转换的?

3.Thread.start()都做了哪些事情?

请自行回顾以上问题,如果还有疑问的自行回顾上一章哦~

本章提要

本章学习完成,你将会掌握Thread常用API接口的使用,包括sleep、yield和join,并且会详细解析join源码和用法。同时配合上一章的start()方法,本章还会介绍一下应该如何去关闭一个线程。鉴于interrupt字段内容较多,我们放到下一章讲哦。(老规矩,熟悉这块的同学可以选择直接关注点赞👍完成本章学习哦!)

本章代码下载

一、Thread常用API详解

本节开头先打个预防针,针对每一个API会用和精通是两个水准哦,这里我们的目标是完全吃透,所以章节内容会比较干,但是我会加油写的有代入感,大家一起加油~👏


(1) sleep

sleep一共有两个重载方法

  • public static native void sleep(long millis) throws InterruptedException
  • public static void sleep(long millis, int nanos) throws InterruptedException

由于这两个实现精度不同,内部调用的都是同一个方法,所以我们这里就挑public static void sleep(long millis, int nanos) throws InterruptedException来看下

/**
     * Causes the currently executing thread to sleep (temporarily cease
     * execution) for the specified number of milliseconds plus the specified
     * number of nanoseconds, subject to the precision and accuracy of system
     * timers and schedulers. The thread does not lose ownership of any
     * monitors.
     *
     * @param  millis
     *         the length of time to sleep in milliseconds
     *
     * @param  nanos
     *         {@code 0-999999} additional nanoseconds to sleep
     *
     * @throws  IllegalArgumentException
     *          if the value of {@code millis} is negative, or the value of
     *          {@code nanos} is not in the range {@code 0-999999}
     *
     * @throws  InterruptedException
     *          if any thread has interrupted the current thread. The
     *          <i>interrupted status</i> of the current thread is
     *          cleared when this exception is thrown.
     */
    public static void sleep(long millis, int nanos)
    throws InterruptedException {
        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

        if (nanos >= 500000 || (nanos != 0 && millis == 0)) {
            millis++;
        }

        sleep(millis);
    }

官方的描述是这样的,使线程暂时停止执行,在指定的毫秒数上再加上指定的纳秒数,但是线程不会失去监视器。这里的关键是不会失去持有的监视器,上一章我们讲过这时线程处于BLOCKED阶段。

if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (nanos < 0 || nanos > 999999) {
            throw new IllegalArgumentException(
                                "nanosecond timeout value out of range");
        }

当millis<0或者nanos不在0-999999范围中的时候就会抛出IllegalArgumentException

 @throws  InterruptedException
     *          if any thread has interrupted the current thread. The
     *          <i>interrupted status</i> of the current thread is
     *          cleared when this exception is thrown.

sleep可被中断方法打断,但是会抛出InterruptedException异常。

好啦到这里我们介绍完了这个API了,是不是感觉很简单呢?哈哈光这样可不行,实践是检验真理的唯一标准下面我们来验证一下sleep之后对象监视锁到底有没有释放。

别犯困啦,划重点啦

/**
   * 创建一个独占锁
   */
  private static final Lock lock = new ReentrantLock();

  public static void main(String[] args) {

    new Thread(new Runnable() {
      @Override
      public void run() {
        lock.lock();
        System.out.println("我是" + Thread.currentThread().getName() + ",lock在我手中");
        try {
          Thread.sleep(3000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        } finally {
          lock.unlock();
          System.out.println(Thread.currentThread().getName() + "不需要lock了");
        }

      }
    }, "一号线程").start();

    new Thread(new Runnable() {
      @Override
      public void run() {
        lock.lock();
        System.out.println("我是" + Thread.currentThread().getName() + ",lock在我手中");
        try {
          Thread.sleep(3000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        } finally {
          lock.unlock();
          System.out.println(Thread.currentThread().getName() + "不需要lock了");
        }

      }
    }, "二号线程").start();

  }

输出结果:

我是一号线程,lock在我手中
一号线程不需要lock了
我是二号线程,lock在我手中
二号线程不需要lock了

同学们可以用各种姿势来run我们的代码,不关你是坐着run,躺着run还是倒立run,结果始终是连续的,不会出现一号线程和二号线程交替打印的情景。这就证明了sleep确实不会释放其获取的监视锁,但是他会放弃CPU执行权。实践也实践完了,但是每次都要计算毫秒也着实费劲,有没有什么好的办法呢?


⚠️会玩的都这么写

假如现在有需求要求我们让线程sleep1小时28分19秒33毫秒我们要咋办?手脚快的同学可能已经掏出了祖传的计算器滴滴滴地操作起来了。但是我们一般不这么做,JDK1.5为我们新增了一个TimeUnit枚举类,请大家收起心爱的计算器,其实我们可以这么写

          //使用TimeUnit枚举类
          TimeUnit.HOURS.sleep(1);
          TimeUnit.MINUTES.sleep(28);
          TimeUnit.SECONDS.sleep(19);
          TimeUnit.MILLISECONDS.sleep(33);

这样我们的代码更加优雅,可读性会更强

写累了,锻炼下身体,给同学们挖个坑。我们已经知道millis的范围是大于等于0,sleep(1000)我们知道是什么意思,那么sleep(0)会有作用吗?

答案是会起作用的,这是我们需要记住的关于sleep的第二个点,sleep(0)的作用是“触发操作系统立刻重新进行一次CPU竞争,竞争结果可能是当前线程继续获取到CPU的执行权,也有可能是别的线程获取到了当前线程的执行权。

两个点希望大家可以牢记

1.sleep不会释放mointor lock。

2.sleep的作用是触发操作系统立刻重新进行一次CPU竞争。

(2) yield

还是老套路,我们先来看API接口描述是怎么定义这个接口的

/**
     * 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> 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();

这个方法的描述是这样的提示调度程序,当前线程愿意放弃CPU执行权。调度程序可以无条件忽略这个提示,打个比方就是说,A暗恋B,A说我愿意怎么怎么样,B可以接受A,但是也可以完全无条件的忽略A,,嗯嗯额~大概就是这么个场景,卑微A。

API接口描述中也明确说明了,这个接口不常用,可能用于调试或测试的目的,可能用于重现由于竞争条件而导致的bug,还有就是在java.util.concurrent.locks包中有用到这个API,总的来说就是在实际生产开发过程中是不用的。但是它又不像stop一样已经被废弃不推荐使用,讲这个API的目的是应因为它很容易和sleep混淆。

1.调用yield并生效之后线程会从RUNNING阶段转变为RUNNABLE,当然被无条件忽略的情况除外。而sleep则是进入BLOCKED阶段,而且是几乎百分百会进入。

2.sleep会导致线程暂停,但是不会消耗CPU时间片,yield一旦生效就会发生线程上下文切换,会带来一定的开销。

3.sleep可以被另一个线程调用interrupt中断,而yield就不会,yield得等到CPU轮询给到执行权的时候才会再次被唤醒,也就是从RUNNABLE阶段编程RUNNING阶段。

光说不练假把式,虽然不常用,但是是驴子是马总归还是要溜一溜。

 private static class MyYield implements Runnable {

    @Override
    public void run() {
      for (int i = 0; i < 5; i++) {
        if (i % 5 == 0) {
          System.out.println(Thread.currentThread().getName()+"线程,yield 它出现了");
//          Thread.yield();

        }
      }

      System.out.println(Thread.currentThread().getName()+"结束了");
    }
  }

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

    Thread t2 = new Thread(new MyYield());
    t2.start();

    Thread t3 = new Thread(new MyYield());
    t3.start();
  }

多次执行,按到最多的输出是连续的,类似下面这种输出结果:

Thread-0线程,yield 它出现了
Thread-0结束了
Thread-1线程,yield 它出现了
Thread-1结束了
Thread-2线程,yield 它出现了
Thread-2结束了

现在我们把注释打开,发现输出结果变了

Thread-0线程,yield 它出现了
Thread-1线程,yield 它出现了
Thread-2线程,yield 它出现了
Thread-0结束了
Thread-1结束了
Thread-2结束了

那是应为在调用到yield到时候当前线程让出了执行权,所以等到大家都出现了之后,大家再分别结束了

(3) join

在本小节中我们介绍一下joinAPI

  • public final void join() throws InterruptedException
  • public final synchronized void join(long millis)
  • public final synchronized void join(long millis, int nanos) 和sleepAPI十分相像,但是join除了两个设置超时等待时间的API外,还额外提供了一个不设置超时时间的方法,但是通过追踪第一个API我们发现内部其实调用的就是第二个API的join(0),设置纳秒的内部调用也是第二个API。所以我们这边就拿第二个API来讲解。
/**
    //设置一段时间等待当前线程结束,如果超时还未返回就会一直等待
     * Waits at most {@code millis} milliseconds for this thread to
     * die. A timeout of {@code 0} means to wait forever.
     *
     这个方法调用的前提就是当前线程还是处于alive状态的
     * <p> This implementation uses a loop of {@code this.wait} calls
     * conditioned on {@code this.isAlive}. As a thread terminates the
     * {@code this.notifyAll} method is invoked. It is recommended that
     * applications not use {@code wait}, {@code notify}, or
     * {@code notifyAll} on {@code Thread} instances.
     *
     * @param  millis
     *         the time to wait in milliseconds
     *
     //超时时间为负数
     * @throws  IllegalArgumentException
     *          if the value of {@code millis} is negative
     *
     * @throws  InterruptedException
     *          if any thread has interrupted the current thread. The
     *          <i>interrupted status</i> of the current thread is
     *          cleared when this exception is thrown.
     */
    public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

通过该方法我们可以看到他的逻辑是通过当前运行机器的时间,判断线程是否isAlive来决定是否需要继续等待,并且内部我们可以看到调用的是wait()方法,直到delay<=0的时刻,就会跳出当前循环,从而结束中断。

又要给同学们讲一个悲伤的故事了

public static void main(String[] args) throws InterruptedException {

    Thread t1 = new Thread(() -> {
      System.out.println("周末都要加班,终于回家了,洗个手吃饭了");
      try {
        TimeUnit.SECONDS.sleep(2);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.print("洗完手,");
    });

    Thread t2 = new Thread(() -> {
      try {
        TimeUnit.SECONDS.sleep(2);
//        t1.join();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.print("拿起筷子,");
    });

    t1.start();
    t2.start();
//    t2.join();
    System.out.println("我要吃饭了");

  }

输出:

周末都要加班,终于回家了,洗个手吃饭了
我要吃饭了
拿起筷子,洗完手,

显然这个结果不是我们想要的结果,也但是不排除加班加的已经意识模糊,手抓饭了,这里我们还是希望按照正常习惯来执行。我们把注释打开

输出:

周末都要加班,终于回家了,洗个手吃饭了
洗完手,拿起筷子,我要吃饭了

这个才是我们需要的结果。

相信同学们通过这个例子已经大概了解join的作用了,没错join是可以让程序能按照一定的次序来完成我们想完成的工作,他的工作原理就是阻塞当前调用join的线程,让新join进来的线程优先执行。

二、线程该如何关闭

线程关闭大致上可以分为三种情况

1.线程正常关闭

2.线程异常退出

3.进程假死

这里我们着重讲一下线程正常关闭的情况,也是实际开发生产中常用方法。

1.线程生命周期正常结束

这个没什么好说的,就是线程逻辑单元执行完成然后自己正常结束。

2.捕获中断信号关闭线程。

早期JDK中还提供有一个stop函数用于关闭销毁线程,但是后来发现会存在monitor锁无法释放的问题,会导致死锁,所以现在强烈建议大家不要用这个方式。这里我们使用捕获线程中断的方式来结束线程。

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
      System.out.println("我要自测代码啦~~");
      while (!Thread.currentThread().isInterrupted()) {
        System.out.println("目前来看是好好的");
      }
      System.out.println("代码中断停止了");
    });

    t1.start();
    TimeUnit.SECONDS.sleep(1);
    t1.interrupt();

  }

输出:

我要自测代码啦~~
...
目前来看是好好的
目前来看是好好的
代码中断停止了

可以看到,我们通过判断当前线程的isInterrupted()状态来捕获线程是否已经被中断,从而可以来控制线程正常关闭。同理,如果我们在线程内部已经执行来某中断方法,比如sleep就可以通过捕获中断异常来退出sleep状态,从而也能让线程正常结束。

3.设置开关关闭

由于interrupt很有可能被擦除,或者整个逻辑单元中并有调用中断方法,这样我们上一种方法就不适用了,这里我们使用volatile关键字来设置一个开关,控制线程的正常退出。

private static class MyInterrupted extends Thread {

    private volatile boolean close = false;

    @Override
    public void run() {
      System.out.println("我要开始自测代码啦~~");
      while (!close) {
        System.out.println("目前来看好好的");
      }
      System.out.println("close已经变成了" + close + ",代码正常关闭了");
    }

    public void closed() {
      this.close = true;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    MyInterrupted myInterrupted = new MyInterrupted();
    myInterrupted.start();
    TimeUnit.SECONDS.sleep(1);
    System.out.println("我要开始关闭线程了");
    myInterrupted.closed();
  }

输出:

我要开始自测代码啦~~
...
目前来看好好的
目前来看好好的
目前来看好好的
我要开始关闭线程了
close已经变成了true,代码正常关闭了

可以看到我们调用closed方法时候把close设置为了true,从而正常关闭代码,关于volatile关键字我们之后的章节会详细讲哦,请同学们继续关注,和我一起学习😁,希望同学们可以帮忙关注下和点点赞👍哦~

下一章也已经出来咯,这次内容稍多,所以拖了比较久,但是内容都是妥妥的,下一章详细讲解了interrupt的执行逻辑,一步一步带同学们调试,感兴趣的同学记得进入下一章的学习哦~

传送门