Streaming Basics: Kafka Streams Concepts for Java Devs

On the Friday of our first big sale, the category-revenue dashboard froze at 2:14 PM. Orders kept flowing — checkout was healthy, Kafka was healthy — but the "revenue by category" panel showed the same numbers for forty minutes while marketing refreshed it like a slot machine. The panel was fed by an hourly batch job: a cron query that summed yesterday's order rows and wrote the totals to a table the dashboard read. It was correct, and it was useless. By the time the numbers arrived, the sale decisions they were supposed to inform had already been made.

The fraud team's version was worse. They had a hand-rolled Kafka consumer that kept per-category totals in a HashMap in memory. Every rebalance — every deploy, every pod restart, every new consumer joining the group — the map vanished and the totals restarted from zero. Alerts fired on garbage for ten minutes until the map "warmed up" again, and nobody could say whether a spike was real fraud or just a restart. Two teams, same underlying problem: they were treating a stream of events as either a pile of rows to re-scan every hour, or a pile of state to keep in a process's memory and pray over.

Kafka Streams is the library-shaped answer to both failures. It runs inside your Java process — no separate cluster to operate — and it makes "the current total per category" a first-class concept instead of a HashMap you babysit. This post builds a real one: a topology that reads the checkout service's order-placed stream and maintains live revenue per product category, run for real with TopologyTestDriver so you can see every aggregate update, watch a late event get dropped, and inspect the state store underneath. Along the way: the stream/table duality, stateful vs stateless operations, windowing, exactly-once semantics and their honest boundary, and when you should just use a cron job instead.

KStream vs KTable: one concept, two views

Everything in Kafka Streams starts from one idea: a stream and a table are two views of the same data. A stream is the history of what happened — every event, in order, forever. A table is the current state — the latest value for each key, right now. Turn a stream of "price changed" events into "the current price per product" and you have a table. Turn a table's updates into a changelog and you have a stream. The library's two core abstractions mirror this exactly:

KStreamKTable
Representsan unbounded sequence of eventsthe latest value per key
Each record isan insert — "this happened"an upsert — "this is now true"
Reading it answers"what happened?""what is the current state?"
Built frombuilder.stream("order-placed")builder.table("product-catalog")
Exampleevery OrderPlaced event evercurrent revenue total per category

The duality is not philosophy — it is the mechanism. When you aggregate a KStream, Kafka Streams maintains the running result as a KTable in a state store, and every change to that table is also written to a changelog topic in Kafka. That changelog is what the fraud team's HashMap was missing: durable, replayable state that survives restarts and rebalances. Lose the process, and the new one rebuilds the table by replaying the changelog. Nothing to warm up, nothing to pray over.

Decision rule: reach for a KStream when each event matters on its own (filter the suspicious orders, route the VIP ones). Reach for a KTable — usually produced by aggregating a stream — when the question is about current state ("what is electronics revenue right now?"). If you find yourself keeping a HashMap next to a consumer, you wanted a KTable.

The worked example: revenue per category, live

Our checkout service publishes one OrderPlaced event per order to the order-placed topic, keyed by order ID (the event itself was covered in this track's "Kafka with Java: Producers, Consumers, Delivery Semantics & Idempotency"). The business question is the dashboard's: revenue per product category, updated continuously, in 5-minute windows — the granularity marketing actually watches during a sale. A plain all-time aggregate would grow its state forever and answer a question nobody asked; a 5-minute tumbling window bounds the state and matches the dashboard's refresh. That is the justification for the window: it is not a technical flourish, it is the shape of the question.

Dependencies first. Kafka Streams 4.3.1, with the test utilities at the same version (the group/artifact/version coordinates follow the convention from "Maven vs Gradle: Builds Demystified" — kafka-clients arrives transitively, and rocksdbjni comes along as the state store's storage engine):

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-streams</artifactId>
    <version>4.3.1</version>
</dependency>
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-streams-test-utils</artifactId>
    <version>4.3.1</version>
    <scope>test</scope>
</dependency>

Here is the topology. Read it top to bottom: a stateless re-key, then everything stateful — group, tumble, reduce — with the running totals materialized into a named state store:

package com.javamakeuse.streaming;

import java.time.Duration;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.common.utils.Bytes;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.Topology;
import org.apache.kafka.streams.kstream.Consumed;
import org.apache.kafka.streams.kstream.Grouped;
import org.apache.kafka.streams.kstream.Materialized;
import org.apache.kafka.streams.kstream.Produced;
import org.apache.kafka.streams.kstream.TimeWindows;
import org.apache.kafka.streams.kstream.WindowedSerdes;
import org.apache.kafka.streams.state.WindowStore;

public final class RevenueTopology {

    public static final String INPUT_TOPIC = "order-placed";
    public static final String OUTPUT_TOPIC = "revenue-per-category";
    public static final String STORE_NAME = "revenue-store";
    public static final Duration WINDOW_SIZE = Duration.ofMinutes(5);

    public static Topology build() {
        StreamsBuilder builder = new StreamsBuilder();

        builder.<String, String>stream(INPUT_TOPIC,
                        Consumed.with(Serdes.String(), Serdes.String()))
                // Stateless: re-key by category, keep only the amount.
                // (Event values are "category,amount" here, standing in for
                // the JSON OrderPlaced event; the split is the only parsing.)
                .map((orderId, raw) -> {
                    String[] parts = raw.split(",", 2);
                    return KeyValue.pair(parts[0], Double.parseDouble(parts[1]));
                })
                // Stateful from here on: group + windowed reduce keeps a
                // per-window, per-category total in a state store.
                .groupByKey(Grouped.with(Serdes.String(), Serdes.Double()))
                .windowedBy(TimeWindows.ofSizeWithNoGrace(WINDOW_SIZE))
                .reduce(Double::sum,
                        Materialized.<String, Double, WindowStore<Bytes, byte[]>>as(STORE_NAME)
                                .withKeySerde(Serdes.String())
                                .withValueSerde(Serdes.Double()))
                .toStream()
                .to(OUTPUT_TOPIC, Produced.with(
                        new WindowedSerdes.TimeWindowedSerde<>(
                                Serdes.String(), WINDOW_SIZE.toMillis()),
                        Serdes.Double()));

        return builder.build();
    }
}

Two things to notice before we run it. First, the .map() re-keys the stream — the input key was the order ID, and aggregation needs the category as the key. Re-keying forces Kafka Streams to repartition: it writes the re-keyed records to an internal topic so all records for one category land on the same task. You will see that topic in the topology description below. Second, the reduce's result is a KTable<Windowed<String>, Double> — a table whose keys are (category, window) pairs — and .toStream() turns its changelog back into a stream for the output topic. That round trip, table → changelog → stream, is the duality in action.

Here is the driver that executes the topology — five timestamped orders through TopologyTestDriver, printing each window update as it happens:

package com.javamakeuse.streaming;

import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Properties;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.TestInputTopic;
import org.apache.kafka.streams.TestOutputTopic;
import org.apache.kafka.streams.Topology;
import org.apache.kafka.streams.TopologyTestDriver;
import org.apache.kafka.streams.kstream.Windowed;
import org.apache.kafka.streams.kstream.WindowedSerdes;
import org.apache.kafka.streams.state.WindowStore;
import org.apache.kafka.streams.state.WindowStoreIterator;

/**
 * Runs {@link RevenueTopology} with TopologyTestDriver — no broker needed.
 * Five orders go in: three inside one 5-minute window, one that pushes
 * stream time into the next window, and one deliberately late event aimed
 * at the closed window. Window bounds are formatted as UTC HH:mm so the
 * output reads like a wall clock instead of epoch millis.
 */
public final class RevenueTopologyDemo {

    private RevenueTopologyDemo() { }

    private static final DateTimeFormatter HHMM =
            DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneOffset.UTC);

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(StreamsConfig.APPLICATION_ID_CONFIG, "revenue-demo");
        props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "dummy:9092");

        Topology topology = RevenueTopology.build();

        try (TopologyTestDriver driver = new TopologyTestDriver(topology, props)) {
            TestInputTopic<String, String> input = driver.createInputTopic(
                    RevenueTopology.INPUT_TOPIC,
                    Serdes.String().serializer(), Serdes.String().serializer());
            TestOutputTopic<Windowed<String>, Double> output = driver.createOutputTopic(
                    RevenueTopology.OUTPUT_TOPIC,
                    new WindowedSerdes.TimeWindowedSerde<>(
                            Serdes.String(), RevenueTopology.WINDOW_SIZE.toMillis()).deserializer(),
                    Serdes.Double().deserializer());

            Instant t0 = Instant.parse("2026-10-05T10:00:00Z");

            input.pipeInput("order-1", "electronics,149.50", t0);
            input.pipeInput("order-2", "books,24.50", t0.plusSeconds(90));
            input.pipeInput("order-3", "electronics,59.25", t0.plusSeconds(150));
            drain("after 3 in-window events", output);

            input.pipeInput("order-4", "books,9.50", t0.plus(Duration.ofMinutes(7)));
            drain("after window advanced to [10:05, 10:10)", output);

            input.pipeInput("order-5", "electronics,1000.00", t0.plusSeconds(200));
            drain("after late event (grace = 0, expect nothing)", output);

            WindowStore<String, Double> store = driver.getWindowStore(RevenueTopology.STORE_NAME);
            dumpStore(store, "electronics", t0);
            dumpStore(store, "books", t0);
        }
    }

    private static void drain(String label, TestOutputTopic<Windowed<String>, Double> output) {
        List<KeyValue<Windowed<String>, Double>> records = output.readKeyValuesToList();
        String count = records.size() == 1 ? "1 output record" : records.size() + " output records";
        System.out.println("-- " + label + " (" + count + ") --");
        for (KeyValue<Windowed<String>, Double> kv : records) {
            System.out.printf("  window [%s, %s) category=%s revenue=%.2f%n",
                    HHMM.format(Instant.ofEpochMilli(kv.key.window().start())),
                    HHMM.format(Instant.ofEpochMilli(kv.key.window().end())),
                    kv.key.key(), kv.value);
        }
        if (records.isEmpty()) {
            System.out.println("  (no output records)");
        }
    }

    private static void dumpStore(WindowStore<String, Double> store, String category, Instant t0) {
        System.out.println("-- store contents for category=" + category + " --");
        try (WindowStoreIterator<Double> it =
                     store.fetch(category, t0.minus(Duration.ofMinutes(1)), t0.plus(Duration.ofMinutes(15)))) {
            while (it.hasNext()) {
                KeyValue<Long, Double> e = it.next();
                System.out.printf("  windowStart=%s revenue=%.2f%n", Instant.ofEpochMilli(e.key), e.value);
            }
        }
    }
}

Now the run. TopologyTestDriver executes the whole topology in-process — no broker, no Docker — with explicit event-time timestamps per record, so windows behave exactly as they would in production. Five orders go in: three inside one 5-minute window, one that pushes stream time into the next window, and one deliberately late event aimed at the closed window. (Amounts use double for readability; every value below is exactly representable in binary, and production code would sum long cents instead.) Genuine output — the driver formats window bounds as UTC HH:mm so the output reads like a wall clock:

-- after 3 in-window events (3 output records) --
  window [10:00, 10:05) category=electronics revenue=149.50
  window [10:00, 10:05) category=books revenue=24.50
  window [10:00, 10:05) category=electronics revenue=208.75
-- after window advanced to [10:05, 10:10) (1 output record) --
  window [10:05, 10:10) category=books revenue=9.50
-- after late event (grace = 0, expect nothing) (0 output records) --
  (no output records)
-- store contents for category=electronics --
  windowStart=2026-10-05T10:00:00Z revenue=208.75
-- store contents for category=books --
  windowStart=2026-10-05T10:00:00Z revenue=24.50
  windowStart=2026-10-05T10:05:00Z revenue=9.50

Read that output the way the dashboard would. The third record is the whole point of stateful streaming: electronics went 149.50 → 208.75 as the second electronics order arrived — the aggregate updated in place, emitting a correction, not a second row to reconcile later. Then stream time crossed 10:05 and the books order opened a fresh window. Then the late event — an electronics order timestamped 10:03:20 arriving after stream time had reached 10:07, with zero grace — produced nothing at all, and the driver logged exactly why:

[main] WARN org.apache.kafka.streams.kstream.internals.KStreamWindowAggregate
  - Skipping record for expired window.
  topic=[revenue-demo-revenue-store-repartition] partition=[0] offset=[4]
  timestamp=[1791194600000] window=[1791194400000,1791194700000)
  expiration=[1791194820000] streamTime=[1791194820000]

That is the late-event caveat made concrete: the record was not wrong, it was too late — its window had already been finalized. A batch job would have silently included it in the next hourly run and given you a subtly different answer than the stream did. Neither is "correct" in the abstract; the difference is that the stream tells you, loudly, that it dropped something. Finally, the store dump shows what lives underneath: per-window totals, queryable by key, surviving independently of any single output record.

Decision rule: when an aggregate must reflect "everything so far in this window", emit the full updated total per event (what the reduce does) rather than deltas — downstream consumers stay correct by simply keeping the latest record per key, with no addition logic to get wrong.

Stateful operations: where the totals actually live

Draw a line through the topology above and everything changes character at groupByKey:

Stateless (map, filter, peek)Stateful (groupBy, aggregate, reduce, count, joins)
Looks atone record at a timeall records for a key so far
Needs a storenoyes — the running result has to live somewhere
Survives restartnothing to surviveonly via the changelog topic
Re-keying?map/selectKey change the keygroupByKey triggers a repartition

The full picture, as the running topology actually wires it — note the repartition topic the re-key forced into existence, and the two places the reduce's state lives:

order-placed source topic key = orderId events map — stateless re-key by category, keep the amount re-keyed revenue-store-repartition internal topic — same category, same partition, same task windowed reduce — stateful 5-minute tumbling windows, Double::sum per (category, window) results revenue-per-category sink topic key = (category, window) state store: revenue-store RocksDB on local disk — fast reads, lost on disk failure changelog topic revenue-demo-revenue-store-changelog every store write, durably — this is what survives a restart

The diagram's bottom row is the answer to the fraud team's HashMap. Every update to the local RocksDB store is also appended to the changelog topic (<application.id>-<store-name>-changelog by convention — here revenue-demo-revenue-store-changelog). Restart the app, lose the disk, rebalance to a new instance: the new task replays the changelog and rebuilds exactly the table the old one had. The local store is a cache for speed; the changelog is the truth. That is also why the store dump in the demo worked: driver.getWindowStore("revenue-store") handed us the same store the reduce was writing to, and we read it directly.

The topology description from the run confirms the wiring — two sub-topologies split at the repartition boundary, with the store attached to the reduce processor:

Sub-topology: 0
  Source: KSTREAM-SOURCE-0000000000 (topics: [order-placed])
  Processor: KSTREAM-MAP-0000000001 (stores: [])
  Sink: revenue-store-repartition-sink (topic: revenue-store-repartition)

Sub-topology: 1
  Source: revenue-store-repartition-source (topics: [revenue-store-repartition])
  Processor: KSTREAM-REDUCE-0000000002 (stores: [revenue-store])
  Sink: KSTREAM-SINK-0000000007 (topic: revenue-per-category)

Decision rule: name every state store deliberately (Materialized.as("revenue-store"), not the default) — the name becomes the changelog topic name, the thing you monitor, and the handle you query interactively later. Anonymous stores are undebuggable stores.

Windowing: tumbling, hopping, session — and late events

A window is how you tell a stateful operation "…so far within what bounds?" Without one, the revenue table grows one row per category forever — fine for a catalog, wrong for a dashboard. The three window types answer three different business questions:

WindowShapeAnswersDefinition
Tumblingfixed, non-overlapping: [10:00,10:05), [10:05,10:10)"revenue per 5-minute bucket" — the dashboardTimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5))
Hoppingfixed size, overlapping: 5-minute windows advancing every minute"revenue in the last 5 minutes, updated every minute" — the alertTimeWindows.ofSizeAndGrace(...).advanceBy(Duration.ofMinutes(1))
Sessionvariable length: bursts of activity separated by idle gaps"this customer's buying session total" — the behaviorSessionWindows.ofInactivityGapWithNoGrace(Duration.ofMinutes(10))

The hopping variant is one line away from our topology — same stream, same reduce, a different window definition. Each order then contributes to five overlapping windows, so one input event produces up to five output updates (the price of a smoother alert is multiplied state):

.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1))
        .advanceBy(Duration.ofMinutes(1)))

And sessions group by behavior instead of the clock — a customer's orders merge into one session while they keep buying, and a new session starts after 10 idle minutes:

.windowedBy(SessionWindows.ofInactivityGapWithNoGrace(Duration.ofMinutes(10)))

Now the caveat the demo already showed you. Windows are evaluated on event time — the timestamp on the record — not on when your app processes it. Stream time is the high-water mark: the largest event timestamp seen so far. A record is late when it arrives after stream time has moved past the end of its window plus the grace period. Our topology used zero grace, so the 10:03:20 order arriving at stream time 10:07 was skipped with that WARN, not folded in. Give the window a grace period and late-but-close records update the window (emitting a correction downstream); miss the grace and they are dropped, loudly. Old windows are eventually purged — retention is configurable — so the state stays bounded.

Decision rule: set the grace period from the data's actual lateness, not from optimism. If your producers can legitimately deliver events minutes late (mobile checkouts, retries), a zero-grace window will silently — well, loudly — drop real revenue. Measure the 99th-percentile event-time lag first, then choose.

Stream-table joins: enriching the stream

Not every question is an aggregation. The checkout's OrderPlaced event carries a product ID; the human-readable category lives in a product-catalog topic keyed by product ID, where each key's latest row is the current truth — a natural KTable. Joining the event stream against that table enriches every order as it flows past, with the join result reflecting the catalog as of the event's timestamp:

KStream<String, String> orders =
        builder.stream("order-placed",
                Consumed.with(Serdes.String(), Serdes.String()));

// The catalog topic is keyed by productId; the table always holds
// the latest row per key.
KTable<String, String> catalog = builder.table(
        "product-catalog",
        Materialized.<String, String, KeyValueStore<Bytes, byte[]>>as("catalog-store")
                .withKeySerde(Serdes.String())
                .withValueSerde(Serdes.String()));

KStream<String, String> enriched =
        orders.join(catalog, (order, category) -> order + " => " + category);
enriched.to("order-enriched");

This is a stream-table join: each order looks up the current catalog row for its product and emits one enriched record. No windowing needed — the table side is "now", the stream side is "this event". (Stream-stream joins exist too, for correlating two event streams inside a time window — order vs payment within 10 minutes — but the stream-table shape covers most enrichment, and it is the one to learn first.)

Decision rule: if the lookup data changes slowly and fits the "latest row per key" shape — catalogs, price lists, customer tiers — model it as a KTable and join. If you catch yourself calling a database from inside a map(), you wanted a stream-table join: the lookup becomes local, replayable state instead of a per-record network call.

Exactly-once: what processing.guarantee actually guarantees

Our demo ran with the default guarantee, at_least_once: if the app had crashed mid-processing, some orders could have been counted twice after recovery. For revenue, double-counting is the kind of wrong that ends up in a board meeting, so production topologies usually set:

props.put(StreamsConfig.APPLICATION_ID_CONFIG, "revenue-app");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-prod-1:9092,kafka-prod-2:9092");
props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, StreamsConfig.EXACTLY_ONCE_V2);

With exactly_once_v2, each task's work becomes atomic the way a database transaction is atomic: consuming the input records, updating the state stores (via their changelog topics), writing the output records, and committing the consumer offsets all happen inside one Kafka transaction. Either the whole unit appears downstream, or none of it does — no half-written windows, no double-counted orders after a crash. Conceptually it is the same promise as Spring's @Transactional around the checkout service (see "Transactions: @Transactional, Isolation & Propagation"): one atomic unit of work. The mechanism differs — the database uses locks and a write-ahead log behind a proxy; Kafka Streams uses transactional producers and read_committed consumers on the broker — but the shape of the guarantee, all-or-nothing, is the same.

Here is the honest boundary, and it matters more than the feature: exactly-once covers Kafka-to-Kafka, and nothing else. The transaction spans the input topic, the state stores' changelog topics, and the output topic. The moment your topology has a side effect outside Kafka — writing the totals to Postgres, sending the fraud alert email, calling the pricing API — that side effect is outside the transaction. Crash between the Kafka commit and the database write and you have applied the effect without the offset, or the offset without the effect. There are exactly two cures, and both live outside the Streams config: make the side effect idempotent (safe to apply twice — dedupe on the order ID, upsert instead of insert), or route it through the transactional outbox pattern from this track's "Transactional Outbox: DB + Kafka Without Losing Messages", so the database write and the event share one real transaction. Anyone selling you "end-to-end exactly-once" across systems is selling you the broker's guarantee with the boundary quietly cropped out.

Decision rule: turn on exactly_once_v2 for any topology whose output drives money, inventory, or alerts — the transactional overhead is real but small next to a double-counted revenue figure. Then audit every side effect in the topology against the Kafka-to-Kafka boundary, and make each one idempotent or outboxed. The config is one line; the audit is the actual work.

When streaming is overkill

Kafka Streams is the right tool when the answer must be fresh. It is the wrong tool when the answer must merely be right eventually — and most dashboards are the second kind. Before reaching for a topology, run this checklist:

SignalVerdict
Nobody acts on the answer within minutesBatch. A scheduled job and a SQL query are simpler, cheaper, and trivially re-runnable.
The question is "what happened yesterday, precisely?"Batch. Streams answer "what is happening now"; reconciling history is a scan's job.
You can re-run the whole answer from a query in secondsBatch. You don't need a stream processor — you need a cron job.
Events arrive in bursts with long idle gaps, and latency doesn't matterBatch. An always-on topology idling 23 hours a day is burning money for nothing.
Someone watches it live during a sale, or fraud must fire in minutesStream. This is the dashboard and the alert from this post.

Streaming's costs are concrete: always-on compute per task, state stores to monitor and size, debugging across event time vs processing time, schema discipline on every topic (a renamed field in OrderPlaced breaks the topology, not just a query), and operational concepts — rebalances, changelog replay, exactly-once transactions — that a cron job never asks you to learn. The pragmatic path most teams take: start batch, move to streaming when latency becomes the bottleneck. The order-placed topic from this track's "Kafka with Java: Producers, Consumers, Delivery Semantics & Idempotency" serves both — the batch job scans it hourly, the topology reads it live, and the migration is a new consumer group, not a rewrite.

Decision rule: if you cannot name the decision someone makes within minutes of the number changing, you do not need streaming yet. Build the batch version, ship it, and let the latency complaint — with a dollar figure attached — be the thing that justifies the topology.

What's next

This was the last stop on the Data & Messaging track: you can now move events through Kafka, keep them from getting lost between the database and the broker, cache what is hot, and maintain live aggregates over event streams with Kafka Streams. The next track is Concurrency & JVM — the machinery underneath everything you have built so far. It starts where the checkout service's thread pool ends: virtual threads and structured concurrency, what the JVM actually does with your threads, and how to reason about throughput when a thousand checkouts land at once. The streaming topology in this post runs on threads too — after the next track, you will know exactly which kind, and why it matters.

Field check before you move on: take the demo topology and change one line — replace TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)) with TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(5)) — then re-run the driver with the same five events. The order-5 event (timestamped 10:03:20, arriving at stream time 10:07) that was skipped before now lands inside the grace period: watch it update the [10:00, 10:05) electronics window to 1208.75 instead of being dropped. Then shrink the grace to one minute and watch it get skipped again. That pair — rescued, then dropped — is the grace period, executable.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number