Java 并发编程 -- 并发编程线程基础(线程创建与运行线程通知与等待join / sleep / yield方法线程中断线程上下文切换死锁守护线程与用户线程ThreadLocal)

Posted CodeJiao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 并发编程 -- 并发编程线程基础(线程创建与运行线程通知与等待join / sleep / yield方法线程中断线程上下文切换死锁守护线程与用户线程ThreadLocal)相关的知识,希望对你有一定的参考价值。

文章目录


1. 并发编程线程基础(上篇)

并发编程线程基础(上篇)
并发编程线程基础(下篇)


1.1 线程基础知识


1.1.1 程序

程序是含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码。


1.2.2 进程

进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。

程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列

进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

进程是程序的⼀次执⾏过程,因此进程是动态的。系统运⾏⼀个程序即是⼀个进程从创建,运⾏到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了⼀个 JVM 的进程,⽽ main 函数所在的线程就是这个进程中的⼀个线程,也称主线程。

如下图所示,在 windows 中通过查看任务管理器的⽅式,我们就可以清楚看到 window 当前运⾏的进程(.exe ⽂件的运⾏)。


1.3.3 线程

线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位。

一个进程可以由很多个线程组成,线程间共享进程的所有资源(堆和⽅法区资源)。每个线程有自己的有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈。所以系统在产⽣⼀个线程,或是在各个线程之间作切换⼯作时,负担要⽐进程⼩得多,也正因为如此,线程也被称为轻量级进程

线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。

进程和线程的关系如下所示:一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域

程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。那么为何要将程序计数器设计为线程私有的呢?前面说了线程是占用CPU执行的基本单位,而CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU时间片用完后,要让出CPU,等下次轮到自己的时候再执行。那么如何知道之前程序执行到哪里了呢?其实程序计数器就是为了记录该线程让出CPU时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。另外需要注意的是,如果执行的是native方法,那么pc计数器记录的是undefined地址,只有执行的是Java代码时程序计数器记录的才是下一条指令的地址。

另外每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。

堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用new操作创建的对象实例。

方法区则用来存放JVM加载的类、常量及静态变量等信息,也是线程共享的。


1.3.4 小结

进程是资源分配的基本单位,它是程序执行时的一个实例,在程序运行时创建;线程是程序执行的最小单位,是进程的一个执行流,一个线程由多个线程组成的。


1.2 线程创建与运行

Java中有三种线程创建方式,分别为实现Runnable接口的run方法,继承Thread类并重写run的方法,使用FutureTask方式。

使用继承方式的好处是方便传参,你可以在子类里面添加成员变量,通过set方法设置参数或者通过构造函数进行传递,而如果使用Runnable方式,则只能使用主线程里面被声明为final的变量。不好的地方是Java不支持多继承,如果继承了Thread类,那么子类不能再继承其他类,而Runable则没有这个限制。前两种方式都没办法拿到任务的返回结果,但是Futuretask方式可以。


1.2.1 继承Thread类方式的实现

public class Test 
    // 继承Thread类并重写run方法
    static class  MyThread extends Thread
        @Override
        public void run() 
            System.out.println("继承Thread类方式的实现");
        
    

    public static void main(String[] args) 
        // 创建线程
        MyThread myThread = new MyThread();
        // 启动线程
        myThread.start();
    

如上代码中的MyThread类继承了Thread类,并重写了run()方法。在main函数里面创建了一个MyThread的实例,然后调用该实例的start方法启动了线程。需要注意的是,当创建完thread对象后该线程并没有被启动执行,直到调用了start方法后才真正启动了线程。

调用start方法后线程并没有马上执行而是处于就绪状态,这个就绪状态是指该线程已经获取了除CPU资源外的其他资源,等待获取CPU资源后才会真正处于运行状态。一旦run方法执行完毕,该线程就处于终止状态。


1.2.2 实现Runnable接口的run方法方式

使用继承方式的好处是,在run()方法内获取当前线程直接使用this就可以了,无须使用Thread.currentThread()方法;不好的地方是Java不支持多继承,如果继承了Thread类,那么就不能再继承其他类。另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码,而Runable则没有这个限制。

    // 实现Runnable接口
    static class RunableTask implements Runnable
        @Override
        public void run() 
            System.out.println("实现Runnable接口的run方法方式");
        
    

    public static void main(String[] args) 
        RunableTask myRunnable = new RunableTask();
        // 创建并启动线程
        new Thread(myRunnable).start();
        new Thread(myRunnable).start();
    

如上面代码所示,两个线程共用一个task代码逻辑。另外,RunableTask可以继承其他类。但是上面介绍的两种方式都有一个缺点,就是任务没有返回值。下面看最后一种,即使用FutureTask的方式。


1.2.3 使用FutureTask的方式


如上代码中的CallerTask类实现了Callable接口的call()方法。在main函数内首先创建了一个FutrueTask对象(构造函数为CallerTask的实例),然后使用创建的FutrueTask对象作为任务创建了一个线程并且启动它,最后通过futureTask.get()等待任务执行完毕并返回结果。


1.3 线程通知与等待

Java中的Object类是所有类的父类,其中包含通知与等待系列函数。


1.3.1 wait()函数

当一个线程调用一个共享变量的wait()方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:

  1. 其他线程调用了该共享对象的notify()或者notifyAll()方法;
  2. 其他线程调用了该线程的interrupt()方法,该线程抛出InterruptedException异常返回。

需要注意的是,如果调用wait()方法的线程没有事先获取该对象的监视器锁,则调用wait()方法时调用线程会抛出IllegalMonitorStateException异常。

一个线程如何才能获取一个共享变量的监视器锁?

  1. 执行synchronized同步代码块时,使用该共享变量作为参数。
  2. 调用该共享变量的方法,并且该方法使用了synchronized修饰。

在多线程的情况下,当多个线程执行了wait()方法后,需要其它线程执行notify()或者notifyAll()方法去唤醒,假如被阻塞的多个线程都被唤醒,但实际情况是被唤醒的线程中有一部分线程是不应该被唤醒的,那么对于这些不应该被唤醒的线程而言就是虚假唤醒

虽然虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用wait()方法进行防范。退出循环的条件是满足了唤醒该线程的条件。

下面代码是经典的调用共享变量wait()方法的实例,首先通过同步块获取obj上面的监视器锁,然后在while循环内调用obj的wait()方法。

当前线程调用共享变量的wait()方法后只会释放当前共享变量上的锁,如果当前线程还持有其他共享变量的锁,则这些锁是不会被释放的。


1.3.2 wait(long timeout)函数

该方法相比wait()方法多了一个超时参数,它的不同之处在于,如果一个线程调用共享对象的该方法挂起后,没有在指定的timeout ms时间内被其他线程调用该共享变量的notify()或者notifyAll()方法唤醒,那么该函数还是会因为超时而返回。如果将timeout设置为0则和wait方法效果一样,因为在wait方法内部就是调用了wait(0)。需要注意的是,如果在调用该函数时,传递了一个负的timeout则会抛出IllegalArgumentException异常。


1.3.3 wait(long timeout, int nanos) 函数

在其内部调用的是wait(long timeout)函数,如下代码只有在nanos>0时才使参数timeout递增1,所以nanos参数存在的意义并不明显。显然,相比于wait(long timeout)函数来说,这个函数毫无亮点。


1.3.4 notify() 函数

一个线程调用共享对象的notify()方法后,会唤醒一个在该共享变量上调用wait系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的

此外,被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回,也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。

wait系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的notify()方法,否则会抛出IllegalMonitorStateException异常。


1.3.5 notifyAll() 函数

不同于在共享变量上调用notify()函数会唤醒被阻塞到该共享变量上的一个线程,notifyAll()方法则会唤醒所有在该共享变量上由于调用wait系列方法而被挂起的线程。


1.4 等待线程执行终止的join方法

在项目实践中经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理。Thread类中有一个join方法就可以做这个事情,前面介绍的等待通知方法是Object类中的方法,而join方法则是Thread类直接提供的。join是无参且返回值为void的方法。下面来看一个简单的例子。

    public static void main(String[] args) throws Exception 
        Thread thread = new Thread(new Runnable() 
            @Override
            public void run() 
                for (int i = 0; i < 5; i++) 
                    System.out.println("任务"+i+"执行成功");
                    try 
                        Thread.sleep(100);
                     catch (InterruptedException e) 
                        e.printStackTrace();
                    
                
                System.out.println("全部任务执行完毕\\n");
            
        );
        thread.start();
        // 等待thread线程执行完全部任务
        thread.join();
        System.out.println("你可总算是执行完毕了.");
    

运行结果:


1.5 让线程睡眠的sleep方法

Thread类中有一个静态的sleep方法,当一个执行中的线程调用了Thread的sleep方法后,调用线程会暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与CPU的调度,获取到CPU资源后就可以继续运行了。如果在睡眠期间其他线程调用了该线程的interrupt()方法中断了该线程,则该线程会在调用sleep方法的地方抛出InterruptedException异常而返回。

示例代码:

    public static void main(String[] args) throws InterruptedException 
        System.out.println(getTime());
        Thread.sleep(5000);
        System.out.println(getTime());
    

    public static String getTime() 
        Date nowTime = new Date();
        return "[" + nowTime.getHours() + ":" + nowTime.getMinutes() + ":" + nowTime.getSeconds() + "]";
    

运行结果:


1.6 让出CPU执行权的yield方法

Thread类中有一个静态的yield方法,当一个线程调用yield方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU使用,但是线程调度器可以无条件忽略这个暗示。我们知道操作系统是为每个线程分配一个时间片来占有CPU的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了Thread类的静态方法yield时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。

当一个线程调用yield方法时,当前线程会让出CPU使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出CPU的那个线程来获取CPU执行权。

总结:sleep与yield方法的区别在于,当线程调用sleep方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用yield方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。


1.7 线程中断

Java中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。

  • void interrupt()方法 :中断线程,例如,当线程A运行时,线程B可以调用线程A的interrupt()方法来设置线程A的中断标志为true并立即返回。设置标志仅仅是设置标志,线程A实际并没有被中断,它会继续往下执行。如果线程A因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,这时候若线程B调用线程A的interrupt()方法,线程A会在调用这些方法的地方抛出InterruptedException异常而返回。

  • boolean isInterrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false。(不清除中断标志)

  • boolean interrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false。与isInterrupted不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是static方法,可以通过Thread类直接调用。另外从下面的代码可以知道,在interrupted()内部是获取当前调用线程的中断标志而不是调用interrupted()方法的实例对象的中断标志。

下面看一个线程使用Interrupted优雅退出的经典例子,代码如下:


1.8 线程上下文切换

在多线程编程中,线程个数一般都大于CPU个数,而每个CPU同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用CPU执行任务。当前线程使用完时间片后,就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程。那么就有一个问题,让出CPU的线程等下次轮到自己占有CPU时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场,当再次执行时根据保存的执行现场信息恢复执行现场。

线程上下文切换时机有:当前线程的CPU时间片使用完处于就绪状态时,当前线程被其他线程中断时。


1.9 线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

死锁的产生必须具备以下四个条件

  1. 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
  2. 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
  3. 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
  4. 环路等待条件(循环等待条件):指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合T0, T1, T2, …, Tn中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待已被T0占用的资源。

避免死锁的方法:至少破坏一个产生死锁的必要条件。

  • 破坏互斥条件:这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

  • 破坏请求与保持条件:一次性申请所有的资源。

  • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

  • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。


1.10 守护线程与用户线程

Java中的线程分为两类,分别为daemon线程(守护线程)和user线程(用户线程)。在JVM启动时会调用main函数,main函数所在的线程就是一个用户线程,其实在JVM内部同时还启动了好多守护线程,比如垃圾回收线程。那么守护线程和用户线程有什么区别呢?区别之一是当最后一个非守护线程结束时,JVM会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响JVM的退出。言外之意,只要有一个用户线程还没结束,正常情况下JVM就不会退出。


1.11 ThreadLocal

多线程访问同一个共享变量时特别容易出现并发问题,特别是在多个线程需要对一个共享变量进行写入时。为了保证线程安全,一般使用者在访问共享变量时需要进行适当的同步。
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。创建一个ThreadLocal变量后,每个线程都会复制一个变量到自己的本地内存。


1.11.1 示例代码

本例开启了两个线程,在每个线程内部都设置了本地变量的值,然后调用print函数打印当前本地变量的值。如果打印后调用了本地变量的remove方法,则会删除本地内存中的该变量,代码如下:

public class ThreadLocalTest 
    // (1) print函数
    static void print(String str) 
        // 打印当前线程本地内存中localVariable的值
        System.out.println(str + " : " + localVariable.get());
        // 1.2 清除当前线程本地内存中localVariable变量
        localVariable.remove();
    

    // (2) 创建ThreadLocal变量
    static ThreadLocal<String> localVariable = new ThreadLocal<>();

    public static void main(String[] args) 
        // (3) 创建线程one并启动
        new Thread(() -> 
            localVariable.set("本地变量1");
            print("线程one:");
            System.out.println("线程one remove后:" + localVariable.get());
        ).start();

        // (4) 创建线程two并启动
        new Thread(() -> 
            localVariable.set("本地变量2");
            print("线程two:");
            System.out.println("线程two remove后:" + localVariable.get());
        ).start();
    

运行结果:


1.11.2 hreadLocal的实现原理

首先看一下ThreadLocal相关类的类图结构,如图所示:

由该图可知,Thread类中有一个threadLocals和一个inheritableThreadLocals,它们都是ThreadLocalMap类型的变量,而ThreadLocalMap是一个定制化的Hashmap。在默认情况下,每个线程中的这两个变量都为null,只有当前线程第一次调用ThreadLocal的set或者get方法时才会创建它们。其实每个线程的本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的threadLocals变量里面。也就是说,ThreadLocal类型的本地变量存放在具体的线程内存空间中。ThreadLocal就是一个工具壳,它通过set方法把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里面将其拿出来使用。如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的threadLocals变量里面,所以当不需要使用本地变量时可以通过调用ThreadLocal变量的remove方法,从当前线程的threadLocals里面删除该本地变量。另外,Thread里面的threadLocals为何被设计为map结构?因为每个线程可以关联多个ThreadLocal变量。

下面简单分析ThreadLocal的set、get及remove方法的实现逻辑。

void set(T value)

T get():

void remove():


1.11.3 ThreadLocal不支持继承性

首先看一个例子:

public class ThreadLocalTest 
    public static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) 
        threadLocal.set("hello world");
        new Thread(() -> System.out.println("thread: " + threadLocal.get())).start();

        System.out.println("main: " + threadLocal.get());
    

运行结果:

也就是说,同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的。根据上节的介绍,这应该是正常现象,因为在子线程thread里面调用get方法时当前线程为thread线程,而这里调用set方法设置线程变量的是main线程,两者是不同的线程,自然子线程访问时返回null。那么有没有办法让子线程能访问到父线程中的值?答案是有。


1.11.4 InheritableThreadLocal类

为了解决上节提出的问题,InheritableThreadLocal应运而生。InheritableThreadLocal继承自ThreadLocal,其提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。下面看一下InheritableThreadLocal的代码。(在Java里面new Thread创建的线程都是mian线程的子线程。因为是在main线程里面创建的其他线程,所以他们被成为子线程)

public class ThreadLocalTest 
    public static ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();

    public static void main(String[] args) 
        threadLocal.set("hello world");
        new Thread(() -> System.out.println("thread: " + threadLocal.get())).start();

        System.out.println("main: " + threadLocal.get());
    

运行结果:



以上是关于Java 并发编程 -- 并发编程线程基础(线程创建与运行线程通知与等待join / sleep / yield方法线程中断线程上下文切换死锁守护线程与用户线程ThreadLocal)的主要内容,如果未能解决你的问题,请参考以下文章

4.java并发编程艺术-java并发编程基础

Java并发编程系列之二线程基础

并发编程总结——java线程基础1

Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)

Java 并发编程 -- 并发编程线程基础(线程安全问题可见性问题synchronized / volatile 关键字CASUnsafe指令重排序伪共享Java锁的概述)

Java并发编程的艺术读书笔记——Java并发编程基础