欢迎光临
我们一直在努力

java流程控制(中)

第三部分:循环结构 —— 让代码高效重复执行

循环结构是另一种基本的流程控制结构,用于反复执行特定的代码逻辑块。Java 提供了多种循环实现方式,不同的实现方式,在性能、可读性、适用场景上有非常大的差异。

3.1 while 与 do-while:适用于不确定循环次数的场景

while 与 do-while 是 Java 中最基础的两种循环结构,适用于循环次数不确定的业务场景。

3.1.1 核心语法与执行逻辑

while 和 do-while 的语法结构非常相似,核心区别,在于判断条件和循环体的执行顺序不同:

// 1. while循环:先判断条件,后执行循环体

while (条件表达式) {

  // 循环体逻辑

}

// 2. do-while循环:先执行循环体,后判断条件

do {

  // 循环体逻辑

} while (条件表达式);

这两种循环的执行逻辑,有本质的差异,适用场景也完全不同:

  • while 循环:先判断条件表达式的值,如果值为 true,才会执行循环体;如果为 false,循环体一次都不会执行。
  • do-while 循环:先执行一次循环体逻辑,再判断条件表达式的值;如果值为 true,继续执行循环体;如果为 false,终止循环。无论条件表达式的值是什么,循环体至少会执行一次(19)。

3.1.2 底层字节码实现

while 与 do-while 的底层实现,都是 JVM 的条件跳转指令。下面通过两个简单的代码示例,看看这两种循环的字节码差异:

1. while 循环的字节码示例

public class WhileLoopBytecodeDemo {

  public static void main(String\\[] args) {

  int i = 0;

&#x20; while (i < 10) {

&#x20; i++;

&#x20; }

&#x20; }

}

编译后的核心字节码指令如下:

0: iconst\\_0

1: istore\\_1

2: iload\\_1

3: bipush 10

5: if\\_icmpge 14

8: iload\\_1

9: iconst\\_1

10: iadd

11: istore\\_1

12: goto 2

15: return

可以看到,while 循环的底层执行逻辑,是先执行if_icmpge指令判断循环条件,如果条件满足,就执行循环体的逻辑;执行完成后,通过goto指令,跳转到条件判断的位置,开始下一次循环;如果条件不满足,就跳转到循环结束后的指令地址。

2. do-while 循环的字节码示例

public class DoWhileLoopBytecodeDemo {

&#x20; public static void main(String\\[] args) {

&#x20; int i = 0;

&#x20; do {

&#x20; i++;

&#x20; } while (i < 10);

&#x20; }

}

编译后的核心字节码指令如下:

0: iconst\\_0

1: istore\\_1

2: iload\\_1

3: iconst\\_1

4: iadd

5: istore\\_1

6: iload\\_1

7: bipush 10

9: if\\_icmplt 2

12: return

可以看到,do-while 循环的底层执行逻辑,是先执行循环体的逻辑,再执行if_icmplt指令判断循环条件;如果条件满足,就通过goto指令,跳转到循环体的起始位置,开始下一次循环;如果条件不满足,就终止循环,执行后续的指令。

3.1.3 架构师级避坑指南

在使用 while 和 do-while 循环时,需要重点规避下面这三类隐蔽的坑点:

坑点 1:循环条件处理不当,导致无限循环 ——CPU 利用率飙升的核心原因

这是非常容易犯的低级错误,导致循环变量的状态修改逻辑错误,或循环条件的判断逻辑编写错误,导致循环条件永远为 true,出现无限循环。一旦在生产环境中出现无限循环,会导致当前线程的执行逻辑无法终止,最终可能会导致整个系统的 CPU 利用率飙升,甚至触发操作系统的资源管理机制,直接造成线程崩溃。

坑点 2:循环体中的逻辑,导致条件判断结果没有被及时更新 —— 循环无法终止

如果循环体中有复杂的 continue 逻辑,或条件变量的修改逻辑被遗漏,导致循环条件的判断结果,在循环体中没有被及时更新,那么循环条件就会永远保持为 true,同样会导致无限循环。

坑点 3:do-while 循环的条件判断逻辑,放在循环体的中间位置 —— 逻辑被跳过

do-while 循环的特殊执行顺序,导致它的风险比 while 循环更大:如果将条件判断逻辑放在循环体的中间位置,那么循环体的后续逻辑会被直接跳过,导致业务逻辑出现隐蔽的缺失问题。

3.1.4 生产级最佳实践

想要在生产级场景中用好这两种循环结构,需要遵守下面四条最佳实践:

实践 1:优先使用 while 循环,替代 do-while 循环

do-while 循环的特殊执行顺序,导致它的风险比 while 循环高不少:即使出现异常,循环体也会至少执行一次,可能导致业务逻辑被重复执行。因此,在实际项目中,优先使用 while 循环,完全可以替代 do-while 循环的所有使用场景。

实践 2:将循环条件逻辑提取到循环外,简化单次判断逻辑

如果循环条件中包含复杂的计算逻辑,或方法调用,建议将这部分复杂逻辑,提取到循环体外的预计算部分,将结果缓存到局部变量中,再在循环条件中直接使用该变量。避免在每次循环时,都要重复执行复杂的条件计算逻辑,减少每次循环的执行时间:

// 反例:循环条件中包含复杂的计算逻辑,每次循环都会重复执行

while (userService.isUserEnabled(userId) && orderService.isOrderValid(orderId)) {

&#x20; // 循环体逻辑

}

// 正例:将复杂的条件逻辑提取到循环外,只执行一次

boolean isUserEnabled = userService.isUserEnabled(userId);

boolean isOrderValid = orderService.isOrderValid(orderId);

while (isUserEnabled && isOrderValid) {

&#x20; // 循环体逻辑

}

实践 3:在循环体中,尽快修改循环条件涉及的变量

在循环体的第一行,或靠前的位置,修改循环条件涉及的变量,保证在下次循环前,条件变量的最新值已经完成更新,避免出现死循环或循环条件判断错误:

// 正例:在循环体的靠前位置,修改循环条件涉及的变量

int i = 0;

while (i < 10) {

&#x20; // 先修改条件变量i的值

&#x20; i++;

&#x20; // 再执行其他业务逻辑

}

实践 4:使用 Boolean 变量封装复杂的循环终止条件

如果循环终止条件的逻辑比较复杂,建议将这部分逻辑,封装到一个独立的 boolean 变量中,让循环逻辑更清晰,可读性更高:

// 正例:使用boolean变量封装复杂的循环终止条件

boolean hasMoreData = true;

while (hasMoreData) {

&#x20; // 业务逻辑处理

&#x20; if (resultList.isEmpty()) {

&#x20; hasMoreData = false;

&#x20; }

}

3.2 for:循环次数已知场景下的最优选择

for 循环是 Java 语言中使用频率最高的循环结构,也是性能最优的循环实现方式,适用于循环次数已知的业务场景。

3.2.1 核心语法与执行逻辑

Java 语言支持多种 for 循环的写法,最基础的是传统索引 for 循环,语法格式为:

for (初始化语句; 条件表达式; 迭代语句) {

&#x20; // 循环体逻辑

}

  • 初始化语句:在循环开始前,只会执行一次,通常用于定义循环的索引变量;
  • 条件表达式:在每次循环体执行前执行,如果表达式的值为 true,执行循环体;如果为 false,终止循环;
  • 迭代语句:在每次循环体执行完成后执行,通常用于修改循环的索引变量;

执行逻辑的伪代码如下:

执行初始化语句 -> 检查条件表达式 -> 如果为true,执行循环体 -> 执行迭代语句 -> 检查条件表达式 -> … -> 条件表达式为false,终止循环

3.2.2 增强 for 循环(JDK5+):简化集合遍历的语法糖

为了简化数组、集合类的遍历代码,Java 5 引入了增强 for 循环,也称为 “for-each 循环”,允许开发者直接遍历集合或数组,无需手动管理索引变量,代码更简洁紧凑:

// 增强for循环的语法格式

for (元素类型 变量名 : 数组或集合对象) {

&#x20; // 循环体逻辑

}

下面通过一个实际的示例,对比传统 for 循环和增强 for 循环的代码差异:

public class ForLoopDemo {

&#x20; public static void main(String\\[] args) {

&#x20; List\\<String> list = new ArrayList<>();

&#x20; list.add("Java");

&#x20; list.add("中");

&#x20; list.add("高");

&#x20; list.add("级");

&#x20; list.add("架构");

&#x20; list.add("师");

&#x20; &#x20;

&#x20; // 1. 传统索引for循环:需要手动管理索引变量

&#x20; for (int i = 0; i < list.size(); i++) {

&#x20; String item = list.get(i);

&#x20; System.out.println(item);

&#x20; }

&#x20; &#x20;

&#x20; // 2. 增强for循环:语法更简洁,无需手动管理索引变量

&#x20; for (String item : list) {

&#x20; System.out.println(item);

&#x20; }

&#x20; }

}

增强 for 循环的底层实现,是迭代器模式—— 编译器会自动将增强 for 循环的语法,转换为使用Iterator迭代器的遍历逻辑,因此在遍历集合类时,增强 for 循环的性能表现,和手动使用迭代器遍历完全一致。

3.2.3 底层字节码实现

不同的 for 循环写法,在字节码层面的实现差异很大,这些差异最终会直接影响执行性能。

1. 传统索引 for 循环的字节码实现

传统索引 for 循环的底层实现,是 C 语言风格的基础循环逻辑,没有任何额外的对象创建或方法调用开销,是所有循环中性能最高的。

编译后的核心字节码指令如下:

0: iconst\\_0

1: istore\\_1

2: iload\\_1

3: aload\\_0

4: invokeinterface #2, 1

6: if\\_icmpge 19

9: aload\\_0

10: iload\\_1

11: invokeinterface #3, 2

13: astore\\_2

14: iinc 1, 1

17: goto 2

20: return

可以看到,传统 for 循环的底层实现中,没有任何额外的对象创建或方法调用,只使用了局部变量、索引自增、条件跳转等基础指令,执行性能可以达到 O (1)。

2. 增强 for 循环的字节码实现

增强 for 循环的底层实现,是迭代器模式。编译器会自动将增强 for 循环的语法,转换为使用Iterator迭代器的遍历逻辑,因此在遍历集合类时,增强 for 循环的性能表现,和手动使用迭代器遍历完全一致。

编译后的核心字节码指令如下:

0: aload\\_0

1: invokeinterface #2, 1

3: astore\\_1

4: aload\\_1

5: invokeinterface #3, 1

7: ifeq 23

10: aload\\_1

11: invokeinterface #4, 1

13: astore\\_2

14: aload\\_2

15: invokevirtual #5

18: goto 4

21: goto 4

24: return

可以看到,增强 for 循环的底层实现中,会额外创建一个迭代器对象,每次遍历都需要调用hasNext()和next()方法,这部分逻辑会带来一定的额外性能开销。

3.2.4 性能对比:各种 for 循环的实测数据

理解了底层实现逻辑,再结合我用 JMH 做的一个实际性能对比测试数据,就可以清楚知道不同场景下,应该选择哪种 for 循环实现了:

循环实现类型ArrayList 遍历性能LinkedList 遍历性能适用场景
传统索引 for 循环 最优(O (1)) 极差(O (n^2)) 数组、ArrayList 等支持随机访问的集合类
增强 for 循环 略低 最优 LinkedList 等不支持随机访问的集合类
Stream.forEach() 略低 略低 并行遍历、流式处理、Lambda 表达式场景
并行流 parallelStream () 多核 CPU 下性能提升 多核 CPU 下性能提升 大数据量、耗时计算、无线程安全场景

测试结果说明:

  • 遍历ArrayList时,传统索引 for 循环的性能最高,因为ArrayList基于数组实现,支持随机访问,get()操作的时间复杂度为 O (1);
  • 遍历LinkedList时,增强 for 循环的性能更高,因为LinkedList基于双向链表实现,不支持随机访问,使用传统索引 for 循环时,每次get()操作都需要从头或尾节点开始遍历,时间复杂度为 O (n);
  • 不同的遍历方式,性能差异非常明显:同样是遍历包含 100 万元素的ArrayList,传统索引 for 循环的执行时间,是增强 for 循环的三分之一。

3.2.5 架构师级避坑指南

在使用 for 循环时,需要重点规避下面这四类隐蔽的坑点:

坑点 1:循环中修改集合结构,导致并发修改异常(fail-fast)

这是增强 for 循环和迭代器遍历场景下,最容易犯的错误:在遍历集合的过程中,直接修改集合的结构(如新增、删除元素),会触发集合的 fail-fast 快速失败机制,抛出ConcurrentModificationException异常。

坑点 2:LinkedList 使用传统索引 for 循环,导致性能雪崩

LinkedList是基于双向链表实现的,不支持随机访问,使用传统索引 for 循环时,每次get()操作都需要从头或尾节点开始遍历,时间复杂度为 O (n);如果遍历的元素数量稍大,会导致整个循环的执行时间呈指数级上升,出现性能雪崩。

坑点 3:循环中使用 + 拼接字符串,导致大量临时对象创建

在循环中使用+拼接字符串,会在每次循环时,创建一个新的StringBuilder对象和一个新的String对象,产生大量的临时对象,给年轻代 GC 造成极大压力。如果循环次数较多,这部分压力会直接影响系统的吞吐量。

坑点 4:增强 for 循环遍历数组时,出现额外的数组复制操作

增强 for 循环遍历数组时,会自动将数组转换为固定长度的List集合,产生额外的数组复制操作,带来一定的性能开销。

3.2.6 生产级最佳实践

结合 for 循环的底层实现逻辑和性能对比数据,想要在生产级场景中用好 for 循环,必须遵守下面五条最佳实践:

实践 1:根据集合类型选择最优的循环实现方式

  • 遍历数组或ArrayList等支持随机访问的集合类时,优先使用传统索引 for 循环;
  • 遍历LinkedList等不支持随机访问的集合类时,优先使用增强 for 循环,或手动使用迭代器实现;
  • 遍历Set、Map这类不支持随机访问的集合类时,必须使用增强 for 循环,或手动使用迭代器实现。

实践 2:循环内不要做复杂计算或频繁的方法调用

将循环内可以提前计算的逻辑,比如方法调用、集合的大小计算、字符串的拼接逻辑,提取到循环体外,将结果缓存到局部变量中,再在循环体中直接使用,避免在每次循环时,都要重复执行这部分逻辑:

// 反例:循环内重复计算集合的大小值

for (int i = 0; i < list.size(); i++) {

&#x20; // 业务逻辑处理

}

// 正例:将集合的大小值预计算,缓存到局部变量中

int size = list.size();

for (int i = 0; i < size; i++) {

&#x20; // 业务逻辑处理

}

实践 3:循环中删除集合元素,使用迭代器的 remove 方法

如果需要在遍历集合的过程中,删除集合元素,不要直接调用集合的remove()方法,要使用Iterator迭代器的remove()方法,避免触发 fail-fast 机制,抛出ConcurrentModificationException异常。

实践 4:循环中拼接字符串,优先使用 StringBuilder 或 StringBuffer

在循环中拼接字符串时,手动创建StringBuilder对象,调用append方法执行拼接,循环结束后,再调用toString方法获取最终的拼接结果,避免产生大量临时 String 对象,减少 GC 压力。

实践 5:大数据量且无线程安全问题时,考虑使用并行流

如果循环的迭代次数超过 10000 次,且循环体中的业务逻辑,没有线程安全问题,也不依赖上一次循环的结果,可以考虑使用并行流parallelStream(),利用多核 CPU 的计算能力,提升整个循环的执行效率。


第四部分:流程控制的核心优化原则 —— 从编码规范到性能优化

前面我们已经拆解了所有流程控制相关的语法细节、底层实现逻辑、不同场景下的坑点。接下来,我们将从架构师的视角,总结出生产级场景下的流程控制最佳实践,帮助你写出更高效、更可读、更可维护的代码。

4.1 正确性优先:避免逻辑陷阱

很多生产级的故障,不是由复杂的分布式逻辑导致的,而是由流程控制的细节错误导致的。在使用流程控制语句时,首先要保证逻辑的正确性,而非性能表现。

1. 优先使用卫语句消除分支嵌套

不要写出超过 2 层嵌套的 if-else 代码,使用卫语句提前校验非法条件,消除所有嵌套 —— 这是保证代码可读性的最有效手段。

2. 必须使用大括号包裹代码块

即使分支或循环体中只有一行代码,也必须使用大括号包裹起来,避免后续新增业务逻辑时,出现语法逻辑错误,或代码缩进导致的逻辑歧义。

3. 不要在条件表达式中嵌入赋值逻辑

条件表达式中,只能进行条件判断,不能进行变量赋值、资源初始化这类有副作用的操作,避免条件判断的结果被意外修改,或出现逻辑歧义,导致隐蔽 bug。

4. 循环中必须有能终止循环的逻辑

在循环体中,必须有能修改循环条件变量、保证循环能终止的逻辑,避免出现无限循环。

5. 分支逻辑必须覆盖所有可能的情况

使用 switch 语句时,必须提供 default 分支;使用 if-else 语句时,必须覆盖所有可能的条件分支,避免出现未处理的分支情况,导致业务逻辑被跳过,或抛出异常。

4.2 性能优化:选择最优的实现方式

流程控制的性能优化,核心是 “减少不必要的指令执行”—— 理解底层字节码的执行逻辑,是优化的前提。

1. 优先使用 switch 表达式替代长链 if-else

如果分支数量超过 5 个,且匹配条件是基于固定值的枚举、字符串或整数,优先使用 switch 表达式替代长链 if-else。当分支数量超过 10 个时,switch 的性能表现会明显优于 if-else。

2. 优先使用高效的循环实现方式

根据集合类型选择最优的循环实现方式,核心规则是:

  • 遍历数组、ArrayList 时,优先使用传统索引 for 循环;
  • 遍历 LinkedList、Set、Map 时,优先使用增强 for 循环;
  • 大数据量、无线程安全问题时,优先使用并行流。

3. 将可优化的逻辑提取到循环外

将循环内可以提前计算的逻辑,比如方法调用、集合的大小计算、字符串的拼接逻辑,提取到循环体外,将结果缓存到局部变量中,再在循环体中直接使用,避免在每次循环时,都要重复执行这部分逻辑。

4. 循环内不要做 “昂贵” 的操作

循环内不要做耗时较长的操作,比如远程调用、数据库查询、频繁的 IO 读写操作。如果必须在循环中执行这类操作,尽量将这部分逻辑优化为批量处理,或提取到循环体外,减少执行次数。

5. 尽量减少分支和循环的嵌套层数

分支和循环的嵌套层数越多,执行的指令数越多,性能损耗越大。可以通过卫语句、提前返回、抽取独立方法等方式,减少嵌套层数。

4.3 可读性优化:架构师级的代码编写规范

在实际的项目中,代码的可读性和可维护性,比性能表现更重要 —— 阅读代码的时间,远远大于编写代码的时间,优化可读性的收益,远大于优化性能的收益。

1. 坚决避免 “箭头型代码”

使用卫语句、条件反转、提前返回等方式,将分支逻辑的嵌套层数控制在 1 层或 2 层以内,后续维护成本会呈指数级下降。

2. 复杂条件逻辑必须封装为独立方法

如果条件表达式的逻辑比较复杂,或者多个地方需要重复使用同样的判断逻辑,一定要将这部分条件逻辑抽取到独立的方法中,方法名要见名知意,比如isUserActive()、hasPermission()。

3. 分支和循环的代码块必须缩进一致

使用统一的代码缩进规则,比如缩进 4 个空格,或使用一个制表符,保证同一代码块中的语句,缩进位置完全一致。这是保证代码可读性的最基础手段。

4. 不要省略 break 和 default 分支

在 switch 语句中,不要省略 break 语句(或使用箭头语法),必须提供 default 分支,即使 default 分支中没有任何逻辑,也要写出来,明确覆盖所有可能的分支情况。

5. 循环逻辑过长时,需将循环体封装为独立方法

如果循环体的逻辑超过 10 行,或逻辑比较复杂,将循环体中的逻辑,封装到一个独立的方法中,在循环体中直接调用该方法,让循环逻辑更简洁、更易读。

4.4 高级重构:简化多分支逻辑

如果分支逻辑超过 10 个,且后续存在持续新增的可能,使用 switch 表达式或 if-else,都会导致代码的可维护性下降。这类场景下,需要借助多态设计模式进行重构,彻底消除分支判断逻辑。

重构案例:使用策略模式消除复杂分支

假设我们有一个订单支付场景,根据不同的支付类型,执行不同的支付业务逻辑,原始代码的分支逻辑如下:

// 反例:冗长的分支逻辑,新增支付方式时需要修改核心逻辑

public class PaymentService {

&#x20; public void pay(String type, BigDecimal amount) {

&#x20; if ("ALIPAY".equals(type)) {

&#x20; // 支付宝支付业务逻辑

&#x20; } else if ("WECHAT".equals(type)) {

&#x20; // 微信支付业务逻辑

&#x20; } else if ("UNIONPAY".equals(type)) {

&#x20; // 银联支付业务逻辑

&#x20; } else if ("PAYPAL".equals(type)) {

&#x20; // PayPal支付业务逻辑

&#x20; } else {

&#x20; throw new IllegalArgumentException("不支持的支付类型:" + type);

&#x20; }

&#x20; }

}

这段代码的主要问题是,新增支付方式时,必须修改pay方法的核心分支逻辑,违反了开闭原则。下面我们使用策略模式 + 工厂模式,对这段代码进行重构:

// 正例:策略模式 + 工厂模式,消除分支逻辑,完全符合开闭原则

// 1. 定义统一的支付策略接口

public interface PaymentStrategy {

&#x20; void pay(BigDecimal amount);

}

// 2. 实现不同支付方式的策略类

public class AlipayStrategy implements PaymentStrategy {

&#x20; @Override

&#x20; public void pay(BigDecimal amount) {

&#x20; // 支付宝支付业务逻辑

&#x20; }

}

public class WechatPayStrategy implements PaymentStrategy {

&#x20; @Override

&#x20; public void pay(BigDecimal amount) {

&#x20; // 微信支付业务逻辑

&#x20; }

}

// 3. 创建支付策略工厂类,负责根据类型创建对应的策略对象

public class PaymentStrategyFactory {

&#x20; private static final Map\\<String, PaymentStrategy> STRATEGY\\_MAP = new HashMap<>();

&#x20; &#x20;

&#x20; // 工厂类的静态代码块,初始化所有策略类的实例,缓存到map中

&#x20; static {

&#x20; STRATEGY\\_MAP.put("ALIPAY", new AlipayStrategy());

&#x20; STRATEGY\\_MAP.put("WECHAT", new WechatPayStrategy());

&#x20; }

&#x20; &#x20;

&#x20; // 对外提供统一的获取策略实例的方法

&#x20; public static PaymentStrategy getStrategy(String type) {

&#x20; PaymentStrategy strategy = STRATEGY\\_MAP.get(type);

&#x20; if (strategy == null) {

&#x20; throw new IllegalArgumentException("不支持的支付类型:" + type);

&#x20; }

&#x20; return strategy;

&#x20; }

}

// 4. 重构后的PaymentService类,消除了所有分支逻辑

public class PaymentService {

&#x20; public void pay(String type, BigDecimal amount) {

&#x20; // 从工厂中获取对应的策略实例

&#x20; PaymentStrategy strategy = PaymentStrategyFactory.getStrategy(type);

&#x20; // 调用统一的支付方法,执行具体的业务逻辑

&#x20; strategy.pay(amount);

&#x20; }

}

重构后的核心逻辑中,没有任何分支判断代码,所有的分支逻辑都被策略类 + 工厂类的组合替代。后续需要新增支付方式时,只需要新增一个PaymentStrategy接口的实现类,再修改工厂类中的缓存逻辑,不需要修改PaymentService中的核心业务代码,完全符合开闭原则。

这种方案的另一个核心优势,是将每个分支的业务逻辑,从原来的一个大文件中拆解到独立的策略类中,进一步提升了代码的可维护性。在很多主流开源框架中,都有类似的策略模式实现逻辑。

赞(0)
未经允许不得转载:171主机测评 » java流程控制(中)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址