并发编程线程基础

2015/03/03 JUC Java

并发编程线程基础

线程的介绍

对计算机来说每一个任务就是一个进程(Process),在每一个进程内部至少要有一个线程(Thread)是在运行中,有时线程也称为轻量级的进程。

线程是程序执行的一个路径,每一个线程都有自己的局部变量表、程序计数器(指向正在执行的指令指针)以及各自的生命周期,现代操作系统中一般不止一个线程在运行,当启动了一个Java虚拟机(JVM)时,从操作系统开始就会创建一个新的进程(JVM进程),JVM进程中将会派生或者创建很多线程。

线程的生命周期详解

每一个线程都有自己的局部变量表、程序计数器,以及生命周期等,下面就来分析一下线程的生命周期。 线程的生命周期大体可以分为如下5个主要的阶段。

  • NEW
  • RUNNABLE
  • RUNNING
  • BLOCKED
  • TERMINATED

NEW(新建状态)

当我们用关键字new创建一个Thread对象时,此时它并不处于执行状态,因为没有调用start方法启动该线程,那么线程的状态为NEW状态,准确地说,它只是Thread对象的状态,因为在没有start之前,该线程根本不存在,与你用关键字new创建一个普通的Java对象没什么区别。

Thread t = new Thread();//这就是线程的新建状态

NEW状态通过start方法进入RUNNABLE状态。

RUNNABLE(可运行状态)

线程对象进入RUNNABLE状态必须调用start方法,那么此时才是真正地在JVM进程中创建了一个线程,线程一经启动就可以立即得到执行吗?答案是否定的,线程的运行与否和进程一样都要听令于CPU的调度,那么我们把这个中间状态称为可执行状态(RUNNABLE),也就是说它具备执行的资格,但是并没有真正地执行起来而是在等待CPU的调度。 新建状态调用 start() 方法,进入可运行状态。而这个又分成两种状态,ready 和 running,分别表示就绪状态和运行状态。

  • 就绪状态(Ready) 线程对象调用了 start() 方法,等待 JVM 的调度,(此时该线程并没有运行)
  • 运行状态(Running) 线程对象获得 JVM 调度,如果存在多个 CPU,那么运行多个线程并行运行

注意:线程对象只能调用一次 start() 方法,否则报错:illegaThreadStateExecptiong

由于存在Running状态,所以不会直接进入BLOCKED状态和TERMINATED状态,即使是在线程的执行逻辑中调用wait、sleep或者其他block的IO操作等,也必须先获得CPU的调度执行权才可以,严格来讲,RUNNABLE的线程只能意外终止或者进入RUNNING状态。

RUNNING(运行状态)

一旦CPU通过轮询或者其他方式从任务可执行队列中选中了线程,那么此时它才能真正地执行自己的逻辑代码,需要说明的一点是一个正在RUNNING状态的线程事实上也是RUNNABLE的,但是反过来则不成立。

在该状态中,线程的状态可以发生如下的状态转换。

  • 直接进入TERMINATED状态,比如调用JDK已经不推荐使用的stop方法或者判断某个逻辑标识。
  • 进入BLOCKED状态,比如调用了sleep,或者wait方法而加入了waitSet中。
  • 进行某个阻塞的IO操作,比如因网络数据的读写而进入了BLOCKED状态。
  • 获取某个锁资源,从而加入到该锁的阻塞队列中而进入了BLOCKED状态。
  • 由于CPU的调度器轮询使该线程放弃执行,进入RUNNABLE状态。
  • 线程主动调用yield方法,放弃CPU执行权,进入RUNNABLE状态。

BLOCKED(阻塞状态)

正在运行的线程因为某种原因放弃 CPU,暂时停止运行,就会进入阻塞状态。此时 JVM 不会给线程分配 CPU,知道线程重新进入就绪状态,才有机会转到 运行状态。

注意:阻塞状态只能先进入就绪状态,不能直接进入运行状态 线程在BLOCKED状态中可以切换至如下几个状态。

  • 直接进入TERMINATED状态,比如调用JDK已经不推荐使用的stop方法或者意外死亡(JVM Crash)。
  • 线程阻塞的操作结束,比如读取了想要的数据字节进入到RUNNABLE状态。
  • 线程完成了指定时间的休眠,进入到了RUNNABLE状态。
  • Wait中的线程被其他线程notify/notifyall唤醒,进入RUNNABLE状态。
  • 线程获取到了某个锁资源,进入RUNNABLE状态。
  • 线程在阻塞过程中被打断,比如其他线程调用了interrupt方法,进入RUNNABLE状态。

TERMINATED

TERMINATED是一个线程的最终状态,在该状态中线程将不会切换到其他任何状态,线程进入TERMINATED状态,意味着该线程的整个生命周期都结束了,下列这些情况将会使线程进入TERMINATED状态。

  • 线程运行正常结束,结束生命周期。
  • 线程运行出错意外结束。
  • JVM Crash,导致所有的线程都结束。

线程的start方法剖析

分析Thread的start方法,在调用了start方法之后到底进行了什么操作? 先来看一下Thread start方法的源码,如下所示:

public synchronized void start() {
    if (threadStatus != 0)
        throw new IllegalThreadStateException();
    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
        }
    }
}

start方法的源码足够简单,其实最核心的部分是start0这个本地方法,也就是JNI方法:

private native void start0();

也就是说在start方法中会调用start0方法,那么重写的那个run方法何时被调用了呢?单从上面是看不出来任何端倪的,但是打开JDK的官方文档,在start方法中有如下的注释说明:

Causes this thread to begin execution; the Java Virtual Machine calls the <code>run</code> method of this thread.

上面这句话的意思是:在开始执行这个线程时,JVM将会调用该线程的run方法,换言之,run方法是被JNI方法start0()调用的,仔细阅读start的源码将会总结出如下几个知识要点。

  • Thread被构造后的NEW状态,事实上threadStatus这个内部属性为0。
  • 不能两次启动Thread,否则就会出现IllegalThreadStateException异常。
  • 线程启动后将会被加入到一个ThreadGroup中,后文中我们将详细介绍ThreadGroup。
  • 一个线程生命周期结束,也就是到了TERMINATED状态,再次调用start方法是不允许的,也就是说TERMINATED状态是没有办法回到RUNNABLE/RUNNING状态的。 ```java Thread thread = new Thread() { @Override public void run() { try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } } }; thread.start();//启动线程

thread.start();//再次启动

执行上面的代码将会抛出IllegalThreadStateException异常,而我们将代码稍作改动,模拟一个线程生命周期的结束,再次启动看看会发生什么:
```java
Thread thread = new Thread()
{
    @Override
    public void run()
    {
        try
        {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
};
thread.start();
TimeUnit.SECONDS.sleep(2);//休眠主要是确保thread结束生命周期
thread.start();//企图重新激活该线程

程序同样会抛出IllegalThreadStateException异常,但是这两个异常的抛出却有本质上的区别,第一个是重复启动,只是第二次启动是不允许的,但是此时该线程是处于运行状态的,而第二次企图重新激活也抛出了非法状态的异常,但是此时没有线程,因为该线程的生命周期已经被终结。

模板设计模式在Thread中的应用

线程的真正的执行逻辑是在run方法中,通常我们会把run方法称为线程的执行单元,这也就回答了我们最开始提出的疑问,重写run方法,用start方法启动线程。Thread中run方法的代码如下,如果我们没有使用Runnable接口对其进行构造,则可以认为Thread的run方法本身就是一个空的实现:

@Override
public void run() {
    if (target != null) {//我们并没有使用runnable构造Thread
        target.run();
    }
}

其实Thread的run和start就是一个比较典型的模板设计模式,父类编写算法结构代码,子类实现逻辑细节。

深入理解Thread构造函数

Java中的Thread为我们提供了比较丰富的构造函数。

线程的命名

在构造线程的时候可以为线程起一个有特殊意义的名字,这也是比较好的一种做法,尤其在一个线程比较多的程序中,为线程赋予一个包含特殊意义的名字有助于问题的排查和线程的跟踪。

线程的默认命名

下面的几个构造函数中,并没有提供为线程命名的参数,那么此时线程会有一个怎样的命名呢?

  • Thread()
  • Thread(Runnable target)
  • Thread(ThreadGroup group,Runnable target) 打开JDK的源码会看到下面的代码:
    public Thread(Runnable target) {
      init(null, target, "Thread-" + nextThreadNum(), 0);
    }
    /* For autonumbering anonymous threads. */
    private static int threadInitNumber;
    private static synchronized int nextThreadNum() {
      return threadInitNumber++;
    }
    

    如果没有为线程显式地指定一个名字,那么线程将会以“Thread-”作为前缀与一个自增数字进行组合,这个自增数字在整个JVM进程中将会不断自增:

    public static void main(String[] args)
    {
      IntStream.range(0, 5).boxed().map(i -> new Thread(
      () -> System.out.println(Thread.currentThread().getName()))
       ).forEach(Thread::start);
     }
    

    执行上面的代码,这里使用无参的构造函数创建了5个线程,并且分别输出了各自的名字,会发现输出结果与我们对源码的分析是一致的,输出如下:

    Thread-0
    Thread-2
    Thread-1
    Thread-4
    Thread-3
    

命名线程

在构造Thread的时候,为线程赋予一个特殊的名字是一种比较好的实战方式,Thread同样也提供了这样的构造函数,具体如下。

  • Thread(Runnable target,String name)
  • Thread(String name)
  • Thread(ThreadGroup group,Runnable target,String name)
  • Thread(ThreadGroup group,Runnable target,String name,long stackSize)
  • Thread(ThreadGroup group,String name) 示例代码如下:
    private final static String PREFIX = "ALEX-";
    public static void main(String[] args)
    {
    IntStream.range(0,5).mapToObj(ThreadConstruction::createThread)
              .forEach(Thread::start);
    }
    private static Thread createThread(final int intName)
    {
      return new Thread(
          () -> System.out.println(Thread.currentThread().getName())
              , PREFIX + intName);
    }
    

    在上面的代码中,我们定义了一个新的前缀“ALEX-”,然后用0~4之间的数字作为后缀对线程进行了命名,代码执行输出的结果如下所示:

    ALEX-0
    ALEX-1
    ALEX-2
    ALEX-3
    ALEX-4
    

修改线程的名字

不论你使用的是默认的函数命名规则,还是指定了一个特殊的名字,在线程启动之前还有一个机会可以对其进行修改,一旦线程启动,名字将不再被修改,下面是Thread的setName源码:

public final synchronized void setName(String name) {
    checkAccess();
    this.name = name.toCharArray();
    if (threadStatus != 0) { //线程不是NEW状态,对其的修改将不会生效
        setNativeName(name);
    }
}

线程的父子关系

Thread的所有构造函数,最终都会去调用一个静态方法init,我们截取片段代码对其进行分析,不难发现新创建的任何一个线程都会有一个父线程:

private void init(ThreadGroup g, Runnable target, String name,
                long stackSize, AccessControlContext acc) {
    if (name == null) {
        throw new NullPointerException("name cannot be null");
    }
    this.name = name.toCharArray();
    Thread parent = currentThread(); //获取当前线程作为父线程
    SecurityManager security = System.getSecurityManager();

上面代码中的currentThread()是获取当前线程,在线程生命周期中,我们说过线程的最初状态为NEW,没有执行start方法之前,它只能算是一个Thread的实例,并不意味着一个新的线程被创建,因此currentThread()代表的将会是创建它的那个线程,因此我们可以得出以下结论。

  • 一个线程的创建肯定是由另一个线程完成的。
  • 被创建线程的父线程是创建它的线程。 我们都知道main函数所在的线程是由JVM创建的,也就是main线程,那就意味着我们前面创建的所有线程,其父线程都是main线程。

Thread与ThreadGroup

在Thread的构造函数中,可以显式地指定线程的Group,也就是ThreadGroup 接着往下阅读Thread init方法的源码:

SecurityManager security = System.getSecurityManager();
if (g == null) {
    /* Determine if it's an applet or not */
    /* If there is a security manager, ask the security manager
        what to do. */
    if (security != null) {
        g = security.getThreadGroup();
    }
    /* If the security doesn't have a strong opinion of the matter
        use the parent thread group. */
    if (g == null) {
        g = parent.getThreadGroup();
    }
}

通过对源码进行分析,我们可以看出,如果在构造Thread的时候没有显示地指定一个ThreadGroup,那么子线程将会被加入父线程所在的线程组,下面写一个简单的代码来测试一下

public class ThreadConstruction
{
    public static void main(String[] args)
    {
        //①
        Thread t1 = new Thread("t1");

        //②
        ThreadGroup group = new ThreadGroup("TestGroup");
        //③
        Thread t2 = new Thread(group, "t2");
        ThreadGroup mainThreadGroup = Thread.currentThread().getThreadGroup();
        System.out.println("Main thread belong group:" + mainThreadGroup.getName());
        System.out.println("t1 and main belong the same group:" + (mainThreadGroup == t1.getThreadGroup()));
        System.out.println("t2 thread group not belong main group:" + (mainThreadGroup == t2.getThreadGroup()));
        System.out.println("t2 thread group belong main TestGroup:" + (group == t2.getThreadGroup()));

    }
}

注释①创建了一个Thread t1,注释②创建了一个ThreadGroup,注释③创建了一个Thread t2,并且将它加入到了group中,我们并没有给t1指定任何Group,执行上面的代码,输出结果为:

Main thread belong group:main
t1 and main belong the same group:true
t2 thread group not belong main group:false
t2 thread group belong main TestGroup:true

通过对Thread源码的分析和我们自己的测试可以得出以下结论。

  • main线程所在的ThreadGroup称为main。
  • 构造一个线程的时候如果没有显式地指定ThreadGroup,那么它将会和父线程同属于一个ThreadGroup。

在默认设置中,当然除了子线程会和父线程同属于一个Group之外,它还会和父线程拥有同样的优先级,同样的daemon。

Thread与JVM虚拟机栈

在Thread的构造函数中,可发现有一个特殊的参数stackSize,这个参数的作用是什么呢?它的值对线程有什么影响呢?下面我们就来一起探讨这个问题。

打开JDK官方文档,将会发现Thread中对stacksize构造函数的文字说明,具体如下:

※The stack size is the approximate number of bytes of address space that the virtual machine is to allocate for this thread’s stack.The effect of the stackSize parameter,if any,is highly platform dependent.

On some platforms,specifying a higher value for the stackSize parameter may allow a thread to achieve greater recursion depth before throwing a StackOverflowError.Similarly,specifying a lower value may allow a greater number of threads to exist concurrently without throwing an OutOfMemoryError(or other internal error).The details of the relationship between the value of the stackSize parameter and the maximum recursion depth and concurrency level are platform-dependent.On some platforms,the value of the stackSize parameter may have no effect whatsoever.

一般情况下,创建线程的时候不会手动指定栈内存的地址空间字节数组,统一通过xss参数进行设置即可,通过上面这段官网文档的描述,我们不难发现stacksize越大则代表着正在线程内方法调用递归的深度就越深,stacksize越小则代表着创建的线程数量越多,当然了这个参数对平台的依赖性比较高,比如不同的操作系统、不同的硬件。

在有些平台下,越高的stack设定,可以允许的递归深度越多;反之,越少的stack设定,则递归深度越浅。当然在某些平台下,该参数压根不会起到任何作用,如果将该参数设置为0,也不会起到任何的作用。

守护线程

守护线程是一类比较特殊的线程,一般用于处理一些后台的工作,比如JDK的垃圾回收线程,什么是守护线程?为什么要有守护线程,以及何时需要守护线程?

要回答关于守护线程的问题,就必须先搞清楚另外一个特别重要的问题:JVM程序在什么情况下会退出?

※The Java Virtual Machine exits when the only threads running are all daemon threads.

上面这句话来自于JDK的官方文档,当然这句话指的是正常退出的情况,而不是调用了System.exit()方法,通过这句话的描述,我们不难发现,在正常情况下,若JVM中没有一个非守护线程,则JVM的进程会退出。

什么是守护线程

我们先通过一个简单的程序,来认识一下守护线程和守护线程的特点:

public class DaemonThread
{
    public static void main(String[] args) throws InterruptedException
    {
    //① main线程开始
        Thread thread = new Thread(() ->
        {
            while (true)
            {
                try
                {
                    Thread.sleep(1);
                } catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
            }
        });
        //thread.setDaemon(true); //②将thread设置为守护线程

        thread.start();  //③ 启动thread线程
        Thread.sleep(2_000L);
        System.out.println("Main thread finished lifecycle.");
        //④ main线程结束
    }
}

上面的代码中存在两个线程,一个是由JVM启动的main线程,另外一个则是我们自己创建的线程thread,运行上面的这段代码,你会发现JVM进程永远不会退出,即使main线程正常地结束了自己的生命周期(main线程的生命周期是从注释①到注释④之间的那段代码),原因就是因为在JVM进程中还存在一个非守护线程在运行。

如果打开注释②,也就是通过setDaemon方法将thread设置为了守护线程,那么main进程结束生命周期后,JVM也会随之退出运行,当然thread线程也会结束。

设置守护线程的方法很简单,调用setDaemon方法即可,true代表守护线程,false代表正常线程。

线程是否为守护线程和它的父线程有很大的关系,如果父线程是正常线程,则子线程也是正常线程,反之亦然,如果你想要修改它的特性则可以借助setDaemon方法。

isDaemon方法可以判断该线程是不是守护线程。

另外需要注意的就是,setDaemon方法只在线程启动之前才能生效,如果一个线程已经死亡,那么再设置setDaemon则会抛出IllegalThreadStateException异常。

守护线程的作用

在了解了什么是守护线程,以及如何创建守护线程之后,我们来讨论一下为什么要有守护线程以及何时使用守护线程。

通过上面的分析,如果一个JVM进程中没有一个非守护线程,那么JVM会退出,也就是说守护线程具备自动结束生命周期的特性,而非守护线程则不具备这个特点,试想一下如果JVM进程的垃圾回收线程是非守护线程,如果main线程完成了工作,则JVM无法退出,因为垃圾回收线程还在正常的工作。再比如有一个简单的游戏程序,其中有一个线程正在与服务器不断地交互以获取玩家最新的金币、武器信息,若希望在退出游戏客户端的时候,这些数据同步的工作也能够立即结束,等等。

守护线程经常用作与执行一些后台任务,因此有时它也被称为后台线程,当你希望关闭某些线程的时候,或者退出JVM进程的时候,一些线程能够自动关闭,此时就可以考虑用守护线程为你完成这样的工作。

Search

    微信好友

    博士的沙漏

    Table of Contents