• 【QT开发(11)】QT 线程QThread


    Qt的线程支持与平台无关的:

    • 线程类、
    • 一个线程安全的发送事件方式
    • 跨线程的信号-槽的关联

    这使得可以从分利用多处理器机器,有效解决不冻结一份应用程序用户界面的情况下,处理一个耗时操作的问题。

    1、QThread 一个与平台无关的线程类

    QThread 从run() 函数开始执行,(一般程序是main 开始执行)。默认是 通过run() 调用exec() 开启事件循环,并在线程内运行一个Qt事件循环。

    1、先定义一个 MyThread类线程,继承自QThread
    2、MyThread 需要定义run(), run()函数结尾需要有exec() 开启循环。
    3、从外部创建该线程的实例,然后调用 start() 来执行这个线程,(start()回去调用run())。
    4、每个线程会在开始、结束、终止发送started(),finished(),terminated() 信号
    5、可以用isFinished() isRunning() 查询线程状态,可以用wait() 阻塞。
    6、堆栈可以从操作系统获得,或者 setStackSize() 设置自定堆栈大小。
    7、exec()是启动,调用exit() quit()可停止事件循环。

    在QT中,当你多次调用一个线程的start()函数时,通常会发生以下几种情况:
    - 如果线程已经在运行,那么再次调用start()函数将不会有任何效果。线程不会重新启动,而且程序也不会报错。这是因为一旦线程开始运行,它就会独立于主线程执行,并且无法通过再次调用start()函数来重新启动。
    - 如果线程当前处于未运行状态,并且你多次调用start()函数,那么线程会开始多次运行。也就是说,线程会启动多次,每次调用start()函数都会创建一个新的线程实例。
    需要注意的是,如果你想要多次执行相同的线程,那么每次调用start()函数时,你需要确保线程在执行完毕后自行销毁。否则,随着时间的推移,你的应用程序可能会创建越来越多的线程实例,这可能会导致系统资源耗尽,甚至导致程序崩溃。
    此外,如果你想要控制线程的运行次数,你可以使用循环或其他控制结构来实现。例如,你可以使用一个计数器来跟踪线程的启动次数,并在达到特定次数后停止启动新的线程实例。

    队列关联机制

    线程中拥有一个事件循环,使它能够关联其他的线程中的信号到本线程的槽上,基于队列关联机制。

    在QT框架中,信号(signal)和槽(slot)是一种重要的机制,它们使得观察者模式在C++中得以实现。

    信号(signal):当某个事件发生时,比如按钮被点击,事件源(即按钮)就会发出一个信号。这个发出是没有目的的,类似广播。

    槽(slot):如果某个对象对某个信号感兴趣,它就会使用连接(connect)函数,意思是,用它的一个函数(称为槽)来处理这个信号。当信号发出时,被连接的槽函数会自动被回调。

    这种机制使得我们能够设计出解耦的程序,增强我们的技术设计能力。例如,一个窗口可以同时与多个按钮连接,当这些按钮被点击时,都会触发相同的窗口更新操作。

    • connect() 函数进行sign和slot 关联,会促使Qt::ConnectionType 的参数设置位Qt::QueuedConnection。
    • 可以使用事件循环的类,例如QTimer和QTcpSocket
    • 线程中无法使用任何界面部件的类。

    使用terminate() 终止运行时,无法进行一些清理工作

    该函数是很危险的,一般不建议使用。

    静态函数 currentThreadId() 和 currentThread() 可以返回当前执行的线程的表示符号ID和指针。

    QThread 提供多个平台无关的睡眠函数,其中 sleep 是秒,msleep是毫秒,usleep是微妙。

    例子1 基础 QThread类

    目的:我们在 窗口中 通过点击SatrtThread 来开启一条线程,在控制台打印信息;通过点击CLoseThread 来关闭这个线程。

    1、在窗口新建两个按钮 SatrtThread 和 CLoseThread ;
    2、添加一个 线程类

    // .h
    #include 
    
    ...
    class MyThread : public QThread{
        Q_OBJECT
    
    public:
        explicit MyThread(QObject *parent=0);
        void stop();
    
    protected:
        void run();
    
    private:
        volatile bool stopped;
        // volatile 线程的可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
    
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    // .cpp
    
    // 构造函数,初始化时,对stopped 赋值。
    MyThread::MyThread(QObject *parent) : QThread(parent) {
        stopped = false;
    }
    
    // 线程的run()  函数,该线程将就是用来打印数据。
    // run()函数结尾exec() 开启循环,我们用while的话,就不需要exec()了
    void MyThread::run(){
    qreal i =0;
    while(!stopped){
        qDebug() << QString("in mythread: %1").arg(i);
        msleep(1000);
        i++;
    }
    stopped = false;
    
    }
    
    // 结束
    void MyThread::stop(){
    qDebug() << QString("stopped in mythread");
    stopped =true;
    
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    3、在界面的类中增加MyThread thread;
    3.1、然后我们在 按钮的slot 里面用 start() 去开启。我们第一节说过从外部创建该线程的实例,然后调用 start() 来执行这个线程,(start()回去调用run())

    
    void c::on_StartThread_clicked()
    {
        thread.start();
    }
    
    // 可以用isFinished() isRunning() 查询线程状态,可以用wait() 阻塞。
    void c::on_CLoseThread_clicked()
    {
        qDebug() << QString("stopped in mythread");
        if(thread.isRunning()){
             thread.stop();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    4、效果
    请添加图片描述

    例子2 : 使用moveToThread() 函数将普通类改变所在的线程

    在这里插入图片描述

    // .h
    #include 
    
    ...
    class Worker: public QObject{
        Q_OBJECT
    public slots:
        void run();
    };
    
    class Controller: public QObject{
        Q_OBJECT
        QThread workThread;
    public:
    Controller();
    ~Controller();
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    // .cpp
    void Worker::run(){
    qreal i =0;
    while(!stopped){
        qDebug() << QString("in mythread: %1").arg(i);
        msleep(1000);
        i++;
    }
    }
    
    Controller::Controller(){
    Woker *worker = new Worker;
    worker->moveToThread(&workThread);
    workThread.start();
    
    }
    Controller::~Controller(){
    workThread.quit();
    workThread.exit();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    另外,可以将线程中的任意对象的任意信号关联到 Worker 的 slots 中,不同信号之间的信号和槽的关联是安全的。关于槽和信号的补充信息可以看【QT开发(12)】QT信号与槽的基本知识点
    在这里插入图片描述

    2 同步线程

    有一种场景,线程之间需要停下来等待其他的线程,例如,两个线程尝试同事访问相同的全局变量,因此需要线程的同步。

    1、QMutex 互斥锁,在任何事件至多有一个线程可以获得mutex。如果一个线程尝试获得mutex,而此时mutex 已经被锁住了,则这个线程将会睡眠,直到mutex被解锁为止。互斥锁经常用于对共享数据的访问进行保护(例如,两个线程同事访问数据)

    2、QReadWriteLock 读写锁,分为读取访问和写入访问,允许多个线程对数据进行读取。某些情况。替换mutex可以提升多线程并发度。

    3、QSemaphore 信号量,是QMutex的一般化,它用来保护一定数量的相同资源,而互斥锁 mutex只能保护一个资源。用信号量QSemaphore 实现生产者和消费者例子。

    4、QWaitCondition,条件变量。允许一个线程在一些条件满足的时候唤醒其他的线程。一个或者多个线程可以被阻塞来顶戴一个QWaitCondition,从而设置一个可以用于wakeOne() 和 wakeAll() 的条件。

    例子,信号量 QSemaphore 实现生产者消费者问题。

    用信号量来保护对生产者线程和消费者线程共享的环形缓冲区的访问。

    1、生产者向缓冲区写入数据,指导它达到缓冲区的终点,这时他会从起点开始,重新覆盖已经存在的数据。
    2、消费者线程读取产生的数据,将其输出。

    这个例子包含了两个类,Producer 和 Consumer,他们继承自 QThread。环形缓冲区用来对这两个类之间的通信,保护缓冲区的信号量被设置位全局变量。

    1、建立 B.h 头文件。
    我们一般不要再.h 头文件申明全局变量哟,在 .cpp 里面申明全局变量!!

    我们定义了两个线程Producer 类和Consumer 类

    /*
     * =========================== B.h ==========================
     *                                        CREATE --
     *                                        MODIFY -- 
     * ----------------------------------------------------------
     */
    #ifndef CLASS_B_H
    #define CLASS_B_H
    
    #include "innDebug.h"
    #include 
    #include 
    #include 
    #include 
    
    class Producer:public QThread{
    public:
        void run();
    };
    
    class Consumer:public QThread{
    public:
        void run();
    };
    #endif
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    2、B.cpp 里面实现 环形缓冲区,以及两个类的run()方法

    定义了全局变量,因为不需要在该cpp源文件以外使用这些全局变量。因此,你不需要:1、不需要再.h文件 中申明extern了;2、建议static 修饰全局量,当然也可以不修饰也不会报错,static修饰全局量后就仅仅只允许被本cpp文件使用。

    /*
     * =========================== B.cpp ==========================
     *                                        CREATE --
     *                                        MODIFY -- 
     * ----------------------------------------------------------
     */
    #include "B.h"
    const int DataSize=10;
    const int BufferSize=5;
    // Producer create circle data buffer!
    static char buffer[BufferSize];
    QSemaphore freeBytes(BufferSize);
    // when init, usedBytes is zero.
    QSemaphore usedBytes(BufferSize);
    
    
    void Producer::run(){
    qsrand((QTime(0,0,0).secsTo(QTime::currentTime())));
    
    for (int i =0;i <DataSize;++i)
    {
     freeBytes.acquire();
     buffer[i%BufferSize]="ACGT"[(int)qrand()%4];
     qDebug()<< QString("producer:%1").arg(buffer[i%BufferSize]);
     usedBytes.release();
     msleep(300);
    }
    }
    
    void Consumer::run(){
        for (int i=0;i<DataSize;++i ){
            usedBytes.acquire();
            qDebug() << QString("COnsumer:%1").arg(buffer[i%BufferSize]);
            freeBytes.release();
     msleep(1100);
        }
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    相关解释《Creator快速入门_第三版__霍亚飞编著》

    在这里插入图片描述

    3、在 主线程类 中定义这两个类

    class c : public QDialog
    {
        Q_OBJECT
    
    public:
        explicit c(QWidget *parent = nullptr);
        ~c();
        Producer producer;
        Consumer consumer;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    4、在主线程的按钮槽里面定义启动这两个类。
    如果使用了 wait() 函数,会阻塞主线程直到这两个类完成他们的工作。

    void c::on_StartThread_clicked()
    {
        producer.start();
        consumer.start();
        //producer.wait();
        //consumer.wait();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    演示

    在这里插入图片描述

    3 可重入re-entrant和线程安全thread-safe

    用于标记类和函数

    • 线程安全:一个线程安全的函数可以同事被多个线程调用,即使他们共享了数据。因为共享数据的所有实例都被序列化了
    • 一个可重入函数可以同时被多个线程调用,但是只能是在每个调用使用自己的数据的情况下。

    一个线程安全的总是可重入的。

    注意:只有文档中标记位线程安全的QT类,才可以用于多线程!!!!!
    没有标记为线程安全或者可重入,不应该被多线程使用;其类的一个特定实例不应该被多个线程访问。

    1、可重入

    一般来说,C++类是可重入的,因为他们只访问自己的数据成员。

    在这里插入图片描述

    可以从截图看出,++ 操作不总是原子操作,不是线程安全的。

    2、线程安全

    ++ 操作需要在完成前,不被其他人中断。一个简单方法就是使用QMutex 对数据成员进行保护。
    在这里插入图片描述

    在C++中,mutable是一个类型修饰符,用于指定一个成员函数可以修改一个常量对象的成员。具体来说,mutable限定符允许在常量对象上调用该成员函数,并且在该成员函数内部修改该对象的常量成员。
    通常,在C++中,如果一个对象被声明为const,那么该对象的成员函数就不能修改该对象的任何成员。这是为了确保对象的不可变性。然而,有时需要在某些特殊情况下修改对象的常量成员,这时就可以使用mutable限定符。
    例如,考虑一个只读属性,它通常会被声明为const成员函数,以防止外部代码修改该属性。然而,如果该属性需要在内部被修改,并且只有在特定条件下才需要修改,那么就可以将该属性声明为mutable,并在相应的成员函数中使用该属性。
    需要注意的是,使用mutable修饰符可能会破坏对象的封装性,并降低代码的可维护性。因此,应该谨慎使用mutable修饰符,并仅在确实需要的情况下使用。

    4 QObjects

    QThread 继承自QObject。QObject 可以发射信号并调用其他线程的槽,而且向其他线程中的对象发送事件。这些实现的基础是每个线程都允许有自己的事件循环。

    1、QObject的可重入性

    大多数非GUI子类可以重入。QTime,QTcpSocket,QUdpSocket,QProcess。
    这些类被设计在位单一线程中创建和使用,在一个线程中创建一个对象。然后再另外一个线程中调用这个对象的一个函数是无法保证一定可以工作的!!!

    需要注意的3 个约束条件

    • QObject 的子对象必须在创建QObject 的父对象的线程中创建。这就是说,永远不要 将 QThread 对象 作为在该线程中创建对象的父对象,因为QThread 对象本身是在其他线程中创建的。

    在Qt中,QObject是所有用户对象的基类。我们可以创建QObject的子对象来创建自定义的QObject子类。下面是一个简单的例子:

    
    #include   
      
    // 自定义的QObject子类  
    class MyObject : public QObject  
    {  
        Q_OBJECT  
      
    public:  
        MyObject(QObject *parent = nullptr) : QObject(parent) {  
            // 在这里可以添加一些初始化代码  
        }  
      
        // 可以添加一些自定义的信号、槽或者其他成员变量  
    };  
      
    int main(int argc, char *argv[]) {  
        QCoreApplication app(argc, argv);  
      
        // 创建MyObject的实例  
        MyObject *myObject = new MyObject(&app);  
      
        return app.exec();  
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    这个例子中,我们创建了一个名为MyObject的自定义QObject子类。在MyObject的构造函数中,我们调用了QObject的构造函数,并传递了父对象(在这个例子中是app)。然后,我们可以在MyObject类中添加自定义的信号、槽或者其他成员变量。最后,在main函数中,我们创建了一个MyObject的实例。

    main 函数是父进程可以创建QObject 子对象。

    • 事件驱动对象只能在单一的线程中使用。例如,你不能在创建定时器的线程以外的其他线程中启动这个定时器

    事件驱动对象是指通过事件来驱动对象行为的计算机程序对象。在事件驱动的系统中,当一个事件发生时,系统会根据事件的类型和状态,调用相应的处理程序或执行相应的操作。事件驱动对象通常包括事件处理器、事件对象和事件队列等组成部分。事件处理器是用于处理事件的对象,它包含了处理事件的程序代码和处理事件所需的数据。事件对象是表示事件的对象,它包含了事件的类型、状态和其他相关信息。事件队列是用于存储事件的对象集合,它按照事件发生的时间顺序排列。在事件驱动的程序中,当一个事件发生时,系统会将该事件对象放入事件队列中。事件处理器会不断地检查事件队列,直到发现一个需要处理的事件。然后,事件处理器会从事件队列中取出该事件对象,并调用相应的处理程序或执行相应的操作来处理该事件。

    不可以在对象所在的线程 QObject::thread() 以外的其他线程中,启动一个定时器或者套接字。

    在C++中,使用Qt库时,一个QObject对象(及其子对象)的生命周期与创建该对象的线程相同。换句话说,一个QObject对象只能在其所在线程中活动。因此,如果你在一个线程中创建了一个QObject对象(例如,通过调用new QObject()),那么你只能在创建该对象的同一线程中使用该对象。

    在多线程编程中,一个常见的问题是跨线程通信。例如,你可能在一个线程中创建了一个定时器(通过new QTimer()),并希望在其他线程中启动这个定时器。然而,由于QObject对象的生命周期与创建它的线程相同,你不能在创建定时器的线程以外的其他线程中启动这个定时器。如果你尝试这样做,可能会导致程序崩溃或其他未定义的行为。

    以下是一个例子,展示了为什么不能在对象所在的线程QObject::thread()以外的其他线程中启动一个定时器:

    #include 
    #include 
    #include 
    
    void startTimerInOtherThread() {
        QTimer *timer = new QTimer();
        timer->moveToThread(QThread::currentThread()); // 试图将定时器移动到当前线程
        QObject::connect(timer, &QTimer::timeout, QCoreApplication::instance(), &QCoreApplication::quit);
        timer->start(1000); // 试图在错误的主线程之外启动定时器
    }
    
    int main(int argc, char *argv[]) {
        QCoreApplication app(argc, argv);
        QThread thread;
        thread.start();
        startTimerInOtherThread(); // 在新线程中尝试启动定时器
        thread.wait();
        return app.exec();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    这段代码尝试在一个新线程中创建一个定时器,并在该线程中启动定时器。然而,这会导致程序崩溃,因为定时器试图在其不是主线程的线程中启动。如果你希望在另一个线程中使用定时器,你应该在那个线程中创建定时器。

    • 必须确保删除QThread 对象以前,删除在该线程中创建的所有对象,可以在run函数中的栈上创建对象来保证这一点。

    一般,GUI类无法重入,无法在主线程以外的线程中使用GUI类。
    在这里插入图片描述

    2 每个线程的事件循环

    每一个线程都可以有它自己的事件循环。
    在这里插入图片描述

    在第一节QThread中, MyThread 需要定义run(), run()函数结尾需要有exec() 开启循环。从外部创建该线程的实例,然后调用 start() 来执行这个线程,(start()回去调用run())。exec()是启动,调用exit() quit()可停止事件循环。
    在这里插入图片描述

    发往这个QObject的事件,有该线程的事件循环进行分派。
    在这里插入图片描述
    没有允许exec事件循环时,事件不会传递到对象。

    可以手动使用线程安全函数 QCoreApplication::postEvent() 在任何事件向任何对象发送事件。该事件会被该对象的线程的事件循环进行分派,如果没有启动exec,应该也不会生效吧。
    在这里插入图片描述

    QCoreApplication::postEvent()QCoreApplication::sendEvent() 是 Qt 框架中用于处理事件的两个重要方法。它们通常用于处理 GUI 事件,例如按钮点击,窗口关闭等等。这些事件由 Qt 的事件循环系统处理。

    • QCoreApplication::postEvent():此方法将一个事件添加到事件队列中,然后立即返回。事件循环系统会在适当的时候处理这个事件。这样,事件可以在不同的线程中处理,这对于多线程应用程序来说是非常有用的。
    • QCoreApplication::sendEvent():此方法直接将一个事件发送给指定的对象。它不会将事件添加到事件队列中,而是立即处理事件。如果事件的目标对象没有足够的事件处理能力,那么此方法会调用该对象的 reject() 方法。

    总的来说,QCoreApplication::postEvent() 更适用于将事件放到事件队列中以便稍后处理,而 QCoreApplication::sendEvent() 更适用于立即处理事件。在选择使用哪一个方法时,需要考虑你的应用程序的特定需求和上下文。

    3 从其他线程访问QObject 子类

    QObject 子类不是线程安全的。
    建议用mutex 保护这个QObject 子类的内部数据的所有访问。

    在C++中,你可以使用互斥锁(mutex)来保护QObject子类的内部数据。以下是一个简单的示例代码:

    #include 
    #include 
    
    class MyQObject : public QObject {
        Q_OBJECT
    
    public:
        MyQObject(QObject *parent = nullptr) : QObject(parent) {
            // 创建一个互斥锁对象
            QMutex mutex;
    
            // 将互斥锁对象作为保护成员变量
            this->mutex = &mutex;
        }
    
        void setValue(int value) {
            // 锁定互斥锁
            this->mutex->lock();
    
            // 在这里执行对内部数据的访问和修改操作
            // ...
    
            // 解锁互斥锁
            this->mutex->unlock();
        }
    
        int getValue() const {
            // 锁定互斥锁
            this->mutex->lock();
    
            // 在这里执行对内部数据的访问操作
            // ...
    
            // 解锁互斥锁
            this->mutex->unlock();
    
            return 0; // 返回值可以根据你的需求进行修改
        }
    
    private:
        QMutex *mutex; // 互斥锁对象指针,用于保护内部数据访问
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    在这个示例中,我们创建了一个名为MyQObject的类,它继承自QObject。在MyQObject的构造函数中,我们创建了一个QMutex对象,并将其赋值给一个保护成员变量mutexsetValue()getValue()方法在访问和修改内部数据之前都会锁定互斥锁,然后在完成后解锁。这样可以确保在任何时候只有一个线程能够访问内部数据,从而保护数据的完整性。

    QThread的槽一般是不安全的,除非用mutex保护成员变量。但是可以安全的在Qthread:run() 发送信号,因为发射信号是线程安全的。

    4 跨线程的信号和槽

    在这里插入图片描述

    代码仓库

    https://gitee.com/hiyanyx/qt5.14-cpp_dds_-project/tree/QThread
    分支:QThread

    (正文完)

    参考

    《QT Creator快速入门_第三版__霍亚飞编著》

  • 相关阅读:
    HTML万字学习总结
    结合使用数据库和最小 API、Entity Framework Core 和 ASP.NET Core
    数据绑定之数据类型转换
    ctrl+delete删除怎么恢复?实用小技巧分享
    Linux入门攻坚——3、基础命令学习-文件管理、别名、glob、重定向、管道、用户及组管理、权限管理
    消防安全无小事!飞凌T507国产核心板助力消防疏散系统智能化升级
    Spring framework Day14:配置类的Lite模式和Full模式
    Ubuntu 18.04下普通用户的一次提权过程
    Eclipse在tomcat运行点击跳转404
    【week307-amazon】leetcode2386. 找出数组的第 K 大和
  • 原文地址:https://blog.csdn.net/djfjkj52/article/details/133950693