DEV Community

Cover image for **5 Essential Techniques to Optimize Java Applications for Kubernetes in 2024**
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

**5 Essential Techniques to Optimize Java Applications for Kubernetes in 2024**

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Running Java applications inside Kubernetes can feel like trying to fit a familiar, comfortable piece of furniture into a sleek, modern, automated home. The furniture is solid and works well, but the new house operates by different rules. For a long time, Java applications ran on dedicated servers or virtual machines they could call their own. They assumed they had all the memory and CPU they could see. Kubernetes changes that. It's a shared environment where applications live in ephemeral containers, and the system decides where they run and how many resources they get. If we don't adjust our Java applications for this new reality, they can be inefficient, unstable, and difficult to manage.

I want to share some practical ways to bridge this gap. These techniques help your Java application speak Kubernetes' language, making it a good citizen in the container ecosystem. This leads to applications that start faster, use resources wisely, scale smoothly, and let the platform help them when things go wrong.

The first and most important conversation your application has with Kubernetes is about health. Kubernetes needs to know: is your application alive, and is it ready for traffic? This is done through probes. Think of a liveness probe as a check on a patient's heartbeat. If the heartbeat stops, Kubernetes restarts the container. A readiness probe is like checking if the patient is awake and alert enough to have a conversation. If not, Kubernetes stops sending new requests until they are ready.

A common mistake is to make these checks too simple. An endpoint that just returns "OK" might report healthy even if the database connection is dead or an internal cache has failed. Your health checks need to reflect your application's true internal state. In Spring Boot, the Actuator module is a great starting point, but you often need to go further.

Here’s how I might implement meaningful checks. A liveness probe can be simple—it just confirms the app is running. The readiness probe does the real work, checking critical dependencies.

@RestController
public class AppHealthController {

    @GetMapping("/health/live")
    public ResponseEntity<?> liveness() {
        // Is the app process functioning?
        return ResponseEntity.ok().build();
    }

    @GetMapping("/health/ready")
    public ResponseEntity<?> readiness() {
        // Can we serve requests? Check key dependencies.
        if (!databaseHealthChecker.isConnected()) {
            return ResponseEntity.status(503).body("Database connection failed");
        }
        if (messageQueueHealthChecker.isBackedUp()) {
            return ResponseEntity.status(503).body("Queue processing is delayed");
        }
        return ResponseEntity.ok().body("Application is ready");
    }
}
Enter fullscreen mode Exit fullscreen mode

You then tell Kubernetes about these endpoints in your container configuration. The timing is crucial. initialDelaySeconds gives your JVM time to start up. periodSeconds defines how often to check.

# In your deployment.yaml file
containers:
- name: java-app
  livenessProbe:
    httpGet:
      path: /health/live
      port: 8080
    initialDelaySeconds: 90  # Give the JVM time to initialize
    periodSeconds: 10
  readinessProbe:
    httpGet:
      path: /health/ready
      port: 8080
    initialDelaySeconds: 30
    periodSeconds: 5
    failureThreshold: 2      # Two failures mark it as not ready
Enter fullscreen mode Exit fullscreen mode

The second critical adjustment is resource management. In a traditional setup, you might set your JVM's maximum heap to a fixed number, like -Xmx4g. In Kubernetes, your container has a strict memory limit. If the total memory used by the JVM and your application exceeds this limit, Kubernetes will terminate the container without warning. It's not a graceful Java OutOfMemoryError; it's a sudden stop.

Modern JVMs (Java 8u131+, Java 10+) include flags specifically for containers. These flags make the JVM aware of the container's limits, not the underlying host machine's memory.

# In your Dockerfile
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport \
                       -XX:MaxRAMPercentage=75.0 \
                       -XX:InitialRAMPercentage=50.0 \
                       -XX:+UseG1GC \
                       -XX:+ExitOnOutOfMemoryError"
Enter fullscreen mode Exit fullscreen mode

Let me explain these. UseContainerSupport tells the JVM to look at cgroup limits. MaxRAMPercentage=75.0 means the heap will be set to 75% of the container's memory limit. This leaves 25% for the JVM's own overhead, native memory, and other processes in the container. ExitOnOutOfMemoryError is vital—if a genuine heap exhaustion happens, it's better to fail fast and let Kubernetes restart the pod than to limp along in a corrupted state.

Sometimes, you need to make runtime decisions based on available resources. You can read this information directly from the container's cgroup filesystem.

public class ContainerResources {

    public static long getMemoryLimitBytes() {
        try {
            Path path = Paths.get("/sys/fs/cgroup/memory/memory.limit_in_bytes");
            String memStr = Files.readString(path).trim();
            return Long.parseLong(memStr);
        } catch (Exception e) {
            // Fallback for non-containerized environments
            return Runtime.getRuntime().maxMemory();
        }
    }

    public static int getCpuCount() {
        try {
            // Read CPU quota and period
            long quota = Long.parseLong(Files.readString(Paths.get("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")).trim());
            long period = Long.parseLong(Files.readString(Paths.get("/sys/fs/cgroup/cpu/cpu.cfs_period_us")).trim());

            if (quota > 0 && period > 0) {
                return (int) Math.ceil((double) quota / period);
            }
        } catch (Exception e) {
            // Fallback
        }
        return Runtime.getRuntime().availableProcessors();
    }
}
Enter fullscreen mode Exit fullscreen mode

You can use this to configure thread pools dynamically, creating an application that scales its internal parallelism based on the CPU it's been granted.

The third technique is about configuration. In the past, you might have used a standalone configuration server. While that's still possible, Kubernetes offers a native way to manage configuration that reduces external dependencies: ConfigMaps and Secrets. Your configuration becomes part of your deployment manifest, versioned and deployed alongside your code.

The simplest method is to inject configuration as environment variables. This works well for individual properties.

# A ConfigMap definition
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  database.url: "jdbc:postgresql://primary-db:5432/appdb"
  feature.flags.enabled: "true"
---
# In your Deployment, using the ConfigMap
containers:
- name: app
  env:
  - name: DATABASE_URL
    valueFrom:
      configMapKeyRef:
        name: app-config
        key: database.url
  - name: FEATURE_FLAGS_ENABLED
    valueFrom:
      configMapKeyRef:
        name: app-config
        key: feature.flags.enabled
Enter fullscreen mode Exit fullscreen mode

Your Java application then reads these just like any other system environment variable.

@Configuration
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        // Read from the injected environment variable
        config.setJdbcUrl(System.getenv("DATABASE_URL"));
        config.setUsername(System.getenv("DB_USER")); // From a Secret
        config.setPassword(System.getenv("DB_PASS")); // From a Secret
        return new HikariDataSource(config);
    }
}
Enter fullscreen mode Exit fullscreen mode

For more complex configuration files (like an application.properties or logback.xml), you can mount a ConfigMap as a read-only volume inside the container. The file appears in the container's filesystem, and your app can read it normally. This is very useful for frameworks that expect configuration files in a specific location.

The fourth technique is handling shutdown gracefully. When Kubernetes decides to terminate a pod—perhaps for a rolling update, scaling down, or because the node is being drained—it doesn't just kill the process. It sends a SIGTERM signal. This is your application's chance to finish what it's doing, clean up, and exit politely. If you don't handle this, ongoing requests are cut off, database transactions might be left hanging, and the user gets an error.

You need a shutdown hook. In Spring Boot, graceful shutdown is often built-in if you enable it. But you should also ensure your own components are aware of the shutdown signal.

@Component
public class ShutdownManager {

    private static volatile boolean shutdownInitiated = false;

    @PostConstruct
    public void init() {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            shutdownInitiated = true;
            System.out.println("Shutdown signal received. Starting cleanup.");
            // 1. Stop accepting new requests (health endpoint returns 503)
            // 2. Wait for ongoing requests to finish (e.g., for 30 seconds)
            // 3. Close connections to databases, message brokers, etc.
            // 4. Flush any in-memory buffers to disk.
            System.out.println("Cleanup complete. Exiting.");
        }));
    }

    public static boolean isShuttingDown() {
        return shutdownInitiated;
    }
}

// In your request controller or service
@GetMapping("/process")
public ResponseEntity<?> processRequest() {
    if (ShutdownManager.isShuttingDown()) {
        return ResponseEntity.status(503)
                .header("Retry-After", "30")
                .body("Service is shutting down");
    }
    // ... normal processing
}
Enter fullscreen mode Exit fullscreen mode

You can configure Spring Boot's graceful shutdown period to give your in-flight requests a deadline to complete.

# application.yaml
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
Enter fullscreen mode Exit fullscreen mode

The final technique is observability. When something goes wrong in a distributed system with dozens of containers, you need clear signals to find the problem. Kubernetes ecosystems standardize on three pillars: logs, metrics, and traces. Your Java application needs to output data in a way these tools can easily consume.

For logs, write structured JSON to the standard output. Kubernetes captures stdout/stderr by default. Tools like Fluentd or Loki can collect these logs. A structured log is much more useful than a plain text line.

import net.logstash.logback.encoder.LogstashEncoder;

// With Logback configuration (logback-spring.xml)
<configuration>
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"order-service","pod":"${HOSTNAME}"}</customFields>
        </encoder>
    </appender>
    <root level="info">
        <appender-ref ref="JSON" />
    </root>
</configuration>
Enter fullscreen mode Exit fullscreen mode

Your log messages will now be JSON objects, easily searchable and filterable by fields like pod name, level, or logger.

For metrics, expose an endpoint in the format Prometheus expects. The Micrometer library is the standard for this in the Java world. It integrates seamlessly with Spring Boot Actuator.

@RestController
public class OrderController {

    private final MeterRegistry meterRegistry;
    private final Counter orderCounter;

    public OrderController(MeterRegistry registry) {
        this.meterRegistry = registry;
        this.orderCounter = Counter.builder("orders.created")
                .description("Count of created orders")
                .register(registry);
    }

    @PostMapping("/order")
    public Order createOrder(@RequestBody Order order) {
        // ... business logic
        orderCounter.increment(); // Prometheus will scrape this metric
        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable the Prometheus endpoint in your application.yaml:

management:
  endpoints:
    web:
      exposure:
        include: prometheus,health
  metrics:
    export:
      prometheus:
        enabled: true
Enter fullscreen mode Exit fullscreen mode

For distributed tracing, which follows a single request across multiple microservices, OpenTelemetry has become the key tool. Adding it involves initializing a tracer and instrumenting your HTTP clients and servers.

// Simplified setup with Spring Cloud Sleuth (which uses OpenTelemetry)
// Just adding the dependency often auto-instruments your app.
// In your application.yaml:
spring:
  sleuth:
    otlp:
      endpoint: http://otel-collector:4317
    propagation:
      type: W3C  # Use the standard traceparent header
Enter fullscreen mode Exit fullscreen mode

When your service calls another, it automatically forwards the trace header. All these spans—from the initial request, through database calls, to downstream service calls—are collected and sent to a backend like Jaeger or Tempo, giving you a complete picture of the request's journey.

Putting these five techniques together transforms how your Java application behaves in Kubernetes. Health checks give the platform the intelligence to manage your app's lifecycle. Proper resource settings prevent unexpected crashes and make efficient use of the cluster. Kubernetes-native configuration makes your deployments self-contained and portable. Graceful shutdown ensures updates don't cause errors. Comprehensive observability gives you the insight to diagnose issues quickly.

The goal isn't to fight the Kubernetes environment but to adapt to it. When your application follows these patterns, it allows the orchestrator to do its job effectively. The platform can heal your app, scale it, update it, and balance traffic to it, all while you retain the development velocity and rich ecosystem that Java provides. It’s about making your robust Java application feel perfectly at home in the dynamic world of containers.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)