Java多线程学习之并发基础

单任务

单任务的特点是排队执行,也就是同步,就像再cmd输入一条命令后,必须等待这条命令执行完才可以执行下一条命令一样。这就是单任务环境的缺点,即CPU利用率大幅降低。


单任务的环境

多任务

操作系统的多任务指的是在同一刻运行多个程序的能力。例如,在编辑或下载邮件的同时可以打印文件。今天,人们很可能有单台拥有多个 CPU 的计算机, 但是 , 并发执行的进程数目并不是由 CPU 数目制约的。操作系统将 CPU 的时间片分配给每一个进程,给人并行处理的感觉。

多任务的环境

并行与并发

并行:当进程的数量小于处理器的数量时,进程的并发是真正的并发,不同的进程运行在不同的处理器上。
并发:如果只有一个CPU,如何做到多个进程同时运行呢?我们先来看操作系统的一些相关概念。大部分操作系统(如Windows、Linux)的任务调度是采用时间片轮转的抢占式调度方式,即把CPU的执行时间分为很多小块,每一小块的时间相等且固定,我们把任务执行的这一小块时间叫做时间片。任务正在执行时的状态叫运行状态,一个任务执行一小段时间后会被强制暂停去执行下一个任务,被暂停的任务就处于就绪状态等待下一个属于它的时间片的到来,每个任务在CPU的调度下轮流执行。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发。
下面是时间片以及CPU轮转调度的示意图:

CPU调度.png

进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程是一种抽象的概念,从来没有统一的标准定义。进程一般由程序、数据集合和进程控制块三部分组成。程序用于描述进程要完成的功能,是控制进程执行的指令集;数据集合是程序在执行时所需要的数据和工作区;程序控制块(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。

进程具有的特征:
动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
并发性:任何进程都可以同其他进程一起并发执行;
独立性:进程是系统进行资源分配和调度的一个独立单位;
结构性:进程由程序、数据和进程控制块三部分组成。

线程

在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。
后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。简言之,线程是比进程还要小的运行单位,可以看作是子程序,一个进程包含一个或多个线程。

主线程

JVM调用main()所产生的线程。

当前线程

当前正在运行的进程,可通过Thread.currentThread()来获取当前线程。

后台线程(守护线程)

指为其他线程提供服务的线程,也称为守护线程。比如JVM的垃圾回收、内存管理等线程都是守护线程。当所有前台线程(用户线程)都结束,程序只剩下后台线程(守护线程)的时候,JVM就退出了,由于如果只剩下守护线程,就没必要继续运行程序了。可以通过isDaemon()和setDaemon()方法来判断一个线程是否为后台线程和设置一个线程为后台线程。
守护线程有时会被初学者错误地使用,他们不打算考虑关机(shutdown)动作。但这是很危险的。守护线程应该永远不去访问固有资源, 如文件、数据库, 因为它会在任何时候甚至在一个操作的中间发生中断。比如一个守护线程在操作资源的时候,如果所有用户线程都退出了,JVM将直接杀死该守护线程而无法执行finally块中的关闭资源的语句。

有几点需要注意:

  • setDaemon(true) 必须在调用线程的 start() 方法之前设置,否则会抛出 IllegalThreadStateException 异常。
  • 在守护线程中产生的新线程也是守护线程。

来看一个例子:
MyThread.java

Run.java

运行结果

原因分析:当thread被设置为守护线程时,主线程是前台线程,执行完之后就直接结束,JVM直接杀死thread,这个守护线程中的内容就不会继续执行下去;当去掉那一行时,thread就默认为前台线程,jvm会等所有前台线程执行完之后才会结束,thread线程就打印出内容。

前台线程(用户线程、非守护线程)

是指接受后台线程服务的线程,其实前台后台线程是联系在一起。由前台线程创建的线程默认也是前台线程。main() 属于非守护线程。

单线程

只包含一个线程的程序,即主线程(主方法所在线程)。

多线程

可以同时运行一个以上线程的程序。在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器依然采用时间片机制。当选择下一个线程时,操作系统考虑线程的优先级。

下面是单线程和多线程的关系示意图:

单线程和多线程.png

进程和线程的区别与关系

(一)拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
(二)调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。
(三)系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
(四)通信方面
进程间通信 (IPC) 需要进程同步和互斥手段的辅助,以保证数据的一致性。而线程间可以通过直接读/写同一进程中的数据段(如全局变量)来进行通信。共享内存空间使线程之间的通信比进程之间的通信更有效、更容易,但也更具风险。

锁池和等待池

在Java中,每个对象都有两个池,锁 (monitor) 池和等待 (wait) 池,而这两个池又与 Object 基类的 wait()、notify()、notifyAll() 三个方法和 synchronized 相关(还有 Lock 和 await 等)。

锁池

设objectX是一个对象,假设有线程A、B、C同时申请objectX这个对象的锁,那么任意时刻只有一个线程能够获得(占用/持有)这个锁,因此除了胜出(即获得了锁)的线程(这里假设是B)外,其他线程(这里就是A和C)都会被同步阻塞。这些因申请锁而落选的线程就会被存入objectX对应的锁池之中。
当objectX的锁被其持有线程(这里就是B)释放时,锁池中的线程开始竞争objectX的锁。某个线程如果成功申请到objectX,那么该线程就从锁池中移除,并进入RUNNABLE状态(注意竞争胜出者不一定是在锁池中时间最长或者最短的,因为 synchronized 无法保证公平)。未竞争到锁的线程仍然会停留在锁池中,等待下次申请锁的机会。

等待池

假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在同步方法或同步代码块中,即要调用某对象的wait()方法之前必须持有该对象的锁),同时线程A就进入到了该对象的等待池中,处于等待阻塞状态。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池,与锁池中已经存在的线程以及其他(可能的)活跃线程共同参与抢夺锁。
等待池中的线程被notify()或者notifyAll()方法唤醒进入到锁池,但此时notify()线程还没有释放锁,当其退出synchronized方法或块。锁池中的线程开始竞争锁,wait的线程如果竞争到了锁后就进入RUNNABLE状态,之后被调度选中进入Running状态,会从wait现场恢复,执行wait()方法之后的代码。

线程的状态

  • 新建状态: 使用 new 关键字创建一个 Thread 类或其子类的线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序调用它的start()方法启动这个线程,随后线程便进入了就绪状态。
  • 就绪状态(可运行状态): 当线程对象调用了start()方法之后,该线程并不是立即开始运行,而是进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度,获取CPU使用权后才进入运行状态。
  • 运行状态: 如果就绪状态的线程获取 CPU 使用权,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。处于运行状态的线程如果CPU的时间片用完或者调用了yield()方法都会转化为就绪状态。而如果线程调用了sleep()方法、join()方法、wait()方法、获取synchronized同步锁失败或发出了I/O请求(比如等待用户输入),线程都会进入阻塞状态。
  • 阻塞状态: 如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。可以分为三种:
    1)等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待池,即进入等待阻塞状态(wait()会释放线程持有的锁),当调用notify()或notifyAll()方法线程从等待池进入锁池。
    2)同步阻塞:也称锁池状态,线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)进入同步阻塞状态,当其他线程释放该锁,并且线程调度器允许本线程持有它的时候,线程重新转入就绪状态。
    3)其他阻塞:通过调用线程的 sleep() 或 join() 或发出了 I/O 请求时,线程就会进入到阻塞状态。当sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 请求完毕(比如用户输入完毕),线程重新转入就绪状态。
  • 死亡状态(终止状态):
    一个线程执行完毕或者异常终止,该线程就切换到终止状态。其他四个状态都可以通过调用stop()方法来进入死亡状态,但stop()方法已经过时了,不建议使用。

线程的生命周期

即前面提到的线程的五个状态之间的转化,可以通过调用Thread类的方法来影响线程的生命周期。

下面是线程生命周期的示意图:

Thread类

Thread类是一个线程类,位于java.lang包下。它实现了Runnable接口。
1、构造方法

Thread类构造方法.png

2、常量

Thread类常量.png

3、常见方法

Thread方法1.png

Thread方法2.png

更多见大牛博客JAVA线程-Thread类的方法

Runnable接口

  • 只有一个方法run();
  • Runnable是Java中用于实现线程的接口
  • 任何实现线程功能的类都必须实现该接口

实例变量与线程安全

自定义线程类的实例变量针对其他线程可以有共享和不共享之分,这在多个线程进行交互时是一个很重要的技术点。

(1)不共享数据的情况

不共享数据

来看下面这个例子:

MyThread.java

Run.java

运行结果

不共享数据的运行结果

分析
一共创建了三个线程,每个线程有自己独立的count变量,自己减少自己的count值,这种情况就是变量不共享,此示例不存在多个线程访问同一个实例变量的情况。

(2)共享数据的情况

共享数据

来看下面的例子:
MyThread.java

Run.java

运行结果

共享数据的运行结果

分析
我们发现A、B计算结果都是3,不是预期的递减,这是以下原因造成的:
1、多个线程访问同一个实例变量
myThread作为一个target创建了5个线程,每个线程访问的count都是myThread的count
2、i--不是原子操作
i--的操作分为以下三步:
1)取得原有的i值
2)计算i-1
3)对i进行赋值
任何一个子操作都可能发生线程切换,从而造成数据不同步的问题。

创建线程

创建线程主要有三种方式:

一、继承Thread类创建线程类

继承Thread类的方法尽管被列为一种多线程实现方式,但Thread本质上也是实现了Runnable接口的一个实例,它代表一个线程的实例,并且启动线程的唯一方法就是通过Thread类的start()实例方法。start()方法是一个native方法,它将启动一个新线程,将线程交给“线程规划器”来处理,“线程规划器”会调度该线程并执行run()方法。这种方式实现多线程很简单,通过自己的类直接extends Thread,并复写run()方法,就可以启动新线程并执行自己定义的run()方法。

下面是使用这种方法创建线程的具体步骤:
(1)定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
(2)创建Thread子类的实例,即创建了线程对象。
(3)调用线程对象的start()方法来启动该线程。

来看下面的一个例子:

package cn.habitdiary.thread;
class MyThread extends Thread{
    public void run(){
        System.out.println(getName()+"该线程正在执行!");
        //通过getName()获取线程名
    }
}
public class ThreadTest {

    public static void main(String[] args) {
        System.out.println("主线程1");
        MyThread mt=new MyThread();
        mt.start();//启动线程
        System.out.println("主线程2");
    }

}

在上述代码中,我们就通过创建一个继承自Thread类的子类MyThread的对象来创建了一个线程mt,此时该程序中共包含3个线程,一个是mt,一个是主方法所在的主线程,一个是垃圾收集器线程。三条输出语句的打印次序是随机的,这是因为某个线程何时获得CPU的使用权是CPU轮转调度的结果。
值得注意的是,在Java中,每次程序运行至少启动2个线程。一个是主线程,一个是垃圾收集器线程。因为当执行一个程序的时候,实际上都会启动一个JVM,启动一个JVM就是在操作系统中启动了一个进程。
我们通过下面的循环输出语句可以令这种线程间的时间片轮转调度更明显:

package cn.habitdiary.thread1;
class MyThread extends Thread{
    public MyThread(String name){
        super(name);
    }
    public void run(){
        for(int i=1;i<=10;i++){
            System.out.println(getName()+"正在运行"+i);
        }
    }
}
public class ThreadTest {

    public static void main(String[] args) {
        MyThread mt1=new MyThread("线程1");
        MyThread mt2=new MyThread("线程2");
        mt1.start();
        mt2.start();
    }

}

运行结果如下:

运行结果.png

可以看到线程的打印语句随机交替出现,这就证明了线程获得CPU使用权是随机的。
注意:
1、不要直接在主线程里调用Thread类或Runnable对象的run方法,如果直接调用run方法,就不是异步执行了,而是同步,此线程对象并不交给"线程规划器"来处理,而是由主线程main来调用run()方法,必须等run()方法中的代码执行完后才可以执行后面的代码。结果只会执行同一个线程中的任务,而不会启动新的线程。启动线程的唯一方法就是通过Thread类的start()实例方法,这个方法将创建一个执行run方法的新线程。
2、不要重复启动同一个线程,比如重复两次调用mt.start(),程序会抛出一个IllegalThreadStateException异常。
3、这种继承Thread类创建线程类的方法已不再推荐,应该将要并行运行的任务与运行机制解耦合。如果有很多个任务,要为每个任务创建一个独立的线程所付出的代价太大了。可以使用线程池来解决这个问题,有关内容参看博客后面的内容。

二、通过实现Runnable接口创建线程类

我们使用这种方法创建线程的频率更高。

下面是使用这种方法创建线程的具体步骤:
(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
(2)创建 Runnable实现类的实例,并依此实例作为Thread的构造方法Thread(Runnable target)的target参数来创建Thread对象,该Thread对象才是真正的线程对象。
(3)调用线程对象的start()方法来启动该线程。

来看下面一个例子:

package cn.habitdiary.runnable;

class PrintRunnable implements Runnable {
    int i = 1;
    @Override
    public void run() {
        
        while (i <= 10)
            System.out.println(Thread.currentThread().getName()
            +"正在运行" + (i++)); 
            /*无法直接调用getName()方法,而是要通过
            Thread.currentThread()先获取当前线程的
            对象,再在其上调用getName()方法*/
    }

}

public class Test {

    public static void main(String[] args) {
        PrintRunnable pr = new PrintRunnable();
        Thread t1 = new Thread(pr);
        t1.start();
        //PrintRunnable pr1 = new PrintRunnable();
        Thread t2 = new Thread(pr);
        t2.start();

    }

}

在这段代码中,t1和t2共享PrintRunnable对象的成员变量i,所以语句被两个线程一共交替打印了十次。如果两个线程的pr是不同的PrintRunnable对象,则两个线程交替着各打印十次语句。这就是多个线程共享资源的简单例子。

注意:由于Runnable只有一个抽象方法,是一个函数式接口,所以我们也可以通过匿名内部类或lambda表达式的方式来简化上述创建线程的步骤。

三、通过Callable和FutureTask创建线程

下面是使用这种方法创建线程的具体步骤:
(1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值,Callable接口是一个泛型接口。
(2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值,它是一个泛型类。
(3)使用FutureTask对象作为Thread对象的target创建并启动新线程。
(4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

示例代码:

public class MyCallable implements Callable<Integer> {
    public Integer call() {
        return 123;
    }
}

public static void main(String[] args) throws ExecutionException, 
InterruptedException {
    MyCallable mc = new MyCallable();
    FutureTask<Integer> ft = new FutureTask<>(mc);
    Thread thread = new Thread(ft);
    thread.start();
    System.out.println(ft.get());
}

四、创建线程的三种方式的对比

1)采用实现Runnable、Callable接口的方式创建多线程
优势:
1.线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
2.在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
3.线程池只能放入实现Runnable或callable类的线程,不能直接放入继承Thread的类。

劣势:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。

2)使用继承Thread类的方式创建多线程
优势:编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。
劣势:线程类已经继承了Thread类,所以不能再继承其他父类。且继承Thread不适合线程间进行资源共享。

currentThread()方法

currentThread()方法返回代码段正在被哪个线程调用的信息,先来看一个简单的例子:
Run1.java


运行结果

分析
main方法被main线程调用

继续实验
MyThread.java

Run2.java

运行结果

分析
MyThread的构造方法是在主线程main中调用的,所以打印出main。run方法的打印结果为什么是Thread-0呢?我们来看看Thread类的源码:

public Thread(Runnable target) {
        init(null, target, "Thread-" + nextThreadNum(), 0);
    }

在Thread类的构造方法中,默认会自动给name赋值。由于没有给MyThread显式命名,就使用了自动分配的线程名Thread-0来标识该线程。

如果把Run2.java的代码改动如下:

运行结果

分析
直接在主线程中调用mythread.run()方法,没有启动新线程,当前线程还是主线程main。

最后我们来看一个更复杂的例子:

CountOperate.java

Run.java

运行结果

分析
其他输出之前已经分析过,只是第二个this.getName()为什么是Thread-0呢?
我们首先要清楚t1和c是两个完全不同的对象,在Thread源码中new Thread(c)会将c对象绑定到一个t1的一个private变量target上,在t1.run()被调用的时候,它会调用target.run()方法,也就是说它是直接调用c对象的run方法,也就是说,在run方法被执行的时候,this.getName()实际上返回的是target.getName(),而Thread.currentThread().getName()实际上是t1.getName()。所以主方法中t1.setName("A")仅仅是给t1对象命名为“A”,而c对象的名称仍然是"Thread-0"。所以第二个Thread.currentThread().getName()结果是“A”,即c对象的run()是由t1线程调用的,而this.getName()的this指的是c对象,所以结果还是"Thread-0"。

线程的优先级

每一个 Java 线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。JVM提供了10个线程优先级,即1到10的整数,超出这个范围会抛出IllegalArgumentException,但它们与常见的操作系统都不能很好的映射,比如Windows只有7个优先级。如果希望程序能移植到各个操作系统中,应该仅仅使用Thread类里三个静态常量作为优先级,这样能保证同样的优先级采用了同样的调度方式。主线程的优先级为5。
Thread类中提供了三个常量来表示优先级,分别为:
Thread.MIN_PRIORITY(等价于1)
Thread.MAX_PRIORITY(等价于10)
Thread.NORM_PRIORITY(等价于5)

Thread类提供了改变和获取某线程优先级的方法

优先级.png

线程优先级的继承特性

默认情况下,一个线程继承它的父线程的优先级。比如A线程启动B线程,B线程的优先级和A是一样的。

优先级具有规则性和随机性

每当线程调度器有机会选择新线程时,它首先选择具有较高优先级的线程,这是优先级的规则性。所以一般来说,高优先级的进程大部分先执行完,但不代表高优先级的进程全部先执行完。例如虽然设置了优先级,但启动线程start()有先后顺序等影响了线程的执行顺序。
当两个线程的优先级差别很大,比如A线程为10,B线程为1,此时谁先执行完与启动顺序无关。因为A线程的优先级较B线程来说很高,所以即使A线程后启动也会在B线程之前执行完。但是如果两个线程的优先级很接近,比如A优先级为6,B优先级为5,那么谁先执行完就受优先级和启动顺序的共同影响,表现出随机性
初级程序员常常过度使用线程优先级,不要将程序构建为功能的正确性依赖于优先级。 如果确实要使用优先级,应该避免初学者常犯的一个错误。如果有几个高优先级的线程没有进入非活动状态,低优先级的线程可能永远也不能执行。每当调度器决定运行一个新线程时,首先会在具有高优先级的线程中进行选择,尽管这样会使低优先级的线程完全饿死。

线程休眠

线程休眠是在指定的毫秒数内让"当前正在执行的线程"休眠(暂停执行),这个"正在执行的线程"是指Thread.currentThread()返回的线程。sleep()是使线程让出CPU使用权的最简单做法,某线程休眠的时候,会将CPU交给其他线程,以便轮换执行,而它自身进入阻塞状态,休眠一定时间后,线程会苏醒,进入就绪状态等待执行。线程的休眠方法是Thread.sleep(long millis)Thread.sleep(long millis,int nanos),均为静态方法,millis参数设定睡眠的时间,以毫秒为单位。调用sleep休眠的哪个线程呢?在哪个线程中调用sleep,哪个线程就休眠。
来看一个例子,线程1休眠后,让出CPU,线程2执行,线程2执行完后,线程2休眠,让出CPU供线程1执行(此时线程1已经休眠结束,在就绪状态),如此循环执行,直到结束。

 package Thread;
  
 public class SleepTest {
      public static void main(String[] args){
          Thread t1=new MyThread_1();
          Thread t2=new Thread(new MyRunnable1());
          t1.start();
          t2.start();
      }
 }
class MyThread_1 extends Thread{
     public void run(){
         for(int i=0;i<3;i++){
             System.out.println("线程1第"+i+"次执行!");
             try{
                 Thread.sleep(500);
             }catch(InterruptedException e){
                 e.printStackTrace();
             }
         }
     }
 }
class MyRunnable1 implements Runnable{
     public void run(){
         for(int i=0;i<3;i++){
             System.out.println("线程2第"+i+"次执行!");
             try{
                 Thread.sleep(500);
             }catch(InterruptedException e){
                 e.printStackTrace();
             }
         }
     }
 }

结果如下:

Sleep.png

注意:调用sleep()方法时必须处理可能抛出的InterruptedException,一般用try-catch块即可。

sleep方法的应用场景:可以实现计时器效果或定期刷新数据的效果,但是由于线程在苏醒之后不会直接进入运行状态,而是进入就绪状态等待获取CPU使用权,所以同一线程两次执行的时间间隔会略大于休眠时间,不能保证精确定时。

我是彩蛋: 了解了线程休眠,我们就可以写出传说中的睡眠排序了hhh,参考大牛博客排序算法--睡眠排序、面条排序、猴子排序 (非常严肃)

线程让步

让步使用Thread.yield()方法,yield方法为静态方法,功能是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。让出的时间和让出给哪个线程都是不可设定的,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。
实际上,yield()方法对应了如下操作:先检测当前是否有相同优先级的线程处于同可运行状态,如有,则把 CPU 的占有权交给此线程,否则,继续运行原来的线程。所以yield()方法称为“退让”,它把运行机会让给了同等优先级的其他线程。
来看一个例子:
MyThread.java

Run.java

运行结果

试着去掉Thread.yield()前面的注释
运行结果

分析
我们发现使用yield()方法把CPU让给其他资源使得同一个线程的运行速度变慢了

sleep()和yield()的区别
sleep()使当前线程进入阻塞状态,所以执行sleep()的线程在指定的时间内肯定不会被执行;yield()只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。
sleep 方法使当前运行中的线程休眠一段时间,进入阻塞状态,这段时间的长短是由程序设定的,yield 方法使当前线程让出 CPU 占有权,但让出的时间是不可设定的。
另外,sleep 方法允许较低优先级的线程获得运行机会,但 yield()方法执行时,当前线程仍处在就绪状态,所以,不可能让出较低优先级的线程些时获得 CPU 占有权。在一个运行系统中,如果较高优先级的线程没有调用 sleep() 方法,又没有受到 I\O 阻塞,那么,较低优先级线程只能等待所有较高优先级的线程运行结束,才有机会运行。

线程中断

当线程的 run 方法执行方法体中最后一条语句后,或者出现了在方法中没有捕获的异常时, 线程将终止。在 Java 的早期版本中,还有一个 stop 方法, 其他线程可以调用它终止线程。但是,这个方法现在已经被弃用了。
没有可以强制线程终止的方法。然而,interrupt方法可以用来请求终止线程

停止不了的线程

当对一个线程调用 interrupt 方法时, 这个线程并不会立即被中断,只是该线程的中断状态将被置位,需要用户自己去监视线程的中断状态位并做处理。这是每一个线程都具有的 boolean 标志。每个线程都应该不时地检査这个标志,以判断线程是否被中断。要想弄清中断状态是否被置位,首先调用静态的Thread.currentThread方法获得当前线程,然后调用 islnterrupted 方法:

while (!Thread.currentThread().isInterrupted() && more work to do)
{
do more work
}

没有任何语言方面的需求要求一个被中断的线程应该终止。中断一个线程不过是引起它的注意。被中断的线程可以决定如何响应中断。某些线程是如此重要以至于应该处理完异常后,继续执行,而不理会中断。但是, 更普遍的情况是, 线程将简单地将中断作为一个终止的请求。这种线程的 run 方法具有如下形式:

Runnable r= () -> {
    try{
        ...
        while (!Thread.currentThread().isInterrupted && 
        more work to do){
                do more work
            }
        }
    catch(InterruptedException e){
    // thread was interruputed during sleep or wait
    }
    finally{
     cleanup,if required
    }// exiting the run method terminates the thread
};

注意:有两个非常类似的方法, interrupted 和isInterrupted。interrupted 方法是一个静态方法,它检测当前的线程是否被中断。而且,调用 interrupted 方法会清除该线程的中断状态,即将中断状态置为false。另一方面,isInterrupted 方法是一个实例方法,可用来检验是否有线程被中断。调用这个方法不会改变中断状态。

在很多发布的代码中会发现 InterruptedException 异常被抑制在很低的层次上,像这样:

void mySubTask(){
    ...
    try { sleep(delay) ; }
    catch(InterruptedException e) { } // Don't ignore!
    ...
}

不要这样做!如果不认为在 catch 子句中做这一处理有什么好处的话,仍然有两种合理的选择:

  • 在 catch 子句中调用 Thread.currentThread().interrupt() 来设置中断状态。于是,调用者可以对其进行检测。
void mySubTask()
{
    ...
    try { sleep(delay);}
    catch(InterruptedException e) 
    {
        Thread.currentThread().interrupt();
    } 
...
}
  • 或者, 更好的选择是, 用 throws InterruptedException 标记你的方法, 不采用 try 语句块捕获异常。于是,调用者(或者 最终的 run 方法)可以捕获这一异常。
void mySubTask() throws InterruptedException
{
    ...
    sleep(delay);
    ...
}

下面是与线程中断有关的API

线程中断.png

能停止的线程 —— 异常法

直接调用线程的interrupt方法无法停止线程,下面给出几种能停止线程的方法,第一种是异常法。来看下面的例子:
MyThread.java

Run.java

运行结果

但上述代码存在一个问题,如果break跳出for循环之后,又遇到了一个for循环,线程将无法停止!

更好的方法是让线程抛出一个InterruptedException,如:
MyThread.java

Run.java

运行结果

在沉睡中停止

如果线程在sleep状态下调用interrupt()方法会发生什么呢?我们来看一个例子:
MyThread.java

Run.java


运行结果

我们发现:在sleep状态下,如果在调用线程的interrupt()方法,将会抛出一个interruptedException,并清除中断状态,即将中断状态位置为false。
这是因为线程被阻塞,就无法检测中断状态。这是产生 InterruptedException 异常的原因。当在一个被阻塞的线程 (调用 sleep 或 wait)上调用 interrupt 方法时, 阻塞调用将会被 InterruptedException 异常中断。
上面的例子是在线程睡眠时调用interrupt方法,我们来考虑与之相反的操作,即在一个调用过interrupt方法,即中断状态标志被置位的线程上调用sleep()方法
MyThread.java

Run.java

运行结果

我们发现:如果在中断状态被置位时调用 sleep 方法,它不会休眠。相反,它将清除这一状态并拋出InterruptedException。

提示
如果在每次工作迭代之后都调用 sleep 方法 (或者其他的可阻塞方法,isInterrupted 检测既没有必要也没有用处。因此,如果你的循环调用sleep,不要检测中断状态。相反,要如下所示捕获InterruptedException异常:

Runnable r= () -> {
    try{
        ...
        while (more work to do){
                do more work
                Thread.sleep(delay);
            }
        }
    catch(InterruptedException e){
    // thread was interruputed during sleep or wait
    }
    finally{
     cleanup,if required
    }// exiting the run method terminates the thread
};

能停止的线程 —— 暴力停止

使用stop()能暴力停止线程。
MyThread.java

Run.java


运行结果
线程被暴力停止,IDE运行图标呈灰色。

方法stop()已经作废,因为如果强制让线程停止,一些清理性工作可能得不到完成。另一个情况就是对锁定的对象进行了"解锁",导致数据得不到同步的处理,出现数据不一致的问题。

方法stop()与java.lang.ThreadDeath异常

调用stop()方法时会抛出java.lang.ThreadDeath异常,但在通常情况下,该异常不需要显式捕获。来做一个验证:
MyThread.java

Run.java

运行结果

stop()释放锁的不良后果

使用stop()释放锁将会给数据造成不一致的后果。来看一个示例:
SynchronizedObject.java

MyThread.java


Run.java

运行结果

使用return停止线程

将方法interrupt()和return结合使用也能实现停止线程的效果
MyThread.java


Run.java

暂停线程

暂停线程意味着该线程还可以恢复运行,可以使用suspend()方法暂停线程,使用resume()方法恢复线程的执行,下面是例子:
MyThread.java


Run.java



从控制台打印的时间来看,线程的确被暂停了,而且还可以恢复到运行状态。

suspend 与 resume 方法的缺点 —— 独占

在使用suspend 与 resume 方法时,如果使用不当,极易造成公共的同步对象的独占,使得其他线程无法访问公共同步对象。来看一个例子:
SynchronizedObject.java

Run.java


运行结果

还有一种独占锁的情况极其容易被忽略!

MyThread.java

Run.java

运行结果

分析
我们发现控制台将不会打印"main end!"。这是因为:程序运行到println()方法内部停止时,同步锁未被释放。
我们来看看println()方法的源码:


可以发现println()方法是一个同步方法,当某线程进入println()方法的临界区时,如果挂起该线程,则该线程将独占当前这个PrintStream对象,锁未被释放。main方法中的System.out.println("main end!")就无法得到打印。

suspend 与 resume 方法的缺点 —— 不同步

在使用suspend 与 resume 方法时也容易出现因为线程的暂停而导致的数据不同步的情况。来看一个例子:
MyObject.java

Run.java

运行结果

多线程开发良好的实践

  • 给线程起个有意义的名字,这样可以方便找 Bug。

  • 缩小同步范围,从而减少锁争用。例如对于 synchronized,应该尽量使用同步块而不是同步方法。

  • 多用同步工具少用 wait() 和 notify()。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger 这些同步类简化了编码操作,而用 wait() 和 notify() 很难实现复杂控制流;其次,这些同步类是由最好的企业编写和维护,在后续的 JDK 中还会不断优化和完善。

  • 使用 BlockingQueue 实现生产者消费者问题。

  • 多用并发集合少用同步集合,例如应该使用 ConcurrentHashMap 而不是 Hashtable。

  • 使用本地变量和不可变类来保证线程安全。

  • 使用线程池而不是直接创建线程,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容