Java 8 and Functional Programming interview questions & answers
239+ real Java 8 and Functional Programming interview questions with model answers, plus free lessons to learn the concepts. Prepare in English & Hinglish, then practise with an AI mock interview.
19 topics · 239+ questions
How Hirenix teaches
One chapter. 90 minutes.
Interview-ready.
Every concept starts with a real-world problem — the kind that actually shows up in production code. Nothing to cram; it just clicks. Every question comes with a model answer: exactly what to say in the room, and why. Then an AI mock interview on the same chapter.
- 📖Concept in 5 minutesNo jargon — straight to the point
- 🛠️Real-world problemThe kind production code throws at you
- 💬Model answerExactly what to say in the room
- 🧠FlashcardsRevise in 10 minutes
- 🤖AI mock interviewIt asks follow-ups too
- 📊Weak topicsSee exactly where you're stuck

The difference isn’t the content — it’s the filter. Only what’s actually used in production and actually asked in interviews. Textbook topics the industry never touches don’t make the cut.
What you’ll learn
- ●Why Java went functional
- ●Lambda expressions
- Functional interfaces and @FunctionalInterfaceFree account
- Predicate, Function, Consumer, SupplierFree account
- Method and constructor referencesFree account
- Default and static methods in interfacesFree account
- What a Stream actually isFree account
- Intermediate operationsFree account
- map vs flatMap, and primitive streamsFree account
- Terminal operations and short-circuitingFree account
- Collectors: grouping, partitioning, joiningFree account
- Optional and the end of null checksFree account
- Parallel streams: when they help and when they hurtFree account
- Exceptions inside lambdas and streamsFree account
- The new Date and Time APIFree account
- RecapFree account
- ●Project: Student Report Card
- Project: Validation Rules EngineFree account
- Project: Imperative to FunctionalFree account
Why Java went functional
Imagine you have booked a cab from Andheri to Bandra-Kurla Complex. There are two completely different ways to get there, and the difference between them is the entire idea behind Java 8.
Way one - you drive the cab. "Straight for 400 metres. Now left. Now second right. Stop at the signal. Now go." You are holding the steering, you are watching the odometer, you decide when to stop. If there is a jam you also have to decide what to do about it. And if you make one mistake - one wrong turn, one missed signal - the whole trip is wrong.
Way two - you tell the driver the address. "BKC, please." That is it. What you want, not how to get it. Now the driver owns the route. He can take the flyover, he can reroute around a jam - none of that is your problem, and none of it changes what you asked for.
In production, way one is a for loop and way two is list.forEach(...). Java before version 8 could only do way one. Java 8, released in 2014, gave the language a way to say what instead of how - and that single change is why every Java codebase written after 2014 looks different from every one written before it.
💡 External iteration = your code drives the loop. You write
for, you hold the index or the iterator, you decide when to stop. The collection only answers questions. 💡 internal iteration = you hand a piece of behaviour to the library and the library drives the loop. You never see an index. This is whatforEach,removeIf,sortand the wholeStream APIdo. 💡 First-class function = code you can store in a variable, pass into a method, and return from a method - exactly like anintor aString. 💡 Higher-order function = a method that takes behaviour as an argument, or hands behaviour back.list.sort(comparator)is one: its argument is not data, it is a decision.
Why Java changed at all
Java sat still on this for about eighteen years, so the reason had better be good. There were three, and they compound.
1. Passing behaviour was humiliatingly verbose. Before Java 8, if you wanted to hand a piece of logic to a method, the only vehicle was an object. So you wrote a whole anonymous class to carry one line of thought:
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
});
System.out.println("A old way = " + names);
names.sort((a, b) -> a.length() - b.length());
System.out.println("B new way = " + names);
A old way = [Diya, Neha, Aarav, Ishaan]
B new way = [Diya, Neha, Aarav, Ishaan]
Same list, same order, same result. Six lines of ceremony around a.length() - b.length() in the first, and none in the second. The idea being expressed was always one line long; Java was making you build a crate around it. (Lambda syntax is topic 2's job - this is only the before-and-after.)
2. The hardware changed and the language did not. By 2014 the cheapest laptop had four cores. But a for loop is a promise that you will visit element 0, then 1, then 2, in that order, on this thread - nobody, not the compiler and not the JVM, is allowed to spread that across cores, because your loop body might depend on the order. As long as you own the loop, only you can parallelise it. The moment the library owns the loop, the library can decide.
3. Collection was frozen. Adding a stream() method to the Collection interface would have broken every class in the world that implements it - overnight, at compile time. So Java 8 had to invent default methods in interfaces before it could ship the Stream API at all. That is why one language release contains what look like two unrelated features: one exists so the other could exist. Topic 6 tells that story.
Code as a value - the actual mental shift
This is the sentence to carry out of this topic: before Java 8 you passed data to methods; after Java 8 you can also pass behaviour.
list.sort(comparator) does not receive a number or a name. It receives a decision about ordering, held in a variable, handed over like any other value. That is what it means to treat functions as first-class citizens, and a method like sort that accepts one is a higher-order function.
Nothing magical happened underneath. Java did not grow a new kind of thing that floats free of classes - a lambda is still an object implementing an interface at runtime, and job.getClass() on a Runnable you wrote as a lambda returns a real class name. What changed is the typing between your idea and the compiler, and - far more importantly - who owns the loop.
Is Java functional now, or still object-oriented?
This is interview question number one on this chapter, and the honest answer is both. Java 8 made Java multi-paradigm: it did not stop being object-oriented, it added a functional paradigm on top.
Say it in these three beats, because half-answers here sound rehearsed:
- Still OO. Everything still lives inside a class. Encapsulation, inheritance and polymorphism did not move an inch. Your lambda is itself an object implementing an interface.
- Now also functional. You can treat behaviour as a value, hold it in a variable, compose small pieces of it, and write declarative code instead of loops.
- But not a functional language. A real functional language enforces immutability and pushes side effects to the edges. Java does neither - a lambda can freely mutate a field, print to the console, or write to a database, and the compiler will not say a word. Java gives you a functional style, not functional guarantees.
If the interviewer pushes - "so is it functional or not?" - the winning line is: Java is an object-oriented language that supports a functional programming paradigm. Not a conversion. An addition.
The three things that make it functional
| The feature | What it gives you |
|---|---|
| Lambda expressions | a way to write behaviour without wrapping it in a class - (a, b) -> a.length() - b.length() |
| Functional interfaces | the type a lambda gets stored in. An interface with exactly one abstract method is the slot a lambda fits into |
Stream API |
the place all of this pays off - a whole library built on internal iteration, where you describe the result and it owns the loop |
Those three are one mechanism seen at three stages: a lambda is the value, a functional interface is its type, and the Stream API is the customer. Topics 2, 3 and 7 take them one at a time. If someone asks "which three features made Java functional", that table is the answer - not a list of every Java 8 change.
The rest of what landed in Java 8
You will be asked to list these, so keep the map in your head even though most of them are later topics:
- Lambda expressions and method references (
String::toUpperCase) - topics 2 and 5 - Functional interfaces, plus a ready-made catalogue of them in
java.util.function- topics 3 and 4 defaultandstaticmethods in interfaces - the backward-compatibility trick above - topic 6- The
Stream API- topics 7 to 13 Optional- a return type that says out loud "there may be no value here" - topic 12- The new date-time API (
java.time) - immutable, thread-safe, and it flatly refuses to accept 30 February - topic 15 - Metaspace replaced PermGen: class metadata moved out of the fixed-size PermGen into native memory that grows on demand, which is why
java.lang.OutOfMemoryError: PermGen spaceno longer exists. Proof from this JDK:java -XX:MaxPermSize=64m -versiondoes not even start, it printsUnrecognized VM option 'MaxPermSize=64m'. That one line is all this deserves here - Ch1memory-heap-stackowns the memory model.
⚠️ One warning about the question lists themselves. Many "Java 8 interview questions" lists still ask about Nashorn / JJS, the JavaScript engine that shipped with Java 8. It was removed in Java 15, so it is not on any modern JDK and answering it earns nothing. If a list is still asking it, that list has not been touched in years - read the rest of its answers with the same suspicion.
Internal vs external iteration - the one idea the rest of this chapter rests on
Everything from topic 7 onwards is a consequence of this single shift, so slow down here.
External iteration - you are driving:
int total = 0;
for (int i = 0; i < batch.size(); i++) {
total = total + batch.get(i).marks;
}
Read what you actually had to write: an index variable, a bound, an increment, a get(i), and an accumulator you mutate. Exactly one of those five things is about adding up marks. The other four are transport - and every one of them is a place to make an off-by-one mistake.
internal iteration - you gave the address:
batch.forEach(s -> box[0] = box[0] + s.marks);
No index. No bound. No get. You said what should happen to each student and ArrayList did the walking. Both printed 323 for the same batch of five students - the runnable file below is where those numbers come from.
External (for) |
Internal (forEach, removeIf, sort, Stream API) |
|
|---|---|---|
| Who runs the loop | your code | the library |
| What you write | how to walk | what to do |
| Order of traversal | fixed by you, always sequential | the library's business |
| Can it be parallelised for you | no | yes - this is what makes parallelStream() even thinkable |
| Early exit | break, trivially |
not with break; the library provides its own short-circuits |
| Debugging | step through it in any IDE | harder - a breakpoint inside a lambda is not a breakpoint on a loop |
Row four is the entire reason the Stream API exists. Row six is why this chapter is never going to tell you loops are dead.
There is also a correctness payoff that shows up immediately. Removing from a list while a for-each is walking it is one of the most common bugs in Java:
for (String s : batch) {
if (s.startsWith("D")) {
batch.remove(s);
}
}
A for-each remove -> java.util.ConcurrentModificationException
B removeIf = [Aarav, Ishaan, Neha]
batch.removeIf(s -> s.startsWith("D")) does the same job and cannot throw that, because ArrayList owns both the walking and the removing and can keep the two consistent. When you own the loop, you own its bugs too.
When to use it: reach for internal iteration - forEach, removeIf, sort, and later the Stream API - when you are describing a transformation of a collection: filter these students, group them by subject, total these marks. The code then reads the way the requirement reads, and the library is free to optimise or parallelise behind your back.
When NOT to use it: keep the plain for loop when you need the index itself, when you must walk two collections in step, when the body throws a checked exception (a lambda cannot let one escape - topic 14), when you need break / continue / a return out of the enclosing method, or when the body's whole purpose is a side effect such as writing rows to a file. And for a three-element list a loop is simply faster, because setting up a pipeline is not free.
Trade-off: you gain readability, composability and the option of parallelism; you give up step-through debugging, cheap early exit, and the ability to throw a checked exception from the body. Topic 19 rewrites a loop-heavy class into this style and deliberately leaves one loop as a loop, with the reason - because the honest version of this chapter is that the new style is better most of the time, not all of the time.
Standard definition: Java 8 introduced functional-programming constructs - lambda expressions, functional interfaces, method references and the Stream API - making Java a multi-paradigm language rather than replacing its object-oriented core. Its central shift is from external iteration, where application code drives the loop, to internal iteration, where the caller supplies behaviour as a first-class value and the library controls the traversal.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Demo {
static class Student {
final String name;
final int marks;
Student(String name, int marks) { this.name = name; this.marks = marks; }
public String toString() { return name + "(" + marks + ")"; }
}
public static void main(String[] args) {
List<Student> batch = new ArrayList<>(List.of(
new Student("Aarav", 82),
new Student("Diya", 47),
new Student("Ishaan", 91),
new Student("Neha", 38),
new Student("Rohan", 65)));
// ---------- EXTERNAL iteration: I drive the loop myself ----------
int total = 0;
for (int i = 0; i < batch.size(); i++) {
total = total + batch.get(i).marks;
}
System.out.println("A external for-loop total = " + total);
// ---------- INTERNAL iteration: I hand the behaviour to the library ----------
int[] box = new int[1];
batch.forEach(s -> box[0] = box[0] + s.marks);
System.out.println("B internal forEach total = " + box[0]);
// ---------- code as value: the SAME list, two different behaviours ----------
List<Student> copy = new ArrayList<>(batch);
copy.sort(Comparator.comparingInt(s -> s.marks));
System.out.println("C sorted by marks = " + copy);
copy.sort(Comparator.comparing(s -> s.name));
System.out.println("D sorted by name = " + copy);
// ---------- the library owns the loop, so it owns the removal too ----------
List<Student> failed = new ArrayList<>(batch);
failed.removeIf(s -> s.marks >= 50);
System.out.println("E below 50 after removeIf = " + failed);
System.out.println("F original list untouched = " + batch);
}
}Lambda expressions
Topic 1 ended on a promise: from Java 8 onwards the library drives the loop and you hand it a piece of behaviour. This topic is about the thing you hand over.
Picture the office of a small tuition centre in Kanpur. The owner needs the attendance register sorted by roll number. Before Java 8, the only way to give an instruction to a helper was to hand over a file — a cover page with the helper's name on it, an index, a signed instruction inside, and a back cover. Four sheets of paperwork wrapped around one sentence of actual instruction: "sort by roll number."
A lambda expression is that one sentence, handed over on a chit, with the paperwork thrown away. Nothing else changed — the helper is still a helper, the instruction is still the same instruction. Only the wrapping is gone.
💡 A lambda expression is a short way to write an implementation of an interface that has exactly one abstract method. It is not a free-floating function, and Java still has no such thing.
The one sentence that has to land first
Almost every learner's first mental model is "a lambda is a function." That model is wrong in Java, and it is wrong in a way that produces real confusion within about ten minutes.
Here is the accurate version: a lambda is an implementation of a one-method interface, written without the ceremony of naming a class. The interface still exists. The object still exists. What disappeared is the boilerplate you used to type around them.
That is why this will never compile:
var greet = name -> "Namaste " + name; // no target type — an implementation of WHAT?
and this will:
interface Greeter { String greet(String name); }
Greeter g = name -> "Namaste " + name;
The interface on the left of the = is what gives the lambda on the right its meaning: its parameter type, its return type, and the name of the method it is implementing. A lambda on its own is a fragment, not a value. (The formal name for that mechanism is target typing, and topic 3 is where it is taught properly.)
The old way, in full
Ch2 finished its anonymous inner class section by pointing at this page, so let us start exactly where it stopped. Sorting five students' names by length, Java 7 style:
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};
old.sort(byLength);
Count what is actually doing work there. One expression: a.length() - b.length(). Everything else is scaffolding — the new, the repeated type name, the braces, the @Override, the method signature, the closing }; whose semicolon everyone forgets. That scaffolding has a name in every code review in the country: boilerplate.
The same behaviour as a lambda
neu.sort((a, b) -> a.length() - b.length());
Both printed the identical result on JDK 17:
A anonymous class = [Diya, Neha, Aarav, Rohan, Ishaan]
B lambda = [Diya, Neha, Aarav, Rohan, Ishaan]
Same answer, same Comparator, same sort method. The compiler is still building an implementation of Comparator.compare — you have only stopped typing the parts it can work out on its own.
The benefits, stated the way an interviewer wants to hear them: less boilerplate, so the intent is visible instead of buried; behaviour becomes a value you can store in a variable and pass to a method; and — the one people forget — it is what made the Stream API possible at all, because an API built on internal iteration is unusable if every behaviour costs six lines.
Anatomy: what sits on each side of ->
(parameters) -> body
The -> (the arrow token) is the whole syntax. Left of it: the parameters of the interface's single method. Right of it: what that method does.
- If the body is one expression, its value is the return value — no
return, no braces, no semicolon. - If the body is a block in
{ }, it is an ordinary method body and you mustreturnyourself (unless the method returnsvoid).
One lambda, four legal spellings
This is the part that makes real code look unfamiliar. All four of these are the same lambda, and all four printed the same number:
interface Discount { int apply(int amount); }
Discount w1 = (int amount) -> { return amount - amount / 10; }; // everything spelled out
Discount w2 = (amount) -> { return amount - amount / 10; }; // parameter type dropped
Discount w3 = amount -> { return amount - amount / 10; }; // single param, parens dropped
Discount w4 = amount -> amount - amount / 10; // expression body: braces + return dropped
C w1 1800 w2 1800 w3 1800 w4 1800
Three rules govern the short forms, and there are no others:
- Parameter types may be dropped — always, as long as you drop all of them.
- Parentheses may be dropped only for exactly one parameter whose type is also dropped. Zero parameters must be written
(). Two or more must be written(a, b). - Braces and
returnmay be dropped only when the body is a single expression.
Rule 1's "all or none" is not a style guideline, it is a compile error:
Fare f = (int km, rate) -> km * rate;
BadMix.java:5: error: invalid lambda parameter declaration
Fare f = (int km, rate) -> km * rate;
^
(cannot mix implicitly-typed and explicitly-typed parameters)
1 error
In production you will see form 4 almost everywhere and form 1 almost nowhere. Write form 1 while you are learning if it helps; nobody ships it.
Type inference: how does the compiler know?
If you never wrote int, how does amount know it is an int?
The compiler works backwards from the target type — the type on the left of the assignment, or the parameter type of the method you are calling. It finds that interface's one abstract method, reads its signature, and copies it onto your lambda:
Discount has exactly one abstract method: int apply(int amount)
^ ^
| +-- so your parameter is an int
+-- so your body must produce an int
This is type inference, and it is the same machinery that makes neu.sort((a, b) -> ...) work: sort wants a Comparator<String>, whose one method is int compare(String, String), so a and b are String and the body must produce an int. Nothing is dynamic and nothing is guessed at runtime — if your body produces the wrong type you get a compile error, exactly as you would have with the anonymous class.
Two consequences worth remembering:
- The return type is inferred too. You never declare it.
- Because inference needs a target type, a lambda cannot be assigned to
varor toObject. There is nothing to infer from.
Effectively final: the error everybody reads backwards
A lambda can read local variables of the method it was written in. This compiled and ran:
int gstPercent = 18; // no `final` anywhere
Discount withGst = amount -> amount + (amount * gstPercent / 100);
System.out.println("F captured local gstPercent = " + withGst.apply(2000));
F captured local gstPercent = 2360
Note carefully: gstPercent is not declared final, and it captured perfectly. Now add one line to the same method — anywhere in it, even after the lambda:
int gstPercent = 18;
Discount withGst = amount -> amount + (amount * gstPercent / 100);
gstPercent = 28; // <- this line, and only this line, is new
BadFinal.java:6: error: local variables referenced from a lambda expression must be final or effectively final
Discount withGst = amount -> amount + (amount * gstPercent / 100);
^
1 error
Read that message the right way round. Learners see the word final and conclude "I must write final." That is not what it says. The first version had no final and was accepted. The rule is:
A local variable captured by a lambda must be assigned exactly once. If it is, the compiler calls it effectively final and lets you capture it. Writing
finalyourself changes nothing except making the intent explicit — and turning a future mistake into an error at the assignment instead of at the capture.
Also notice where the caret points — at the use inside the lambda, on line 6, not at the reassignment on line 7. The compiler reports the capture, not the crime. That is why this error sends people to stare at the wrong line.
Why the rule exists at all: a local variable lives on the stack frame of its method, and that frame is gone the moment the method returns. The lambda may well outlive it. So Java does not capture the variable, it copies the value into the lambda. If the variable could still change afterwards, the copy and the original would silently disagree — one of the two would be lying. Java refuses to create that situation.
The proof that the rule is about locals, not about mutability in general, is a field:
int gstPercent = 18; // an INSTANCE FIELD, not a local
void run() {
Discount d = amount -> amount + (amount * gstPercent / 100);
gstPercent = 28; // reassigning a field is perfectly legal
System.out.println("E field captured, then changed = " + d.apply(2000));
}
E field captured, then changed = 2560
2560, not 2360 — the lambda saw the new value. A field is reached through this, which is itself effectively final, and the field lives on the heap, so there is nothing to copy and no lie to prevent. This is also the standard escape hatch: if you genuinely need a mutable counter inside a lambda, put it in a field or a one-element array — and then ask yourself whether a plain loop was the better answer.
this — the difference that actually bites
Inside an anonymous inner class, this means that anonymous object. Inside a lambda, this means the enclosing instance — the object whose method you wrote the lambda in. A lambda does not introduce a new scope for this at all.
Task anon = new Task() {
public void go() {
System.out.println(" anon this.getClass() = " + this.getClass().getName());
System.out.println(" anon Demo.this.label = " + Demo.this.label);
}
};
Task lam = () -> {
System.out.println(" lambda this.getClass() = " + this.getClass().getName());
System.out.println(" lambda this.label = " + this.label);
};
anon this.getClass() = Demo$2
anon Demo.this.label = Hirenix-batch-2026
lambda this.getClass() = Demo
lambda this.label = Hirenix-batch-2026
Demo$2 versus Demo — that single line is the whole difference. The anonymous class needed the awkward Demo.this.label syntax to reach out to the enclosing object; the lambda just wrote this.label, because inside a lambda this is the enclosing Demo. The same applies to a bare field name or method call: in a lambda it resolves against the enclosing class, in an anonymous class it resolves against the anonymous one first.
This stops being trivia the day you write a handler that calls this.retry() and quietly get a different object than you expected.
Lambda vs anonymous inner class — the interview table
| Anonymous inner class | Lambda | |
|---|---|---|
| What it can implement | any interface or abstract class, any number of methods | only an interface with exactly one abstract method |
this |
the anonymous object itself | the enclosing instance |
| Can hold its own fields / state | yes | no |
| Can shadow an enclosing variable name | yes | no — it is a compile error |
| Compiled output | a real class file (Demo$1.class) |
no class file at all — an invokedynamic call site |
| Runtime class name | Demo$1 |
Demo$$Lambda$5/0x0000000800c02218 |
The last two rows are not trivia either; they are the honest answer to "is a lambda just syntax sugar for an anonymous class?" No. Compiling the demo produced Demo$1.class and Demo$2.class for the two anonymous classes and nothing for the lambdas — the JVM builds their implementation on first use, through invokedynamic. The printed names show it:
D anon class = Demo$1
E lambda class = Demo$$Lambda$5/0x0000000800c02218
(That hex suffix is a memory address and will differ on your machine — the shape is the point, not the digits.)
When to use it: use a lambda whenever the target type is a one-method interface and the body is short — a Comparator, a click handler, a forEach body, a validation rule. This is now the default in Java; a modern reviewer will ask why you wrote an anonymous class.
When NOT to use it: keep the anonymous inner class when you need state inside it (a call counter, a cached value), when you must implement more than one method or extend an abstract class, or when you need this to mean the handler object itself. And keep a plain named method — or a loop — when the body grows past two or three lines: a nine-line lambda buried inside a method call is harder to read than the anonymous class it replaced, and you cannot unit-test it or put a clean breakpoint on it.
Trade-off: you buy a large reduction in boilerplate and the ability to pass behaviour around as a value. You give up a nameable, testable, stateful object, and you give up an easy stack trace — a failure inside a lambda shows up as lambda$main$0, not as a method name that means anything to you.
Standard definition: A lambda expression is a concise syntax, introduced in Java 8, for supplying an implementation of a functional interface — an interface with exactly one abstract method. It has the form (parameters) -> body; parameter types and the return type are supplied by the compiler through type inference against the target type. It may read local variables of the enclosing method only if they are final or effectively final, and this inside it refers to the enclosing instance, not to the lambda.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Demo {
// A one-method interface. This is the ONLY thing a lambda can be.
interface Discount {
int apply(int amount);
}
interface Task {
void go();
}
String label = "Hirenix-batch-2026";
public static void main(String[] args) {
List<String> names = List.of("Aarav", "Diya", "Ishaan", "Neha", "Rohan");
// ---------- THE OLD WAY: anonymous inner class ----------
List<String> old = new ArrayList<>(names);
Comparator<String> byLength = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};
old.sort(byLength);
System.out.println("A anonymous class = " + old);
// ---------- THE NEW WAY: the same behaviour, one line ----------
List<String> neu = new ArrayList<>(names);
neu.sort((a, b) -> a.length() - b.length());
System.out.println("B lambda = " + neu);
// ---------- ONE lambda, FOUR legal spellings ----------
Discount w1 = (int amount) -> { return amount - amount / 10; };
Discount w2 = (amount) -> { return amount - amount / 10; };
Discount w3 = amount -> { return amount - amount / 10; };
Discount w4 = amount -> amount - amount / 10;
System.out.println("C w1 " + w1.apply(2000) + " w2 " + w2.apply(2000)
+ " w3 " + w3.apply(2000) + " w4 " + w4.apply(2000));
// ---------- what each one really IS at runtime ----------
System.out.println("D anon class = " + byLength.getClass().getName());
System.out.println("E lambda class = " + w4.getClass().getName());
// ---------- capture: READING a local is fine ----------
int gstPercent = 18;
Discount withGst = amount -> amount + (amount * gstPercent / 100);
System.out.println("F captured local gstPercent = " + withGst.apply(2000));
// ---------- `this` ----------
System.out.println("G this inside anonymous class vs inside lambda:");
new Demo().show();
}
void show() {
Task anon = new Task() {
@Override
public void go() {
System.out.println(" anon this.getClass() = " + this.getClass().getName());
System.out.println(" anon Demo.this.label = " + Demo.this.label);
}
};
Task lam = () -> {
System.out.println(" lambda this.getClass() = " + this.getClass().getName());
System.out.println(" lambda this.label = " + this.label);
};
anon.go();
lam.go();
}
}Project: Student Report Card
Every Indian school has two people in the exam office, and they do very different jobs.
The first one keeps the register: one line per student per subject, written in the order the answer sheets came back. Aarav's Maths, then Aarav's Physics, then Aarav's Chemistry, then Diya's Maths. It is a flat list. Nobody can read it and tell you who topped Physics.
The second one prints the report card. Same data, completely different shape: grouped by student, subtotalled, ranked, stamped PASS or FAIL. And the interesting part is that she never edits the register. She reads it and produces a view of it.
That second person is a stream pipeline, and this project is her job. You start with one flat List<Student> — 15 rows, five students, three subjects — and you produce eight sections of a printed report without ever mutating the list you were handed.
This is also, bluntly, the most commercially useful topic in the chapter. The "write a Java 8 program to…" section of every Indian interview-prep list is essentially this project cut into pieces: group employees by department, partition students by pass/fail, find the top 3 salaries, find the highest mark per subject, count occurrences, average by group. Build the report once and you have answered about ten of them with the same five collectors.
🌍 Real-world example: an ed-tech company's teacher dashboard runs exactly this pipeline over a term's marks — class average per subject, the students below the pass line, a merit list of ten. The bug that reached production there was not a crash. Two students tied at 96 in Chemistry and the merit list showed one of them.
limit(10)had silently dropped a child from a list her parents were shown. You will run that bug on purpose in section 5.
💡 Downstream collector = the collector you hand inside
groupingBy, which decides what each group turns into — a list of names, a count, an average, a maximum. 💡 Partition = a split into exactly two groups by a yes/no question.partitioningByisgroupingByfor aPredicate. 💡 Merit list = the ranked top-N, the section of a report card that ties break.
First decision: what does one row look like?
Before any pipeline, the data model. Two options, and beginners almost always take the wrong one.
The tempting model is a Student object holding a Map<String, Integer> of subject to marks. It reads nicely, and then every single report section has to reach inside that map, flatten it, and put it back — you spend the whole project fighting your own model.
The model this project uses is the register: one object per student-subject-mark row.
class Student {
private final String name;
private final String subject;
private final int marks;
// constructor + three getters
}
Five students × three subjects = 15 flat rows. Every report section is then one grouping away, because grouping is the operation that turns a flat list into a shape. Keep the data flat and let the collectors do the shaping — that sentence is most of what this project teaches.
(A record would be shorter here. record is Java 16+, not Java 8, so this project uses a plain class — the code has to run for someone whose company is still on 8.)
The data, marks out of 100, pass mark 33:
| Student | Maths | Physics | Chemistry |
|---|---|---|---|
| Aarav Sharma | 88 | 76 | 91 |
| Diya Patel | 94 | 82 | 67 |
| Ishaan Reddy | 29 | 45 | 58 |
| Ananya Nair | 71 | 90 | 84 |
| Kabir Singh | 55 | 31 | 62 |
Two failing marks, on purpose. A report over data where nothing goes wrong proves nothing.
Section 1 — pass and fail: the loop first, then partitioningBy
Every learner arrives with the loop, so start there.
Before
int passLoop = 0;
int failLoop = 0;
for (Student s : marks) {
if (s.getMarks() >= PASS_MARK) passLoop++;
else failLoop++;
}
After
Map<Boolean, Long> split = marks.stream()
.collect(Collectors.partitioningBy(
s -> s.getMarks() >= PASS_MARK,
Collectors.counting()));
Both print pass=13 fail=2.
What was gained: honestly, for two counters — very little. The loop is four lines and perfectly clear, and if the report needed nothing else, partitioningBy would be showing off. What earns it its place is the second argument. Collectors.counting() there is a downstream collector, and swapping it for Collectors.toList() gives you the two lists of students instead of two numbers, with no other change. That is the loop's ceiling: to get lists as well as counts, the loop grows two more ArrayLists and two more add calls.
Two facts that get asked, both run here:
keys are always both = [false, true]
partitioningBy always returns both keys, even when one side matched nothing. A probe on this same data, partitioning at 95 marks (which nobody scored), gave true=[] — an empty list, not null — while groupingBy on the same filtered data returned {}, a map with no keys at all. That difference is a real bug source: groupingBy(...).get("Maths").size() is an NPE the day nobody in Maths qualifies; the partitioningBy equivalent is 0.
And Collectors.counting() returns a java.lang.Long, not an Integer — verified by printing getClass().getName(). Map<Boolean, Integer> does not compile.
Section 2 — grouping by subject, and the key order that bites
Before — the hand-built map every pre-Java-8 codebase contains:
Map<String, List<String>> bySubject = new HashMap<>();
for (Student s : marks) {
if (!bySubject.containsKey(s.getSubject()))
bySubject.put(s.getSubject(), new ArrayList<String>());
bySubject.get(s.getSubject()).add(s.getName());
}
After
Map<String, List<String>> bySubject = marks.stream()
.collect(Collectors.groupingBy(Student::getSubject, TreeMap::new,
Collectors.mapping(Student::getName, Collectors.toList())));
Why three arguments and not two. The two-argument form is what every tutorial shows, and on this data it printed:
plain groupingBy class = HashMap
plain groupingBy keys = [Maths, Chemistry, Physics]
TreeMap::new keys = [Chemistry, Maths, Physics]
[Maths, Chemistry, Physics] is neither the order the rows arrived in nor alphabetical — it is wherever the hash buckets landed. A report card whose subject order changes when you add a student is a report card the school will not sign off. The middle argument of the three-argument groupingBy is a map factory, and TreeMap::new sorts the keys inside the collect. Decide your ordering; do not inherit it from a hash table.
The third argument, Collectors.mapping(Student::getName, Collectors.toList()), is the downstream collector. Without it you get Map<String, List<Student>> — the whole row objects — and then you write another loop to pull the names out. mapping is the piece most learners never find, and hand-rolling around it is where a clean pipeline turns into three nested loops.
Section 3 — averages, and the type that will not compile
Map<String, Double> avgBySubject = marks.stream()
.collect(Collectors.groupingBy(Student::getSubject, TreeMap::new,
Collectors.averagingInt(Student::getMarks)));
Chemistry 72.40
Maths 67.40
Physics 64.80
Write the map as Map<String, Integer> — which is what you want a mark to be — and javac refuses, in a message worth reading once so it is never frightening again:
BadAvg.java:8: error: incompatible types: inference variable D has incompatible equality constraints Integer,Double
.collect(Collectors.groupingBy(Student::getSubject,
^
averagingInt reads ints and returns a Double; the Int in the name is about the input, not the output. Same trap as counting() returning Long. Read the collector's return type, not its name.
🔴 The empty-subject trap. Suppose Biology is on the timetable and nobody sat it. Two ways to average that empty list, two different answers — both run here:
averagingInt on empty = 0.0
mapToInt().average() = OptionalDouble.empty
averagingInt reports the class average in Biology as 0.0. Nobody failed Biology; nobody took Biology. The first form silently lies and the report prints a zero the principal will ask about; the second form says "there is no number here" in the type. Use mapToInt(...).average() and handle the empty case whenever "no data" and "zero" mean different things — and in a report card they always do.
Section 4 — totals per student
Map<String, Integer> totals = marks.stream()
.collect(Collectors.groupingBy(Student::getName, TreeMap::new,
Collectors.summingInt(Student::getMarks)));
Aarav Sharma 255
Ananya Nair 245
Diya Patel 243
Ishaan Reddy 132
Kabir Singh 148
Same three-argument shape as section 2 with a different downstream — that repetition is the point. summingInt is boxed too — collect(...) hands back a java.lang.Integer, exactly like counting() gives Long and averagingInt gives Double. Only the primitive-stream route, mapToInt(...).sum(), gives you a real int. Read the collector's return type; never guess it from its name.
Notice the register never changed. Sections 2, 3 and 4 grouped the same 15 rows three different ways — by subject twice, by student once — and the source list is untouched after all of them. That is what "produce a view, do not edit the register" buys you.
Section 5 — top 3, and the bug that reaches production
totals.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(3)
.forEach(e -> System.out.println(" " + pad(e.getKey()) + e.getValue()));
Aarav Sharma 255
Ananya Nair 245
Diya Patel 243
sorted(...) then limit(n) is the whole top-N idiom, and it is asked in interviews as "find the top 3 salaries / the second-highest mark". Two things to say about it.
It is not free. sorted is a stateful operation: it must pull the entire stream into memory and sort it before it can emit the first element, so limit(3) saves you nothing on the sort. For 5 students that is irrelevant. For a million rows where you want three, a bounded structure beats sorting everything — know that the idiom has a ceiling.
🔴 And it silently drops ties. This was run on three tied rows — Rohan Iyer 90, Meera Joshi 90, Vihaan Rao 72:
maxBy on a tie = Rohan Iyer
sorted+limit(1) on a tie = [Rohan Iyer]
everyone on the top mark = [Rohan Iyer, Meera Joshi]
limit(1) returned one name. Two students scored 90. Nothing threw, nothing warned, and Meera is simply not in the merit list. Java's sort is stable, so the tie broke on the order rows happened to arrive in — which here means whoever's answer sheet was entered first is the topper.
If "top 3" means the three best marks and everyone who holds them, limit is the wrong tool. Find the cut-off mark first, then filter on it:
int cutoff = /* the 3rd highest total */;
totals.entrySet().stream().filter(e -> e.getValue() >= cutoff)
Deciding which one your report means is a product question, not a Java question — and asking it is what separates a developer from someone who copied a pipeline off a blog.
Section 6 — the subject topper, and why an Optional shows up
Map<String, Optional<Student>> topper = marks.stream()
.collect(Collectors.groupingBy(Student::getSubject, TreeMap::new,
Collectors.maxBy(Comparator.comparingInt(Student::getMarks))));
Chemistry Aarav Sharma (91)
Maths Diya Patel (94)
Physics Ananya Nair (90)
The Optional<Student> in that type annoys everyone the first time. It is Collectors.maxBy being honest: a maximum of an empty group does not exist, so the type says the value may be missing, and you unwrap it deliberately:
best.map(s -> s.getName() + " (" + s.getMarks() + ")").orElse("no marks")
There is a wrinkle worth knowing: groupingBy never creates an empty group, so in this particular pipeline the Optional is always full — which tempts people to call .get(). Don't. Collectors.collectingAndThen(maxBy(...), o -> o.orElse(null)) is the sanctioned way to flatten it, and it hands you back the null you were trying to escape. Leaving the Optional in the map is usually the better answer.
On a tie, maxBy returned the first of the equal elements — Rohan Iyer above. That is the same silent choice as section 5, in a different disguise.
Section 7 — toMap, and the exception this data guarantees
"Make a map of student to marks" sounds like the easiest line in the project:
Map<String, Integer> flat = marks.stream()
.collect(Collectors.toMap(Student::getName, Student::getMarks));
java.lang.IllegalStateException: Duplicate key Aarav Sharma (attempted merging values 88 and 76)
It throws on row two. Aarav has three rows — Maths, Physics, Chemistry — and toMap has no idea which of the three you meant, so it refuses rather than guessing. This is not a corner case in this project; it is the shape of the data. Any flat register keyed by a non-unique field does this.
The fix is toMap's third argument, a merge function that says what to do with two values for one key:
Map<String, Integer> bestMark = marks.stream()
.collect(Collectors.toMap(Student::getName, Student::getMarks,
Integer::max, TreeMap::new));
{Aarav Sharma=91, Ananya Nair=90, Diya Patel=94, Ishaan Reddy=58, Kabir Singh=62}
Integer::max reads "keep the higher mark" — so this map is now each student's best subject score, a different report line, deliberately chosen. Integer::sum would have given totals; (a, b) -> a keeps the first. The fourth argument is the same map factory idea as groupingBy, and TreeMap::new is why the names come out alphabetically.
The rule: if you cannot say out loud why the key is unique, pass a merge function. The IllegalStateException is a good failure — it is loud, it happens immediately, and it names the offending key.
Section 8 — the printed card, which stays a loop
The last section prints a card per student: three subject lines, a total, a percentage, a PASS/FAIL stamp. It looks like the most "streamy" part of the report. It is a loop, and it should be.
for (Map.Entry<String, List<Student>> entry : byStudent.entrySet()) {
List<Student> rows = entry.getValue();
int total = rows.stream().mapToInt(Student::getMarks).sum();
boolean passed = rows.stream().allMatch(s -> s.getMarks() >= PASS_MARK);
System.out.println(" " + entry.getKey());
for (Student s : rows) { /* one line per subject */ }
/* the TOTAL line */
}
The body prints — that is a side effect — and it prints five separate lines whose text depends on values computed from the group. A pipeline here would be a forEach with a multi-statement lambda block inside it: the same code, with { } and a trailing );, and nothing gained. Streams are for producing values, and this section produces none.
Note what is streamed: mapToInt(...).sum() and allMatch(...) inside the loop body, because those two are genuine value-producing questions about a group. Mixed is the correct answer, and a codebase where every loop became a pipeline is as much a smell as one where none did.
Ishaan Reddy
Maths 29 F
Physics 45 P
Chemistry 58 P
TOTAL 132 44.00% FAIL
allMatch is what stamps that FAIL, and it short-circuits — it stops at Maths and never examines the other two. On five students that is invisible; the habit is what matters.
The interview questions this one program answers
| The question, as asked | The section |
|---|---|
| group employees by department | 2 — groupingBy |
| count occurrences per group | 1 — groupingBy/partitioningBy + counting() |
| average salary per department | 3 — averagingInt |
| sum per group | 4 — summingInt |
| find the top 3 / highest / second-highest | 5 — sorted(...).limit(n) |
| find the highest-paid employee per department | 6 — maxBy |
| partition a list into two by a condition | 1 — partitioningBy |
| convert a list to a map | 7 — toMap and its merge function |
| find duplicates in a list | 7 — the duplicate key is the duplicate detector |
| check if all/any elements satisfy a condition | 8 — allMatch |
Ten questions, one program, five collectors. That is why this project exists in this position in the chapter.
The judgment
When to use it: reach for a collector pipeline when you are turning a flat list into a shape — grouped, counted, averaged, ranked, keyed. The three-argument groupingBy with a downstream collector replaces the containsKey/put/get dance and a nested inner loop in one expression, and it stays readable at three or four report sections in a way a 60-line loop does not.
When NOT to use it: keep the loop when the section prints or writes rather than computes (section 8), when you need the row index, or when one pass must maintain more than one interdependent counter. A forEach with a five-statement lambda block is a loop wearing a costume — write the loop.
Trade-off: the pipeline hands the how to the JDK, and the JDK's defaults are not your report's defaults. Three of them cost real money here: groupingBy returns a HashMap with no order you may rely on, limit(n) after sorted drops tied students without a word, and averagingInt reports an empty group as 0.0 instead of "no data". A loop makes you write those decisions out; a collector makes you know them. That is the trade — less code, more knowledge required, and the failures are quiet ones.
Try it yourself
- Change section 3 to
mapToInt(Student::getMarks).average()per subject and print theOptionalDoubledirectly. Then add an empty Biology list and watch the two averaging styles disagree. Which one would you show a principal? - Add a fourth student with 255 total — an exact tie with Aarav — and re-run section 5. Then rewrite it as a cut-off filter so both appear. Count the lines each version costs.
- Swap
Integer::maxin section 7 forInteger::sumand for(a, b) -> a. Three merge functions, three completely different report lines from one pipeline. Name each one. - Delete
TreeMap::newfrom section 2 and printbySubject.getClass().getSimpleName()alongside the keys. Add a sixth student and run again — does the subject order move? - Add a subject-wise rank column to section 8. It needs the position within a sorted group, which is exactly the index a stream throws away. Try it as a pipeline, then as a loop, and merge whichever one you would defend in review.
Standard definition: A report over a flat List is built by keeping the source list unchanged and deriving each section with a collector: partitioningBy for a pass/fail split (both keys always present), the three-argument groupingBy with a map factory and a downstream collector for per-group lists, counts (counting() → Long), averages (averagingInt → Double) and sums (summingInt → Integer, boxed like the rest), sorted(...).limit(n) for a top-N that silently drops ties, maxBy for a per-group maximum returned as an Optional, and toMap with a merge function wherever the key is not unique — while ordinary loops keep the sections that print rather than compute.
import java.util.*;
import java.util.stream.*;
public class StudentReport {
static final int PASS_MARK = 33;
public static void main(String[] args) {
List<Student> marks = Data.sample();
System.out.println("== Hirenix Class XII - Term 1 report ==");
// ---- 1) how many passed? loop first, then the pipeline ----
int passLoop = 0;
int failLoop = 0;
for (Student s : marks) {
if (s.getMarks() >= PASS_MARK) {
passLoop++;
} else {
failLoop++;
}
}
System.out.println("1) loop pass=" + passLoop + " fail=" + failLoop);
Map<Boolean, Long> split = marks.stream()
.collect(Collectors.partitioningBy(
s -> s.getMarks() >= PASS_MARK,
Collectors.counting()));
System.out.println("1) partitioned pass=" + split.get(true) + " fail=" + split.get(false));
System.out.println(" keys are always both = " + split.keySet());
// ---- 2) grouping: who sat for which subject ----
Map<String, List<String>> bySubject = marks.stream()
.collect(Collectors.groupingBy(Student::getSubject, TreeMap::new,
Collectors.mapping(Student::getName, Collectors.toList())));
System.out.println("2) students per subject");
bySubject.forEach((sub, names) -> System.out.println(" " + pad(sub) + names));
// ---- 3) averages per subject ----
Map<String, Double> avgBySubject = marks.stream()
.collect(Collectors.groupingBy(Student::getSubject, TreeMap::new,
Collectors.averagingInt(Student::getMarks)));
System.out.println("3) subject average");
avgBySubject.forEach((sub, avg) ->
System.out.println(" " + pad(sub) + String.format("%.2f", avg)));
// ---- 4) totals per student ----
Map<String, Integer> totals = marks.stream()
.collect(Collectors.groupingBy(Student::getName, TreeMap::new,
Collectors.summingInt(Student::getMarks)));
System.out.println("4) totals out of 300");
totals.forEach((name, total) -> System.out.println(" " + pad(name) + total));
// ---- 5) top 3 ----
System.out.println("5) top 3 by total");
totals.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(3)
.forEach(e -> System.out.println(" " + pad(e.getKey()) + e.getValue()));
// ---- 6) subject topper: maxBy hands back an Optional ----
Map<String, Optional<Student>> topper = marks.stream()
.collect(Collectors.groupingBy(Student::getSubject, TreeMap::new,
Collectors.maxBy(Comparator.comparingInt(Student::getMarks))));
System.out.println("6) subject topper");
topper.forEach((sub, best) -> System.out.println(" " + pad(sub)
+ best.map(s -> s.getName() + " (" + s.getMarks() + ")").orElse("no marks")));
// ---- 7) the toMap trap: one student has three rows ----
System.out.println("7) toMap name -> marks");
try {
Map<String, Integer> flat = marks.stream()
.collect(Collectors.toMap(Student::getName, Student::getMarks));
System.out.println(" " + flat);
} catch (IllegalStateException e) {
System.out.println(" " + e.getClass().getName() + ": " + e.getMessage());
}
Map<String, Integer> bestMark = marks.stream()
.collect(Collectors.toMap(Student::getName, Student::getMarks,
Integer::max, TreeMap::new));
System.out.println(" with a merge function = " + bestMark);
// ---- 8) the printed report card. This is a LOOP on purpose. ----
Map<String, List<Student>> byStudent = marks.stream()
.collect(Collectors.groupingBy(Student::getName, TreeMap::new,
Collectors.toList()));
System.out.println("8) report cards");
for (Map.Entry<String, List<Student>> entry : byStudent.entrySet()) {
List<Student> rows = entry.getValue();
int total = rows.stream().mapToInt(Student::getMarks).sum();
boolean passed = rows.stream().allMatch(s -> s.getMarks() >= PASS_MARK);
System.out.println(" ------------------------------");
System.out.println(" " + entry.getKey());
for (Student s : rows) {
System.out.println(String.format(" %-10s %3d %s",
s.getSubject(), s.getMarks(), s.getMarks() >= PASS_MARK ? "P" : "F"));
}
System.out.println(String.format(" %-10s %3d %.2f%% %s",
"TOTAL", total, total / 3.0, passed ? "PASS" : "FAIL"));
}
}
static String pad(String s) {
return String.format("%-14s", s);
}
}
class Student {
private final String name;
private final String subject;
private final int marks;
Student(String name, String subject, int marks) {
this.name = name;
this.subject = subject;
this.marks = marks;
}
String getName() { return name; }
String getSubject() { return subject; }
int getMarks() { return marks; }
}
class Data {
static List<Student> sample() {
List<Student> list = new ArrayList<>();
list.add(new Student("Aarav Sharma", "Maths", 88));
list.add(new Student("Aarav Sharma", "Physics", 76));
list.add(new Student("Aarav Sharma", "Chemistry", 91));
list.add(new Student("Diya Patel", "Maths", 94));
list.add(new Student("Diya Patel", "Physics", 82));
list.add(new Student("Diya Patel", "Chemistry", 67));
list.add(new Student("Ishaan Reddy", "Maths", 29));
list.add(new Student("Ishaan Reddy", "Physics", 45));
list.add(new Student("Ishaan Reddy", "Chemistry", 58));
list.add(new Student("Ananya Nair", "Maths", 71));
list.add(new Student("Ananya Nair", "Physics", 90));
list.add(new Student("Ananya Nair", "Chemistry", 84));
list.add(new Student("Kabir Singh", "Maths", 55));
list.add(new Student("Kabir Singh", "Physics", 31));
list.add(new Student("Kabir Singh", "Chemistry", 62));
return list;
}
}Java 8 and Functional Programminginterview questions & answers
10 sample questions below — 239+ in the full bank inside.
Explain LocalDate, LocalTime and LocalDateTime with an example of each.
Each one holds exactly the information its job needs and nothing more. LocalDate is a date only -- year, month, day -- so LocalDate.of(2026, 8, 24) prints 2026-08-24 and is right for a date of birth, a joining date or an EMI due date. LocalTime is a time only -- hour, minute, second -- so LocalTime.of(9, 30) prints 09:30 and is right for a shift start or a lunch break. LocalDateTime is both together with no timezone attached: LocalDateTime.of(2026, 8, 24, 15, 30) prints 2026-08-24T15:30, which is the interview slot exactly as written on the letter. All three are built with static of(...) factory methods rather than constructors, all three are immutable, and all three have a now() that reads the system clock. The word Local is the key -- it means no timezone information is carried.
In simple terms: The naming trips people up, because Local sounds like it means your local timezone, and it means the opposite: no zone at all. A LocalDate is a date the way a wall calendar shows it. 15 March 2004 as a date of birth is the same value in Pune and in London -- attaching a zone to it invents information you do not have, and it is what makes a birthday shift by a day when the server runs in UTC and the users are on IST. The practical way to choose is to ask what the field actually is. Does it need a time? Date of birth does not -- LocalDate. Does it need a date? A shop's opening time does not -- LocalTime. Does it need both but the zone is obvious from context and the value never leaves your system? LocalDateTime. The moment two people in different places must agree on one instant, none of these three is enough and you need ZonedDateTime.
An old library hands you a java.util.Date. How do you convert it to a LocalDate, and back?
One line each way. Old to new: LocalDate d = legacy.toInstant().atZone(ZoneId.of("Asia/Kolkata")).toLocalDate(); New to old: java.util.Date back = java.util.Date.from(zdt.toInstant()); I ran the round trip on the 15:30 IST slot and got 2026-08-24 back. The reason there is no shorter version is that a java.util.Date is a point on the UTC timeline, not a calendar date, so turning it into a calendar date forces you to say whose calendar -- and that is exactly what atZone supplies. Do the conversion once, at the boundary where the old library hands you the value, and use java.time everywhere inside your own code.
In simple terms: The rule that matters more than the syntax is where you put the conversion. Treat the legacy API like a currency exchange counter at an airport: you change money once on the way in, and once on the way out, and you do not carry two currencies around the whole trip. A codebase that converts back and forth in the middle of business logic ends up with both APIs in every method signature and gets the worst of both. There is one subtlety worth naming: the zone you pass to atZone is a real decision, not boilerplate. A Date created from an IST-entered value and read back with atZone(ZoneOffset.UTC) can come out one day earlier for anything late in the evening, because 2026-08-24T00:30 IST is 2026-08-23 in UTC. Pass the zone your data actually means -- for an Indian product that is Asia/Kolkata -- rather than the system default, which changes when the server does.
You have a List<Student> with name, subject and marks. How do you group it subject-wise?
batch.stream().collect(Collectors.groupingBy(s -> s.subject)). The lambda is called the classifier -- whatever it returns becomes the map key -- and groupingBy hands back a Map whose keys are the distinct classifier results and whose values are Lists of everything that fell into each bucket. Here the type is Map<String, List<Student>> and it printed {Maths=[Aarav(Maths,82), Ishaan(Maths,91)], Chemistry=[Rohan(Chemistry,65)], Physics=[Diya(Physics,47), Neha(Physics,38)]}. It is the Stream API's version of SQL GROUP BY, and it replaces a nested loop plus a map.computeIfAbsent dance with one line.
In simple terms: The mental picture is a clerk with a stack of mark sheets and a row of empty trays. He reads the subject off each sheet and drops it in the matching tray, creating a tray the first time he sees a new subject. That last detail matters and comes back in a later question: groupingBy creates a bucket only when something actually lands in it, so a subject nobody took simply has no key. Also worth knowing: the value lists came back as java.util.ArrayList, so they are mutable, and the map itself was a java.util.HashMap.
Which packages does the Java 8 Date/Time API live in, and which are its three most important classes?
The core package is java.time -- that is where LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Instant, Duration, Period, ZoneId and Year live. Two sub-packages matter alongside it: java.time.format, which holds DateTimeFormatter and DateTimeParseException, and java.time.temporal, which holds the units and fields, most usefully ChronoUnit. There are two more you rarely touch -- java.time.zone for the timezone rule data, and java.time.chrono for non-ISO calendar systems like the Hijrah or Japanese calendar. If I had to name three classes: LocalDate for a plain calendar date, LocalDateTime for a date with a time but no zone, and ZonedDateTime for a moment that a timezone makes unambiguous. In everyday code Instant and DateTimeFormatter come up just as often as those three.
In simple terms: The package split is not decoration, it tells you what each thing is. java.time holds values -- objects that ARE a date or a length of time. java.time.format holds machinery for turning those values to and from text, which is a completely separate concern and the reason a formatter is a separate object you can configure and share. java.time.temporal holds the vocabulary -- ChronoUnit.DAYS, ChronoUnit.MONTHS -- so that between(start, end) can be asked in whatever unit you want without every class needing its own daysBetween method. Practically, only one import surprises people: ChronoUnit is not in java.time, so import java.time.* alone leaves it unresolved and you need import java.time.temporal.ChronoUnit. Knowing which package a class is in is a small thing, but it is exactly the sort of small thing an interviewer uses to tell reading-about-it from having-used-it.
What is Predicate and when would you use it?
Predicate<T> is the yes/no shape: it takes one object of type T and returns a boolean, and its single abstract method is test(T). You use it anywhere your old code had a helper called isValid, isEligible or shouldSkip that returned boolean -- a scholarship check on a Student, a pincode check on a parcel, an email-format check on a form field. The reason to write it as a Predicate instead of a private method is that a Predicate is a value: you can store it in a variable, pass it into another method, keep a List of them, and combine them with and, or and negate.
In simple terms: A boolean method and a Predicate compute the same thing, but only one of them can be handed around. boolean isEligible(Student s) is a verb bolted to a class; Predicate<Student> eligible = s -> s.marks >= 75 is a noun you can put in a Map<String, Predicate<Student>> and let a user pick from a dropdown. That is the whole reason the interface exists. Predicate also carries three combinators as default methods, and reflection on JDK 17 shows the split exactly: test is ABSTRACT, and, or and negate are default, and isEqual and not are static. (Scope those two when you say them out loud: isEqual is Java 8, not arrived in Java 11 -- they are not a pair.) So it still has exactly one abstract method and remains a perfectly ordinary functional interface.
Name five methods of the Collectors class and say what each one gives you back.
toList() gives a List -- in practice an ArrayList, and it is mutable. toSet() gives a Set -- a HashSet, mutable and unordered, which is how you kill duplicates. toMap(keyFn, valueFn) gives a HashMap, turning a list into a lookup table. counting() gives a Long, not an Integer. joining(separator, prefix, suffix) gives one printable String. Beyond those five, summingInt(f) gives an Integer, averagingInt(f) gives a Double, and groupingBy and partitioningBy give you a Map of buckets, which is where most interview coding questions live.
In simple terms: The useful way to remember them is by the return type, because that is what the next line of your code has to accept. On the five-student batch Aarav/Maths/82, Diya/Physics/47, Ishaan/Maths/91, Neha/Physics/38, Rohan/Chemistry/65 the actual outputs were: counting() = 5, summingInt(s -> s.marks) = 323, averagingInt(s -> s.marks) = 64.6, and joining(', ', '[', ']') over the names = [Aarav, Diya, Ishaan, Neha, Rohan]. Notice the shapes differ wildly -- one number, one number, one decimal, one string -- from the same five objects. A Collector is a report format, and picking the right one is picking what the report looks like.
Java 8 shipped a set of pre-defined functional interfaces. Which package are they in, and what are the categories?
They live in java.util.function, and they are organised by shape -- what goes in and what comes out -- not by name. There are four core shapes: Predicate<T> takes an object and returns a boolean (test), Function<T,R> takes an object and returns another object (apply), Consumer<T> takes an object and returns nothing (accept), and Supplier<T> takes nothing and returns an object (get). Everything else in the package is a variation on those four: the two-argument Bi* forms, the same-type shortcuts UnaryOperator and BinaryOperator, and the primitive specialisations like IntPredicate and ToIntFunction. On JDK 17 I listed the package and it holds 43 interfaces, which sounds frightening until you see it is four shapes times a few variations.
In simple terms: Think of a small courier office in Pune. All day only four kinds of request cross the desk: someone asks a yes/no question about a parcel, someone asks you to turn a parcel into a receipt, someone asks you to stamp a parcel and hand nothing back, and someone asks you for a fresh blank form having given you nothing. The contents change a thousand times a day; the four shapes do not. That is exactly what java.util.function encodes. The practical trick is to hold it as a grid rather than a list: ask 'does it take an argument?' and 'does it return something?' and the interface falls out in seconds -- no argument plus a return is Supplier, argument plus no return is Consumer, argument plus boolean is Predicate, argument plus anything else is Function.
What is the relationship between collect(), Collector and Collectors?
They are three different things whose names look almost identical. collect() is a terminal operation -- a method on Stream that actually runs the pipeline and pushes every surviving element into a container. Collector is an interface in java.util.stream that describes HOW that container gets built: a supplier to create an empty container, an accumulator to add one element, a combiner to merge two containers, and a finisher for the final touch-up. Collectors is a final utility class full of static factory methods that hand you ready-made Collector objects -- toList, toSet, toMap, counting, joining, groupingBy, partitioningBy. So collect(Collectors.toList()) reads left to right as: run the pipeline, and build the result using the toList recipe.
In simple terms: The kitchen analogy nails it: collect() is the cook, Collector is what a recipe is as a concept, and Collectors is the recipe book on the shelf. The plural s is the tell, exactly the way Collections is to Collection. Concretely, batch.stream().filter(s -> s.marks >= 50).map(s -> s.name).collect(Collectors.toList()) printed [Aarav, Ishaan, Rohan] on a five-student batch. Nothing before collect() had done any work -- filter and map are lazy, and it is the terminal collect() that pulls elements through. A fresher is never asked to write a custom Collector; being able to name these three apart is what actually gets marked.
What are Supplier and Consumer?
They are the two mirror-image shapes. Consumer<T> takes one object and returns nothing -- its single abstract method is accept(T), declared void, so it exists only for a side effect such as printing, logging or saving a row. Supplier<T> is the opposite: it takes no argument at all and produces an object, via get(). So Consumer<Student> audit = s -> System.out.println("AUDIT " + s.name) and Supplier<List<String>> batch = ArrayList::new are the two ends of the same axis. Consumer also carries one default method, andThen, which runs two consumers over the same input in order; Supplier carries nothing at all -- reflection on JDK 17 shows just the abstract get.
In simple terms: The clean way to hold all four core interfaces is by direction of flow. Consumer is data flowing in and stopping there -- a letterbox. Supplier is data flowing out of nothing -- a ticket machine you press without handing it anything. Predicate and Function both take something in and hand something back; they differ only in what. Because Consumer.accept is void, the compiler actively enforces the shape: writing Consumer<String> c = name -> { return name.length(); }; fails on JDK 17 with incompatible types: bad return type in lambda expression and the note unexpected return value, which is the compiler telling you that you wanted Function<String, Integer>.
What does this print, and what is wrong with it? LocalDate emi = LocalDate.of(2026, 1, 31); emi.plusDays(1); System.out.println(emi);
It prints 2026-01-31 -- completely unchanged. Every java.time class is immutable, so plusDays returns a NEW LocalDate and leaves the original alone; the returned object was thrown away, so the line did nothing at all. It compiles without a warning because emi.plusDays(1); is a perfectly legal expression statement, exactly like "abc".toUpperCase(); on its own line. The fix is one character: emi = emi.plusDays(1);, and then it prints 2026-02-01. The same is true of every other method that looks like a mutator -- minusMonths, withDayOfMonth, plusYears. I ran emi.withDayOfMonth(5) and emi was still 2026-01-31 while the returned value was 2026-01-05.
In simple terms: Immutability here is not a design opinion to argue about, it is a property of the classes, and it has two faces. The face that bites you on day one is this bug: the returned value IS the answer, so discarding it discards the whole operation. Think of it like asking someone what is 31 January plus a day -- they tell you the answer, they do not go and edit your calendar. The other face is the payoff, and it is the reason the API was designed this way. Because a LocalDate can never change, you can make it a static final constant, use it as a Map key without it silently breaking the map, share it across threads with no locking, and hand it to any method at all knowing none of them can corrupt it. That is exactly the bug in java.util.Date -- which had setTime() -- fixed at the root rather than patched with defensive copies.
229+ more Java 8 and Functional Programming questions inside
Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.
Unlock all questions — freeReady to practise Java 8 and Functional Programming?
Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.