Hello World & Compiling
Hello, World
The class disappears — C has file scope, so a function does not live inside anything — and the newline becomes yours to write.
printf prints exactly what you give it.class Main {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}Three things to register from four lines.
#include <stdio.h> is not an import: the preprocessor pastes that file into yours before the compiler sees anything, which is why the Headers section exists. main returns int, and that value is the process exit status the shell reads. And void in the parameter list means "no parameters" — leaving it empty means something subtly different in older C and is worth not learning the hard way.What the compiler produces
One compilation step instead of two, and nothing happens at run time that you did not write. There is no runtime, no class loader and no JIT — which is what makes a C program start in microseconds and what makes it non-portable.
// javac Main.java → Main.class, holding BYTECODE
// java Main → the JVM loads it, interprets, then JIT-compiles
// the hot parts while the program runs
//
// The same class file runs anywhere a JVM does. Startup costs tens of
// milliseconds and the first thousand loop iterations are slow.
class Main {
public static void main(String[] args) {
long total = 0;
for (int index = 0; index < 1000; index++) total += index;
System.out.println(total);
}
}/* cc -std=c17 -O2 -Wall main.c -o program
*
* Out comes machine code for THIS processor and THIS operating system.
* Nothing runs beneath it, startup is microseconds, and the first
* iteration is exactly as fast as the millionth.
*
* The binary does not run on another architecture at all. */
#include <stdio.h>
int main(void) {
long total = 0;
for (int index = 0; index < 1000; index++) total += index;
printf("%ld\n", total);
return 0;
}The trade runs both ways and it is worth being fair about. C wins outright on startup and on predictability: no warmup, no pause, no heap to size. The JIT wins where it can see behavior a static compiler cannot — it inlines the one implementation an interface actually has and deoptimizes if another appears — which is why a long-running Java service can outrun naively written C. What you lose is "compile once, run anywhere": a C binary is for one architecture and one operating system, so the portable artifact is the source.
Memory Is Yours Now
🚨 Allocation is yours, and so is freeing
Every allocation is now paired with a free that you write, on every path out of the function — including the error paths. This is the whole of the change, and every other section on this page is downstream of it.
class Main {
public static void main(String[] args) {
// new allocates. Nothing frees it, because the collector will,
// eventually, once nothing can reach it.
int[] values = new int[5];
for (int index = 0; index < 5; index++) values[index] = index * index;
for (int value : values) System.out.print(value + " ");
System.out.println();
// No free. No delete. Nothing to remember.
}
}#include <stdio.h>
#include <stdlib.h>
int main(void) {
/* malloc returns a pointer, or NULL when it cannot. Checking is
* not optional — nothing else will notice. */
int *values = malloc(5 * sizeof(int));
if (values == NULL) {
fprintf(stderr, "out of memory\n");
return 1;
}
for (int index = 0; index < 5; index++) values[index] = index * index;
for (int index = 0; index < 5; index++) printf("%d ", values[index]);
printf("\n");
free(values); /* forget this and the memory leaks */
values = NULL; /* and this, so a later use fails loudly */
return 0;
}Four failure modes arrive with it, and Java has none of them: leaking (never freed), use-after-free (used after it was), double-free (freed twice), and freeing a pointer that was not malloc'd. Only the first is merely a waste; the other three are undefined behavior. Setting the pointer to
NULL after freeing turns a use-after-free into an immediate crash instead of silent corruption, which is why the line is there. Run everything under a sanitizer — -fsanitize=address — from the first day.The stack is back, and it is free
A local variable of struct type lives on the stack: no allocator is involved, and it disappears when the function returns. This is the cheapest memory there is, and Java gives you no way to ask for it.
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) {
// Every object is on the heap. There is no other option.
Point point = new Point(1, 2);
System.out.println(point.x + "," + point.y);
}
}#include <stdio.h>
struct Point { int x; int y; };
int main(void) {
/* On the STACK. No allocation, no free, and it ceases to exist
* when this function returns — which costs nothing at all. */
struct Point point = {1, 2};
printf("%d,%d\n", point.x, point.y);
printf("bytes per Point: %zu\n", sizeof(struct Point));
return 0;
}The rule that follows is the one to internalize immediately: never return a pointer to a local. The storage is reused as soon as the function returns, so the pointer is dangling and everything after that is undefined. Return the struct by value (C copies it), or take a pointer to storage the caller owns, or
malloc. In Java you cannot make this mistake because there are no stack objects to point at.What you lose with the collector
No collector means no cycle problem to solve and no pauses — and also no safety net. Ownership becomes a design question you answer in comments and conventions, because the language has no way to express it.
class Node {
Node next;
}
class Main {
public static void main(String[] args) {
// A cycle. The collector traces from the roots, so once main
// stops referring to these, both are collectible. No leak.
Node first = new Node();
Node second = new Node();
first.next = second;
second.next = first;
System.out.println("no leak, nothing to write");
}
}#include <stdio.h>
#include <stdlib.h>
struct Node { struct Node *next; };
int main(void) {
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;
/* A cycle is not special here — nothing is tracing anything.
* You free what you allocated, in an order you worked out. */
free(first);
free(second);
printf("freed both, by hand\n");
return 0;
}The discipline that works is to decide, for every allocation, exactly one piece of code that owns it and frees it. A function that returns a
malloc'd pointer is handing over ownership and must say so in its documentation, because the type cannot. Where C++ would use unique_ptr and Rust the borrow checker, C has convention and review — which is why leak checking (valgrind, -fsanitize=leak) belongs in the build rather than in a heroic debugging session later.Pointers
A reference becomes a pointer you can inspect
C makes you choose, and say so at the call site:
&counter passes an address, and passing the struct itself copies the whole thing. Java has one convention and no syntax for it.class Counter {
int value;
}
class Main {
static void increment(Counter counter) {
counter.value++; // the caller's object
}
static void replace(Counter counter) {
counter = new Counter(); // only the local name moves
}
public static void main(String[] args) {
Counter counter = new Counter();
increment(counter);
replace(counter);
System.out.println(counter.value);
}
}#include <stdio.h>
struct Counter { int value; };
void increment(struct Counter *counter) {
counter->value++; /* -> follows the pointer */
}
void by_value(struct Counter counter) {
counter.value = 99; /* a COPY: the caller sees nothing */
}
int main(void) {
struct Counter counter = {0};
increment(&counter); /* & takes the address */
by_value(counter); /* the whole struct is copied */
printf("%d\n", counter.value);
return 0;
}The three-way distinction is worth spelling out because Java collapses it to two. Java passes a reference by value: the callee can modify the object and cannot replace it. C lets you pass the struct by value (a real copy — no aliasing at all, which Java cannot do), or a pointer (the callee modifies yours), or a pointer to a pointer (the callee can replace yours).
-> is (*pointer).field, and it is the operator you will type most.Pointer arithmetic
A pointer is an address you can do arithmetic on, and the arithmetic is scaled by the pointed-to type —
pointer + 1 advances by one element, not one byte. Java has references you can only follow.class Main {
public static void main(String[] args) {
int[] values = {10, 20, 30, 40};
// Indexing is the only way in, and every access is checked.
int total = 0;
for (int index = 1; index < 3; index++) total += values[index];
System.out.println(total);
}
}#include <stdio.h>
int main(void) {
int values[] = {10, 20, 30, 40};
/* A pointer into the middle, and arithmetic in ELEMENTS rather
* than bytes: pointer + 1 moves sizeof(int) bytes. */
int *middle = values + 1;
int total = 0;
for (int index = 0; index < 2; index++) total += middle[index];
printf("%d\n", total);
/* The difference of two pointers is a count of elements. */
printf("distance: %td\n", (values + 3) - values);
return 0;
}This is the capability that makes C fast and dangerous in the same breath: walking a buffer with a moving pointer is idiomatic, costs nothing, and is checked by nobody. Two rules keep it survivable — arithmetic is only defined within an array (and one past its end), and comparing or subtracting pointers into different objects is undefined even though it will appear to work. The reason Java forbade all of it is that a collector must be able to move objects, which it cannot do if the program is holding addresses.
NULL, and the absence of an exception
Dereferencing NULL is not an exception, because there are none. It is undefined behavior — usually a segmentation fault on a desktop operating system, and on a bare-metal target quite possibly a successful read of address zero.
class Main {
public static void main(String[] args) {
String text = null;
try {
System.out.println(text.length());
} catch (NullPointerException error) {
// Caught, named, with a stack trace and (since Java 15) a
// message saying exactly which reference was null.
System.out.println("caught: " + error.getClass().getSimpleName());
}
}
}#include <stdio.h>
int main(void) {
int *pointer = NULL;
/* printf("%d", *pointer); ← undefined behavior. On a desktop it
* usually segfaults; on an embedded target it may read address
* zero and carry on with a plausible-looking wrong number. */
if (pointer == NULL) {
printf("checked before use\n");
}
return 0;
}So every pointer that could be NULL is checked before use, by you, every time. The habits that make this workable: check what
malloc returns, check what any function returning a pointer returns, and document which parameters may be NULL since the type cannot say. A segmentation fault has no stack trace of its own — you get one from a debugger or from a sanitizer build, which is another reason to compile with -g -fsanitize=address while developing.Types & Sizes
🚨 int is not 32 bits by definition
This is the first portability trap and it is a real one:
long is 64 bits on Linux and macOS and 32 bits on Windows, so a struct written with long fields has a different layout on the two.class Main {
public static void main(String[] args) {
// Every size is fixed by the specification, everywhere:
// byte 8, short 16, int 32, long 64, char 16, float 32, double 64.
System.out.println("int bits: " + Integer.SIZE);
System.out.println("long bits: " + Long.SIZE);
System.out.println("int max: " + Integer.MAX_VALUE);
}
}#include <limits.h>
#include <stdint.h>
#include <stdio.h>
int main(void) {
/* The standard promises MINIMUMS, not sizes. int is at least 16
* bits; long is at least 32 and is 64 on Linux but 32 on Windows. */
printf("int bytes: %zu\n", sizeof(int));
printf("long bytes: %zu\n", sizeof(long));
printf("int max: %d\n", INT_MAX);
/* When the width matters, say the width: */
int32_t exactly_32 = 42;
uint64_t unsigned_64 = 18446744073709551615u;
printf("%d %llu\n", exactly_32, (unsigned long long)unsigned_64);
return 0;
}The rule for new code is to use
<stdint.h> — int32_t, uint64_t, size_t for sizes and indices, ptrdiff_t for pointer differences — and to leave bare int for loop counters and small values. The other half of the difference is that unsigned types exist here, which Java has none of. They wrap by definition rather than overflowing, and mixing signed and unsigned in one comparison converts the signed one — so -1 < 1u is false, which is the classic C bug that no Java experience prepares you for.What the type system does not have
void * is C's "any pointer", and it is the whole of its generics: the caller casts it back to what they believe it is, and nothing checks the belief. Type safety at the container boundary is a convention.import java.util.List;
class Main {
static <Element> Element first(List<Element> values) {
return values.get(0);
}
public static void main(String[] args) {
// A generic method, checked at compile time.
System.out.println(first(List.of("alpha", "beta")));
boolean ready = true;
System.out.println(ready);
}
}#include <stdbool.h>
#include <stdio.h>
#include <string.h>
/* No generics. A container that holds "anything" holds void*, and
* the size of an element is a runtime argument. Nothing is checked. */
void *first(void *array, size_t element_size) {
(void)element_size;
return array;
}
int main(void) {
const char *names[] = {"alpha", "beta"};
char **found = first(names, sizeof(names[0]));
printf("%s\n", *found);
bool ready = true; /* stdbool.h; it is an int underneath */
printf("%s\n", ready ? "true" : "false");
return 0;
}The other absences worth naming up front: no
boolean until C99 added <stdbool.h> (and it is an integer underneath, so any non-zero value is true), no method overloading, no namespaces, no destructors, no exceptions, no String class, and no standard collections at all — no list, no map, no set. A C project either writes its own or picks a library, and this is the single biggest day-to-day difference in how much code you write.Strings Are char*
🚨 A string is a pointer and a convention
There is no string type. A "string" is a pointer to bytes with a zero byte on the end, and every operation is a function that takes that pointer plus, if it writes anything, somewhere to put it.
class Main {
public static void main(String[] args) {
String text = "hello";
String more = text + " world"; // a NEW string
System.out.println(more);
System.out.println(more.length()); // stored, O(1)
}
}#include <stdio.h>
#include <string.h>
int main(void) {
const char *text = "hello"; /* a pointer to bytes ending in '\0' */
/* Concatenation needs storage YOU provide, and a size you get right. */
char buffer[32];
snprintf(buffer, sizeof(buffer), "%s %s", text, "world");
printf("%s\n", buffer);
printf("%zu\n", strlen(buffer)); /* WALKS the string, O(n) */
return 0;
}Three consequences that all bite.
strlen walks the bytes looking for the terminator, so it is O(n) and calling it in a loop condition is a classic accidental quadratic. The terminator is a convention — nothing enforces it, and a byte sequence without one makes every string function run off the end. And the buffer is yours: snprintf takes the size and truncates, where strcpy and sprintf do not and are how buffer overflows happen. Use the n forms, always.Comparing strings
The same bug as Java's
==-on-strings, with the fix spelled differently: strcmp returns zero when the strings match, which is the opposite of what "equal" reads as and catches everybody at least once.class Main {
public static void main(String[] args) {
String first = "hello";
String second = "hel" + "lo";
System.out.println("equals: " + first.equals(second));
// == would compare references and is the classic Java bug.
}
}#include <stdio.h>
#include <string.h>
int main(void) {
const char *first = "hello";
char second[6];
snprintf(second, sizeof(second), "%s", "hello");
/* == compares ADDRESSES. strcmp compares contents and returns
* zero when they are equal, which reads backwards forever. */
printf("equals: %s\n", strcmp(first, second) == 0 ? "true" : "false");
printf("addresses equal: %s\n", first == second ? "true" : "false");
return 0;
}The return value is a sign, not a boolean: negative when the first sorts before the second, positive when after, zero when equal — so it drops straight into
qsort. strncmp bounds the comparison, which matters when either side might not be terminated. And unlike Java, where literals are interned so == sometimes accidentally works, C makes no promise at all about whether two identical literals share an address.Arrays Forget Their Length
🚨 An array decays to a pointer
An array passed to a function becomes a bare pointer and the length is gone — which is why nearly every C function that takes an array takes a count beside it. The
sizeof trick works only where the array is declared.class Main {
static int total(int[] values) {
// The array knows how long it is. Always.
int sum = 0;
for (int value : values) sum += value;
return sum;
}
public static void main(String[] args) {
int[] values = {1, 2, 3, 4};
System.out.println(total(values) + " of " + values.length);
}
}#include <stdio.h>
/* The parameter is a POINTER. sizeof(values) here would be the size
* of a pointer, not of the array — so the count must be passed. */
int total(const int *values, size_t count) {
int sum = 0;
for (size_t index = 0; index < count; index++) sum += values[index];
return sum;
}
int main(void) {
int values[] = {1, 2, 3, 4};
size_t count = sizeof(values) / sizeof(values[0]); /* only works HERE */
printf("%d of %zu\n", total(values, count), count);
return 0;
}This is the root of the most consequential difference between the languages. Java checks every index because it always knows the length; C cannot check because by the time your function has the array, the length does not exist. Reading
values[10] from a four-element array is not an exception, it is whatever those bytes happen to be — and writing there corrupts something else. Keep the pointer and the count together, in a struct if the pair travels, and never let them separate.Two dimensions are genuinely two dimensions
A C two-dimensional array is a single rectangular block, so
grid[1][2] is one multiplication and one load. A Java int[][] is an array of references to separately allocated rows, so the same access is two loads and a bounds check each.class Main {
public static void main(String[] args) {
// An array OF ARRAYS: one object holding references to four
// more, each separately allocated and possibly ragged.
int[][] grid = new int[2][3];
grid[1][2] = 7;
System.out.println(grid[1][2] + " rows: " + grid.length
+ " columns: " + grid[0].length);
}
}#include <stdio.h>
int main(void) {
/* ONE contiguous block of 6 ints. Not rows of pointers. */
int grid[2][3] = {{0}};
grid[1][2] = 7;
printf("%d rows: %zu columns: %zu\n", grid[1][2],
sizeof(grid) / sizeof(grid[0]),
sizeof(grid[0]) / sizeof(grid[0][0]));
/* Which is why this works — the memory really is one run: */
int *flat = &grid[0][0];
printf("as one run: %d\n", flat[5]);
return 0;
}That layout difference is most of why numeric C outruns naive numeric Java: the block is contiguous, so a cache line prefetches the next elements, while Java's rows may be anywhere. Java can get most of it back with a flat
int[] and manual index arithmetic, which is exactly what serious numeric Java does. The cost on the C side is that a rectangular array cannot be ragged, and passing one to a function requires all dimensions but the first in the parameter type.Structs Instead of Classes
A struct is data, and only data
A struct holds fields. Everything a class adds — methods, constructors, access control, inheritance, overriding — is absent, and the convention that replaces methods is a free function taking a pointer to the struct as its first parameter.
class Rectangle {
private final int width;
private final int height;
Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
int area() { return width * height; }
}
class Main {
public static void main(String[] args) {
System.out.println(new Rectangle(3, 4).area());
}
}#include <stdio.h>
/* No methods, no constructors, no private, no inheritance. Fields. */
struct Rectangle {
int width;
int height;
};
/* A "method" is a function whose first parameter is the struct. */
int rectangle_area(const struct Rectangle *rectangle) {
return rectangle->width * rectangle->height;
}
int main(void) {
struct Rectangle rectangle = {.width = 3, .height = 4};
printf("%d\n", rectangle_area(&rectangle));
return 0;
}The naming convention does the work a class did:
rectangle_area, rectangle_scale, rectangle_free are what a Java reader would find as methods on Rectangle. Encapsulation is available through an opaque struct — declare the type in the header without its fields and define it in the implementation file, so callers can only hold a pointer and must go through your functions. That is the C equivalent of private, and it is the pattern every serious C library uses. The .width = spelling is a designated initializer, from C99, and is worth using because it survives a field being reordered.Structs are copied on assignment
Assignment copies the whole struct, so
second is a separate object. This is the reverse of the Java rule, and it is the difference that makes C's aliasing situation both simpler and easier to get wrong.class Point {
int x;
int y;
}
class Main {
public static void main(String[] args) {
Point first = new Point();
Point second = first; // a second NAME for one object
second.x = 99;
System.out.println(first.x); // 99 — there was only one Point
}
}#include <stdio.h>
struct Point { int x; int y; };
int main(void) {
struct Point first = {0, 0};
struct Point second = first; /* a genuine COPY, field by field */
second.x = 99;
printf("%d %d\n", first.x, second.x);
return 0;
}Two follow-ons. Passing a struct to a function copies it too, so a large struct passed by value is a real cost that nothing in the call site reveals — which is why C code passes
const struct Thing * almost everywhere. And the copy is shallow: if the struct contains a pointer, both copies now point at the same thing, and both will try to free it unless you decided which one owns it. Java's equivalent question does not arise, because there are no value copies at all.Functions & Function Pointers
Function pointers are the interface
A function pointer is what replaces the interface, the lambda and the callback.
qsort takes one, along with the element count and size, because it has no idea what it is sorting.import java.util.Arrays;
import java.util.Comparator;
class Main {
public static void main(String[] args) {
Integer[] values = {30, 10, 20};
Arrays.sort(values, Comparator.reverseOrder());
for (int value : values) System.out.print(value + " ");
System.out.println();
}
}#include <stdio.h>
#include <stdlib.h>
/* qsort takes a POINTER TO A FUNCTION. This is C's interface, its
* lambda and its strategy pattern, all at once. */
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};
size_t count = sizeof(values) / sizeof(values[0]);
qsort(values, count, sizeof(values[0]), descending);
for (size_t index = 0; index < count; index++) printf("%d ", values[index]);
printf("\n");
return 0;
}Notice what the comparator has to do: it receives
const void * and casts to the type it believes is there, so nothing checks that qsort was given a comparator matching the array. That is the shape of every C callback. The absences are real — no closures, so context reaches a callback through an extra void *user_data parameter that most APIs provide by convention, and no overloading, so a function pointer type must match exactly. The subtraction trick in the return avoids the overflow that a - b would risk.static means something else entirely
🚨 The keyword is the same word for a completely different idea. At file scope
static restricts visibility; inside a function it changes a variable's lifetime. Neither is Java's "belongs to the class".class Helper {
// Belongs to the class rather than to an instance.
static int twice(int value) { return value * 2; }
}
class Main {
public static void main(String[] args) {
System.out.println(Helper.twice(21));
}
}#include <stdio.h>
/* At file scope, static means "not visible outside this .c file" —
* the closest thing C has to private. It is about LINKAGE. */
static int twice(int value) { return value * 2; }
int main(void) {
/* Inside a function, static means "keeps its value between
* calls and lives for the whole program" — a third meaning. */
static int calls = 0;
calls++;
printf("%d (call %d)\n", twice(21), calls);
return 0;
}The file-scope meaning is the one to adopt as a habit: mark every function and global that is not part of your header
static, so it cannot be linked to from elsewhere. Without it, two files that both define helper collide at link time — C has no namespaces, so every non-static name is in one global pool, which is why library functions are prefixed (png_read_info, sqlite3_open). The function-local meaning is worth being careful with: a static local is shared by every caller, so it is not thread-safe.No Exceptions
🚨 Errors come back as return values
No exceptions, so no stack unwinding, no
try, no finally and no stack trace. A function that can fail returns a status, and the value you wanted comes back through a pointer parameter.class Main {
static int parse(String text) {
if (text.isEmpty()) throw new IllegalArgumentException("empty");
return Integer.parseInt(text);
}
public static void main(String[] args) {
try {
System.out.println(parse("42"));
System.out.println(parse(""));
} catch (IllegalArgumentException error) {
// The failure could not be ignored: it unwound to here.
System.out.println("caught: " + error.getMessage());
}
}
}#include <stdio.h>
#include <stdlib.h>
/* The return value says whether it worked; the ANSWER goes through a
* pointer. This is the standard C shape for a function that can fail. */
static int parse(const char *text, int *out) {
if (text[0] == '\0') return -1;
*out = atoi(text);
return 0;
}
int main(void) {
int value = 0;
if (parse("42", &value) == 0) printf("%d\n", value);
if (parse("", &value) != 0) {
printf("caught: empty\n"); /* and nothing forced this check */
}
return 0;
}🚨 Nothing makes the caller check. Delete the
if and the program compiles, runs, and carries on with an untouched variable — which is the single largest source of real C bugs, and the reason -Wall -Wextra and static analysis are not optional. Two other conventions you will meet: errno, a thread-local integer that library functions set (check it only after a call has already reported failure), and goto cleanup — the one respectable use of goto, where every error path jumps to a single block that frees what was allocated. That is C's finally.goto cleanup is the finally block
This is the idiom, and it is worth recognizing rather than being alarmed by: a single
cleanup label at the bottom, every failure path jumping to it, and the resources released once in the order they were acquired.class Main {
static void work() {
try {
System.out.println("acquired");
throw new RuntimeException("boom");
} finally {
// Runs on every path out, including the exception.
System.out.println("released");
}
}
public static void main(String[] args) {
try { work(); }
catch (RuntimeException error) {
System.out.println("caught: " + error.getMessage());
}
}
}#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) { /* stand-in for a failing step */
goto cleanup; /* every error path jumps to one place */
}
status = 0;
cleanup:
free(buffer);
printf("released\n");
return status;
}
int main(void) {
if (work() != 0) printf("caught: boom\n");
return 0;
}It exists because the alternative is worse — releasing three things on five different error paths means fifteen chances to forget one, and that is where leaks come from. The rules that keep it readable are to declare and zero-initialize everything before the first
goto (jumping over an initialization is an error), to free in reverse order of acquisition, and to have exactly one label. Any C programmer will read this instantly; it is the one place the profession stopped arguing about goto.Headers & the Preprocessor
import becomes textual inclusion
An
#include is not an import. The preprocessor copies the named file into yours before compilation, which is why headers need include guards and why a large C build spends most of its time re-parsing the same headers for every source file.import java.util.List;
// The compiler reads the OTHER class file for its declarations.
// One class per file, the package is the namespace, and the
// classpath says where to look. Nothing is copied into this file.
class Main {
public static void main(String[] args) {
System.out.println(List.of(1, 2, 3).size());
}
}#include <stdio.h>
/* #include is the PREPROCESSOR pasting a file into yours, verbatim,
* before the compiler sees a single token. A typical program pastes
* in tens of thousands of lines this way.
*
* A header of your own would look like:
*
* #ifndef RECTANGLE_H // the include guard — without it,
* #define RECTANGLE_H // a twice-included header defines
* // its types twice and fails
* struct Rectangle { int width; int height; };
* int rectangle_area(const struct Rectangle *rectangle);
* #endif
*/
int main(void) {
printf("declarations here, definitions elsewhere\n");
return 0;
}The mental model to build: a header declares (types, function prototypes, macros) and a .c file defines. Every file that wants to call your function includes the header; the linker matches the calls to the one definition afterwards. That also explains C's two error messages, which a Java programmer meets as one — "implicit declaration" comes from the compiler and means it never saw the promise, while "undefined reference" comes from the linker and means the promise was never kept.
The preprocessor
The preprocessor is a text substitution pass that runs before the compiler and knows nothing about C — not types, not scope, not precedence. That is the source of both its power and every macro bug you have heard about.
class Main {
// No preprocessor. A constant is a static final field, and
// conditional compilation does not exist — the closest thing is
// an if on a system property, evaluated at run time.
private static final int MAX_ITEMS = 100;
public static void main(String[] args) {
System.out.println("max: " + MAX_ITEMS);
}
}#include <stdio.h>
#define MAX_ITEMS 100 /* textual replacement */
#define SQUARE(value) ((value) * (value)) /* parentheses are not optional */
int main(void) {
printf("max: %d\n", MAX_ITEMS);
printf("square: %d\n", SQUARE(3 + 1)); /* 16, thanks to the parens */
#ifdef __linux__
printf("compiled for Linux\n");
#else
printf("compiled for something else\n");
#endif
return 0;
}Those parentheses in
SQUARE are the canonical example: written value * value, the call SQUARE(3 + 1) expands to 3 + 1 * 3 + 1, which is 7. A macro also evaluates its argument as many times as it appears, so SQUARE(index++) increments twice. Prefer static const int and static inline functions, which the compiler type-checks; keep the preprocessor for include guards and #ifdef platform selection, which genuinely have no alternative.Undefined Behavior
🚨 A category Java does not have
This section exists because undefined behavior is a category with no Java counterpart, and no amount of care substitutes for knowing it is there. It does not mean "an unspecified result" — it means the standard places no requirement on the program at all.
class Main {
public static void main(String[] args) {
// Every operation here has a defined answer, by specification:
System.out.println("overflow wraps: " + (Integer.MAX_VALUE + 1));
System.out.println("shift wraps: " + (1 << 33));
int[] values = new int[2];
try {
System.out.println(values[5]);
} catch (ArrayIndexOutOfBoundsException error) {
System.out.println("out of bounds: throws");
}
// A Java program cannot corrupt its own memory. Ever.
}
}#include <limits.h>
#include <stdio.h>
int main(void) {
/* Each of these is UNDEFINED — not "wrong", not "platform
* specific": the standard places NO requirement on the program.
*
* INT_MAX + 1 signed overflow
* 1 << 33 shift past the width
* values[5] out of bounds, on a 2-element array
* *(int *)NULL null dereference
* use after free()
*
* The compiler may assume none of them happen, and OPTIMIZE on
* that assumption — which is why a bounds check written after the
* access can be deleted as provably unreachable. */
printf("defined: %d\n", INT_MAX / 2);
printf("unsigned overflow is defined and wraps: %u\n", (unsigned)UINT_MAX + 1u);
return 0;
}🚨 The part that surprises everyone is that the optimizer is allowed to assume it never happens. A null check written after a dereference can be deleted, because if the pointer were null the program would already have been undefined — so the compiler concludes it cannot be. This is why "it worked in the debug build" is a sentence C programmers say. The defenses are real and they are not optional:
-Wall -Wextra, -fsanitize=address,undefined during development, and a static analyzer in the build.Nothing is zeroed for you
Java guarantees zeroed fields and arrays, and refuses to compile a read of a local that might not have been assigned. C zeroes statics and globals only — a local or a
malloc'd block holds whatever the memory held before.class Main {
static int field; // fields ARE zeroed: 0, false, null
public static void main(String[] args) {
int[] values = new int[3]; // and so are new arrays
int local;
// System.out.println(local); ← will not compile: definite assignment
System.out.println(field + " " + values[0]);
}
}#include <stdio.h>
#include <stdlib.h>
static int global; /* statics and globals ARE zeroed */
int main(void) {
int local; /* this is NOT — it holds whatever was there */
/* printf("%d", local); ← undefined behavior, and it will often
* print zero on the first run and something else later */
local = 0; /* so initialize, always, at declaration */
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;
}The habit that removes the whole class of bug is to initialize at the point of declaration, every time:
int count = 0;, struct Thing thing = {0};, char buffer[64] = "";. Use calloc when you want zeroed heap memory and malloc when you are about to fill every byte anyway. This is a favorite of security researchers, because uninitialized memory frequently contains something interesting from a previous allocation.Calling C From Java
The Foreign Function and Memory API
The old answer was JNI: a header generated from your Java class, a C function whose name encodes the package and method, and a build step keeping the two in step. The new one is a plain shared library and a lookup by symbol name.
// Java 22 finalized this, and it replaces JNI. No generated header,
// no C glue, no build step wired into your Java build:
//
// 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("the boundary is a library lookup");
}
}/* The C side is an ORDINARY shared library. Nothing about it knows
* that Java is the caller.
*
* 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;
}
/* No JNIEnv, no jlong, no Java_com_example_Fast_total naming scheme,
* and no header generated from a Java class. */The pieces map onto C ideas directly. 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 from the Arrays section made into one value. A FunctionDescriptor is the prototype, written where the linker can use it. The rules that carry over are the ones this whole page has been about: do not keep a segment past the arena that owns it, and mind which thread calls in.What crossing the boundary costs
This is the row that matters most if you are here because of a real interop task. Three guarantees a Java programmer has never had to think about are suspended for the duration of a native call.
class Main {
public static void main(String[] args) {
// On the Java side of a native call, three things stop being true:
//
// 1. The collector can move objects — so a native pointer into
// the heap must be pinned, or copied out first.
// 2. Exceptions do not cross. C returns a status; the wrapper
// turns it into an exception, or nobody does.
// 3. A crash in C takes the whole JVM with it. There is no
// catching a segmentation fault.
System.out.println("three guarantees, suspended");
}
}#include <stdio.h>
/* And on the C side, the same three facts in reverse:
*
* 1. A pointer handed in from Java is valid for the CALL only,
* unless the caller pinned it. Storing it is a dangling
* pointer with extra steps.
* 2. There is no exception to throw. Return a status and let the
* wrapper translate it.
* 3. Undefined behavior here is undefined for the entire process,
* including all the Java that had nothing to do with it. */
int main(void) {
printf("the safety net stops at this line\n");
return 0;
}The practical advice that follows is narrow and worth taking. Keep the boundary small — a few coarse calls rather than many fine ones, since each crossing costs more than a Java call. Copy data across rather than holding pointers into the Java heap, unless you have measured that you cannot afford to. Validate on the C side rather than trusting the caller. And test the native library on its own, with a sanitizer build and its own test program, because a crash inside the JVM is far harder to diagnose than the same crash in a small C program.