【Java基础】多线程开发

Java多线程编程学习笔记

Author: Jim.kk   Video: Bilibili

文章目录







学习路线

在这里插入图片描述

简介

程序、进程与线程的关系

程序:为了完成特定任务,用某种语言编写的一组指令的集合。即指一段静态代码,静态对象。

进程:**程序的一次执行过程,或是正在内存中运行的应用程度。**如运行中的IDEA、音乐播放器等。

  • 进程是操作系统调度和分配资源的最小单位
  • 每一个进程都有一个独立的内存空间,系统运行一个程序即是一个进程从创建、运行到消亡的过程
  • 程序是静态的,进程是动态的
  • 进程作为操作系统调度和分配资源的最小单位(亦是操作系统运城程序的基本单位),系统在运行时会为每个进程分配不同的内存区域
  • 现代的操作系统,大都是支持多进程的,支持同时运行多个程序。比如同时使用浏览器播放视频,同时使用IDEA编辑代码

线程:进程可进一步细分为线程,是程序内部的一条执行路径,一个进程中至少有一个线程

  • 线程是CPU执行的最小单位
  • 一个线程同一时间若并行多个线程,就是支持多线程的

操作系统是支持多“进程”的,进程又是支持多“线程”的。

下图是我电脑中正在运行的N个进程

在这里插入图片描述

一个进程中的多个线程共享相通的内存单元,他们从一个堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能会带来安全隐患。

JVM简介 | 多线程在JVM中的执行示例

关于各个分区的详细介绍可以查看博客【JVM】Java虚拟机运行时数据分区介绍

在这里插入图片描述

上图中,左侧“线程共享区”内信息共享,多个线程可以处理一个进程中的方法区域堆(但是会造成线程安全问题),右侧“线程隔离区”中每一个线程都会拥有一个独立的内容,线程之间隔离运行/互不打扰。

下面有一段简单的方法,我们思考下:

  • 如果在main方法中我们没有创建新的线程来调用telNum方法的话,那么此时的程序执行栈为:main()telNum()System.out.print()print()完毕,出栈telNum方法完毕,出栈main方法执行完毕,出栈(程序执行结束)
  • 但是以下代码是有多线程的,那么它此时将会创建一个新的虚拟机方法栈,所以两条执行线路分别如下:
    • main()telNum() → …
    • Thread-00tellNum() → …
    • 此处两条线程交叉执行,谁先抢到CPU的执行权限,谁就执行,因此可以在控制台看到两条线程的内容交叉输出。

在这里插入图片描述

public class Run2 {

    public static void main(String[] args) {
        // 线程中的调用,输出 ++$
        new Thread() {
            @Override
            public void run() {
                telNum("++");
            }
        }.start();
        
        // main方法中的调用,输出 --$
        telNum("--");
        
    }

    public static void telNum(String str) {
        for (int i = 0; i < 100; i++) {
            System.out.println(str + i);
        }
    }
}

线程共享区,举个简单的例子:我们使用懒汉式单例模式的时候,需要避免创建实例的时候同时进入两个线程从而创造了不同的实例。

名词解释:

1. 本地方法栈(Native Method Stack)

  • 本地方法栈用于执行本地方法,即使用非Java语言编写的方法,例如C或C++。
  • 它与虚拟机栈类似,但不同的是,虚拟机栈执行的是Java方法,而本地方法栈执行的是本地方法。
  • 本地方法栈的深度可以由用户通过调整JVM的参数来设置。

2. 虚拟机栈(Java Virtual Machine Stack)

  • 虚拟机栈用于执行Java方法,每个线程在执行Java方法时都会创建一个对应的虚拟机栈。
  • 每个方法在执行时都会在虚拟机栈中创建一个栈帧(Stack Frame),用于存储局部变量、操作数栈、方法返回地址等信息。
  • 虚拟机栈会随着方法的调用和返回而动态地进行压栈和弹栈操作。
  • 虚拟机栈的大小也可以由用户通过调整JVM的参数来设置。

3. 程序计数器(Program Counter Register)

  • 记录当前线程的执行位置:程序计数器会记录当前线程正在执行的字节码指令地址,当线程被中断或者调度时,JVM可以通过程序计数器恢复线程执行的位置,从而实现线程的恢复和切换。

  • 支持线程间的独立执行: 每个线程都拥有自己的程序计数器,这样不同线程之间的执行互不干扰,可以实现多线程并发执行。

  • 指导程序流程控制: 程序计数器中存储的指令地址决定了当前线程接下来将要执行的指令,它在程序控制流转换时起到重要的作用。

  • 异常处理: 在Java中,程序计数器也用于支持异常处理。当发生异常时,程序计数器可以帮助JVM确定异常处理器的位置,从而进行异常处理。

4. 方法区(Method Area)

  • 存储类的结构信息:方法区存储了加载的类的结构信息,包括类的字段、方法、构造方法、接口等。

  • 存储静态变量:所有类的静态变量都存储在方法区中,而不是存储在堆中。

  • 存储常量池:方法区中还包含了每个类的常量池,常量池用于存储类中的字面量常量、符号引用和其他常量。

  • 存储方法字节码:加载的类的方法字节码被存储在方法区中,JVM通过解释或者编译执行这些字节码。

5. 堆(Heap)

  • 动态分配内存: Java程序中的对象实例和数组对象都在堆中动态分配内存。当使用new关键字创建对象时,对象会被分配在堆中。
  • 垃圾回收: 堆中的对象实例是由Java垃圾回收器管理的,它们会根据对象的引用关系来判断哪些对象是可达的,哪些是不可达的,然后回收不可达对象所占用的内存。
  • 自动内存管理:Java虚拟机负责堆的自动内存管理,它会在需要时进行堆的扩展和收缩,以满足程序的内存需求。
  • 分代结构:堆通常会被划分为不同的代,例如新生代(Young Generation)、老年代(Old Generation)和持久代(PermGen或Metaspace)。不同代的对象有不同的生命周期和回收策略,这样可以提高垃圾回收的效率。

JVM的学习并不属于多线程的一部分,但是最好还是要弄清楚JVM虚拟机中的一些结构,比如**栈管运行,堆管存储**等内容。

CPU 线程的调度方式

简单复习下大学的内容:CPU对线程的调度方式。

CPU有几种调度方式,比如:

  • 分时调度:所有线程轮流使用CPU,并且为每个线程平均分配时间)
  • 抢占式调度:让优先级高的线程以较大的概率优先使用CPU。如果线程的优先级相同,那么会随机选择一个(线程随机性)

Java 采用的是抢占式的调度方式,我们可以给线程创建优先级,让它优先被调度。

多线程的意义

  1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
  2. 提高计算机系统CPU利用率。
  3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改。

并行与并发

并行:是指两个或多个事件在同一时刻发生。指在同一时刻,有多条指令在多个CPU上同时执行。

并发:指两个或多个时间在同一时间段内发生。即在一段时间内,有多条指令在CPU上快速轮换、交替执行,使得在宏观上具有多个进程同时执行的效果。

创建多线程 1 | Thread 与 Runnable

方式 1 | 继承 Thread 类

  1. 创建一个继承于Thread类的子类
  2. 重写Thread类的run()方法,将此线程要执行的操作,声明在此方法体中
  3. 创建当前Thread子类的对象
  4. 通过对象调用start()方法 | 启动当前线程,调用当前线程的run()方法:1. 启动线程 2. 调用线程的run方法

注意 !!!:一定是start()方法,而不是run()方法,run方法会被当做一个普通的方法来执行,而不会创建一个新的线程,使用run方法启动的话,只是在main线程中对不同的方法进行压栈执行出栈退出的操作。执行顺序为main方法第一个run方法(run1)main方法run2mainrun3mian …。

示例代码如下:

package Demo01Thread;

/**
 * @author Jim
 * @Description
 * @createTime 2024年06月04日
 */
public class Demo1 {
    public static void main(String[] args) {
        Thread t1 = new JThread1();
        Thread t2 = new JThread2();

        t1.start();
        t2.start();

        for (int i = 1; i <= 100; i++) {
            System.out.println(Thread.currentThread().getName() + "***" + i);
        }
    }
}

class JThread1 extends Thread {

    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            if ( i % 2 == 0) {
                System.out.println(Thread.currentThread().getName() + "---" + i);
            }
        }
    }
}


class JThread2 extends Thread {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            if ( i % 2 != 0) {
                System.out.println(Thread.currentThread().getName() + "==>" + i + "---");
            }
        }
    }
}

以上代码中,我们总共有三个线程,他们的名称分别为main、Thread-0、Thread-1。三个交替执行。

在这里插入图片描述




方式 2 | 实现Runnable接口

由于Java中的类是单继承/多实现的,那么在程序中若是某个类需要继承某个(非Thread)父类,那么就无法使用上述方式创还能多线程了(继承冲突)。此时,我们可以使用多实现来实现一个多线程。

实现Runnable接口来创建多线程的方式,无需担心存在冲突问题,因为我们的类除了实现该接口以外,还可以实现其他的接口,同时还能继承一个类。

创建步骤:

  1. 创建一个实现Runnable接口的类
  2. 实现接口中的run()方法
  3. 创建当前实现类的对象
  4. 当此对象作为参数传递到Thread的构造器中,创建Thread的实例
  5. 调用Thread类的实例的start()方法:1. 启动线程 2. 调用线程的run方法
public class Demo02 {
    public static void main(String[] args) {
        // 1. 创建Runnable实现类的对象
        Runnable r = new RThread();
        // 2. 创建Thread实例,并传入Runnable实现类的对象
        Thread t = new Thread(r);
        // 3. 调用Thread实例的start方法
        t.start();
    }
}


class RThread implements Runnable {
    @Override
    public void run() {
        // 线程1需要做的事情
        System.out.println("Hello,I'm Jim.kk!!");
    }
}

以上代码控制台仅仅输出:Hello,I’m Jim.kk!!,这里并没有在main线程中输出内容以体现多线程,但程序仍是多线程执行的,仅做参考。

方式 3 匿名子类 | 使用Thread的匿名子类创建

public class Demo03 {

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                // 线程中要做的事情
            }
        }.start();
        
    }
}

方式 4 匿名实现类 | 使用Runnable的匿名实现类

public class Demo04 {

    public static void main(String[] args) {
        
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 线程中要做的事情
            }
        }).start();
        
    }
}

补充 1 | 继承与实现的对比

异同对比

同:

  1. 启动线程都是调用Thread的start方法
  2. 创建的线程对象,都是Thread类或其子类的实例

异:

  1. 一个是继承,一个是实现

推荐使用Runnable实现类及原因

建议使用Runnable接口的方式:

  1. 实现方式避免了类的单继承的局限性
  2. 更适合处理共享数据,使用实现的方式,我们可以创建多个Thread但是使用一个Runnable,此时接口实现类的对象相当于是一个单例,接口中的变量都是共享的(见下方的代码)
  3. 可以实现数据与代码的分离

扩展 | Runnable的共享数据与Thread的非共享数据

1. Runnable处理共享数据
public class Demo05 {
    public static void main(String[] args) {
        RThread r = new RThread();
        new Thread(r).start();
        new Thread(r).start();
        new Thread(r).start();
    }
}


class RThread implements Runnable {

    private int count = 0;

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + "|" + ++count);
        }
    }

}

先思考一下以上代码的输出结果是多少呢?是三个线程每个线程输出0100,还是输出0300(也是三个线程,只不过每个数字只出现一次,也就是逐渐累加)

答案:是输出0~300

在以上代码中,我们创建了一个RThread的实例,因此该实例是单例的,因此里面的count在内存空间中也只有一个,虽然我们有三个线程的,但是无论哪个线程抢占到CPU,都会对同一个count执行+1,直到三个线程全部执行完毕,所以看上去是300。

以上代码的执行结果如下所示:

在这里插入图片描述

**补充:**但是在一些情况下,最终结果只有299或者298,当我查看控制台的时候,发现重复输出了两次或三次“1”,这便是线程不安全问题:当0号线程第一次拿到count时,1号线程同时也拿到了count,此时两个线程拿到的结果都是“0”,执行print(++1)后,都变成了1,此时已经经过了两个循环,因此当循环结束时,最终结果是299(三个线程同时拿到则是198),关于线程安全问题下文会有介绍,这里只是提个醒,如果最终执行结果没有300,可以多执行几次。

2. Thread的非共享数据

我们使用Thread实现类的方式实现以下上述代码来看看效果,代码如下:

public class Demo05 {
    public static void main(String[] args) {
        new TThread().start();
        new TThread().start();
        new TThread().start();
    }
}

class TThread extends Thread {

    private int count = 0;

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + "|" + ++count);
        }
    }
}

这里最终输出三次100(执行效果如下图所示),这一段代码中,由于我们创建了3个TThread对象,因此我们有3个count,所以每次执行的时候,只会对对象内的count进行+1,而不会影响到其它两个对象中的count,因此这里的最终输出效果是3个0~100。

在这里插入图片描述

补充 2 | 线程的名字

    Thread.currentThread().setName("Jim's Test Thread"); // 给线程设计名称
    Thread.currentThread().getName(); // 获取线程的名称

线程具有名称,默认情况下,main线程的名称即为main,其他的线程为Thread-0/1/2/3。现成的名称其实是一个成员属性,我们可以在创建线程的时候为它设置名称,也可以使用Thread.currentThread().setName("");的方式为线程取名字。

.currentThread()方法是获取当前执行的线程,里面包含你所在线程的信息,可以使用setter/getter方法设置/获取信息。

补充 3 | start方法与run方法的区别

start()方法:用于启动当前线程,并且调用线程的run()方法

run()方法:一个普通的方法,里面包含有程序员定义的代码*(直接调用run方法不是多线程的)*

补充 4 | 不可重复start线程

一个线程.start()之后无法再次start,否则会报非法线程状态的异常(IllegalThreadStateException),再次执行需要创建新的对象。

补充 5 | 多线程的执行流程

继承Thread的话,我们就是直接重写run方法,此时start之后,会创建线程并执行run方法,run已经被我们重写了,可以直接执行。

传入Runnable实现类对象的的方式:Thread 其实是实现了Runnable的一个类,这是一种简单的代理模式,当我们向Thread中传入Runnable的实现类对象时,Thread(父类)内的target成员变量(Runnable格式)会被赋值为我们传入的对象,此时调用Thread的start方法,Thread(父类)会创建线程并执行自己的run方法,而自己的run方法会调用实现类的run方法。

线程中的常用结构

线程中的构造器

构造器说明
public Thread()分配一个新的线程对象
public Thread(String name)分配一个指定名字的新的线程对象
public Thread(Runnable target)制定创建线程的目标对象,它实现了Runnable接口中的run方法
public Thread(Runnable target,String name)分配一个带有制定目标的新的线程对象,并指定名称

线程中的常用方法

常用方法

方法名称说明备注
start()启动线程,调用线程的run方法
run()将线程要执行的操作声明在run方法中static
currentThread()获取当前执行线程
getName()获取线程名称
setName("")给线程命名
sleep(long millis)线程等待方法,单位毫秒static/Exception
yield()主动释放CPU的执行权,下次可能就不执行它了static
join()join()方法需要线程对象(x)调用,意为等待x线程执行结束后,才继续执行当前线程Exception
isAlive()线程是否存活,返回true/false

过时方法

方法名说明
stop()强行结束一个线程不建议使用
suspend()/resume()线程挂起/线程继续不建议使用

线程的优先级

前文介绍了,Java的线程是抢占式的,Java会按照线程优先级来抢占CPU。

Java默认优先级是5,设置范围为1 ~ 10

方法名说明
getPriority()获取线程优先级
setPriority(num)设置线程的优先级(越高执行的可能性会高,但是多核CPU体现不明显)

关于native方法:由操作系统分配的方法

创建多线程 2 | Callable 与 线程池

方式 1 | Callable

简介

实现Callable接口的方式也可以创建一个新的线程,Callable具有以下特点:

  1. Callable可以有返回值,线程执行结束后可以返回一个Object
  2. Callable可以抛出异常,我们不需要再使用try...catch...的方式来捕获异常
  3. Callable可以传入泛型,我们在实现Callable接口的时候,可以传入一个泛型,以确定返回值的类型

使用方式

  1. 实现Callable接口(可以定义泛型)
  2. 声明该实现类的对象,并将对象传递进FutureTask中以声明一个FutureTask的对象
  3. FutureTask的对象传递进入Thread中,创建一个Thread的对象
  4. 使用FutureTask的对象,调用get()方法,获取返回值(若没有返回值则会等待)

示例代码

我们编写一段代码,要求使用一个线程,输出1~100内所有的偶数,并且计算出这些偶数的和。

以下代码中,我们实现Callable实现一个线程,里面输出偶数并计算和,随后返回和。在主函数中,我们先声明该类的对象,随后将该对象传递到FutureTask中去,随后创建一个Thread,并接收FutureTask,线程启动后,输出与计算就开始了;此时Main线程会等待这个线程,直到执行结束后,Main线程才会拿到返回值。

import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

class NumThread implements Callable {
    @Override
    public Object call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            if(i%2 == 0) {
                System.out.println(i);
                sum += i;
            }
        }
        return sum;
    }
}


public class Demo14 {

    public static void main(String[] args) {
        NumThread numThread = new NumThread();
        FutureTask futureTask = new FutureTask(numThread);

        Thread t1 = new Thread(futureTask);
        t1.start();

        try {
            Object sum = futureTask.get();
            System.out.println(sum);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

对比 | Callable 与 Runnable

  1. Callable 有返回值,更灵活
  2. Callable 可以抛出异常,不必使用try...catch...的方式
  3. Callable 使用了泛型参数,可以指明具体的返回值类型
  4. 缺点:调用Callable的线程时,在等待返回值的时候,调用方会处于阻塞状态,直到拿到返回值

方式 2 | 线程池

简介

由于线程的创建与销毁比较消耗资源,那么如果并发的线程数量很多,并且每一个线程都是执行一个很短的时间就结束了,这样频繁创建线程会大大降低系统的运行效率,因此可以采用**创建一个或多个线程,执行完毕后该线程不被销毁,去接着执行下一个任务。**

在这里插入图片描述

上图中,我们创建了线程池之后,只需要将我们需要执行的任务丢给任务队列,随后任务队列中的任务会排队等待线程池获取,当线程中的线程得到了任务时,便会开始执行该任务,执行完毕后获取下一个任务

注意:该图中上面的是任务队列,下面的是线程池

使用线程池的优缺点

优点:

  1. 由于线程已经提前创建好,所以可以**提高程序的执行效率**
  2. 由于执行完的线程并未销毁,所以**提高了资源的复用率**
  3. 可以设置相关参数,对线程池中的线程进行管理,通过参数控制内存的使用,比较灵活

示例 1 | 双线程打印奇数与偶数

  1. 以下代码中,我们实现了两个Runnable的实现类,分别用于打印奇数与偶数

  2. 我们在main方法中,创建一个线程池,大小为10,并且最大可容纳线程数为50,随后通过execute(Runnable runnable)的方式,向线程池提交任务,就可以执行线程了。

  3. 最后关闭线程池,退出程序

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

// 打印偶数
class NumberThread1 implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            if( i % 2 == 0 ) {
                System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }
    }
}

// 打印奇数
class NumberThread2 implements Runnable {
    @Override
    public void run() {
        for (int i = 1; i <= 100; i++) {
            if( i % 2 != 0 ) {
                System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }
    }
}
public class Demo15 {

    public static void main(String[] args) {
        // 创建一个大小为10的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;

        /* 设置线程参数 */
        // 最多可以容纳50个线程
        service1.setMaximumPoolSize(50);

        // 执行指定的线程操作,需要提供Runnable接口或Callable接口实现类对象
        service.execute(new NumberThread1());
        service.execute(new NumberThread2());

        // service.submit(Callable callable); // 适用于Callable的实现类方式

        // 关闭线程池(不手动关闭的话会一直开启)
        service.shutdown();

    }
}

示例 2 | ChatGPT的举例

以下代码使用循环的方式,一次向线程中插入多个任务

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个固定大小的线程池,大小为3
        ExecutorService executor = Executors.newFixedThreadPool(3);

        // 提交10个任务给线程池执行
        for (int i = 0; i < 10; i++) {
            // 创建一个任务
            Runnable task = new MyTask("Task " + i);
            // 提交任务给线程池执行
            executor.submit(task);
        }

        // 关闭线程池
        executor.shutdown();
    }

    // 自定义任务类
    static class MyTask implements Runnable {
        private String name;

        public MyTask(String name) {
            this.name = name;
        }

        @Override
        public void run() {
            System.out.println("Executing task: " + name + " in thread: " + Thread.currentThread().getName());
            try {
                // 模拟任务执行过程
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Task " + name + " completed.");
        }
    }
}

示例 3 | 创建线程池的方式

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,  // 核心线程数
    maximumPoolSize,  // 最大线程数
    keepAliveTime,  // 空闲时间等待时间
    TimeUnit.SECONDS,  // 时间单位
    new ArrayBlockingQueue<>(queueCapacity)  // 任务队列
);

常用方法

方法 1 | 创建线程池
方法名说明
Executors.newFixedThreadPool(int nThreads)创建固定大小的线程池,该线程池中的线程数量始终为指定的数量
Executors.newCachedThreadPool()创建一个根据需要创建新线程的线程池,但会在可用时重用先前构造的线程
Executors.newSingleThreadExecutor()创建一个单线程的线程池,保证任务按顺序执行
方法 2 | 提交任务
方法名说明
void execute(Runnable command)提交一个任务给线程池执行,没有返回值
<T> Future<T> submit(Callable<T> task)提交一个带有返回值的任务给线程池执行,并返回一个Future对象用于获取任务的执行结果
Future<?> submit(Runnable task)提交一个无返回值的任务给线程池执行,并返回一个Future对象
方法 3 | 关闭线程池
方法名说明
void shutdown()启动有序关闭,不再接受新任务,等待已经提交的任务执行完成(包括未启动的任务)
List<Runnable> shutdownNow()尝试停止所有正在执行的活动任务,暂停处理正在等待的任务,并返回等待执行的任务列表
方法 4 | 等待线程池终止
方法名说明
boolean awaitTermination(long timeout, TimeUnit unit)请求关闭,等待最多timeout的时间以便已经提交的任务终止执行,或者直到超时,或者当前线程被中断
方法 5 | 其它方法
方法说明
void setCorePoolSize(int corePoolSize)设置核心线程数
void setMaximumPoolSize(int maximumPoolSize)设置最大线程数
void setKeepAliveTime(long time, TimeUnit unit)设置线程空闲时的等待时间
void setRejectedExecutionHandler(RejectedExecutionHandler handler)设置拒绝策略
int getActiveCount()获取活动线程的数量
int getPoolSize()获取线程池的当前线程数

名词介绍

核心线程数(Core Pool Size)
  • 核心线程数是线程池中始终保持活动的线程数量。
  • 即使线程是空闲的,核心线程也不会被回收。
  • 当有新的任务提交到线程池时,如果核心线程数尚未达到最大值,线程池会优先创建新的核心线程来处理任务。
  • 如果核心线程数已经达到最大值,并且任务队列已满,则新任务会被拒绝(根据配置的拒绝策略)。
最大线程数(Maximum Pool Size)
  • 最大线程数是线程池中允许的最大线程数量,包括核心线程和非核心线程。
  • 当有新的任务提交到线程池时,如果核心线程数已经达到最大值,且任务队列也已满,则会创建新的非核心线程来处理任务,直到达到最大线程数。
  • 如果线程池中的线程数量已经达到最大值,并且任务队列也已满,则新任务会被拒绝(根据配置的拒绝策略)。
  • 当线程池中的线程数超过核心线程数时,空闲的非核心线程会在一定时间内被回收,以减少系统资源的消耗。
线程空闲等待时间(此处设置为x)

当非核心线程数空闲且等待x个时间单位后,会回收该非核心线程

  • ThreadPoolExecutor中,有一个keepAliveTime参数,用于设置线程的空闲时间等待时间,它的含义是:当线程池中的线程数量超过核心线程数时,空闲的非核心线程在空闲时间超过keepAliveTime后会被回收。keepAliveTime是一个时间值,可以配合TimeUnit来指定时间单位。当线程空闲时间达到指定的等待时间后,如果线程池中的线程数量超过核心线程数,则线程会被终止,直到线程池中的线程数再次等于核心线程数。

  • 除了keepAliveTime参数之外,还有一个相关的参数是TimeUnit,用于指定时间单位。通常情况下,这两个参数会一起使用来设置线程的空闲时间等待时间。

拒绝策略

**拒绝策略:**当线程池目前执行的任务已经达到最大上限时,对于继续提交的线程会进行拒绝,以下是几个拒绝策略:

1. AbortPolicy(默认策略):

  • 这是线程池的默认拒绝策略。当线程池无法处理新任务时,会抛出RejectedExecutionException异常,拒绝新任务的提交。
  • 优点是简单直接,当线程池过载时可以快速失败,避免任务堆积。
  • 缺点是不能对任务进行缓存或者重试。

2. CallerRunsPolicy:

  • 在调用线程中直接执行被拒绝的任务。
  • 这个策略不会抛出异常,而是将任务交给调用线程(submit任务的线程)执行,由调用线程来处理被拒绝的任务。
  • 这样可以确保任务不会丢失,但是会降低任务提交速度,并可能导致调用线程负载过重。

3. DiscardPolicy:

  • 直接丢弃被拒绝的任务,不做任何处理。
  • 这个策略会导致被拒绝的任务被丢弃,不会进行处理,可能导致任务丢失。

4. DiscardOldestPolicy:

  • 丢弃队列中最旧的任务,然后尝试将新任务添加到队列中。
  • 这个策略会丢弃等待时间最长的任务,然后尝试将新任务添加到任务队列中,以释放队列空间。
任务队列

创建线程池时可以指定任务队列,该任务队列可以**在没有立即可用的线程时存储这些任务**。

ArrayBlockingQueue:

  • ArrayBlockingQueue是一个有界队列,它基于数组实现,固定大小,不支持扩容。
  • 当任务队列已满时,新任务将被阻塞,直到有线程从队列中取走任务。
  • 这种队列适合有固定线程数的线程池,可以有效控制内存使用量。

LinkedBlockingQueue:

  • LinkedBlockingQueue是一个基于链表实现的有界或无界队列,可以指定容量也可以不指定。
  • 在不指定容量时,队列的大小为Integer.MAX_VALUE,近似于无界队列。
  • 当任务队列已满时,新任务将被阻塞,直到有线程从队列中取走任务。
  • 这种队列适合于具有固定大小线程池的情况,并且通常比ArrayBlockingQueue更适用于大多数情况,因为它支持更大的容量。

SynchronousQueue:

  • SynchronousQueue是一个没有容量的阻塞队列,每个插入操作必须等待另一个线程的对应移除操作。
  • 插入线程和移除线程在同一时间进行操作,因此对于每个插入操作,都必须等待对应的移除操作。
  • 当线程池中的线程数达到核心线程数时,新任务将被直接提交给SynchronousQueue,而不会存储在队列中。因此,使用这种队列时,最大线程数应该至少大于核心线程数。

PriorityBlockingQueue:

  • PriorityBlockingQueue是一个支持优先级的无界队列,元素按照它们的自然顺序或者通过构造函数提供的Comparator来排序。
  • 插入操作和移除操作都是非阻塞的,并且不会受到容量限制的影响。
  • 这种队列适合需要按照优先级执行任务的情况。

补充

补充 1 | execute 与 submit 的异同

相同之处:

  1. 都用于想线程池中提交任务以执行
  2. 都接受RunnableCallable类型的任务

不同之处:

  1. submit()方法还可以用于提交带有返回值的任务(Callable类型的任务),而execute()方法只能用于提交无返回值的任务(Runnable类型的任务)
  2. ``submit()方法比execute()方法更灵活,因为它可以接受Callable`类型的任务,并且可以获取任务的执行结果。
补充 2 | ExecutorService 与 ThreadPoolExecutor 的异同

ExecutorService:

  • ExecutorService是一个接口,它扩展了Executor接口,提供了更丰富的线程池管理功能。
  • ExecutorService提供了管理线程池的方法,例如提交任务、关闭线程池等。
  • ExecutorService可以通过Executors工厂类来创建不同类型的线程池,例如newFixedThreadPool()newCachedThreadPool()等。

ThreadPoolExecutor:

  • ThreadPoolExecutor是一个具体的线程池实现类,它实现了ExecutorService接口。
  • ThreadPoolExecutor提供了一个灵活的线程池实现,可以通过构造函数来指定核心线程数、最大线程数、线程空闲时间等参数,以及任务队列的类型和大小。
  • ThreadPoolExecutor允许更加精细地控制线程池的行为,例如通过自定义RejectedExecutionHandler来处理任务拒绝策略。

异同点:

  • 共同点: 二者都用于管理线程池,可以提交任务给线程池执行,可以关闭线程池等。
  • 不同点: ExecutorService是一个接口,提供了线程池管理的高级功能;而ThreadPoolExecutor是一个具体的线程池实现类,提供了更加灵活和可定制的线程池功能。

线程的生命周期

JDK 1.5 之前

JDK 1.5 之前,线程有5种状态,分别是:新建、就绪、运行、死亡 + 阻塞。

  • 新建:当我们new Thread()的时候,线程就处于新建的状态,此时线程已经被程序员创建出来,但是还无法被执行

  • 就绪:当我们调用.start()方法的时候,线程被创建且执行run方法,此时线程已经可以被执行了,不过还要看它有没有抢到CPU的执行权限

  • 运行:当线程处于就绪状态且获得CPU执行权限时,线程就被执行了,当失去CPU执行权限时(或使用yield方法让出执行权限),线程会重新回到就绪状态

  • 死亡:线程执行完毕 或 出现报错强制退出 或 使用stop(类似于Linux的kill,不过kill是对进程,此处是线程)则会退出线程,线程死亡

  • 阻塞:当线程正在被CPU调用时,我们使用sleep() / join() / 等待同步锁 / wait() / suspend() 时候,会进入到阻塞状态,此时线程将不会被执行

  • 结束阻塞:当线程的sleep时间结束 或者 join的线程结束 或者 notify() 或者resume()时候,线程将会重新回到就绪状态,此时若是抢占到CPU,则会继续执行

在这里插入图片描述

需要注意的是,线程的变化状态只能遵循上面的箭头,新建线程 → 就绪 → 抢占到CPU则运行/失去CPU则重新就绪 → 手动进入阻塞状态 → 重新进入就绪状态 → 运行 → 运行结束或退出 → 线程死亡

JDK 1.5 之后

JDK 1.5 之后,线程有新建、可运行、死亡 + 锁阻塞 / 计时等待 / 无限等待 6 种状态

在这里插入图片描述

其实十分好理解,这种理解线程的方式更贴近于程序员的理解思维:

  • 新建 / 死亡状态没发生变化,new Thread的时候新建,运行结束或出现错误或stop后线程死亡

  • 可运行状态(Runnable): 准备(就绪)阶段与运行阶段不是程序员可控制的,所以程序员可以将这两种状态理解为一种状态,至于什么时候是就绪状态什么时候是运行状态,这个由CPU来说了算

  • 其余的三种都是阻塞状态,不过根据不同的表现形式分为了三种,下面来简单讨论下:

    • 锁阻塞:当多个线程抢占一个资源的时候,那么此时拿到资源的这个线程就处于Runnable状态,没有拿到线程资源的就是锁阻塞状态
    • 计时等待:我们可以使用Sleep之类的方法让线程等待一段时间,此时便是计时等待状态
    • 无限等待:在比如join下,我们无法控制线程在多长时间后结束,只能等待join的线程结束,这个时间可能是几毫秒,也可能是几百年,这时候便是无限等待状态
    • 无限等待与计时等待比较相似,只不过计时等待有一个准确的结束的时间点,但是无限等待需要外部的“刺激”才能结束

线程安全问题

线程安全问题示例

我们在使用支付的时候,经常会有这么一个猜想:我能不能在ATM机钱按下取钱按钮的一瞬间,向支付宝转账2000元,此时机器会不会“反应不过来”,只扣了2000元但是同时给我了4000元呢?答案是不可能的。这边只是举一个例子,还涉及到了数据库中的一些知识,有兴趣的同学可以参考《凤凰架构》一书中的数据库隔离性实现的章节。

同样,在上述我们描述共享数据的时候,说到了三个线程同时对一个共享数据进行100次循环+1操作,最终结果可能是298/299/300。300是我们期待的结果,298/299是由于线程安全问题导致的,这时候我们也可以使用此章节介绍到的几种方式来解决线程安全问题。

此处我们以售票为例,重现一下多线程操作共享数据可能会带来的问题。

class SaleTicket implements Runnable {

    int ticket = 100;

    @Override
    public void run() {
        while (true) {
            if(ticket > 0) {
                System.out.println(Thread.currentThread().getName() + "售票,票号为" + ticket);
                ticket--;
            } else {
                break;
            }
        }
    }
}

public class Demo09 {

    public static void main(String[] args) {
        SaleTicket s = new SaleTicket();
        new Thread(s, "窗口1").start();
        new Thread(s, "窗口2").start();
        new Thread(s, "窗口3").start();
    }

}

该代码的详细输出结果可见文末【附件1】

此处是三个线程操作一个ticket的共享数据,每个线程都名为“窗口”,三个窗口同时售票,最终结果为:票号为100的票被出售了三次,其余票号基本正常(在不同电脑上执行结果不同)。我们并不想出现这种情况,因为按理说,一张票只能被卖出一次。

分析:当A线程被CPU调度时,B线程与C线程也被CPU进行了调度(我是多核CPU),此时A、B、C线程均执行到了if(ticket>0)的判断中,并进行输出,此时三个线程输出的值都是100。

在这里插入图片描述

方式 1 | 同步代码块

我们可以使用synchronized来控制一个资源每次只有一个线程访问,但是这种效率较低,不适用与高并发的场景。

synchronized(同步监视器) {
    // 需要被同步的代码
}

说明:

  • 需要被同步的代码:即为操作共享数据的代码
  • 共享数据:即多个线程需要操作的数据、比如ticket
  • 需要被同步的代码,在被synchronizd包裹以后,就是的一个线程在操作这些代码的过程中,其它线程必须等待
  • 同步监视器,俗称锁。那个线程获取了锁,那个县城就能执行需要被同步的代码
  • 同步监视器:可以使用任何一个类的对象充当,但是多个线程必须公用同一个(注意必须是唯一的

我们使用synchronize对以上代码做如下修改,仅修改Runnable实现类部分即可:

class SaleTicket implements Runnable {

    int ticket = 100;

    Object o = new Object();

    @Override
    public void run() {
        while (true) {
            synchronized (o) {
                if(ticket > 0) {
                    System.out.println(Thread.currentThread().getName() + "售票,票号为" + ticket);
                    ticket--;
                } else {
                    break;
                }
            }
        }
    }
}

此时输出正常,每个票号仅售出一张,关于此段代码的输出内容可见【附件2】

此时,当线程A被调度时,线程A进入到同步代码块中,此时对同步代码块加锁,其余线程无法进入;若此时线程B、C被CPU调度,遇到被锁的同步代码块则只能等待,直到线程A执行结束并释放锁,线程B、C中的某一个线程才能进入到同步代码块,进入后会再次加锁,另外俩个线程只能等待它被执行结束。

为了方便理解,我们可以给同步监视器取名为dog、key,或者我们可以直接使用this(当前类的对象)来当做同步监视器,也可以实现一样的效果(慎用,注意this是不是唯一的)。

思考:能不能将整个while放在同步代码块中呢?答案是不可以的,这种情况下整个while都会等待,那么一个线程进入循环后,会等待整个while循环结束后才会释放锁,那么另外两个线程进来后就无事可做了;像极了单线程。

思考 1 | 继承Thread该如何使用同步代码块?

如果我们使用继承Thread的方式实现的话,那么无论是在继承的类中定义Object还是使用this都会失效,因为此时this代表创建出来的对象,上文中我们已经说过此种情况会创建三个不同的对象,所以此时同步监视器不是唯一的,我们可以创建一个static的Object对象作为同步监视器,或使用Window.class来充当同步监视器。

方式 2 | 同步方法

如果操作代码块的内容完整的声明在了一个方法中,那么就被称为同步方法。此时同步监视器默认是this,因此在继承Thread的时候,我们无法使用这种方式。

我们对Runnable做以下修改,抽离出售卖票的部分,将它单独放在一个方法中,并使用synchronized修饰该方法。

class SaleTicket implements Runnable {

    int ticket = 100;
    boolean flag = true;

    @Override
    public void run() {
        while (flag) {
            sale();
        }
    }

    public synchronized void sale() {
        if(ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "售票,票号为" + ticket);
            ticket--;
        } else {
            flag = false;
        }
    }
}

该此段代码的输出详情与同步代码块没有太大的区别,只是存在输出顺序上的不同,详见【附件2】

思考 2 | 继承Thread的方式能不能使用同步方法?

答案:不行,因为此时的同步监视器默认是this,在创建对象的时候,我们会创建三个对象,所以我们拥有三个不同的this,同步监视器不唯一。而使用实现Runnable的方式时,我们只创建一个Runnable实现类的对象,因此此时this是唯一的。

方式 3 | 使用Lock锁的方式

注意事项:

  1. 一定要保证多个线程使用同一个锁对象
  2. 使用lock()锁定,使用unlock()解锁
  3. 一定要保证unlock()被执行,否则因为异常等问题没有执行unlock的话,可能会直接造成死锁问题

对以上售票问题做以下修改:

import java.util.concurrent.locks.ReentrantLock;

class SaleTicket implements Runnable {

    int ticket = 100;

    private ReentrantLock lock = new ReentrantLock();


    @Override
    public void run() {
        while (true) {
            try {
                lock.lock();
                if(ticket > 0) {
                    System.out.println(Thread.currentThread().getName() + "售票,票号为" + ticket);
                    ticket--;
                } else {
                    break;
                }
            } finally {
                lock.unlock();
            }
        }
    }
}

public class Demo12 {

    public static void main(String[] args) {
        SaleTicket s = new SaleTicket();
        new Thread(s, "窗口1").start();
        new Thread(s, "窗口2").start();
        new Thread(s, "窗口3").start();
    }

}

上述代码中,我们显示创建了ReentrantLock的对象(由于此处是实现Runnable接口,所以不用使用static),随后使用lock.lock()锁定该段代码,为了保证unlock()一定会被执行,这里使用了try...finally...

总结

使用synchronized的优缺点

优点:解决了线程安全问题

缺点:浪费资源,在操作共享数据时,多线程其实是串行的,性能低

synchronized 与 Lock的对比

  • synchronized无论如何都需要代码段执行结束后才会被释放
  • Lock更加灵活,在任意地方只需要执行unlock就可以实现解锁
  • Lock作为接口,提供了多种实现类,适合各种复杂的场景,效率更高

ChatGPT | 对于Lock优点的总结

Lock 相对于 Synchronized 具有以下优点

  1. 灵活性Lock 提供了更多的灵活性和控制,可以支持更复杂的线程同步需求。例如,Lock 接口的实现类 ReentrantLock 允许在锁上进行条件等待和超时等待,而 synchronized 关键字不支持这些功能。
  2. 可中断性Lock 接口的实现类可以支持线程中断,即在等待锁的过程中可以响应中断请求,而 synchronized 关键字却不支持中断操作。
  3. 公平性Lock 接口的实现类可以支持公平锁,即按照请求锁的顺序依次获取锁,而 synchronized 关键字默认是非公平的。公平锁在某些场景下能够更好地保证线程执行的公平性。
  4. 性能优化:在某些情况下,Lock 的性能可能比 synchronized 更好。尤其是在高度竞争的场景下,Lock 的一些实现(如 ReentrantLock)可能会比 synchronized 更高效。
  5. 可替代性Lock 接口的实现类提供了一些 tryLock() 等方法,允许在获取锁时进行非阻塞的尝试,这在一些需要避免线程阻塞的场景下很有用。

总的来说,虽然 synchronized 使用起来更简单,但是 Lock 提供了更多的功能和灵活性,并且在某些场景下可能具有更好的性能表现。

ChatGPT | 对于Lock效率更高的描述

在高度竞争的场景下,Lock 的一些实现可能比 synchronized 更高效的原因主要有以下几点:

  1. 更精细的控制Lock 接口的实现类(如 ReentrantLock)提供了更多的控制选项,允许程序员更精细地控制锁的获取和释放过程。相比之下,synchronized 关键字对锁的控制较为简单,可能无法满足某些高度竞争场景下的需求。
  2. 减少竞争ReentrantLock 实现了可重入锁的概念,允许同一个线程多次获取同一个锁,而不会造成死锁。这在某些情况下可以减少线程间的竞争,提高整体的并发性能。
  3. 非阻塞的尝试获取锁Lock 接口提供了 tryLock() 方法,允许线程尝试获取锁而不会被阻塞。在高度竞争的场景下,这种非阻塞的尝试获取锁的方式可能比 synchronized 更适合,因为它避免了线程长时间地等待锁的释放。
  4. 条件等待Lock 接口的实现类支持条件等待机制,允许线程在等待某个条件成立时释放锁并进入等待状态,而 synchronized 关键字无法直接支持这种功能。条件等待可以减少不必要的锁竞争,提高程序的性能。

总的来说,Lock 接口的一些实现(如 ReentrantLock)在设计上更加灵活,提供了更多的功能和选项,使得它们在高度竞争的并发场景下能够更高效地管理锁的获取和释放,从而提升整体性能。

死锁

诱发原因

  1. 互斥条件
  2. 占用且等待
  3. 不可抢夺(或不可抢占)
  4. 循环等待

同时满足以上四个条件,就会触发死锁

解决方案

只要破坏任意一个条件即可

状况方案
互斥条件无法解决
占用且等待一次申请所有的资源
不可抢夺(或不可抢占)一段时间内无法执行,则主动释放自己的锁即可
循环等待将资源修改为线程顺序。申请资源时,现申请顺序号较小的,可以避免循环等待问题

死锁的示例

以下代码中,线程1会获取s1作为锁,沉睡100毫秒,此时线程2获取s2作为一个锁,沉睡100毫秒;

当线程1的睡眠结束后,会等待获取s2作为锁,但是由于此时s2正在被线程2持有,所以只能等待线程2先执行结束后,才能获得s2

当线程2的睡眠结束后,会等待获取s1作为锁,但是由于此时s1正在被线程1持有,所以只能等待线程1先执行结束后,才能获得s1

此时两个线程便开始互相等待,等待对方执行结束后,自己才能继续执行了

public class Demo11 {

    public static void main(String[] args) {
        StringBuffer s1 = new StringBuffer();
        StringBuffer s2 = new StringBuffer();

        new Thread() {
            @Override
            public void run() {
                synchronized (s1) {
                    s1.append("a");
                    s2.append("1");

                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                    synchronized (s2) {
                        s1.append("b");
                        s2.append("2");
                        System.out.println(s1);
                        System.out.println(s2);
                    }
                }
            }
        }.start();

        new Thread() {
            @Override
            public void run() {
                synchronized (s2) {
                    s1.append("c");
                    s2.append("3");

                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                    synchronized (s1) {
                        s1.append("d");
                        s2.append("4");
                        System.out.println(s1);
                        System.out.println(s2);
                    }
                }
            }
        }.start();
    }
}

线程间的通讯

简介

在某些情况下,我们可能会需要多个线程来共同完成一件任务,并且我们希望他们有规律的执行,那么多线程之间需要一些通讯机制,可以协调他们工作,以实现多线程共同操作一份数据。

线程的通讯机制,其实就是对于线程的一些方法的使用。

方式 1 | 等待唤醒机制

wait():调用方法,该线程就会进入阻塞状态,并且释放同步监视器

notify():唤醒执行权限最高的一个线程,如果执行权限一样,则随机唤醒

notifyAll():唤醒所有正在wait的线程

此三个方法的调用者,必须是同步监视器。

class PrintNumber implements  Runnable {

    private int num = 1;

    @Override
    public void run() {
        while(true) {
            synchronized (this){
                // 随机唤醒一个线程,此处只有一个线程正在wait,所以会唤醒该线程
                notify();
                if(num <= 100) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println(Thread.currentThread().getName() + num);
                    num++;
                    try {
                        // 线程一旦执行此方法,就进入等待状态,同时会释放对监视器的调用
                        wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                } else {
                    break;
                }
            }
        }
    }
}
public class Demo13 {

    public static void main(String[] args) {
        PrintNumber p = new PrintNumber();
        new Thread(p,"线程1 --").start();
        new Thread(p,"线程2 ++").start();
    }

}

以上代码中,当第一个线程进入同步代码块,并执行wait()之后,该线程会被阻塞并释放同步监视器,此时第二个线程进入同步代码块,唤醒第一个线程,但由于此时同步监视器正在被第二个线程持有,第一个线程无法继续执行,直到第二个线程执行到wait()方法,阻塞并释放同步监视器后,第一个线程才会进入到同步代码块,随后唤醒第二个线程,第二个线程又被挡在同步代码块之外了,直到第一个线程再次wait()

对比 | wait 与 sleep 的异同

同:

  • 一旦执行,线程都会进入阻塞状态

异:

  • wait声明在Object中,sleep声明在Thread中
  • wait只能使用在同步代码块或同步方法中,sleep可以声明在任意位置
  • wait会释放同步监视器,sleep不会释放同步监视器
  • 结束阻塞的方式不同,wait可以被唤醒,也可以等计时唤醒,sleep只能计时唤醒

附件

附件 1 | 多线程售票输出

窗口1售票,票号为100
窗口1售票,票号为99
窗口1售票,票号为98
窗口1售票,票号为97
窗口1售票,票号为96
窗口1售票,票号为95
窗口1售票,票号为94
窗口3售票,票号为100
窗口2售票,票号为100
窗口2售票,票号为91
窗口2售票,票号为90
窗口2售票,票号为89
窗口2售票,票号为88
窗口2售票,票号为87
窗口2售票,票号为86
窗口2售票,票号为85
窗口2售票,票号为84
窗口2售票,票号为83
窗口2售票,票号为82
窗口3售票,票号为92
窗口3售票,票号为80
窗口3售票,票号为79
窗口3售票,票号为78
窗口3售票,票号为77
窗口1售票,票号为93
窗口1售票,票号为75
窗口3售票,票号为76
窗口3售票,票号为73
窗口3售票,票号为72
窗口3售票,票号为71
窗口3售票,票号为70
窗口3售票,票号为69
窗口3售票,票号为68
窗口2售票,票号为81
窗口3售票,票号为67
窗口3售票,票号为65
窗口3售票,票号为64
窗口3售票,票号为63
窗口3售票,票号为62
窗口3售票,票号为61
窗口3售票,票号为60
窗口3售票,票号为59
窗口3售票,票号为58
窗口3售票,票号为57
窗口3售票,票号为56
窗口3售票,票号为55
窗口3售票,票号为54
窗口3售票,票号为53
窗口3售票,票号为52
窗口3售票,票号为51
窗口3售票,票号为50
窗口3售票,票号为49
窗口3售票,票号为48
窗口3售票,票号为47
窗口3售票,票号为46
窗口3售票,票号为45
窗口3售票,票号为44
窗口3售票,票号为43
窗口3售票,票号为42
窗口1售票,票号为74
窗口1售票,票号为40
窗口1售票,票号为39
窗口1售票,票号为38
窗口1售票,票号为37
窗口1售票,票号为36
窗口1售票,票号为35
窗口1售票,票号为34
窗口1售票,票号为33
窗口1售票,票号为32
窗口1售票,票号为31
窗口1售票,票号为30
窗口1售票,票号为29
窗口1售票,票号为28
窗口1售票,票号为27
窗口1售票,票号为26
窗口1售票,票号为25
窗口1售票,票号为24
窗口1售票,票号为23
窗口1售票,票号为22
窗口1售票,票号为21
窗口1售票,票号为20
窗口1售票,票号为19
窗口1售票,票号为18
窗口1售票,票号为17
窗口1售票,票号为16
窗口1售票,票号为15
窗口1售票,票号为14
窗口1售票,票号为13
窗口1售票,票号为12
窗口1售票,票号为11
窗口1售票,票号为10
窗口1售票,票号为9
窗口1售票,票号为8
窗口1售票,票号为7
窗口1售票,票号为6
窗口1售票,票号为5
窗口1售票,票号为4
窗口1售票,票号为3
窗口1售票,票号为2
窗口1售票,票号为1
窗口3售票,票号为41
窗口2售票,票号为66

附件 2 | 同步代码块 或 同步方法的输出

窗口1售票,票号为100
窗口1售票,票号为99
窗口1售票,票号为98
窗口1售票,票号为97
窗口1售票,票号为96
窗口1售票,票号为95
窗口1售票,票号为94
窗口1售票,票号为93
窗口1售票,票号为92
窗口1售票,票号为91
窗口1售票,票号为90
窗口1售票,票号为89
窗口1售票,票号为88
窗口1售票,票号为87
窗口1售票,票号为86
窗口1售票,票号为85
窗口1售票,票号为84
窗口1售票,票号为83
窗口1售票,票号为82
窗口1售票,票号为81
窗口1售票,票号为80
窗口1售票,票号为79
窗口1售票,票号为78
窗口1售票,票号为77
窗口1售票,票号为76
窗口1售票,票号为75
窗口1售票,票号为74
窗口1售票,票号为73
窗口1售票,票号为72
窗口1售票,票号为71
窗口1售票,票号为70
窗口1售票,票号为69
窗口1售票,票号为68
窗口1售票,票号为67
窗口3售票,票号为66
窗口3售票,票号为65
窗口3售票,票号为64
窗口3售票,票号为63
窗口3售票,票号为62
窗口3售票,票号为61
窗口3售票,票号为60
窗口3售票,票号为59
窗口3售票,票号为58
窗口3售票,票号为57
窗口3售票,票号为56
窗口3售票,票号为55
窗口3售票,票号为54
窗口3售票,票号为53
窗口3售票,票号为52
窗口3售票,票号为51
窗口3售票,票号为50
窗口3售票,票号为49
窗口3售票,票号为48
窗口3售票,票号为47
窗口3售票,票号为46
窗口3售票,票号为45
窗口3售票,票号为44
窗口3售票,票号为43
窗口3售票,票号为42
窗口3售票,票号为41
窗口3售票,票号为40
窗口3售票,票号为39
窗口3售票,票号为38
窗口3售票,票号为37
窗口3售票,票号为36
窗口3售票,票号为35
窗口3售票,票号为34
窗口3售票,票号为33
窗口3售票,票号为32
窗口3售票,票号为31
窗口3售票,票号为30
窗口3售票,票号为29
窗口3售票,票号为28
窗口3售票,票号为27
窗口3售票,票号为26
窗口3售票,票号为25
窗口3售票,票号为24
窗口3售票,票号为23
窗口3售票,票号为22
窗口3售票,票号为21
窗口3售票,票号为20
窗口3售票,票号为19
窗口3售票,票号为18
窗口3售票,票号为17
窗口3售票,票号为16
窗口3售票,票号为15
窗口3售票,票号为14
窗口3售票,票号为13
窗口3售票,票号为12
窗口3售票,票号为11
窗口3售票,票号为10
窗口3售票,票号为9
窗口3售票,票号为8
窗口3售票,票号为7
窗口3售票,票号为6
窗口3售票,票号为5
窗口3售票,票号为4
窗口3售票,票号为3
窗口3售票,票号为2
窗口3售票,票号为1
  • 21
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值