Rust for Java Developers: A Practical Guide
If you’ve spent years with Java, you already have most of what you need to learn Rust. You think in types. You know generics, interfaces, lambdas, and stream pipelines. Rust has all of these: traits look a lot like interfaces with default methods, iterators read like streams, and Cargo does the jobs of Maven or Gradle, only with far less configuration.
What’s new is everything the JVM used to do for you. Rust has no garbage collector and no virtual machine. Your code compiles to a native binary, and the compiler decides when memory is freed by checking ownership and borrowing before the program runs. Rust also has no null (you use Option), no exceptions (you use Result), and no class inheritance. Those four changes are where you’ll spend most of your learning time.
Why do Java developers pick up Rust? Usually for one of these reasons: services that need low, steady memory use and no GC pauses; command-line tools and serverless functions that should start instantly without a JVM; a hot path in a Java app that you want to move into native code (Rust can be called from Java through JNI or the newer Foreign Function & Memory API); or simply curiosity about why Rust keeps topping “most admired language” surveys. This page maps what you know from Java to Rust, so you can focus on the parts that are really different.
For a general comparison with C++, Go, and Python, see Rust vs Other Languages. If you also know .NET, there’s a separate page: Rust for C# Developers.
Language concepts: Java to Rust
Section titled “Language concepts: Java to Rust”Treat this table as a phrase book. The last column links to the lesson on this site that explains the Rust side.
| Java | Rust | Learn more |
|---|---|---|
class with fields and methods |
struct for the data + an impl block for the methods |
Structs |
Constructors and new |
A plain function, by convention called new, that returns Self |
Structs |
interface |
trait |
Traits |
default methods in an interface |
Default methods in a trait (they work the same way) | Traits |
abstract class, extends, super |
No inheritance. Use traits with default methods for shared behavior and composition (a struct inside a struct) for shared data | Traits, Design Patterns |
List<Shape> holding different classes |
Vec<Box<dyn Shape>> (a trait object) |
Trait Objects |
sealed interface + records + pattern switch (Java 17/21) |
An enum whose variants carry data, plus match |
Enums, match |
enum with fields and constructors |
enum where each variant can hold different data |
Enums |
record Point(int x, int y) |
#[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } |
Structs, Common Traits |
toString(), equals(), hashCode(), compareTo() |
Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (usually derived) |
Common Traits |
== (same object) vs .equals() (same value) |
== always compares values. There is no “reference equals” trap |
Operators |
null and Optional<T> |
Option<T>: either Some(value) or None |
Option |
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent |
unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) |
Option, if let |
Checked exceptions and throws |
Result<T, E> in the return type, plus the ? operator |
Result, Custom Errors |
Unchecked exceptions for real bugs (IllegalStateException) |
panic! (stops the thread) |
Panic |
Exception chaining (new ConfigException(msg, cause)) |
map_err, or thiserror’s #[source] and anyhow’s .context(...) |
anyhow & thiserror |
try-with-resources and AutoCloseable |
Drop, which runs automatically when the owner goes out of scope |
Scope, Smart Pointers |
| Garbage collector | Ownership and borrowing, checked at compile time | Ownership, Borrowing |
final local variables |
Plain let (immutable by default); write let mut to allow changes |
Variables |
static final constants |
const |
Variables |
Mutable static fields, singletons |
static with LazyLock or OnceLock, wrapped in a Mutex if it must change |
Shared State |
Generics with <T extends Comparable<T>> |
Generics with trait bounds: T: Ord or where T: Ord |
Generics |
Type erasure, List<Integer> instead of List<int> |
Monomorphization: generic code is compiled for each type, and Vec<i32> stores plain numbers |
Generics, Advanced Generics |
Lambdas, Function<T, R>, Supplier<T>, Consumer<T> |
Closures, described by the traits Fn, FnMut, FnOnce |
Closures, Fn, FnMut & FnOnce |
Streams: filter, map, reduce, collect |
Iterator adapters: filter, map, fold, sum, collect |
Iterators |
Iterable<T> and Iterator<T> |
IntoIterator and Iterator (one method: next, which returns an Option) |
Custom Iterators |
parallelStream() |
par_iter() from the rayon crate |
Concurrency |
Thread, ExecutorService |
std::thread::spawn and std::thread::scope; rayon or tokio for pools |
Concurrency |
synchronized, ReentrantLock |
Mutex<T>, which wraps the data it protects |
Shared State |
AtomicInteger, AtomicLong |
AtomicI32, AtomicI64, AtomicUsize |
Shared State |
BlockingQueue between threads |
Channels (std::sync::mpsc) |
Channels |
CompletableFuture, virtual threads |
async fn and .await, run by a runtime like tokio |
Async/Await |
String (immutable) and StringBuilder |
&str (a borrowed view of text) and String (owned, growable) |
Strings |
char (a UTF-16 unit) |
char (any Unicode character, 4 bytes); strings are UTF-8 |
Strings |
int, long, double and boxed Integer |
i32, i64, f64, plus unsigned u8 to u128. Never boxed |
Data Types |
| Silent integer overflow | A panic in debug builds; use checked_add, wrapping_add, saturating_add to choose |
Integer Overflow |
Casts like (int) x |
as for numbers, From / TryFrom for safe conversions |
Type Casting, Conversions |
BigDecimal, BigInteger |
The rust_decimal and num-bigint crates |
Data Types |
Packages and import |
Modules (mod) and use |
Modules |
public, private, protected, package-private |
Private by default; pub and pub(crate). There is no protected |
Modules |
| A JAR / Maven artifact | A crate (a library or a binary) | Cargo & Crates |
Annotations (@Override, Lombok’s @Data) |
Attributes like #[derive(Debug)] and #[test] |
Macros |
| Reflection, runtime annotation processing | Mostly none at runtime. Macros generate code at compile time instead | Procedural Macros |
| Method overloading | No overloading. Use different names, or a generic parameter | Functions |
instanceof checks and downcasts |
match on an enum (downcasting with Any exists, but is rare) |
Pattern Matching |
| JNI, Foreign Function & Memory API | extern "C" functions |
FFI |
Collections
Section titled “Collections”The Java Collections Framework maps neatly onto std::collections. The biggest difference: Rust collections never return null. A lookup gives you an Option.
| Java | Rust | Learn more |
|---|---|---|
ArrayList<T> |
Vec<T> |
Vectors |
int[], String[] |
[T; N] when the length is known at compile time, otherwise Vec<T>. Pass either as a slice &[T] |
Arrays, Slices |
List.of(...) (unmodifiable) |
An array, or a Vec you only hand out as &Vec<T> / &[T] (nobody can change it through a shared reference) |
Borrowing |
HashMap<K, V> |
HashMap<K, V> (get returns Option<&V>) |
HashMaps |
map.merge(word, 1, Integer::sum) |
*map.entry(word).or_insert(0) += 1 |
HashMaps |
TreeMap<K, V> |
BTreeMap<K, V> (keys stay sorted) |
HashMaps |
LinkedHashMap<K, V> (insertion order) |
IndexMap from the indexmap crate |
|
HashSet<T>, TreeSet<T> |
HashSet<T>, BTreeSet<T> |
HashSet |
ArrayDeque<T> |
VecDeque<T> |
VecDeque & BinaryHeap |
Stack<T>, or Deque used as a stack |
Vec<T> with push / pop |
Vectors |
PriorityQueue<T> (smallest first) |
BinaryHeap<T> (largest first; wrap items in Reverse for smallest first) |
VecDeque & BinaryHeap |
LinkedList<T> |
LinkedList<T> exists, but Vec or VecDeque is almost always faster |
VecDeque & BinaryHeap |
ConcurrentHashMap<K, V> |
Mutex<HashMap<K, V>>, or the dashmap crate |
Shared State |
list.sort(Comparator.comparing(...)) |
v.sort_by_key(...) or v.sort_by(...) |
Sorting |
list.removeIf(...) |
v.retain(...) (keeps the items that match) |
Vectors |
Like Java’s HashMap, a Rust HashMap has no fixed order, and the order can change between runs. If you need sorted output, use a BTreeMap or sort the keys first.
Tooling: Maven and Gradle to Cargo
Section titled “Tooling: Maven and Gradle to Cargo”Most Java developers fall for Cargo quickly. There’s one tool, one config format, and no plugin XML. Builds, dependencies, tests, docs, formatting, and lints all come from the same place.
| Java | Rust | Notes |
|---|---|---|
| Maven / Gradle | cargo |
Builds, runs, tests, adds dependencies, publishes. See Cargo & Crates |
pom.xml / build.gradle.kts |
Cargo.toml |
Short, and no build scripts needed for normal projects |
| Gradle dependency locking | Cargo.lock |
Created and updated for you |
| Multi-module build | A Cargo workspace | One Cargo.toml at the top lists the member crates |
| Maven Central | crates.io | Every published crate’s API docs are on docs.rs |
Adding a <dependency> |
cargo add serde |
|
mvn archetype:generate / gradle init |
cargo new my_app (cargo new --lib for a library) |
|
mvn compile / mvn test / mvn package |
cargo build / cargo test / cargo build --release |
Debug builds are much slower at runtime; measure performance in release |
java -jar app.jar |
./target/release/app |
A single native binary. No JVM to install on the server |
| JDK versions, SDKMAN!, toolchains | rustup and a rust-toolchain.toml file |
See Rust Tools & Setup |
javac |
rustc |
Cargo calls it for you; you rarely run it yourself |
| JShell | The Rust Playground in your browser, or evcxr_repl |
|
| Checkstyle, SpotBugs, PMD, Error Prone | cargo clippy |
Hundreds of official lints |
| google-java-format, Spotless | cargo fmt |
One standard style; nobody argues about braces |
| IntelliJ IDEA, Eclipse | JetBrains RustRover, or VS Code + rust-analyzer | rust-analyzer gives completion, go-to-definition, and inline errors |
| Javadoc | /// doc comments in Markdown + cargo doc |
Code examples in docs run as tests. See Documentation |
| JUnit 5 | Built-in #[test] and cargo test (cargo nextest is a popular faster runner) |
See Testing |
@ParameterizedTest |
rstest |
|
| Mockito | mockall, or hand-written fakes that implement your trait |
See Testing Beyond the Basics |
| AssertJ / Hamcrest | assert!, assert_eq!, and pretty_assertions for readable diffs |
|
| WireMock, Testcontainers | wiremock, testcontainers |
Same ideas, Rust versions |
| JaCoCo | cargo llvm-cov or cargo tarpaulin |
|
| JMH | criterion |
See Performance |
| JFR, async-profiler, VisualVM | cargo flamegraph (installed with cargo install flamegraph), perf |
No GC or JIT to tune |
| GraalVM native-image | Not needed | Every Rust build is already native code |
Here’s a small Cargo.toml. Compare it with the pom.xml you’d need for the same three dependencies:
[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }Libraries and frameworks: “What do we use instead of…?”
Section titled “Libraries and frameworks: “What do we use instead of…?””This is the first question every Java team asks: “We’re on Spring Boot and Hibernate. What’s the Rust version?” Rust has no Spring, no single framework that brings everything. Instead you combine a few focused crates. Here are the closest matches.
Web and networking
Section titled “Web and networking”| Java | Rust | Notes |
|---|---|---|
| Spring Boot (Spring MVC / WebFlux) | axum (we build one in Project: Web API), actix-web, Rocket | axum and actix-web are widely used in production |
@RestController + @GetMapping |
axum routes pointing to plain async fn handlers |
No annotations or classpath scanning; you wire routes in code |
| Servlet filters, Spring interceptors | tower layers and the tower-http crate (CORS, compression, tracing, timeouts) |
axum uses tower directly |
Spring dependency injection (@Autowired) |
Shared State in axum, plain constructors, and traits |
See the DI row further down |
| springdoc-openapi | utoipa |
|
| Thymeleaf | askama (templates checked at compile time) or tera |
|
| Spring WebSocket | WebSockets in axum (the ws feature), or tokio-tungstenite |
|
| Spring for GraphQL | async-graphql |
|
| gRPC Java | tonic (with prost for protobuf) |
|
java.net.http.HttpClient, OkHttp, WebClient |
reqwest |
Async by default, with an optional blocking client |
| Netty | tokio + hyper |
|
| Spring Security (JWT, password hashing) | jsonwebtoken, argon2 |
No all-in-one security framework |
Data and messaging
Section titled “Data and messaging”| Java | Rust | Notes |
|---|---|---|
| Hibernate / JPA / Spring Data JPA | SeaORM: async ORM with entities, relations, and migrations | Feels closest to JPA; built on top of sqlx |
| Hibernate with strict typing in mind | Diesel: ORM and query builder; queries are type-checked at compile time | Mature (2.x), synchronous by default (diesel-async exists) |
JDBC, JdbcTemplate, jOOQ, MyBatis |
sqlx: plain SQL, and the query! macro checks it against your real database at compile time |
Async, very popular, not an ORM |
| Flyway / Liquibase | sqlx migrate (sqlx-cli), diesel migration, or refinery |
|
| Jedis / Lettuce (Redis) | redis |
|
| Kafka clients, Spring Kafka | rdkafka (built on librdkafka) |
|
| RabbitMQ Java client | lapin |
|
| AWS SDK for Java | The official AWS SDK for Rust: aws-config, aws-sdk-s3, … |
If you like JPA entities, try SeaORM first. If you prefer writing SQL (the jOOQ and MyBatis crowd), try sqlx. If you want the strongest compile-time guarantees, try Diesel. Rust Databases shows all three with working code.
Everyday building blocks
Section titled “Everyday building blocks”| Java | Rust | Notes |
|---|---|---|
| Jackson / Gson | serde + serde_json |
The standard. Also does TOML, YAML, and more. See Serde & JSON |
| JAXB, Jackson XML | quick-xml (with its serialize feature for serde) |
|
| SLF4J + Logback / Log4j 2 | tracing (+ tracing-subscriber), or log + env_logger |
See Logging |
| Lombok | Built-in derives (Debug, Clone, PartialEq, Default, …); bon or derive_builder for builders |
No bytecode tricks; it’s all regular macros |
| MapStruct | From / Into implementations |
Written by hand, checked by the compiler. See Conversions |
| Spring’s IoC container | Usually nothing: pass dependencies into constructors, use traits for things you swap in tests | Rust projects rarely use a DI container |
Bean Validation (@NotNull, @Size) |
validator or garde (derive-based validation) |
@NotNull isn’t needed: use Option only where a value may be missing |
| Resilience4j | backon, or tower’s retry and timeout middleware |
|
| Caffeine (caching) | moka |
|
| Guava, Apache Commons | Mostly the standard library, plus itertools |
|
java.time |
chrono or jiff |
See Date & Time |
| picocli | clap |
See Project: CLI with clap |
@Scheduled, Quartz |
tokio-cron-scheduler |
|
| Micrometer | metrics, or opentelemetry |
|
| Jakarta Mail | lettre |
Desktop, games, Android, and interop
Section titled “Desktop, games, Android, and interop”| Java | Rust | Notes |
|---|---|---|
| JavaFX / Swing | Tauri (web UI + Rust backend), Slint, egui, Iced |
Tauri 2 and Slint 1.x are the most stable. See GUI Apps |
| LibGDX, jMonkeyEngine | Bevy, macroquad |
Bevy is pre-1.0 and changes its API between releases. See Game Development |
| Android app code | Keep Kotlin or Java for the UI; put shared logic in a Rust library built with the Android NDK | Mozilla’s uniffi generates Kotlin bindings |
| Calling native code from Java | Expose extern "C" functions and call them through the FFM API (JDK 22+), or write JNI functions with the jni crate |
See FFI |
Side by side: an abstract class becomes a trait
Section titled “Side by side: an abstract class becomes a trait”Here’s a classic Java hierarchy: an abstract base class with one abstract method and one shared method.
abstract class Employee { protected final String name; Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " earns " + monthlyPay() + " a month"; }}
class Salaried extends Employee { private final int yearly; Salaried(String name, int yearly) { super(name); this.yearly = yearly; } @Override int monthlyPay() { return yearly / 12; }}
class Contractor extends Employee { private final int hours, rate; Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; } @Override int monthlyPay() { return hours * rate; }}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));for (Employee e : staff) System.out.println(e.payslip());Rust has no extends. The shared method moves into a trait as a default method, and each struct keeps its own data:
trait Employee { fn name(&self) -> &str; fn monthly_pay(&self) -> u32;
// A default method: like the concrete payslip() in the abstract class. fn payslip(&self) -> String { format!("{} earns {} a month", self.name(), self.monthly_pay()) }}
struct Salaried { name: String, yearly: u32,}
struct Contractor { name: String, hours: u32, rate: u32,}
impl Employee for Salaried { fn name(&self) -> &str { &self.name } fn monthly_pay(&self) -> u32 { self.yearly / 12 }}
impl Employee for Contractor { fn name(&self) -> &str { &self.name } fn monthly_pay(&self) -> u32 { self.hours * self.rate }}
fn main() { // Box<dyn Employee> plays the role of "a reference to some Employee". let staff: Vec<Box<dyn Employee>> = vec![ Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }), Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }), ];
for e in &staff { println!("{}", e.payslip()); }}Output:
Ada earns 5000 a monthLinus earns 6000 a monthA trait can’t hold fields, so the protected name becomes a small name() method each struct implements. That feels like extra typing at first, but it removes a whole class of problems: no fragile base classes, no super calls to forget, and no deep hierarchies to scroll through. If you know Java 8’s default methods on interfaces, you already know how Rust shares behavior. And when the set of types is fixed (like a sealed interface in Java 17), an enum with a match is often simpler than a trait.
Side by side: Optional becomes Option
Section titled “Side by side: Optional becomes Option”Java’s Optional was a big step forward, but it’s only used for return values, and map.get() or any field can still be null:
static Optional<String> findEmail(int userId) { return userId == 1 ? Optional.of("ada@example.com") : Optional.empty();}
for (int id : new int[] {1, 2}) { System.out.println(findEmail(id).map(String::toUpperCase).orElse("no email on file"));}findEmail(1).ifPresent(email -> System.out.println("sending to " + email));Rust’s Option has almost the same API, but it’s used everywhere a value might be missing, including collection lookups. And there’s no null hiding behind it:
fn find_email(user_id: u32) -> Option<String> { if user_id == 1 { Some("ada@example.com".to_string()) } else { None }}
fn main() { for id in [1, 2] { // map + orElseGet let shown = find_email(id) .map(|email| email.to_uppercase()) .unwrap_or_else(|| "no email on file".to_string()); println!("{shown}"); }
// ifPresent if let Some(email) = find_email(1) { println!("sending to {email}"); }
// flatMap is called and_then let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string())); println!("{domain:?}");}Output:
ADA@EXAMPLE.COMno email on filesending to ada@example.comSome("example.com")The compiler won’t let you call .to_uppercase() on an Option<String> until you’ve dealt with None, so NullPointerException simply doesn’t exist. Option also costs nothing extra: an Option<&T> is the same size as a plain reference. Learn the helper methods on the Option page.
Side by side: checked exceptions become Result
Section titled “Side by side: checked exceptions become Result”Checked exceptions had a good idea at heart: the signature tells you what can go wrong. Here’s a typical Java version:
class InsufficientFundsException extends Exception { InsufficientFundsException(int needed) { super("need " + needed + " more"); }}
static int withdraw(int balance, int amount) throws InsufficientFundsException { if (amount > balance) throw new InsufficientFundsException(amount - balance); return balance - amount;}
static int pay(int balance, int amount) throws InsufficientFundsException { int left = withdraw(balance, amount); System.out.println("paid " + amount + ", " + left + " left"); return left;}
try { pay(100, 80); pay(50, 80); }catch (InsufficientFundsException e) { System.out.println("error: " + e.getMessage()); }Rust keeps that good idea and drops the rest. The error is part of the return type (Result<u32, BankError>), and ? does what throws does: “if this failed, pass the error up to my caller”. Errors are usually an enum, so one type can list every way an operation can fail:
use std::fmt;
#[derive(Debug)]enum BankError { InvalidAmount, InsufficientFunds { needed: u32 },}
// Display plays the role of getMessage().impl fmt::Display for BankError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { BankError::InvalidAmount => write!(f, "amount must be more than zero"), BankError::InsufficientFunds { needed } => write!(f, "need {needed} more"), } }}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> { if amount == 0 { return Err(BankError::InvalidAmount); } if amount > balance { return Err(BankError::InsufficientFunds { needed: amount - balance }); } Ok(balance - amount)}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> { let left = withdraw(balance, amount)?; // like a call to a method that "throws" println!("paid {amount}, {left} left"); Ok(left)}
fn main() { for (balance, amount) in [(100, 80), (50, 80), (100, 0)] { if let Err(e) = pay(balance, amount) { println!("error: {e}"); } }}Output:
paid 80, 20 lefterror: need 30 moreerror: amount must be more than zeroNotice what’s gone: no try blocks, no stack unwinding, no exceptions wrapped in RuntimeException just to get them through a lambda. Errors are ordinary values, so they work fine inside closures and iterator chains. The ? is a visible marker at every spot where the function can return early. Writing Display by hand gets old fast; the thiserror crate generates it for you, and anyhow gives applications a catch-all error type (see anyhow & thiserror). For real bugs, the kind you’d signal with an unchecked exception, Rust has panic!.
Side by side: streams become iterators
Section titled “Side by side: streams become iterators”A stream pipeline with a groupingBy collector:
record Order(String customer, String city, int total) {}
List<Order> orders = List.of( new Order("Ada", "London", 120), new Order("Grace", "New York", 80), new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream() .filter(o -> o.total() > 100) .sorted(Comparator.comparingInt(Order::total).reversed()) .map(Order::customer) .toList();System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream() .collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));In Rust, .iter() plays the role of .stream(), and the adapters have familiar names:
use std::cmp::Reverse;use std::collections::BTreeMap;
struct Order { customer: String, city: String, total: u32,}
fn order(customer: &str, city: &str, total: u32) -> Order { Order { customer: customer.to_string(), city: city.to_string(), total }}
fn main() { let orders = vec![ order("Ada", "London", 120), order("Grace", "New York", 80), order("Linus", "Helsinki", 200), order("Ken", "New York", 150), ];
// filter + toList let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect(); // sorted(...reversed()): sorting happens on a Vec, not inside the iterator. big.sort_by_key(|o| Reverse(o.total)); // map let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect(); println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total)) let mut by_city: BTreeMap<&str, u32> = BTreeMap::new(); for o in &orders { *by_city.entry(o.city.as_str()).or_insert(0) += o.total; } for (city, sum) in &by_city { println!("{city}: {sum}"); }}Output:
Linus, Ken, AdaHelsinki: 200London: 120New York: 230Like streams, Rust iterators are lazy and run only when something consumes them (collect, sum, a for loop). Unlike streams, there’s no Collectors class: collect() looks at the type you ask for (Vec, HashMap, String, …) and builds it. For grouping, a plain loop with the entry API is the usual answer; the itertools crate adds helpers if you miss groupingBy. And iterator chains compile down to the same machine code as a hand-written loop, with no boxing of ints along the way. Read more in Iterators and Sorting.
Side by side: type erasure vs monomorphization
Section titled “Side by side: type erasure vs monomorphization”Java generics exist only at compile time. At runtime T is erased, so you can’t write new T(), and you can’t put an int in a List without boxing it:
static <T> List<T> filled(int n) { List<T> list = new ArrayList<>(); for (int i = 0; i < n; i++) list.add(new T()); // does not compile: T is erased return list;}
List<int> ids; // does not compile either: you need List<Integer>Rust compiles a separate copy of each generic function for every type you use it with (this is called monomorphization). So the function knows exactly what T is, and can call T::default():
use std::mem::size_of;
// "T must have a default value and be cloneable"fn filled<T: Default + Clone>(n: usize) -> Vec<T> { vec![T::default(); n]}
fn main() { let ids: Vec<i32> = filled(3); let names: Vec<String> = filled(2); let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// No boxing: a Vec<i32> stores plain 4-byte numbers side by side. println!("an i32 takes {} bytes", size_of::<i32>()); println!("a bool takes {} byte", size_of::<bool>());}Output:
[0, 0, 0] ["", ""] [false, false]an i32 takes 4 bytesa bool takes 1 byteThe trade-off: more copies of the code means bigger binaries and longer compile times, but every call is direct and can be inlined. When you want one shared copy (like Java’s behavior), use a trait object such as Box<dyn Trait>. The bounds after the colon (T: Default + Clone) are Rust’s version of <T extends ...>. See Generics and Advanced Generics.
Side by side: synchronized becomes Arc and Mutex
Section titled “Side by side: synchronized becomes Arc and Mutex”Four threads increment a shared counter. In Java, the lock lives on the object, and it’s up to you to remember synchronized:
class Counter { private int count = 0; synchronized void increment() { count++; } synchronized int get() { return count; }}
Counter counter = new Counter();List<Thread> threads = new ArrayList<>();for (int i = 0; i < 4; i++) { Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); }); t.start(); threads.add(t);}for (Thread t : threads) t.join();System.out.println("count = " + counter.get());In Rust, the Mutex owns the number. There is no way to reach it without locking first. Arc (an atomically reference-counted pointer) lets several threads share the mutex:
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { // Each thread gets its own handle to the same Mutex. let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { // lock() returns a guard; the lock is released when the guard is dropped. *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("count = {}", *counter.lock().unwrap());}Output:
count = 4000Here’s the part Java developers love: if you forget synchronized in Java, the code compiles and gives wrong numbers once in a while. In Rust, if you try to share a plain i32 or Vec between threads and change it, the program doesn’t compile. Data races are caught by the type system (through the Send and Sync traits). For a single counter, an AtomicUsize would be lighter than a mutex, just like AtomicInteger. See Concurrency and Shared State.
Things that will trip you up as a Java developer
Section titled “Things that will trip you up as a Java developer”Every Java developer hits the same bumps. Knowing them in advance makes them smaller.
-
Passing a value to a function moves it. In Java, passing an object passes a reference, and you can keep using your variable. In Rust, passing a
StringorVecby value moves it into the function:fn greet(name: String) {println!("Hello, {name}!");}fn main() {let name = String::from("Ada");greet(name);greet(name);}error[E0382]: use of moved value: `name`--> src/main.rs:8:11|6 | let name = String::from("Ada");| ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait7 | greet(name);| ---- value moved here8 | greet(name);| ^^^^ value used here after moveThe usual fix is to let the function borrow:
fn greet(name: &str)and callgreet(&name). Read Ownership first; it explains this rule, and E0382 covers this error in detail. -
The borrow checker. You can have many readers (
&T) or one writer (&mut T) at a time, never both. Object graphs that are normal in Java, like a child that points back to its parent, or many objects holding and changing one shared object, need smart pointers or a different design. The first few weeks can be frustrating. It does get easier. -
No inheritance, no
protected, nosuper. You’ll be tempted to recreate class hierarchies with traits. Try enums and composition first; they usually fit better. -
==compares values. Good news for once:a == bon twoStrings compares the text, so the classic==vs.equals()bug can’t happen. For your own structs,==only works after you derive or implementPartialEq. -
BinaryHeappops the largest item. Java’sPriorityQueuegives you the smallest first. In Rust, wrap items instd::cmp::Reverseto get min-heap behavior. See VecDeque & BinaryHeap. -
Integer overflow isn’t silent.
Integer.MAX_VALUE + 1quietly wraps in Java. In a Rust debug build it panics. Say what you mean withchecked_add,wrapping_add, orsaturating_add. See Integer Overflow. -
Stringvs&str, and nocharAt. Rust strings are UTF-8, sos[0]doesn’t compile. Uses.chars()to walk through characters. Functions usually take&str; structs usually storeString. See Strings. -
No method overloading. You can’t have two
printmethods with different parameters. Use different names (print,print_with_color), or a generic parameter. -
No runtime magic. There’s no classpath scanning, no
@Autowired, no proxies adding transactions around your methods. Everything is wired explicitly in code, and macros run at compile time. It’s more typing, but you can always see what happens. -
No
nulland no exceptions. At first,OptionandResulteverywhere feel like extra work. After a few weeks you’ll notice you haven’t seen aNullPointerExceptionor a surprise stack trace. Calling.unwrap()is fine while learning, but in real code it’s the Rust version of “let it crash”. -
Compile times. A clean build pulls in and compiles all dependencies from source, so it’s slower than
javac. Incremental builds are much quicker, andcargo check(which skips code generation) is what you’ll run most while editing. The good news on the other end: the program starts instantly, with no JVM warm-up. -
Async needs a runtime. There are no virtual threads. An
async fndoes nothing until an executor runs it, so web projects add tokio and put#[tokio::main]onmain. See Async/Await.
A learning path for Java developers
Section titled “A learning path for Java developers”You don’t need to read this site in order. This route skips what Java already taught you and spends time where Rust is different:
- Get Started and Rust Tools & Setup: install Rust, pick an editor, run
cargo new. - Variables and Data Types: skim these, mostly for
let mutand the number types. - Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
- Strings:
Stringvs&str. - Structs and Enums, then match and Pattern Matching.
- Option and Result, then Custom Errors and anyhow & thiserror.
- Traits, Generics, Common Traits, and Trait Objects: your interfaces and abstract classes.
- Closures and Iterators: your lambdas and streams.
- Vectors, HashMaps, and VecDeque & BinaryHeap.
- Modules, Cargo & Crates, and Testing.
- Lifetimes and Smart Pointers.
- Concurrency, Shared State, Channels, and Async/Await.
- Serde & JSON, Logging, and Rust Databases, then build the Web API project.
- Want to speed up an existing Java app? Read FFI to see how Rust exposes
extern "C"functions.
Practice
Section titled “Practice”Both exercises give you some Java. Port it to Rust.
1. Port a record and a stream pipeline
Section titled “1. Port a record and a stream pipeline”This code prints the students who passed, whether anyone got a perfect score, and the best score. Hint: iterators have any (like anyMatch) and max (which returns an Option, like OptionalInt). For Collectors.joining, collect into a Vec<&str> and call .join(", ").
record Student(String name, int score) {}
List<Student> students = List.of( new Student("Ada", 91), new Student("Brian", 42), new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream() .filter(s -> s.score() >= 50) .map(Student::name) .collect(Collectors.joining(", "));boolean perfect = students.stream().anyMatch(s -> s.score() == 100);int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("passed: " + passed);System.out.println("perfect score: " + perfect);System.out.println("best: " + best);Show solution
#[derive(Debug, Clone, PartialEq)]struct Student { name: String, score: u32,}
fn student(name: &str, score: u32) -> Student { Student { name: name.to_string(), score }}
fn main() { let students = vec![ student("Ada", 91), student("Brian", 42), student("Grace", 100), student("Ken", 67), ];
let passed: Vec<&str> = students .iter() .filter(|s| s.score >= 50) .map(|s| s.name.as_str()) .collect(); let perfect = students.iter().any(|s| s.score == 100); let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("passed: {}", passed.join(", ")); println!("perfect score: {perfect}"); println!("best: {best}");}Output:
passed: Ada, Grace, Kenperfect score: truebest: 100The derives give you what a Java record gives you for free: a debug string ({:?}), copying (.clone()), and value equality (==). The student helper plays the role of the record’s constructor. Unlike a Java stream, students.iter() can be called as many times as you like, because each call creates a new iterator over the same Vec.
2. Port a checked exception with a cause
Section titled “2. Port a checked exception with a cause”This method reads a port=<number> line from a config file. It throws a checked ConfigException, and wraps the NumberFormatException it gets from Integer.parseInt. Port it so that read_port returns a Result<u16, String>. Hint: line.split_once('=') returns an Option<(&str, &str)>; turn it into a Result with .ok_or_else(...). .parse::<u16>() returns a Result, and .map_err(...) swaps its error for your own message, just like wrapping one exception in another.
class ConfigException extends Exception { ConfigException(String message) { super(message); }}
static int readPort(String line) throws ConfigException { String[] parts = line.split("=", 2); if (parts.length != 2 || !parts[0].trim().equals("port")) throw new ConfigException("expected port=<number>, got '" + line + "'"); try { return Integer.parseInt(parts[1].trim()); } catch (NumberFormatException e) { throw new ConfigException("bad port number: " + parts[1].trim()); }}
for (String line : List.of("port = 8080", "port=eighty", "host=localhost")) { try { System.out.println("port is " + readPort(line)); } catch (ConfigException e) { System.out.println("config error: " + e.getMessage()); }}Show solution
fn read_port(line: &str) -> Result<u16, String> { let expected = || format!("expected port=<number>, got '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?; if key.trim() != "port" { return Err(expected()); }
let value = value.trim(); value .parse::<u16>() .map_err(|_| format!("bad port number: {value}"))}
fn main() { for line in ["port = 8080", "port=eighty", "host=localhost"] { match read_port(line) { Ok(port) => println!("port is {port}"), Err(e) => println!("config error: {e}"), } }}Output:
port is 8080config error: bad port number: eightyconfig error: expected port=<number>, got 'host=localhost'The throws ConfigException clause became the Result return type, the try/catch around parseInt became one map_err, and the throw became Err(...). Using u16 for the port is a small bonus: a number above 65535 is rejected by parse itself, which int in Java wouldn’t do. The little expected closure builds the error message only when it’s needed, like an orElseThrow(() -> ...) supplier.
- A lot of Java carries over: generics, interfaces (as traits), lambdas (as closures), and stream pipelines (as iterators).
- The big shifts are ownership instead of a garbage collector,
Optioninstead ofnull,Resultand?instead of exceptions, and traits plus composition instead of inheritance. - Generics are compiled per type (monomorphization), so there’s no type erasure and no boxing of numbers.
- Shared data between threads lives inside a
Mutex(wrapped in anArc), and forgetting to lock is a compile error, not a race. - Cargo replaces Maven or Gradle; clippy and rustfmt replace Checkstyle, SpotBugs and formatters; testing is built in.
- For Spring Boot look at axum or actix-web, for JPA look at SeaORM or Diesel, for JDBC and jOOQ look at sqlx, and for Jackson use serde.
Quick check
Section titled “Quick check”Quick check
1. A Java abstract class has one abstract method and one concrete method that calls it. What is the usual Rust replacement?
2. Java's PriorityQueue returns the smallest item first. What does BinaryHeap::pop return in Rust?
3. Why can a Rust generic function call T::default(), while Java can't write new T()?
4. You share a plain Vec between threads and try to push to it without a Mutex. What happens in Rust?
Score: 0 / 4
Read more
Section titled “Read more”- The Rust Programming Language: the official book. Chapters 4 (ownership), 9 (error handling), 10 (generics and traits), and 16 (concurrency) matter most for Java developers.
- std::collections: the standard library’s own guide to choosing between
Vec,VecDeque,HashMap,BTreeMap, and the rest. - JEP 454: Foreign Function & Memory API: the JDK 22 feature for calling native libraries, including Rust ones, from Java without writing JNI code.
- The jni crate on docs.rs: Rust bindings for JNI, if you need to call Rust from older JDKs or call back into Java.
- Are We Web Yet?: a community overview of Rust’s web frameworks, database libraries, and more, if you’re looking for your Spring Boot stack.