PONYλM2Modula-2

C++.CodeCompared.To/Java

An interactive executable cheatsheet comparing C++ and Java

C++23 (GCC) Java 25
Hello World & Building
Hello, World
Everything in Java lives inside a class, including main — there is no file scope to put a free function at. The String[] args is the command line, so the entry point takes its arguments rather than reading a pair of globals.
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
No headers, no includes for anything in java.lang, and no return value: main is void, and a status code comes from System.exit when you want one. The other visible absence is std::endl; println adds the newline, and System.out flushes on newline by default, so the flush-versus-newline distinction that matters in a C++ loop does not arise here.
What the compiler produces
Two compilation steps rather than one, and the second happens while the program runs. javac produces bytecode for an abstract machine; the JVM interprets it, notices which methods are hot, and compiles those to machine code.
// g++ -std=c++23 -O2 main.cpp -o program // // Out comes machine code for THIS processor. It runs with no runtime // beneath it, starts in microseconds, and is as fast on its first // iteration as on its millionth. #include <iostream> int main() { long total = 0; for (int index = 0; index < 1000; ++index) total += index; std::cout << total << std::endl; return 0; }
// javac Main.java → Main.class, holding BYTECODE, not machine code // java Main → the JVM loads it and interprets, then compiles // the hot parts to machine code while running // // The same class file runs on any processor with a JVM. class Main { public static void main(String[] args) { long total = 0; for (int index = 0; index < 1000; index++) total += index; System.out.println(total); } }
The practical consequences run in both directions. Startup is slower and the first thousand iterations of a loop are much slower, which is why a Java command-line tool feels sluggish and a Java server does not. But the JIT compiles against what is actually happening — it inlines the one implementation an interface really has, and deoptimizes if a second one shows up — which a static compiler cannot do. That is the real answer to "which is faster": for a short program, C++, and for a long-running one, it depends on the code.
No headers, no declaration order
The forward declaration disappears, and so does the header it would have lived in. A Java compiler reads an entire class before resolving names inside it, so a method may call one defined below it.
#include <iostream> #include <string> // A name must be DECLARED before it is used, because the compiler // reads the file top to bottom, once. std::string inner(); std::string outer() { return inner(); } std::string inner() { return "resolved at link time"; } int main() { std::cout << outer() << std::endl; return 0; }
class Main { // Order does not matter. The compiler reads the whole class // before it resolves anything inside it. static String outer() { return inner(); } static String inner() { return "resolved at link time"; } public static void main(String[] args) { System.out.println(outer()); } }
That removes a whole category of C++ work: no header/implementation split, no include guards, no forward declarations, no "undeclared identifier" versus "undefined reference" distinction to explain, and no compile-time cost from a header being re-parsed for every translation unit. What replaces it is the classpath — a list of directories and jar files the compiler and the JVM search for classes — and one public class per file, named after the file.
Memory & Object Lifetime
🚨 There are no destructors
This is the row the rest of the page depends on. The C++ program prints three lines and the Java program prints two, and the missing one is the destructor that never runs.
#include <iostream> #include <string> struct Noisy { std::string name; Noisy(std::string name) : name(std::move(name)) { std::cout << "made " << this->name << std::endl; } ~Noisy() { std::cout << "destroyed " << name << std::endl; } }; void work() { Noisy thing("inner"); std::cout << "working" << std::endl; } // ← destroyed HERE, deterministically int main() { work(); std::cout << "after work()" << std::endl; return 0; }
class Noisy { private final String name; Noisy(String name) { this.name = name; System.out.println("made " + name); } // There is no destructor to write. The object becomes garbage when // nothing refers to it, and is collected at an unspecified time — // possibly never, if the program ends first. } class Main { static void work() { Noisy thing = new Noisy("inner"); System.out.println("working"); } // ← nothing happens here public static void main(String[] args) { work(); System.out.println("after work()"); } }
🚨 Do not look for a replacement — there is not one. finalize() existed, was unreliable for twenty years, and was removed. Cleaner and phantom references exist for native memory and are not a lifetime mechanism. What replaces RAII is try-with-resources, in the Resources section, and the difference is that the obligation moves from the type to every call site: in C++ you cannot forget to close a file, and in Java you can.
Objects are always on the heap
Every new is a heap allocation and there is no way to ask for anything else — no stack objects, no Point stored by value inside a collection, no sizeof to ask about.
#include <iostream> #include <vector> struct Point { int x; int y; }; int main() { Point onStack{1, 2}; // no allocation at all std::vector<Point> many(3, {0, 0}); // ONE allocation, 3 points inside it std::cout << onStack.x << " " << many.size() << std::endl; std::cout << "bytes per Point: " << sizeof(Point) << std::endl; return 0; }
import java.util.ArrayList; import java.util.List; class Point { int x; int y; Point(int x, int y) { this.x = x; this.y = y; } } class Main { public static void main(String[] args) { Point one = new Point(1, 2); // heap allocation // A List<Point> holds REFERENCES: one allocation for the backing // array, plus one more for each Point it points at. List<Point> many = new ArrayList<>(); for (int index = 0; index < 3; index++) many.add(new Point(0, 0)); System.out.println(one.x + " " + many.size()); } }
The layout difference is the one that shows up in a profiler. A std::vector<Point> is one contiguous block that a cache line reads three points out of; an ArrayList<Point> is an array of references pointing at objects scattered wherever the allocator put them. Escape analysis lets the JIT put some short-lived objects on the stack, and Project Valhalla is adding real value types — but as of Java 25 the rule stands, and it is why numeric code stores int[] rather than List<Integer>.
What the collector gives and takes
The cycle is the case where the trade is clearest. shared_ptr counts references, so two objects pointing at each other keep each other alive forever; a tracing collector asks "can I still reach this from a root", and the answer is no.
#include <iostream> #include <memory> struct Node { std::shared_ptr<Node> next; ~Node() { std::cout << "node freed" << std::endl; } }; int main() { // A CYCLE of shared_ptr never reaches zero. This leaks, silently, // and the destructors above never run. auto first = std::make_shared<Node>(); auto second = std::make_shared<Node>(); first->next = second; second->next = first; std::cout << "use count: " << first.use_count() << std::endl; return 0; }
class Node { Node next; } class Main { public static void main(String[] args) { // The same cycle. The collector traces from the roots rather // than counting references, so once main stops referring to // these two, both are unreachable and both are collectible. Node first = new Node(); Node second = new Node(); first.next = second; second.next = first; System.out.println("both are collectible once unreachable"); } }
So the collector buys you no leaks from cycles, no use-after-free, no double-free, and no weak_ptr to remember. What it costs is control: you cannot say when memory is reclaimed, a pause can happen at an inconvenient moment (modern collectors like ZGC keep those under a millisecond, which is a different world from the old ones), and the heap is several times larger than the live data. The C++ answer — unique_ptr everywhere and a cycle broken by hand — is more work and more predictable, which is exactly the trade.
Everything Is a Reference
🚨 Assignment copies the reference, not the object
The exact mirror of the Python page's headline row, read from the other side: = copies in C++ and aliases in Java, so the Java list grows to four elements because there was only ever one list.
#include <iostream> #include <vector> int main() { std::vector<int> first{1, 2, 3}; std::vector<int> second = first; // a COPY, element by element second.push_back(4); std::cout << "first: " << first.size() << std::endl; std::cout << "second: " << second.size() << std::endl; return 0; }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> first = new ArrayList<>(List.of(1, 2, 3)); List<Integer> second = first; // a second NAME for one list second.add(4); System.out.println("first: " + first.size()); System.out.println("second: " + second.size()); } }
Every habit built on value semantics has to be re-examined. A method that takes a List can modify the caller's list, and the signature cannot say otherwise — there is no const anywhere in the language. The defensive moves are copying at the boundary (new ArrayList<>(other)), handing out List.copyOf(other), which is genuinely immutable, or designing with immutable types so the question does not arise. Only the eight primitives are copied on assignment.
There is no const
🚨 final is not const. It stops the name being reassigned, exactly as int* const does — it says nothing at all about whether the object it points at can change.
#include <iostream> #include <vector> // The signature is a promise: this function will not modify the vector, // and the compiler enforces it at every line of the body. int total(const std::vector<int>& values) { // values.push_back(1); // ← would not compile int sum = 0; for (int value : values) sum += value; return sum; } int main() { std::vector<int> numbers{1, 2, 3}; std::cout << total(numbers) << std::endl; return 0; }
import java.util.List; class Main { // final means the PARAMETER cannot be reassigned. It says nothing // about the list, which this method could clear if it wanted to. static int total(final List<Integer> values) { int sum = 0; for (int value : values) sum += value; return sum; } public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3); // immutable, by choice System.out.println(total(numbers)); } }
There is no way to express "I will not modify this argument" in a Java signature, and that absence shapes the standard library: List.of(...), Map.of(...) and List.copyOf(...) return collections that throw on any modification, because immutability had to be moved into the object when it could not live in the type. Read a Java API defensively — if a method's documentation does not say it leaves your collection alone, it may not.
== compares references, equals compares values
🚨 == on any object compares references, so it answers the question C++ spells &a == &b. Using it on strings is the most common Java bug there is, and it is worse than it looks because it often works — the compiler interns string literals, so "hello" == "hello" is true and gives false confidence.
#include <iostream> #include <string> int main() { std::string first = "hello"; std::string second = "hel"; second += "lo"; std::cout << "equal? " << (first == second) << std::endl; // value std::cout << "same? " << (&first == &second) << std::endl; // address return 0; }
class Main { public static void main(String[] args) { String first = "hello"; String second = "hel"; second += "lo"; System.out.println("equal? " + first.equals(second)); // value System.out.println("same? " + (first == second)); // reference } }
Two obligations follow. Call .equals() for value comparison, always. And when you write a class that has a notion of equality, override equals and hashCode together — a HashMap uses hashCode to find the bucket and equals to search it, so overriding one without the other produces an object that cannot find itself in a map it was just put into. Records, later on this page, generate both for you and are the reason to prefer them.
null, and what replaced it
Optional is the same idea as std::optional and arrived for the same reason, but it is a reference like everything else — so an Optional variable can itself be null, which is a joke the language plays on you exactly once.
#include <iostream> #include <optional> #include <vector> std::optional<int> findEven(const std::vector<int>& values) { for (int value : values) if (value % 2 == 0) return value; return std::nullopt; } int main() { auto found = findEven({1, 3, 5}); std::cout << (found.has_value() ? "found" : "nothing found") << std::endl; return 0; }
import java.util.List; import java.util.Optional; class Main { static Optional<Integer> findEven(List<Integer> values) { for (int value : values) if (value % 2 == 0) return Optional.of(value); return Optional.empty(); } public static void main(String[] args) { Optional<Integer> found = findEven(List.of(1, 3, 5)); System.out.println(found.isPresent() ? "found" : "nothing found"); } }
The real difference is that Java has null underneath everything and cannot get rid of it: any reference may be null, there is no non-nullable type, and NullPointerException is the most common failure in production Java. Modern practice is Optional for return values (never for fields or parameters), Objects.requireNonNull at boundaries, and annotations that a static analyzer checks. Helpful NullPointerException messages, on by default since Java 15, at least now name which reference was null.
Types & Primitives
Eight primitives, and everything else
The eight primitives — byte, short, int, long, float, double, char, boolean — have fixed sizes on every platform, so there is no <cstdint> and no wondering how big an int is here.
#include <cstdint> #include <iostream> #include <limits> int main() { std::int32_t count = 42; double ratio = 0.5; bool enabled = true; char letter = 'x'; std::cout << count << " " << ratio << " " << enabled << " " << letter << std::endl; std::cout << "int32 max: " << std::numeric_limits<std::int32_t>::max() << std::endl; // Unsigned exists, and wraps rather than overflowing: std::uint32_t wrapped = std::numeric_limits<std::uint32_t>::max(); std::cout << "uint32 max + 1: " << wrapped + 1 << std::endl; return 0; }
class Main { public static void main(String[] args) { int count = 42; double ratio = 0.5; boolean enabled = true; char letter = 'x'; // 16 bits, a UTF-16 code unit System.out.println(count + " " + ratio + " " + enabled + " " + letter); System.out.println("int max: " + Integer.MAX_VALUE); // There are NO unsigned types. Overflow wraps, and it is DEFINED. System.out.println("int max + 1: " + (Integer.MAX_VALUE + 1)); } }
Two differences that matter in practice. Signed overflow is defined to wrap in Java, where in C++ it is undefined behavior the optimizer may assume cannot happen — so the Java answer is merely wrong rather than dangerous. And there are no unsigned types at all: use >>> for a logical right shift, and the Integer.toUnsignedString and Long.compareUnsigned family when you have to treat a value as unsigned. A char is 16 bits because Java predates the discovery that Unicode needed more than that.
Boxing, and where it costs you
Generics cannot hold primitives, so List<int> is not expressible and every element of a List<Integer> is a separate heap object with a pointer to it.
#include <iostream> #include <vector> int main() { // A vector of int holds ints — one block, no indirection. std::vector<int> values{1, 2, 3}; long total = 0; for (int value : values) total += value; std::cout << total << std::endl; std::cout << "bytes per element: " << sizeof(int) << std::endl; return 0; }
import java.util.List; class Main { public static void main(String[] args) { // A List cannot hold int. Each element is an Integer OBJECT, // allocated on the heap, and the conversion is automatic. List<Integer> values = List.of(1, 2, 3); long total = 0; for (int value : values) total += value; // unboxed on the way out System.out.println(total); // 🚨 And the trap that follows from it: Integer a = 1000, b = 1000; System.out.println("== on boxed values: " + (a == b)); System.out.println("equals: " + a.equals(b)); } }
🚨 The last two lines are the trap, and it is worse than it looks: Integer values from −128 to 127 are cached and shared, so a == b is true for small numbers and false for large ones. Code that tests boxed integers with == passes every test written with small values. For arithmetic-heavy work use primitive arrays (int[]) and IntStream, which avoid boxing entirely; Project Valhalla is the long-term fix.
Arrays know their length and check it
Every array access is bounds-checked, with no way to opt out and no unchecked operator[] alongside a checked .at(). An array also carries its own length, so the sizeof arithmetic and the decay-to-pointer problem both disappear.
#include <iostream> #include <vector> int main() { int raw[3] = {10, 20, 30}; // raw[5] reads memory that is not yours. No error, no warning at // run time — just whatever happens to be there, or a crash later. std::vector<int> checked{10, 20, 30}; try { std::cout << checked.at(5) << std::endl; // .at() DOES check } catch (const std::out_of_range& error) { std::cout << "caught: " << error.what() << std::endl; } std::cout << "length: " << (sizeof(raw) / sizeof(raw[0])) << std::endl; return 0; }
class Main { public static void main(String[] args) { int[] values = {10, 20, 30}; try { System.out.println(values[5]); // ALWAYS checked } catch (ArrayIndexOutOfBoundsException error) { System.out.println("caught: " + error.getMessage()); } System.out.println("length: " + values.length); } }
The check costs something and the JIT removes most of it — it proves the index is in range for a counted loop and drops the test. What you get for the rest is that a Java program cannot corrupt its own memory: no buffer overruns, no dangling pointers, no undefined behavior. That single property is why Java took the industry it took, and it is the thing a C++ programmer is genuinely being handed in exchange for everything else on this page.
Strings
Strings are immutable
A Java String cannot be modified at all — every operation returns a new one — which is why StringBuilder exists and why concatenating in a loop is the classic performance mistake.
#include <iostream> #include <string> int main() { std::string text = "hello"; text += " world"; // modifies the string in place when it can text[0] = 'H'; // and individual characters are assignable std::cout << text << std::endl; std::cout << text.size() << std::endl; return 0; }
class Main { public static void main(String[] args) { String text = "hello"; text += " world"; // builds a NEW string; the old one is garbage text = "H" + text.substring(1); // no way to assign one character System.out.println(text); System.out.println(text.length()); // For repeated building, use the mutable one: StringBuilder builder = new StringBuilder(); for (int index = 0; index < 3; index++) builder.append(index).append(","); System.out.println(builder); } }
Immutability buys real things: a string can be shared between threads with no synchronization, used as a map key safely, and cached — which is why literals are interned. Two encoding notes for a C++ reader: a Java string is UTF-16 internally (compacted to Latin-1 when it fits), so length() counts code units rather than characters and an emoji counts as two, while std::string::size() counts bytes. Neither counts characters; they are wrong in different ways.
Formatting and joining
Java's format string is printf's, with %d, %s and %.2f — not the brace mini-language std::format adopted from Python. String.join is the one-liner C++ still makes you write a loop for.
#include <format> #include <iostream> #include <sstream> #include <string> #include <vector> int main() { std::string name = "widget"; int count = 7; std::cout << std::format("{} x {} at {:.2f}", count, name, 12.5) << std::endl; std::vector<std::string> parts{"alpha", "beta", "gamma"}; std::string joined; for (std::size_t index = 0; index < parts.size(); ++index) { if (index) joined += ", "; joined += parts[index]; } std::cout << joined << std::endl; return 0; }
import java.util.List; class Main { public static void main(String[] args) { String name = "widget"; int count = 7; System.out.println(String.format("%d x %s at %.2f", count, name, 12.5)); List<String> parts = List.of("alpha", "beta", "gamma"); System.out.println(String.join(", ", parts)); } }
The important difference is when the format string is checked: std::format parses it at compile time, so a mismatched placeholder fails the build, while String.format throws at run time. Java 21 added string templates as a preview and then withdrew them, so as of Java 25 there is still no interpolation — concatenation with + is idiomatic and the compiler turns it into efficient code.
Collections
vector becomes ArrayList
The variable is declared as the interface List and constructed as the implementation ArrayList. That is the idiom throughout the Java collections, and it is what lets a caller be handed a different implementation without changing.
#include <iostream> #include <vector> int main() { std::vector<int> values{3, 1, 4}; values.push_back(1); values.insert(values.begin(), 9); for (int value : values) std::cout << value << ' '; std::cout << std::endl; std::cout << values.size() << " " << values.front() << " " << values.back() << std::endl; return 0; }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> values = new ArrayList<>(List.of(3, 1, 4)); values.add(1); values.add(0, 9); for (int value : values) System.out.print(value + " "); System.out.println(); System.out.println(values.size() + " " + values.get(0) + " " + values.get(values.size() - 1)); } }
There is no operator[], so element access is get and set — a consequence of having no operator overloading at all. LinkedList exists and is almost always the wrong choice; ArrayList is the default the way std::vector is. And unlike a std::vector, adding to an ArrayList cannot invalidate a reference to an element, because the elements are separate objects and only the array of pointers is reallocated.
map becomes HashMap
HashMap is the hash table and matches std::unordered_map; TreeMap is the sorted one that matches std::map, used here so the iteration order is defined.
#include <iostream> #include <map> #include <string> int main() { std::map<std::string, int> counts{{"apple", 2}, {"pear", 5}}; counts["plum"] = 1; std::cout << counts["apple"] << std::endl; std::cout << (counts.contains("fig") ? "true" : "false") << std::endl; for (const auto& [key, value] : counts) { std::cout << key << " " << value << std::endl; } return 0; }
import java.util.Map; import java.util.TreeMap; class Main { public static void main(String[] args) { Map<String, Integer> counts = new TreeMap<>(Map.of("apple", 2, "pear", 5)); counts.put("plum", 1); System.out.println(counts.get("apple")); System.out.println(counts.containsKey("fig")); for (Map.Entry<String, Integer> entry : counts.entrySet()) { System.out.println(entry.getKey() + " " + entry.getValue()); } } }
One trap goes away and another arrives. get on a missing key returns null rather than inserting a default the way std::map::operator[] does — a genuine improvement — but that null then flows into your code, so getOrDefault(key, 0) is usually what you want. Going the other way, merge(key, 1, Integer::sum) and computeIfAbsent do in one call what C++ needs a find-then-insert dance for.
Ranges become streams
Streams and ranges are the same idea reached independently: lazy, composable, evaluated only when a terminal operation asks. The reading order is the same too — filter, then transform, then consume.
#include <iostream> #include <ranges> #include <vector> int main() { std::vector<int> values{1, 2, 3, 4, 5, 6}; auto result = values | std::views::filter([](int value) { return value % 2 == 0; }) | std::views::transform([](int value) { return value * value; }); int total = 0; for (int value : result) total += value; std::cout << total << std::endl; return 0; }
import java.util.List; class Main { public static void main(String[] args) { List<Integer> values = List.of(1, 2, 3, 4, 5, 6); int total = values.stream() .filter(value -> value % 2 == 0) .mapToInt(value -> value * value) .sum(); System.out.println(total); } }
Two Java specifics. mapToInt switches to a primitive stream and avoids boxing every intermediate value, which is worth doing whenever the elements are numbers. And a stream is single-use: consuming it leaves it spent, where a C++ view can be iterated again. .parallel() is the one thing with no ranges equivalent — it spreads the work over the common pool, which is free to write and easy to misuse on work that is too small.
Classes & Inheritance
🚨 Every method is virtual
The default is inverted. In C++ you opt in to dynamic dispatch with virtual; in Java you opt out with final, and every instance method is overridable unless you say otherwise.
#include <format> #include <iostream> struct Shape { // Without the word virtual, report() below calls THIS one even for // a Square — dispatch on the static type. virtual double area() const { return 0; } virtual ~Shape() = default; }; struct Square : Shape { double area() const override { return 4; } }; void report(const Shape& shape) { std::cout << std::format("area: {:.1f}", shape.area()) << std::endl; } int main() { Square square; report(square); return 0; }
class Shape { // No keyword needed: instance methods are virtual by default. double area() { return 0; } } class Square extends Shape { @Override double area() { return 4; } } class Main { static void report(Shape shape) { System.out.println("area: " + shape.area()); } public static void main(String[] args) { report(new Square()); } }
Three consequences. There is no object slicing, because objects are never copied by value. There is no need for a virtual destructor, because there are no destructors. And @Override is not required but should always be written — it makes the compiler check you really are overriding something, which catches a base-class signature change, and it is the same discipline as C++'s override. The cost of virtual-by-default is paid by the JIT, which inlines a call site with only one implementation and undoes that if a second class turns up.
One base class, many interfaces
A class may extend exactly one class and implement any number of interfaces. The default method shows what an interface has gained: it may carry an implementation, so an interface can be extended without breaking everyone who implements it.
#include <iostream> #include <string> // C++ allows multiple inheritance, including of implementation, with // the diamond problem and virtual inheritance to manage it. struct Named { virtual std::string name() const = 0; virtual ~Named() = default; }; struct Sized { virtual int size() const = 0; virtual ~Sized() = default; }; struct Box : Named, Sized { std::string name() const override { return "box"; } int size() const override { return 3; } }; int main() { Box box; std::cout << box.name() << " " << box.size() << std::endl; return 0; }
interface Named { String name(); default String describe() { return "a thing called " + name(); } } interface Sized { int size(); } class Box implements Named, Sized { public String name() { return "box"; } public int size() { return 3; } } class Main { public static void main(String[] args) { Box box = new Box(); System.out.println(box.name() + " " + box.size()); } }
Multiple inheritance of state is what Java refuses, which is what removes the diamond problem and virtual inheritance along with it — an interface has no fields. That is a real loss for a C++ programmer who uses mixins and the curiously recurring template pattern; what replaces them is composition, default methods, and generics with bounded type parameters. Records and sealed interfaces, later on this page, cover much of what an abstract base class was used for.
No operator overloading
Methods with names replace the operators: plus for +, equals for ==, compareTo for <, and toString for the stream insertion operator, which the string concatenation calls for you.
#include <format> #include <iostream> class Money { public: explicit Money(int cents) : cents_(cents) {} Money operator+(const Money& other) const { return Money(cents_ + other.cents_); } bool operator==(const Money& other) const = default; friend std::ostream& operator<<(std::ostream& stream, const Money& money) { return stream << std::format("{}.{:02d}", money.cents_ / 100, money.cents_ % 100); } private: int cents_; }; int main() { std::cout << (Money(150) + Money(275)) << std::endl; return 0; }
class Money { private final int cents; Money(int cents) { this.cents = cents; } Money plus(Money other) { return new Money(cents + other.cents); } @Override public boolean equals(Object other) { return other instanceof Money money && money.cents == cents; } @Override public int hashCode() { return Integer.hashCode(cents); } @Override public String toString() { return cents / 100 + "." + String.format("%02d", cents % 100); } } class Main { public static void main(String[] args) { System.out.println(new Money(150).plus(new Money(275))); } }
The one exception is + on strings, which is built into the language rather than overloadable. This is a deliberate design choice rather than an omission — no operator on a Java expression can be arbitrary user code, so reading a + b never requires knowing the types — and it is the trade a C++ programmer feels most in numeric code, where BigDecimal arithmetic becomes a.multiply(b).add(c). Note equals takes Object and must be paired with hashCode.
Static members and nested classes
A static field is declared and initialized in one place, with no separate definition and no inline needed — the out-of-class definition that C++ requires has no counterpart.
#include <iostream> class Counter { public: Counter() { ++instances; } static int howMany() { return instances; } private: // A static member must be DEFINED outside the class as well, // unless it is inline or constexpr. static inline int instances = 0; }; int main() { Counter first, second; std::cout << Counter::howMany() << std::endl; return 0; }
class Counter { private static int instances = 0; // declared and initialized here Counter() { instances++; } static int howMany() { return instances; } } class Main { public static void main(String[] args) { new Counter(); new Counter(); System.out.println(Counter.howMany()); } }
The distinction worth knowing about nested classes is one C++ does not have: a nested class declared static is what a C++ nested class is, while a non-static inner class holds a hidden reference to an instance of its enclosing class. That hidden reference is a real memory leak in long-lived code, so the rule of thumb is to write static class unless you specifically want the enclosing instance. Static initialization also has a defined order here, so the static initialization order fiasco does not exist.
Generics vs Templates
🚨 Generics are erased
A template is a recipe the compiler stamps out per type; a generic is one method with the type parameter removed before it runs. The last line proves it — a list of integers and a list of strings are the same class at run time.
#include <iostream> #include <vector> #include <string> // One template, and the compiler writes a SEPARATE function for each // type it is used with. The type is present in the generated code. template <typename Element> void describe(const std::vector<Element>& values) { std::cout << "size " << values.size() << ", element size " << sizeof(Element) << std::endl; } int main() { describe(std::vector<int>{1, 2, 3}); describe(std::vector<std::string>{"a"}); return 0; }
import java.util.List; class Main { // ONE method exists at run time. The type parameter is erased — // by the time this runs, Element is just Object. static <Element> void describe(List<Element> values) { System.out.println("size " + values.size()); // sizeof does not exist, and neither does Element at run time: // new Element[10] ← will not compile // if (values instanceof List<String>) ← will not compile } public static void main(String[] args) { describe(List.of(1, 2, 3)); describe(List.of("a")); System.out.println("same class? " + (List.of(1).getClass() == List.of("a").getClass())); } }
Erasure is why several things a C++ programmer reaches for are simply unavailable: no new Element[10], no instanceof List<String>, no specialization for a particular type, no compile-time computation, and no primitive type arguments. What it buys is that generic code compiles once, produces no code bloat, and interoperates with pre-generics code. Passing Class<Element> as an argument is the standard workaround when you genuinely need the type at run time.
Bounds are declared, not discovered
<Element extends Comparable<Element>> is the bound, and it is the whole difference: the constraint is written down, so the generic method is type-checked on its own rather than at every instantiation.
#include <iostream> #include <vector> // The requirement is implicit: this works for any type supporting <. // Break it and the error appears at instantiation, in the body. template <typename Element> Element largest(const std::vector<Element>& values) { Element best = values[0]; for (const Element& value : values) if (best < value) best = value; return best; } int main() { std::cout << largest<int>({3, 9, 2}) << std::endl; return 0; }
import java.util.List; class Main { // The bound is part of the signature, and it is checked when this // METHOD is compiled — not when someone calls it. static <Element extends Comparable<Element>> Element largest(List<Element> values) { Element best = values.get(0); for (Element value : values) if (best.compareTo(value) < 0) best = value; return best; } public static void main(String[] args) { System.out.println(largest(List.of(3, 9, 2))); } }
This is what C++20 concepts added, and Java has had it since generics arrived. The error messages are the visible payoff — a violated bound names the constraint rather than producing a page of template instantiation backtrace. The cost is expressiveness: a bound can only say "is a subtype of", where a C++ concept can require an operation, so there is no way to say "any type with a +". Wildcards (List<? extends Number>) cover variance and are the part everyone finds confusing.
Exceptions
🚨 Checked exceptions
This is the Java idea with no C++ counterpart at all. An exception extending Exception is checked: the signature must declare it, and every caller must either catch it or declare it too, all verified at compile time.
#include <iostream> #include <stdexcept> #include <string> // Nothing in this signature says it can throw. A caller who does not // catch it finds out at run time, possibly in production. int parse(const std::string& text) { if (text.empty()) throw std::invalid_argument("empty"); return std::stoi(text); } int main() { try { std::cout << parse("42") << std::endl; std::cout << parse("") << std::endl; } catch (const std::invalid_argument& error) { std::cout << "caught: " << error.what() << std::endl; } return 0; }
class EmptyInputException extends Exception { EmptyInputException(String message) { super(message); } } class Main { // "throws" is part of the signature and the compiler ENFORCES it: // a caller must catch this or declare it in turn. static int parse(String text) throws EmptyInputException { if (text.isEmpty()) throw new EmptyInputException("empty"); return Integer.parseInt(text); } public static void main(String[] args) { try { System.out.println(parse("42")); System.out.println(parse("")); } catch (EmptyInputException error) { System.out.println("caught: " + error.getMessage()); } } }
C++ tried something adjacent with exception specifications, deprecated them, and kept only noexcept. Java kept its version and the profession has argued about it ever since: it genuinely prevents an unhandled failure mode, and it genuinely produces catch (Exception e) { } blocks written to silence the compiler. Extending RuntimeException instead makes an exception unchecked, which is what most modern libraries do, and what lambdas force — a checked exception cannot escape a standard functional interface.
Catching, and what a stack trace costs
Catching by value versus by reference is not a decision here — an exception is a reference like everything else, so the slicing hazard that makes catch (const std::exception&) mandatory in C++ does not exist.
#include <iostream> #include <stdexcept> int main() { try { throw std::runtime_error("boom"); } catch (const std::runtime_error& error) { // by CONST REFERENCE std::cout << "caught: " << error.what() << std::endl; } catch (...) { std::cout << "caught something" << std::endl; } return 0; }
class Main { public static void main(String[] args) { try { throw new RuntimeException("boom"); } catch (IllegalStateException | IllegalArgumentException error) { System.out.println("multi-catch: " + error.getMessage()); } catch (RuntimeException error) { System.out.println("caught: " + error.getMessage()); // error.printStackTrace() shows every frame, with line numbers. } } }
The genuinely useful addition is the stack trace: every Java exception captures the call stack when it is constructed, with method names and line numbers, and that is most of the difference between debugging a Java production failure and a C++ one. It is not free — filling in the trace is the expensive part of throwing — which is why exceptions are not used for control flow, and why a hot path that throws should reuse a preallocated exception or, better, not throw. Multi-catch with | has no C++ equivalent.
Resource Management
RAII becomes try-with-resources
The two programs print the same four lines. What differs is who is responsible: in C++ the type owns its cleanup and cannot be used wrongly, while in Java the call site must remember to write try (…).
#include <iostream> #include <stdexcept> #include <string> class Held { public: Held(std::string name) : name_(std::move(name)) { std::cout << "opened " << name_ << std::endl; } ~Held() { std::cout << "closed " << name_ << std::endl; } private: std::string name_; }; void work() { Held held("resource"); // the type owns the cleanup throw std::runtime_error("boom"); } int main() { try { work(); } catch (const std::runtime_error& error) { std::cout << "caught: " << error.what() << std::endl; } return 0; }
class Held implements AutoCloseable { private final String name; Held(String name) { this.name = name; System.out.println("opened " + name); } @Override public void close() { System.out.println("closed " + name); } } class Main { static void work() { // The CALLER writes the try. Forget it and close() never runs. try (Held held = new Held("resource")) { throw new RuntimeException("boom"); } } public static void main(String[] args) { try { work(); } catch (RuntimeException error) { System.out.println("caught: " + error.getMessage()); } } }
🚨 That shift is the practical cost of losing destructors, and it is worth being blunt about: a leaked file handle in Java is a code review problem rather than an impossible one. Static analysis and the compiler warning for an unclosed AutoCloseable help. The compensating advantages are real too — resources are closed in reverse order, an exception thrown by close() is suppressed rather than replacing the original (getSuppressed() retrieves it), and there is no equivalent of a destructor throwing during unwinding and terminating the program.
No moves, no copies, no rule of five
The rule of five disappears entirely, along with move semantics, std::move, copy elision and every question about which constructor a particular expression calls.
#include <iostream> #include <string> #include <utility> #include <vector> // Every non-trivial C++ class faces this: copy constructor, copy // assignment, move constructor, move assignment, destructor. class Buffer { public: Buffer(std::size_t size) : data_(size) {} Buffer(const Buffer& other) : data_(other.data_) { std::cout << "copied " << data_.size() << " bytes" << std::endl; } Buffer(Buffer&& other) noexcept : data_(std::move(other.data_)) { std::cout << "moved" << std::endl; } private: std::vector<char> data_; }; int main() { Buffer first(1024); Buffer second = first; // copies Buffer third = std::move(second); // moves return 0; }
class Buffer { private final byte[] data; Buffer(int size) { data = new byte[size]; } // There is nothing else to write. No copy constructor, no move // constructor, no assignment operators, no destructor. } class Main { public static void main(String[] args) { Buffer first = new Buffer(1024); Buffer second = first; // both names refer to ONE buffer Buffer third = second; // and so does this one System.out.println("one buffer, three names: " + (first == third)); } }
This is one of the largest simplifications on the page, and its cost is the one from the References section: since assignment never copies, a class that wants value semantics has to provide them explicitly — a copy constructor you call by name, or a clone() that is widely regarded as a design mistake. The usual answer is to make the class immutable, at which point sharing is safe and copying is pointless, which is why so much modern Java is written that way.
Threads & Memory Model
Threads and locks
synchronized is a block rather than an object, so there is no lock_guard to construct and no way to forget to unlock — the monitor is released when the block exits, including when an exception unwinds through it.
#include <iostream> #include <mutex> #include <thread> int main() { long total = 0; std::mutex guard; auto addMany = [&]() { for (int step = 0; step < 100000; ++step) { std::lock_guard<std::mutex> lock(guard); ++total; } }; std::thread worker(addMany); addMany(); worker.join(); std::cout << total << std::endl; return 0; }
class Main { private static long total = 0; private static final Object guard = new Object(); static void addMany() { for (int step = 0; step < 100000; step++) { synchronized (guard) { // the lock is released automatically total++; } } } public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(Main::addMany); worker.start(); addMany(); worker.join(); System.out.println(total); } }
Java's memory model was the first in a mainstream language and C++11 followed it, so the concepts line up: volatile in Java is roughly std::atomic with sequential consistency (it is not C++'s volatile, which means something else entirely), and java.util.concurrent.atomic matches <atomic>. The important difference is what happens when you get it wrong: a data race in Java produces a stale or torn value, while in C++ it is undefined behavior. Prefer ExecutorService and CompletableFuture over raw threads.
Virtual threads
This is the one place on the page where Java has a capability C++ has no answer to, and it is worth understanding rather than skimming: a virtual thread is a thread that the JVM, not the operating system, schedules.
// C++ has no equivalent in the standard library. A thread is an // operating-system thread costing about a megabyte of stack, so a // server handling 10,000 connections uses an event loop, or a // coroutine framework (Asio, cppcoro) built on C++20 coroutines — // which the standard library ships no scheduler for. #include <iostream> #include <thread> int main() { std::cout << "hardware threads: " << std::thread::hardware_concurrency() << std::endl; return 0; }
// Java 21 made these final. A virtual thread is scheduled by the JVM // onto a small pool of carrier threads, costs a few hundred bytes, // and BLOCKS CHEAPLY — so ordinary blocking code scales to hundreds // of thousands of concurrent tasks with no async/await coloring. // // try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { // for (int index = 0; index < 100_000; index++) { // executor.submit(() -> { doBlockingWork(); return null; }); // } // } class Main { public static void main(String[] args) { System.out.println("available processors: " + Runtime.getRuntime().availableProcessors()); } }
The consequence is that blocking stops being expensive, so the whole async style — futures, callbacks, colored functions, an event loop you must never block — becomes unnecessary for input-and-output-bound work. Write the straightforward blocking code and run it on a virtual thread. C++'s nearest equivalent is a coroutine framework, and the standard library ships coroutines with no scheduler, so every project picks a third-party one.
Records & Pattern Matching
Records are aggregates that work
One line. A record generates the constructor, the accessors, equals, hashCode and toString, and its fields are final — so the equals/hashCode obligation from the References section is discharged for you.
#include <iostream> #include <string> struct Point { int x; int y; // == is defaulted; printing still needs an operator<< of your own. bool operator==(const Point&) const = default; }; int main() { Point first{1, 2}; Point second{1, 2}; std::cout << "equal? " << (first == second) << std::endl; std::cout << "x: " << first.x << std::endl; return 0; }
record Point(int x, int y) { } class Main { public static void main(String[] args) { Point first = new Point(1, 2); Point second = new Point(1, 2); // equals, hashCode, toString and the accessors are all generated. System.out.println("equal? " + first.equals(second)); System.out.println("x: " + first.x()); System.out.println(first); } }
This is the closest Java gets to a C++ aggregate, and for a C++ programmer it is the feature most worth adopting immediately: reach for a record wherever you would write a struct. A compact constructor validates or normalizes arguments without restating them. The limits are that a record is final, cannot extend anything, and cannot have non-final fields — which is the point, because those constraints are what make the generated methods correct.
Sealed types are your variant
sealed lists the permitted implementations, so the compiler knows the closed set — which is what lets the switch be checked for exhaustiveness with no default branch.
#include <format> #include <iostream> #include <string> #include <variant> struct Circle { double radius; }; struct Rect { double width; double height; }; using Shape = std::variant<Circle, Rect>; double area(const Shape& shape) { return std::visit([](const auto& value) -> double { using Type = std::decay_t<decltype(value)>; if constexpr (std::is_same_v<Type, Circle>) return 3.14159 * value.radius * value.radius; else return value.width * value.height; }, shape); } int main() { std::cout << std::format("{:.1f}", area(Rect{2, 3})) << std::endl; return 0; }
sealed interface Shape permits Circle, Rect { } record Circle(double radius) implements Shape { } record Rect(double width, double height) implements Shape { } class Main { static double area(Shape shape) { // No default branch: the compiler knows the permitted set and // fails the build if a case is missing. return switch (shape) { case Circle circle -> 3.14159 * circle.radius() * circle.radius(); case Rect rect -> rect.width() * rect.height(); }; } public static void main(String[] args) { System.out.println(area(new Rect(2, 3))); } }
Sealed interfaces plus records plus switch patterns are Java's answer to std::variant and std::visit, and they are considerably more pleasant to read: no if constexpr, no decay_t, no visitor lambda. Patterns also destructure — case Rect(double width, double height) -> width * height binds the components directly — and take guards with when. For a C++ programmer used to hand-rolled visitors, this is the second feature worth adopting on sight.
Lambdas capture by value only
There is no capture list, and no choice to make: a lambda captures local variables by value, and the compiler requires that they never be reassigned — "effectively final".
#include <iostream> int main() { int factor = 3; auto byValue = [factor](int value) { return value * factor; }; auto byReference = [&factor](int value) { return value * factor; }; factor = 10; std::cout << byValue(5) << " " << byReference(5) << std::endl; return 0; }
import java.util.function.IntUnaryOperator; class Main { public static void main(String[] args) { int factor = 3; // A captured local must be final or effectively final, so this // is the ONLY option — there is no capture list to choose with. IntUnaryOperator scale = value -> value * factor; // factor = 10; ← uncomment and the lambda above stops compiling System.out.println(scale.applyAsInt(5)); } }
That restriction removes the dangling-capture bug that [&] makes easy in C++, at the price of some awkwardness: accumulating into a captured local is impossible, so you use an array of one element, an AtomicInteger, or the stream operation that was the right answer anyway. A Java lambda is also not a distinct type — it is an instance of whatever functional interface the context expects, which is why the parameter is typed IntUnaryOperator rather than auto.
Build & Dependencies
Dependencies are a solved problem here
This is the part of the move that a C++ programmer notices most, and it goes in Java's favor by a wide margin. There is no vcpkg-versus-Conan question because Maven Central won in 2004 and both build tools read it.
// There is no standard package manager, and this is the honest state: // // CMakeLists.txt, plus one of: // vcpkg install fmt (Microsoft's package manager) // conan install . (the other one) // FetchContent_Declare(...) (CMake downloads and builds it) // git submodule add ... (vendor it and build it yourself) // // A dependency is SOURCE that your build compiles with your flags, // because a library built with a different standard version, standard // library or ABI may not link with your code at all. #include <iostream> int main() { std::cout << "dependency handling is not a solved problem here" << std::endl; return 0; }
// One file, one command, and it works the same on every machine: // // <dependency> // <groupId>com.google.guava</groupId> // <artifactId>guava</artifactId> // <version>33.4.0-jre</version> // </dependency> // // mvn package (or: gradle build) // // A dependency is a COMPILED jar from Maven Central, and it works // because bytecode is portable and the JVM defines binary compatibility. class Main { public static void main(String[] args) { System.out.println("dependency handling is a solved problem here"); } }
The reason C++ cannot have this is not neglect: a compiled C++ artifact is only compatible with code built the same way, so distributing binaries means distributing a matrix of them. The JVM defines binary compatibility precisely, so one jar works everywhere. What you give up is the ability to debug into a dependency's source or compile it with your own flags — and, in practice, a dependency tree that is far larger than a C++ project would tolerate, because adding one is so cheap.
Calling C++ from Java
The old answer was JNI: a generated header, a C++ function whose name encodes the Java class and method, and a build step to keep the two in step. The new one is a library lookup, and the C++ side is an ordinary shared library.
// The C++ side of the Foreign Function and Memory API: an ordinary // shared library with a C-compatible interface. No JNI headers, no // generated stubs, no mangled names. // // g++ -std=c++23 -shared -fPIC fastmath.cpp -o libfastmath.so #include <numeric> #include <vector> extern "C" long total(const long* values, long count) { return std::accumulate(values, values + count, 0L); } // extern "C" is what stops the name being mangled to _Z5totalPKll, // so the loader on the other side can find it by the name you wrote.
// Java 22 finalized the Foreign Function and Memory API, which // replaces JNI and needs no C++ glue at all: // // Linker linker = Linker.nativeLinker(); // SymbolLookup library = SymbolLookup.libraryLookup("fastmath", arena); // MethodHandle total = linker.downcallHandle( // library.find("total").orElseThrow(), // FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG)); // // try (Arena arena = Arena.ofConfined()) { // MemorySegment values = arena.allocateFrom(JAVA_LONG, 1L, 2L, 3L); // long sum = (long) total.invoke(values, 3L); // } class Main { public static void main(String[] args) { System.out.println("the boundary is a library lookup, not a code generator"); } }
The pieces map onto ideas a C++ programmer already has: an Arena is a scoped allocator whose close frees everything at once, a MemorySegment is a bounds-checked span, and a FunctionDescriptor is the signature written in a form the linker can use. The rules that carry over from JNI are the important ones — do not hold a pointer past the arena that owns it, and be careful about which thread calls in.

Thank you — anything else?