Stream是怎么运行的?

先看这段样例代码:

java 复制代码
        var al = new ArrayList<String>();
        al.add("Hello");
        al.add("world");
        al.stream().map(String::toUpperCase).forEach(System.out::println);

1 创建流

样例代码创建一个 ArrayList​ 实例,并将其转化为流进行数据处理。stream()​ 方法源自 Collection 接口。自 Java 8 起,该接口为适配 Stream 新增了几个转换方法。

代码1-1:

java 复制代码
    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }
	// 这是继承自 Iterable 接口的方法
    @Override
    default Spliterator<E> spliterator() {
        return Spliterators.spliterator(this, 0);
    }

为保持向后兼容,这些新增方法均提供了默认实现。java.util.stream.StreamSupport​ 是操作流的工具类,该类在 Stream 内部代码中被频繁使用,但通常的业务代码无需直接调用。至于其中调用的 spliterator() 方法,则引入了一个新概念。

1.1 Spliterator

"拆分器"(本文使用译名)以接口形式出现,java.util.Spliterator。官方文档对此接口说明为:

用于遍历和分割源元素的对象。Spliterator 所覆盖元素的源可以是数组、集合、IO 通道或生成函数。

拆分器可逐个遍历元素(tryAdvance()​ ),也可批量顺序遍历(forEachRemaining())。

主要方法有,代码1-2:

java 复制代码
public interface Spliterator<T> {
	// 消费拆分器中一个元素
    boolean tryAdvance(Consumer<? super T> action);

	// 消费拆分器中剩余所有元素
    default void forEachRemaining(Consumer<? super T> action) {
        do { } while (tryAdvance(action));
    }
	
	// 分出自身部分元素到一个新实例
    Spliterator<T> trySplit();

	// 当前拆分器中预估元素数量
    long estimateSize();

    default long getExactSizeIfKnown() {
        return (characteristics() & SIZED) == 0 ? -1L : estimateSize();
    }

	// 当前拆分器的 特质
    int characteristics();
}

特别地,拆分器的特质是通过一组二进制位(如 ORDERED​、DISTINCT​、SORTED 等)来表示当前数据集的一些特性,这部分计算较为复杂,本文不作深入探讨。

Iterable​ 接口自 Java 8 起新增了 spliterator() 方法,实现了从"迭代器"到"拆分器"的转换。其默认实现如代码1-3所示:

java 复制代码
    default Spliterator<T> spliterator() {
        return Spliterators.spliteratorUnknownSize(iterator(), 0);
    }

Spliterators​ 是一个用于操作或创建拆分器的工具类,通常业务代码中不会直接使用。尽管接口提供了默认实现,但 JDK 中每个具体的集合类型都会根据自身特点重写此方法。这里以最熟悉的 ArrayList 为例,观察其拆分器的工作方式,如代码1-4所示:

java 复制代码
    public Spliterator<E> spliterator() {
        return new ArrayListSpliterator(0, -1, 0);
    }

    final class ArrayListSpliterator implements Spliterator<E> {
        private int index; // current index, modified on advance/split
        private int fence; // -1 until used; then one past last index
        private int expectedModCount; // initialized when fence set

        ArrayListSpliterator(int origin, int fence, int expectedModCount) {
            this.index = origin;
            this.fence = fence;
            this.expectedModCount = expectedModCount;
        }
        private int getFence() { // initialize fence to size on first use
            int hi; // (a specialized variant appears in method forEach)
            if ((hi = fence) < 0) {
                expectedModCount = modCount;
                hi = fence = size;
            }
            return hi;
        }
        public ArrayListSpliterator trySplit() {
            int hi = getFence(), lo = index, mid = (lo + hi) >>> 1;
            return (lo >= mid) ? null : // divide range in half unless too small
                new ArrayListSpliterator(lo, index = mid, expectedModCount);
        }
        public boolean tryAdvance(Consumer<? super E> action) {
            if (action == null)
                throw new NullPointerException();
            int hi = getFence(), i = index;
            if (i < hi) {
                index = i + 1;
                @SuppressWarnings("unchecked") E e = (E)elementData[i];
                action.accept(e);
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
                return true;
            }
            return false;
        }
	}

ArrayListSpliterator​ 类的实现基于二分查找的思想。index​ 可理解为起始位置索引,fence​ 可理解为结束位置索引,expectedModCount​ 则记录了 ArrayList​ 中用于快速失败检测的 modCount​ 变量的值。getFence​ 方法的作用是初始化 fence​ 和 expectedModCount

trySplit 方法对指定范围内的元素进行二分。具体过程可通过以下代码演示:

java 复制代码
        var al = new ArrayList<Integer>(List.of(1, 2, 3, 4, 5));
        var s0 = al.spliterator();  // 3 4 5 
        var s1 = s0.trySplit(); // 2
        var s2 = s1.trySplit(); // 1

s0​ 最初从 al​ 中获取全部元素,调用一次 trySplit​ 方法后,将元素 1, 2​ 分配给了变量 s1​。变量 s1​ 再次调用 trySplit​ 方法,将元素 1​ 分配给了变量 s2​。对于单元大小的拆分器(如 s1​、s2​),再次调用 trySplit​ 方法将返回 null

ArrayList​ 的 forEachRemaining 方法基于数组索引实现,其效果与接口的默认实现一致,此处不再赘述。

!IMPORTANT\] ❗ 注意 拆分器只能遍历一次,即读指针只能向前走,没重置读指针位置的方法。

后续看到Spliterator​相关的代码,基本可以简单理解为"数据源",例如在样例代码中它就代表ArrayList实例。

2 操作流水线

在开头的样例代码中,我们使用 map(String::toUpperCase)​ 来操作数据。String::toUpperCase​ 是 Java 中的方法引用,作用是将字符串转换为大写形式。而 map 方法则是 Stream 接收数据处理逻辑的入口。接下来,我们将探究数据的"操作"是如何被处理的。

至于为什么选择这四个类型,官方的解释是:Java 中的其他类型都可以转化为这四个类型,从而在效率与代码优雅之间取得平衡。换句话说,如果一个接口需要为所有类型都编写独立的实现,那得写9份看起来有点🤪。

对于样例代码来说,java.util.stream.Stream#map​ 方法的实现可以在ReferencePipeline类中找到。

2.1 ReferencePipeline 特化类

ReferencePipeline​ 类对应引用类型的特化实现,通常业务代码中也是这个类运行得最多。其 map 方法的实现如代码2-1所示:

java 复制代码
    public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
        Objects.requireNonNull(mapper);
        return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                     StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
            @Override
            Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
                return new Sink.ChainedReference<P_OUT, R>(sink) {
                    @Override
                    public void accept(P_OUT u) {
                        downstream.accept(mapper.apply(u));
                    }
                };
            }
        };
    }

可以看到,我们传入的"操作"逻辑被包装进一个 StatelessOp 类实例并返回。

2.1.1 StatelessOp类

StatelessOp​ 类,顾名思义,代表无状态操作,它是 ReferencePipeline​ 的一个内部类。很容易联想到,应该还有表示有状态操作的类:StatefulOp。有状态计算较为复杂,本文不会涉及,主要关注无状态计算的情况。

!TIP\] 无状态:元素处理互不依赖;有状态:需要知道其他元素的信息,如`sorted`。

StatelessOp 类的继承关系,图2-1:

classDiagram class PipelineHelper { <> } class BaseStream~T, S~ { <> } class AbstractPipeline~E_IN, E_OUT, S~ { <> } class ReferencePipeline~P_IN, P_OUT~ { <> } class StatelessOp~E_IN, E_OUT~ { <> <> } class Stream~T~ { <> } PipelineHelper <|-- AbstractPipeline : 继承 BaseStream <|.. AbstractPipeline : 实现 BaseStream <|.. Stream : 继承 Stream <|.. ReferencePipeline : 实现 AbstractPipeline <|-- ReferencePipeline : 继承 ReferencePipeline <|-- StatelessOp : 继承

这个继承关系很重要,后面需要不时回顾。

StatelessOp 类的声明,代码2-2:

java 复制代码
    abstract static class StatelessOp<E_IN, E_OUT>
            extends ReferencePipeline<E_IN, E_OUT> {
        StatelessOp(AbstractPipeline<?, E_IN, ?> upstream,
                    StreamShape inputShape,
                    int opFlags) {
            super(upstream, opFlags);
            assert upstream.getOutputShape() == inputShape;
        }
        @Override
        final boolean opIsStateful() {
            return false;
        }
    }

StreamShape​ 是一个枚举,列举了前面提到的四种数据类型,用于表示当前数据流中的元素类型。StreamOpFlag​ 也是一个枚举(其注释远比代码丰富),用于表示数据流的各种特质。至于代码2-1中实现的 opWrapSink​ 方法,则定义在其父类 AbstractPipeline 中,如代码2-3所示:

java 复制代码
    abstract Sink<E_IN> opWrapSink(int flags, Sink<E_OUT> sink);

此方法的具体作用我们稍后再谈。接下来,让我们看看 Sink.ChainedReference 类。

2.2 Sink

Sink这个词数据处理相关的程序中拿来作术语(flink中也有),动词形式有"下沉; 坐下; 减弱; 挖掘; 使受挫; 击球入洞; 灌"等意思;名词形式常为"洗涤池"之意。在数据处理中,这个词代表的概念常是数据处理的最后一步。

java.util.stream.Sink​接口是增强版的Consumer(消费者),负责流中"消费"或"处理"数据,代码2-4:

java 复制代码
interface Sink<T> extends Consumer<T> {
	// 在开始处理数据元素之前被调用。用于进行一些初始化工作(例如,提前分配一个合适大小的数组)
	// -1 表示无法预估大小
    default void begin(long size) {}
	// 在所有数据元素都被处理完毕之后被调用。用于执行最终的清理或计算工作。
    default void end() {}
	// 这是一个短路判断机制。在处理每个元素之前,流框架会询问这个 Sink:"是否需要取消后续处理?"
    default boolean cancellationRequested() {
        return false;
    }
	// ...
}

Java Stream中所有对数据的操作几乎都与此接口相关。它内部也有对四个类型实现的内部类,上文用到的Sink.ChainedReference类就是其中之一。

Sink.ChainedReference类部分代码如下,代码2-5:

java 复制代码
interface Sink<T> extends Consumer<T> {
	// ... 
	
    abstract static class ChainedReference<T, E_OUT> implements Sink<T> {
        protected final Sink<? super E_OUT> downstream;

        public ChainedReference(Sink<? super E_OUT> downstream) {
            this.downstream = Objects.requireNonNull(downstream);
        }

        @Override
        public void begin(long size) {
            downstream.begin(size);
        }

        @Override
        public void end() {
            downstream.end();
        }

        @Override
        public boolean cancellationRequested() {
            return downstream.cancellationRequested();
        }
    }
}

结合代码2-1来看,我们传入的操作逻辑(即 String::toUpperCase​ 方法)被放置在了 Consumer#accept 方法的实现内部,将在后续的某个时刻被触发调用。

2.3 流水线的构成

结合代码2-1和代码2-2,当前(ReferencePipeline​类)实例由构方法传入到父构造器中,通过层层调用最后回到AbstractPipeline类(回顾图2-1)。涉及此类的两个构造器方法,代码2-5:

java 复制代码
    // Constructor for the head of a stream pipeline.
	AbstractPipeline(Spliterator<?> source,
                     int sourceFlags, boolean parallel) {
        this.previousStage = null;
        this.sourceSpliterator = source;
        this.sourceStage = this;
        this.sourceOrOpFlags = sourceFlags & StreamOpFlag.STREAM_MASK;
        // The following is an optimization of:
        // StreamOpFlag.combineOpFlags(sourceOrOpFlags, StreamOpFlag.INITIAL_OPS_VALUE);
        this.combinedFlags = (~(sourceOrOpFlags << 1)) & StreamOpFlag.INITIAL_OPS_VALUE;
        this.depth = 0;
        this.parallel = parallel;
    }
	// Constructor for appending an intermediate operation stage onto an existing pipeline.
    AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
        if (previousStage.linkedOrConsumed)
            throw new IllegalStateException(MSG_STREAM_LINKED);
        previousStage.linkedOrConsumed = true;
        previousStage.nextStage = this;

        this.previousStage = previousStage;
        this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
        this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
        this.sourceStage = previousStage.sourceStage;
        if (opIsStateful())
            sourceStage.sourceAnyStateful = true;
        this.depth = previousStage.depth + 1;
    }

忽略其他操作,可以看出这是双向链表的构建方法。

  • depth:链表的"深度",或者为当前节的序号,从0开始,依次加1。
  • sourceStage:头节点的指针。
  • nextStage:下一个节点的指针。
  • previousStage:上一个节点的指针。

从代码1-1中可以知道,流是调用StreamSupport.stream(spliterator(), false)方法创建的。它的具体实现是这样的,代码2-6:

java 复制代码
    public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
        Objects.requireNonNull(spliterator);
        return new ReferencePipeline.Head<>(spliterator,
                                            StreamOpFlag.fromCharacteristics(spliterator),
                                            parallel);
    }

这个ReferencePipeline.Head类构造器最后调用的就是代码2-5中头节点构造器。

对于样例代码中Stream#map​方法的实现(代码2-1),return new StatelessOp<P_OUT, R>(this, ...)​ 这行代码中的this​是ReferencePipeline.Head​实例。但这行StatelessOp​的构造方法最终会调用到它的父类AbstractPipeline​构造方法(代码2-5),当运行至previousStage.nextStage = this;​行时,此时的this​则是当前StatelessOp​类实例。后续节点调用此方法时,这两个this将会依次后移,链表就这样构造出来了。形成类似于下图的逻辑结构,图2-2:

flowchart LR A[Null] B[ReferencePipeline头节点] C[ReferencePipeline节点B] D[ReferencePipeline节点C] E[Null] B --> |nextStage| C C --> |nextStage| D D --> |nextStage| E C -.-> |sourceStage| B D -.-> |sourceStage| B C -.-> |previousStage| B D -.-> |previousStage| C B -.-> |previousStage| A

在看源码时需注意代码中this指向的节点。

3 流的计算

前面分析了数据和操作流水线的构建,接下来就是流最终是如何进行运算的。

3.1 流的执行

在样例代码中,最后调用一个forEach​的终止操作来结束流的定义。终止操作是流计算和获取结果的方法,它们都定义在java.util.stream.Stream​接口中,实现在ReferencePipeline类中。部分终止方法实现,代码3-1:

java 复制代码
    @Override
    public void forEach(Consumer<? super P_OUT> action) {
        evaluate(ForEachOps.makeRef(action, false));
    }
    @Override
    public final boolean allMatch(Predicate<? super P_OUT> predicate) {
        return evaluate(MatchOps.makeRef(predicate, MatchOps.MatchKind.ALL));
    }
    @Override
    public final Optional<P_OUT> findAny() {
        return evaluate(FindOps.makeRef(false));
    }

这就是流的终止操作的特殊性,每个终止操作都由一个JDK内部XxxOps类包装后使用。

evaluate​方法是执行流计算并获取结果的方法,为 ReferencePipeline​ 的父类 AbstractPipeline​ 中的final方法,代码3-2:

java 复制代码
    final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
        assert getOutputShape() == terminalOp.inputShape();
        if (linkedOrConsumed)
            throw new IllegalStateException(MSG_STREAM_LINKED);
        linkedOrConsumed = true;

        return isParallel()
               ? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
               : terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
    }

此方法的主要作用是分派计算方法:对于串行计算调用 evaluateSequential​ 方法,对于并发计算则调用 evaluateParallel​ 方法。具体的计算方案由 terminalOp 这个终止操作类来实现。并发计算涉及的内容较为复杂,本文不会涉及。

注意代码中this​的指向。就样例代码而言,当前terminalOp.evaluateSequential​ 方法中的this​指向Stream#map​方法构建出的ReferencePipeline实例。

sourceSpliterator方法可简单理解为"取出数据"操作,不过会有一些校验等,此处不展开。

此时terminalOp​变量为java.util.stream.ForEachOps实例,它的部分代码如下,代码3-3:

java 复制代码
final class ForEachOps {

    private ForEachOps() { }
    public static <T> TerminalOp<T, Void> makeRef(Consumer<? super T> action,
                                                  boolean ordered) {
        Objects.requireNonNull(action);
        return new ForEachOp.OfRef<>(action, ordered);
    }
	// ...
	    abstract static class ForEachOp<T>
            implements TerminalOp<T, Void>, TerminalSink<T, Void> {
			// ... 
	        static final class OfRef<T> extends ForEachOp<T> {
	            final Consumer<? super T> consumer;
	
	            OfRef(Consumer<? super T> consumer, boolean ordered) {
	                super(ordered);
	                this.consumer = consumer;
	            }
	
	            @Override
	            public void accept(T t) {
	                consumer.accept(t);
	            }
	        }
	        @Override
	        public <S> Void evaluateSequential(PipelineHelper<T> helper,
	                                           Spliterator<S> spliterator) {
	            return helper.wrapAndCopyInto(this, spliterator).get();
	        }
	        @Override
	        public Void get() {
	            return null;
	        }
		// ...
		}
	// ...
}

可以看到它的evaluateSequential​方法是执行计算并获取结果的地方,但forEach​的终止操作不产出数据所以get()​方法直接返空。下面有返回值的终止操作例子(不知道为什么Java Stream模块内部没怎么用Optional类):

java 复制代码
final class FindOps {
		// ...
        static final class OfInt extends FindSink<Integer, OptionalInt>
                implements Sink.OfInt {
			// ...
            public OptionalInt get() {
                return hasValue ? OptionalInt.of(value) : null;
            }
}

PipelineHelper​类看名字像个工具类,但它是ReferencePipeline​类的父类,回看图2-1,此处helper​参数应当视为AbstractPipeline类实例或者理解为"流水线"节点。

结合代码3-2,样例代码会运行到helper.wrapAndCopyInto(this, spliterator).get()​行。此时this​指向ForEachOps​实例,helper​参数更确切点说为Stream#map​方法构建出的ReferencePipeline​实例。wrapAndCopyInto​方法定义在PipelineHelper​中,实现在AbstractPipeline类里,如下,代码3-4:

java 复制代码
    @Override
    final <P_IN, S extends Sink<E_OUT>> S wrapAndCopyInto(S sink, Spliterator<P_IN> spliterator) {
        copyInto(wrapSink(Objects.requireNonNull(sink)), spliterator);
        return sink;
    }

    @Override
    final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
        Objects.requireNonNull(wrappedSink);

        if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
            wrappedSink.begin(spliterator.getExactSizeIfKnown());
            spliterator.forEachRemaining(wrappedSink);
            wrappedSink.end();
        }
        else {
            copyIntoWithCancel(wrappedSink, spliterator);
        }
    }

wrapAndCopyInto 方法名虽未提及计算,但对于串行流而言,计算基本上就是在此处执行的,终止操作主要负责获取最终结果。

在当前样例代码的上下文中,由于没有使用 filter 等可能引发短路计算的中间操作,因此会执行非短路分支内的代码。

java 复制代码
            wrappedSink.begin(spliterator.getExactSizeIfKnown());
            spliterator.forEachRemaining(wrappedSink);
            wrappedSink.end();

在执行计算之前,还需要调用 wrapSink(Sink<E_OUT> sink) 方法。在了解此方法之前,我们先补充两个相关的接口定义,如代码3-5所示:

java 复制代码
interface TerminalOp<E_IN, R> {
	// ...
}
interface TerminalSink<T, R> extends Sink<T>, Supplier<R> { }

第一个接口定义了终止操作的公共行为(主要是evaluateParallel​和evaluateSequential​方法),第二接口则是拓展了Sink​接口。结合代码3-3来看,ForEachOp.OfRef​是针对引用类数据的实现,也间接实现了Sink​接口。此时wrapSink(Sink<E_OUT> sink)​方法的入参为ForEachOp.OfRef实例。

接下来再看java.util.stream.AbstractPipeline#wrapSink方法,代码3-6:

java 复制代码
    @Override
    @SuppressWarnings("unchecked")
    final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
        Objects.requireNonNull(sink);

        for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
            sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
        }
        return (Sink<P_IN>) sink;
    }

这里要注意代码中this​的指向,它是指向上一级AbstractPipeline​节点。对于样例代码,此处为Stream#map​方法构建出的AbstractPipeline​节点。而sink​参数则传入的ForEachOp.OfRef实例。

以样例代码来讲:

java 复制代码
ForEachOp.OfRef实例 传入 -> wrapSink(Sink<E_OUT> sink) 方法
AbstractPipeline.this 拿出 Stream#map 方法构建出的AbstractPipeline实例

p.opWrapSink(p.previousStage.combinedFlags, sink)的调用展开为:

            @Override
            Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {  // ForEachOp.OfRef实例 -> sink
                return new Sink.ChainedReference<P_OUT, R>(sink) {
                    @Override
                    public void accept(P_OUT u) {
                        downstream.accept(mapper.apply(u));
                    }
                };
            }
        
返回Sink.ChainedReference实例

p指针的已移动到头节点,depth 为 0 ,退出循环

结合代码2-5中的

java 复制代码
        public ChainedReference(Sink<? super E_OUT> downstream) {
            this.downstream = Objects.requireNonNull(downstream);
        }

各个Sink​类中的downstream​变量确实指向下一级的Sink实例。

综上,流计算的前期步骤已经完成。接下来就看看数据是如何计算的。

3.2 数据的运算

之前分析说过,串行流在wrapAndCopyInto方法就执行了计算。对于我们的样例代码来说计算就发生这三行代码中:

java 复制代码
            wrappedSink.begin(spliterator.getExactSizeIfKnown());
            spliterator.forEachRemaining(wrappedSink);
            wrappedSink.end();

​​wrappedSink​变量代表着流第一个操作,在本例中为map(String::toUpperCase)

​​spliterator​代表数据源,本例中它为ArrayList

​​wrappedSink.begin​流运算开始前的准备工作。大部分操作并不需要做什么准备,通常使用默认实现downstream.begin(size);​通知下游操作;对于filter​之类的操作,常常实现为downstream.begin(-1);表示未知大小。

spliterator.forEachRemaining​语义为把数据源中的所有元素逐一遍历,ArrayList​实现为使用索引遍历具体此处略。这里需要注意数据流的形成。先看forEachRemaining方法类似的实现:

java 复制代码
public void forEachRemaining(Consumer<? super E> action) {  // 传入的是map(String::toUpperCase)方法构成的Sink实例
    for (i = lo; i < hi; ++i) { 
        E e = (E) a[i];
        action.accept(e);
    }
}

结合代码2-1中的片段

java 复制代码
public void accept(P_OUT u) {
    downstream.accept(mapper.apply(u)); // mapper为map(String::toUpperCase)方法构成的Sink实例
}

downstream​变量结合之前所讲它为ForEachOps实例,即为打印元素的语句。

这里也表明上一级操作结果就是下一级操作的输入,数据流动起来了。

wrappedSink.end方法通常也只是通知下游操作。

4 总结

本文从一个简单的例子出发,展示了Java Stream运行的基本而完整流程,其中也涉及到重要概念与类设计。

限于篇幅与能力,本文略过有状态计算、并行计算等复杂部分;如有错漏,欢迎指出。

相关推荐
C雨后彩虹2 小时前
幼儿园分班
java·数据结构·算法·华为·面试
黄俊懿2 小时前
【深入理解SpringCloud微服务】Gateway源码解析
java·后端·spring·spring cloud·微服务·gateway·架构师
悟能不能悟2 小时前
java list.addAll介绍
java·windows·list
Alsn862 小时前
30.登录用户名密码 RSA 加密传输-后端为java
java·开发语言
益达3212 小时前
IDEA 整合 Git 版本控制:提交、分支管理与冲突解决实操
java·intellij-idea
MoonBit月兔2 小时前
海外开发者实践分享:用 MoonBit 开发 SQLC 插件(其三)
java·开发语言·数据库·redis·rust·编程·moonbit
天呐草莓2 小时前
企业微信运维手册
java·运维·网络·python·微信小程序·企业微信·微信开放平台
小兔崽子去哪了2 小时前
Java 登录专题
java·spring boot·后端
毕设源码-邱学长2 小时前
【开题答辩全过程】以 高校跨校选课系统为例,包含答辩的问题和答案
java·eclipse