当前位置: 首页 > article >正文

并发专题(1)之深入理解并发、线程与等待通知机制

一、基础概念

1.1 进程和线程

1.1.1 进程

        我们常听说的是应用程序, 也就是 app ,由指令和数据组成。但是当我们不 运行一个具体的 app 时,这些应用程序就是放在磁盘(也包括 U 盘、远程网络 存储等等)上的一些二进制的代码。 一旦我们运行这些应用程序,指令要运行, 数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中 还需要用到磁盘、网络等设备,从这种角度来说, 进程就是用来加载指令、管理 内存、管理 IO 的。

        进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程 (例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如 网易云音乐、 360 安全卫士等) 。显然,程序是死的、静态的,进程是活的、动态 的。进程可以分为系统进程和用户进程。凡是用于完成操作系统的各种功能的进 程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由 你启动的进程。

1.1.2 线程

        线程必须依赖于进程而存在,线程是进程中的一个实体,是 CPU 调度和分 派的基本单位,它是比进程更小的、能独立运行的基本单位。线程自己基本上不 拥有系统资源,,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈), 但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。 一个进程可 以拥有多个线程, 一个线程必须有一个父进程。线程, 有时也被称为轻量级进程 (Lightweight Process ,LWP),早期 Linux 的线程实现几乎就是复用的进程,后来 才独立出自己的 API。

1.2 CPU核心数和线程数的关系

         目前主流 CPU 都是多核的, 线程是 CPU 调度的最小单位。同一 时刻, 一个 CPU 核心只能运行一个线程,也就是 CPU 内核和同时运行的线程数 是 1:1 的关系, 也就是说 8 核 CPU 同时可以执行 8 个线程的代码。但 Intel 引入 超线程技术后,产生了逻辑处理器的概念, 使核心数与线程数形成 1:2 的关系。 

        在 Java 中提供了 Runtime.getRuntime().availableProcessors(),可以让我们获 取当前的 CPU 核心数, 注意这个核心数指的是逻辑处理器数。

        获得当前的 CPU 核心数在并发编程中很重要,并发编程下的性能优化往往 和 CPU 核心数密切相关。

1.3 上下文切换

        既然操作系统要在多个进程(线程) 之间进行调度, 而每个线程在使用 CPU 时总是要使用 CPU 中的资源,比如 CPU 寄存器和程序计数器。这就意味着,操 作系统要保证线程在调度前后的正常执行,所以, 操作系统中就有上下文切换的 概念,它是指 CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。

        上下文是CPU 寄存器和程序计数器在任何时间点的内容。

        寄存器是CPU 内部的一小部分非常快的内存(相对于CPU 内部的缓存和CPU 外部较慢的RAM 主内存),它通过提供对常用值的快速访问来加快计算机程序的 执行。

        程序计数器是一种专门的寄存器,它指示CPU 在其指令序列中的位置,并 保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系 统。

        下文切换可以更详细地描述为内核(即操作系统的核心)对 CPU 上的进程 (包括线程)执行以下活动:

        1. 暂停一个进程的处理, 并将该进程的 CPU 状态(即上下文)存储在内存中的 某个地方;

        2. 从内存中获取下一个进程的上下文,并在 CPU 的寄存器中恢复它;

        3. 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进 程。

        从数据来说, 以程序员的角度来看, 是方法调用过程中的各种局部的变量 与资源; 以线程的角度来看, 是方法的调用栈中存储的各类信息。引发上下文切换的原因一般包括: 线程、进程切换、系统调用等等。上下文 切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回 拷贝。就 CPU 时间而言, 一次上下文切换大概需要 5000~20000 个时钟周期, 相 对一个简单指令几个乃至十几个左右的执行时钟周期, 可以看出这个成本的巨大。

 二、线程的启动和终止

2.1 线程的启动方式

         启动线程的方式有:

        1 、X extends Thread;,然后 X.start。

        2 、X implements Runnable;然后交给 Thread 运行

2.2 Thread 和 Runnable 的区别 

         Thread 才是 Java 里对线程的唯一抽象, Runnable 只是对任务(业务逻辑) 的抽象。 Thread 可以接受任意一个 Runnable 的实例并执行。

2.3 Callable 、Future 和 FutureTask

         Runnable 是一个接口, 在它里面只声明了一个 run()方法,由于 run()方法返 回值为 void 类型,所以在执行完任务之后无法返回任何结果。

        Callable 位于 java.util.concurrent 包下, 它也是一个接口, 在它里面也只声明 了一个方法,只不过这个方法叫做 call() ,这是一个泛型接口, call()函数返回的 类型就是传递进来的 V 类型。

        Future 就是对于具体的 Runnable 或者 Callable 任务的执行结果进行取消、查 询是否完成、获取结果。必要时可以通过 get 方法获取执行结果, 该方法会阻塞 直到任务返回结果。

         因为 Future 只是一个接口,所以是无法直接用来创建对象使用的,因此就 有了下面的 FutureTask。

         FutureTask 类实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable 接口和 Future 接口, 而 FutureTask 实现了 RunnableFuture 接口。所以它既可以 作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。

        因此我们通过一个线程运行 Callable,但是 Thread 不支持构造方法中传递 Callable 的实例,所以我们需要通过 FutureTask 把一个 Callable 包装成 Runnable, 然后再通过这个 FutureTask 拿到 Callable 运行后的返回值。

2.4 线程终止

         要么是 run 执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。

2.4.1 Thread的stop方法

         暂停、恢复和停止操作对应在线程 Thread 的 API 就是 suspend() 、resume() 和 stop()。但是这些 API 是过期的,也就是不建议使用的。不建议使用的原因主 要有:以 suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如 锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样, stop()方 法在终结一个线程时不会保证线程的资源正常释放, 通常是没有给予线程完成资 源释放工作的机会, 因此会导致程序可能工作在不确定状态下。正因为 suspend()、 resume()和 stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方 法。

2.4.2 中断 

        安全的中止则是其他线程通过调用某个线程 A 的 interrupt()方法对其进行中 断操作, 中断好比其他线程对该线程打了个招呼,“A,你要中断了”,不代表 线程 A 会立即停止自己的工作,同样的 A 线程完全可以不理会这种中断请求。

        线程通过检查自身的中断标志位是否被置为 true 来进行响应,线程通过方法 isInterrupted()来进行判断是否被中断,也可以调用静态方法 Thread.interrupted()来进行判断当前线程是否被中断,不过 Thread.interrupted() 会同时将中断标识位改写为 false。

        如果一个线程处于了阻塞状态(如线程调用了 thread.sleep 、thread.join、thread.wait 等),则在线程在检查中断标示时如果发现中断标示为 true,则会在 这些阻塞方法调用处抛出 InterruptedException 异常,并且在抛出异常后会立即 将线程的中断标示位清除,即重新设置为 false。

        不建议自定义一个取消标志位来中止线程的运行。因为 run 方法里有阻塞调 用时会无法很快检测到取消标志, 线程必须从阻塞调用返回后, 才会检查这个取 消标志。这种情况下,使用中断会更好,因为,

        一、一般的阻塞方法,如 sleep 等本身就支持中断的检查,

        二、检查中断位的状态和检查取消标志位没什么区别, 用中断位的状态还可 以避免声明取消标志位,减少资源的消耗。

2.5 深入理解run和start方法

         Thread 类是Java 里对线程概念的抽象,可以这样理解:我们通过 new Thread() 其实只是 new 出一个 Thread 的实例,还没有操作系统中真正的线程挂起钩来。 只有执行了 start()方法后,才实现了真正意义上的启动线程。

        从 Thread 的源码可以看到, Thread 的 start 方法中调用了 start0()方法,而 start0()是个 native 方法, 这就说明 Thread#start 一定和操作系统是密切相关的。

        start()方法让一个线程进入就绪队列等待分配 cpu,分到 cpu 后才调用实现 的 run()方法,start()方法不能重复调用,如果重复调用会抛出异常。

        而 run 方法是业务逻辑实现的地方, 本质上和任意一个类的任意一个成员方 法并没有任何区别, 可以重复执行,也可以被单独调用。

 三、线程的生命周期

         Java 中线程的状态分为 6 种:

1. 初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。

2. 运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种 状态笼统的称为“运行”。线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。 该状态的线程位于可运行线程池中, 等待被线程调度选中, 获取 CPU 的使用权, 此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中 状态(running)。

3. 阻塞(BLOCKED):表示线程阻塞于锁。

4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)。

5. 超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时 间后自行返回。

6. 终止(TERMINATED):表示该线程已经执行完毕。

         状态之间的变迁如下图所示:

        3.1 其他线程的相关方法 

         yield()方法:使当前线程让出 CPU 占有权, 但让出的时间是不可设定的。也 不会释放锁资源。同时执行 yield()的线程有可能在进入到就绪状态后会被操作系 统再次选中马上又被执行。

        比如, ConcurrentHashMap#initTable 方法中就使用了这个方法,这是因为 ConcurrentHashMap 中可能被多个线程同时初始化 table,但是其 实这个时候只允许一个线程进行初始化操作, 其他的线程就需要被阻塞或等待, 但是初始化操作其实很快, 这里 Doug Lea 大师为了避免阻塞或者等待这些操作 引发的上下文切换等等开销, 就让其他不执行初始化操作的线程干脆执行 yield() 方法,以让出 CPU 执行权,让执行初始化操作的线程可以更快的执行完成。

 3.2 线程的调度

         线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种:

  • 协同式线程调度(Cooperative Threads-Scheduling)
  • 抢占式线程调度(Preemptive Threads-Scheduling)

         使用协同式线程调度的多线程系统, 线程执行的时间由线程本身来控制, 线 程把自己的工作执行完之后, 要主动通知系统切换到另外一个线程上。使用协同 式线程调度的最大好处是实现简单, 由于线程要把自己的事情做完后才会通知系 统进行线程切换, 所以没有线程同步的问题, 但是坏处也很明显, 如果一个线程 出了问题,则程序就会一直阻塞。

        使用抢占式线程调度的多线程系统, 每个线程执行的时间以及是否切换都由 系统决定。在这种情况下, 线程的执行时间不可控, 所以不会有「一个线程导致 整个进程阻塞」的问题出现。

Java 线程调度就是抢占式调度,为什么? 后面会分析。

在 Java 中, Thread.yield()可以让出 CPU 执行时间,但是对于获取执行时间, 线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是 设置线程优先级, Java 设置了 10 个级别的程序优先级,当两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

3.3 线程和协程 

         为什么 Java 线程调度是抢占式调度?这需要我们了解 Java 中线程的实现模式。

        我们已经知道线程其实是操作系统层面的实体, Java 中的线程怎么和操作系 统层面对应起来呢?

        任何语言实现线程主要有三种方式:使用内核线程实现(1:1 实现),使用用户线程实现(1:N 实现) ,使用用户线程加轻量级进程混合实现(N:M 实现)。

3.3.1 内核线程实现

         使用内核线程实现的方式也被称为 1: 1 实现。 内核线程(Kernel-Level Thread , KLT) 就是直接由操作系统内核(Kernel , 下称内核) 支持的线程,这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进 行调度, 并负责将线程的任务映射到各个处理器上。

        由于内核线程的支持, 每个线程都成为一个独立的调度单元, 即使其中某一 个在系统调用中被阻塞了, 也不会影响整个进程继续工作,相关的调度工作也不 需要额外考虑,已经由操作系统处理了。

局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户 态(User Mode)和内核态(Kernel Mode)中来回切换。其次, 每个语言层面的 线程都需要有一个内核线程的支持, 因此要消耗一定的内核资源(如内核线程的 栈空间),因此一个系统支持的线程数量是有限的。

 3.3.2 用户线程实现

         严格意义上的用户线程指的是完全建立在用户空间的线程库上, 系统内核不 能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成, 不需要内核的帮助。 如果程序实现得当, 这种线程不需要 切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的 线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。

         用户线程的优势在于不需要系统内核支援, 劣势也在于没有系统内核的支援, 所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度 都是用户必须考虑的问题, 而且由于操作系统只把处理器资源分配到进程, 那诸 如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问 题解决起来将会异常困难, 甚至有些是不可能实现的。 因为使用用户线程实现 的程序通常都比较复杂, 所以一般的应用程序都不倾向使用用户线程。Java 语言 曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点 的编程语言又普遍支持了用户线程,譬如 Golang。

3.3.3 混合实现 

        线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将 内核线程与用户线程一起使用的实现方式, 被称为 N:M 实现。 在这种混合实 现下, 既存在用户线程, 也存在内核线程。 

        用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 销毁等操作依然廉价, 并且可以支持大规模的用户线程并发。同样又可以使用内核提供的线程调度功能及处理器映射, 并且用户线程的系 统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的 数量比是不定的,是 N:M 的关系。

3.3.4 Java线程实现方式

        Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的, 但从 JDK 1.3 起, 主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系 统原生线程模型来实现,即采用 1: 1 的线程模型。 

         以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线 程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉 线程调度的,全权交给底下的操作系统去处理。

         所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线 程优先级是通过映射到操作系统的原生线程上实现的, 所以线程的调度最终取决 于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以 Java 优先级并不是特别靠谱。

3.3.5 协程

        出现原因: 随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。 比如, 互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不 同机器上的大量服务共同协作来实现, ,也就是我们常说的微服务, 这种服务细 分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服 务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短 的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服 务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服 务被阻塞而出现等待。

        Java 目前的并发编程机制就与上述架构趋势产生了一些矛盾, 1:1 的内核 线程模型是如今 Java 虚拟机线程实现的主流选择, 但是这种映射到操作系统上 的线程天然的缺陷是切换、调度成本高昂, 系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中, 具有这种线程切换的成本 也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多 的前提下, 用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销, 这就会造成严重的浪费。

         另外我们常见的 Java Web 服务器,比如 Tomcat 的线程池的容量通常在几十 个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得 过来,但其中的切换损耗也是相当可观的。其次, Go 语言等支持用户线程等新型语言给 Java 带来了巨大的压力, 也使 得 Java 引入用户线程成为了一个绕不开的话题。

        协程简介:为什么用户线程又被称为协程呢?我们知道, 内核线程的切换开销是来自于 保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉 吗? 答案还是“不能”。 但是, 一旦把保护、恢复现场及调度的工作从操作系 统交到程序员手上, 则可以通过很多手段来缩减这些开销。

        由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling) 的,所以它有了一个别名—— “协程”(Coroutine) 完整地做调用栈的保护、 恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。

         协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核 线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB ,此外内核数据结构(Kernel Data Structures) 还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支 持协程的应用中, 同时并存的协程数量可数以十万计。

        协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这 些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协 程上也存在。

        总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 io), 不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的 延迟)。

3.3.5.1 纤程 -Java中的协程

         在 JVM 的实现上,以 HotSpot 为例, 协程的实现会有些额外的限制, Java 调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正 常切换协程而不影响整个线程? 另外, 如果协程中遇传统的线程同步措施会怎 样? 譬如 Kotlin 提供的协程实现, 一旦遭遇 synchronize 关键字, 那挂起来的 仍将是整个线程。

        所以 Java 开发组就 Java 中协程的实现也做了很多努力, OpenJDK 在 2018 年 创建了 Loom 项目,这是 Java 的官方解决方案, 并用了“纤程(Fiber) ”这个 名字。

        Loom 项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为 了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在 Java 虚拟 机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似 的 API 设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为 了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。

        据 Loom 团队在 2018 年公布的他们对 Jetty 基于纤程改造后的测试结果, 同样在 5000QPS 的压力下, 以容量为 400 的线程池的传统模式和每个请求配以 一个纤程的新并发处理模式进行对比, 前者的请求响应延迟在 10000 至 20000 毫秒之间, 而后者的延迟普遍在 200 毫秒以下,目前 Java 中比较出名的协程库是 Quasar[ˈkweɪzɑː(r)](Loom 项目的 Leader 就 是 Quasar 的作者 Ron Pressler), Quasar 的实现原理是字节码注入,在字节码 层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖 Java 虚 拟机的现场保护虽然能够工作,但影响性能。

3.3.5.2 Quasar实战

       引入对应的maven依赖:

         在具体的业务场景上, 我们模拟调用某个远程的服务, 假设远程服务处理耗 时需要 1S,使用休眠 1S 来代替。为了比较, 用多线程和协程分别调用这个服务 10000 次,来看看两者所需的耗时。

        Quasar耗时:

        

        线程的:

 

         从代码层面来看, 两者的代码高度相似, 忽略两者的公共部分, 代码不同的 地方也就 2 、3 行。

        可以看到性能的提升还是非常明显的。而且上面多线程编程时, 并没有指定 线程池的大小, 在实际开发中是绝不允许的。一般我们会设置一个固定大小的线 程池, 因为线程资源是宝贵, 线程多了费内存还会带来线程切换的开销。上面的 场景在设置 200 个固定大小线程池时(Executors.newFixedThreadPool(200)),在 本机的测试结果达到了 50 多秒,几乎是数量级的增加。

3.3.6 虚拟线程

         2022 年 9 月 22 日,JDK19(非 LTS 版本) 正式发布,引入了协程,并称为 轻量级虚拟线程。但是这个特性目前还是预览版, 还不能引入生成环境。 因为环 境所限,本课程不提供实际的范例,只讲述基本用法和原理。

        在具体实现上, 虚拟线程当然是基于用户线程模式实现的,JDK 的调度程序 不直接将虚拟线程分配给处理器, 而是将虚拟线程分配给实际线程,是一个 M: N 调度,具体的调度程序由已有的 ForkJoinPool 提供支持。

         但是虚拟线程不是协同调度的, JDK 的虚拟线程调度程序通过将虚拟线程挂 载到平台线程上来分配要在平台线程上执行的虚拟线程。在运行一些代码之后, 虚拟线程可以从其载体卸载。此时平台线程是空闲的, 因此调度程序可以在其上 挂载不同的虚拟线程,从而使其再次成为载体。

         通常,当虚拟线程阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take ())时,它将卸载。当阻塞操作准备完成时(例如,在套接字上已经接收到字节) , 它将虚拟线程提交回调度程序, 调度程序将在运营商上挂载虚拟线程以恢复执行。 虚拟线程的挂载和卸载频繁且透明,并且不会阻塞任何 OS 线程。


http://www.kler.cn/a/401376.html

相关文章:

  • 面向服务的软件工程——巨详细讲解商务流程建模符号 (BPMN),一篇章带你入门BPMN!!!(week1)
  • 【插件】重复执行 pytest-repeat
  • MySQL时间字段TIMESTAMP和DATETIME
  • 51单片机基础01 单片机最小系统
  • 机器学习3
  • 【Nginx从入门到精通】03 、安装部署-让虚拟机可以联网
  • 24. 两两交换链表中的节点
  • 行列式的理解与计算:线性代数中的核心概念
  • 浅谈python
  • AI新闻周刊 | 最新AI资讯速递(11.11-11.17)
  • SMARTFORMS函数调用完整示例:打印工厂物料信息
  • 建造者模式(Builder Pattern)
  • 数字后端零基础入门系列 | Innovus零基础LAB学习Day11(Function ECO流程)
  • [控制理论]—位置式PID与增量式PID
  • 【大数据学习 | Spark】RDD的概念与Spark任务的执行流程
  • JMeter实战技巧:使用Java Request Sampler进行接口测试
  • MySQL高级SQL技巧:提升数据库性能与效率
  • 【机器学习】机器学习中用到的高等数学知识-8. 图论 (Graph Theory)
  • Redis配置主从架构、集群架构模式 redis主从架构配置 redis主从配置 redis主从架构 redis集群配置
  • STM32完全学习——外部中断
  • 【第七节】在RadAsm中使用OllyDBG调试器
  • Android 12.0 系统默认蓝牙打开状态栏显示蓝牙图标功能实现
  • postman快速测试接口是否可用
  • css3中的多列布局,用于实现文字像报纸一样的布局
  • 解决Windows + Chrome 使用Blob下载大文件时,部分情况下报错net:ERR_FAILED 200 (OK)的问题
  • Spark RDD各种join算子从源码层分析实现方式