عرض عملي: المجموعة الخاطئة، بالقياس
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
Lessons 2 and 6 said that contains on a list is slow and that a Set or Map is the right tool for lookups. This demo does not ask you to take that on trust. You will fill an ArrayList and a HashSet with 100,000 numbers, run 10,000 lookups on each, time them with System.nanoTime(), and look at the gap.
We also plant one measurement mistake on purpose: drawing a conclusion from a single noisy run. A benchmark is only worth anything if you repeat it and look at the pattern, not one number.
Type and run the code yourself. Your absolute numbers will differ from the ones printed here, because they depend on your computer, your Java version, and what else is running; the pattern should be the same. This lesson builds on Lessons 2, 3, 4, and 6, and on the idea of counting steps from BEG-017-L06 and racing searches from BEG-017-L07.
Learning Objectives
By the end of this lesson you will be able to:
- Time a block of code with
System.nanoTime()and convert the result to microseconds. - Explain why
containson a list scans and on a hash-based set does not. - Interpret timings from several runs, and explain why one run is not evidence.
- Compare how time changes when the input size changes.
- State what a quick benchmark like this can and cannot prove.
- Use the measurement to justify "use a
SetorMapfor lookups".
Written Explanation
The experiment
We want to compare one operation, "is this number in the collection?", on two structures that hold the same 100,000 integers, 0 to 99,999. We ask 10,000 questions on each and measure the total time.
To avoid timing the cheapest case, we look up values from the end of the range (99,999 down to 90,000). A list scans from the front, so these lookups walk almost the whole list every time. Every lookup is a hit, and we count hits to prove the code did what we think.
How to time code
System.nanoTime() returns a number of nanoseconds from an arbitrary starting point. Its documentation says to use it only to measure elapsed time, by subtracting two readings, not as a clock. A nanosecond is a billionth of a second, so we divide to get friendlier units.
long start = System.nanoTime();
// ... the work being measured ...
long elapsed = System.nanoTime() - start;The program
import java.util.*;
public class Bench {
static int N = 100_000;
static final int LOOKUPS = 10_000;
static long timeLookups(Collection<Integer> c) {
int hits = 0;
long start = System.nanoTime();
for (int i = 0; i < LOOKUPS; i++) {
if (c.contains(N - 1 - i)) hits++;
}
long elapsed = System.nanoTime() - start;
if (hits != LOOKUPS) System.out.println("unexpected hits " + hits);
return elapsed / 1_000; // microseconds
}
public static void main(String[] args) {
if (args.length > 0) N = Integer.parseInt(args[0]);
List<Integer> list = new ArrayList<>();
Set<Integer> set = new HashSet<>();
for (int i = 0; i < N; i++) { list.add(i); set.add(i); }
for (int run = 1; run <= 5; run++) {
long listUs = timeLookups(list);
long setUs = timeLookups(set);
System.out.println("run " + run + ": list " + listUs + " us, set " + setUs + " us");
}
}
}A few details. The method takes a Collection<Integer>, so the same timing code serves both structures, which keeps the comparison fair (Lesson 1). The underscores in 100_000 are just readability separators in number literals. N - 1 - i is an int, and Java automatically converts it to an Integer for contains. Setting args[0] lets us change the size from the command line (BEG-007).
What the results mean
One run of this program on the JDK 21 used for this lesson printed:
run 1: list 2783642 us, set 1447 us
run 2: list 2708372 us, set 584 us
run 3: list 2723488 us, set 208 us
run 4: list 2748484 us, set 145 us
run 5: list 2687757 us, set 152 usRead the units: the list needed roughly 2.7 seconds for 10,000 lookups, the set a fraction of a millisecond to about one and a half. That is a difference of thousands of times, and it is the same ranking on every run even though the individual numbers move.
Why? ArrayList.contains starts at index 0 and compares each element with equals until it finds a match, so looking up a value near the end touches nearly all 100,000 elements. HashSet.contains uses the value's hashCode() to jump to the right place and compares only a handful of elements (Lessons 3 and BEG-026). The HashSet documentation says its basic operations take constant time, assuming a good hash function, while the list's cost grows with its size.
The planned mistake: trusting one run
Look at the set column: run 1 took 1447 microseconds, and runs 4 and 5 about 150. The program did not change, but the first run was about ten times slower. The Java virtual machine starts by running code in a slower mode and optimizes it after seeing it repeatedly (a "warm-up"); other programs on your computer also add noise. If you had stopped after run 1 you would have reported a slower set than the real steady state, and a different run on a busy machine might even reverse a small difference.
The habit this teaches:
- Repeat the measurement several times and look at the whole set of numbers.
- Discard or flag the first run when measuring something small.
- Prefer big differences (thousands of times) as evidence; ignore small differences (10% or 20%) from a simple benchmark like this.
Professional benchmarking uses a dedicated tool (the Java Microbenchmark Harness, JMH) to handle warm-up and other pitfalls. A loop with nanoTime is a good teaching tool and a rough sanity check, but not a basis for fine-grained decisions.
Does the size matter?
Run the program with a different size, for example java Bench 50000 and java Bench 200000. In the runs recorded for this lesson, the list's total time for 10,000 lookups rose from about 0.7 seconds at 50,000 elements to about 2.7 seconds at 100,000 and about 7 seconds at 200,000, while the set stayed in the range of a few hundred microseconds. The list grows with the size; the set barely notices. Your numbers will differ, and the list time may grow faster than the size because larger data also hurts memory behavior. We therefore make only the safe claim: the list gets slower as the data grows; the set does not.
Maps and other comparisons
The same reasoning applies to Map: a HashMap.containsKey on 100,000 keys for the same 10,000 queries took under a millisecond in a single run on the same machine, roughly the same as the set. Another experiment from Lesson 2: reading the middle element by index 2,000 times took about 0.2 to 0.7 ms on an ArrayList and about 0.35 to 0.63 seconds on a LinkedList of 100,000 elements, because the linked list must walk to the middle each time.
What the benchmark does not show: the cost of building the set (it uses more memory than the list), or what happens with tiny collections. For 10 elements a list scan is trivially fast, and the choice does not matter for speed; it still matters for meaning (uniqueness, key lookup). Choose by the requirement first (Lesson 6), and use measurement to confirm a suspected problem, not to guess one.
Examples
Example 1: one lookup, step by step (simple)
Inside the list, contains(99_999) compares 99_999 with 0, 1, 2, ... up to the last element: 100,000 comparisons. Inside the set, contains(99_999) computes a hash code, goes to the matching slot, and compares with the one or two elements stored there. Same question, two very different amounts of work. The 10,000 lookups multiply this difference.
Example 2: a fair comparison (practical)
The timing method accepts Collection<Integer>, so the same code times both. A comparison is fair when everything except the thing being tested is identical: same data, same lookups, same hit count. We even verify with hits != LOOKUPS; if a bug made all lookups miss, the list would scan everything on every call and the numbers would mean something different. Checking that a benchmark computes the right answer is part of the benchmark.
Example 3: from measurement to decision (realistic)
A service keeps 100,000 product codes in an ArrayList<String> and checks contains(code) on every request. At a few thousand requests per second, each scanning tens of thousands of strings, the application would spend most of its time on a check that a set answers almost instantly. The fix is one line and a type: Set<String> codes = new HashSet<>(codeList);. The benchmark in this lesson is the evidence you can show a teammate when they ask why you changed it.
Backend Insight
Performance work in backend systems follows three rules that this demo illustrates. First, choose the right structure up front; the difference between a scan and a hash lookup is not something you can tune away later with faster hardware. Second, measure before optimizing and measure repeatedly; a single timing, especially the first, can mislead. Third, know the size of your data: with 20 items nothing here matters, with 2 million it matters a great deal, and data in production grows.
In real systems the "collection" is often a database table. The same lesson reappears as an index: a database finds a row by key without scanning the table, the way a HashMap finds an entry without scanning a list. When you reach the database modules, remember this experiment.
Common Mistakes
Mistake 1: Concluding from one run
A single timing includes warm-up and noise. Repeat and compare, and discard the first run when the work is small.
Mistake 2: Measuring something other than what you intended
Printing inside the timed loop, or building the collection inside the timed region, adds costs unrelated to contains. Time only the operation under test.
Mistake 3: Using different data or lookups for the two sides
If the list is searched for values near the front and the set for values at the end, the comparison is meaningless. Use identical queries.
Mistake 4: Optimizing tiny collections
On ten elements, choose by meaning, not speed. A Set for ten elements is still correct when uniqueness is the requirement.
Mistake 5: Trusting a microbenchmark for precise numbers
Quote "thousands of times faster" from a result like this, never "exactly 1,847 times faster". For decisions that hinge on small differences, use a proper benchmarking tool.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. Which collection should be faster for 10,000 contains calls on 100,000 integers: ArrayList or HashSet? Why?
أظهر الحل A1
A1. HashSet. A list scans element by element, so a lookup near the end touches almost all 100,000 elements, while a hash set uses the hash code to go almost directly to the element.
A2. What does System.nanoTime() return, and how do you use it to time a block?
أظهر الحل A2
A2. It returns a number of nanoseconds from an arbitrary starting point. Read it before and after the block and subtract: elapsed = after - before. The absolute value means nothing on its own.
A3. Why do we run the benchmark several times?
أظهر الحل A3
A3. Because the first run includes warm-up and any run includes noise from the rest of the computer. Several runs show the pattern, and a ranking that repeats is believable.
Level B: Application
B1. Modify the benchmark to time a TreeSet<Integer> as well. Predict where it will land: near the list, near the hash set, or between. Then run it and compare. (The TreeSet documentation guarantees logarithmic time for contains.)
أظهر الحل B1
B1. The TreeSet should be much closer to the hash set than to the list. A tree lookup takes a logarithmic number of comparisons (roughly 17 for 100,000 elements) rather than 100,000. Replace the set with Set<Integer> tree = new TreeSet<>();, fill it in the same loop, and call timeLookups(tree). Expect tens or hundreds of microseconds; the order of magnitude, not the exact value, is the point.
B2. Write a Map<Integer, Integer> version: fill it with keys 0 to 99,999, and time 10,000 containsKey calls. Compare with the set.
أظهر الحل B2
B2.
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < N; i++) map.put(i, i);
long start = System.nanoTime();
int hits = 0;
for (int i = 0; i < LOOKUPS; i++) if (map.containsKey(N - 1 - i)) hits++;
long us = (System.nanoTime() - start) / 1_000;In a single run on the JDK used here: all 10,000 hits in 792 microseconds, the same order as the set.
B3. The benchmark checks hits != LOOKUPS. Explain what bug this would catch and why it matters.
أظهر الحل B3
B3. It would catch a bug where the lookups do not find the values (for example, an off-by-one in the loop, or looking up the wrong type). A benchmark that does the wrong work can produce impressive-looking but meaningless numbers, so verify the result before trusting the time.
Level C: Challenge
C1. Run the benchmark for sizes 25,000, 50,000, and 100,000 (the args[0] option) and record the list times. Does the time grow roughly in proportion to the size? Write two sentences on what you observe and one on why you should not over-interpret it.
أظهر الحل C1
C1. The list times in the recorded runs were about 0.7 seconds, 2.7 seconds, and (at 200,000) about 7 seconds for sizes 50,000, 100,000, and 200,000; your numbers will differ. The time clearly grows with the size, as expected of a scan. Do not over-interpret exact ratios: memory effects, warm-up, and noise change them, so say "grows with size" rather than "grows exactly in proportion".
C2. A teammate says: "My List<String> of 30 banned words works fine, I will change nothing." Another says: "Always use a Set for lookups." Who is right, and what would you check first?
أظهر الحل C2
C2. For 30 words the speed difference is negligible, so the first teammate is right about speed, and the code is fine. But "always use a Set" is still a good default when the requirement is membership with uniqueness, because it states intent and does not cost more. Check first the size of the data now and in the future, and whether duplicates in the list would be a bug.
Mini Challenge
Run your own experiment and write a half-page lab note:
- Run
Benchfive times at 100,000 and record the list and set columns. - Add a
HashMaptiming, as in B2, and aTreeSettiming, as in B1. - Run at two other sizes and record the list time.
- In four or five sentences, say which structure you would use for lookups, citing your numbers, mention one source of noise, and say what you would do before basing a design decision on these results.
Check your work: you report units (microseconds or seconds) with every number, you discuss the first run's effect, and your conclusion names the requirement ("lookup by value") rather than only the speed.
Summary & Next Lesson
Key takeaways
containson a list scans; on a hash set it jumps. For 100,000 elements and 10,000 lookups the difference in the recorded run was seconds against well under a millisecond.- Time with
System.nanoTime()by subtracting two readings; convert to friendlier units. - One run proves nothing: warm-up and noise distort it. Repeat, and trust big, repeatable differences.
- A fair benchmark uses identical data and queries, and verifies the result.
- Choose the structure by requirement first, and use measurement to confirm or investigate.
Next lesson
This is the last lesson of the module. The next module, BEG-031, opens with "Life before generics" and explains the <String> notation that you have been reading and writing throughout this module.
Self-check
- Why is
containsslow on a list? - What does subtracting two
nanoTime()readings give you? - Why was run 1 of the set so much slower than run 4?
- Why verify the hit count?
- Which collection family would you use to find an order by its id?
أظهر الإجابات
- It compares the target with each element in turn until it finds a match, so the work grows with the size of the list.
- The elapsed time in nanoseconds between the two readings.
- Warm-up of the Java virtual machine and background noise; steady-state runs are faster.
- To prove the benchmark did the intended work; wrong results make the timing meaningless.
- A
Map, such as aHashMapkeyed by order id.
References
- Oracle, Java SE 21 API,
HashSet. Supports constant-timecontainsassuming a good hash function. - Oracle, Java SE 21 API,
TreeSet. Supports guaranteed log(n) time forcontains. - Oracle, Java SE 21 API,
LinkedList. Supports that index operations traverse from the nearer end. - All timings are from real runs on one Windows machine with JDK 21 and will differ on yours. I did not fetch the
System.nanoTime()or JMH documentation, so the statements thatnanoTimeis for measuring elapsed time and that JMH is a dedicated benchmarking tool are general knowledge; the explanation of JVM warm-up is also general knowledge, not a cited claim.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.