38. 【Java】Stream API(下):高级操作与性能

摘要: 本文深入探讨Java Stream API的高级特性,涵盖并行流的多核加速原理与线程安全实践、Collectors工具类的多级分组与下游收集技巧、原始类型 IntStream/LongStream/DoubleStream 的性能优化、Stream调试方法以及性能考量策略。通过实际代码示例展示如何利用takeWhile/dropWhile进行条件截取,并提供一个综合数据报表生成案例,帮助开发者充分发挥Stream API在大数据处理中的潜力。

关键词: Java Stream API, 并行流, Collectors, 原始类型流, 性能优化


在上一篇中,我们学习了 Stream API 的基础:filtermapsortedcollect......你现在应该已经能用 Stream 写出很优雅的集合处理代码了。

但是,如果只停留在"会用"的层面,那你可能还没发挥出 Stream 的全部潜力。Stream API 还有一些高级特性,能让你的代码更强大、更高效:

  • 如何利用并行流让数据处理跑得更快?
  • Collectors 还有哪些高级收集方式------比如分组后做聚合、多级分组?
  • 如何调试一个复杂的 Stream 流水线?
  • 什么时候该用 Stream,什么时候该用循环?性能怎么考量?

今天,我们就来深入 Stream 的"下半场",把这些高级话题一网打尽。


1. 并行流 ------ 让多核 CPU "动起来"

1.1 什么是并行流?

我们之前用的 stream() 创建的是顺序流 ------所有操作在单线程中按顺序执行。而 parallelStream() 创建的是并行流------它会利用 Fork/Join 框架,把数据分成多个子任务,在多个线程中同时处理,最后再合并结果。

java 复制代码
// 顺序流
list.stream().filter(...).map(...).collect(...);

// 并行流
list.parallelStream().filter(...).map(...).collect(...);

对于大型数据集,并行流可以显著提升处理速度。

1.2 一个简单的性能对比
java 复制代码
import java.util.stream.IntStream;
import java.time.Duration;
import java.time.Instant;

public class ParallelStreamDemo {
    public static void main(String[] args) {
        // 创建一个包含 1000 万个整数的流
        int size = 10_000_000;
        
        // 顺序流:计算所有数的平方和
        Instant start1 = Instant.now();
        long sum1 = IntStream.rangeClosed(1, size)
            .map(n -> n * n)
            .sum();
        Instant end1 = Instant.now();
        System.out.println("顺序流耗时:" + Duration.between(start1, end1).toMillis() + "ms");
        
        // 并行流:计算所有数的平方和
        Instant start2 = Instant.now();
        long sum2 = IntStream.rangeClosed(1, size)
            .parallel()
            .map(n -> n * n)
            .sum();
        Instant end2 = Instant.now();
        System.out.println("并行流耗时:" + Duration.between(start2, end2).toMillis() + "ms");
        
        System.out.println("结果是否一致:" + (sum1 == sum2));
    }
}

在 4 核以上的机器上,并行流通常会比顺序流快 2-4 倍(取决于任务类型和 CPU 核心数)。

1.3 什么时候用并行流?

适合并行流的场景

  • 数据量很大(至少成千上万个元素)
  • 每个元素的处理是独立的(没有共享状态)
  • 处理每个元素的计算量较大(不仅仅是简单的加减)
  • CPU 是多核的(现在的机器基本都是)

不适合并行流的场景

  • 数据量很小(几百个元素,并行开销可能超过收益)
  • 操作有顺序依赖(比如需要保持元素顺序,用 forEachOrdered
  • 操作涉及共享可变状态(比如多个线程同时修改同一个 ArrayList
  • 操作本身很快(比如简单的 mapfilter,并行开销反而大)

💡 经验法则:不要默认用并行流。先用顺序流写对,如果性能不够且数据量大,再尝试换成并行流并测试效果。

1.4 并行流的线程安全注意事项

并行流底层使用 Fork/Join 线程池(默认线程数 = CPU 核心数)。如果你在并行流中修改了共享状态,必须确保线程安全。

java 复制代码
// ❌ 危险:多个线程同时修改 ArrayList
List<Integer> result = new ArrayList<>();
IntStream.range(0, 1000).parallel()
    .forEach(i -> result.add(i));  // 可能导致数据丢失或异常!

// ✅ 安全:用 collect 收集
List<Integer> result2 = IntStream.range(0, 1000).parallel()
    .boxed()
    .collect(Collectors.toList());  // 线程安全

// ✅ 安全:用线程安全的容器
List<Integer> result3 = Collections.synchronizedList(new ArrayList<>());
IntStream.range(0, 1000).parallel()
    .forEach(i -> result3.add(i));

原则 :在并行流中,尽量使用无状态的 Lambda,并用 collect 等安全操作来收集结果。

1.5 控制并行流的线程数

默认情况下,并行流使用 ForkJoinPool.commonPool()。你可以通过系统属性调整线程数,但更好的做法是用自定义的 ForkJoinPool

java 复制代码
import java.util.concurrent.ForkJoinPool;

// 用自定义线程池执行并行流
try (ForkJoinPool pool = new ForkJoinPool(4)) {
    int result = pool.submit(() ->
        IntStream.range(0, 1000).parallel()
            .sum()
    ).get();
    System.out.println(result);
}

2. Collectors 高级用法

我们在上一篇用了 Collectors.toList()Collectors.groupingBy()。其实 Collectors 还有更多强大的能力。

2.1 groupingBy 的多级分组

你可以嵌套 groupingBy,实现多级分组

java 复制代码
// 先按年级分组,再按年龄分组
Map<GradeLevel, Map<Integer, List<Student>>> multiLevel = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,                      // 第一级:年级
        Collectors.groupingBy(Student::getAge)  // 第二级:年龄
    ));

// 遍历两级分组
multiLevel.forEach((grade, ageMap) -> {
    System.out.println(grade.getChineseName());
    ageMap.forEach((age, list) -> 
        System.out.println("  " + age + "岁:" + list.size() + "人")
    );
});
2.2 groupingBy + 下游收集器

groupingBy 的第二个参数是一个下游收集器(downstream collector),它决定了每个组里收集什么。

java 复制代码
// 按年级分组,每组只收集学生的姓名(转为 Set)
Map<GradeLevel, Set<String>> namesByGrade = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,
        Collectors.mapping(Student::getName, Collectors.toSet())
    ));

// 按年级分组,计算每个组的平均年龄
Map<GradeLevel, Double> avgAgeByGrade = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,
        Collectors.averagingInt(Student::getAge)
    ));

// 按年级分组,找出每个组中年龄最大的学生
Map<GradeLevel, Optional<Student>> oldestByGrade = students.stream()
    .collect(Collectors.groupingBy(
        Student::getGrade,
        Collectors.maxBy(Comparator.comparing(Student::getAge))
    ));
2.3 partitioningBy ------ 二分组

partitioningBygroupingBy 的特殊情况,它把元素分成两组(满足条件 / 不满足条件),返回 Map<Boolean, List<T>>

java 复制代码
// 将学生分为成年(>=18岁)和未成年两组
Map<Boolean, List<Student>> byAdult = students.stream()
    .collect(Collectors.partitioningBy(s -> s.getAge() >= 18));

System.out.println("成年组:" + byAdult.get(true).size());
System.out.println("未成年组:" + byAdult.get(false).size());
2.4 summarizing ------ 一次性获取多项统计信息

Collectors.summarizingIntsummarizingLongsummarizingDouble 能一次性返回计数、总和、平均值、最小值、最大值。

java 复制代码
IntSummaryStatistics stats = students.stream()
    .collect(Collectors.summarizingInt(Student::getAge));

System.out.println("总人数:" + stats.getCount());
System.out.println("年龄总和:" + stats.getSum());
System.out.println("平均年龄:" + stats.getAverage());
System.out.println("最小年龄:" + stats.getMin());
System.out.println("最大年龄:" + stats.getMax());
2.5 collectingAndThen ------ 收集后再转换

当你想收集到一个集合后,再对它做一步转换,可以用 collectingAndThen

java 复制代码
// 收集学生姓名到 List,然后再转成不可变的 List
List<String> immutableNames = students.stream()
    .map(Student::getName)
    .collect(Collectors.collectingAndThen(
        Collectors.toList(),
        Collections::unmodifiableList
    ));

// 收集后,把 List 转成 Set 再获取大小
int uniqueNameCount = students.stream()
    .map(Student::getName)
    .collect(Collectors.collectingAndThen(
        Collectors.toSet(),
        Set::size
    ));

3. 原始类型流 ------ IntStreamLongStreamDoubleStream

为了减少自动装箱/拆箱的性能开销,Stream API 专门为基本类型提供了原始类型流:

类型 对应的流
int IntStream
long LongStream
double DoubleStream
3.1 创建原始类型流
java 复制代码
// 从范围创建
IntStream.range(1, 10);        // 1,2,3,4,5,6,7,8,9(不包含10)
IntStream.rangeClosed(1, 10);  // 1,2,3,4,5,6,7,8,9,10(包含10)

// 从数组创建
int[] array = {1, 2, 3, 4, 5};
IntStream stream = Arrays.stream(array);

// 从集合转成原始流
List<Integer> list = Arrays.asList(1, 2, 3);
IntStream intStream = list.stream().mapToInt(Integer::intValue);
3.2 原始类型流的特有方法
java 复制代码
IntStream.rangeClosed(1, 100)
    .filter(n -> n % 2 == 0)    // 偶数
    .average()                  // 计算平均值(特有)
    .ifPresent(avg -> System.out.println("平均:" + avg));

// sum() 是 IntStream/LongStream/DoubleStream 的特有方法
int sum = IntStream.rangeClosed(1, 100).sum();

// 统计摘要(比 Collectors.summarizingInt 更简洁)
IntSummaryStatistics stats2 = IntStream.rangeClosed(1, 100)
    .summaryStatistics();
3.3 原始类型流 ↔ 对象流
java 复制代码
// 对象流 → 原始类型流
List<Student> students = ...;
IntStream ageStream = students.stream().mapToInt(Student::getAge);

// 原始类型流 → 对象流
Stream<Integer> boxedStream = IntStream.range(1, 10).boxed();

什么时候用原始类型流?

  • 处理大量数值型数据时(intlongdouble
  • 需要调用 sum()average()summaryStatistics() 等特有方法时
  • 需要避免反复装箱拆箱带来的性能开销时

4. 调试 Stream ------ 让看不见的执行过程"现形"

Stream 的流水线是惰性的,中间操作不执行,调试起来比普通循环困难。但有几种方法可以帮助你"看到"Stream 的执行过程。

4.1 peek() ------ 插入"观察点"

peek() 让你在流水线的任意位置插入一个操作,通常是打印日志。

java 复制代码
List<String> result = names.stream()
    .peek(s -> System.out.println("原始:" + s))
    .filter(s -> s.length() > 2)
    .peek(s -> System.out.println("过滤后:" + s))
    .map(String::toUpperCase)
    .peek(s -> System.out.println("转大写后:" + s))
    .collect(Collectors.toList());

输出会清晰地显示每一步的数据变化。

4.2 用 IDE 的调试工具

在 IntelliJ IDEA 中,你可以在 Stream 操作上设置断点,然后使用 "Trace Current Stream Chain" 功能(在 Debug 面板的 Streams 标签页中),它能可视化地展示每个操作前后的元素变化,非常强大。

4.3 用 forEach 临时调试

当你怀疑某个中间操作的结果时,可以临时加一个 forEach 来查看,但注意 forEach 是终止操作,加了之后就无法继续链式调用了。更适合用 peek


5. 性能考量 ------ 什么时候用 Stream?

5.1 Stream vs 传统循环
场景 推荐
代码可读性更重要 Stream(大多数业务场景)
性能是首要考虑,且循环逻辑简单 传统 for 循环(更底层,开销小)
数据量很大(> 10 万)且操作复杂 并行 Stream 或手动多线程
需要提前终止(break) 传统循环(Stream 的短路操作如 limitfindFirst 也支持提前终止,但不如 break 灵活)
5.2 Stream 的性能开销
  • 对象创建:每个中间操作都会创建新的 Stream 对象。
  • Lambda 调用:每个元素都会调用 Lambda,比普通循环的方法调用有额外开销。
  • 对于小集合(< 100 个元素),Stream 的开销可能超过收益。

结论 :在绝大多数业务应用中,可读性和可维护性比微小的性能差异更重要。优先选择 Stream,除非你在热路径上(每秒执行数万次)且性能测试显示 Stream 是瓶颈。

5.3 并行流的性能陷阱
  • 任务拆分开销:将数据拆分成子任务是有成本的,小数据集反而更慢。
  • 合并开销:子任务的结果需要合并,也可能消耗时间。
  • 竞争:如果多个任务争抢同一个资源(比如写入同一个文件),并行会变得更慢。
java 复制代码
// ❌ 糟糕:每个元素的操作很简单,并行反而更慢
IntStream.range(0, 1000).parallel()
    .map(n -> n * 2)   // 操作太简单
    .sum();

// ✅ 良好:每个元素的操作很重,并行有收益
IntStream.range(0, 1000).parallel()
    .map(n -> heavyComputation(n))  // 计算量大
    .sum();

6. takeWhiledropWhile ------ 条件截取(JDK 9+)

JDK 9 为 Stream 增加了两个新操作,用于基于条件截取元素。

  • takeWhile(predicate):从开头开始,一直取到第一个不满足条件的元素为止(包含满足条件的部分)。
  • dropWhile(predicate):从开头开始,一直丢弃到第一个不满足条件的元素为止(保留不满足条件的部分)。
java 复制代码
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 从开头取,直到遇到第一个 > 5 的元素为止
List<Integer> taken = numbers.stream()
    .takeWhile(n -> n <= 5)
    .collect(Collectors.toList());
System.out.println(taken);  // [1, 2, 3, 4, 5]

// 从开头丢弃,直到遇到第一个 > 5 的元素为止
List<Integer> dropped = numbers.stream()
    .dropWhile(n -> n <= 5)
    .collect(Collectors.toList());
System.out.println(dropped);  // [6, 7, 8, 9, 10]

注意takeWhiledropWhile有序流 中表现符合预期(按顺序截取),但在无序流中行为不确定(可能取到任意满足条件的元素)。


7. 综合示例 ------ 复杂数据报表生成

让我们综合运用今天学到的所有高级技巧,生成一份学生数据报表:

java 复制代码
package com.example.school;

import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class AdvancedStreamDemo {
    public static void main(String[] args) {
        // 准备数据:学生们有姓名、年龄、年级、各科成绩
        List<Student> students = createSampleData();

        // 1. 按年级分组,统计每个年级的平均年龄、平均分
        System.out.println("=== 年级统计报表 ===");
        Map<GradeLevel, DoubleSummaryStatistics> statsByGrade = students.stream()
                .collect(Collectors.groupingBy(
                        Student::getGrade,
                        Collectors.summarizingDouble(Student::getAverageScore)
                ));

        statsByGrade.forEach((grade, stats) -> {
            System.out.printf("%s:人数 %d,平均分 %.1f%n",
                    grade.getChineseName(),
                    stats.getCount(),
                    stats.getAverage());
        });

        // 2. 找出每个年级前 2 名的学生(按平均分)
        System.out.println("\n=== 各年级前2名 ===");
        Map<GradeLevel, List<Student>> top2ByGrade = students.stream()
                .collect(Collectors.groupingBy(
                        Student::getGrade,
                        Collectors.collectingAndThen(
                                Collectors.toList(),
                                list -> list.stream()
                                        .sorted(Comparator.comparing(Student::getAverageScore).reversed())
                                        .limit(2)
                                        .collect(Collectors.toList())
                        )
                ));

        top2ByGrade.forEach((grade, list) -> {
            System.out.println(grade.getChineseName() + ":");
            list.forEach(s -> System.out.println("  " + s.getName() + ":" + s.getAverageScore()));
        });

        // 3. 多级分组:先按年级,再按成绩等级(优秀>=85, 良好>=70, 及格>=60, 不及格<60)
        System.out.println("\n=== 成绩等级分布 ===");
        Map<GradeLevel, Map<String, List<Student>>> byGradeAndLevel = students.stream()
                .collect(Collectors.groupingBy(
                        Student::getGrade,
                        Collectors.groupingBy(s -> {
                            double score = s.getAverageScore();
                            if (score >= 85) return "优秀";
                            else if (score >= 70) return "良好";
                            else if (score >= 60) return "及格";
                            else return "不及格";
                        })
                ));

        byGradeAndLevel.forEach((grade, levelMap) -> {
            System.out.println(grade.getChineseName() + ":");
            levelMap.forEach((level, list) ->
                    System.out.println("  " + level + ":" + list.size() + "人")
            );
        });

        // 4. 并行流处理:计算所有学生的总平均分(大数据量时用)
        System.out.println("\n=== 整体统计(并行) ===");
        double overallAvg = students.parallelStream()
                .mapToDouble(Student::getAverageScore)
                .average()
                .orElse(0);
        System.out.printf("总平均分:%.1f%n", overallAvg);

        // 5. 使用 takeWhile 找出成绩持续下降的学生(假设按学号顺序)
        System.out.println("\n=== takeWhile 示例 ===");
        List<Integer> scores = Arrays.asList(85, 82, 78, 75, 80, 72, 68);
        List<Integer> decreasing = scores.stream()
                .takeWhile(new Predicate<Integer>() {
                    private int prev = Integer.MAX_VALUE;
                    @Override
                    public boolean test(Integer value) {
                        boolean result = value <= prev;
                        prev = value;
                        return result;
                    }
                })
                .collect(Collectors.toList());
        // 注意:上述 Lambda 用了有状态的条件,在实际中要谨慎
        System.out.println("持续下降的部分:" + decreasing);  // 直到遇到 80 之前都是下降的
    }

    private static List<Student> createSampleData() {
        // 创建老师
        Teacher t1 = new Teacher("张教授", 45, Gender.MALE, "T001", Title.PROFESSOR);
        Teacher t2 = new Teacher("李副教授", 38, Gender.FEMALE, "T002", Title.ASSOCIATE_PROFESSOR);
        Teacher t3 = new Teacher("王讲师", 32, Gender.MALE, "T003", Title.LECTURER);

        // 创建课程
        Course c1 = new Course("C001", "Java程序设计", 4.0, t1);
        Course c2 = new Course("C002", "数据库原理", 3.0, t2);
        Course c3 = new Course("C003", "数据结构", 3.5, t3);
        Course c4 = new Course("C004", "操作系统", 3.0, t1);

        // 创建学生
        Student s1 = new Student("张三", 20, Gender.MALE, "S001", GradeLevel.SOPHOMORE);
        Student s2 = new Student("李四", 22, Gender.FEMALE, "S002", GradeLevel.JUNIOR);
        Student s3 = new Student("王五", 18, Gender.MALE, "S003", GradeLevel.FRESHMAN);
        Student s4 = new Student("赵六", 21, Gender.FEMALE, "S004", GradeLevel.JUNIOR);
        Student s5 = new Student("孙七", 19, Gender.MALE, "S005", GradeLevel.SOPHOMORE);
        Student s6 = new Student("周八", 20, Gender.MALE, "S006", GradeLevel.FRESHMAN);
        Student s7 = new Student("吴九", 23, Gender.FEMALE, "S007", GradeLevel.JUNIOR);

        // 选课并录入成绩
        try {
            // 张三(大二):Java 88, 数据库 92
            s1.enroll(c1);
            s1.enroll(c2);
            c1.assignGrade(s1, 88.0);
            c2.assignGrade(s1, 92.0);

            // 李四(大三):Java 95, 数据结构 87, 操作系统 90
            s2.enroll(c1);
            s2.enroll(c3);
            s2.enroll(c4);
            c1.assignGrade(s2, 95.0);
            c3.assignGrade(s2, 87.0);
            c4.assignGrade(s2, 90.0);

            // 王五(大一):Java 72, 数据库 68
            s3.enroll(c1);
            s3.enroll(c2);
            c1.assignGrade(s3, 72.0);
            c2.assignGrade(s3, 68.0);

            // 赵六(大三):数据库 85, 数据结构 79, 操作系统 82
            s4.enroll(c2);
            s4.enroll(c3);
            s4.enroll(c4);
            c2.assignGrade(s4, 85.0);
            c3.assignGrade(s4, 79.0);
            c4.assignGrade(s4, 82.0);

            // 孙七(大二):Java 91, 数据库 88
            s5.enroll(c1);
            s5.enroll(c2);
            c1.assignGrade(s5, 91.0);
            c2.assignGrade(s5, 88.0);

            // 周八(大一):Java 65, 数据结构 58
            s6.enroll(c1);
            s6.enroll(c3);
            c1.assignGrade(s6, 65.0);
            c3.assignGrade(s6, 58.0);

            // 吴九(大三):Java 78, 数据库 82, 操作系统 80
            s7.enroll(c1);
            s7.enroll(c2);
            s7.enroll(c4);
            c1.assignGrade(s7, 78.0);
            c2.assignGrade(s7, 82.0);
            c4.assignGrade(s7, 80.0);

        } catch (DuplicateEnrollmentException | InvalidScoreException e) {
            throw new RuntimeException("初始化示例数据失败:" + e.getMessage(), e);
        }

        List<Student> students = new ArrayList<>();
        students.add(s1);
        students.add(s2);
        students.add(s3);
        students.add(s4);
        students.add(s5);
        students.add(s6);
        students.add(s7);
        return students;
    }
}

这个示例里包含了:多级分组、下游收集器、collectingAndThen、并行流、以及 takeWhile 的初步使用。它展示了如何用 Stream 构建复杂的业务报表,而代码依然清晰可读。


8. 今天的总结

今天我们深入学习了 Stream API 的高级特性和性能考量:

  • 并行流parallelStream() 利用多核 CPU 加速大数据处理,但要注意线程安全和适用场景。
  • Collectors 高级用法 :多级分组、下游收集器(mappingmaxByaveragingInt)、partitioningBysummarizingIntcollectingAndThen
  • 原始类型流IntStreamLongStreamDoubleStream,避免装箱开销,提供 sum()average() 等便利方法。
  • 调试技巧 :用 peek() 插入观察点,用 IDE 的可视化调试工具。
  • 性能考量:Stream 在可读性上有巨大优势;并行流适合数据量大且操作重的场景;小数据集用顺序流即可。
  • JDK 9+ 的新操作takeWhiledropWhile,基于条件截取元素。

Stream API 是 Java 函数式编程的核心。掌握它的高级用法,你就能用很少的代码完成复杂的数据处理任务,同时保持代码清晰和可维护。


动手试试

  1. 你有一个 List<Order>(包含 customerIdamountstatus),用 Stream 统计每个客户的订单总金额,并按金额从高到低排序,只取前 5 个。

  2. partitioningBy 把一个 List<Integer> 分成奇数和偶数两组,然后分别计算每组的总和。

  3. 你有一个 List<String> 包含多行文本,用并行流统计每行包含的单词数,并计算总单词数(提示:用 mapToIntsum)。

  4. (挑战)实现一个方法,接收一个 List<Student>,返回一个 Map<String, Map<String, Double>>,结构为:年级 → 学生姓名 → 平均分。使用 groupingBy 嵌套和 Collectors.toMap

  5. takeWhile 实现一个功能:从一个已排序的 List<Integer> 中,取出所有小于某个阈值的元素(假设列表是升序的,takeWhile 会在遇到第一个大于等于阈值的元素时停止,效率比 filter 高)。

Stream API 的进阶内容就讲到这里了。下一篇文章,我们将学习 Optional ------ 这个用来优雅地处理 null 的容器,让你彻底告别 NullPointerException 的烦恼。

我们下一篇见。😃

📌 获取本系列示例代码请访问 GitCode

相关推荐
Lhan.zzZ1 小时前
在 Visual Studio 2022 中打造可扩展的动态链接库模块:从零搭建到原理解析
开发语言·c++·visual studio
心平气和量大福大1 小时前
android-实例-蒲公英-更新与安装-5-签名与生成APK
android·java·开发语言
码哥DFS1 小时前
构造函数、实例对象、对象原型 ----三者关系
开发语言·javascript·原型模式
952362 小时前
Spring-cloud配置中心
java·spring·spring cloud·微服务
quantdash_cc2 小时前
告别自建 Requests/BS4 网页爬虫:基于 QuantDash 搭建零维保的高性能量化行情流水线
开发语言·爬虫·python·pandas·量化·quantdash
ydd1001002 小时前
寻找两个正序数组的中位数 Java 题解,二分分割详解
java·开发语言
半亩码田3 小时前
C#转Python第3.1篇:Python 的 class 没有访问修饰符?面向对象的另一条路
开发语言·python·c#
真上帝的左手3 小时前
10. 软件设计&架构-Spring Security 7 整合CAS SSO 单点登录
java·spring·架构·sso
Wzx1980123 小时前
python沙箱和docker沙箱你选对了吗?
开发语言·python·docker