多线程基础

多线程基础


(1)单线程:一旦前面的车阻塞了,后面的车就只能一直等待。

(2)多线程:其中一条车道堵塞了,不影响其他车道。

线程的概述

​ 线程是进程中的一个小的执行单位,线程是不能脱离进程单独存在的,一个进程中可以有一个或多个线程。

进程

​ 几乎所有的操作系统都支持进程,但一个程序进入内存运行时,就启动了一个进程,即进程是处于运行过程的程序。每一个进程都具有一定的独立功能,操作系统会给每一个进程分配独立的内存等资源,即进程是操作系统资源分配、调度和管理的最小单位

三大特性

​ (1)独立性:进程操作系统进行资源分配和调度的一个独立单位,每个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间哪怕在同一台计算机上运行,进程之间的通信也需要通过网络或独立于进程的文件等来交换数据。

​ (2)动态性:程序只是一个静态指令的集合,而进程是一个正在系统中运行的活动指令的集合。进程中加入了时间的概念,进程具有自己的生命周期和各种不同状态,这些概念在程序中都是不具备的

​ (3)并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会相互影响。

现在的硬件和操作系统都已经能够支持多进程并行(parallel)或并发(concurrency)执行了。进程的并行和并发是两个概念。

​ (1)并行是指在同一时刻,有多条指令在多个处理器上同时执行。

​ (2)并发是指在同一个时刻只能有一条指令执行,但多个进程的指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。

​ 不同的硬件和操作系统的并发实现细节也各不相同,目前大多数采用效率更高的抢占式多任务策略。对于一个CPU而言,它在某个时间点上只能执行一个程序,也就是只能运行一个进程,CPU不断地在这些进程之间轮换执行。那么,我们为什么可以一边用开发工具写

程序,一边听音乐,还能一边上网查资料呢?这是因为CPU的执行速度相对于我们的感知速度来说实在是太快了,所以我们会感觉像是在同时运行一样。但是当我们启动足够多的程序时,依然可以感觉到运行速度是下降的。
如果多个进程同时运行,就会有以下几种情况发生

​ (1)第一种情况是多个进程之间完全独立,互不影响

​ (2)第二种情况是多个进程之间具有竞争关系。

​ (3)第三种情况是多个进程之间需要相互协作来完成任务。
对于某些资源来说,在同一时间只能被一个进程占用,这些一次只能被一个进程占用的资源就是临界资源。当多个进程都要访问临界资源时,它们就构成了竞争的互斥关系,如进程B需要访问打印机,但此时进程A占有了打印机,那么进程B就会被阻塞,直到进程A释放打印机资源,进程B才可以继续执行。**而有时候为完成某种任务,多个进程在某些位置上需要通过互相发送消息、互相合作、互相等待等来协调它们的工作次序,这种直接制约关系,称为同步。**例如,输入进程A通过单缓冲向进程B提供数据,当该缓冲区空时,进程B不能获得所需数据而被阻塞,一旦进程A将数据送入缓冲区,进程B就会被唤醒,反之,当缓冲区满时,进程A被阻塞,仅当进程B取走缓冲数据时,才会唤醒进程A。

线程

​ 多线程扩展了多进程的概念,使得一个进程可以同时并发处理多个任务**,线程也被称为轻量级进程**。就像进程在操作系统中的地位一样,线程在进程中也是独立的、并发的执行流。当进程被初始化后,主线程就被创建了,对于 Java 程序来说,main 线程就是主线程,我们可以在该进程中创建多条顺序执行路径,这些独立的执行路径都是线程。
​ 进程中的每个线程可以完成一定的任务,并且是独立的,线程可以拥有自己独立的堆栈、程序计数器和局部变量,但不再拥有系统资源,它与父进程的其他线程共享该进程所拥有的系统资源。

​ 线程是抢占式的,当前运行的线程在任何时候都可能被挂起,以便另一个线程可以运行。CPU可以在不同的进程间轮换,进程又在不同的线程间轮换,因此线程是CPU执行和调度的最小单元

线程的创建和启动

​ 在java中我们可以通过java.lange.Thread类来实现多线程。所有线程对象必须是Thread类或其子类对象。每个线程的作用是完成一定的任务,实际上就是执行一段代码,称之为线程执行体。java使用run方法来封装这段代码,即run方法的方法体就是线程的执行体。

继承Thread类

(1)定义继承Thread的子类,并重写该类的run方法。

(2)创建Thread子类的实例对象,一个实例对象就是一个线程对象。

(3)调用线程的start方法来启动线程,如果没有start方法启动,那么这个线程对象和普通的java对象没什么区别。

案例:在主线程中打印5 ~ 1的数字,另外两个线程打印1 ~ 5的数字,并实现3个线程同时运行

image-20230723134749715

解析:Java SE 的程序至少有一个 main 线程,它的方法体就是线程体
getName()方法是Thread 类的实例方法,该方法返回当前线程对象的名称,除了 main 线程的名称,其他线程的名称依次默认为Thread-0、Thread-1等,可以通过 setName(String name)方法设置线程名称。因为MyThread 类继承了Thread类。所以可以直接调用getName()方法获取线程名称。而main方法是静态方法,因此无法像MyThread类那样直接调用 getName()方法获取线程名称,需要通过Thread类的静态方法currentThread()方法先获取当前执行线程对象,后调用getName()方法获取线程名称。
Thread-0 和Thread-1线程虽然都是MyThread类的线程对象,但是**各自调用各自的run()方法,**相互之间是独立的,因此各打印1~5。
启动线程用start()方法,而不是run()方法。调用start()方法来启动线程,系统会把run()方法当成线程执行体来处理。但是如果直接调用run()方法,系统就会把线程对象当成一个普通对象处理,run()方法就是一个普通方法。

实现Runnable接口

(1)定义Runnable 接口的实现类,并重写该接口的run()方法。

(2)创建Runnable 接口实现类的对象。

(3)创建Thread类的对象,并将 Runnable 接口实现类的对象作为target。该Thread类的对象才是真正的线程对象。当JVM 调用线程对象的run()方法时,如果 target不为空,那么就会调用target 的run()方法。

(4)调用线程对象的start方法启动线程

案例:在主线程中打印5 ~ 1的数字,另外两个线程打印1 ~ 5的数字,并实现3个线程同时运行

image-20230723140020997

两者区别

​ 实现Runnable接口的方式,无疑比继承Thread类的方式更加灵活,避免了单继承的局限性。另外在处理共享资源的情况时,实现Runnable接口的方式更容易实现资源的共享。

案例需求:

使用多线程模拟3个售票窗口,共售100张票

案例分析:

3个线程的任务都是一样的,因此只需要定义一个线程类编写任务体,然后创建3个线程对象即可。

示例代码:

(1)使用继承Thread类的方式实现。

image-20230723141410928

运行上述代码时我们可以发现三个售票窗口都独享100张票。原因是创建了三个SellTicketThread对象,而每次创建对象都将 tickets 的值初始化为100,因此每个线程对象就相当于独享100张票。当然有读者说可以将tickets变量声明为static静态变量来实现共享,但是如果这样操作,就会导致所有的SellTicketThread 对象共享同一个 tickets 变量的值,无法实现这几个SellTicketThread的对象共享一个tickets 变量的值,另外SellTicketThread的对象共享另一个 tickets 变量的值。

(2)使用实现Runnable方式

image-20230723142848772

运行上述代码时我们可以发现,它很好的实现了3个售票窗口共享100张票。因为我们从头到尾只创建一个SellTicketRunnable对象,3个线程共享同一个SellTicketRunnable对象.

案例总结:

  • 实现Runnable接口的方式,有效的避免了单继承的局限性。
  • 实现Runnable接口的方式,更合适处理有共享资源的情况。

线程的生命周期

(1)新建
当一个Thread类或其子类的对象被创建时,新生的线程对象就处于新建状态。此时它和其他Java对象一样,仅由JVM 为其分配了内存,并初始化了实例变量的值。此时的线程对象并没有任何线程的动态特征,程序也不会执行它的线程体run()方法。
(2)就绪
但是当线程对象调用了start()方法后,就不一样了,线程就从新建状态转换为就绪状态。一旦线程启动之后,JVM 就会为其分配 run()方法调用栈和分配程序计数器等。当然,处于这个状态中的线程并没有开始运行,只是表示已经具备了运行的条件,随时可以被调度,至于什么时候被调度,取决于JVM中线程调度器的调度。
程序只能对新建状态的线程调用start()方法,并且只能调用一次,如果对非新建状态的线程调用,**如已启动的线程或已死亡的线程调用 start()方法,则都会报错。**当start()方法返回后,线程就会处于就绪状态。
(3)运行。
**如果处于就绪状态的线程获得了CPU,开始执行 run()方法的线程体代码,则该线程就处于运行状态。**如果计算机只有一个CPU,那么在任何时刻就只有一个线程处于运行状态。如果计算机有多个处理器,那么就会有多个线程并行执行。
对于抢占式策略的系统而言,CPU 讲究雨露均沾,CPU 只给每个可执行的线程一个小时间段来处理任务,该时间段用完后,系统就会剥夺该线程所占用的资源,让其回到就绪状态等待下一次被调度。此时其他线程将获得执行机会,在选择下一个线程时,系统会适当考虑线程的优先级。
(4)阻塞。
当在运行过程中的线程遇到如下几种情况时,线程就会进入阻塞状态。

  • 线程调用了sleep()方法,主动放弃所占用的CPU资源。
  • 线程调用了阻塞式IO方法,在该方法返回之前,该线程被阻塞。
  • 线程试图获取一个同步监视器,但该同步监视器正被其他线程持有。
  • 在线程执行过程中,同步监视器调用了wait()方法,让它等待某个通知。
  • 在线程执行过程中,遇到了其他线程对象的加塞。
  • 线程被调用suspend方法挂起(已过时,因为容易发生死锁)。

当前正在执行的线程被阻塞后,其他线程就有机会执行了。针对上述几种情况,当发生如下几种情况时就会解除阻塞,让该线程重新进入就绪状态,等待线程调度器再次调度。

  • 线程的sleep()时间到。
  • 线程调用的阻塞式I0方法已经返回。
  • 线程成功获得了同步监视器。
  • 线程等到了通知。
  • 加塞的线程结束了
  • 被挂起的线程又被调用了resume方法(已过时,因为容易发生死锁)。

(5)死亡。
线程会以以下三种方式之一结束,结束后的线程就处于死亡状态。

  • run()方法执行完成,线程正常结束。
  • 线程执行过程中抛出了一个未捕获的异常或错误。
  • 直接调用该线程的stop()来结束该线程(已过时,因为容易发生死锁)。

可以调用线程的isAlive()方法判断该线程是否死亡,当线程处于就绪、运行、阻塞这三种状态时,该方法返回“true”,当线程处于新建、死亡这两种状态时,该方法返回“false”。

​ 在JDK1.5之后,Thread类中增加了一个内部枚举类State,明确定义了线程的生命周期状态:

image-20230723145259458

首先,它没有区分就绪和运行状态,因为对于Java对象来说,只能标记为可运行,至于什么时候运行,就不是JVM来控制的了,是操作系统来调度的,而且时间非常短,因此对于Java对象的状态来说,就不区分了。
其次,根据Thread.State的定义,阻塞状态分为三种:BLOCKED(阻塞)、WAITING(不限时等待)、TIMED_WAITING(限时等待)

  1. BLOCKED:互相有竞争关系的几个线程,当其中一个线程占有锁对象时,其他线程只能等待锁。只有获得锁对象的线程才能有执行机会。
  2. TIMED_WAITING:当前线程执行过程中遇到Thread 类的sleep方法或join方法,Object类的wait方法,LockSupport类的park方法,并且在调用这些方法时,设置了时间,那么当前线程就会进入TIMED_WAITING,直到等待时间到,或者被中断。
  3. WAITING:当前线程执行过程中遇到Object 类的wait方法,Thread类的join 方法,LockSupport类的park方法,并且在调用这些方法时,没有指定时间,那么当前线程就会进入WAITING状态,直到被唤醒。
  • 通过 Object 类的wait方法进入WAITING状态的要有Object类的notify/notifyAll唤醒
  • 通过Condition类的await方法进入WAITING状态的要有Conditon类的signal方法唤醒
  • 通过LockSupport类的park方法进入WAITING状态的要有LockSupport 类的unpark方法唤醒
  • 通过Thread类的join方法进入WAITING状态,只有调用join方法的线程对象结束才能让当前线程恢复。

当从WAITING或TIMED WAITING恢复到Runnable状态时,如果发现当前线程没有得到监视器锁,那么就会立刻转入BLOCKED状态。

a5711395018308653db5af0849264a9

d6865d0399b46f925acfd8428ae4806

Thread类的方法

无论是继承Thread 类的方法还是实现Runnable 接口的方法,最终都离不开Thread类,因此熟悉Thread 类都有哪些 API 供我们使用是非常有必要的。
首先,每个线程对象被创建都离不开 Thread 类构造器。以下列出的是最常用的构造器形式。

  • Thread():创建新的Thread对象,线程名称是默认的。
  • Thread(String threadname):创建线程并手动指定线程名称。
  • Thread(Runnable target):指定创建线程的目标对象,它必须实现Runnable接口,线程名称是默认的。
  • Thread(Runnable target,String name):指定创建线程的目标对象,并手动指定线程名称
  • 每个线程任务代码的编写和线程的启动必定离不开如下两个方法。
  • public void run():子类必须重写run()方法以编写线程体。
  • public void start():启动线程。

案例需求:
用实现Runnable接口的方式启动一个线程打印1 ~ 100的偶数,用继承Thread类的方式启动一个线程打印1 ~100的奇数,两个线程同时运行。

image-20230723154811078

上述代码使用了匿名内部类的方式创建和启动线程。

获取和设置线程信息

除了上面列出的构造器和基础API,Thread 类中还有很多方法供我们使用。

例如,可以通过如下方法来获取和设置线程对象的基本信息。

  • public static Thread currentThread():静态方法,总是返回当前执行的线程对象。

  • public final boolean isAlive():测试线程是否处于活动状态,如果线程已经启动且尚未终止,则为活动状态。

  • public final String getName():Thread类的实例方法,该方法返回当前线程对象的名称。

  • public final void setName(String name):设置该线程名称。除了主线程,其他线程可以在创建时指定线程名称或通过 setName(String name)方法设置线程名称,否则名称依次为Thread-0,Thread-1等。

  • public final int getPriority():返回线程优先级。

  • public final void setPriority(int newPriority):改变线程的优先级。每个线程都有一定的优先级,优先级高的线程将获得较多的执行机会,但不代表优先级低的线程没有执行机会。每个线程默认的优先级都与创建它的父线程具有相同的优先级。如果要修改优先级,则必须设置在[1,10],否则会报错。通常推荐设置Thread 类的三个优先级常量,即最高优先级(MAX PRIORITY)、最低优先级(MIN_PRIORITY)、普通优先级(NORM PRIORITY),默认情况下主线程具有普通优先级,它们分别对应的优先级等级值为10、1、5,使用常量比使用数字的可读性更好。

    案例需求:
    使用多线程模拟两个售票窗口,共同出售100张票。两个线程分别命名为普通窗口和紧急窗口。获取主线程的优先级查看其是否是NORM PRIORITY,并且把紧急窗口线程的优先级设置为MAX PRIORITY,把普通窗口的线程优先级设置为 MIN_PRIORITY。启动线程,实现两个窗口同时售票,请观察效果。

image-20230723160127796

线程的控制

Thread类提供了以下方法,可以控制线程的执行。

  • public static void sleep(long millis) throws InterruptedException:在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器、调度程序精度和准确性的影响。
  • public static void yield():它可以让当前正在执行的线程暂停,但它不会阻塞该线程,只是将该线程转入就绪状态。
  • public final void join()throws InterruptedException:插入另一个线程之前,使另一个线程暂停直到当前线程结束后再继续执行。
  • public final void join(long millis) throws InterruptedException:插入另一个线程之前,使另一个线程暂停直到毫秒后再继续执行。
  • public final void stop():强迫线程停止执行(但是该方法已经过时不建议使用)。
  • public final void suspend():挂起当前线程(但是该方法已经过时不建议使用)。
  • public final void resume():重新开始执行挂起的线程(但是该方法已经过时不建议使用)。
  • public void interrupt():中断线程。如果线程在调用 Object 类的 wait()、wait(long)或 wait(long,int)方法,或者Thread 类的join()、join(long)、join(long,int)、sleep(long)或 sleep(long,int)方法过程中受阻,则其中断状态将被清除,它还将收到一个InterruptedException。
  • public static boolean interrupted():测试当前线程是否已经中断。线程的中断状态由该方法清除。换句话说,如果连续两次调用该方法,则第二次调用就会返回“false”。
  • public boolean isInterrupted():测试线程是否已经中断。线程的中断状态不受该方法的影响。
  • public final void setDaemon(boolean on):将指定线程设置为守护线程。必须在线程启动之前设置,否则会报 IlegalThreadStateException。
  • public final boolean isDaemon():判断线程是否是守护线程。

案列:倒计时

​ 如果需要让当前正在执行的线程暂停一段时间,则可以调用Thread类的静态方法sleep,sleep方法会使当前线程进入阻塞状态。

案例需求:

通过Thread类的sleep方法实现新年倒计时的效果.

示例代码:

image-20230723162213131

案例:线程让步

​ yield方法只是让当前线程暂停一下,让系统的线程调度器重新调度一次,希望优先级与当前线程相同或更高的其他线程能够获得执行机会,但是这种情况不能保证执行。完全有可能的情况是,当某个线程调用yield方法暂停后,线程调度器又将其调度出来重新执行。

image-20230723162805063

案例:龟兔赛跑

​ 当在某个线程的线程体中调用另一个线程的join方法时,当前线程将被阻塞,直到join进来的线程执行完(join()不限时加塞),或者阻塞一段时间后(join(millis)限时加塞),它才能继续执行。
案例需求:
​ 编写龟兔赛跑多线程程序。假设赛跑长度为30米,兔子的速度为10米每秒,兔子每跑完10米后休眠的时间为10秒;乌龟的速度为1米每秒,乌龟每跑完10米后休眠的时间为1秒。最后要等兔子和乌龟的线程结束,主线程(裁判)才能公布最后的结果。

示例代码:

跑步者(Racer)线程示例代码

package com.szy;

public class Racer implements Runnable {
    private String name; //运动员名字
    private long runTime; //每米需要时间,单位毫秒
    private long restTime; //每10米的休息时间,单位毫秒
    private long distance;//全程距离,单位米
    private long time;//跑完全程的总时间
    private boolean finished;//是否跑完全过程

    public Racer(String name, long distance, long runTime, long restTime) {
        super();
        this.name = name;
        this.distance = distance;
        this.runTime = runTime;
        this.restTime = restTime;

    }
    @Override
    public void run() {
        long sum = 0;
        long start = System.currentTimeMillis();
        while (sum < distance) {
            System.out.println(name + "正在努力奔跑...");
            try {
                Thread.sleep(runTime);//每米距离该运动员需要的时间
            } catch (InterruptedException e) {
                System.out.println(name + "出现意外");
                return;
            }
            sum++;
            try {
                if (sum % 10 == 0 && sum < distance) {
                    //每10米休息一下
                    System.out.println(name + "已经跑了" + sum + "米正在休息.....");
                    Thread.sleep(restTime);
                }
            } catch (InterruptedException e) {
                System.out.println(name + "出现意外");
                return;
            }
        }
        long end = System.currentTimeMillis();
        time = end - start;
        System.out.println(name + "跑了" + sum + "米,已达到终点,共用时" + (double) time / 1000.0 + "秒");
        finished = true;
    }

    public long getTime() {
        return time;
    }

    public boolean isFinished() {
        return finished;
    }
}

主线程(裁判)测试类代码

package com.szy;

public class RacerTest {
    public static void main(String[] args) {
        Racer rabbit = new Racer("兔子", 30, 100, 10000);
        Racer turtoise = new Racer("乌龟", 30, 1000, 1000);
        Thread 兔子 = new Thread(rabbit, "兔子");
        Thread 乌龟 = new Thread(turtoise, "乌龟");
        兔子.start();
        乌龟.start();

        try {
            兔子.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
            乌龟.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //因为要兔子和乌龟都跑完才能公布结果
        System.out.println("比赛结束");
        if (rabbit.isFinished() && turtoise.isFinished()){
            if (rabbit.getTime()<turtoise.getTime()){
                System.out.println("兔子赢了");
            }else if (rabbit.getTime()>turtoise.getTime()){
                System.out.println("乌龟赢了");
            }else {
                System.out.println("平局");
            }
        }else if (rabbit.isFinished() || turtoise.isFinished()){
            System.out.println(rabbit.isFinished() ? "兔子赢" : "乌龟赢了");
        }else{
            System.out.println("都没有完成比赛");
        }
    }
}

案例:守护线程

​ 有一种线程,它是在后台运行的,它的任务是为其他线程提供服务,这种线程称为守护线程。守护线程有个特点,就是如果所有的非守护线程都死亡,那么守护线程会自动死亡。JVM的垃圾回收线程就是典型的守护线程。
案例需求:
​ 为主线程启动一个守护线程,守护线程每1毫秒打印一句话“我是MyDaemon,我默默地守护你,只为你而存在。”,主线程打印1~10的数字,查看运行效果。

示例代码:

image-20230723173018783

案例:停止线程

​ 我们知道线程体执行完,或者遇到未捕获的异常自然就会停止。当我们希望由另一个线程检测某个情况时,就会提前停止一个线程。Thread 类提供了stop方法来停止一个线程,但是该方法具有固有的不安全性,已经标记为@Dcprecated,不建议再使用,那么我们就需要通过其他方式来停止线程,其中一种方式是使用标识。
案例需求:
​ 编写龟兔赛跑多线程程序。假设赛跑长度为30米,兔子的速度为10米每秒,兔子每跑完10米后休眠的时间为10秒;乌龟的速度为1米每秒,乌龟每跑完10米后休眠的时间为1秒。现在要求,只要兔子和乌龟中的其中一只到达终点,就宣布比赛结束,没到达终点的也要求停止。

跑步者(player)线程的代码:

package com.szy;

public class Player extends Thread{
    private String name; //运动员名字
    private long runTime; //每米需要时间,单位毫秒
    private long restTime; //每10米的休息时间,单位毫秒
    private long distance;//全程距离,单位米
    private long time;//跑完全程的总时间
    private boolean runFlag = true;//用于标记是否继续跑,即结束线程的标记
    private volatile boolean finished;//用于标记是否到达终点

    public Player(String name, long distance, long runTime, long restTime) {
        super();
        this.name = name;
        this.distance = distance;
        this.runTime = runTime;
        this.restTime = restTime;

    }
    @Override
    public void run() {
        long sum = 0;
        long start = System.currentTimeMillis();
        while (sum < distance && runFlag) {
            System.out.println(name + "正在努力奔跑...");
            try {
                Thread.sleep(runTime);//每米距离该运动员需要的时间
            } catch (InterruptedException e) {
                System.out.println(name + "出现意外");
                runFlag = false;
                break;
            }
            sum++;
            try {
                if (sum % 10 == 0 && sum < distance) {
                    //每10米休息一下
                    System.out.println(name + "已经跑了" + sum + "米正在休息.....");
                    Thread.sleep(restTime);
                }
            } catch (InterruptedException e) {
                System.out.println(name + "出现意外");
                runFlag = false;
                break;
            }
        }
        long end = System.currentTimeMillis();
        time = end - start;
        System.out.println(name + "跑了" + sum + "米,已达到终点,共用时" + (double) time / 1000.0 + "秒");
        finished = sum == distance ? true : false;
    }

    public long getTime() {
        return time;
    }
    public void setRunFlag(boolean runFlag){
        this.runFlag =runFlag;
    }
    public boolean isRunFlag(){
        return runFlag;
    }

    public boolean isFinished() {
        return finished;
    }
}

主线程示例代码:

package com.szy;

public class StopTest {
    public static void main(String[] args) {
        Player rabbit = new Player("兔子", 30, 100, 10000);
        Player turtoise = new Player("乌龟", 30, 1000, 1000);

        rabbit.start();
        turtoise.start();
        while (true){
            if (rabbit.isFinished() || turtoise.isFinished()){
                rabbit.setRunFlag(false);
                turtoise.setRunFlag(false);
                rabbit.interrupt();
                turtoise.interrupt();
                //只要有人跑完,就结束比赛并公布结果
                break;
            }else if (!rabbit.isRunFlag() && !turtoise.isRunFlag()){
                break;
            }
        }
        //因为要兔子和乌龟都跑完才能公布结果
        System.out.println("比赛结束");
        if (rabbit.isFinished() && turtoise.isFinished()){
            if (rabbit.getTime()<turtoise.getTime()){
                System.out.println("兔子赢了");
            }else if (rabbit.getTime()>turtoise.getTime()){
                System.out.println("乌龟赢了");
            }else {
                System.out.println("平局");
            }
        }else if (rabbit.isFinished() || turtoise.isFinished()){
            System.out.println(rabbit.isFinished() ? "兔子赢" : "乌龟赢了");
        }else{
            System.out.println("都没有完成比赛");
        }
    }
}

案例解析:
Java 中的每个线程都有一个独有的工作内存,每个线程不直接操作主内存中的变量,而是将主内存中变量的副本放进工作内存,只操作工作内存中的变量。当变量修改完后,再把修改后的结果放回主内存。每个线程都只能操作自己

工作内存中的变量,无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

​ 在上述代码中,如果Player 类的finished变量不加volatile修饰,那么当乌龟或兔子线程对变量finished值做了变动后,可能没有及时写回主内存,或者因为主线程频繁访问主内存中的finished变量而导致乌龟或兔子线程修改了自己工

作内存中的finished 后想要写回主内存会找不到机会,所以主线程可能访问不到最新的finished 值**。加了 volatile修饰后,可以保证当乌龟或兔子线程对变量finished值做了变动后,会立即刷回主内存中,而其他线程读取到该变量**

的值也会作废,强迫重新从主内存中读取该变量的值,这样在任何时刻,主线程总是会看到变量 finished 的最新值,这体现了volatile 能够保证内存的可见性作用。

线程的同步

​ 多线程编程是有趣且复杂的事情,它常常容易突然出现错误情况,这是因为系统的线程调度具有一定的随机性,即使程序在运行过程中只是偶尔会出现问题,那也是因为代码有问题导致的。最常见的问题就是线程安全问题。

线程安全问题

​ 当多线程操作共享资源时,共享资源出现错乱就是线程安全问题。线程安全问题都是由共享变量引起的,共享变量一般都是某个类的静态变量,或者因为多个线程使用了同一个对象的实例变量,方法的局部变量是不可能成为共享变量的。如果每个线程中对共享变量只有读操作,而无写(修改)操作,那么一般来说,这个共享变量是线程安全的。如果有多个线程同时执行写操作,那么一般都需要考虑线程安全问题。
为了更加直观地展示线程安全问题,下面通过经典的售票问题来说明线程安全的重要性。
案例需求;
​ 使用多线程模拟三个售票窗口,共同售出10张票。
示例代码:

Ticket

public class Ticket {
    private int total = 10;
    public void sale(){
        if (total > 0){
            --total;
            System.out.println(Thread.currentThread().getName()+"卖了一张票,剩余:"+total);
        }else {
            throw new RuntimeException("没有票了");
        }
    }

    public int getTotal() {
        return total;
    }
}


SaleThread
public class SaleThread extends Thread{
    private Ticket ticket;

    public SaleThread(String name,Ticket ticket){
        super();
        this.ticket = ticket;
    }

    @Override
    public void run(){
        while (true){
            try {
                Thread.sleep(100);//加入休眠时间使得问题暴露更加明显
                ticket.sale();
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
                break;
            }
        }
    }
}


SaleTicketDemo
public class SaleTicketDemo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        SaleThread t1 = new SaleThread("窗口一",ticket);
        SaleThread t2 = new SaleThread("窗口二",ticket);
        SaleThread t3 = new SaleThread("窗口三",ticket);

        t1.start();
        t2.start();
        t3.start();
    }
}

情形一:出现售出重复票的情况。

原因:CPU发生线程切换是可能在任意两个指令之间的。例如,当窗口一线程执行了“-total”后,就失去CPU执行权,接着窗口二抢到了CPU资源,它也执行了“-total”后失去了CPU执行权,巧的是线程窗口三抢到了CPU资源,它也执行了“–

total”后失去了CPU 执行权。经过三次的“–total”后,total的值是7,现在窗口一、二、三依次又抢到了CPU执行权,所以访问的total值都是7。

情形二:多卖票。3个窗口卖出的总票数超过了10张。

原因:每个线程在JVM中都有自己独立的内存空间,当多个线程在使用共享变量total时,每个线程都会在自己独立的内存空间中建立一个total的副本,每次线程对total进行访问和修改前先对这个副本进行操作,再与主内存进行同步。“-

-total”操作不是原子性的,分为该变量的值-1、获取该变量的值、将该变量的值写回主内存三个步骤。例如,上述三个线程读取完主内存中的total值为7后,会各自执行“–total”操作,然后把 total 值写回主内存导致修改值被覆盖,所以出

现了三次6,这就导致多售出了几张票。有读者会想到volatile关键字,volatile关键字可以保证主内存的可见性,但是不能保证其原子性。

同步代码块

​ 为解决线程安全问题,java提供了synchronized关键字。该关键字的作用就是对多条操作共享数据的语句加锁,被锁住的语句代码只能让一个线程执行完后,其他线程才能进入,否则在这个线程执行的过程中,其他线程不可参与执行,这个锁称为同步锁

​ java中的同步锁是通过一个对象当监视者来实现的,因此我们把同步锁又称为对象监视器,当我们使用synchronized关键字时,一定要有一个锁对象配合工作。

​ synchronized关键字的使用形式有两种:同步代码块和同步方法。

同步代码块的语法格式如下所示:

synchronized(同步监视器对象){
	//......
}

​ 上述代码的含义是线程在开始执行同步代码块之前,必须先获得对同步监视器的锁定(占有),换句话说如果没有获得对同步监视器的锁定,那么就不能进入同步代码块的执行,线程就会进入阻塞状态,直到对方释放了对同步监视器

对象的锁定。
Java 的同步锁可以是任意类型的对象。在HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。HotSpot 虚拟机的对象头(Object Header)又

包括两部分信息,第一部分用于存储对象自身运行时的数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,HotSpot 虚拟机通过这个指针来确定这个对象是哪

个类的实例。所以同步锁看起来锁的是代码,其实本质上锁的是对象,即在同步锁对象中有锁标记,能够明确知道现在是哪个线程在占用锁。任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行结束后,该线程自

然会释放对同步监视器对象的锁定。所以,我们必须保证竞争共享资源的这几个线程,选的是同一个同步监视器对象,否则无法实现同步效果。

下面使用同步代码块来解决售票案例的线程安全问题:

image-20230723211313295

结果正常了

image-20230723211356854

​ 从上述代码的运行结果中可以看出,线程安全问题被完美地解决了 Ticket类中使用了同步代码块,并且选择了this对象作为同步监视器对象,这里的this对象代表 Ticket对象本身,窗口一、窗口二、窗口三线程使用的Ticket对象是同一个对象,即同步监视器对象是 同一个对象,所以没问题。

​ 特别提示:不是所有情况都可以使用this对象作为同步监视器对象的。因为当多个线程执行 synchronized同步代码块时,this对象代表的不是同一个对象,那么它将失去监视器的作用,即不能解 决线程安全问题。

同步方法

​ Java的多线程安全措施除了支持同步代码块,还支持同步方法。同步方法就是使用synchronized 键字来修饰某个方法,该方法称为同步方法。对于同步方法而言,不需要显式指定同步监视器,因为静态方法的同步监视器对象就是当前类的Class对象,而非静方法的同步监视器对象调用的是当前方法的this对象。

image-20230723212419059

这样也能解决线程安全问题

特别提示:不要对线程安全类的所有方法都加同步,只对会影响竞争资源(共享资源)的方法进行同步即可。而且也要注意非静态同步方法的默认同步的监视器对象对于竞争资源的多个线程来说是否是同一个对象,如果不是同一个对象是起不到监视作用的。

释放锁与否的操作

任何线程进入同步代码块、同步方法之前,都必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?
(1)释放锁的操作。

  • 当前线程的同步方法、同步代码块执行结束。
  • 当前线程在同步代码块、同步方法中遇到break和return 终止了该同步代码块或同步方法的继续执行。
  • 当前线程在同步代码块、同步方法中出现了未处理的错误或异常,导致当前线程异常结束。
  • 当前线程在同步代码块、同步方法中执行了锁对象的wait()等方法,当前线程被挂起,并释放锁。

(2)不会释放锁的操作。

  • 当线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法暂停当前线程的执行。
  • 当线程执行同步代码块时,其他线程调用了该线程的 suspend()等方法将该线程挂起,该线程不会释放锁。

死锁

​ **当不同的线程分别锁住对方需要的同步监视器对象不释放,都在等待对方先放弃时,就会形成死锁。**一旦出现死锁,整个程序既不会发生异常,也不会给出任何提示,**只是所有线程处于阻塞状态,无法继续。**在实际开发中,我们要避免出现死锁问题。
案例需求:
​ 目前,大家几乎都离不开各大电商平台。但是在电商刚刚推行之初,买卖双方是互相不信任的。卖家担心发货后顾客不付款,顾客担心付款后卖家不发货,所以卖家希望顾客先付款,卖家再发货;而顾客希望卖家先发货,等顾客收到货后再付款。如果没有中间的监管平台,那么这个问题就会出现死锁。
示例代码:

image-20230723213926774

image-20230723213941132

image-20230723213953810

上述代码的运行可能发生死锁现象。如果其中的一个线程动作非常迅速,在抢到CPU资源后,一口气执行完自己的run()方法,那么就不会和对方构成竞争锁对象的关系,因此就不会出现死锁现象。
​ 当其中一个线程(如Owner 线程),进入了外部的同步代码块,占有了外部同步代码块的监视器对象(如goods)后,还未来得及进入内部同步代码块,即还未占有内部同步代码块的监视器对象(如money),那么它就失去了CPU资源。而此时另一个线程(如Customer线程),也进入了它的外部同步代码块,占有了外部同步代码块的监视器对象(如money)后,该线程想要进入内部同步代码块执行,但是要进入内部同步代码块,就要先获取同步代码块的监视器对象(如goods),可是此时该监视器对象 goods 正被Owner 线程占用并还未释放,所以Customer 线程就被阻塞了。而同时Owner 线程再次获取CPU资源后,想要尽快执行完自己的代码,但此时它的内部同步代码块的监视器对象(如money)正在被Customer 线程占用,无法获取,所以Owner 线程也被阻塞了,这就出现了死锁现象。

等待唤醒机制

​ 生产者与消费者问题(Producer-Consumer Problem)也称为有限缓冲问题(Bounded-Buffer Problem),是一个多线程同步问题的经典案例。该问题描述了两个或多个共享固定大小缓冲区的线程,即所谓的生产者与消费者在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区,然后重复此过程,与此同时,消费者也会在缓冲区消耗这些数据。该问题的关键是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区空时消耗数据。
生产者与消费者的工作模式中其实隐含了以下两个线程问题
(1)线程安全问题:因为生产者与消费者共享数据缓冲区,所以这个问题通过同步机制解决。
(2)线程协调工作问题:**这个问题需要通过等待唤醒机制来解决。**让生产者线程在缓冲区满时等待,暂停进入阻塞状态,等到下次消费者消耗了缓冲区的数据时,通知正在等待的线程恢复到就绪状态,重新开始往缓冲区添加数据。同样,也可以让消费者线程在缓冲区空时进入等待,暂停进入阻塞状态,等到生产者往缓冲区添加数据后,再通知正在等待的线程恢复到就绪状态。
​ Object 类中提供了 wait、notify、notifyA1l方法,这三个方法并不属于Thread类,是因为这三个方法必须用同步监视器对象调用,而同步监视器对象可以是任意类型的对象,所以它们只能声明在 Object 类中。如果不是同步监视器对象来调用 wait 和notify方法,则会报 IlegalMonitorStateException。

案例:初级快餐店

​ 有家快餐店的规模比较小,后厨与饭堂之间的取餐口比较小,只能放一份快餐(这里把上限定为一是故意让问题极端化,使问题暴露得更明显一些),厨师做完快餐后会放在取餐口的工作台上,服务员从取餐口的工作台取出快餐给顾客。现在该餐馆只有一个厨师和一个服务员。厨师线程相当于生产者,服务员线程相当于消费者,他们俩共享取餐口的工作台。请编写代码模拟这个工作场景。

案例分析:
首先,我们需要声明工作台这个资源类,厨师线程和服务员线程都要访问和操作工作台对象Workbench。工作台对象中需要有表示快餐数量的成员变量,并且需要设计两个方法,一个是put方法,用于实现厨师做好快餐访问工作台的需求,即快餐数量增加;另一个是take方法,用于实现服务员取走快餐访问工作台的需求,即快餐数量减少。其次,我们需要声明两个线程类,一个表示厨师线程(Cook),在其n方法中,通过工作台对象调用put方法;另一个表示服务员线程(Waiter),通过工作台对象调用take方法。这里要考虑到厨师线程和服务员线程操作的是同一个工作台,所以需要在创建厨师线程对象和服务员线程对象时,通过参数传入工作台对象。

image-20230723220958460

image-20230723221017067

image-20230723221029460

image-20230723221039330

​ 上述代码中工作台是厨师(生产者)和服务员(消费者)共同访问的资源对象,而且两个线程都有写操作,所以就会有线程安全问题。我们必须给工作台(数据缓冲区)对象的put和take方法都加同步(synchronized)处理。
另外,**因为工作台(数据缓冲区)是有上限(MAX_VALUE=1)和下限(0)的,所以需要借助wait和notify方法实现线程通信。**当num达到上限时,厨师(生产者)线程就必须wait,当num达到下限时,服务员(消费者)线程就必须wait。**厨师做(生产)了新的菜(数据)之后,就可以notify 其他线程(现在和它互动的只有服务员线程)恢复工作,反过来也一样。**因为工作台对象的put 和take方法都是非同步静态方法,所以该方法中的同步监视器对象(锁)默认是this 对象。因此在put和take方法中必须使用this 对象调用 wait 和 notify方法,否则就会报 IllegalMonitorStateException。

案例:快餐店升级

​ 快餐店经营良好,开始出现顾客逐渐增多的良好趋势,现在快餐店虽然还未拓展面积,取餐口的工作台仍然只能放一份快餐(这里把上限定为一是故意让问题极端化,使问题暴露得更明显一些),厨师做完快餐后放在取餐口的工作台上,服务员从取餐口的工作台取出快餐给顾客,但是现在有多个厨师和多个服务员可以同时工作。请编写代码模拟这个工作场景。

该升级案例主要修改的有两个地方,一个是测试类中创建了多个厨师线程和服务员线程,另一个是工作台的修改。
首先,工作台把wait方法的判断条件从换成了while。如果判断条件是if,一旦该线程从等待中被唤醒,就会执行下面的代码,不会重复判断条件,那么就可能出现本来应该继续等待的对象,却去工作了的情况。例如,假设一种情况,此时num=1,厨师线程先后等待,正常工作的服务员线程取走一份菜(num-0)后,通知了正在等待的线程恢复工作,它唤醒的是其中一个厨师线程,厨师线程抢到了CPU 资源,该厨师线程做了一份菜(num=1)后,通知了正在等待的线程恢复工作,它唤醒的正好是另一个之前等待的厨师线程,被唤醒的厨师线程也抢到了CPU资源,又做了一份菜(num=2),这就导致了有一份菜没地方放的问题。如果把 i换成while,被唤醒的线程就会重复判断条件,如果等待的条件仍然满足,则继续等待,否则退出while循环,执行下面的代码。
其次,工作台把 notify方法换成了notifyAll方法,这是因为notify方法只会通知一个等待的线程恢复工作。如果此时出现了一种特殊情况,如此时num=1,厨师线程先后等待,正常工作的服务员线程取走一份菜(num=0)后,通知了正在等待的线程恢复工作,它唤醒的是其中一个厨师线程,而厨师线程并未抢到CPU资源,两个服务员线程先后抢到了CPU资源,但是此时num=0,所以服务员线程先后等待,现在只有一个厨师线程在工作,它做了一份菜(num=1)后,通知了正在等待的线程恢复工作,它唤醒的正好是另一个之前等待的厨师线程,两个厨师线程因为num=1,也先后等待了,那么就会导致所有线程都在等待,整个程序卡死,所以要把 notify 换成notifyAll,notifyAll可以唤醒所有等待线程而不是一个线程。

88edc55fc53a556e3b9d9381fc83419

案例:交替打印数字

案例需求:
实现两个线程交替打印1~100整数,一个线程打印奇数,另一个线程打印偶数,要求输出结果有序,即奇数线程打印一个数字后,交给偶数线程打印一个偶数,再让奇数线程继续打印,以此类推。
案例分析:
因为两个线程需要交替打印1~100的整数,所以声明一个打印数字线程类(PrintNumber),并且用一个num变量记录当前需要打印的数字,两个线程交替修改。同一个时刻修改num值和打印num值的代码只能让一个线程运行,所以必须放到同步块或同步方法中。实现交替打印的效果,就是一个线程打印完就等待,这样另一个线程就可以打印。另外,要记得唤醒等待的线程。

image-20230723223800780

单例设计模式

单例设计模式是软件开发中最常用的设计模式之一,它是某个类在整个系统中只能有一个实例对象可被获取和使用的代码模式。例如,代表JVM运行环境的Runtime类。
大家可以参照枚举类的设计思路设计一个单例类:第一,构造器私有化,这样在外面无法创建该类的对象;第二,在单例类的内部创建好唯一的实例对象,并且使外面可以获取到该实例对象。根据创建单例类对象的时机,单例设计模式可以分为饿汉式和懒汉式两种。

饿汉式

所谓饿汉式,是指在类初始化时,直接创建对象,就像一个人很饿,看到食物后直接就吃,饥不择食**。饿汉式单例设计模式的优点是不存在线程安全问题**,因为Java的类加载和初始化的机制绝对可以保证线程安全;**缺点是不管你暂时是否需要该实例对象,都会创建,这会使得类初始化时间及对象占用内存时间加长。**饿汉式单例设计模式的实现方式有如下三种。
(1)直接实例化。

public class Singleton1{
	public static final Singleton1 INSTANCE = new Singleton1();
	pricate Singleton1(){
	
	}
}

(2)新式枚举式

public enum Singleton2{
	INSTANCE
}

(3)饿汉式静态代码块。

​ 当创建单例类的实例对象,需要做的初始化操作比较复杂时,可以选择在静态代码块中做相关的初始化操作,然后创建该实例对象,具体操作如下

第一步:在src下先建立一个single.properties文件,文件内容如下所示。

info = szy

第二步:编写单例类

public class Singleton3 {
    public static final Singleton3 INSTANCE = null;
    private String info;

    //静态代码块
    static {
        try {
            Properties pro = new Properties();
            pro.load(Singleton3.class.getClassLoader().getResourceAsStream("single.properties"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private Singleton3(String info) {
        this.info = info;
    }

    @Override
    public String toString() {
        return "";
    }

}

上述代码把single.properties文件放到了src下,当项目工程编译时,会把src下的资源文件同,java文件一起编译到类路径下,即和字节码文件放在一起。当需要加载 single.properties 文件时,可以让类加载器帮忙加载

懒汉式

​ 所谓懒汉式,是指延迟创建对象,直到用户获取这个对象时再创建,就像懒人做事一样,不到逼不得已,绝对不会主动干活,在最后期限之前能拖就拖。懒汉式单例设计模式的优点是不用时不创建,用时再创建,减少了对象占用内存的时间;缺点是可能存在线程安全问题
​ 懒汉式单例设计模式的实现方式主要有以下两种。

  • 一种是在get单例对象的方法中创建单例对象,该实现方式可能存在线程安全问题。
  • 另一种是用静态内部类形式存储单例类对象,该实现方式没有线程安全问题。

image-20230723233546736

image-20230723233558417

上面的代码在运行时出现了线程安全问题,这就会出现创建两个单例类实例对象的情况
我们需要改进一下单例类的代码,来避免线程安全问题。

image-20230723234005604

再次用多个线程获取单例类的实例对象,这时我们可以发现线程安全问题已经解决。

(2)静态内部类形式存储单例类对象。

image-20230723234244623

因为静态内部类 Inner的初始化并不是随着外部类的初始化而初始化的,而是在调用getInstance方法使用到Inner类时才初始化的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值