What is Java? The Language That Runs Minecraft, Your Bank, and Half the Internet
Java has been declared dead approximately forty times since 1995. It still runs Minecraft, Android, LinkedIn, Twitter's backend, and the financial systems that process your paycheck. Here is what the language actually is, how the JVM works, and why it refuses to die.
Java has been declared dead so many times it is practically a meme. Every few years, someone writes "Java is dying" and the post gets passed around developer communities like a badge of enlightenment. Then you look at actual production systems and find Java running LinkedIn, Twitter's backend services, Android applications, the financial infrastructure that processes your direct deposit, and a video game that has sold over 280 million copies.
I have strong opinions about Java. Some good, some bad. The language has genuine problems that took decades to address, and some of those problems are still being addressed. The verbosity was real. The enterprise culture that grew around it produced some of the worst over-engineered code I have ever read. The ecosystem created patterns like AbstractSingletonProxyFactoryBean that became jokes about unnecessary abstraction.
And yet. The JVM is one of the most sophisticated runtime environments ever built. Java's garbage collectors are pieces of engineering that people spend careers studying. The type system, for all its verbosity, catches entire categories of bugs that dynamic languages surface only in production. Modern Java, starting from Java 14 and accelerating through Java 21 and Java 26, is a substantially different language from the Java that earned its reputation for ceremony.
This article covers what Java is, how the JVM works, what Minecraft's relationship with Java tells us about the language, and what modern Java development actually looks like.
What Java Actually Is
Java is a statically typed, object-oriented, compiled-and-interpreted programming language created by James Gosling at Sun Microsystems. The first public release was Java 1.0 in January 1996. Oracle acquired Sun Microsystems in 2010 and has maintained Java since.
The design goals from the original white paper: simple, object-oriented, distributed, interpreted, robust, secure, architecture-neutral, portable, high-performance, multithreaded, and dynamic. Most of these goals have held up. "Simple" is debatable. The language added features for thirty years, and the accumulated complexity is real.
The defining characteristic is the JVM. Java source code compiles to bytecode, not to native machine code. The JVM executes that bytecode at runtime. This is what "write once, run anywhere" means: the bytecode is the same on every platform. The JVM handles the differences between Windows, Linux, and macOS. You compile once and the resulting bytecode runs on any machine that has a JVM installed.
This was revolutionary in 1996. Before Java, writing cross-platform software meant either writing in C with extensive platform-specific preprocessor guards, or shipping separate binaries for each operating system. Java's bytecode approach made platform portability a solved problem.
The JVM does more than just execute bytecode. It performs Just-In-Time (JIT) compilation, converting frequently executed bytecode into native machine code at runtime. HotSpot, the JVM shipped with standard JDK distributions, profiles code execution and identifies hot paths, then compiles those paths to native code optimized for the specific hardware. A Java program starts slightly slow and gets faster as the JVM learns which code paths matter.
The Syntax
Java is verbose by modern standards. Some of that verbosity is historical baggage. Some of it is deliberate explicitness that large teams find valuable. Here is what the language looks like:
public class User {
private final int id;
private final String username;
private final String email;
private final UserRole role;
public User(int id, String username, String email, UserRole role) {
this.id = id;
this.username = username;
this.email = email;
this.role = role;
}
public int getId() { return id; }
public String getUsername() { return username; }
public String getEmail() { return email; }
public UserRole getRole() { return role; }
@Override
public String toString() {
return "User{id=" + id + ", username='" + username + "', email='" + email + "'}";
}
}
public enum UserRole {
GUEST, USER, MODERATOR, ADMIN
}
This is a simple value object. Thirty lines for four fields. In Python you write @dataclass and move on. In Kotlin you write data class User(val id: Int, val username: String, val email: String, val role: UserRole) on one line.
Modern Java addresses this directly. Java 16 introduced records:
public record User(int id, String username, String email, UserRole role) {}
One line. The compiler generates the constructor, getters, equals(), hashCode(), and toString(). Records are immutable by design. This is the direction modern Java has been moving, and it is the right direction.
The JVM: What Makes Java Interesting
The JVM is the reason Java is worth taking seriously. It is not just a runtime. It is a managed execution environment that handles memory allocation, garbage collection, JIT compilation, thread scheduling, and security isolation.
Understanding the JVM means understanding Java memory management. The heap is where objects live. The stack is where method frames and local variables live. The JVM divides the heap into generations based on object lifetime: the Young Generation for new objects, the Old Generation for long-lived objects, and the Metaspace (previously PermGen) for class metadata.
Garbage Collection
Java's garbage collection is the feature that made it viable for server-side development. You do not manually allocate and free memory. The GC tracks which objects are reachable from your program's roots (local variables, static fields, thread stacks), marks everything reachable as alive, and collects everything else.
The cost is GC pauses. When the GC runs a full collection, it briefly stops all application threads. For interactive applications, a 200ms GC pause is visible lag. For latency-sensitive services, it is unacceptable.
Java ships with multiple garbage collectors optimized for different use cases:
G1GC (Garbage First) is the default since Java 9. It divides the heap into fixed-size regions, collects regions with the most garbage first, and targets a configurable maximum pause time. For most applications, G1GC is the right choice.
ZGC (Z Garbage Collector) is designed for very large heaps and sub-millisecond pause times. ZGC performs concurrent garbage collection without stopping application threads for more than a fraction of a millisecond, regardless of heap size. Production deployments with 100GB+ heaps and strict latency requirements use ZGC.
Shenandoah is Red Hat's concurrent GC, now included in OpenJDK. Similar goals to ZGC with different implementation tradeoffs.
ParallelGC is the throughput-optimized collector. It accepts longer pauses in exchange for maximum garbage collection throughput. Batch processing jobs that can tolerate pauses in exchange for raw throughput use ParallelGC.
You tune GC behavior through JVM flags:
java -XX:+UseZGC \
-Xms4g \
-Xmx4g \
-XX:+ZGenerational \
-XX:MaxGCPauseMillis=10 \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime,level,tags \
-jar myapp.jar
-Xms and -Xmx set the minimum and maximum heap size. Setting them equal prevents the JVM from resizing the heap, which eliminates heap resize pauses and is the standard practice for server deployments. -XX:+ZGenerational enables ZGC's generational mode, introduced in Java 21, which significantly improves throughput for most workloads.
JIT Compilation
The JVM's JIT compiler is what makes Java competitive with C++ for long-running server applications. The JVM starts by interpreting bytecode. It instruments execution to count how many times each method is called. When a method exceeds a threshold (default 10,000 invocations for server mode), the JIT compiles it to native machine code.
The compiled native code is not just a direct translation of the bytecode. The JIT applies optimizations: method inlining, dead code elimination, loop unrolling, escape analysis, and speculative optimizations based on runtime profiling. A Java method called millions of times ends up as native code optimized for the actual data types and execution patterns the JVM observed.
This is why Java benchmarks often show JVM warming up over the first few minutes of a server running. The JIT is still compiling and optimizing hot paths. After warmup, Java's throughput for CPU-bound computation is often within 10-20% of equivalent C++ code, which is remarkable for a managed runtime.
GraalVM's native image mode can AOT-compile Java applications to native executables, eliminating the startup time and warmup cost. The resulting binary starts in milliseconds instead of seconds, which matters enormously for CLI tools and serverless functions. The tradeoff is that AOT compilation cannot apply the runtime-informed optimizations that JIT compilation produces, so peak throughput may be lower for long-running applications.
Modern Java Features
Java releases every six months. Every three years, a Long-Term Support (LTS) release ships. Java 21 is the current LTS. Java 26 shipped in March 2025 as a feature release. The pace of language evolution has accelerated significantly compared to the Java 6 to Java 8 era.
Records
Records are immutable data carriers. The compiler generates all the boilerplate:
public record ProductRecord(
int id,
String name,
String slug,
java.math.BigDecimal price,
int stock,
String status
) {
public ProductRecord {
if (price.compareTo(java.math.BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Price must be positive");
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be blank");
}
name = name.strip();
}
public boolean inStock() {
return stock > 0;
}
public ProductRecord withStock(int newStock) {
return new ProductRecord(id, name, slug, price, newStock, status);
}
}
The compact constructor (the block without parameter list) runs validation and normalization logic before the record is created. Records are final. They cannot be extended. They are automatically serializable if all their components are serializable. They work cleanly with Java's pattern matching.
Sealed Classes and Pattern Matching
Sealed classes restrict which classes can extend or implement them. Combined with pattern matching in switch, they enable exhaustive case handling that the compiler verifies:
public sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
double perimeter();
}
public record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
public double perimeter() { return 2 * Math.PI * radius; }
}
public record Rectangle(double width, double height) implements Shape {
public double area() { return width * height; }
public double perimeter() { return 2 * (width + height); }
}
public record Triangle(double a, double b, double c) implements Shape {
public Triangle {
if (a + b <= c || b + c <= a || a + c <= b) {
throw new IllegalArgumentException("Invalid triangle sides");
}
}
public double area() {
double s = (a + b + c) / 2;
return Math.sqrt(s * (s - a) * (s - b) * (s - c));
}
public double perimeter() { return a + b + c; }
}
public class ShapeProcessor {
public static String describe(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 100 -> "Large circle with radius " + c.radius();
case Circle c -> "Circle with radius " + c.radius();
case Rectangle r when r.width() == r.height() -> "Square with side " + r.width();
case Rectangle r -> "Rectangle " + r.width() + "x" + r.height();
case Triangle t -> String.format("Triangle with perimeter %.2f", t.perimeter());
};
}
public static double totalArea(java.util.List<Shape> shapes) {
return shapes.stream().mapToDouble(Shape::area).sum();
}
}
The switch on shape is exhaustive. The compiler knows that Shape can only be Circle, Rectangle, or Triangle because of the sealed permits declaration. If you add a fourth permitted type and forget to add a case, the compiler rejects the code. This is the kind of safety guarantee that prevents entire categories of bugs at compile time.
Pattern matching with when guards lets you refine cases without nested if statements. The code reads linearly: handle the specific case, fall through to the general case.
Virtual Threads
Virtual threads are the most significant addition to Java's concurrency model since java.util.concurrent landed in Java 5. They landed as a preview in Java 19 and as a production feature in Java 21.
Traditional Java threads map 1:1 to OS threads. Creating thousands of threads is expensive in memory (each thread has a 512KB to 1MB stack by default) and in OS scheduling overhead. Server applications that handle concurrent requests by assigning one thread per request hit practical limits around 10,000-20,000 concurrent threads.
Virtual threads are lightweight threads managed by the JVM, not the OS. You can create millions of them. When a virtual thread blocks (waiting for I/O, sleeping, waiting on a lock), the JVM unmounts it from its carrier OS thread and the carrier thread is free to execute another virtual thread. When the blocking operation completes, the JVM mounts the virtual thread on any available carrier thread and resumes execution.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
public class VirtualThreadDemo {
record ApiResult(int id, int statusCode, long durationMs, String body) {}
public static List<ApiResult> fetchUrlsConcurrently(List<String> urls) throws InterruptedException {
var results = new java.util.concurrent.CopyOnWriteArrayList<ApiResult>();
var client = HttpClient.newHttpClient();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = urls.stream().map(url -> CompletableFuture.runAsync(() -> {
var start = System.currentTimeMillis();
try {
var request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(java.time.Duration.ofSeconds(10))
.GET()
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var duration = System.currentTimeMillis() - start;
results.add(new ApiResult(
urls.indexOf(url),
response.statusCode(),
duration,
response.body().substring(0, Math.min(100, response.body().length()))
));
} catch (Exception e) {
results.add(new ApiResult(urls.indexOf(url), 0, System.currentTimeMillis() - start, e.getMessage()));
}
}, executor)).toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
return results;
}
public static void demonstrateMillionVirtualThreads() throws Exception {
var start = System.currentTimeMillis();
var counter = new java.util.concurrent.atomic.AtomicInteger(0);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 1_000_000)
.mapToObj(i -> CompletableFuture.runAsync(() -> {
Thread.sleep(java.time.Duration.ofMillis(100));
counter.incrementAndGet();
}, executor))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
var elapsed = System.currentTimeMillis() - start;
System.out.printf("Ran %d virtual threads in %dms%n", counter.get(), elapsed);
}
}
Executors.newVirtualThreadPerTaskExecutor() creates an executor that spawns a new virtual thread for each submitted task. One million virtual threads blocking for 100ms each completes in roughly 100ms plus overhead on a modern machine. The same workload with platform threads would require careful thread pool tuning and would likely fail with OutOfMemoryError or hit OS thread limits.
Virtual threads change how you write Java I/O code. The previous approach was either blocking I/O with a thread pool (limited by thread count) or non-blocking async code with callbacks or reactive streams (complex, error-prone). Virtual threads let you write simple, linear, blocking code that scales like async code. The JVM handles the scheduling complexity.
The one caveat: synchronized blocks pin virtual threads to their carrier OS thread. If your virtual thread holds a synchronized lock while blocking on I/O, it occupies an OS thread the entire time, eliminating the benefit. Java 24 and 25 have been progressively removing synchronized pinning from standard library code. For new code, use ReentrantLock instead of synchronized for locking that spans blocking operations.
Text Blocks and String Templates
String sql = """
SELECT u.id, u.username, u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.is_active = true
AND u.created_at > :cutoff_date
GROUP BY u.id, u.username, u.email
ORDER BY order_count DESC
LIMIT :limit
""";
String json = """
{
"status": "success",
"data": {
"userId": %d,
"username": "%s",
"email": "%s"
}
}
""".formatted(user.getId(), user.getUsername(), user.getEmail());
Text blocks, introduced in Java 15, eliminate the string concatenation that made SQL and JSON in Java so painful to read. The compiler handles indentation stripping based on the position of the closing """. The resulting string has no leading whitespace from the code indentation.
Spring Boot: Java in Production
Raw Java for web services requires significant boilerplate. Spring Boot is the framework that eliminated that boilerplate and became the dominant platform for Java backend services. LinkedIn, Netflix, Amazon internal services, and thousands of enterprise applications run Spring Boot.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.domain.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Entity
@Table(name = "products", indexes = {
@Index(name = "idx_products_slug", columnList = "slug", unique = true),
@Index(name = "idx_products_status_created", columnList = "status, created_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
@NotBlank
private String name;
@Column(nullable = false, unique = true, length = 255)
private String slug;
@Column(nullable = false, columnDefinition = "TEXT")
private String description;
@Column(nullable = false, precision = 10, scale = 2)
@DecimalMin("0.01")
private BigDecimal price;
@Column(nullable = false)
@Min(0)
private Integer stock;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ProductStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public boolean isInStock() {
return stock > 0;
}
public synchronized boolean reserveStock(int quantity) {
if (stock < quantity) return false;
stock -= quantity;
return true;
}
}
public enum ProductStatus {
DRAFT, ACTIVE, ARCHIVED
}
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySlug(String slug);
Page<Product> findByStatusAndCategorySlug(
ProductStatus status,
String categorySlug,
Pageable pageable
);
@Query("""
SELECT p FROM Product p
WHERE p.status = 'ACTIVE'
AND (LOWER(p.name) LIKE LOWER(CONCAT('%', :query, '%'))
OR LOWER(p.description) LIKE LOWER(CONCAT('%', :query, '%')))
""")
Page<Product> searchActive(@Param("query") String query, Pageable pageable);
@Query("SELECT p FROM Product p WHERE p.status = 'ACTIVE' AND p.stock > 0 ORDER BY p.createdAt DESC")
List<Product> findTopInStock(Pageable pageable);
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id AND p.stock >= :quantity")
int decrementStock(@Param("id") Long id, @Param("quantity") int quantity);
}
public record ProductResponse(
Long id, String name, String slug, BigDecimal price,
int stock, boolean inStock, ProductStatus status, Instant createdAt
) {
public static ProductResponse from(Product product) {
return new ProductResponse(
product.getId(), product.getName(), product.getSlug(),
product.getPrice(), product.getStock(), product.isInStock(),
product.getStatus(), product.getCreatedAt()
);
}
}
public record CreateProductRequest(
@NotBlank String name,
@NotBlank String description,
@DecimalMin("0.01") BigDecimal price,
@Min(0) int stock,
@NotNull Long categoryId
) {}
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
@GetMapping
public ResponseEntity<Page<ProductResponse>> listProducts(
@RequestParam(required = false) String category,
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String sortDir
) {
var sort = Sort.by(
sortDir.equalsIgnoreCase("asc") ? Sort.Direction.ASC : Sort.Direction.DESC,
sortBy
);
var pageable = PageRequest.of(page, Math.min(size, 100), sort);
Page<Product> products;
if (search != null && !search.isBlank()) {
products = productRepository.searchActive(search.trim(), pageable);
} else if (category != null && !category.isBlank()) {
products = productRepository.findByStatusAndCategorySlug(
ProductStatus.ACTIVE, category, pageable
);
} else {
products = productRepository.findAll(pageable);
}
return ResponseEntity.ok(products.map(ProductResponse::from));
}
@GetMapping("/{slug}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable String slug) {
return productRepository.findBySlug(slug)
.map(ProductResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody CreateProductRequest request) {
var category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category not found"));
var slug = generateSlug(request.name());
if (productRepository.findBySlug(slug).isPresent()) {
slug = slug + "-" + java.util.UUID.randomUUID().toString().substring(0, 8);
}
var product = Product.builder()
.name(request.name())
.slug(slug)
.description(request.description())
.price(request.price())
.stock(request.stock())
.category(category)
.status(ProductStatus.DRAFT)
.build();
var saved = productRepository.save(product);
return ResponseEntity.status(201).body(ProductResponse.from(saved));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
if (!productRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
productRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private String generateSlug(String name) {
return name.toLowerCase()
.replaceAll("[^a-z0-9\\s-]", "")
.replaceAll("\\s+", "-")
.replaceAll("-+", "-")
.strip();
}
}
@SpringBootApplication enables Spring's component scanning, auto-configuration, and configuration property binding. The framework scans your classpath, detects what libraries you have, and configures sensible defaults. You override the defaults where you need to. There is no XML configuration. There is no deployment to an application server. You run java -jar myapp.jar and the embedded Tomcat server starts.
Lombok's annotations (@Getter, @Setter, @Builder, etc.) generate boilerplate at compile time via annotation processing. This is a practical solution until Java gets more first-class data handling (which records partially address). Lombok is controversial in some circles, but it is widely used in production Java.
Spring Data JPA's repository interface ProductRepository extends JpaRepository<Product, Long> provides CRUD operations, pagination, and sorting without any implementation code. Spring generates a proxy implementation at runtime. The findBySlug(String slug) method is derived from the method name: Spring parses it, recognizes the findBy prefix and the Slug field name, and generates the appropriate JPQL query.
The @Query annotation with JPQL (Java Persistence Query Language) handles complex queries that the method naming convention cannot express. The @Modifying with @Query runs a bulk update, which is far more efficient than loading all affected entities, modifying them in Java, and letting JPA batch the updates.
Minecraft and Java
Minecraft is the most commercially successful Java application in history. Mojang released it in 2009 as a Java application. It has sold over 280 million copies across all platforms. The Java Edition, the original desktop version, still ships as a Java application today.
The Java Edition is why the Minecraft modding ecosystem exists. Java bytecode is trivially decompilable. Tools like Fernflower and CFR convert Java bytecode back to readable source code. The modding community reverse-engineered Minecraft's internals, built deobfuscation mappings, and created frameworks like Forge and Fabric that let developers hook into the game's systems.
I have written about upgrading Minecraft servers between Java versions, which is its own operational concern. The short version: major Java version upgrades affect server performance, GC behavior, and occasionally plugin compatibility. Paper and Purpur servers actively optimize for newer Java versions and JVM flags. The tooling around Minecraft server administration is deeply tied to Java's release cycle in ways most Java developers never encounter.
The relationship between Java version upgrades and Minecraft performance is a live example of JVM tuning in action. When Java 21 shipped ZGC with generational mode, Minecraft server administrators began experimenting with --add-modules=jdk.incubator.vector and ZGC flags to reduce GC pause times. The Java 22 article on this site covers what that update specifically changed for Minecraft and enterprise workloads.
For Minecraft mod development:
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.event.RegisterCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod.EventBusSubscriber;
import com.mojang.brigadier.Command;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
@Mod("examplemod")
public class ExampleMod {
public static final String MOD_ID = "examplemod";
public ExampleMod() {
var bus = net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext.get().getModEventBus();
bus.addListener(this::setup);
}
private void setup(net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent event) {
System.out.println("ExampleMod initialized");
}
@EventBusSubscriber(modid = MOD_ID, bus = EventBusSubscriber.Bus.FORGE)
public static class ForgeEvents {
@SubscribeEvent
public static void onRegisterCommands(RegisterCommandsEvent event) {
event.getDispatcher().register(
Commands.literal("example")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("hello")
.executes(context -> {
var player = context.getSource().getPlayerOrException();
player.sendSystemMessage(
Component.literal("Hello from ExampleMod!")
);
return Command.SINGLE_SUCCESS;
})
)
);
}
}
}
Minecraft modding uses Brigadier, Microsoft's command parsing library originally built for Minecraft Bedrock. The event-driven architecture through Forge's event bus is how mods hook into game systems without modifying base game code. Events fire for player interactions, block placements, entity spawns, chunk generation, and essentially every significant game event. Mods register listeners and respond.
This is the primary pipeline for millions of developers who learn Java through Minecraft modding. The language's barrier to entry drops when you have a concrete, visually engaging application. Someone who wants to add custom ores to their Minecraft world and googles "Minecraft modding tutorial" is going to write Java.
Java Concurrency
Java's concurrency story spans thirty years and several distinct models. Understanding it requires knowing which model fits which problem.
The original Java concurrency model used synchronized methods and blocks, wait(), notify(), and notifyAll(). Low-level and error-prone. Java 5 introduced java.util.concurrent, which added thread pools, concurrent collections, atomic operations, latches, semaphores, and higher-level coordination primitives. Java 8 added CompletableFuture for composing async operations. Java 21 added virtual threads and structured concurrency.
Here is a practical concurrent system with the modern Java approach:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.List;
import java.util.ArrayList;
import java.time.Duration;
public class TaskProcessor {
private final ExecutorService executor;
private final AtomicLong processedCount = new AtomicLong(0);
private final AtomicLong failedCount = new AtomicLong(0);
public TaskProcessor() {
this.executor = Executors.newVirtualThreadPerTaskExecutor();
}
public record ProcessingResult(String taskId, Object result, boolean success, String error) {
public static ProcessingResult success(String taskId, Object result) {
return new ProcessingResult(taskId, result, true, null);
}
public static ProcessingResult failure(String taskId, String error) {
return new ProcessingResult(taskId, null, false, error);
}
}
public List<ProcessingResult> processBatch(List<String> taskIds) throws InterruptedException {
var futures = taskIds.stream()
.map(taskId -> CompletableFuture.supplyAsync(
() -> processTask(taskId), executor
))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(f -> {
try {
return f.get();
} catch (Exception e) {
return ProcessingResult.failure("unknown", e.getMessage());
}
})
.toList();
}
private ProcessingResult processTask(String taskId) {
try {
Thread.sleep(Duration.ofMillis(50));
processedCount.incrementAndGet();
return ProcessingResult.success(taskId, "Processed: " + taskId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failedCount.incrementAndGet();
return ProcessingResult.failure(taskId, "Interrupted");
} catch (Exception e) {
failedCount.incrementAndGet();
return ProcessingResult.failure(taskId, e.getMessage());
}
}
public record Stats(long processed, long failed) {}
public Stats getStats() {
return new Stats(processedCount.get(), failedCount.get());
}
public void shutdown() throws InterruptedException {
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
public static void main(String[] args) throws InterruptedException {
var processor = new TaskProcessor();
var taskIds = new ArrayList<String>();
for (int i = 0; i < 10000; i++) {
taskIds.add("task-" + i);
}
var start = System.currentTimeMillis();
var results = processor.processBatch(taskIds);
var elapsed = System.currentTimeMillis() - start;
var stats = processor.getStats();
System.out.printf("Processed %d tasks in %dms%n", results.size(), elapsed);
System.out.printf("Success: %d, Failed: %d%n", stats.processed(), stats.failed());
processor.shutdown();
}
}
10,000 tasks that each sleep 50ms complete in roughly 50-100ms total because virtual threads handle all of them concurrently. With platform threads and a pool of 200, the same workload takes 2.5 seconds (10,000 tasks / 200 concurrent * 50ms each). Virtual threads remove the thread count as a bottleneck for I/O-bound work.
AtomicLong is a thread-safe counter without locks. Operations like incrementAndGet() use CPU-level compare-and-swap instructions. No synchronized block needed. No contention for a lock. For simple counters and flags, atomic operations are faster and simpler than explicit locking.
The JVM Ecosystem
The JVM hosts multiple production-quality languages beyond Java. This is a genuine advantage: you choose the language that fits your team and problem, while sharing the same runtime infrastructure, tooling, and libraries.
Kotlin is the language Google chose for Android development. It is concise (data classes eliminate all Java boilerplate), null-safe (the type system distinguishes nullable and non-nullable types at compile time), and interoperates bidirectionally with Java. Kotlin code can call Java libraries. Java code can call Kotlin classes. Existing Java projects migrate incrementally. Kotlin on the JVM compiles to Java bytecode. Kotlin Multiplatform compiles to native code for iOS and Wasm for the browser.
Scala is a strongly typed functional-and-object-oriented language that sparked the reactive programming movement in the JVM ecosystem. Twitter, LinkedIn, and many data infrastructure projects run Scala. The learning curve is steep. The type system is expressive enough to encode invariants that Java's type system cannot. Apache Spark, the dominant big data processing framework, is Scala.
Groovy is the scripting language of the JVM. Gradle build scripts are Groovy (or Kotlin). Jenkins pipelines are Groovy. It is dynamically typed, syntactically similar to Java, and embeds naturally in Java applications as a scripting layer.
Clojure is a Lisp that runs on the JVM. It is immutable by default, concurrency-oriented, and has a small but devoted following in financial technology and data processing.
The shared infrastructure matters. A Spring Boot application can use Kotlin instead of Java with no framework changes. A Kafka consumer can be written in Scala. Your data pipeline can use Python (via Jython, or calling into JVM via GraalVM Polyglot). The JVM is a platform, not just a language runtime.
Java's Genuine Problems
The "Java is dying" crowd has legitimate complaints. I am not going to pretend they do not.
Checked exceptions forced you to catch or declare every exception a method might throw. In theory, this makes error handling explicit and auditable. In practice, it produced vast amounts of boilerplate try-catch blocks that swallowed exceptions, wrapped them in RuntimeException, or declared throws Exception on every method signature, defeating the purpose entirely. Modern Java has not removed checked exceptions, but the ecosystem has mostly stopped using them.
Null references are the "billion dollar mistake" that Tony Hoare coined the phrase for. Java references can be null. Calling a method on a null reference throws NullPointerException. Java added Optional<T> in Java 8 to make nullability explicit in return types, and Java 21 added Helpful NullPointerExceptions that tell you exactly which variable was null. But null is still in the language.
Verbose generics with erasure. Java generics are erased at runtime. A List<String> and List<Integer> are the same type to the JVM at runtime. This leads to unchecked cast warnings, instanceof checks against raw types, and limitations like not being able to create new T[]. Project Valhalla is working on generic specialization with value types, but this has been "coming soon" for years.
Startup time. A simple Java application takes several hundred milliseconds to start. This is manageable for long-running servers but painful for CLI tools and serverless functions where every invocation pays the startup cost. GraalVM native image solves this but introduces its own constraints around reflection and dynamic class loading.
The OpenJDK ecosystem fragmentation. "Java" is not a single thing. Oracle JDK, OpenJDK, Azul Zulu, Amazon Corretto, Eclipse Temurin, GraalVM, Microsoft Build of OpenJDK: all are valid Java distributions with different support timelines, licensing terms, and optional features. For someone new to the ecosystem, choosing a JDK is a confusing first step.
Who Runs Java at Scale
Java is the dominant language for enterprise backend services. The scale of Java in production is not a historical artifact. Companies actively choose it for new projects.
LinkedIn runs a significant portion of their backend infrastructure on Java. Their Kafka message broker, originally built at LinkedIn, is Java. The Samza stream processing framework, also from LinkedIn, is Java.
Netflix built much of their microservices infrastructure in Java, running on Spring Boot. They have contributed significantly to the Spring ecosystem, including the Hystrix circuit breaker and Ribbon load balancer.
Amazon runs enormous Java workloads across AWS services. The AWS SDK for Java is one of the most widely used client libraries in the ecosystem. Significant internal Amazon infrastructure runs on Java.
Twitter (now X) ran their core backend services on Java and Scala. The Finagle RPC framework used internally at Twitter, which influenced many other systems, is Scala/JVM.
Apache Kafka, the dominant distributed messaging platform, is Java. Hundreds of thousands of production deployments run it daily. Apache Spark, the de facto standard for large-scale data processing, is Scala on the JVM. Elasticsearch, the search engine behind millions of search features, is Java. Apache Cassandra, widely used for time-series and IoT data, is Java.
The pattern is infrastructure. When the developers who build developer tools and data infrastructure need something to run for years with predictable performance, they reach for the JVM. The GC guarantees, the tooling around heap dumps and thread profiling, the mature monitoring ecosystem, and the years of JIT optimization research make the JVM the most well-understood production runtime for long-running services.
Virtual Threads and the Concurrency Future
The CompletableFuture article on this site covers the pain that virtual threads are solving. The pre-Java-21 story for high-concurrency Java involved either:
- Blocking I/O with thread pools, limited by thread count and memory
- Reactive programming with Project Reactor or RxJava, which works but produces code that is genuinely hard to read and debug
- Async callbacks with CompletableFuture, which chains nicely but loses stack trace context when exceptions occur
Virtual threads change the calculus entirely. You write blocking, linear code. The JVM schedules it efficiently. Stack traces are readable. Debugging works normally. The performance of the concurrent-aware async model with the readability of sequential code.
Spring Boot 3.2 added virtual thread support. You enable it with one property:
spring.threads.virtual.enabled=true
That is it. Tomcat's request handling switches from platform threads to virtual threads. Your existing code, unchanged, handles significantly more concurrent requests with the same hardware.
Java in 2025
The Java you learned in 2008, or 2015, or even 2020 is not the Java that ships today. Records, sealed classes, pattern matching in switch, text blocks, the Stream API improvements, ZGC with generational mode, virtual threads, structured concurrency (preview in Java 21, progressing toward production), string templates: the language has been systematically addressing its historical weaknesses.
The release cadence matters. Every six months, a new feature release. Every three years, an LTS. The six-month cycle means features ship quickly and the community can give feedback before the feature finalizes. The LTS cycle means production deployments have a stable target to run on.
Java has survived the rise and fall of multiple competitors that were going to replace it. Scala, which ran on the JVM and had better type inference and functional programming support, has a strong community but never replaced Java. Groovy, Clojure, Kotlin: all JVM languages that offer different tradeoffs. Kotlin in particular has taken significant market share for Android development and some backend work. But Java remains the dominant JVM language for backend services.
The language is not perfect. The verbosity is real. The checked exception legacy creates friction. Null references remain. But the JVM is magnificent infrastructure, the tooling is mature, the library ecosystem is enormous, and modern Java is genuinely enjoyable to write compared to the Java 6 era that shaped most developers' opinions.
For people who manage Minecraft infrastructure and want to understand the underlying runtime, for enterprise developers who want to understand what they are actually running, and for anyone new to the language who wants to know why it persists: the JVM is why. Modern Java is why. The ecosystem is why.
Declaring Java dead is a pastime. Running Java in production is a choice that hundreds of thousands of engineering teams make every day, deliberately, with full awareness of the alternatives.