PONYλM2Modula-2

C.CodeCompared.To/Java

An interactive executable cheatsheet comparing C and Java

C17 (GCC) Java 25
Hello, World & Output
Hello, World
The statement syntax is the same language you already write — braces, semicolons, the same comment markers. What changed is everything around it: no #include, and main traditionally lives inside a class and takes an array of strings rather than argc/argv. (Java 25 also accepts a source file with a bare void main() and no class, but every Java example on this page uses the classic form.)
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
System.out.println appends the newline and knows how to render whatever you hand it; printf takes a format string and believes it. Note also what is missing from the Java side: no return 0, because main returns void and the exit status comes from System.exit or from falling off the end normally. Java does have System.out.printf with many of C's conversion specifiers (%d, %s, %x, %5.2f), for when you want the field widths — though C's %u, %ld and %zu throw an exception there.
Arguments and the Exit Status
C's argv[0] is the program name and argc counts it. Java's args holds only the arguments — the program name is not in it, and the count is args.length. System.exit is the counterpart of returning a value from main.
./main alpha beta
#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { /* argv[0] is the program name, so a bare run has argc == 1. */ (void) argv; printf("%d\n", argc - 1); exit(3); }
java Main alpha beta
class Main { public static void main(String[] args) { // args does NOT include the program name, so this is already the count. System.out.println(args.length); System.exit(3); } }
The off-by-one between argc - 1 and args.length is the kind of difference that survives a mechanical translation and then produces a wrong answer. Note too that System.exit does not run any pending finally blocks — it is the abrupt exit, the same as C's, while returning normally from main lets the JVM shut down in an orderly way.
printf Is Still Here
The format string survives almost intact — %d, %s, %-10s and %5.2f all mean what they mean in C, because Java copied the syntax deliberately.
#include <stdio.h> int main(void) { const char *name = "widget"; int count = 7; printf("%d x %s\n", count, name); printf("%-10s|%5.2f|\n", name, 12.5); return 0; }
class Main { public static void main(String[] args) { String name = "widget"; int count = 7; System.out.printf("%d x %s%n", count, name); System.out.printf("%-10s|%5.2f|%n", name, 12.5); } }
Two differences. %n rather than a backslash-n escape emits the platform line separator, which is what printf in Java is documented to prefer. And the format string is checked at run time, not compile time — a mismatched conversion throws IllegalFormatConversionException rather than being caught by -Wformat, so a C habit of trusting the compiler here does not transfer. String.format is the same machinery returning a string instead of printing.
Types Have Fixed Widths Now
int Is Always 32 Bits
C guarantees only minimum widths — int is at least 16 bits, long at least 32 — which is why stdint.h exists. Java fixes every width by specification: byte 8, short 16, int 32, long 64, on every platform.
#include <stdio.h> #include <stdint.h> int main(void) { /* sizeof(long) is 8 on Linux/macOS 64-bit and 4 on 64-bit Windows. Only the stdint.h names mean the same thing everywhere. */ printf("%zu\n", sizeof(int32_t)); printf("%zu\n", sizeof(int64_t)); printf("%d\n", INT32_MAX); return 0; }
class Main { public static void main(String[] args) { // No sizeof, because there is nothing to ask: the widths are // fixed by the language specification, not by the platform. System.out.println(Integer.BYTES); System.out.println(Long.BYTES); System.out.println(Integer.MAX_VALUE); } }
Java removed sizeof because it removed the question. The cost is the other half of the trade: apart from the 16-bit char, Java has no unsigned integer types. A C unsigned int arriving over JNI has to be carried in a Java int and read with Integer.toUnsignedLong or Integer.compareUnsigned, and a value above 2³¹−1 looks negative until you do.
There Are No Unsigned Types
🚨 Apart from char, an unsigned 16-bit type meant for UTF-16 code units, Java has no unsigned integer types. A value that needed uint32_t needs a long, and one that needed uint64_t needs care.
#include <stdint.h> #include <stdio.h> int main(void) { uint32_t large = 4000000000u; /* fits comfortably */ printf("%u\n", large); /* And the classic: mixing signed with unsigned converts the * signed operand, so this comparison is FALSE. */ printf("%d\n", -1 < 1u); return 0; }
class Main { public static void main(String[] args) { // No uint32_t. The value does not fit in a signed int, so it // needs the next size up. long large = 4000000000L; System.out.println(large); // -1 < 1 is simply true: there is no unsigned int to convert // toward, so the classic C surprise cannot happen. System.out.println(-1 < 1); } }
The gain is that the usual-arithmetic-conversions trap disappears: -1 < 1u is false in C because the signed operand converts to unsigned — GCC's -Wextra warns about comparing the two, but it compiles — and there is no way to write that mistake in Java. The cost shows up at a byte boundary — a byte is signed, so a byte read from a file arrives as −1 rather than 255, and value & 0xFF is the idiom you will write constantly. Integer.toUnsignedString, Long.compareUnsigned and Integer.divideUnsigned exist for when the bits really are unsigned.
A Java char Is 16 Bits
C's char is a byte, and whether it is signed is implementation-defined. Java's char is an unsigned 16-bit UTF-16 code unit — a different thing wearing the same name, and one of the sharpest traps when porting.
#include <stdio.h> int main(void) { char letter = 'A'; printf("%zu\n", sizeof(letter)); /* 1 — a byte */ printf("%d\n", letter); /* 65 */ return 0; }
class Main { public static void main(String[] args) { char letter = 'A'; // 2 bytes, not 1: a UTF-16 code unit. System.out.println(Character.BYTES); System.out.println((int) letter); } }
The consequence bites in two directions. A Java char[] is not a byte buffer, so handing one to C as a char* is wrong by a factor of two — byte[] is the counterpart. And because a UTF-16 unit cannot hold every code point, characters outside the Basic Multilingual Plane take two chars, which is why String.length() counts code units rather than characters.
No sizeof, and No typedef
Neither sizeof nor typedef exists. Every numeric primitive has one size given by the specification (boolean's size is left to the JVM), and an object's layout — padding, header, alignment — belongs to the JVM.
#include <stdio.h> typedef struct { int x; int y; } Point; int main(void) { printf("%zu %zu %zu\n", sizeof(int), sizeof(long), sizeof(Point)); /* Layout is yours to know about: padding, alignment, and a struct * whose size depends on the platform. */ return 0; }
class Point { int x; int y; } class Main { public static void main(String[] args) { // There is no sizeof, because every numeric primitive has one // fixed size and an object's layout is the JVM's business. System.out.println(Integer.BYTES + " " + Long.BYTES); // typedef is gone too: a class or interface IS the name. Point point = new Point(); point.x = 1; System.out.println(point.x); } }
That is mostly a relief and occasionally a loss. You cannot compute a buffer size, cannot control padding, and cannot know how much an object costs without a profiler — which matters when you are used to laying out a struct deliberately. Integer.BYTES and friends give you the primitive widths as constants. The nearest thing to control over layout is choosing primitive arrays over object graphs, and Project Valhalla is the long-term answer.
References Instead of Pointers
No Arithmetic, No Address-Of
A reference can be followed and nothing else — no arithmetic, no &, no cast to an integer, and no way to ask where an object lives. (Memory outside the heap is another matter: the Foreign Function and Memory API's MemorySegment.address() hands back a raw address.)
#include <stdio.h> int main(void) { int values[] = {10, 20, 30, 40}; int *walker = values; int total = 0; while (walker < values + 4) { total += *walker; walker++; /* moves sizeof(int) bytes */ } printf("%d\n", total); printf("%td\n", (values + 3) - values); return 0; }
class Main { public static void main(String[] args) { int[] values = {10, 20, 30, 40}; // No pointer to move, so the walk is by index. There is no &, // no *, and no way in ordinary Java code to hold an address. int total = 0; for (int index = 0; index < values.length; index++) { total += values[index]; } System.out.println(total); System.out.println(3); } }
The reason is the collector: it moves objects to compact the heap and rewrites the references it knows about, which it could only do if no program can hold a raw address. That single constraint is what buys the whole safety story, and it is why every C idiom built on address arithmetic — walking a buffer with a moving pointer, packing a tagged pointer, casting a struct to char* to serialize it — needs rethinking rather than translating. ByteBuffer and the Foreign Function and Memory API are where that work goes.
No Out Parameters
With no address-of operator there is no way to pass somewhere to write, so a function that produces two values returns an object holding both. A record makes that one line.
#include <stdio.h> /* The C way to return two things: one through the return value, * one through a pointer the caller supplies. */ static int divide(int numerator, int denominator, int *remainder) { *remainder = numerator % denominator; return numerator / denominator; } int main(void) { int remainder; int quotient = divide(17, 5, &remainder); printf("%d %d\n", quotient, remainder); return 0; }
record Division(int quotient, int remainder) { } class Main { static Division divide(int numerator, int denominator) { return new Division(numerator / denominator, numerator % denominator); } public static void main(String[] args) { Division result = divide(17, 5); System.out.println(result.quotient() + " " + result.remainder()); } }
A record generates its constructor, accessors, equals, hashCode and toString, so the C-style pattern of an out parameter — which exists mostly to avoid the cost of returning a struct — becomes cheaper to write than it was in C. Note that Java can modify what a reference points at, so a method taking an array or a list can fill it in; what it cannot do is replace the caller's variable, which is what int ** is for in C.
A Reference Is Not a Pointer
Java has no *, no &, and no pointer arithmetic. A variable of class type holds a reference, which behaves like a pointer you can only follow — never offset, never compare by address for ordering, and never turn into an integer.
#include <stdio.h> typedef struct { int value; } Box; static void modify(Box *box) { box->value = 99; } int main(void) { Box box = { 1 }; modify(&box); /* the address is taken EXPLICITLY */ printf("%d\n", box.value); Box *cursor = &box; printf("%d\n", cursor->value); return 0; }
class Box { int value; } class Main { static void modify(Box box) { box.value = 99; // no ->, and no address was taken } public static void main(String[] args) { Box box = new Box(); box.value = 1; modify(box); // the reference is passed by value System.out.println(box.value); Box cursor = box; // another name for the same object System.out.println(cursor.value); } }
Java is pass-by-value throughout, and the value being passed is the reference — which is why modify can change the object's field but could not make the caller's variable point at a different object. That is exactly C's Box * versus Box ** distinction, with the second one simply unavailable. The absence of pointer arithmetic is what lets the collector move objects and rewrite the references.
NULL Is Checked For You
Following a null pointer in C is undefined behavior — usually a segmentation fault delivered by the hardware, with no message and no line number. Java performs the comparison on every dereference and throws an exception naming the line.
#include <stdio.h> typedef struct { int value; } Box; int main(void) { Box *box = NULL; /* This check is the one the JVM performs for you. */ if (box == NULL) { printf("null\n"); } else { printf("%d\n", box->value); } return 0; }
class Box { int value; } class Main { public static void main(String[] args) { Box box = null; try { System.out.println(box.value); } catch (NullPointerException error) { // The JVM did the comparison; this is what it found. System.out.println("null"); } } }
A NullPointerException is the runtime having compared against zero and lost — which is why it arrives as a catchable object with a stack trace rather than as a crash. Since Java 15 the message also names the expression that was null (a local variable appears as <local1> unless the class was compiled with -g), which is the part C can never offer: by the time the hardware faults, the information about which subexpression it was has already been thrown away.
Arrays Know Their Length
Two Dimensions Are Arrays of Arrays
A C two-dimensional array is one rectangular block; a Java one is an array of references to rows, each separately allocated and possibly a different length.
#include <stdio.h> int main(void) { /* ONE contiguous block of 6 ints. The row length is part of * the type, which is why a function taking it needs the second * dimension spelled out. */ int grid[2][3] = {{1, 2, 3}, {4, 5, 6}}; printf("%d\n", grid[1][2]); printf("%zu\n", sizeof(grid) / sizeof(grid[0][0])); int *flat = &grid[0][0]; printf("%d\n", flat[5]); /* the memory really is one run */ return 0; }
class Main { public static void main(String[] args) { // An array OF ARRAYS: one object holding two references to // separately allocated rows. int[][] grid = {{1, 2, 3}, {4, 5, 6}}; System.out.println(grid[1][2]); System.out.println(grid.length * grid[0].length); // The rows may be different lengths, which a C rectangular // array cannot be: int[][] ragged = new int[2][]; ragged[0] = new int[]{1}; ragged[1] = new int[]{1, 2, 3}; System.out.println(ragged[1].length); } }
The performance consequence is the one to carry: grid[1][2] in C is one multiplication and one load, and in Java it is two loads with a bounds check each, into memory that may be anywhere. For numeric work the usual answer is a flat int[] with your own index arithmetic — which is what C was doing for you. What Java gains is ragged arrays, and that a row can be passed on its own because it carries its own length.
The Length Travels With the Array
A C array name decays to a bare pointer the moment it is passed anywhere, losing its size — which is why every C function taking an array takes a count beside it. A Java array is an object with a length field that goes wherever it goes.
#include <stdio.h> /* The count MUST be passed: 'values' is a pointer here, and sizeof(values) would give the size of a pointer. */ static int sum(const int *values, size_t count) { int total = 0; for (size_t index = 0; index < count; index++) { total += values[index]; } return total; } int main(void) { int numbers[3] = { 10, 20, 12 }; size_t count = sizeof(numbers) / sizeof(numbers[0]); /* works only HERE */ printf("%d\n", sum(numbers, count)); printf("%zu\n", count); return 0; }
class Main { static int sum(int[] values) { int total = 0; for (int value : values) { // no count parameter needed total += value; } return total; } public static void main(String[] args) { int[] numbers = { 10, 20, 12 }; System.out.println(sum(numbers)); System.out.println(numbers.length); } }
The sizeof(numbers) / sizeof(numbers[0]) idiom is correct in main and would count 2 inside sum — a pointer's 8 bytes over an int's 4 — because there the name is a pointer. GCC warns that such a division does not compute the number of array elements, but builds it anyway. That is the single most common source of buffer overruns in C, and Java removed the possibility by making the length part of the object. The for (int value : values) form exists because the loop no longer needs an index to stay in bounds.
Every Index Is Checked
C does not check, and reading past the end is undefined behavior — usually it simply returns the next bytes, which is what makes the bug so hard to find. Java checks every access and throws with both numbers in the message.
#include <stdio.h> int main(void) { int numbers[3] = { 10, 20, 30 }; size_t count = 3; size_t index = 5; /* Omit this and numbers[5] reads past the array with nothing to object. */ if (index >= count) { printf("index %zu out of bounds for length %zu\n", index, count); } else { printf("%d\n", numbers[index]); } return 0; }
class Main { public static void main(String[] args) { int[] numbers = { 10, 20, 30 }; int index = 5; try { System.out.println(numbers[index]); } catch (ArrayIndexOutOfBoundsException error) { // The JVM made the comparison; this is its own message. System.out.println(error.getMessage()); } } }
The C check had to be written, and had to use an unsigned type so a "negative" index wraps to something enormous and still fails. Java's check is not free either — it is a compare and a branch on every access — but the JIT removes most of them by proving the bound once, which is why the enhanced for loop is usually faster than indexing.
Strings Are Objects, Not char*
Building a String
StringBuilder grows as needed, so the buffer size, the running length and the truncation arithmetic all disappear — along with the overflow they invite.
#include <stdio.h> #include <string.h> int main(void) { /* The buffer, its size, and the truncation are all yours. */ char buffer[64]; buffer[0] = ''; for (int index = 0; index < 3; index++) { char piece[16]; snprintf(piece, sizeof(piece), "%d,", index); strncat(buffer, piece, sizeof(buffer) - strlen(buffer) - 1); } printf("%s\n", buffer); return 0; }
class Main { public static void main(String[] args) { // No buffer, no size, no truncation to get right. StringBuilder builder = new StringBuilder(); for (int index = 0; index < 3; index++) { builder.append(index).append(","); } System.out.println(builder); } }
The rule that goes with it: a String is immutable, so result += piece in a loop allocates a new string every iteration, which is quadratic. That is the one performance mistake a C programmer is likely to make here, and it is the reverse of the C instinct — in C you would reuse a buffer, and in Java you use StringBuilder for exactly the same reason. Outside a loop, + is compiled into efficient code and is the readable choice.
Counted, Immutable, and Not NUL-Terminated
A C string is an address whose end is the first zero byte — nothing records the length and every operation rediscovers it. A Java String stores its length, is immutable, and may contain \0 as ordinary data.
#include <stdio.h> #include <string.h> int main(void) { const char *message = "Hello, World!"; printf("%zu\n", strlen(message)); /* a LOOP looking for the zero byte */ char window[6]; memcpy(window, message, 5); window[5] = '\0'; /* a substring needs its own buffer */ printf("%s\n", window); const char with_zero[] = "ab\0cd"; /* ends at the zero: length 2 */ printf("%zu\n", strlen(with_zero)); return 0; }
class Main { public static void main(String[] args) { String message = "Hello, World!"; System.out.println(message.length()); // a stored field System.out.println(message.substring(0, 5)); String withZero = "ab\0cd"; // the zero is DATA: length 5 System.out.println(withZero.length()); } }
C reports 2 where Java reports 5, and that disagreement is the lesson: a zero byte terminates one and is data in the other. Immutability is the other half — substring hands back a new object rather than writing into a buffer you sized, so there is no + 1 to forget and no overflow to cause. The cost is that building a string in a loop allocates repeatedly, which is what StringBuilder exists to avoid.
Comparing Strings, and the == Trap
C has no == for strings at all — comparing two char* compares addresses, so you call strcmp. Java's == compiles fine on strings and compares references, which is the same mistake with none of the friction.
#include <stdio.h> #include <string.h> int main(void) { char first[] = "hello"; char second[] = "hello"; /* Comparing the pointers compares ADDRESSES — always different here. */ printf("%d\n", first == second); /* strcmp compares the contents. */ printf("%d\n", strcmp(first, second) == 0); return 0; }
class Main { public static void main(String[] args) { String first = new String("hello"); String second = new String("hello"); // == compares REFERENCES, exactly like comparing two char* in C. System.out.println(first == second); // equals compares the contents. System.out.println(first.equals(second)); } }
This is the one place where Java's syntax being C-like is actively dangerous: first == second is a type error nowhere and wrong nearly everywhere. It is made worse by string interning — two identical literals are the same object, so "hello" == "hello" is true and the bug hides until a string arrives from input. The new String(...) above defeats interning to make the comparison honest.
new, And No free
What the Collector Removes, and What It Costs
A cycle is the case that shows what tracing buys. Nothing counts references, so two objects pointing at each other are collectible the moment nothing outside can reach them.
#include <stdio.h> #include <stdlib.h> struct Node { struct Node *next; }; int main(void) { /* A cycle. Reference counting would never free these; with * malloc/free it is simply your job to work out the order. */ struct Node *first = malloc(sizeof(struct Node)); struct Node *second = malloc(sizeof(struct Node)); if (first == NULL || second == NULL) return 1; first->next = second; second->next = first; free(first); free(second); printf("freed by hand\n"); return 0; }
class Node { Node next; } class Main { public static void main(String[] args) { Node first = new Node(); Node second = new Node(); first.next = second; second.next = first; // The collector traces from the roots rather than counting, so // a cycle is not special: once main stops referring to these, // both are unreachable and both are collectible. System.out.println("no free to write"); } }
What goes away: leaks from a forgotten free, use-after-free, double-free, and the design work of deciding who owns what. What arrives: you cannot say when memory is reclaimed, the heap is several times the size of the live data, and a pause can happen at an awkward moment — though modern collectors such as ZGC keep those under a millisecond. Java can still leak in the ordinary sense: an object reachable from a long-lived collection is never collected, and that is the leak you will actually meet.
goto cleanup Becomes try-with-resources
The goto cleanup idiom — one label at the bottom, every error path jumping to it — becomes a block whose resources are closed when it ends.
#include <stdio.h> #include <stdlib.h> static int work(void) { int status = -1; char *buffer = malloc(16); if (buffer == NULL) return -1; printf("acquired\n"); if (1) goto cleanup; /* stand-in for a failing step */ status = 0; cleanup: free(buffer); printf("released\n"); return status; } int main(void) { work(); return 0; }
class Held implements AutoCloseable { Held() { System.out.println("acquired"); } @Override public void close() { System.out.println("released"); } } class Main { static void work() { // The resource is released at the end of the block, on every // path out — including an exception. One label, no goto. try (Held held = new Held()) { // a failing step would throw here } } public static void main(String[] args) { work(); } }
Memory is not what this is for, since the collector handles that; it is for the things a collector cannot help with, which are exactly the things goto cleanup released in C: files, sockets and native handles. Anything implementing AutoCloseable works — which a Lock is not, so a lock is released with synchronized or in a finally. Resources close in reverse order of acquisition, and an exception thrown by close() is suppressed rather than replacing the original — getSuppressed() retrieves it, which is better than C, where a failing cleanup usually just gets ignored.
new Versus malloc and free
malloc returns an address or NULL, and reclaiming it is a line you write. new cannot fail in the same way — it throws OutOfMemoryError rather than returning null — and nothing reclaims it explicitly.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t count = 4; int *buffer = malloc(count * sizeof(int)); if (buffer == NULL) return 1; /* allocation can FAIL, visibly */ for (size_t index = 0; index < count; index++) { buffer[index] = (int) index * 10; } int total = 0; for (size_t index = 0; index < count; index++) total += buffer[index]; printf("%d\n", total); free(buffer); /* and someone must remember this */ return 0; }
class Main { public static void main(String[] args) { int[] buffer = new int[4]; // zeroed, and cannot return null for (int index = 0; index < buffer.length; index++) { buffer[index] = index * 10; } int total = 0; for (int value : buffer) total += value; System.out.println(total); // Nothing is freed. The collector decides when. } }
Three things vanish at once: the failure check, the free, and the uninitialized memory — new int[4] is guaranteed zeroed while malloc gives you whatever was there. What you give up is knowing when memory is released. That imprecision is why Java needed try-with-resources for things that are not memory: the collector handles the heap but has nothing to say about a file handle or a socket.
A Struct With Functions Is a Class
A Function Pointer Becomes an Interface
The callback keeps its shape and loses its casts: a comparator receives the element type rather than const void *, so nothing has to be cast back and nothing can be cast wrongly.
#include <stdio.h> #include <stdlib.h> static int descending(const void *left, const void *right) { int a = *(const int *)left; int b = *(const int *)right; return (a < b) - (a > b); } int main(void) { int values[] = {30, 10, 20}; qsort(values, 3, sizeof(values[0]), descending); for (int index = 0; index < 3; index++) printf("%d ", values[index]); printf("\n"); return 0; }
import java.util.Arrays; import java.util.Comparator; class Main { public static void main(String[] args) { Integer[] values = {30, 10, 20}; // The comparator is typed, so no void* and no cast — and a // lambda replaces the named function. Comparator<Integer> descending = (left, right) -> right - left; Arrays.sort(values, descending); for (int value : values) System.out.print(value + " "); System.out.println(); } }
Two things a C programmer gains here. A lambda captures surrounding values, so the void *user_data parameter that every C callback API carries is unnecessary. And the comparator cannot be mismatched to the array, because the types are checked — where qsort will happily accept a comparator written for a different type and produce nonsense. What you give up is that Integer[] boxes; sorting an int[] uses the primitive Arrays.sort, which takes no comparator at all.
static Means Something Else Here
🚨 The same keyword, a different idea. C's two meanings are file-scope visibility and a local that survives between calls; Java's central meaning is "belongs to the class, not to an instance" — the same idea behind its other uses, a static nested class, a static { } initializer and import static.
#include <stdio.h> /* At file scope: not visible outside this .c file — the closest * thing C has to private. */ static int twice(int value) { return value * 2; } static int call_count(void) { /* Inside a function: keeps its value between calls. */ static int calls = 0; return ++calls; } int main(void) { int first = call_count(); int second = call_count(); printf("%d %d %d\n", twice(21), first, second); return 0; }
class Main { // Belongs to the CLASS rather than to an instance. Neither C // meaning survives. private static int calls = 0; private static int twice(int value) { return value * 2; } private static int callCount() { return ++calls; } public static void main(String[] args) { System.out.println(twice(21) + " " + callCount() + " " + callCount()); } }
What replaces the C meanings: private is the visibility control, enforced by the compiler rather than by linkage, and a value that must persist between calls becomes a field. Java also has no global namespace to collide in — a class is always inside a package (the unnamed package, when the file declares none, as every example here does), so the prefixing convention that gives C png_read_info and sqlite3_open is unnecessary. Note that a static field is shared by every thread, which makes it the usual home of a threading bug.
The this You Passed By Hand
The C idiom is a struct plus functions whose first parameter is a pointer to it. A class is that pattern with the first parameter supplied automatically — and the functions moved inside the braces.
#include <stdio.h> typedef struct { long total; } Counter; /* The first parameter IS "this". */ static void counter_add(Counter *counter, long amount) { counter->total += amount; } static long counter_doubled(const Counter *counter) { return counter->total * 2; } int main(void) { Counter counter = { 0 }; counter_add(&counter, 5); counter_add(&counter, 16); printf("%ld\n", counter.total); printf("%ld\n", counter_doubled(&counter)); return 0; }
class Counter { private long total; // No first parameter: "this" is supplied. void add(long amount) { total += amount; } long doubled() { return total * 2; } long total() { return total; } } class Main { public static void main(String[] args) { Counter counter = new Counter(); counter.add(5); counter.add(16); System.out.println(counter.total()); System.out.println(counter.doubled()); } }
The mechanical change is small — counter_add(&counter, 5) becomes counter.add(5) — and the real addition is private. In C the struct's fields are visible to every file that includes the header, and the convention that only counter_* functions touch them is enforced by nothing — unless the header declares only an opaque struct Counter; and hands out pointers, the one way C has of hiding fields. Java made that convention a compile error, which is most of what "encapsulation" buys.
The Preprocessor Is Gone
No #ifdef, and What Replaces It
Conditional compilation has almost no equivalent, and the reason is the point of the platform: one class file runs on every machine, so the platform cannot be decided when it is compiled.
#include <stdio.h> #define MAX_ITEMS 100 int main(void) { printf("max: %d\n", MAX_ITEMS); #ifdef __linux__ printf("compiled for Linux\n"); #else printf("compiled for something else\n"); #endif return 0; }
class Main { // A constant is a field, type-checked and visible in a debugger. static final int MAX_ITEMS = 100; public static void main(String[] args) { System.out.println("max: " + MAX_ITEMS); // The platform is a run-time question, because one build runs // everywhere. String os = System.getProperty("os.name"); System.out.println("running on " + (os == null ? "unknown" : "a machine")); } }
Platform differences become run-time branches (System.getProperty), and what would have been a build flag becomes configuration. The related loss is debug builds. The one form of conditional compilation Java has is an if on a static final boolean constant: javac leaves the dead branch out of the class file, so if (DEBUG) with DEBUG = false compiles logging away — but only by editing the source, never from a build flag. Logging frameworks therefore use a run-time level check — and if (logger.isDebugEnabled()) exists precisely to avoid building a message that will be discarded. assert stays in the class file and is skipped at run time: it is disabled by default, and java -ea turns it on.
No #define, No #include, No Header
Java has no preprocessor at all. A constant is a static final field with a type; there is no textual substitution, so a "macro" cannot capture the wrong expression or evaluate its argument twice. import names a class the compiler already knows how to find rather than pasting a file in.
#include <stdio.h> /* Textual substitution: the argument is evaluated TWICE. */ #define SQUARE(x) ((x) * (x)) #define LIMIT 10 int main(void) { int counter = 3; printf("%d\n", SQUARE(counter)); /* The classic macro trap: counter is incremented twice. */ printf("%d\n", SQUARE(counter++)); printf("%d\n", counter); printf("%d\n", LIMIT); return 0; }
class Main { // A typed constant, not a text substitution. static final int LIMIT = 10; // A method: its argument is evaluated exactly once. static int square(int value) { return value * value; } public static void main(String[] args) { int counter = 3; System.out.println(square(counter)); // Evaluated once, so counter ends at 4 rather than 5. System.out.println(square(counter++)); System.out.println(counter); System.out.println(LIMIT); } }
The two columns disagree on the last two numbers, and that is the point: SQUARE(counter++) expands to ((counter++) * (counter++)), which increments twice and is in fact undefined behavior for reading and modifying the same object twice without a sequence point. A method cannot do that. Losing #ifdef also means Java has almost no conditional compilation — only an if on a static final boolean, which javac compiles away — so platform differences are handled at run time instead, which is what "write once, run anywhere" is really describing.
Exceptions Instead of Return Codes
The Compiler Makes You Handle It
The largest single difference in error handling, and it runs in Java's favor: a checked exception is part of the method's declaration (though not of its signature: two methods differing only in throws clash), and the compiler refuses to build a caller that neither catches nor declares it.
#include <stdio.h> #include <stdlib.h> static int parse(const char *text, int *out) { if (text[0] == '') return -1; *out = atoi(text); return 0; } int main(void) { int value = 0; /* Nothing forces this check. Delete the if and it still compiles, * still runs, and carries on with an untouched variable. */ if (parse("", &value) != 0) { printf("caught: empty\n"); } return 0; }
class EmptyInputException extends Exception { EmptyInputException(String message) { super(message); } } class Main { // "throws" is part of the DECLARATION 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("")); } catch (EmptyInputException error) { System.out.println("caught: " + error.getMessage()); } } }
That directly addresses the biggest source of real C bugs — an ignored return value. Nothing in C makes you check what parse returned, which is why -Wall -Wextra and static analysis are not optional there. The counter-argument, argued about for twenty years, is that checked exceptions also produce empty catch blocks written to satisfy the compiler. Extending RuntimeException makes an exception unchecked, which is what most modern libraries do.
A Failure Says Where It Came From
A Java exception captures the call stack where it was constructed, by default — method names and line numbers — and carries it to whoever catches it.
#include <stdio.h> /* A segfault gives you a signal and a core file, if one is enabled. * There is no stack trace unless a debugger or a sanitizer build * produces one — which is why -g and -fsanitize=address matter. */ int main(void) { int *pointer = NULL; if (pointer == NULL) { printf("would have crashed with no trace\n"); return 0; } return 0; }
class Main { static int depth(int level) { if (level == 0) throw new IllegalStateException("bottom"); return depth(level - 1); } public static void main(String[] args) { try { depth(3); } catch (IllegalStateException error) { // Every frame, with method names and line numbers. StackTraceElement[] frames = error.getStackTrace(); System.out.println(error.getMessage() + " at depth " + frames.length + " frames"); } } }
This is most of the difference between diagnosing a production failure in the two languages. A C program that dies gives you a signal and, if you configured it, a core file; a Java one gives you the frames in the log. 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.
Throwing Versus Returning a Status
C signals failure with a sentinel return plus a global errno that the next call may overwrite. Java throws an object that unwinds the stack, runs every finally on the way, and cannot be ignored by accident.
#include <stdio.h> #include <stdlib.h> #include <errno.h> int main(void) { const char *text = "123"; char *end = NULL; errno = 0; /* MUST be cleared first */ long value = strtol(text, &end, 10); if (errno != 0 || end == text || *end != '\0') { printf("error: bad number\n"); return 1; } printf("%ld\n", value); const char *bad = "nope"; char *bad_end = NULL; errno = 0; long other = strtol(bad, &bad_end, 10); (void) other; /* No errno is set for "not a number" — the only signal is that the end pointer did not move. This is the trap. */ if (bad_end == bad || *bad_end != '\0') { printf("error: bad number\n"); } return 0; }
class Main { public static void main(String[] args) { System.out.println(Integer.parseInt("123")); try { Integer.parseInt("nope"); } catch (NumberFormatException error) { System.out.println("error: bad number"); } } }
The C column is not exaggerated: strtol genuinely reports "not a number at all" only by leaving the end pointer where it started, and genuinely requires clearing errno first because it is not cleared on success. What Java adds beyond brevity is that the failure carries information — a type, a message, and the stack — and that a finally block runs on the way out, which C's goto cleanup idiom achieves only if every early return remembers to jump.
Collections Instead of Hand-Rolling
The Linked List You Do Not Write
Twenty-eight lines against twelve, and the twelve do not leak. This is the row that shows the practical size of having a standard library: C has no list, no map, no set, and no growable array.
#include <stdio.h> #include <stdlib.h> struct Node { int value; struct Node *next; }; int main(void) { struct Node *head = NULL; for (int index = 3; index >= 1; index--) { struct Node *node = malloc(sizeof(struct Node)); if (node == NULL) return 1; node->value = index; node->next = head; head = node; } for (struct Node *walker = head; walker != NULL; walker = walker->next) { printf("%d ", walker->value); } printf("\n"); while (head != NULL) { /* and free it, node by node */ struct Node *next = head->next; free(head); head = next; } return 0; }
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<Integer> values = new ArrayList<>(); for (int index = 1; index <= 3; index++) values.add(index); for (int value : values) System.out.print(value + " "); System.out.println(); } }
The habit worth changing along with it: reach for ArrayList rather than LinkedList. A C programmer's instinct that a linked list is the natural sequence comes from it being the one that is easy to hand-roll, but a growable array wins on nearly every real workload because it is contiguous — the same cache argument that makes a C array fast. LinkedList exists in Java and is almost always the wrong choice.
Generics Instead of void*
void * is C's whole answer to generic code: the caller casts it back to what they believe is there, and nothing checks the belief.
#include <stdio.h> #include <string.h> /* A container that holds "anything" holds void*, the element size is * a runtime argument, and the caller casts it back on faith. */ static void *first_element(void *array, size_t element_size) { (void)element_size; return array; } int main(void) { const char *names[] = {"alpha", "beta"}; char **found = first_element(names, sizeof(names[0])); printf("%s\n", *found); return 0; }
import java.util.List; class Main { // The element type is a parameter, and the compiler checks it. static <Element> Element firstElement(List<Element> values) { return values.get(0); } public static void main(String[] args) { System.out.println(firstElement(List.of("alpha", "beta"))); } }
Generics check it at compile time, so a container cannot be read as the wrong type — unless a raw type or an unchecked cast opts out, and then the mistake surfaces as a ClassCastException at the read. The caveat a C programmer should know is that Java generics are erased — List<String> and List<Integer> are the same class at run time, there is no new Element[10], and a primitive cannot be a type argument, so List<Integer> costs an object per element. That is closer to void * underneath than it looks; what changed is that the compiler now proves the casts are right, for code that does not opt out.
A Hash Map in the Library
C's standard library has no map, no growable array, and no set, so a small table is usually an array of pairs and a loop. Java ships them, and the growable one resizes itself.
#include <stdio.h> #include <string.h> typedef struct { const char *key; long value; } Entry; /* Returns 1 and writes *out when found, 0 when not. */ static int lookup(const Entry *entries, size_t count, const char *key, long *out) { for (size_t index = 0; index < count; index++) { if (strcmp(entries[index].key, key) == 0) { *out = entries[index].value; return 1; } } return 0; } int main(void) { Entry ages[3] = { { "alice", 30 }, { "bob", 25 }, { "carol", 41 } }; size_t count = 3; long age = 0; if (lookup(ages, count, "bob", &age)) printf("bob %ld\n", age); long ignored = 0; if (!lookup(ages, count, "dave", &ignored)) printf("dave not found\n"); printf("%zu\n", count); return 0; }
import java.util.HashMap; import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> ages = new HashMap<>(); ages.put("alice", 30); ages.put("bob", 25); ages.put("carol", 41); Integer age = ages.get("bob"); if (age != null) System.out.println("bob " + age); if (!ages.containsKey("dave")) System.out.println("dave not found"); System.out.println(ages.size()); } }
The C version is a linear scan — fine for three entries, wrong for three thousand — and deciding when to graduate to a real hash table is a judgment Java made once, for everyone. Note the shape that survives translation: get returning null for a missing key is the same "found/not found" signal as the C function's return value, and it exists for the same reason, because a zero value is otherwise indistinguishable from a missing one.
Undefined Behavior Is Gone
Nothing Is Uninitialized
Fields and arrays are zeroed by specification, and a local that might not have been assigned is a compile error rather than a read of whatever was there.
#include <stdio.h> #include <stdlib.h> static int global; /* zeroed */ int main(void) { int local = 0; /* NOT zeroed unless you write this */ int *heap = calloc(3, sizeof(int)); /* calloc zeroes; malloc does not */ if (heap == NULL) return 1; printf("%d %d %d\n", global, local, heap[0]); free(heap); return 0; }
class Main { static int field; // 0, guaranteed public static void main(String[] args) { int[] values = new int[3]; // every element 0, guaranteed int local; // System.out.println(local); // will not compile: definite assignment local = 0; System.out.println(field + " " + local + " " + values[0]); } }
Definite assignment analysis is the part with no C counterpart: the compiler proves every path assigns the variable before it is read, so the class of bug where a value depends on stack contents cannot occur. That closes one of the favorite doors for security researchers, since uninitialized memory frequently holds something interesting from a previous allocation. The C habit worth keeping anyway — initialize at the point of declaration — is still good style here.
Evaluation Order Is Specified
Java specifies left-to-right evaluation of operands and arguments. C leaves the order unspecified, so the same expression can produce different answers on different compilers.
#include <stdio.h> static int calls = 0; static int next_value(void) { return ++calls; } int main(void) { /* printf("%d %d\n", next_value(), next_value()); may print "1 2" * or "2 1": the order a call's arguments are evaluated in is * unspecified, and GCC and clang disagree. So C code that cares * sequences the calls itself: */ int first = next_value(); int second = next_value(); printf("%d %d\n", first, second); return 0; }
class Main { static int calls = 0; static int nextValue() { return ++calls; } public static void main(String[] args) { // Java specifies strict left-to-right evaluation of operands // and arguments, so this is 1 then 2 on every implementation. System.out.println(nextValue() + " " + nextValue()); } }
This belongs in the same family as overflow and shift counts: Java chose to nail down behavior that C left open for the optimizer. The everyday consequence is that i++ + i++ and its relatives — which are undefined in C, not merely unspecified — have defined answers here. It is not licence to write them; it is one fewer class of portability bug, and it is why a single-threaded Java expression gives the same answer on every implementation in a way a C expression does not.
Overflow and Shifts Are Specified
Signed overflow is undefined behavior in C — the compiler may assume it never happens and optimize accordingly — and shifting by the width or more is undefined too. Java specifies both: signed arithmetic wraps two's-complement, and a shift count is taken modulo the width.
#include <stdio.h> #include <limits.h> int main(void) { /* Signed overflow is UNDEFINED, so this is written to avoid it: compute in unsigned, where wrapping IS defined. */ unsigned int wrapped = (unsigned int) INT_MAX + 1u; printf("%d\n", (int) wrapped); /* Shifting by >= the width is UNDEFINED in C. Kept in range here. */ unsigned int shifted = 1u << 31; printf("%u\n", shifted); return 0; }
class Main { public static void main(String[] args) { // Signed overflow is DEFINED: it wraps, two's complement. int wrapped = Integer.MAX_VALUE + 1; System.out.println(wrapped); // A shift count is taken modulo 32 for int — also defined. int shifted = 1 << 31; System.out.println(Integer.toUnsignedString(shifted)); } }
This is the deepest difference on the page and the one with no syntax to see. In C, "it wrapped on my machine" is not a guarantee — the optimizer is allowed to delete an overflow check on the grounds that overflow cannot occur, and it does. Java has no undefined behavior as a category: evaluation order is left-to-right, integer sizes are fixed, and arithmetic gives the same answer everywhere. (What still varies is what the specification leaves open on purpose: the interleaving of a data race, and the iteration order of a hash map.) That predictability is bought with the bounds checks and the null checks in the rows above.
Where They Still Meet: JNI
The Modern Replacement for JNI
JNI required a header generated from your Java class and a C function whose name encoded the package and method. The Foreign Function and Memory API needs neither — the C side is a plain shared library.
/* The C side is now an ORDINARY shared library. No jni.h, no JNIEnv, * no Java_com_example_Fast_total naming scheme, and no header * generated from a Java class. * * cc -std=c17 -shared -fPIC fastmath.c -o libfastmath.so */ #include <stddef.h> long total(const long *values, size_t count) { long sum = 0; for (size_t index = 0; index < count; index++) sum += values[index]; return sum; }
// Java 22 finalized the Foreign Function and Memory API: // // Linker linker = Linker.nativeLinker(); // try (Arena arena = Arena.ofConfined()) { // SymbolLookup library = SymbolLookup.libraryLookup("fastmath", arena); // MethodHandle total = linker.downcallHandle( // library.find("total").orElseThrow(), // FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG)); // // 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("a library lookup, not a code generator"); } }
The pieces map onto C ideas directly, which is why this is the easier direction to learn. An Arena is a scoped allocator whose close frees everything at once — the goto cleanup block as a language feature. A MemorySegment is a pointer that carries its length and is bounds-checked, which is the pointer-plus-count pair you already pass everywhere, made into one value. A FunctionDescriptor is the prototype, written where the linker can use it.
A JNI Function Is a C Function With a Mangled Name
This is where the two languages still meet in practice. A native method in Java binds to a C function whose name encodes the package, class and method, and whose first two parameters are the JNI environment and the receiver. The Java example declares the native method without calling it, because a call needs the compiled library loaded first; the C example compiles and runs.
#include <stdio.h> /* What a JNI implementation looks like, minus <jni.h>: * * JNIEXPORT jlong JNICALL * Java_Main_combine(JNIEnv *env, jobject self, * jlong first, jlong second, jlong third); * * The name is Java_<package>_<class>_<method>, and the first two * parameters are always the environment and the receiver. */ long combine(long first, long second, long third) { return first + second * third; } int main(void) { printf("%ld\n", combine(2, 5, 8)); return 0; }
class Main { // Binds to Java_Main_combine in a library loaded by System.loadLibrary. // jlong is 64-bit, so this one is unambiguous; jint is 32. private static native long combine(long first, long second, long third); public static void main(String[] args) { // Calling it needs the native library present: // System.loadLibrary("combine"); // System.out.println(combine(2, 5, 8)); System.out.println("declaration only"); } }
Three things must agree and none is checked at compile time: the mangled symbol name, the parameter widths, and the presence of the library at run time — a mismatch surfaces as UnsatisfiedLinkError. The widths are the merciful part: jint and jlong are fixed at 32 and 64 bits, so unlike C-to-C interop there is no platform ambiguity. The unsigned gap remains, though — a C unsigned long has no Java counterpart and must be carried in a long and read with Long.toUnsignedString.
Threads Are in the Language
Threads Without a Library
Threads ship with the platform rather than as a library you link, synchronized and the memory model are part of the language itself, and synchronized is a block — the monitor is released when it exits, including when an exception unwinds through it.
/* C11 has <threads.h>, and almost nobody uses it — glibc only gained * it in 2.28 and macOS does not ship it at all, so most C code uses * POSIX threads instead. Either way it is a library * bolted on, with no memory model in the language until C11. */ #include <stdio.h> int main(void) { long total = 0; for (int index = 1; index <= 100000; index++) total += index; printf("%ld\n", total); return 0; }
class Main { private static long total = 0; private static final Object guard = new Object(); public static void main(String[] args) throws InterruptedException { Runnable addMany = () -> { for (int index = 1; index <= 50000; index++) { synchronized (guard) { total += index; } } }; Thread worker = new Thread(addMany); worker.start(); addMany.run(); worker.join(); System.out.println(total); } }
Java had the first memory model of any mainstream language, and C11 followed it, so the concepts line up: volatile in Java is roughly C11's atomic with sequential consistency — and is not C's volatile, which means something else entirely. The difference that matters is the failure mode: a data race in Java gives you a stale or torn value, while in C it is undefined behavior. Prefer ExecutorService over raw threads, and see the next row for why blocking is cheap now.
Virtual Threads
This is the place Java has a capability C has no standard answer to: a thread the JVM schedules rather than the operating system, cheap enough that blocking stops being a design problem.
/* A C thread is an operating-system thread with megabytes of stack * reserved for it (8 MB by default on Linux), so a server handling * 10,000 connections uses poll/epoll and an event loop — or io_uring, * or a coroutine library. The complexity is the price of the thread * being expensive. */ #include <stdio.h> int main(void) { printf("an event loop, because threads are expensive\n"); return 0; }
// Java 21 made virtual threads final. One is scheduled by the JVM // onto a small pool of carrier threads, costs a few hundred bytes, // and BLOCKS CHEAPLY — so ordinary sequential code scales to // hundreds of thousands of concurrent tasks with no event loop: // // try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { // for (int index = 0; index < 100_000; index++) { // executor.submit(() -> { blockingCall(); return null; }); // } // } class Main { public static void main(String[] args) { System.out.println("blocking code, at event-loop scale"); } }
The consequence is that the whole asynchronous style — an event loop, callbacks, state machines, never blocking — becomes unnecessary for input-and-output-bound work. Write the straightforward blocking code and run it on a virtual thread. C's equivalents are all libraries with their own control flow (libuv, io_uring wrappers, a coroutine framework), and none of them is in the standard.
Build & Dependencies
One Build Tool, and Dependencies That Resolve
This is the part of the move that a C programmer notices most, and it goes Java's way by a wide margin — a dependency is one coordinate rather than a header search, a link line and an ABI question.
/* There is no standard build system and no package manager: * * make, CMake, meson, autotools, or a shell script * pkg-config, if the library ships a .pc file * the distribution's -dev packages, or vendoring the source * * And a header must be found, a library must be linked, and both * must match the ABI your compiler produces. */ #include <stdio.h> int main(void) { printf("dependency handling is a real cost here\n"); return 0; }
// One coordinate, one command, 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) 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 on every JVM at least as new as the one it was compiled for (an older JVM refuses it with UnsupportedClassVersionError). What you give up is compiling a dependency with your own flags and stepping into its source — and, in practice, a dependency tree far larger than a C project would tolerate, because adding one is so cheap.

Thank you — anything else?