【多线程】线程互斥 {竞态条件,互斥锁的基本用法,pthread_mutex系列函数,互斥锁的原理;死锁;可重入函数和线程安全}

一、进程线程间通信的相关概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源。确切的说,临界资源在同一时刻只能被一个执行流访问。
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
  • 互斥:通过互斥操作能够保证在任何时刻,有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

二、互斥锁

2.1 竞态条件

  • 大部分情况,线程使用的数据都是局部变量,变量存储在线程栈空间内。这种情况,变量归属单个线程,其他线程无法访问这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。比如,全局数据、堆空间数据。
  • 多个线程并发的操作共享变量,会带来数据竞争,冲突以及数据不一致等竞态条件问题。

竞态条件:

  • 竞态条件(Race Condition)是指多个线程或进程同时访问共享资源,并且对资源的访问顺序不确定,导致最终结果的正确性依赖于线程执行的具体时序。竞态条件可能导致不可预测的结果,破坏程序的正确性和一致性。
  • 竞态条件通常发生在多个线程或进程同时对共享资源进行读写操作时,其中至少一个是写操作。当多个线程或进程同时读写共享资源时,由于执行顺序的不确定性,可能会导致数据的不一致性、丢失、覆盖等问题。

测试程序:

int tickets = 100; //共有100张票void *ThreadRoutine(void *name)
{while (1){if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;}else{break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}int main()
{srand((unsigned)time(nullptr));pthread_t tid1, tid2, tid3, tid4;pthread_create(&tid1, nullptr, ThreadRoutine, (void *)"child thread 1");pthread_create(&tid2, nullptr, ThreadRoutine, (void *)"child thread 2");pthread_create(&tid3, nullptr, ThreadRoutine, (void *)"child thread 3");pthread_create(&tid4, nullptr, ThreadRoutine, (void *)"child thread 4");pthread_join(tid1, nullptr);pthread_join(tid2, nullptr);pthread_join(tid3, nullptr);pthread_join(tid4, nullptr);return 0;
}

运行结果:

在这里插入图片描述

  1. 同一编号的票被多个线程售出
  2. 某些线程售出了负数编号的票

该程序存在竞态条件问题,即公共变量tickets被多执行流同时访问和修改。

提示:除了多线程进程外,信号处理函数也是异步执行的(多执行流执行),同样存在竞态条件问题。

并发运行问题

例如:tickets > 0--tickets操作并不是原子性操作,而是对应三条汇编指令:

  1. 将数据从内存加载到寄存器(当前线程的上下文中)
  2. 进行逻辑运算或算数运算
  3. 将数据写回内存

在这三条步骤的其中任何一步,该线程都有可能被切换,切换前线程上下文会被保存。其他线程在执行时也对tickets进行了访问和修改。当原线程再次被CPU调度执行时,恢复上下文数据,此时的寄存器与内存就会发生数据不一致的错误。

并行运行问题

多核CPU允许多线程并行(同时)运行。在ThreadRoutine函数中,由于没有对访问tickets的操作进行互斥,可能会导致多个线程同时读取和修改tickets变量,从而产生不可预测的结果。

例如:当多个线程同时执行if (tickets > 0)语句时,可能会出现以下情况:

  • 线程A和线程B同时读取tickets的值为1。
  • 线程A先执行--tickets操作,将tickets的值减为0。
  • 线程B再执行--tickets操作,将tickets的值减为-1。

这样,就会出现某些线程售出了负数编号的票。


2.2 互斥锁的基本用法

为了解决竞态条件问题,可以使用互斥锁(Mutex)来保护对tickets变量的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区(对tickets变量的访问),从而避免数据竞争的发生。

在这里插入图片描述

下面是互斥锁的基本使用方法:

  1. 定义互斥锁变量:在使用互斥锁之前,需要先定义一个互斥锁变量。可以使用pthread_mutex_t类型来声明互斥锁变量,例如:pthread_mutex_t mutex;
  2. 初始化互斥锁:在使用互斥锁之前,需要对互斥锁进行初始化。
    • 静态初始化:在定义互斥锁变量时,使用PTHREAD_MUTEX_INITIALIZER宏进行初始化。
    • 动态初始化:可以使用pthread_mutex_init函数来初始化互斥锁,例如:pthread_mutex_init(&mutex, NULL);。第一个参数是要初始化的互斥锁变量,第二个参数是互斥锁的属性,通常使用NULL表示使用默认属性;
  3. 加锁:在访问共享资源之前,需要先加锁。可以使用pthread_mutex_lock函数来加锁,例如:pthread_mutex_lock(&mutex);。如果互斥锁已经被其他线程锁定,那么当前线程会被阻塞,直到互斥锁被解锁。
  4. 访问共享资源:在互斥锁被锁定的情况下,可以安全地串行访问共享资源。
  5. 解锁:在访问共享资源完成后,需要解锁互斥锁,以便其他线程可以继续访问共享资源。可以使用pthread_mutex_unlock函数来解锁,例如:pthread_mutex_unlock(&mutex);
  6. 销毁互斥锁:
    • 不再需要使用互斥锁时,需要将其销毁。可以使用pthread_mutex_destroy函数来销毁互斥锁,例如:pthread_mutex_destroy(&mutex);
    • 静态初始化的互斥锁在程序结束时会自动被系统回收,无需手动销毁。
    • 不要销毁一个已经加锁的互斥量
    • 对于已经销毁的互斥量,要确保后面不会有线程再尝试加锁

我们将上面的售票程序加入互斥锁:

int tickets = 100; //临界资源
// 定义一个全局的互斥锁变量,并利用宏进行初始化(静态初始化)
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void *ThreadRoutine(void *name)
{while (1){// 在访问共享资源之前,需要先加锁。pthread_mutex_lock(&mtx);//临界区if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);}else{// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);break;}// 在此处解锁?不行,如果线程执行break,就不会解锁互斥锁。其他线程会被一直阻塞。usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}

运行结果:

在这里插入图片描述

需要注意的几点:

  1. 在访问共享资源完成后,需要解锁互斥锁。否则,其他线程会被一直阻塞。需要特别注意break, goto等跳转语句跳过解锁函数。

  2. 被互斥锁锁定的临界区只能串行执行(互斥访问),虽然保证了多执行流访问临界资源的安全性,但是会在一定程度上降低程序的效率

  3. 尽量保证被互斥锁锁定的代码都是访问临界资源的代码,不要将其他无关的操作也放入临界区中。因为相比并发或并行执行,临界区串行执行的效率较低。

再次改进上面的代码:

#define THREAD_NUM 5
int tickets = 100;//声明一个ThreadData类,使线程入口函数的参数更多样化。
class ThreadData
{
public:string _tname; //线程名pthread_mutex_t *_pmtx; //互斥锁变量的地址ThreadData(const string &tname, pthread_mutex_t *pmtx): _tname(tname),_pmtx(pmtx){};
};void *ThreadRoutine(void *arg)
{ThreadData *td = (ThreadData *)arg;while (1){// 在访问临界资源前进行加锁pthread_mutex_lock(td->_pmtx);if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", td->_tname.c_str(), tickets);--tickets;// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);}else{// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}delete td; // 释放各自的ThreadData结构空间return nullptr;
}int main()
{srand((unsigned)time(nullptr));// 在主线程栈区创建互斥锁变量pthread_mutex_t mtx;// 调用pthread_mutex_init初始化互斥锁(动态初始化)pthread_mutex_init(&mtx, nullptr);// 循环创建子线程pthread_t tid[THREAD_NUM];for (int i = 0; i < THREAD_NUM; ++i){string tmp = "child thread ";tmp += to_string(i + 1);ThreadData *td = new ThreadData(tmp, &mtx);pthread_create(tid + i, nullptr, ThreadRoutine, td); //传入ThreadData对象的指针}// 循环等待子线程for (int i = 0; i < THREAD_NUM; ++i){pthread_join(tid[i], nullptr);}// 在不再需要使用互斥锁时,需要将其销毁。(动态初始化的互斥锁需要进行销毁,而静态初始化不需要)pthread_mutex_destroy(&mtx);return 0;
}

新的问题:

  1. 加锁了之后,线程在执行临界区代码时,是否会被切换,会有问题吗?
    会被切换,但不会有问题!虽然被切换了,但是你是持有锁被切换的, 所以其他抢票线程要执行临界区代码,也必须先申请锁,而它是无法申请成功的。所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性!

  2. 对于访问临界资源的线程而言,临界区代码要么全部执行成功,要么全部不执行,访问临界资源的操作不可被中断(不能同时执行其他线程的临界区代码),这就是原子性的体现。

  3. 要访问临界资源,每一个线程都必须先申请锁,而锁本身就是一种共享资源,那么谁来保证锁的安全呢?

    所以,为了保证锁的安全,申请和释放锁,必须是原子的!


2.3 互斥锁的原理

  1. 在汇编层面,一条汇编语句要么已经执行完,要么就还没有执行,是原子性的。
  2. 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange汇编指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理器平台(并行),访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
  3. CPU的寄存器数据,本质就是当前执行流的上下文。寄存器的存储空间被所有执行流共享,但是寄存器的内容是被每一个执行流私有的。所以在切换线程时,要将当前线程的寄存器数据保存到其PCB中,并恢复下一个线程的寄存器数据。

以下是加锁的核心汇编伪代码:

lock:movb $0, %al // 将数值0,move到al寄存器中xchgb %al, mutex //交换al寄存器与mutex变量(内存)的数据if(al寄存器的内容 > 0){return 0;}else挂起等待;goto lock; //跳转到lock标签,再次申请锁
  1. 我们可以将互斥锁变量mutex理解成一个整形变量,值为1表示互斥锁未被线程持有;值为0,表示互斥锁已经被其他线程锁定。创建互斥锁变量并进行初始化后,其默认值为1。
  2. 由于exchange汇编指令是原子的,所以不管线程如何切换,只有一个线程能够将mutex(内存)中的1值交换到自己的寄存器当中,即该线程的上下文中。而线程上下文是线程的私有数据,实现了公有到私有的转换。同时寄存器当中的0值被交换到了mutex中,其他线程再进行交换也只能交换到0。
  3. 在进行if判断时,交换到1值的线程执行return 0,可以安全地进入临界区访问临界资源;而交换到0值的线程阻塞等待,直到互斥锁被解锁,这些线程才会被唤醒,然后再次尝试申请锁。

以下是解锁的核心汇编伪代码:

unlock:movb $1, mutex //将数值1,move到mutex变量(内存)唤醒等待mutex的线程;return 0;
  1. 当持有锁的线程访问完临界资源后,会将mutex变量重新置为1,即解锁互斥锁。
  2. 同时,应该唤醒等待互斥锁解锁的线程,让他们再次竞争申请锁。

回答之前的问题:

  1. 谁来保证锁的安全呢?

    为了保证锁的安全,申请和释放锁,必须是原子的!在设计加锁时,通过一条原子性的exchange指令,保证了加锁和解锁的原子性。

  2. 加锁了之后,线程在临界区中,是否会切换,会有问题吗?

    线程在临界区中也可能会被切换,但他是持有锁被切换的,所谓持有锁切换是指互斥锁的1值保存在当前线程的上下文,被当前线程私有。而其他线程即使被CPU调度执行,也无法抢占互斥锁,也就无法访问临界区代码。所以不会有任何问题。


三、可重入函数和线程安全

  • 可重入函数:同一个函数被多个执行流同时进入,就叫重入。如果该函数在被重入执行的过程中不会出现任何错误,则被称为可重入函数。反之就是不可重入函数。
  • 线程安全:多个线程并发执行时,在没有锁保护的情况下访问了共享资源(如全局或静态变量,堆区数据等),会出现数据竞争从而导致数据冲突,数据不一致等线程安全问题。

3.1 线程安全的情况

  1. 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  2. 使用互斥锁(Mutex)来保护对共享资源的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区,从而避免数据竞争的发生。
  3. 每个线程对共享资源只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  4. 不调用线程不安全的函数

3.2 可重入函数的情况

  1. 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  2. 使用互斥锁(Mutex)来保护对共享资源的访问。如全局、静态变量或其他共享资源。
  3. 不调用不可重入函数

常见不可重入的情况

  1. 调用了malloc/free函数,因为Linux内核是用全局链表和全局红黑树结构来组织和管理堆空间的。(请看提示)
  2. 调用了标准I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用了全局数据结构。

提示:

  1. 关于Linux内核中的堆区管理,请阅读:【多线程】线程的概念 {Linux内核中的堆区管理;虚拟地址到物理地址的转换,页,页框,页表,MMU内存管理单元;Linux线程概念,轻量级进程;线程共享进程的资源;线程的优缺点;线程的用途}-CSDN博客
  2. 关于多执行流调用不可重入函数插入链表节点,请阅读:【信号】信号处理 {信号处理的时机;内核态和用户态;信号捕捉的原理;信号处理函数:signal, sigaction;可重入函数;volatile关键字;SIGCHLD信号}-CSDN博客

3.3 区别和联系

联系

  1. 函数是可重入的,那就是线程安全的。
  2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。

区别

  1. 可重入函数是线程安全函数的一种。
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的。但如果这个重入函数加锁还未释放则会产生死锁,因此是不可重入的。

四、死锁

死锁(Deadlock)是指在并发系统中,两个或多个进程(或线程)因为互相等待对方释放资源而无法继续执行的状态。在死锁状态下,进程无法前进,也无法释放资源,导致系统无法正常运行。

死锁通常发生在多个进程(或线程)同时竞争有限的资源时,每个进程都在等待其他进程释放资源,而自己又无法释放已经占有的资源。

在这里插入图片描述

特殊情况:一个执行流,一把互斥锁也可能导致死锁,即加锁后,不解锁,再次申请锁。

死锁的发生需要满足以下四个条件,也被称为死锁的必要条件:

  1. 互斥条件:一个资源每次只能被一个执行流使用(不加锁自然就不会产生死锁)。

  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。

  3. 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。

  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,使得每个执行流都在等待下一个执行流所占有的资源。

当这四个条件同时满足时,就可能发生死锁。一旦发生死锁,系统将无法自动解除死锁状态,需要通过人工干预来解决。

为了避免死锁的发生,可以采取以下策略:

  1. 破坏互斥条件:例如,允许多个进程(或线程)同时访问某些资源。

  2. 破坏请求与保持条件:例如,要求进程(或线程)在执行之前一次性获取所有需要的资源,否则在等待资源时释放已经占有的资源。

  3. 破坏不可剥夺条件:例如,允许系统强制剥夺某些进程(或线程)的资源。

  4. 破坏循环等待条件:例如,通过对资源进行排序,按照固定的顺序申请资源,避免交叉申请,循环等待。(T1,T2都先申请R1再申请R2)

  5. 其他方法:精简临界区代码,缩短持有锁的时间;合并临界区,资源一次性分配(一把锁);

死锁是并发系统中的一个重要问题,对系统的性能和可靠性有很大影响。因此,在设计和实现并发系统时,需要合理地分配和管理资源,以避免死锁的发生。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/123956.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

基于鸟群算法的无人机航迹规划-附代码

基于鸟群算法的无人机航迹规划 文章目录 基于鸟群算法的无人机航迹规划1.鸟群搜索算法2.无人机飞行环境建模3.无人机航迹规划建模4.实验结果4.1地图创建4.2 航迹规划 5.参考文献6.Matlab代码 摘要&#xff1a;本文主要介绍利用鸟群算法来优化无人机航迹规划。 1.鸟群搜索算法 …

0基础学习PyFlink——用户自定义函数之UDF

大纲 标量函数入参并非表中一行&#xff08;Row&#xff09;入参是表中一行&#xff08;Row&#xff09;alias PyFlink中关于用户定义方法有&#xff1a; UDF&#xff1a;用户自定义函数。UDTF&#xff1a;用户自定义表值函数。UDAF&#xff1a;用户自定义聚合函数。UDTAF&…

vue2+ant-design-vue a-select组件二次封装(支持单选/多选添加全选/分页(多选跨页选中)/自定义label)

一、效果图 二、参数配置 1、代码示例 <t-antd-selectv-model"selectVlaue":optionSource"stepList"change"selectChange" />2、配置参数&#xff08;Attributes&#xff09;继承 a-select Attributes 参数说明类型默认值v-model绑定值…

vivado crash

将增量编译去了

FPGA时序分析与约束(9)——主时钟约束

一、时序约束 时序引擎能够正确分析4种时序路径的前提是&#xff0c;用户已经进行了正确的时序约束。时序约束本质上就是告知时序引擎一些进行时序分析所必要的信息&#xff0c;这些信息只能由用户主动告知&#xff0c;时序引擎对有些信息可以自动推断&#xff0c;但是推断得到…

Sprint Cloud Stream整合RocketMq和websocket实现消息发布订阅

1.引入RocketMQ依赖&#xff1a;首先&#xff0c;在pom.xml文件中添加RocketMQ的依赖&#xff1a; <dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId><version>2.2.0</versi…

文件改名,轻松添加前缀顺序编号,文件改名更高效!

您是否曾经需要批量修改文件名&#xff0c;并希望在文件名中添加特定的前缀或顺序编号&#xff1f;现在&#xff0c;我们为您带来了一款全新的文件改名工具&#xff0c;帮助您轻松解决这个问题&#xff01; 第一步&#xff0c;进入文件批量改名高手主页面&#xff0c;在板块栏…

C++学习笔记之四(标准库、标准模板库、vector类)

C 1、C标准库2、C标准模板库2.1、vector2.1.1、vector与array2.1.2、vector与函数对象2.1.3、vector与迭代器2.1.4、vector与算法 1、C标准库 C C C标准库指的是标准程序库( S t a n d a r d Standard Standard L i b a r a y Libaray Libaray)&#xff0c;它定义了十个大类…

亚马逊,速卖通,美客多如何打造爆款商品,排名提升榜首

1、产品Listing的完整性 Listing是亚马逊A9算法认识你产品的基础&#xff0c;在发布一条listing的时候&#xff0c;尽可能地做到最好!在准备一条listing之前&#xff0c;一定事先要收集、整理足够多的产品关键词&#xff0c;在优化listing内容的时候填充进去。仔细观察优秀竞品…

Realrek 2.5G交换机 8+1万兆光RTL8373-VB-CG方案简介

新一代2.5G交换机方案RTL8373-VB-CG可以提供4中不同形态 a. 52.5G 电口110G光》RTL8373 b. 52.5G 电口110G电》RTL83738261 c. 82.5G 电口110G光》RTL83738224 d.82.5G 电口110G电口》RTL837382248261 1.概述 Realtek RTL8373-CG是一款低功耗、高性能、高度集成的八端口2.5G和一…

C++设计模式_19_Memento 备忘录(理解,目前多使用序列化方案来实现)

Memento 备忘录模式也属于“状态变化”模式&#xff0c;它是一个小模式&#xff0c;在今天来看有些过时&#xff0c;当今已经很少使用当前模式实现需求&#xff0c;思想却不变&#xff08;信息隐藏&#xff09;&#xff0c;目前多使用序列化方案来实现。本系列所介绍的模式&…

小程序开发——小程序项目的配置与生命周期

1.app.json配置属性 app.json配置属性 2.页面配置 app的页面配置指的是pages属性&#xff0c; pages数组的第一个页面将默认作为小程序的启动页。利用开发工具新建页面时&#xff0c;则pages属性对应的数组将自动添加该页面的路径&#xff0c;若是在硬盘中添加文件的形式则不…

C++数据结构X篇_23_快速排序(最快、不稳定的排序)

文章参考十大经典排序算法-快速排序算法详解进行整理补充。快速排序是最快的排序方法。 排序思路&#xff1a;分治法-挖坑填数&#xff1a;大问题分解为各个小问题&#xff0c;对小问题求解&#xff0c;使得大问题得以解决 文章目录 1. 什么是快速排序1.1 概念1.2 算法原理1.3 …

Linux rm命令:删除文件或目录

当 Linux 系统使用很长时间之后&#xff0c;可能会有一些已经没用的文件&#xff08;即垃圾&#xff09;&#xff0c;这些文件不但会消耗宝贵的硬盘资源&#xff0c;还是降低系统的运行效率&#xff0c;因此需要及时地清理。 rm 是强大的删除命令&#xff0c;它可以永久性地删除…

一百九十七、Java——IDEA项目中把多层文件夹拆开显示

一、目的 由于IDEA项目中&#xff0c;默认的是把文件夹连在一起显示&#xff0c;于是为了方便需要把这些连在一起的文件夹拆开&#xff0c;分层显示 如文件夹cn.kgc 二、解决措施 解决方法很简单 &#xff08;一&#xff09;找到IDEA项目上的小齿轮 &#xff08;二&#xf…

基于深度学习的单图像人群计数研究:网络设计、损失函数和监控信号

摘要 https://arxiv.org/pdf/2012.15685v2.pdf 单图像人群计数是一个具有挑战性的计算机视觉问题,在公共安全、城市规划、交通管理等领域有着广泛的应用。近年来,随着深度学习技术的发展,人群计数引起了广泛的关注并取得了巨大的成功。通过系统地回顾和总结2015年以来基于深…

基于STM32的多功能智能密码锁控制设计

**单片机设计介绍&#xff0c;1653基于STM32的多功能智能密码锁控制设计 文章目录 一 概要二、功能设计设计思路 三、 软件设计原理图 五、 程序文档 六、 文章目录 一 概要 基于STM32的多功能智能密码锁控制设计是一种用STM32微控制器开发的系统&#xff0c;用于控制和管理密码…

手机桌面待办事项APP推荐,手机上可使用哪些待办事项APP

生活中&#xff0c;无论你是一名专业人士&#xff0c;学生&#xff0c;还是家庭主妇&#xff0c;总有各种各样的任务等待着你&#xff0c;有时候需要额外的工具来提醒和管理这些待办事项。手机上的待办事项APP软件成为了这个任务的好帮手&#xff0c;为我们提供了快速、方便的方…

spring boot配置ssl(多cer格式)保姆级教程

1. 准备cer格式的证书&#xff1b; 2. 合并cer证书并转化成jks格式的证书 为啥有这一步&#xff0c;因为cer证书配置在spring boot项目中&#xff0c;项目启动不起来。如果有大佬想指导一下可以给我留言&#xff0c;在此先谢过大佬。 1&#xff09;先创建一个jks格式的证…

Kotlin(八) 数据类、单例

目录 一&#xff1a;创建数据类 二&#xff1a;单例类 一&#xff1a;创建数据类 和Java的不同&#xff0c;kotlin的数据类比较简单&#xff0c;New→Kotlin File/Class&#xff0c;在弹出的对话框中输入“Book”&#xff0c;创建类型选择“Data”。如图&#xff1a; 然后编…