JAVA线程生命周期分阶段详解哲学家们深感死锁难解

55次阅读

共计 5761 个字符,预计需要花费 15 分钟才能阅读完成。

每个事物都有其 生命周期 ,也就是事物 从出生开始 最终消亡 这中间的整个过程;在其整个生命周期的历程中,会有不同阶段,每个阶段对应着一种状态,比如:人的一生会经历从婴幼儿、青少年、青壮年、中老年到最终死亡,离开这人世间,这是人一生的状态;同样的,线程作为一种事物,也有生命周期,在其生命周期中也存在着不同的状态,不同的状态之间还会有互相转换。

在上文中,我们提到了 线程通信,在多线程系统中,不同的线程执行不同的任务;如果这些任务之间存在联系,那么执行这些任务的线程之间就必须能够通信,共同协调完成系统任务。

在本文中,我们接着来说说线程通信中的线程的生命周期。

线程的生命周期

我们先来查看 jdk 文档,在Java 中,线程有以下几个状态:

Java 中,给定的时间点上, 一个线程只能处于一种状态 ,上述图片中的这些状态都是 虚拟机状态 ,并不是操作系统的线程状态。线程对象的状态存放在Thread 类的内部类 (State) 中,是一个枚举,存在着 6 种固定的状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED

状态之间的转换如下图所示:

下面就来对这些状态一一解释:

1. 新建状态 (new): 使用new 创建一个线程对象,仅仅在堆中分配内存空间,在调用 start 方法 之前的线程所处的状态;在此状态下,线程还没启动,只是创建了一个线程对象存储在堆中;比如:

   Thread t = new Thread(); //  此时 t 就属于新建状态

当新建状态下的线程对象调用了 start 方法,该线程对象就从新建状态进入 可运行状态(runnable);线程对象的 start 方法只能调用一次,多次调用会发生IllegalThreadStateException

2. 可运行状态 (runnable): 又可以细分成两种状态,readyrunning,分别表示 就绪状态和运行状态

  • 就绪状态: 线程对象调用 start 方法 之后,等待 JVM 的调度(此时该线程并没有运行),还未开始运行;
  • 运行状态: 线程对象已获得 JVM 调度,处在运行中;如果存在多个CPU,那么允许多个线程并行运行;

3. 阻塞状态 (blocked): 处于运行中的线程因为某些原因放弃 CPU 时间片,暂时停止运行,就会进入 阻塞状态 ;此时JVM 不会给线程分配 CPU 时间片,直到线程重新进入 就绪状态(ready,才有可能转到运行状态;

阻塞状态只能先进入就绪状态,进而由操作系统转到运行状态,不能直接进入运行状态 阻塞状态发生的两种情况:

  1. A 线程 处于运行中,试图获取同步锁时,但同步锁却被 B 线程 获取,此时 JVM 会把 A 线程 存到共享资源对象的锁池中, A 线程 进入阻塞状态;
  2. 当线程处于运行状态,发出了 IO 请求时,该线程会进入阻塞状态;

4. 等待状态 (waiting): 运行中的线程调用了 wait 方法(无参数的wait 方法),然后JVM 会把该线程储存到共享资源的对象等待池中,该线程进入等待状态;处于该状态中的线程只能被其他线程唤醒;

5. 计时等待状态 (timed waiting): 运行中的线程调用了 带参数的 wait 方法 或者sleep 方法,此状态下的线程不会释放同步锁 / 同步监听器,以下几种情况都会进入计时等待状态:

  1. 当处于运行中的线程,调用了 wait(long time) 方法,JVM会把当前线程存在共享资源对象等待池中,线程进入计时等待状态;
  2. 当前线程执行了 sleep(long time) 方法,该线程进入计时等待状态;

6. 终止状态(terminated):也可以称为死亡状态,表示线程终止,它的生命走到了尽头;线程一旦终止, 就不能再重启启动,否则会发生IllegalThreadStateException;有以下几种情况线程会进入终止状态:

  1. 正常执行完 run 方法 而退出,寿终正寝,属于正常死亡;
  2. 线程执行遇到异常而退出,线程中断,属于意外死亡;

线程控制

线程休眠:让运行中的的线程暂停一段时间,进入 计时等待状态

方法:static void sleep(long millis) 

调用 sleep 后,当前线程放弃 CPU 时间片,进入 计时等待状态,在指定时间段之内,调用 sleep 的线程不会获得执行的机会,此状态下的线程不会释放同步锁 / 同步监听器

该方法更多的用于模拟网络延迟,让多线程并发访问同一个资源的错误效果更明显;也有让程序的执行便于观察的调用:

public static void main(String[] args) {for (int i = 5; i > 0; i--) {System.out.println("还剩" + i + "秒");                  
        Thread.sleep(1000);        
    }              
    System.out.println("时间到");  
}

联合线程

线程的 join 方法 表示一个线程等待另一个线程完成后才执行;join 方法 被调用之后,调用 join 方法 的线程对象所在的线程处于 阻塞状态 ,调用join 方法 的线程对象进入运行状态。之所以把这种方式称为 联合线程 ,是因为通过join 方法 把当前线程和当前线程所在的线程联合成一个线程。

public class JoinThreadDemo {public static void main(String []args) throws Exception {System.out.println("开始");
        JoinThread join = new JoinThread();
        for (int i = 0; i < 50; i++) {System.out.println("i :" + i);
            if (i == 10) {join.start();
            }
            if (i == 20) {join.join();
            }
        }
        System.out.println("结束");
    }
}

class JoinThread extends Thread {
    @Override
    public void run() {for (int i = 0; i < 50; i++) {System.out.println("join :" + i);
        }
    }
}

运行结果打印如下:

可以看到,当 i = 20 时,join 线程 对象开始执行,主线程(主函数)进入阻塞状态,暂停执行;join 线程 对象运行完成后,主线程(主函数)才重新开始执行。那为啥 i=10 时,join 线程 对象没有执行呢?原因是虽然 join 线程 对象调用了 start 方法,但还未获得JVM 调度,所以没有执行。

后台线程

后台线程 ,在后台运行的线程,其目的是为其他线程提供服务,也称为“ 守护线程 “。JVM 的垃圾回收线程就是典型的后台线程。

Java 中,开发者们通过代码创建的线程默认都是 前台线程 ,如果想要转为后台线程可以通过调用 setDaemon(true) 来实现,该方法必须在start 方法 之前调用,否则会触发 IllegalThreadStateException 异常,因为线程一旦启动,就无法对其做修改了。

由前台线程创建的新线程除非特别设置,否则都是前台线程,同理,后台线程创建的新线程也是后台线程。若是不知道某个线程是前台线程还是后台线程,可通过线程对象调用 isDaemon() 方法来判断。

若所有的前台线程都死亡,后台线程自动死亡,若是前台线程没有结束,后台线程是不会结束的。

线程优先级

每个线程都有优先级,优先级的高低只与线程获得执行机会的次数多少有关 ,并非是线程优先级越高的就一定先执行,因为哪个线程的先运行取决于CPU 的调度,无法通过代码控制。

Java 中,支持了从1 - 1010个优先级,1是最低优先级,10是最高优先级,默认优先级是 5jdk 文档中的 线程优先级 如下图所示:

  • MAX_PRIORITY=10,最高优先级
  • MIN_PRIORITY=1,最低优先级
  • NORM_PRIORITY=5,默认优先级

JavaThread 类中提供了获取、设置线程优先级的方法:

int getPriority():返回线程的优先级

void setPriority(int newPriority) : 设置线程的优先级

每个线程在创建时都有默认优先级,主线程默认优先级为 5,如果 A 线程创建了 B 线程,那么 B 线程 A 线程 具有相同优先级;虽然 Java 中可设置的优先级有10 个,但不同的操作系统支持的线程优先级不同的,windows支持的,linux不见得支持;所以,一般情况下,不建议自定义,建议使用上述 Thread 类中提供的三个优先级,因为这三个优先级各个操作系统均支持。

线程礼让

yield方法:表示当前线程对象 提示调度器自己愿意让出CPU 时间片,但是调度器却不一定会采纳,因为调度器同样也有选择是否采纳的自由,他可以选择忽略该提示。

调用该方法之后,线程对象进入 就绪状态 ,所以完全有可能出现某个线程调用了yield() 之后,线程调度器又把它调度出来重新执行。

在开发中很少会使用到该方法,该方法主要用于调试或者测试,比如在多线程竞争条件下,让错误重现现象或者更加明显。

sleep 方法 yield 方法 的区别:

  1. 共同点 是都能使当前处于运行状态的线程放弃CPU 时间片,把运行的机会给其他线程;
  2. 不同点 在于:sleep 方法 会给其他线程运行机会,但是并不会在意其他线程的优先级;而 yield 方法 只会给相同优先级或者更高优先级的线程运行的机会;
  3. 调用sleep 方法

后,线程进入 计时等待状态 ,而调用yield 方法 后,线程进入 就绪状态

线程组

Java 中,ThreadGroup 类表示线程组,可以对属于同组的线程进行集中管理,在创建线程对象时,可以通过构造器指定其所属的线程组。

Thread(ThreadGroup group,String name);

如果 A 线程 创建了 B 线程,如果没有设置 B 线程 的分组,那么 B 线程 会默认加入到 A 线程 的线程组;一旦线程加入某个线程组,该线程就会一直存在于该线程组中直至该线程死亡,也就是说一个线程只能有存在于一个线程组中,在一个线程的整个生命周期中,线程组一经设定,便不能中途修改。当 Java 程序运行时,JVM会创建名为 main 的线程组,在默认情况下,所有的线程都归属于该改线程组下。

线程组和定时器

在 JDK 的 java.util 包中提供了 Timer 类,使用此类可以定时执行特定的任务;

其中有几个常用的方法:

// 将指定的任务 (task) 安排在指定的时间 (time) 执行
void schedule(TimerTask task, Date time)

// 从指定的时间(firstTime)开始,按照某一周期(period),重复执行定时任务(task)void    schedule(TimerTask task, Date firstTime, long period)

// 指定的任务在(task)一定的延时(delay)后执行
void    schedule(TimerTask task, long delay)

// 指定的任务(task),在一定的延时(delay)后,按一定周期(period)重复执行
void    schedule(TimerTask task, long delay, long period)

TimerTask 类 表示定时器执行的某一项任务;

通过 jdk 文档中的描述,不难发现,TimerTask其实也是一个线程,具有线程的属性和操作,所以通过前几篇文章的介绍,这个类的使用已经很熟悉了。就不再这里赘述了。

线程死锁

线程死锁, A 线程 在等待由 B 线程 持有的锁时,而 B 线程 也在等待 A 线程 持有的锁,此时,这种线程现象称为线程死锁;由于 JVM 不检测也不试图避免这种情况的发生,所以程序员必须要避免死锁的发生。

多线程通信的时候很容易造成死锁,线程死锁无法解决,只能避免;当多个线程都要访问共享的资源 A、B、C 时,要保证每一个线程都按照相同的顺序去访问他们,比如都先访问A,然后是B,最后才是C

Java 的 Thread 类存在 一些因死锁被废弃过时的方法

  • suspend(): 让正在运行的线程放弃CPU 时间片,暂停运行;
  • resume(): 让暂停的线程恢复运行;

由上述两个方法可能导致的的死锁情况:

假设有 A、B 两个线程,首先 A 线程 获得对象锁,正在执行一个同步方法,如果 B 线程 调用 A 线程suspend 方法 ,此时 A 线程 会暂停运行,并放弃 CPU 时间片,但是并不会释放拥有的锁,从而导致A、B 两个线程都处于等待中;B在等待 A 释放锁,而 A 已暂停,没办法释放锁;这样就会出现无论 A、B 哪个线程都不能获得锁。

哲学家就餐问题

哲学家就餐的问题也是一个描述死锁很好的例子,以下是 问题描述(内容来源于百度百科):

假设有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌中间有一大碗意大利面(也可以是其他的食物,比如:米饭,因为吃米饭必须用两根筷子),每两个哲学家之间有一只餐叉。因为用一只餐叉很难吃到意大利面,所以 假设哲学家必须用两只餐叉吃东西,且他们只能使用自己左右手两边的那两只餐叉

哲学家从来不交谈 ,这就很有可能产生死锁,出现: 每个哲学家都拿着左手的餐叉,永远都在等右边的餐叉(或者相反),永远都吃不到东西,最后饿死。即使没有死锁,也很有可能耗尽服务器资源。

假设规定当哲学家等待另一只餐叉超过五分钟后就放下自己手里的那一只餐叉,并且再等五分钟后进行下一次尝试。这个策略消除了死锁(系统总会进入到下一个状态),但又会产生新的问题:如果五位哲学家在完全相同的时刻进入餐厅,并同时拿起左边或者右边的餐叉,那么这些哲学家就会同时等待五分钟,同时放下手中的餐叉,又再等五分钟,哲学家任然会饿死

在实际的计算机问题中,缺乏餐叉可以类比为缺乏共享资源。一种常用的计算机技术是 资源加锁,保证在某个时刻,资源只能被一个程序或一段代码访问;当一个程序想要使用的资源已经被另一个程序锁定,它就等待资源解锁。但是当多个程序涉及到加锁的资源时,在某些情况下仍然可能发生死锁。例如,某个程序需要访问两个文件,访问了其中一个文件,另外一个文件被其他的线程锁定,这两个程序都在等待对方解锁另一个文件,但这永远不会发生。

所以在 Java 多线程开发中,尽量避免死锁问题,因为发生这样的问题真的很头疼。尽量多熟悉,多实践多线程中的理论和操作,从一次次的成功案例中体会 Java 多线程设计的魅力。

完结。老夫虽不正经,但老夫一身的才华!关注我,获取更多编程科技知识。

正文完
 0