java多线程(二):线程

144 阅读20分钟

线程简介

线程是依附于进程的,进程是分配资源的最小单位,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。现代操作系统调度的最小单元是线程,也叫轻量级进程(LightWeight Process),

多线程并发环境下,本质上要解决地是这两个问题:

  1. 线程之间如何通信;
  2. 线程之间如何同步;

进程与线程的区别

线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—Weight Process)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。

  • 根本区别: 进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
  • 资源开销: 每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
  • 包含关系: 如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
  • 内存分配: 同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
  • 影响关系: 一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
  • 执行过程: 每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

线程优先级

现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需要多或者少分配一些处理器资源的线程属性。在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int) 方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多于优先级低的线程设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。 在不同的JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。

线程的状态

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。Java线程在运行的生命周期中可能处于下表所示的6种不同的状态,在给定的一个时刻,线程只能处于其中的一个状态。 image.png

image.png

新建(New)状态

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时的线程情况如下:

  • 此时JVM为其分配内存,并初始化其成员变量的值;
  • 此时线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体;

就绪(Ready)状态

当线程对象调用了start()方法之后,该线程处于就绪状态。此时的线程情况如下:

  • 此时JVM会为其创建方法调用栈和程序计数器;
  • 该状态的线程一直处于线程就绪队列(尽管是采用队列形式,事实上,把它称为可运行池而不是可运行队列。因为CPU的调度不一定是按照先进先出的顺序来调度的),线程并没有开始运行;
  • 此时线程等待系统为其分配CPU时间片,并不是说执行了start()方法就立即执行; 调用start()方法与run()方法,对比如下:
  • 调用start()方法来启动线程,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体;
  • 需要指出的是,调用了线程的run()方法之后,该线程已经不再处于新建状态,不要再次调用线程对象的start()方法。只能对处于新建状态的线程调用start()方法,否则将引发IllegaIThreadStateExccption异常; 如何让子线程调用start()方法之后立即执行而非"等待执行": 程序可以使用Thread.sleep(1) 来让当前运行的线程(主线程)睡眠1毫秒,1毫秒就够了,因为在这1毫秒内CPU不会空闲,它会去执行另一个处于就绪状态的线程,这样就可以让子线程立即开始执行;

运行(Running)状态

当CPU开始调度处于就绪状态的线程时,此时线程获得了CPU时间片才得以真正开始执行run()方法的线程执行体,则该线程处于 运行状态。

  • 如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态;
  • 如果在一个多处理器的机器上,将会有多个线程并行执行,处于运行状态;
  • 当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象; 处于运行状态的线程最为复杂,它不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就执行结束了),线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。线程状态可能会变为 阻塞状态、就绪状态和死亡状态。比如:
  • 对于采用 抢占式策略 的系统而言,系统会给每个可执行的线程分配一个时间片来处理任务;当该时间片用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。线程就会又 从运行状态变为就绪状态,重新等待系统分配资源;
  • 对于采用协作式策略 的系统而言,只有当一个线程调用了它的yield()方法后才会放弃所占用的资源—也就是必须由该线程主动放弃所占用的资源,线程就会又 从运行状态变为就绪状态。

阻塞(Blocked)状态

处于运行状态的线程在某些情况下,让出CPU并暂时停止自己的运行,进入 阻塞状态

当发生如下情况时,线程将会进入阻塞状态:

  • 线程调用sleep()方法,主动放弃所占用的处理器资源,暂时进入中断状态(不会释放持有的对象锁),时间到后等待系统分配CPU继续执行;
  • 线程调用一个阻塞式IO方法,在该方法返回之前,该线程被阻塞;
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有;
  • 程序调用了线程的suspend方法将线程挂起;
  • 线程调用wait,等待notify/notifyAll唤醒时(会释放持有的对象锁); 阻塞状态分类:
  • 等待阻塞:运行状态中的 线程执行wait()方法,使本线程进入到等待阻塞状态;
  • 同步阻塞:线程在 获取synchronized同步锁失败(因为锁被其它线程占用),它会进入到同步阻塞状态;
  • 其他阻塞:通过调用线程的 sleep()或join()或发出I/O请求 时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕 时,线程重新转入就绪状态; 在阻塞状态的线程只能进入就绪状态,无法直接进入运行状态法直接进入运行状态。而就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定。当处于就绪状态的线程获得处理器资源时,该线程进入运行状态;当处于运行状态的线程失去处理器资源时,该线程进入就绪状态。但有一个方法例外,调用yield()方法可以让运行状态的线程转入就绪状态。

等待(WAITING)状态

线程处于无限制等待状态,等待一个特殊的事件来重新唤醒,如:

  • 通过wait()方法进行等待的线程等待一个notify()或者notifyAll()方法;
  • 通过join()方法进行等待的线程等待目标线程运行结束而唤醒; 以上两种一旦通过相关事件唤醒线程,线程就进入了 就绪(RUNNABLE)状态 继续运行。

时限等待(TIMED_WAITING)状态

线程进入了一个 时限等待状态,如:sleep(3000),等待3秒后线程重新进行 就绪(RUNNABLE)状态 继续运行。

终止(TERMINATED)状态

线程执行完毕后,进入终止(TERMINATED)状态。

守护线程

守护线程和用户线程有什么区别呢?

  • 用户 (User) 线程:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程
  • 守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作 main 函数所在的线程就是一个用户线程啊,main 函数启动的同时在 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程。

比较明显的区别之一是用户线程结束,JVM 退出,不管这个时候有没有守护线程运行。而守护线程不会影响 JVM 的退出。

注意事项:

  • setDaemon(true)必须在start()方法前执行,否则会抛出 IllegalThreadStateException 异常
  • 在守护线程中产生的新线程也是守护线程
  • 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑
  • 守护 (Daemon) 线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为我们上面也说过了一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作,所以守护 (Daemon) 线程中的 finally 语句块可能无法被执行。 守护线程与普通线程写法上基本没啥区别,调用线程对象的方法setDaemon(true),则可以将其设置为守护线程。 守护线程使用的情况较少,但并非无用,举例来说,JVM的垃圾回收、内存管理等线程都是守护线程。还有就是在做数据库应用时候,使用的数据库连接池,连接池本身也包含着很多后台线程,监控连接个数、超时时间、状态等等。

线程创建

创建线程有四种方式:

  • 继承 Thread 类;
  • 实现 Runnable 接口;
  • 实现 Callable 接口;

继承 Thread 类

步骤

  1. 定义一个Thread类的子类,重写run方法,将相关逻辑实现,run()方法就是线程要执行的业务逻辑方法
  2. 创建自定义的线程子类对象
  3. 调用子类实例的start()方法来启动线程
public class MyThread extends Thread {
 
    private String name;
 
    public MyThread(String name){
        this.name = name;
    }

    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(name+"掘金"+i+"%");
        }
    }

public class ThreadTest {
 
    public static void main(String[] args) {
        //创建一个线程的对象
        MyThread mt = new MyThread("抱枕");
        //启动一个线程
        mt.start();
        //创建一个线程的对象
        MyThread mt1 = new MyThread("保温杯");
        //启动一个线程
        mt1.start();
    }
}

实现 Runnable 接口

步骤

  1. 定义Runnable接口实现类MyRunnable,并重写run()方法
  2. 创建MyRunnable实例myRunnable,以myRunnable作为target创建Thead对象,该Thread对象才是真正的线程对象
  3. 调用线程对象的start()方法
public class JueJin implements Runnable {
 
    private String name;
 
    public JueJin(String name) {
        this.name = name;
    }
 
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            System.out.println(name+"掘金"+i+"%");
        }
    }
    
    public class ThreadTest {
 
    public static void main(String[] args) {
       
        Thread t = new Thread(new JueJin("抱枕"));
        Thread t1 = new Thread(new JueJin("保温杯"));
        t.start();
        t1.start();
    }

实现 Callable 接口

步骤

  1. 创建实现Callable接口的类myCallable
  2. 以myCallable为参数创建FutureTask对象
  3. 将FutureTask作为参数创建Thread对象
  4. 调用线程对象的start()方法
public class CreateThreadTest {
    public static void main(String[] args) {
        CallableTest callableTest = new CallableTest();
        FutureTask<Integer> futureTask = new FutureTask<>(callableTest);
        new Thread(futureTask).start();
        try {
            System.out.println("子线程的返回值: " + futureTask.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

class CallableTest implements Callable{

    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int i = 1; i < 101; i++) {
            sum += i;
        }
        System.out.println(Thread.currentThread().getName() + " is running: " + sum);
        return sum;
    }
}

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

实现Runnable/Callable接口相比继承Thread类的优势

(1)适合多个线程进行资源共享

(2)可以避免java中单继承的限制

(3)增加程序的健壮性,代码和数据独立

(4)线程池只能放入Runable或Callable接口实现类,不能直接放入继承Thread的类

Callable和Runnable的区别

(1) Callable重写的是call()方法,Runnable重写的方法是run()方法

(2) call()方法执行后可以有返回值,run()方法没有返回值

(3) call()方法可以抛出异常,run()方法不可以

(4) 运行Callable任务可以拿到一个Future对象,表示异步计算的结果 。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果

线程间通信

为什么需要线程通信

线程是操作系统调度的最小单位,有自己的栈空间,可以按照既定的代码逐步的执行,但是如果每个线程间都孤立的运行,那就会造资源浪费。所以在现实中,我们需要这些线程间可以按照指定的规则共同完成一件任务,所以这些线程之间就需要互相协调,这个过程被称为线程的通信。

线程的通信可以被定义为:

线程通信就是当多个线程共同操作共享的资源时,互相告知自己的状态以避免资源争夺。

线程通信的方式

线程通信主要可以分为三种方式,分别为共享内存消息传递管道流。每种方式有不同的方法来实现

  • 共享内存:线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来隐式通信。

    > volatile共享内存
    
  • 消息传递:线程之间没有公共的状态,线程之间必须通过明确的发送信息来显示的进行通信。

    wait/notify等待通知方式

    join方式

  • 管道流

    管道输入/输出流的形式

共享内存

在Java内存模型中,为了提示程序的运行速度,Java将内存分为了工作内存(线程独占,不与其他线程共享)与主内存。当多个线程同时访问同一个对象或者变量的时候,由于每个线程都需要将该对象或变量拷贝到自己的工作内存中。又因为线程的工作内存是私有且不与其他线程共享的。那么当一线程修改变量的值后,会导致对其他线程不可见。 如果线程A要和线程B通信,则需要经过以下步骤

①线程A把本地内存A更新过的共享变量刷新到主内存中
②线程B到内存中去读取线程A之前已更新过的共享变量。

这保证了线程间的通信必须经过主内存。 为了保证数据的可见性。Java提供了volatile 关键字。volatile关键字修饰变量,就是告知线程对该变量的访问必须重主内存中获取。

volatile有一个关键的特性:保证内存可见性,即多个线程访问内存中的同一个被volatile关键字修饰的变量时,当某一个线程修改完该变量后,需要先将这个最新修改的值写回到主内存,从而保证下一个读取该变量的线程取得的就是主内存中该数据的最新值,这样就保证线程之间的透明性,便于线程通信。

volatile的使用

举个例子,定义一个表示程序是否运行的成员变量boolean on=true,那么另一个线程可能对它执行关闭动作(on=false),这里涉及多个线程对变量的访问,因此需要将其定义成为volatile boolean on=true,这样其他线程对它进行改变时,可以让所有线程感知到变化,因为所有对on变量的访问和修改都需要以共享内存为准。但是,过多地使用volatile是不必要的,因为它会降低程序执行的效率。

Java并发关键字解析-volatile

消息传递

wait/notify等待通知方式

一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者,这种模式隔离了“做什么”(what)和“怎么做”(How),在功能层面上实现了解耦,体系结构上具备了良好的伸缩性,但是在Java语言中如何实现类似的功能呢?

等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

image.png

public class WaitNotify {
    static boolean flag=true;
    static Object lock=new Object();
    public static void main(String[] args) throws InterruptedException {
        Thread waitThread=new Thread(new WaitThread(),"WaitThread");
        waitThread.start();
        TimeUnit.SECONDS.sleep(1);
        Thread notifyThread=new Thread(new NotifyThread(),"NotifyThread");
        notifyThread.start();
    }
    //等待线程
    static class WaitThread implements Runnable{
        public void run() {
            //加锁
            synchronized (lock){
                //条件不满足时,继续等待,同时释放lock锁
                while (flag){
                    System.out.println("flag为true,不满足条件,继续等待");
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                //条件满足
                System.out.println("flag为false,我要从wait状态返回继续执行了");
            }
        }
    }
    //通知线程
    static class NotifyThread implements Runnable{
        public void run() {
            //加锁
            synchronized (lock){
                //获取lock锁,然后进行通知,但不会立即释放lock锁,需要该线程执行完毕
                lock.notifyAll();
                System.out.println("设置flag为false,我发出通知了,但是我不会立马释放锁");
                flag=false;
            }
        }
    }
}

执行结果如下

image.png WaitThread首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁并进入了对象的等待队列WaitQueue中,进入等待状态。由于WaitThread释放了对象的锁,NotifyThread随后获取了对象的锁,并调用对象的notify()方法,将WaitThread从WaitQueue移到SynchronizedQueue中,此时WaitThread的状态变为阻塞状态。NotifyThread释放了锁之后,WaitThread再次获取到锁并从wait()方法返回继续执行。

调用wait()、notify()以及notifyAll()时需要注意的细节,如下。

  • 使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
  • 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
  • notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或notifAll()的线程释放锁之后,等待线程才有机会从wait()返回。
  • notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为BLOCKED。
  • 从wait()方法返回的前提是获得了调用对象的锁。 从上述细节中可以看到,等待/通知机制依托于同步机制,其目的就是确保等待线程从wait()方法返回时能够感知到通知线程对变量做出的修改。
等待/通知的经典范式

从上文中WaitNotify示例中可以提炼出等待/通知的经典范式,该范式分为两部分,分别针对等待方(消费者)和通知方(生产者)。 等待方遵循如下原则

  1. 获取对象的锁。
  2. 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
  3. 条件满足则执行对应的逻辑。 对应的伪代码如下。
synchronized(对象){
    while(条件不满足){
        对象.wait()
    }
    对应的处理逻辑
}

通知方遵循如下原则。

  1. 获得对象的锁。
  2. 改变条件。
  3. 通知所有等待在对象上的线程。 对应的伪代码如下。
synchronized(对象){
    改变条件
    对象.notifyAll
}

join方式

在很多应用场景中存在这样一种情况,主线程创建并启动子线程后,如果子线程要进行很耗时的计算,那么主线程将比子线程先结束,但是主线程需要子线程的计算的结果来进行自己下一步的计算,这时主线程就需要等待子线程,java中提供可join()方法解决这个问题。

如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(longmillis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程thread在给定的超时时间里没有终止,那么将会从该超时方法中返回。

public class Join {
    public static void main(String[] args) throws Exception {
        Thread previous = Thread.currentThread();
        for (int i = 0; i < 10; i++) { // 每个线程拥有前一个线程的引用,需要等待前一个线程终止,才能从等待中返回
            Thread thread = new Thread(new Domino(previous), String.valueOf(i));
            thread.start();
            previous = thread;
        }
        TimeUnit.SECONDS.sleep(5);
        System.out.println(Thread.currentThread().getName() + " terminate-");
    }

    static class Domino implements Runnable {
        private Thread thread;
        public Domino(Thread thread) {
            this.thread = thread;
        }
        public void run() {
            try {
                thread.join();
            } catch (InterruptedException e) {
            }
            System.out.println(Thread.currentThread().getName() + " terminate.");
        }
    }
}

运行结果

image.png 从上述输出可以看到,每个线程终止的前提是前驱线程的终止,每个线程等待前驱线程终止后,才从join()方法返回,这里涉及了等待/通知机制(等待前驱线程结束,接收前驱线程结 束通知)

join源码

// 加锁当前线程对象
public final synchronized void join() throws InterruptedException {
// 条件不满足,继续等待
    while (isAlive()) {
        wait(0);
    }
// 条件符合,方法返回
}

当线程终止时,会调用线程自身的notifyAll()方法,会通知所有等待在该线程对象上的线程。可以看到join()方法的逻辑结构与上中描述的等待/通知经典范式一致,即加锁、循环和处理逻辑3个步骤 管道流是是一种使用比较少的线程间通信方式,管道输入/输出流和普通文件输入/输出流或者网络输出/输出流不同之处在于,它主要用于线程之间的数据传输,传输的媒介为管道。

管道输入/输出流

管道输入/输出流和普通文件输入/输出流或者网络输出/输出流不同之处在于,它主要用于线程之间的数据传输,传输的媒介为管道。

管道输入/输出流主要包括4种具体的实现:PipedOutputStrean、PipedInputStrean、PipedReader和PipedWriter,前两种面向字节,后两种面向字符。

线程常用方法分析

sleep() 和 wait() 有什么区别?

两者都可以暂停线程的执行

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。

参考

java线程基础详解 - 掘金 (juejin.cn)

线程间的通信方式_LallanaLee的博客-CSDN博客_线程间的通信方式