java中10d是什么意思_为什么需用指令重排序

2023-10-09 09:30

本文主要是介绍java中10d是什么意思_为什么需用指令重排序,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:

编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

1属于编译器重排序,2和3属于处理器重排序。从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

e6cda45d58d0

image.png

那么为啥要用指令重排序呢?

编译期重排序有啥好处?

CPU计算的时候要访问值,如果常常利用到寄存器中已有的值就不用去内存读取了,先看JAVA中String.intern的理解一个例子

String a = new String("ab");

String b = new String("ab");

String c = "ab";

String d = "a" + "b";

String e = "b";

String f = "a" + e;

System.out.println(b.intern() == a);

System.out.println(b.intern() == c);

System.out.println(b.intern() == d);

System.out.println(b.intern() == f);

System.out.println(b.intern() == a.intern());

运行结果:

false

true

true

false

true

由运行结果可以看出来,b.intern() == a和b.intern() == c可知,

采用new 创建的字符串对象不进入字符串池,并且通过b.intern() == d和b.intern() == f可知,字符串相加的时候,都是静态字符串的结果会添加到字符串池,如果其中含有变量(如f中的e)则不会进入字符串池中。但是字符串一旦进入字符串池中,就会先查找池中有无此对象。如果有此对象,则让对象引用指向此对象。如果无此对象,则先创建此对象,再让对象引用指向此对象

回到刚才的问题,String c = "ab"执行后String d = "a" + "b",就无需再创建对象或者字符串了,直接从常量池里面获得引用,性能自然会有所优化了。

处理器为啥要重排序?

因为一个汇编指令也会涉及到很多步骤,每个步骤可能会用到不同的寄存器,现在的CPU一般采用流水线来执行指令,也就是说,CPU有多个功能单元(如获取、解码、运算和结果),一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段,**流水线是并行的, **第一条指令执行还没完毕,就可以执行第二条指令,前提是这两条指令功能单元相同或类似,所以一般可以通过指令重排使得具有相似功能单元的指令接连执行来减少流水线中断的情况。

具体的编译优化请看javac 编译与 JIT 编译

我们写一段代码来试试:

package *****;

/**

* reorder

* @author Mageek Chiu

* @date 2018/5/25 0025:12:49

*/

public class ReOrder {

public int value ;

private ReOrder(int value) {

this.value = value;

}

public static void main(String... args){

ReOrder reOrder = new ReOrder(111);

ReOrder reOrder1 = new ReOrder(222);

ReOrder reOrder2 = new ReOrder(333);

System.out.println(add1(reOrder,reOrder1,reOrder2));

}

static int add1(ReOrder reOrder,ReOrder reOrder1,ReOrder reOrder2){

int result = 0;

result += reOrder.value;

result += reOrder1.value;

result += reOrder2.value;//***

result += reOrder.value;

result += reOrder1.value;

result += reOrder2.value;

result += reOrder.value;

result += reOrder1.value;

result += reOrder2.value;

return result;

}

}

运行结果中:

# {method} {0x000000001c402c80} 'add1' '(*****/ReOrder;*****/ReOrder;*****/ReOrder;)I' in '*****/ReOrder'

# parm0: rdx:rdx = '*****/ReOrder'

# parm1: r8:r8 = '*****/ReOrder'

# parm2: r9:r9 = '*****/ReOrder'

# [sp+0x20] (sp of caller)

0x00000000032a86c0: mov dword ptr [rsp+0ffffffffffffa000h],eax

0x00000000032a86c7: push rbp

0x00000000032a86c8: sub rsp,10h ;*synchronization entry

; - *****.ReOrder::add1@-1 (line 24)

0x00000000032a86cc: mov r11d,dword ptr [rdx+0ch]

;*getfield value

; - *****.ReOrder::add1@4 (line 26)

; implicit exception: dispatches to 0x00000000032a86ff

0x00000000032a86d0: mov r10d,dword ptr [r8+0ch] ;*getfield value

; - *****.ReOrder::add1@11 (line 27)

; implicit exception: dispatches to 0x00000000032a870d

0x00000000032a86d4: mov r9d,dword ptr [r9+0ch] ;*getfield value

; - *****.ReOrder::add1@18 (line 28)

; implicit exception: dispatches to 0x00000000032a8719

0x00000000032a86d8: mov eax,r11d

0x00000000032a86db: add eax,r10d

0x00000000032a86de: add eax,r9d

0x00000000032a86e1: add eax,r11d

0x00000000032a86e4: add eax,r10d

0x00000000032a86e7: add eax,r9d

0x00000000032a86ea: add eax,r11d

0x00000000032a86ed: add eax,r10d

0x00000000032a86f0: add eax,r9d ;*iadd

也就是先用mov把方法里面所需要的三个value加载了,再统一用add进行加法运算。

现在我们把//***哪一行注释掉,运行结果如下:

[Constants]

# {method} {0x000000001c052c78} 'add1' '(*****/ReOrder;*****/ReOrder;*****/ReOrder;)I' in '*****/ReOrder'

# parm0: rdx:rdx = '*****/ReOrder'

# parm1: r8:r8 = '*****/ReOrder'

# parm2: r9:r9 = '*****/ReOrder'

# [sp+0x20] (sp of caller)

0x0000000002f47d40: mov dword ptr [rsp+0ffffffffffffa000h],eax

0x0000000002f47d47: push rbp

0x0000000002f47d48: sub rsp,10h ;*synchronization entry

; - *****.ReOrder::add1@-1 (line 24)

0x0000000002f47d4c: mov r11d,dword ptr [rdx+0ch]

;*getfield value

; - *****r.ReOrder::add1@4 (line 26)

; implicit exception: dispatches to 0x0000000002f47d7c

0x0000000002f47d50: mov r10d,dword ptr [r8+0ch] ;*getfield value

; - *****.ReOrder::add1@11 (line 27)

; implicit exception: dispatches to 0x0000000002f47d89

0x0000000002f47d54: mov r9d,dword ptr [r9+0ch] ;*getfield value

; - *****::add1@32 (line 32)

; implicit exception: dispatches to 0x0000000002f47d95

0x0000000002f47d58: mov eax,r11d

0x0000000002f47d5b: add eax,r10d

0x0000000002f47d5e: add eax,r11d

0x0000000002f47d61: add eax,r10d

0x0000000002f47d64: add eax,r9d

0x0000000002f47d67: add eax,r11d

0x0000000002f47d6a: add eax,r10d

0x0000000002f47d6d: add eax,r9d ;*iadd

依然是先把所有value都用mov指令加载后再进行加法运算。

总结起来就是不管代码里这个值使用顺序多靠后,都先用mov加载后再使用add对这个值进行运算。

注意,上面的运行参数为:

-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*ReOrder.add1 -XX:+PrintCompilation。

Xcomp 含义是使用编译模式而不是解释模式,

-XX:CompileCommand=print,*ReOrder.add1表示只打印这个方法,

-XX:+PrintCompilation表示打印方法名称。

需要插件hsdis,编译好后放在jdk的jre的bin的server中就好,具体环境搭建可以参阅这里

这里提前对下面的一个例子的重排序解释一下:

int a = 0;

boolean flag = false;

public synchronized void init() {

a = 1; // 1

flag = true; // 2

//.......

}

init()方法将会执行重排序,因为true已经存在,而常量1不存在,所以先执行flag=true 再执行a=1

这个例子如果多线程情况下,指令重排序将会更加复杂,可能产生不一样的结果,那么我们又应该如何保证正确的内存加载呢?(线程的同步和锁这里不展开讨论)

先看几个概念:

as-if-serial 语义

as-if-serial的意思是:不管指令怎么重排序,在单线程下执行结果不能被改变。不管是编译器级别还是处理器级别的重排序都必须遵循as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。但是as-if-serial规则允许对有控制依赖关系的指令做重排序,因为在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果,但是多线程下确有可能会改变结果。

数据依赖

int a = 1; // 1

int b = 2; // 2

int c = a + b; // 3

上述代码,a和b不存在依赖关系,所以1、2可以进行重排序;c依赖 a和b,所以3必须在1、2的后面执行。

控制依赖

public void use(boolean flag, int a, int b) {

if (flag) { // 1

int i = a * b; // 2

}

}

flag和i存在控制依赖关系。当指令重排序后,2这一步会将结果值写入重排序缓冲(Reorder Buffer,ROB)的硬件缓存中,当判断为true时,再把结果值写入变量i中。

happens-before 语义

JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

happens-before 部分规则

程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作。

主要含义是:在一个线程内不管指令怎么重排序,程序运行的结果都不会发生改变。和as-if-serial 比较像。

监视器锁规则: 对一个锁的解锁,happens-before于随后对这个锁的加锁。

主要含义是:同一个锁的解锁一定发生在加锁之后

管程锁定规则: 一个线程获取到锁后,它能看到前一个获取到锁的线程所有的操作结果。

主要含义是:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

volatile变量规则: 对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

主要含义是:如果一个线程先去写一个volatile变量,然后另一个线程又去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

传递性: 如果A happens-before B,且B happens-before C,那么A happens-before C。

start()规则: 如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

主要含义是:线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

join()规则: 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

主要含义是:如果在线程A执行过程中调用了线程B的join方法,那么当B执行完成后,在线程B中所有操作结果对线程A可见。

线程中断规则: 对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

主要含义是:响应中断一定发生在发起中断之后。

对象终结规则: 就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

一个happens-before规则对应于一个或多个编译器和处理器重排序规则。

as-if-serial和happens-before的主要作用都是:在保证不改变程序运行结果的前提下,允许部分指令的重排序,最大限度的提升程序执行的效率。

内存屏障

我们先来看一个并发环境下指令重排序带来的问题:

e6cda45d58d0

并发环境下指令重排序带来的问题.png

这里有两个线程A和线程B,当A执行init方法时发生了指令重排,2先执行,这时线程B执行use方法,这时我们拿到的变量a却还是0,所以最后得到的结果 i=0,而不是i=1。

如何解决上述问题呢?

一种是使用内存屏障(volatile)

另一种使用临界区(synchronized )。

如果我们使用内存屏障,那么JMM的处理器,会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

内存屏障的类型

e6cda45d58d0

image.png

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂 贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

常见处理器允许的重排序类型的列表,“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序:

e6cda45d58d0

image.png

那么上面的问题,我们可以在flag处插入一个内存屏障,其作用是:保证在init()方法中,第1步操作一定在第2步之前,禁止第1步和第2步操作出现指令重排序,代码如下:

public class ControlDep {

int a = 0;

volatile boolean flag = false;

public void init() {

a = 1; // 1

flag = true; // 2

//.......

}

public void use() {

if (flag) { // 3

int i = a * a; // 4

}

//.......

}

}

A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。也就是说程序执行执行完第2步的时候,处理器会将第2步和其之前的所有结果强制刷新到主内存。也就是说a=1也会被强制刷新到主内存中。那么当另一个线程执行到步骤3的时候,如果判断到flag=true时,那么第4步处a一定是等于1的,这样就保证了程序的正确运行。

顺序一致性

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

一个线程中的所有操作必须按照程序的顺序来执行。

(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

我们看到JMM仅仅是保证了程序运行的结果是和顺序执行是一致,并没有实现真正的顺一致性。它又是怎么实现的呢?

JMM使用了临界区(加锁)来保证程序的顺序执行,但是在临界区内是允许出现指令重排的(JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。

我们在回过来看下上面遇到的并发问题,在上面我们说了使用内存屏障来解决,这里我们使用临界区。

临界区synchronized

public class ControlDep {

int a = 0;

boolean flag = false;

public synchronized void init() {

a = 1; // 1

flag = true; // 2

//.......

}

public synchronized void use() {

if (flag) { // 3

int i = a * a; // 4

}

//.......

}

}

虽然线程A执行init()方法时,在临界区内做了重排序,但由于监视器互斥执行的特性,线程B执行use()方法时,根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

e6cda45d58d0

image.png

从这里我们可以看到,JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执 行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

volatile的内存语义

volatile的特性

可见性: 对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入

原子性: 对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(++不具备原子性,volatile直接赋值具备原子性)

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下:

class VolatileFeaturesExample {

// 使用volatile声明64位的long型变量

volatile long vl = 0L;

// 普通long型变量

volatile long v2 = 0L;

public void set(long l) {

// 单个volatile变量的写

vl = l;

}

public synchronized void syncSet(long l) {

// 单个volatile变量的写执行效果等价于对普通变量的加同步锁来写

v2 = l;

}

public long get() {

// 单个volatile变量的读

return vl;

}

public synchronized long syncGet() {

// 单个volatile变量的读执行效果等价于对普通变量的加同步锁来读

return vl;

}

public void getAndIncrement() {

// 复合(多个)volatile变量的读/写 不具备原子性

vl++;

// v1++ 等价于 如下代码(不具备原子性)

long temp = syncGet();

temp = temp + 1;

syncSet(temp);

}

}

线程A在线程B先后执行结果会不一致,因为volatile是在读取的时候会同步,而同步的时候temp = temp + 1; syncSet(temp);不一定已经执行完毕,只有将三个语句用一个锁同步,才能保证原子性(++不具备原子性,volatile直接赋值具备原子性)。

volatile写和读的内存语义

volatile写的内存语义: 当写一个volatile变量时,JMM会把该线程对应的本地内存中的所有共享变量值刷新到主内存

e6cda45d58d0

image.png

volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取所有共享变量。

e6cda45d58d0

image.png

volatile内存语义的实现

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

具体限制规则如下

e6cda45d58d0

image.png

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

具体插入的内存屏障

在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。

e6cda45d58d0

image.png

在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障。

e6cda45d58d0

image.png

锁的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。

线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

final的内存语义

在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。也就是说只有将对象实例化完成后,才能将对象引用赋值给变量。

初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。也就是下面示例的4和5不能重排序。

当final域为引用类型时,在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

下面通过代码在说明一下:

public class FinalExample {

int i; // 普通变量

final int j; // final变量

static FinalExample obj;

public FinalExample() { // 构造函数

i = 1;// 写普通域

j = 2;// 写final域

}

public static void writer() { // 写线程A执行

// 这一步实际上有三个指令,如下:

// memory = allocate();  // 1:分配对象的内存空间

// ctorInstance(memory); // 2:初始化对象

// instance = memory;  // 3:设置instance指向刚分配的内存地址

obj = new FinalExample();

}

public static void reader() { // 读线程B执行

FinalExample object = obj; // 4\. 读对象引用

int a = object.i; // 5\. 读普通域

int b = object.j; // 读final域

}

}

如果没有final语义的保证,在writer()方法中,那三个指令可能发生重排序,导致步骤3先于2执行,然后线程B在执行reader()方法时拿到一个没有初始化的对象。

在读一个对象的final域之前,一定会先读包含这个final 域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经 被A线程初始化过了。

final语义在处理器中的实现

会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。

读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。

这篇关于java中10d是什么意思_为什么需用指令重排序的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/171883

相关文章

Java编译生成多个.class文件的原理和作用

《Java编译生成多个.class文件的原理和作用》作为一名经验丰富的开发者,在Java项目中执行编译后,可能会发现一个.java源文件有时会产生多个.class文件,从技术实现层面详细剖析这一现象... 目录一、内部类机制与.class文件生成成员内部类(常规内部类)局部内部类(方法内部类)匿名内部类二、

SpringBoot实现数据库读写分离的3种方法小结

《SpringBoot实现数据库读写分离的3种方法小结》为了提高系统的读写性能和可用性,读写分离是一种经典的数据库架构模式,在SpringBoot应用中,有多种方式可以实现数据库读写分离,本文将介绍三... 目录一、数据库读写分离概述二、方案一:基于AbstractRoutingDataSource实现动态

Springboot @Autowired和@Resource的区别解析

《Springboot@Autowired和@Resource的区别解析》@Resource是JDK提供的注解,只是Spring在实现上提供了这个注解的功能支持,本文给大家介绍Springboot@... 目录【一】定义【1】@Autowired【2】@Resource【二】区别【1】包含的属性不同【2】@

springboot循环依赖问题案例代码及解决办法

《springboot循环依赖问题案例代码及解决办法》在SpringBoot中,如果两个或多个Bean之间存在循环依赖(即BeanA依赖BeanB,而BeanB又依赖BeanA),会导致Spring的... 目录1. 什么是循环依赖?2. 循环依赖的场景案例3. 解决循环依赖的常见方法方法 1:使用 @La

Java枚举类实现Key-Value映射的多种实现方式

《Java枚举类实现Key-Value映射的多种实现方式》在Java开发中,枚举(Enum)是一种特殊的类,本文将详细介绍Java枚举类实现key-value映射的多种方式,有需要的小伙伴可以根据需要... 目录前言一、基础实现方式1.1 为枚举添加属性和构造方法二、http://www.cppcns.co

Elasticsearch 在 Java 中的使用教程

《Elasticsearch在Java中的使用教程》Elasticsearch是一个分布式搜索和分析引擎,基于ApacheLucene构建,能够实现实时数据的存储、搜索、和分析,它广泛应用于全文... 目录1. Elasticsearch 简介2. 环境准备2.1 安装 Elasticsearch2.2 J

Java中的String.valueOf()和toString()方法区别小结

《Java中的String.valueOf()和toString()方法区别小结》字符串操作是开发者日常编程任务中不可或缺的一部分,转换为字符串是一种常见需求,其中最常见的就是String.value... 目录String.valueOf()方法方法定义方法实现使用示例使用场景toString()方法方法

Java中List的contains()方法的使用小结

《Java中List的contains()方法的使用小结》List的contains()方法用于检查列表中是否包含指定的元素,借助equals()方法进行判断,下面就来介绍Java中List的c... 目录详细展开1. 方法签名2. 工作原理3. 使用示例4. 注意事项总结结论:List 的 contain

Java实现文件图片的预览和下载功能

《Java实现文件图片的预览和下载功能》这篇文章主要为大家详细介绍了如何使用Java实现文件图片的预览和下载功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... Java实现文件(图片)的预览和下载 @ApiOperation("访问文件") @GetMapping("

Spring Boot + MyBatis Plus 高效开发实战从入门到进阶优化(推荐)

《SpringBoot+MyBatisPlus高效开发实战从入门到进阶优化(推荐)》本文将详细介绍SpringBoot+MyBatisPlus的完整开发流程,并深入剖析分页查询、批量操作、动... 目录Spring Boot + MyBATis Plus 高效开发实战:从入门到进阶优化1. MyBatis