Encryption is the process of converting readable data (plaintext) into an unreadable format (ciphertext) using a mathematical algorithm and a secret key. Only someone who has the correct key can reverse the process (decryption) and recover the original data.
Real-world analogy: Think of encryption like a lockbox. You put a letter (plaintext) inside a lockbox, close it with a key (encryption), and hand it to someone. Only the person with the matching key can open the box and read the letter (decryption). Anyone who intercepts the locked box sees nothing useful.
Encryption is critical in software development for several reasons:
Protecting sensitive data — passwords, credit card numbers, personal information, medical records
Securing communication — HTTPS, email encryption, messaging apps
Compliance — GDPR, HIPAA, PCI DSS all require data encryption
Data integrity — ensuring data has not been tampered with during transmission
Authentication — proving identity through digital signatures and certificates
There are two fundamental types of encryption:
Type
Keys Used
Speed
Use Case
Example Algorithms
Symmetric Encryption
Same key for encrypt and decrypt
Fast
Encrypting large amounts of data
AES, DES, ChaCha20
Asymmetric Encryption
Public key encrypts, private key decrypts
Slow
Key exchange, digital signatures
RSA, ECDSA, Ed25519
In practice, most systems use both together. For example, HTTPS uses asymmetric encryption to exchange a symmetric key, then uses that symmetric key to encrypt the actual data. This is called hybrid encryption.
Here is a simple visualization of the encryption process:
Throughout this tutorial, we will explore how Java provides all the tools you need to implement encryption, hashing, and message authentication in your applications.
2. Java Cryptography Architecture (JCA)
Java provides a comprehensive set of cryptographic APIs through the Java Cryptography Architecture (JCA) and the Java Cryptography Extension (JCE). These are part of the standard JDK, so you do not need any external libraries for core cryptographic operations.
Here are the key classes and packages you will use:
Class / Package
Purpose
Example Use
javax.crypto.Cipher
Core encryption/decryption engine
AES encryption, RSA encryption
javax.crypto.KeyGenerator
Generate symmetric keys
Generate AES-256 key
javax.crypto.SecretKey
Represents a symmetric key
AES key storage
javax.crypto.Mac
Message Authentication Code
HMAC-SHA256
java.security.KeyPairGenerator
Generate public/private key pairs
RSA key pair
java.security.MessageDigest
Cryptographic hashing
SHA-256 hash
java.security.SecureRandom
Cryptographically secure random numbers
Generating IVs, salts, tokens
java.security.Signature
Digital signatures
Sign and verify data
java.util.Base64
Encode/decode binary data as text
Transmitting encrypted data as strings
The JCA uses a provider model. A provider is a package that implements cryptographic algorithms. The default provider is SunJCE, but you can plug in third-party providers like Bouncy Castle for additional algorithms. In most cases, the built-in providers are sufficient.
The general pattern for using JCA classes follows a consistent factory pattern:
Hashing is a one-way process that converts data of any size into a fixed-length string of characters. Unlike encryption, hashing is not reversible — you cannot recover the original data from a hash. The same input always produces the same hash (deterministic), but even a tiny change in input produces a completely different hash (avalanche effect).
Important: Hashing is NOT encryption. Encryption is reversible (with the key); hashing is not. They serve different purposes.
Algorithm
Output Size
Secure?
Use Case
MD5
128 bits (32 hex chars)
No — broken, collisions found
Checksums only (never for security)
SHA-1
160 bits (40 hex chars)
No — deprecated
Legacy systems only
SHA-256
256 bits (64 hex chars)
Yes
Data integrity, file verification
SHA-512
512 bits (128 hex chars)
Yes
High security requirements
Java provides hashing through the MessageDigest class:
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class HashingExamples {
public static void main(String[] args) throws Exception {
String data = "Hello, World!";
// SHA-256 hash
String sha256Hash = hash(data, "SHA-256");
System.out.println("SHA-256: " + sha256Hash);
// Output: SHA-256: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
// SHA-512 hash
String sha512Hash = hash(data, "SHA-512");
System.out.println("SHA-512: " + sha512Hash);
// Output: SHA-512: 374d794a95cdcfd8b35993185fef9ba368f160d8daf432d08ba9f1ed1e5abe6cc69291e0fa2fe0006a52570ef18c19def4e617c33ce52ef0a6e5fbe318cb0387
// MD5 (DO NOT use for security purposes)
String md5Hash = hash(data, "MD5");
System.out.println("MD5: " + md5Hash);
// Output: MD5: 65a8e27d8879283831b664bd8b7f0ad4
// Demonstrate avalanche effect -- tiny change, completely different hash
String data2 = "Hello, World?"; // changed ! to ?
String hash2 = hash(data2, "SHA-256");
System.out.println("\nOriginal: " + sha256Hash);
System.out.println("Modified: " + hash2);
// Completely different hashes despite one character difference
}
public static String hash(String data, String algorithm) throws Exception {
MessageDigest digest = MessageDigest.getInstance(algorithm);
byte[] hashBytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hashBytes);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}
Hashing with Salt: When hashing passwords or sensitive data, you should always add a salt — a random value prepended to the input before hashing. This prevents attackers from using precomputed hash tables (rainbow tables) to crack your hashes.
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class SaltedHashing {
public static void main(String[] args) throws Exception {
String password = "mySecretPassword";
// Generate a random salt (16 bytes = 128 bits)
byte[] salt = generateSalt();
System.out.println("Salt: " + Base64.getEncoder().encodeToString(salt));
// Hash with salt
String hashedPassword = hashWithSalt(password, salt);
System.out.println("Hashed: " + hashedPassword);
// Verify -- same password + same salt = same hash
String verifyHash = hashWithSalt(password, salt);
System.out.println("Verified: " + hashedPassword.equals(verifyHash));
// Output: Verified: true
// Different password = different hash
String wrongHash = hashWithSalt("wrongPassword", salt);
System.out.println("Wrong password match: " + hashedPassword.equals(wrongHash));
// Output: Wrong password match: false
}
public static byte[] generateSalt() {
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
return salt;
}
public static String hashWithSalt(String password, byte[] salt) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(salt); // add salt first
byte[] hashBytes = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hashBytes);
}
}
Warning: Even salted SHA-256 is NOT sufficient for password hashing in production. SHA-256 is too fast — attackers can try billions of guesses per second. For passwords, use a dedicated password hashing algorithm like bcrypt, scrypt, or Argon2 (covered in Section 8).
4. Symmetric Encryption (AES)
Symmetric encryption uses the same key for both encryption and decryption. It is fast and efficient, making it the standard choice for encrypting data at rest and in transit.
AES (Advanced Encryption Standard) is the most widely used symmetric encryption algorithm. It was adopted by the U.S. government in 2001 and is considered secure with proper key sizes (128, 192, or 256 bits).
When using AES, you must choose a cipher mode and padding scheme. The mode determines how blocks of data are processed:
Mode
Full Name
IV Required?
Authenticated?
Recommendation
ECB
Electronic Codebook
No
No
NEVER use — identical blocks produce identical ciphertext, leaks patterns
CBC
Cipher Block Chaining
Yes
No
Acceptable but requires separate HMAC for authentication
CTR
Counter
Yes
No
Good for streaming, but no built-in authentication
GCM
Galois/Counter Mode
Yes
Yes
Recommended — provides both encryption AND authentication
Key Concepts:
IV (Initialization Vector) — a random value used to ensure the same plaintext does not produce the same ciphertext each time. The IV does not need to be secret, but it must be unique for each encryption with the same key.
Padding — block ciphers require input to be a multiple of the block size (128 bits for AES). Padding fills the gap. GCM mode does not need padding (use NoPadding).
Authentication Tag — GCM mode produces a tag that verifies the ciphertext has not been tampered with.
Now let us implement the recommended approach: AES-256-GCM. This mode provides both encryption and authentication (it detects if the ciphertext has been tampered with).
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AESGCMEncryption {
private static final int GCM_IV_LENGTH = 12; // 96 bits (recommended for GCM)
private static final int GCM_TAG_LENGTH = 128; // 128 bits authentication tag
public static void main(String[] args) throws Exception {
// Generate AES-256 key
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
SecretKey secretKey = keyGen.generateKey();
String plaintext = "This is a secret message that needs to be encrypted.";
System.out.println("Original: " + plaintext);
// Encrypt
String encrypted = encrypt(plaintext, secretKey);
System.out.println("Encrypted: " + encrypted);
// Decrypt
String decrypted = decrypt(encrypted, secretKey);
System.out.println("Decrypted: " + decrypted);
// Verify
System.out.println("Match: " + plaintext.equals(decrypted));
// Output: Match: true
// Encrypting the same text again produces DIFFERENT ciphertext (because of random IV)
String encrypted2 = encrypt(plaintext, secretKey);
System.out.println("\nSame plaintext, different ciphertext:");
System.out.println("Encryption 1: " + encrypted);
System.out.println("Encryption 2: " + encrypted2);
System.out.println("Same ciphertext? " + encrypted.equals(encrypted2));
// Output: Same ciphertext? false (because IV is different each time)
}
public static String encrypt(String plaintext, SecretKey key) throws Exception {
// Generate a random IV
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
// Initialize cipher for encryption
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec);
// Encrypt
byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));
// Prepend IV to ciphertext (IV is needed for decryption but is not secret)
byte[] ivAndCiphertext = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, ivAndCiphertext, 0, iv.length);
System.arraycopy(ciphertext, 0, ivAndCiphertext, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(ivAndCiphertext);
}
public static String decrypt(String encryptedBase64, SecretKey key) throws Exception {
byte[] ivAndCiphertext = Base64.getDecoder().decode(encryptedBase64);
// Extract IV from the beginning
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(ivAndCiphertext, 0, iv, 0, iv.length);
// Extract ciphertext (rest of the bytes)
byte[] ciphertext = new byte[ivAndCiphertext.length - iv.length];
System.arraycopy(ivAndCiphertext, iv.length, ciphertext, 0, ciphertext.length);
// Initialize cipher for decryption
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec);
// Decrypt
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, "UTF-8");
}
}
For reference, here is AES-CBC encryption (still commonly used in legacy systems). Note that CBC mode requires a separate HMAC step for authentication, which is why GCM is preferred:
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AESCBCEncryption {
private static final int IV_SIZE = 16; // 128 bits for AES block size
public static String encrypt(String plaintext, SecretKey key) throws Exception {
// Generate random IV
byte[] iv = new byte[IV_SIZE];
new SecureRandom().nextBytes(iv);
// Initialize cipher
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
// Encrypt
byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));
// Prepend IV to ciphertext
byte[] result = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(result);
}
public static String decrypt(String encryptedBase64, SecretKey key) throws Exception {
byte[] data = Base64.getDecoder().decode(encryptedBase64);
// Extract IV
byte[] iv = new byte[IV_SIZE];
System.arraycopy(data, 0, iv, 0, iv.length);
// Extract ciphertext
byte[] ciphertext = new byte[data.length - iv.length];
System.arraycopy(data, iv.length, ciphertext, 0, ciphertext.length);
// Decrypt
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
return new String(cipher.doFinal(ciphertext), "UTF-8");
}
public static void main(String[] args) throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
SecretKey key = keyGen.generateKey();
String message = "AES-CBC encryption example";
String encrypted = encrypt(message, key);
String decrypted = decrypt(encrypted, key);
System.out.println("Original: " + message);
System.out.println("Encrypted: " + encrypted);
System.out.println("Decrypted: " + decrypted);
// Output:
// Original: AES-CBC encryption example
// Encrypted: (Base64 string - different each time due to random IV)
// Decrypted: AES-CBC encryption example
}
}
5. Asymmetric Encryption (RSA)
Asymmetric encryption uses a pair of keys: a public key and a private key. Data encrypted with the public key can only be decrypted with the corresponding private key, and vice versa.
Real-world analogy: Think of a mailbox. Anyone can drop a letter into the mailbox (encrypt with the public key), but only the person with the mailbox key can open it and read the letters (decrypt with the private key).
RSA (Rivest-Shamir-Adleman) is the most widely used asymmetric algorithm. It is significantly slower than AES, so it is typically used for:
Key exchange — securely share a symmetric key over an insecure channel
Digital signatures — prove that data came from the expected sender and was not modified
Small data encryption — encrypt small pieces of data (RSA can only encrypt data smaller than the key size minus padding)
Digital signatures work in the opposite direction from encryption. You sign with your private key (proving you are the author) and anyone can verify with your public key (confirming the data was not modified).
import java.security.*;
import java.util.Base64;
public class DigitalSignatureExample {
public static void main(String[] args) throws Exception {
// Generate key pair
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
String message = "This document was signed by me.";
// Sign with private key
String signature = sign(message, keyPair.getPrivate());
System.out.println("Signature: " + signature);
// Verify with public key
boolean isValid = verify(message, signature, keyPair.getPublic());
System.out.println("Signature valid: " + isValid);
// Output: Signature valid: true
// Tamper with the message
boolean isTamperedValid = verify("This document was NOT signed by me.", signature, keyPair.getPublic());
System.out.println("Tampered message valid: " + isTamperedValid);
// Output: Tampered message valid: false
}
public static String sign(String data, PrivateKey privateKey) throws Exception {
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(data.getBytes("UTF-8"));
byte[] signatureBytes = signer.sign();
return Base64.getEncoder().encodeToString(signatureBytes);
}
public static boolean verify(String data, String signatureBase64, PublicKey publicKey) throws Exception {
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(data.getBytes("UTF-8"));
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
return verifier.verify(signatureBytes);
}
}
6. Base64 Encoding
Important: Base64 is NOT encryption. It is an encoding scheme that converts binary data into a text representation using 64 printable ASCII characters. Anyone can decode Base64 — there is no key, no secret, and no security. It is completely reversible by anyone.
Base64 is used when you need to transmit binary data (like encrypted bytes, images, or files) over channels that only support text (like JSON, XML, email, or URLs).
Java 8+ provides the java.util.Base64 class with three variants:
Encoder/Decoder
Character Set
Use Case
Base64.getEncoder()
A-Z, a-z, 0-9, +, /
General purpose (may include line breaks with MIME)
Base64.getUrlEncoder()
A-Z, a-z, 0-9, -, _
URL-safe (no +, /, or =)
Base64.getMimeEncoder()
Same as basic + line breaks
Email attachments (MIME format)
import java.util.Base64;
import java.nio.charset.StandardCharsets;
public class Base64Examples {
public static void main(String[] args) {
String original = "Hello, Java Encryption!";
// Basic encoding/decoding
String encoded = Base64.getEncoder().encodeToString(original.getBytes(StandardCharsets.UTF_8));
System.out.println("Encoded: " + encoded);
// Output: Encoded: SGVsbG8sIEphdmEgRW5jcnlwdGlvbiE=
String decoded = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
System.out.println("Decoded: " + decoded);
// Output: Decoded: Hello, Java Encryption!
// URL-safe encoding (replaces + with - and / with _)
byte[] binaryData = {(byte) 0xFF, (byte) 0xFE, (byte) 0xFD, (byte) 0xFC};
String urlSafe = Base64.getUrlEncoder().encodeToString(binaryData);
System.out.println("URL-safe: " + urlSafe);
// Output: URL-safe: __79_A==
// URL-safe without padding (no trailing = signs)
String urlSafeNoPadding = Base64.getUrlEncoder().withoutPadding().encodeToString(binaryData);
System.out.println("URL-safe (no padding): " + urlSafeNoPadding);
// Output: URL-safe (no padding): __79_A
// Encoding binary data (like encrypted bytes) for storage as text
byte[] encryptedBytes = new byte[]{0x12, 0x34, 0x56, 0x78, (byte) 0x9A, (byte) 0xBC};
String storable = Base64.getEncoder().encodeToString(encryptedBytes);
System.out.println("Binary as text: " + storable);
// Output: Binary as text: EjRWeJq8
// Decode back to original bytes
byte[] restored = Base64.getDecoder().decode(storable);
System.out.println("Bytes match: " + java.util.Arrays.equals(encryptedBytes, restored));
// Output: Bytes match: true
}
}
7. HMAC (Hash-based Message Authentication Code)
An HMAC is a specific type of message authentication code that combines a cryptographic hash function with a secret key. It provides two guarantees:
Data integrity — the data has not been modified in transit
Authenticity — the data came from someone who knows the secret key
Unlike a plain hash, an HMAC requires a secret key. This means an attacker cannot forge a valid HMAC without knowing the key. This is why HMACs are used in API authentication (like AWS request signing), JWT tokens, webhook verification, and session tokens.
Common HMAC algorithms:
Algorithm
Output Size
Use Case
HmacSHA256
256 bits
Most common — API signing, JWTs, general purpose
HmacSHA384
384 bits
Higher security requirement
HmacSHA512
512 bits
Maximum security, slightly slower
HmacMD5
128 bits
Do NOT use — MD5 is broken
import javax.crypto.Mac;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class HMACExample {
public static void main(String[] args) throws Exception {
// Generate a key for HMAC
KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
SecretKey hmacKey = keyGen.generateKey();
String message = "Order #12345: $99.99 charged to card ending in 4242";
// Generate HMAC
String hmac = generateHMAC(message, hmacKey);
System.out.println("Message: " + message);
System.out.println("HMAC: " + hmac);
// Verify HMAC -- same message + same key = same HMAC
String verifyHmac = generateHMAC(message, hmacKey);
System.out.println("Valid: " + hmac.equals(verifyHmac));
// Output: Valid: true
// Tampered message produces different HMAC
String tamperedMessage = "Order #12345: $9999.99 charged to card ending in 4242";
String tamperedHmac = generateHMAC(tamperedMessage, hmacKey);
System.out.println("Tampered valid: " + hmac.equals(tamperedHmac));
// Output: Tampered valid: false
// Using a string-based key (common for API integrations)
String apiSecret = "my-api-secret-key-do-not-share";
SecretKeySpec stringKey = new SecretKeySpec(
apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
);
String apiHmac = generateHMAC("webhook-payload-data", stringKey);
System.out.println("API HMAC: " + apiHmac);
}
public static String generateHMAC(String data, SecretKey key) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
byte[] hmacBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hmacBytes);
}
}
Practical use case: Webhook verification. When services like Stripe or GitHub send webhooks, they include an HMAC signature in the header. You recompute the HMAC using your secret key and compare it to the received signature. If they match, the webhook is legitimate.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
public class WebhookVerification {
/**
* Verify a webhook signature.
* The sender computes HMAC-SHA256 of the body using a shared secret
* and sends it in the X-Signature header.
*/
public static boolean verifyWebhook(String payload, String receivedSignature, String secret)
throws Exception {
SecretKeySpec key = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
byte[] computedBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
// Convert to hex string for comparison
StringBuilder hex = new StringBuilder();
for (byte b : computedBytes) {
hex.append(String.format("%02x", b));
}
String computedSignature = hex.toString();
// Use constant-time comparison to prevent timing attacks
return java.security.MessageDigest.isEqual(
computedSignature.getBytes(),
receivedSignature.getBytes()
);
}
public static void main(String[] args) throws Exception {
String webhookBody = "{\"event\":\"payment.completed\",\"amount\":9999}";
String sharedSecret = "whsec_test_secret_key";
// Simulate: sender computes signature
SecretKeySpec key = new SecretKeySpec(
sharedSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
byte[] sigBytes = mac.doFinal(webhookBody.getBytes(StandardCharsets.UTF_8));
StringBuilder sigHex = new StringBuilder();
for (byte b : sigBytes) {
sigHex.append(String.format("%02x", b));
}
// Receiver verifies
boolean valid = verifyWebhook(webhookBody, sigHex.toString(), sharedSecret);
System.out.println("Webhook signature valid: " + valid);
// Output: Webhook signature valid: true
// Tampered payload fails
boolean tampered = verifyWebhook(
"{\"event\":\"payment.completed\",\"amount\":0}",
sigHex.toString(), sharedSecret
);
System.out.println("Tampered webhook valid: " + tampered);
// Output: Tampered webhook valid: false
}
}
8. Password Hashing
Password hashing is a special case that requires algorithms specifically designed to be slow. This might sound counterintuitive, but here is why:
SHA-256 can hash billions of values per second on modern GPUs
An attacker with a database of SHA-256 password hashes can try every common password in seconds
Dedicated password hashing algorithms (bcrypt, scrypt, Argon2) are intentionally slow and memory-intensive
They include a work factor (cost parameter) that can be increased as hardware gets faster
Approach
Hashes/Second (GPU)
Suitable for Passwords?
MD5
~100 billion
Absolutely not
SHA-256
~10 billion
No — too fast
SHA-256 + salt
~10 billion
No — still too fast
bcrypt
~10,000 (tunable)
Yes — recommended
scrypt
~1,000 (tunable)
Yes — memory-hard
Argon2
~100 (tunable)
Yes — winner of PHC, best choice
bcrypt is the most widely used in Java applications. It automatically handles salt generation and embeds the salt in the output, so you do not need to manage salts separately.
Here is a bcrypt implementation using the jBCrypt library (or Spring Security’s BCryptPasswordEncoder):
// Using Spring Security's BCryptPasswordEncoder (most common in Java projects)
// Add dependency: org.springframework.security:spring-security-crypto
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class BCryptPasswordExample {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // work factor = 12
String rawPassword = "MySecureP@ssw0rd!";
// Hash the password (salt is generated automatically)
String hashedPassword = encoder.encode(rawPassword);
System.out.println("Hashed: " + hashedPassword);
// Output: Hashed: $2a$12$LJ/3Xq... (60 characters, different each time)
// Hashing the same password again gives a DIFFERENT hash (different salt)
String hashedPassword2 = encoder.encode(rawPassword);
System.out.println("Hashed again: " + hashedPassword2);
System.out.println("Same hash? " + hashedPassword.equals(hashedPassword2));
// Output: Same hash? false (because salt is different)
// Verify password -- this is how you check login credentials
boolean matches = encoder.matches(rawPassword, hashedPassword);
System.out.println("Correct password: " + matches);
// Output: Correct password: true
boolean wrongMatch = encoder.matches("WrongPassword", hashedPassword);
System.out.println("Wrong password: " + wrongMatch);
// Output: Wrong password: false
}
}
If you are not using Spring Security, here is a pure Java approach using PBKDF2 (Password-Based Key Derivation Function 2), which is built into the JDK:
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class PBKDF2PasswordHashing {
private static final int ITERATIONS = 210_000; // OWASP recommendation (2023+)
private static final int KEY_LENGTH = 256; // bits
private static final int SALT_LENGTH = 16; // bytes
public static void main(String[] args) throws Exception {
String password = "MySecureP@ssw0rd!";
// Hash the password
String stored = hashPassword(password);
System.out.println("Stored: " + stored);
// Output: Stored: 210000:BASE64_SALT:BASE64_HASH
// Verify
boolean valid = verifyPassword(password, stored);
System.out.println("Valid: " + valid);
// Output: Valid: true
boolean invalid = verifyPassword("WrongPassword", stored);
System.out.println("Wrong password: " + invalid);
// Output: Wrong password: false
}
public static String hashPassword(String password) throws Exception {
// Generate random salt
byte[] salt = new byte[SALT_LENGTH];
new SecureRandom().nextBytes(salt);
// Hash with PBKDF2
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(), salt, ITERATIONS, KEY_LENGTH
);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = factory.generateSecret(spec).getEncoded();
// Store iterations:salt:hash (all needed to verify later)
return ITERATIONS + ":"
+ Base64.getEncoder().encodeToString(salt) + ":"
+ Base64.getEncoder().encodeToString(hash);
}
public static boolean verifyPassword(String password, String stored) throws Exception {
String[] parts = stored.split(":");
int iterations = Integer.parseInt(parts[0]);
byte[] salt = Base64.getDecoder().decode(parts[1]);
byte[] expectedHash = Base64.getDecoder().decode(parts[2]);
// Hash the input password with the same salt and iterations
PBEKeySpec spec = new PBEKeySpec(
password.toCharArray(), salt, iterations, expectedHash.length * 8
);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] actualHash = factory.generateSecret(spec).getEncoded();
// Constant-time comparison to prevent timing attacks
return java.security.MessageDigest.isEqual(expectedHash, actualHash);
}
}
9. Practical Examples
9.1 File Encryption and Decryption
Encrypting files follows the same AES pattern but processes data in streams to handle large files without loading everything into memory:
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.io.*;
import java.security.SecureRandom;
public class FileEncryption {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
public static void encryptFile(File inputFile, File outputFile, SecretKey key)
throws Exception {
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
// Write IV first (needed for decryption)
fos.write(iv);
try (FileInputStream fis = new FileInputStream(inputFile);
CipherOutputStream cos = new CipherOutputStream(fos, cipher)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
cos.write(buffer, 0, bytesRead);
}
}
}
}
public static void decryptFile(File inputFile, File outputFile, SecretKey key)
throws Exception {
try (FileInputStream fis = new FileInputStream(inputFile)) {
// Read IV
byte[] iv = new byte[GCM_IV_LENGTH];
fis.read(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
try (CipherInputStream cis = new CipherInputStream(fis, cipher);
FileOutputStream fos = new FileOutputStream(outputFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = cis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
}
}
public static void main(String[] args) throws Exception {
// Generate key
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
SecretKey key = keyGen.generateKey();
// Create a test file
File original = new File("secret_document.txt");
try (FileWriter fw = new FileWriter(original)) {
fw.write("This is my secret document with sensitive information.");
}
// Encrypt
File encrypted = new File("secret_document.enc");
encryptFile(original, encrypted, key);
System.out.println("File encrypted: " + encrypted.getName());
System.out.println("Original size: " + original.length() + " bytes");
System.out.println("Encrypted size: " + encrypted.length() + " bytes");
// Decrypt
File decrypted = new File("secret_document_decrypted.txt");
decryptFile(encrypted, decrypted, key);
System.out.println("File decrypted: " + decrypted.getName());
// Verify
try (BufferedReader br = new BufferedReader(new FileReader(decrypted))) {
System.out.println("Content: " + br.readLine());
}
// Output: Content: This is my secret document with sensitive information.
// Cleanup
original.delete();
encrypted.delete();
decrypted.delete();
}
}
9.2 Secure Token Generation
Generating cryptographically secure tokens for session IDs, API keys, password reset links, and verification codes:
import java.security.SecureRandom;
import java.util.Base64;
public class SecureTokenGenerator {
private static final SecureRandom secureRandom = new SecureRandom();
/**
* Generate a URL-safe token (for password reset links, API keys, etc.)
*/
public static String generateToken(int byteLength) {
byte[] tokenBytes = new byte[byteLength];
secureRandom.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}
/**
* Generate a hex token (for session IDs, CSRF tokens)
*/
public static String generateHexToken(int byteLength) {
byte[] tokenBytes = new byte[byteLength];
secureRandom.nextBytes(tokenBytes);
StringBuilder hex = new StringBuilder();
for (byte b : tokenBytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
/**
* Generate a numeric OTP (for SMS verification, 2FA)
*/
public static String generateOTP(int digits) {
int max = (int) Math.pow(10, digits);
int otp = secureRandom.nextInt(max);
return String.format("%0" + digits + "d", otp);
}
public static void main(String[] args) {
// API key (32 bytes = 256 bits of entropy)
System.out.println("API Key: " + generateToken(32));
// Output: API Key: xK9mPq... (43 characters, URL-safe)
// Session ID (16 bytes = 128 bits)
System.out.println("Session ID: " + generateHexToken(16));
// Output: Session ID: a1b2c3d4e5f6... (32 hex characters)
// Password reset token (24 bytes = 192 bits)
System.out.println("Reset Token: " + generateToken(24));
// Output: Reset Token: Mj7kL2... (32 characters, URL-safe)
// 6-digit OTP
System.out.println("OTP: " + generateOTP(6));
// Output: OTP: 384729
// NEVER use java.util.Random for security tokens!
// Random is predictable. SecureRandom uses OS entropy sources.
}
}
9.3 Encrypting Sensitive Configuration
A common need is encrypting sensitive values in configuration files (database passwords, API keys) so they are not stored in plaintext:
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class ConfigEncryptor {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
private static final String PREFIX = "ENC(";
private static final String SUFFIX = ")";
private final SecretKey key;
public ConfigEncryptor(String base64Key) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
this.key = new SecretKeySpec(keyBytes, "AES");
}
public String encryptValue(String plaintext) throws Exception {
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes("UTF-8"));
byte[] combined = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
return PREFIX + Base64.getEncoder().encodeToString(combined) + SUFFIX;
}
public String decryptValue(String encryptedValue) throws Exception {
// Strip ENC(...) wrapper
String base64 = encryptedValue.substring(PREFIX.length(), encryptedValue.length() - SUFFIX.length());
byte[] combined = Base64.getDecoder().decode(base64);
byte[] iv = new byte[GCM_IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, iv.length);
byte[] ciphertext = new byte[combined.length - iv.length];
System.arraycopy(combined, iv.length, ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
return new String(cipher.doFinal(ciphertext), "UTF-8");
}
public boolean isEncrypted(String value) {
return value.startsWith(PREFIX) && value.endsWith(SUFFIX);
}
public static void main(String[] args) throws Exception {
// In production, this key comes from environment variable or key vault
// Generate once: Base64.getEncoder().encodeToString(KeyGenerator...generateKey().getEncoded())
String masterKey = "k7Hn3pRqSvWxYz1A2bCdEfGhIjKlMnOp4qRsTuVw5X8=";
ConfigEncryptor encryptor = new ConfigEncryptor(masterKey);
// Encrypt sensitive config values
Map config = new HashMap<>();
config.put("db.url", "jdbc:mysql://localhost:3306/mydb");
config.put("db.password", encryptor.encryptValue("SuperSecret123!"));
config.put("api.key", encryptor.encryptValue("sk-abc123def456"));
// Print config (safe to commit to version control)
System.out.println("=== Encrypted Config ===");
for (Map.Entry entry : config.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
// Output:
// db.url = jdbc:mysql://localhost:3306/mydb
// db.password = ENC(base64encodeddata...)
// api.key = ENC(base64encodeddata...)
// At runtime, decrypt the values
System.out.println("\n=== Decrypted at Runtime ===");
for (Map.Entry entry : config.entrySet()) {
String value = entry.getValue();
if (encryptor.isEncrypted(value)) {
value = encryptor.decryptValue(value);
}
System.out.println(entry.getKey() + " = " + value);
}
// Output:
// db.url = jdbc:mysql://localhost:3306/mydb
// db.password = SuperSecret123!
// api.key = sk-abc123def456
}
}
10. Common Mistakes
Cryptography is one of the easiest areas to get wrong in software development. A small mistake can make your encryption completely useless. Here are the most common mistakes Java developers make:
Mistake 1: Using ECB Mode
ECB (Electronic Codebook) mode encrypts each block independently. This means identical plaintext blocks produce identical ciphertext blocks, which leaks patterns in your data.
// WRONG -- ECB mode leaks patterns
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); // NEVER DO THIS
cipher.init(Cipher.ENCRYPT_MODE, key);
// Encrypting "AAAAAAAAAAAAAAAA" twice produces the SAME ciphertext
// An attacker can see which blocks are identical
// RIGHT -- Use GCM mode (provides encryption + authentication)
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
Mistake 2: Hardcoding Encryption Keys
// WRONG -- key is visible in source code and version control
private static final String SECRET_KEY = "MyHardcodedKey123";
// WRONG -- key is in a config file committed to Git
// application.properties: encryption.key=MyHardcodedKey123
// RIGHT -- key comes from environment variable
String key = System.getenv("ENCRYPTION_KEY");
if (key == null) {
throw new IllegalStateException("ENCRYPTION_KEY environment variable not set");
}
// RIGHT -- key comes from a secrets manager (AWS Secrets Manager, HashiCorp Vault)
// String key = secretsManager.getSecret("encryption-key");
Mistake 3: Using Random Instead of SecureRandom
import java.util.Random;
import java.security.SecureRandom;
// WRONG -- java.util.Random is predictable (uses a linear congruential generator)
// If an attacker knows the seed, they can predict ALL future values
Random insecureRandom = new Random();
byte[] iv = new byte[16];
insecureRandom.nextBytes(iv); // PREDICTABLE IV -- defeats the purpose of encryption
// RIGHT -- SecureRandom uses OS entropy (e.g., /dev/urandom on Linux)
SecureRandom secureRandom = new SecureRandom();
byte[] safeIv = new byte[16];
secureRandom.nextBytes(safeIv); // Cryptographically unpredictable
Mistake 4: Using MD5 or SHA-1 for Security
// WRONG -- MD5 has known collision attacks since 2004
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte[] hash = md5.digest(data.getBytes()); // Two different inputs can produce the same hash
// WRONG -- SHA-1 has been broken since 2017 (Google's SHAttered attack)
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
// RIGHT -- Use SHA-256 or SHA-512
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
// For passwords, do NOT use any SHA variant -- use bcrypt/scrypt/Argon2
Mistake 5: Not Using Authenticated Encryption
// WRONG -- AES-CBC without HMAC. An attacker can modify the ciphertext
// and you will decrypt garbage without knowing it was tampered with.
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// ... encrypt ...
// No way to detect if ciphertext was modified!
// RIGHT -- AES-GCM provides authenticated encryption.
// If the ciphertext is modified, decryption throws AEADBadTagException.
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// ... encrypt ...
// Decryption automatically verifies integrity via the authentication tag
Mistake 6: Reusing IVs with the Same Key
// WRONG -- Using a fixed IV
private static final byte[] FIXED_IV = new byte[12]; // all zeros
// Same key + same IV + different plaintexts = complete security failure in GCM mode
// WRONG -- Using a counter without proper management
int counter = 0;
byte[] iv = ByteBuffer.allocate(12).putInt(counter++).array();
// If the counter resets (app restart), you reuse IVs!
// RIGHT -- Always generate a random IV for each encryption
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv); // Fresh random IV every time
Mistake 7: Rolling Your Own Crypto
// WRONG -- Inventing your own "encryption" algorithm
public static String myEncrypt(String text) {
StringBuilder result = new StringBuilder();
for (char c : text.toCharArray()) {
result.append((char) (c + 3)); // Caesar cipher -- trivially broken
}
return result.toString();
}
// WRONG -- XOR "encryption" with a repeated key
public static byte[] xorEncrypt(byte[] data, byte[] key) {
byte[] result = new byte[data.length];
for (int i = 0; i < data.length; i++) {
result[i] = (byte) (data[i] ^ key[i % key.length]); // trivially broken
}
return result;
}
// RIGHT -- Use standard algorithms that have been analyzed by cryptographers
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// AES has been analyzed by thousands of cryptographers since 1998
// and is approved for TOP SECRET government data
11. Best Practices
Here is a summary of what you should and should not do when implementing cryptography in Java:
Category
Do
Do Not
Symmetric Encryption
Use AES-256-GCM
Use ECB mode, DES, 3DES, or Blowfish
Asymmetric Encryption
Use RSA-OAEP (2048+ bits) or ECDSA
Use RSA-PKCS1v1.5 or RSA less than 2048 bits
Password Hashing
Use bcrypt, scrypt, or Argon2
Use SHA-256, MD5, or any fast hash for passwords
General Hashing
Use SHA-256 or SHA-512
Use MD5 or SHA-1 for security purposes
Random Numbers
Use SecureRandom
Use java.util.Random for crypto
Key Storage
Use env vars, secrets manager, or key vault
Hardcode keys in source code or config files
IV / Nonce
Generate a fresh random IV for each encryption
Reuse IVs or use fixed/predictable IVs
Authentication
Use authenticated encryption (GCM) or HMAC
Encrypt without verifying integrity
Algorithm Design
Use standard, well-tested algorithms
Invent your own encryption scheme
Comparison
Use constant-time comparison for MACs/hashes
Use String.equals() for crypto comparisons
Key Size
AES-256, RSA-2048+, HMAC-SHA256
AES-128 (acceptable), RSA-1024 (broken)
Dependencies
Keep crypto libraries updated
Use deprecated or unmaintained crypto libraries
Additional guidance:
Never log plaintext secrets. Log the fact that encryption/decryption happened, but never log the actual passwords, keys, or sensitive data.
Wipe sensitive data from memory when done. Use char[] instead of String for passwords (strings are immutable and linger in memory). Call Arrays.fill(charArray, '\0') when done.
Use try-with-resources or finally blocks to ensure keys and sensitive byte arrays are zeroed out.
Rotate keys periodically. Have a plan for key rotation without downtime -- encrypt new data with new keys, re-encrypt old data during migration.
Test your crypto. Write unit tests that verify encryption round-trips correctly, that different plaintexts produce different ciphertexts, and that tampered ciphertext is rejected.
12. Complete Practical Example -- SecureVault
Let us bring everything together in a practical SecureVault class that provides the four most common cryptographic operations: encrypt/decrypt strings, hash passwords, generate secure tokens, and verify data integrity with HMAC.
This class is designed to be production-quality. You can drop it into your project and use it immediately.
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
/**
* SecureVault -- a utility class for common cryptographic operations.
*
* Features:
* - AES-256-GCM encryption/decryption (authenticated encryption)
* - PBKDF2 password hashing with salt
* - HMAC-SHA256 message authentication
* - Cryptographically secure token generation
* - SHA-256 hashing for data integrity
*/
public class SecureVault {
private static final int GCM_IV_LENGTH = 12; // 96 bits
private static final int GCM_TAG_LENGTH = 128; // 128 bits
private static final int PBKDF2_ITERATIONS = 210_000;
private static final int PBKDF2_KEY_LENGTH = 256; // bits
private static final int SALT_LENGTH = 16; // bytes
private final SecureRandom secureRandom = new SecureRandom();
// ===== ENCRYPTION / DECRYPTION (AES-256-GCM) =====
/**
* Generate a new AES-256 encryption key.
*/
public SecretKey generateEncryptionKey() throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
return keyGen.generateKey();
}
/**
* Reconstruct a key from its Base64-encoded form.
*/
public SecretKey keyFromBase64(String base64Key) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
return new SecretKeySpec(keyBytes, "AES");
}
/**
* Export a key as a Base64 string (for secure storage).
*/
public String keyToBase64(SecretKey key) {
return Base64.getEncoder().encodeToString(key.getEncoded());
}
/**
* Encrypt a string using AES-256-GCM.
* The IV is prepended to the ciphertext in the output.
*/
public String encrypt(String plaintext, SecretKey key) throws Exception {
byte[] iv = new byte[GCM_IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
return Base64.getEncoder().encodeToString(combined);
}
/**
* Decrypt a string that was encrypted with encrypt().
* Throws AEADBadTagException if the ciphertext was tampered with.
*/
public String decrypt(String encryptedBase64, SecretKey key) throws Exception {
byte[] combined = Base64.getDecoder().decode(encryptedBase64);
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
}
// ===== PASSWORD HASHING (PBKDF2) =====
/**
* Hash a password using PBKDF2-HMAC-SHA256.
* Returns: "iterations:base64Salt:base64Hash"
*/
public String hashPassword(String password) throws Exception {
byte[] salt = new byte[SALT_LENGTH];
secureRandom.nextBytes(salt);
byte[] hash = pbkdf2(password.toCharArray(), salt, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH);
return PBKDF2_ITERATIONS + ":"
+ Base64.getEncoder().encodeToString(salt) + ":"
+ Base64.getEncoder().encodeToString(hash);
}
/**
* Verify a password against a stored hash.
*/
public boolean verifyPassword(String password, String storedHash) throws Exception {
String[] parts = storedHash.split(":");
int iterations = Integer.parseInt(parts[0]);
byte[] salt = Base64.getDecoder().decode(parts[1]);
byte[] expectedHash = Base64.getDecoder().decode(parts[2]);
byte[] actualHash = pbkdf2(password.toCharArray(), salt, iterations, expectedHash.length * 8);
return MessageDigest.isEqual(expectedHash, actualHash);
}
private byte[] pbkdf2(char[] password, byte[] salt, int iterations, int keyLength)
throws Exception {
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyLength);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return factory.generateSecret(spec).getEncoded();
}
// ===== HMAC (Message Authentication) =====
/**
* Generate an HMAC-SHA256 for data integrity verification.
*/
public String hmac(String data, SecretKey key) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
byte[] hmacBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hmacBytes);
}
/**
* Verify an HMAC using constant-time comparison.
*/
public boolean verifyHmac(String data, String expectedHmac, SecretKey key) throws Exception {
String actualHmac = hmac(data, key);
return MessageDigest.isEqual(
actualHmac.getBytes(StandardCharsets.UTF_8),
expectedHmac.getBytes(StandardCharsets.UTF_8)
);
}
// ===== HASHING (SHA-256) =====
/**
* Hash data using SHA-256 (for data integrity, NOT passwords).
*/
public String sha256(String data) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hashBytes);
}
// ===== TOKEN GENERATION =====
/**
* Generate a URL-safe random token.
*/
public String generateToken(int byteLength) {
byte[] tokenBytes = new byte[byteLength];
secureRandom.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}
/**
* Generate a numeric OTP.
*/
public String generateOTP(int digits) {
int max = (int) Math.pow(10, digits);
int otp = secureRandom.nextInt(max);
return String.format("%0" + digits + "d", otp);
}
}
Now let us use SecureVault in a complete application that demonstrates every feature:
BCryptPasswordEncoder or SecretKeyFactory + PBEKeySpec
Verify data integrity
HMAC-SHA256
Mac, SecretKeySpec
Hash data (non-password)
SHA-256
MessageDigest
Generate random tokens
SecureRandom + Base64
SecureRandom, Base64.getUrlEncoder()
Exchange keys securely
RSA-OAEP or ECDH
KeyPairGenerator, Cipher
Sign data
SHA256withRSA
Signature, KeyPairGenerator
Encode binary as text
Base64
Base64.getEncoder() / getUrlEncoder()
March 18, 2020
log4j
1. Why Logging?
Imagine you are a detective investigating a crime scene. Without evidence — fingerprints, security camera footage, witness statements — you would have no way to reconstruct what happened. Logging is the evidence trail for your application. It records what your program did, when it did it, and what went wrong.
Logging is the practice of recording messages from your application during runtime. These messages capture events, errors, state changes, and diagnostic information that help you understand your application’s behavior — especially when things go wrong in production at 3 AM and you cannot attach a debugger.
Why Not System.out.println?
Every Java developer starts with System.out.println() for debugging. It works, but it is the equivalent of using a flashlight when you need a full surveillance system. Here is why it falls short in real applications:
Warnings — deprecated API usage, retry attempts, approaching resource limits
Performance data — request duration, query execution time, cache hit/miss ratios
External system interactions — API calls sent/received, database queries, message queue operations
What NOT to Log
Passwords — never log user passwords, even encrypted ones
Credit card numbers — PCI-DSS compliance requires masking (show only last 4 digits)
Social Security Numbers — personally identifiable information (PII)
API keys and secrets — attackers read log files too
Session tokens — could enable session hijacking
Medical or health data — HIPAA compliance
A simple rule: if you would not want it on a billboard, do not log it.
// BAD: System.out.println for debugging
public class BadDebugging {
public void processOrder(Order order) {
System.out.println("Processing order: " + order.getId()); // No timestamp
System.out.println("Order total: " + order.getTotal()); // No severity
System.out.println("Sending to payment..."); // Cannot turn off
// These println calls will clutter production logs forever
}
}
// GOOD: Proper logging
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class GoodLogging {
private static final Logger log = LoggerFactory.getLogger(GoodLogging.class);
public void processOrder(Order order) {
log.info("Processing order id={}, total={}", order.getId(), order.getTotal());
log.debug("Sending order to payment gateway");
// Output: 2026-02-28 10:15:32.451 [main] INFO GoodLogging - Processing order id=12345, total=99.99
// In production, DEBUG messages are automatically suppressed
}
}
2. Java Logging Landscape
Java has multiple logging frameworks, which can be confusing for newcomers. Here is the landscape and how the pieces fit together:
Framework
Type
Description
Status
java.util.logging (JUL)
Implementation
Built into the JDK since Java 1.4. No external dependencies needed.
Active but rarely used in modern projects
Log4j 1.x
Implementation
Was the de facto standard for years. Uses log4j.properties or log4j.xml.
END OF LIFE — Critical security vulnerability CVE-2021-44228. DO NOT USE.
Log4j 2
Implementation
Complete rewrite of Log4j. Async logging, plugin architecture, modern design.
Active, maintained by Apache
Logback
Implementation
Created by the founder of Log4j as its successor. Native SLF4J implementation.
Active, default in Spring Boot
SLF4J
Facade (API)
Simple Logging Facade for Java. An abstraction layer — you code against SLF4J and swap implementations without changing code.
Active, industry standard
The Facade Pattern: Why SLF4J Matters
Think of SLF4J like a universal remote control. You press the same buttons regardless of whether your TV is Samsung, LG, or Sony. Similarly, you write logging code using SLF4J’s API, and the actual logging is handled by whichever implementation (Logback, Log4j2) is on the classpath.
This means:
Your application code uses org.slf4j.Logger — never a specific implementation class
You can switch from Logback to Log4j2 by changing a Maven dependency — zero code changes
Libraries you depend on can use SLF4J too, and their logs funnel through your chosen implementation
Recommended Stack
For most Java applications in 2026, use: SLF4J (facade) + Logback (implementation). This is the default in Spring Boot and the most widely adopted combination. This tutorial will focus primarily on this stack, but we will also cover JUL and Log4j2.
3. Log Levels
Log levels let you categorize messages by severity. You can then configure your application to show only messages at or above a certain level — for example, showing everything in development but only WARN and ERROR in production.
SLF4J Log Levels (from least to most severe)
Level
Purpose
When to Use
Example
TRACE
Extremely detailed diagnostic information
Step-by-step algorithm execution, variable values in loops, entering/exiting methods
log.trace("Entering calculateTax with amount={}", amount)
Unhandled exceptions, failed database connections, data corruption, business rule violations that halt processing
log.error("Failed to process payment for order {}", orderId, exception)
Level Hierarchy
Levels form a hierarchy. When you set the log level to a certain value, all messages at that level and above are logged. Messages below that level are suppressed.
Configured Level
TRACE
DEBUG
INFO
WARN
ERROR
TRACE
Yes
Yes
Yes
Yes
Yes
DEBUG
No
Yes
Yes
Yes
Yes
INFO
No
No
Yes
Yes
Yes
WARN
No
No
No
Yes
Yes
ERROR
No
No
No
No
Yes
Rule of thumb: Development uses DEBUG or TRACE. Production uses INFO (or WARN for very high-throughput systems). You should be able to understand what your application is doing from INFO logs alone.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogLevelDemo {
private static final Logger log = LoggerFactory.getLogger(LogLevelDemo.class);
public void processPayment(String orderId, double amount) {
log.trace("Entering processPayment(orderId={}, amount={})", orderId, amount);
log.debug("Validating payment amount: {}", amount);
if (amount <= 0) {
log.warn("Invalid payment amount {} for order {}, using minimum $0.01", amount, orderId);
amount = 0.01;
}
try {
log.info("Processing payment of ${} for order {}", amount, orderId);
// ... payment logic ...
log.info("Payment successful for order {}", orderId);
} catch (Exception e) {
log.error("Payment failed for order {} with amount ${}", orderId, amount, e);
// The exception 'e' is passed as the LAST argument -- SLF4J will print the full stack trace
}
log.trace("Exiting processPayment for order {}", orderId);
}
}
// If level is set to INFO, output would be:
// 2026-02-28 10:30:00.123 [main] INFO LogLevelDemo - Processing payment of $49.99 for order ORD-001
// 2026-02-28 10:30:00.456 [main] INFO LogLevelDemo - Payment successful for order ORD-001
// (TRACE and DEBUG messages are suppressed)
4. java.util.logging (JUL)
Java includes a built-in logging framework in the java.util.logging package. It requires no external dependencies, which makes it a good starting point for learning and for simple applications where you want zero third-party libraries.
JUL Log Levels
JUL uses its own level names, which differ from SLF4J:
JUL Level
SLF4J Equivalent
Description
FINEST
TRACE
Highly detailed tracing
FINER
TRACE
Fairly detailed tracing
FINE
DEBUG
General debugging
CONFIG
-
Configuration info
INFO
INFO
Informational messages
WARNING
WARN
Potential problems
SEVERE
ERROR
Serious failures
import java.util.logging.Level;
import java.util.logging.Logger;
public class JulExample {
// Create a logger named after the class
private static final Logger logger = Logger.getLogger(JulExample.class.getName());
public static void main(String[] args) {
// Basic logging at different levels
logger.info("Application starting");
logger.warning("Configuration file not found, using defaults");
logger.severe("Database connection failed!");
// Parameterized logging (JUL uses {0}, {1} style -- not {} like SLF4J)
String user = "alice";
int loginAttempts = 3;
logger.log(Level.INFO, "User {0} logged in after {1} attempts", new Object[]{user, loginAttempts});
// Logging an exception
try {
int result = 10 / 0;
} catch (ArithmeticException e) {
logger.log(Level.SEVERE, "Division error occurred", e);
}
// Check if level is enabled before expensive operations
if (logger.isLoggable(Level.FINE)) {
logger.fine("Debug data: " + expensiveToString());
}
}
private static String expensiveToString() {
// Imagine this method is costly to call
return "detailed debug information";
}
}
// Output:
// Feb 28, 2026 10:45:00 AM JulExample main
// INFO: Application starting
// Feb 28, 2026 10:45:00 AM JulExample main
// WARNING: Configuration file not found, using defaults
// Feb 28, 2026 10:45:00 AM JulExample main
// SEVERE: Database connection failed!
JUL Limitations
While JUL works for simple cases, it has significant drawbacks compared to modern frameworks:
Verbose API -- logger.log(Level.INFO, "msg {0}", new Object[]{val}) vs. SLF4J's log.info("msg {}", val)
Limited formatting -- the default output format is ugly and multi-line (class name and method on separate lines)
Poor configuration -- uses a global logging.properties file that is awkward to customize per-package
No native support for modern features -- no built-in JSON output, no MDC (Mapped Diagnostic Context), no async logging
Performance -- slower than Logback and Log4j2 for high-throughput scenarios
Verdict: Use JUL for quick scripts or when you truly cannot add dependencies. For any real application, use SLF4J + Logback.
5. SLF4J + Logback (Recommended)
SLF4J (Simple Logging Facade for Java) + Logback is the most popular logging stack in the Java ecosystem. Spring Boot uses it by default. SLF4J provides the API you code against; Logback provides the engine that does the actual logging.
The setup follows a consistent two-step pattern in every class:
Import org.slf4j.Logger and org.slf4j.LoggerFactory
Create a private static final Logger field using LoggerFactory.getLogger(YourClass.class)
Passing the class to getLogger() means the logger is named after your class, so log output shows exactly which class produced each message.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class UserService {
// Step 1: Declare the logger -- always private static final
private static final Logger log = LoggerFactory.getLogger(UserService.class);
public User findUserById(long id) {
log.info("Looking up user with id={}", id);
User user = userRepository.findById(id);
if (user == null) {
log.warn("User not found for id={}", id);
return null;
}
log.debug("Found user: name={}, email={}", user.getName(), user.getEmail());
return user;
}
}
// Output with INFO level:
// 2026-02-28 10:30:00.123 [main] INFO c.e.service.UserService - Looking up user with id=42
// 2026-02-28 10:30:00.125 [main] WARN c.e.service.UserService - User not found for id=42
5.3 Parameterized Logging with {} Placeholders
This is one of SLF4J's most important features. Never use string concatenation in log statements. Use {} placeholders instead.
Why? With string concatenation, Java builds the string every time, even if the log level is disabled. With placeholders, SLF4J only builds the string if the message will actually be logged.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ParameterizedLogging {
private static final Logger log = LoggerFactory.getLogger(ParameterizedLogging.class);
public void demonstrate(Order order) {
// BAD: String concatenation -- always builds the string, even if DEBUG is off
log.debug("Processing order " + order.getId() + " for user " + order.getUserId()
+ " with " + order.getItems().size() + " items");
// GOOD: Parameterized logging -- string built ONLY if DEBUG is enabled
log.debug("Processing order {} for user {} with {} items",
order.getId(), order.getUserId(), order.getItems().size());
// Multiple placeholders -- they are filled in order
log.info("User {} placed order {} with total ${}", "alice", "ORD-123", 99.99);
// Output: User alice placed order ORD-123 with total $99.99
// Logging exceptions -- exception is ALWAYS the last argument
try {
processPayment(order);
} catch (Exception e) {
// The exception goes last -- SLF4J recognizes it and prints the full stack trace
log.error("Payment failed for order {}", order.getId(), e);
// Output:
// 2026-02-28 10:30:00.123 [main] ERROR ParameterizedLogging - Payment failed for order ORD-123
// java.lang.RuntimeException: Insufficient funds
// at ParameterizedLogging.processPayment(ParameterizedLogging.java:35)
// at ParameterizedLogging.demonstrate(ParameterizedLogging.java:22)
// ...
}
}
}
5.4 Logging Exceptions Correctly
When logging exceptions, always pass the exception object as the last argument. SLF4J will automatically print the full stack trace. This is the single most important logging pattern to get right.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ExceptionLogging {
private static final Logger log = LoggerFactory.getLogger(ExceptionLogging.class);
public void demonstrateExceptionLogging() {
try {
riskyOperation();
} catch (Exception e) {
// BAD: Loses the stack trace entirely
log.error("Something failed");
// BAD: Only logs the exception message, no stack trace
log.error("Something failed: " + e.getMessage());
// BAD: Converts stack trace to string manually -- ugly and loses structure
log.error("Something failed: " + e.toString());
// GOOD: Pass exception as the last argument -- full stack trace is printed
log.error("Something failed", e);
// GOOD: With context AND exception -- placeholders first, exception last
log.error("Failed to process order {} for user {}", orderId, userId, e);
// SLF4J knows the last argument is an exception because {} count (2) < argument count (3)
}
}
}
6. Log4j2
Log4j2 is the modern successor to Log4j 1.x, built from the ground up by Apache. It is a completely different codebase from Log4j 1.x.
Critical Warning: Log4j 1.x (versions 1.2.x) reached end of life in 2015 and has the critical Log4Shell vulnerability (CVE-2021-44228), one of the most severe security vulnerabilities in Java history. If you are using Log4j 1.x, you must migrate immediately. Log4j2 (versions 2.x) is the safe, modern version.
Log4j2's standout feature is its async logging capability using the LMAX Disruptor library. This can dramatically improve performance in high-throughput applications by logging on a separate thread.
com.lmaxdisruptor4.0.0
6.4 When to Choose Log4j2 Over Logback
Feature
Logback
Log4j2
Spring Boot default
Yes
No (requires exclusion + config)
Async performance
Good (AsyncAppender)
Excellent (LMAX Disruptor)
Garbage-free logging
No
Yes (reduces GC pauses)
Lambda support
No
Yes (lazy message construction)
Plugin architecture
Limited
Extensive
Community adoption
Higher (Spring ecosystem)
Strong (Apache ecosystem)
Configuration reload
Yes
Yes (automatic)
Bottom line: Use Logback for most applications, especially with Spring Boot. Choose Log4j2 if you need maximum throughput with async logging (e.g., high-frequency trading, real-time data pipelines).
7. Logback Configuration
Logback is configured via an XML file named logback.xml (or logback-spring.xml in Spring Boot) placed in src/main/resources/. The configuration has three main components:
Appenders -- Where log output goes (console, files, remote servers)
Encoders/Patterns -- How log messages are formatted
Loggers -- Which packages/classes log at which level
Performance note:%class, %method, and %line are computed by generating a stack trace, which is expensive. Avoid them in production patterns.
7.3 Common Patterns
// Development pattern (human-readable with colors)
%d{HH:mm:ss.SSS} %highlight(%-5level) %cyan(%logger{36}) - %msg%n
// Output: 10:30:00.123 INFO c.e.service.UserService - Order placed
// Production pattern (full detail, no color)
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
// Output: 2026-02-28 10:30:00.123 [http-nio-8080-exec-1] INFO c.e.service.UserService - Order placed
// Production with MDC (request tracking)
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [requestId=%X{requestId}] - %msg%n
// Output: 2026-02-28 10:30:00.123 [http-nio-8080-exec-1] INFO c.e.service.UserService [requestId=abc-123-def] - Order placed
// JSON pattern for ELK/Splunk (see Section 12)
{"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ}","level":"%level","logger":"%logger","thread":"%thread","message":"%msg","requestId":"%X{requestId}"}%n
7.4 Filtering by Package
One of the most powerful configuration features is setting different log levels for different packages. This lets you see detailed logs from your code while keeping framework noise quiet.
%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n
8. Logging Patterns and Formats
The format of your log messages matters more than you might think. In development, you want human-readable output. In production, you often want structured (JSON) output that can be parsed by log aggregation tools like the ELK stack (Elasticsearch, Logstash, Kibana) or Splunk.
For production environments using ELK stack, Splunk, or Datadog, structured JSON logs are essential. Each log line is a valid JSON object that these tools can parse, index, and search.
This structured output means you can search for all logs where userId="user-42" or find all ERROR-level messages for a specific requestId -- something that is extremely difficult with plain text logs.
9. MDC (Mapped Diagnostic Context)
Imagine you are a doctor in a busy emergency room, treating 20 patients simultaneously. Without patient wristbands (IDs), you would have no way to tell which vitals belong to which patient. MDC is the wristband for your application's requests.
MDC (Mapped Diagnostic Context) lets you attach key-value pairs to the current thread. These values are then automatically included in every log message produced by that thread. This is invaluable in multi-threaded web applications where dozens of requests are processed concurrently.
Common MDC Fields
requestId -- A unique ID for each HTTP request, used to trace all log lines for one request
userId -- The authenticated user making the request
sessionId -- The user's session
transactionId -- For tracing business transactions across services
correlationId -- For tracing requests across microservices
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class MdcExample {
private static final Logger log = LoggerFactory.getLogger(MdcExample.class);
public void handleRequest(String requestId, String userId) {
// Put values into MDC at the start of the request
MDC.put("requestId", requestId);
MDC.put("userId", userId);
try {
log.info("Request received");
processOrder();
sendConfirmation();
log.info("Request completed successfully");
} finally {
// CRITICAL: Always clear MDC when the request is done
// Threads are reused in thread pools -- leftover MDC values leak into other requests!
MDC.clear();
}
}
private void processOrder() {
// This log line automatically includes requestId and userId from MDC
log.info("Processing order");
// Output: 2026-02-28 10:30:00.123 [http-exec-1] INFO MdcExample [requestId=abc-123, userId=user-42] - Processing order
}
private void sendConfirmation() {
log.info("Sending confirmation email");
// Output: 2026-02-28 10:30:00.456 [http-exec-1] INFO MdcExample [requestId=abc-123, userId=user-42] - Sending confirmation email
}
}
9.1 MDC with Web Applications (Servlet Filter)
In real applications, you set up MDC in a servlet filter or Spring interceptor so that every request automatically gets a unique ID. You never have to manually add MDC in individual controllers or services.
import org.slf4j.MDC;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;
public class LoggingFilter implements Filter {
private static final String REQUEST_ID = "requestId";
private static final String USER_ID = "userId";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
try {
// Generate or extract request ID
String requestId = httpRequest.getHeader("X-Request-ID");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString().substring(0, 8);
}
// Set MDC values
MDC.put(REQUEST_ID, requestId);
// Extract user from security context (if authenticated)
String userId = extractUserId(httpRequest);
if (userId != null) {
MDC.put(USER_ID, userId);
}
// Continue processing the request
chain.doFilter(request, response);
} finally {
// Always clean up to prevent thread pool contamination
MDC.clear();
}
}
private String extractUserId(HttpServletRequest request) {
// In a real app, extract from security context or JWT token
return request.getRemoteUser();
}
}
9.2 MDC in logback.xml
To display MDC values in your log output, use the %X{key} specifier in your pattern:
These are the logging practices that separate junior developers from senior developers. Follow these in every Java project.
10.1 Use SLF4J as Your Logging Facade
Always code against the SLF4J API, never a specific implementation. This gives you the freedom to switch between Logback, Log4j2, or any future implementation without touching your code.
// BAD: Coupling to a specific implementation
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
private static final Logger log = LogManager.getLogger(MyClass.class);
// BAD: Using java.util.logging directly
import java.util.logging.Logger;
private static final Logger log = Logger.getLogger(MyClass.class.getName());
// GOOD: SLF4J facade -- works with ANY implementation
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
10.2 Use Parameterized Logging
This is the single most common logging mistake in Java code reviews. Never concatenate strings in log statements.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ParameterizedBestPractice {
private static final Logger log = LoggerFactory.getLogger(ParameterizedBestPractice.class);
public void process(Order order) {
// BAD: String concatenation -- always builds the string even if DEBUG is off
log.debug("Order " + order.getId() + " has " + order.getItems().size() + " items totaling $" + order.getTotal());
// This calls order.getId(), order.getItems().size(), and order.getTotal()
// PLUS concatenates 5 strings -- all wasted work if DEBUG is disabled
// GOOD: Parameterized -- only builds string if DEBUG is enabled
log.debug("Order {} has {} items totaling ${}", order.getId(), order.getItems().size(), order.getTotal());
}
}
10.3 Log at the Appropriate Level
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class AppropriateLevel {
private static final Logger log = LoggerFactory.getLogger(AppropriateLevel.class);
public void processOrder(Order order) {
// TRACE: Very fine-grained, method entry/exit
log.trace("Entering processOrder with order={}", order);
// DEBUG: Technical detail helpful during development
log.debug("Validating order items against inventory");
// INFO: Business event -- this is what operations teams monitor
log.info("Order {} placed by user {} for ${}", order.getId(), order.getUserId(), order.getTotal());
// WARN: Something unusual but recoverable
if (order.getTotal() > 10000) {
log.warn("High-value order {} for ${} -- flagged for review", order.getId(), order.getTotal());
}
// ERROR: Something failed -- needs human attention
try {
chargePayment(order);
} catch (PaymentException e) {
log.error("Payment failed for order {} with amount ${}", order.getId(), order.getTotal(), e);
}
}
}
10.4 Include Context in Messages
A log message without context is like a clue without a case number. Always include the relevant IDs and values that will help you investigate.
// BAD: No context -- useless for debugging
log.error("Payment failed");
log.info("User logged in");
log.warn("Retry attempt");
// GOOD: Context-rich -- you can trace exactly what happened
log.error("Payment failed for order={} user={} amount=${} gateway={}", orderId, userId, amount, gateway);
log.info("User {} logged in from IP {} using {}", userId, ipAddress, userAgent);
log.warn("Retry attempt {}/{} for order={} after {}ms delay", attempt, maxRetries, orderId, delay);
10.5 Use isDebugEnabled() for Expensive Operations
While parameterized logging avoids string concatenation overhead, it does not avoid the cost of computing the arguments. If computing an argument is expensive, guard the log statement.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ExpensiveLogging {
private static final Logger log = LoggerFactory.getLogger(ExpensiveLogging.class);
public void processLargeDataSet(List records) {
// BAD: computeStats() is called EVERY TIME, even when DEBUG is off
log.debug("Dataset statistics: {}", computeStats(records));
// computeStats() might iterate over millions of records
// GOOD: Guard expensive computation
if (log.isDebugEnabled()) {
log.debug("Dataset statistics: {}", computeStats(records));
}
// ALSO GOOD for simple arguments -- no guard needed
log.debug("Processing {} records", records.size());
// records.size() is O(1) and trivially cheap
}
private String computeStats(List records) {
// Imagine this iterates the entire list, computes averages, etc.
return "min=1, max=100, avg=42.5, stddev=12.3";
}
}
10.6 Do Not Log Sensitive Data
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SensitiveDataLogging {
private static final Logger log = LoggerFactory.getLogger(SensitiveDataLogging.class);
public void authenticateUser(String username, String password) {
// BAD: NEVER log passwords
log.info("Login attempt: user={}, password={}", username, password);
// GOOD: Log the event without sensitive data
log.info("Login attempt for user={}", username);
}
public void processPayment(String creditCardNumber, double amount) {
// BAD: NEVER log full credit card numbers
log.info("Charging card {} for ${}", creditCardNumber, amount);
// GOOD: Mask the sensitive data
String masked = maskCreditCard(creditCardNumber);
log.info("Charging card {} for ${}", masked, amount);
// Output: Charging card ****-****-****-4242 for $99.99
}
private String maskCreditCard(String number) {
if (number == null || number.length() < 4) return "****";
return "****-****-****-" + number.substring(number.length() - 4);
}
}
10.7 Do Not Log Inside Tight Loops
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LoopLogging {
private static final Logger log = LoggerFactory.getLogger(LoopLogging.class);
public void processRecords(List records) {
// BAD: Logging inside a loop with 1 million records = 1 million log lines
for (Record record : records) {
log.debug("Processing record: {}", record.getId());
process(record);
}
// GOOD: Log summary information
log.info("Starting to process {} records", records.size());
int successCount = 0;
int failCount = 0;
for (Record record : records) {
try {
process(record);
successCount++;
} catch (Exception e) {
failCount++;
// Only log individual failures -- these are exceptional
log.warn("Failed to process record {}: {}", record.getId(), e.getMessage());
}
}
log.info("Completed processing: {} succeeded, {} failed out of {} total",
successCount, failCount, records.size());
}
}
Best Practices Summary
Practice
Do
Do Not
API
Use SLF4J facade
Use implementation-specific API (JUL, Log4j directly)
Parameters
log.info("User {}", userId)
log.info("User " + userId)
Exceptions
log.error("Msg", exception)
log.error("Msg: " + e.getMessage())
Levels
INFO for business events, DEBUG for technical details
Everything at INFO or everything at DEBUG
Context
Include IDs, amounts, counts
Vague messages like "Error occurred"
MDC
Set requestId/userId in filter
Manually add IDs to every message
Sensitive data
Mask or omit
Log passwords, credit cards, tokens
Loops
Log summary before/after
Log every iteration
Guards
if (log.isDebugEnabled()) for expensive computation
Call expensive methods as log arguments
Logger declaration
private static final Logger
Creating new Logger per method call
11. Common Mistakes
Every experienced Java developer has made these mistakes. Recognizing them in code reviews will make you a better developer.
Mistake 1: Using System.out.println Instead of a Logger
// MISTAKE: System.out.println scattered through production code
public class OrderService {
public void placeOrder(Order order) {
System.out.println("Placing order: " + order); // No level, no timestamp, no thread
System.out.println("Validating..."); // Cannot turn off without deleting
System.out.println("Done!"); // Goes to stdout only
}
}
// FIX: Use a proper logger
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void placeOrder(Order order) {
log.info("Placing order {}", order.getId());
log.debug("Validating order items");
log.info("Order {} placed successfully", order.getId());
}
}
Mistake 2: String Concatenation in Log Statements
// MISTAKE: String concatenation is evaluated even when the level is disabled
log.debug("User " + user.getName() + " has " + user.getOrders().size() + " orders"
+ " totaling $" + calculateTotal(user.getOrders()));
// If DEBUG is off, Java still:
// 1. Calls user.getName()
// 2. Calls user.getOrders().size()
// 3. Calls calculateTotal() -- potentially expensive!
// 4. Concatenates 5 strings
// 5. Throws the result away
// FIX: Use parameterized logging
log.debug("User {} has {} orders totaling ${}",
user.getName(), user.getOrders().size(), calculateTotal(user.getOrders()));
// With parameterized logging, if DEBUG is off, SLF4J skips building the string.
// NOTE: The arguments are still evaluated. For expensive arguments, use isDebugEnabled() guard.
Mistake 3: Not Logging Exceptions Properly
try {
connectToDatabase();
} catch (SQLException e) {
// MISTAKE 1: Swallowing the exception entirely
// (empty catch block -- the worst possible thing)
// MISTAKE 2: Only logging the message, losing the stack trace
log.error("Database error: " + e.getMessage());
// Output: Database error: Connection refused
// WHERE did it fail? Which line? What was the root cause? All lost.
// MISTAKE 3: Using printStackTrace() instead of logging
e.printStackTrace();
// This goes to System.err, bypassing the logging framework entirely.
// No timestamp, no level, no file output, no MDC.
// CORRECT: Pass the exception as the last argument
log.error("Failed to connect to database", e);
// Output includes the full stack trace:
// 2026-02-28 10:30:00.123 [main] ERROR DatabaseService - Failed to connect to database
// java.sql.SQLException: Connection refused
// at com.mysql.cj.jdbc.ConnectionImpl.createNewIO(ConnectionImpl.java:839)
// at com.mysql.cj.jdbc.ConnectionImpl.(ConnectionImpl.java:453)
// at DatabaseService.connectToDatabase(DatabaseService.java:42)
// ...
// Caused by: java.net.ConnectException: Connection refused (Connection refused)
// at java.base/java.net.PlainSocketImpl.socketConnect(Native Method)
// ...
}
Mistake 4: Logging Too Much or Too Little
// MISTAKE: Logging too much -- "log diarrhea"
public double calculateTax(double amount, String state) {
log.info("calculateTax called"); // Noise
log.info("amount = " + amount); // Noise + concatenation
log.info("state = " + state); // Noise + concatenation
double rate = getTaxRate(state);
log.info("tax rate = " + rate); // Noise
double tax = amount * rate;
log.info("tax = " + tax); // Noise
log.info("returning tax"); // Noise
return tax;
}
// This method generates 6 log lines for a simple calculation.
// Multiply by 1000 requests/second and you have 6000 lines/second of noise.
// MISTAKE: Logging too little
public double calculateTax(double amount, String state) {
return amount * getTaxRate(state);
// No logging at all. If tax calculations are wrong, where do you start?
}
// CORRECT: Log meaningful events at the right level
public double calculateTax(double amount, String state) {
log.debug("Calculating tax for amount={} state={}", amount, state);
double rate = getTaxRate(state);
double tax = amount * rate;
log.debug("Tax calculated: amount={} state={} rate={} tax={}", amount, state, rate, tax);
return tax;
}
// Two DEBUG lines that can be turned off in production but enabled when needed.
Mistake 5: Logging Sensitive Data
// MISTAKE: Logging user data verbatim
public void registerUser(UserRegistration reg) {
log.info("Registering user: {}", reg);
// If UserRegistration.toString() includes password, SSN, or credit card... game over.
// Log files are often stored in plain text, backed up to multiple servers,
// and accessed by many team members.
}
// CORRECT: Log only safe, relevant fields
public void registerUser(UserRegistration reg) {
log.info("Registering user: email={}", reg.getEmail());
// Or override toString() to exclude sensitive fields:
// @Override public String toString() {
// return "UserRegistration{email='" + email + "', name='" + name + "'}";
// // password, ssn, creditCard intentionally excluded
// }
}
Mistake 6: Using Log4j 1.x
// MISTAKE: Still using Log4j 1.x (versions 1.2.x)
import org.apache.log4j.Logger; // <-- This is Log4j 1.x -- SECURITY VULNERABILITY!
// Log4j 1.x reached End of Life in August 2015.
// CVE-2021-44228 (Log4Shell) allows Remote Code Execution -- attackers can take over your server.
// This is a CRITICAL vulnerability rated 10.0 out of 10.0 on the CVSS scale.
// FIX: Migrate to SLF4J + Logback (or Log4j2)
// Step 1: Remove log4j 1.x dependency
// Step 2: Add SLF4J + Logback dependencies (see Section 5)
// Step 3: Replace imports:
import org.slf4j.Logger; // <-- SLF4J facade
import org.slf4j.LoggerFactory;
// Step 4: Replace logger creation:
// OLD: private static final Logger log = Logger.getLogger(MyClass.class);
// NEW:
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
// Step 5: Replace log4j.properties with logback.xml (see Section 7)
// Step 6: The logging method calls (log.info, log.error, etc.) are almost identical
12. Logging in Production
Production logging has different requirements than development logging. In production, your logs are the primary tool for understanding what is happening across hundreds of servers processing thousands of requests per second.
12.1 Structured Logging (JSON)
In production, logs should be machine-parseable. Plain text logs like 2026-02-28 10:30 INFO OrderService - Order placed are hard for log aggregation tools to parse reliably. JSON format solves this.
With JSON logging, tools like Elasticsearch, Splunk, Datadog, and Grafana Loki can index every field and let you write queries like:
Show all ERROR logs from the last hour
Show all logs where userId = "alice" and orderId = "ORD-123"
Count the number of payment failures per minute
Alert when error rate exceeds 5% of total requests
12.2 The ELK Stack
The ELK stack (Elasticsearch, Logstash, Kibana) is the most popular open-source log aggregation platform:
Component
Role
Description
Elasticsearch
Store and search
Distributed search engine that indexes log data for fast queries
Logstash
Collect and transform
Ingests logs from multiple sources, parses them, and sends to Elasticsearch
Kibana
Visualize
Web UI for searching logs, building dashboards, and setting up alerts
12.3 Log Rotation
Without log rotation, log files grow until they fill the disk and your application crashes. Always configure rolling policies:
Let us tie everything together with a realistic, production-quality example. This OrderService demonstrates all the logging concepts we have covered: appropriate log levels, parameterized messages, exception handling, MDC for request tracking, and best practices throughout.
package com.example.orders;
import java.util.List;
public class Order {
private final String id;
private final String userId;
private final List items;
private double total;
private OrderStatus status;
public Order(String id, String userId, List items) {
this.id = id;
this.userId = userId;
this.items = items;
this.total = items.stream().mapToDouble(OrderItem::getSubtotal).sum();
this.status = OrderStatus.PENDING;
}
public String getId() { return id; }
public String getUserId() { return userId; }
public List getItems() { return items; }
public double getTotal() { return total; }
public OrderStatus getStatus() { return status; }
public void setStatus(OrderStatus status) { this.status = status; }
public void setTotal(double total) { this.total = total; }
// toString excludes any sensitive user data
@Override
public String toString() {
return "Order{id='" + id + "', items=" + items.size() + ", total=" + total + ", status=" + status + "}";
}
}
enum OrderStatus { PENDING, VALIDATED, PAID, SHIPPED, CANCELLED }
class OrderItem {
private final String productName;
private final int quantity;
private final double price;
public OrderItem(String productName, int quantity, double price) {
this.productName = productName;
this.quantity = quantity;
this.price = price;
}
public String getProductName() { return productName; }
public int getQuantity() { return quantity; }
public double getPrice() { return price; }
public double getSubtotal() { return quantity * price; }
}
13.4 OrderService with Production-Quality Logging
package com.example.orders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.List;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private static final double HIGH_VALUE_THRESHOLD = 1000.0;
private static final double TAX_RATE = 0.08;
private static final double DISCOUNT_THRESHOLD = 500.0;
private static final double DISCOUNT_RATE = 0.10;
/**
* Process an order end-to-end with proper logging at every stage.
*/
public void processOrder(Order order) {
// Set MDC context for this order -- all subsequent log lines include these values
MDC.put("orderId", order.getId());
MDC.put("userId", order.getUserId());
long startTime = System.currentTimeMillis();
try {
// INFO: Business event -- order processing started
log.info("Order processing started: {} items, total=${}",
order.getItems().size(), order.getTotal());
// Step 1: Validate
validateOrder(order);
// Step 2: Apply discounts
applyDiscounts(order);
// Step 3: Calculate tax
calculateTax(order);
// Step 4: Process payment
processPayment(order);
// Step 5: Ship
shipOrder(order);
long elapsed = System.currentTimeMillis() - startTime;
// INFO: Business event -- order completed with timing
log.info("Order processing completed successfully in {}ms, finalTotal=${}",
elapsed, order.getTotal());
} catch (Exception e) {
long elapsed = System.currentTimeMillis() - startTime;
order.setStatus(OrderStatus.CANCELLED);
// ERROR: Something went wrong -- include the exception for stack trace
log.error("Order processing failed after {}ms", elapsed, e);
} finally {
// CRITICAL: Always clear MDC to prevent thread contamination
MDC.clear();
}
}
private void validateOrder(Order order) {
log.debug("Validating order");
if (order.getItems() == null || order.getItems().isEmpty()) {
// ERROR: Invalid input -- this should not happen if upstream validation works
log.error("Order has no items");
throw new IllegalArgumentException("Order must have at least one item");
}
for (OrderItem item : order.getItems()) {
if (item.getQuantity() <= 0) {
log.error("Invalid quantity {} for product '{}'",
item.getQuantity(), item.getProductName());
throw new IllegalArgumentException("Quantity must be positive for: " + item.getProductName());
}
if (item.getPrice() < 0) {
log.error("Negative price ${} for product '{}'",
item.getPrice(), item.getProductName());
throw new IllegalArgumentException("Price cannot be negative for: " + item.getProductName());
}
}
order.setStatus(OrderStatus.VALIDATED);
// DEBUG: Technical detail about validation result
log.debug("Order validated: {} items passed all checks", order.getItems().size());
}
private void applyDiscounts(Order order) {
double originalTotal = order.getTotal();
log.debug("Checking discounts for total=${}", originalTotal);
if (originalTotal >= DISCOUNT_THRESHOLD) {
double discount = originalTotal * DISCOUNT_RATE;
order.setTotal(originalTotal - discount);
// INFO: Business event -- discount applied (operations wants to track this)
log.info("Discount applied: {}% off ${} = -${}, newTotal=${}",
(int)(DISCOUNT_RATE * 100), originalTotal, discount, order.getTotal());
} else {
log.debug("No discount applied: total ${} below threshold ${}",
originalTotal, DISCOUNT_THRESHOLD);
}
}
private void calculateTax(Order order) {
double beforeTax = order.getTotal();
double tax = beforeTax * TAX_RATE;
order.setTotal(beforeTax + tax);
// DEBUG: Technical calculation detail
log.debug("Tax calculated: ${} * {} = ${}, newTotal=${}",
beforeTax, TAX_RATE, tax, order.getTotal());
}
private void processPayment(Order order) {
// INFO: Business event -- payment attempt
log.info("Processing payment of ${}", order.getTotal());
// WARN: Flag high-value orders
if (order.getTotal() > HIGH_VALUE_THRESHOLD) {
log.warn("High-value order detected: ${} exceeds threshold ${}",
order.getTotal(), HIGH_VALUE_THRESHOLD);
}
// Simulate payment processing
try {
simulatePaymentGateway(order);
order.setStatus(OrderStatus.PAID);
log.info("Payment processed successfully for ${}", order.getTotal());
} catch (RuntimeException e) {
// ERROR: Payment failed -- include the exception
log.error("Payment gateway rejected transaction for ${}", order.getTotal(), e);
throw e;
}
}
private void simulatePaymentGateway(Order order) {
// Simulate: orders with total over $5000 fail (for demo purposes)
if (order.getTotal() > 5000) {
throw new RuntimeException("Payment declined: exceeds single transaction limit");
}
log.debug("Payment gateway returned: APPROVED");
}
private void shipOrder(Order order) {
log.info("Initiating shipment");
order.setStatus(OrderStatus.SHIPPED);
log.info("Order shipped to user {}", order.getUserId());
}
}
13.5 Running the Example
package com.example.orders;
import java.util.List;
public class OrderApp {
public static void main(String[] args) {
OrderService service = new OrderService();
// Scenario 1: Normal order
System.out.println("=== Scenario 1: Normal Order ===");
Order normalOrder = new Order("ORD-001", "alice",
List.of(new OrderItem("Laptop Stand", 1, 45.99),
new OrderItem("USB-C Cable", 2, 12.99)));
service.processOrder(normalOrder);
System.out.println();
// Scenario 2: High-value order with discount
System.out.println("=== Scenario 2: High-Value Order ===");
Order highValue = new Order("ORD-002", "bob",
List.of(new OrderItem("MacBook Pro", 1, 2499.00),
new OrderItem("AppleCare+", 1, 399.00)));
service.processOrder(highValue);
System.out.println();
// Scenario 3: Order that exceeds payment limit (will fail)
System.out.println("=== Scenario 3: Failed Payment ===");
Order tooExpensive = new Order("ORD-003", "charlie",
List.of(new OrderItem("Server Rack", 3, 2500.00)));
service.processOrder(tooExpensive);
System.out.println();
// Scenario 4: Invalid order (empty items)
System.out.println("=== Scenario 4: Invalid Order ===");
Order emptyOrder = new Order("ORD-004", "dave", List.of());
service.processOrder(emptyOrder);
}
}
13.6 Expected Output
=== Scenario 1: Normal Order ===
2026-02-28 10:30:00.001 [main] INFO c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order processing started: 2 items, total=$71.97
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Validating order
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order validated: 2 items passed all checks
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Checking discounts for total=$71.97
2026-02-28 10:30:00.002 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - No discount applied: total $71.97 below threshold $500.0
2026-02-28 10:30:00.003 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Tax calculated: $71.97 * 0.08 = $5.7576, newTotal=$77.7276
2026-02-28 10:30:00.003 [main] INFO c.e.orders.OrderService [orderId=ORD-001 user=alice] - Processing payment of $77.7276
2026-02-28 10:30:00.003 [main] DEBUG c.e.orders.OrderService [orderId=ORD-001 user=alice] - Payment gateway returned: APPROVED
2026-02-28 10:30:00.003 [main] INFO c.e.orders.OrderService [orderId=ORD-001 user=alice] - Payment processed successfully for $77.7276
2026-02-28 10:30:00.003 [main] INFO c.e.orders.OrderService [orderId=ORD-001 user=alice] - Initiating shipment
2026-02-28 10:30:00.003 [main] INFO c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order shipped to user alice
2026-02-28 10:30:00.004 [main] INFO c.e.orders.OrderService [orderId=ORD-001 user=alice] - Order processing completed successfully in 3ms, finalTotal=$77.7276
=== Scenario 2: High-Value Order ===
2026-02-28 10:30:00.005 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order processing started: 2 items, total=$2898.0
2026-02-28 10:30:00.005 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Validating order
2026-02-28 10:30:00.005 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order validated: 2 items passed all checks
2026-02-28 10:30:00.005 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Checking discounts for total=$2898.0
2026-02-28 10:30:00.005 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Discount applied: 10% off $2898.0 = -$289.8, newTotal=$2608.2
2026-02-28 10:30:00.006 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Tax calculated: $2608.2 * 0.08 = $208.656, newTotal=$2816.856
2026-02-28 10:30:00.006 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Processing payment of $2816.856
2026-02-28 10:30:00.006 [main] WARN c.e.orders.OrderService [orderId=ORD-002 user=bob] - High-value order detected: $2816.856 exceeds threshold $1000.0
2026-02-28 10:30:00.006 [main] DEBUG c.e.orders.OrderService [orderId=ORD-002 user=bob] - Payment gateway returned: APPROVED
2026-02-28 10:30:00.006 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Payment processed successfully for $2816.856
2026-02-28 10:30:00.006 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Initiating shipment
2026-02-28 10:30:00.006 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order shipped to user bob
2026-02-28 10:30:00.007 [main] INFO c.e.orders.OrderService [orderId=ORD-002 user=bob] - Order processing completed successfully in 2ms, finalTotal=$2816.856
=== Scenario 3: Failed Payment ===
2026-02-28 10:30:00.008 [main] INFO c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Order processing started: 3 items, total=$7500.0
2026-02-28 10:30:00.008 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Validating order
2026-02-28 10:30:00.008 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Order validated: 3 items passed all checks
2026-02-28 10:30:00.008 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Checking discounts for total=$7500.0
2026-02-28 10:30:00.008 [main] INFO c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Discount applied: 10% off $7500.0 = -$750.0, newTotal=$6750.0
2026-02-28 10:30:00.009 [main] DEBUG c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Tax calculated: $6750.0 * 0.08 = $540.0, newTotal=$7290.0
2026-02-28 10:30:00.009 [main] INFO c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Processing payment of $7290.0
2026-02-28 10:30:00.009 [main] WARN c.e.orders.OrderService [orderId=ORD-003 user=charlie] - High-value order detected: $7290.0 exceeds threshold $1000.0
2026-02-28 10:30:00.009 [main] ERROR c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Payment gateway rejected transaction for $7290.0
java.lang.RuntimeException: Payment declined: exceeds single transaction limit
at com.example.orders.OrderService.simulatePaymentGateway(OrderService.java:112)
...
2026-02-28 10:30:00.010 [main] ERROR c.e.orders.OrderService [orderId=ORD-003 user=charlie] - Order processing failed after 2ms
=== Scenario 4: Invalid Order ===
2026-02-28 10:30:00.011 [main] INFO c.e.orders.OrderService [orderId=ORD-004 user=dave] - Order processing started: 0 items, total=$0.0
2026-02-28 10:30:00.011 [main] DEBUG c.e.orders.OrderService [orderId=ORD-004 user=dave] - Validating order
2026-02-28 10:30:00.011 [main] ERROR c.e.orders.OrderService [orderId=ORD-004 user=dave] - Order has no items
2026-02-28 10:30:00.011 [main] ERROR c.e.orders.OrderService [orderId=ORD-004 user=dave] - Order processing failed after 0ms
java.lang.IllegalArgumentException: Order must have at least one item
at com.example.orders.OrderService.validateOrder(OrderService.java:70)
...
"Payment gateway rejected" -- exception passed as last argument
8
Parameterized logging ({})
Every log statement uses {} instead of string concatenation
9
Context in messages
Order ID, user ID, amounts, item counts included
10
Performance tracking
Elapsed time measured and logged on completion/failure
11
No sensitive data logged
toString() excludes user details; no passwords/tokens
12
Separate logback.xml configuration
Console + rolling file, package-level filtering, MDC in pattern
14. Quick Reference
Topic
Key Point
Recommended stack
SLF4J (facade) + Logback (implementation)
Logger declaration
private static final Logger log = LoggerFactory.getLogger(MyClass.class)
Parameterized logging
log.info("User {} placed order {}", userId, orderId)
Exception logging
log.error("Something failed for order {}", orderId, exception) -- exception is always the last argument
Log levels
TRACE < DEBUG < INFO < WARN < ERROR. Use INFO for business events, DEBUG for technical details.
MDC
MDC.put("requestId", id) in filter/interceptor, %X{requestId} in pattern, MDC.clear() in finally
Configuration file
logback.xml in src/main/resources
Production format
JSON via logstash-logback-encoder for ELK/Splunk/Datadog
Log rotation
SizeAndTimeBasedRollingPolicy with maxFileSize, maxHistory, totalSizeCap
Async logging
Logback AsyncAppender or Log4j2 AsyncLogger for high throughput
Never log
Passwords, credit cards, SSNs, API keys, session tokens
Never use
Log4j 1.x (CVE-2021-44228), System.out.println, string concatenation in log calls
March 18, 2020
Database
1. What is JDBC?
JDBC (Java Database Connectivity) is Java’s standard API for connecting to and interacting with relational databases. Think of it as the bridge between your Java application and a database — it provides a uniform way to send SQL statements, retrieve results, and manage database connections regardless of which database vendor you use (MySQL, PostgreSQL, Oracle, SQLite, H2, etc.).
Before JDBC, every database vendor had its own proprietary API. If you wrote code for Oracle, you could not reuse it for MySQL without a complete rewrite. JDBC solved this by defining a standard set of interfaces in the java.sql package that every database vendor implements through a driver.
Your application only talks to the JDBC API. The driver translates your calls into database-specific protocol. This means you can switch databases by changing the driver and connection URL -- your SQL code stays the same (assuming standard SQL).
Key JDBC Interfaces
Interface / Class
Package
Purpose
DriverManager
java.sql
Manages JDBC drivers, creates connections
Connection
java.sql
Represents a session with the database
Statement
java.sql
Executes static SQL statements
PreparedStatement
java.sql
Executes parameterized (precompiled) SQL
CallableStatement
java.sql
Calls stored procedures
ResultSet
java.sql
Holds query results, row-by-row iteration
DataSource
javax.sql
Factory for connections (preferred over DriverManager in production)
JDBC Driver Types
There are four types of JDBC drivers, but in modern development you will almost exclusively use Type 4 (Thin Driver):
Type
Name
Description
Used Today?
Type 1
JDBC-ODBC Bridge
Translates JDBC calls to ODBC calls
Removed in Java 8
Type 2
Native-API
Uses native database client libraries
Rare
Type 3
Network Protocol
Middleware translates to database protocol
Rare
Type 4
Thin Driver (Pure Java)
Directly converts JDBC calls to database protocol
Yes -- standard
Type 4 drivers are pure Java, require no native libraries, and are the easiest to deploy. MySQL Connector/J, PostgreSQL JDBC, and the H2 driver are all Type 4.
2. Setting Up
To use JDBC, you need two things: the java.sql package (included in the JDK) and a JDBC driver for your specific database.
Adding the JDBC Driver Dependency
The driver is a JAR file that you add to your project. Here are the most common ones:
In older Java code (pre-JDBC 4.0 / pre-Java 6), you had to explicitly load the driver class:
// Legacy approach -- no longer needed since Java 6 / JDBC 4.0
Class.forName("com.mysql.cj.jdbc.Driver");
Class.forName("org.postgresql.Driver");
Since JDBC 4.0, drivers use the Service Provider mechanism (META-INF/services/java.sql.Driver) and are auto-loaded when present on the classpath. You do not need Class.forName() anymore -- just add the dependency and call DriverManager.getConnection().
If you see Class.forName() in production code today, it is legacy code that can be safely removed.
3. Establishing a Connection
A Connection object represents an active session with the database. You create one using DriverManager.getConnection() with a JDBC URL, username, and password.
JDBC URL Format
The URL follows this pattern: jdbc:<subprotocol>://<host>:<port>/<database>?<parameters>
Database
JDBC URL Example
Default Port
MySQL
jdbc:mysql://localhost:3306/mydb
3306
PostgreSQL
jdbc:postgresql://localhost:5432/mydb
5432
H2 (in-memory)
jdbc:h2:mem:testdb
N/A
H2 (file-based)
jdbc:h2:file:./data/mydb
N/A
Oracle
jdbc:oracle:thin:@localhost:1521:orcl
1521
SQL Server
jdbc:sqlserver://localhost:1433;databaseName=mydb
1433
Basic Connection (Don't Do This in Production)
The simplest way to connect -- useful for learning, but it has problems we will fix shortly:
Proper Connection with try-with-resources (Always Use This)
Connection, Statement, PreparedStatement, and ResultSet all implement AutoCloseable. This means you should always use try-with-resources to ensure they are closed properly, even when exceptions occur.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class ProperConnection {
// Store these in environment variables or a config file, never hardcoded
private static final String URL = "jdbc:mysql://localhost:3306/mydb";
private static final String USER = "root";
private static final String PASSWORD = "secret";
public static void main(String[] args) {
// try-with-resources -- connection auto-closes when block ends
try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD)) {
System.out.println("Connected!");
System.out.println("Valid: " + conn.isValid(5)); // 5-second timeout
// ... do work ...
} catch (SQLException e) {
// Connection automatically closed, even on exception
System.err.println("Error: " + e.getMessage());
}
// No finally block needed -- connection is guaranteed closed
}
}
Connection with Properties
For more configuration options, pass connection parameters as a Properties object:
JDBC provides two main ways to execute SQL: Statement and PreparedStatement. Understanding the difference is critical -- using the wrong one is the #1 security mistake in database programming.
Statement -- Simple, Static SQL
Statement executes a raw SQL string. It is fine for static queries with no user input:
import java.sql.*;
public class StatementExample {
public static void main(String[] args) throws SQLException {
try (Connection conn = DriverManager.getConnection(
"jdbc:h2:mem:testdb", "sa", "")) {
Statement stmt = conn.createStatement();
// DDL -- creating a table (no user input, Statement is fine)
stmt.executeUpdate("CREATE TABLE countries (id INT PRIMARY KEY, name VARCHAR(50))");
// Static insert (no user input)
stmt.executeUpdate("INSERT INTO countries VALUES (1, 'United States')");
stmt.executeUpdate("INSERT INTO countries VALUES (2, 'Japan')");
// Static query
ResultSet rs = stmt.executeQuery("SELECT * FROM countries");
while (rs.next()) {
System.out.println(rs.getInt("id") + ": " + rs.getString("name"));
}
}
}
}
// Output:
// 1: United States
// 2: Japan
The SQL Injection Problem
If you ever build SQL by concatenating user input into a Statement, you create a SQL injection vulnerability -- one of the most dangerous and common security flaws in software:
// DANGEROUS -- NEVER DO THIS
public User findUser(String username) throws SQLException {
Statement stmt = conn.createStatement();
// User input is concatenated directly into SQL
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
ResultSet rs = stmt.executeQuery(sql);
// ...
}
// Normal input: username = "john"
// SQL becomes: SELECT * FROM users WHERE username = 'john' <-- works fine
// Malicious input: username = "' OR '1'='1"
// SQL becomes: SELECT * FROM users WHERE username = '' OR '1'='1'
// This returns ALL users!
// Even worse: username = "'; DROP TABLE users; --"
// SQL becomes: SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
// This DELETES your entire users table!
PreparedStatement -- Parameterized, Safe SQL
PreparedStatement solves SQL injection by separating the SQL structure from the data. You write the SQL with ? placeholders, and the driver safely binds the values:
// SAFE -- Always use PreparedStatement for any query involving user input
public User findUser(String username) throws SQLException {
String sql = "SELECT * FROM users WHERE username = ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, username); // Parameter index starts at 1
ResultSet rs = pstmt.executeQuery();
// ...
}
}
// Input: username = "' OR '1'='1"
// The driver treats the ENTIRE string as a literal value, not SQL.
// It effectively becomes: WHERE username = '\' OR \'1\'=\'1'
// No injection possible!
Statement vs PreparedStatement Comparison
Feature
Statement
PreparedStatement
SQL Injection
Vulnerable when concatenating user input
Immune -- parameters are escaped automatically
Performance
Parsed and compiled every execution
Precompiled -- the database caches the execution plan
Readability
Messy string concatenation
Clean ? placeholders
Type Safety
Everything is string concatenation
Typed setters: setInt(), setDate(), setString()
Batch Operations
Supported but slower
Significantly faster with reuse
Use Case
DDL (CREATE TABLE, ALTER TABLE), static admin queries
All DML (INSERT, UPDATE, DELETE, SELECT with parameters)
Rule of thumb: Always use PreparedStatement. The only exception is DDL statements (CREATE TABLE, DROP TABLE) where there are no parameters to bind.
PreparedStatement Setter Methods
Method
Java Type
SQL Type
setInt(index, value)
int
INTEGER
setLong(index, value)
long
BIGINT
setDouble(index, value)
double
DOUBLE
setString(index, value)
String
VARCHAR / TEXT
setBoolean(index, value)
boolean
BOOLEAN / BIT
setDate(index, value)
java.sql.Date
DATE
setTimestamp(index, value)
java.sql.Timestamp
TIMESTAMP / DATETIME
setBigDecimal(index, value)
BigDecimal
DECIMAL / NUMERIC
setNull(index, sqlType)
null
Any (specify with Types.VARCHAR, etc.)
setObject(index, value)
Object
Auto-detected
5. CRUD Operations
CRUD stands for Create, Read, Update, Delete -- the four fundamental database operations. Let us walk through each one using a users table.
5.1 CREATE Table
Creating a table is a DDL (Data Definition Language) operation. Use executeUpdate() since it does not return a result set:
import java.sql.*;
public class CreateTableExample {
public static void main(String[] args) {
String url = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1";
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
String sql = """
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL,
age INT,
salary DECIMAL(10,2),
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""";
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate(sql);
System.out.println("Table 'users' created successfully.");
}
} catch (SQLException e) {
System.err.println("Error: " + e.getMessage());
}
}
}
// Output:
// Table 'users' created successfully.
5.2 INSERT -- Adding Records
Use PreparedStatement with executeUpdate(). To retrieve the auto-generated ID, pass Statement.RETURN_GENERATED_KEYS:
public class InsertExample {
// Insert a single user and return the generated ID
public static long insertUser(Connection conn, String username, String email,
int age, double salary) throws SQLException {
String sql = "INSERT INTO users (username, email, age, salary) VALUES (?, ?, ?, ?)";
try (PreparedStatement pstmt = conn.prepareStatement(sql,
Statement.RETURN_GENERATED_KEYS)) {
pstmt.setString(1, username);
pstmt.setString(2, email);
pstmt.setInt(3, age);
pstmt.setDouble(4, salary);
int rowsAffected = pstmt.executeUpdate();
System.out.println(rowsAffected + " row(s) inserted.");
// Retrieve the auto-generated key
try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) {
if (generatedKeys.next()) {
long id = generatedKeys.getLong(1);
System.out.println("Generated ID: " + id);
return id;
}
}
}
return -1;
}
public static void main(String[] args) throws SQLException {
try (Connection conn = DriverManager.getConnection(
"jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", "sa", "")) {
// Create table first (from previous example)
// ...
insertUser(conn, "john_doe", "john@example.com", 28, 75000.00);
insertUser(conn, "jane_smith", "jane@example.com", 32, 92000.00);
insertUser(conn, "bob_wilson", "bob@example.com", 45, 68000.00);
}
}
}
// Output:
// 1 row(s) inserted.
// Generated ID: 1
// 1 row(s) inserted.
// Generated ID: 2
// 1 row(s) inserted.
// Generated ID: 3
5.3 SELECT -- Reading Records
Use executeQuery() which returns a ResultSet. Iterate over it with next():
Use executeUpdate() which returns the number of affected rows. Always check this value to confirm the operation succeeded:
public class UpdateExample {
public static int updateUserEmail(Connection conn, int userId, String newEmail)
throws SQLException {
String sql = "UPDATE users SET email = ? WHERE id = ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, newEmail);
pstmt.setInt(2, userId);
int rowsAffected = pstmt.executeUpdate();
if (rowsAffected > 0) {
System.out.println("Updated email for user ID " + userId);
} else {
System.out.println("No user found with ID " + userId);
}
return rowsAffected;
}
}
public static int giveRaise(Connection conn, double percentage, double minCurrentSalary)
throws SQLException {
String sql = "UPDATE users SET salary = salary * (1 + ? / 100) WHERE salary >= ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setDouble(1, percentage);
pstmt.setDouble(2, minCurrentSalary);
int rowsAffected = pstmt.executeUpdate();
System.out.println(rowsAffected + " user(s) received a " + percentage + "% raise.");
return rowsAffected;
}
}
}
// Output:
// Updated email for user ID 1
// 2 user(s) received a 10.0% raise.
5.5 DELETE -- Removing Records
Delete operations also use executeUpdate(). Be very careful -- always use a WHERE clause unless you intentionally want to delete all rows:
public class DeleteExample {
public static int deleteUser(Connection conn, int userId) throws SQLException {
String sql = "DELETE FROM users WHERE id = ?";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, userId);
int rowsAffected = pstmt.executeUpdate();
if (rowsAffected > 0) {
System.out.println("Deleted user with ID " + userId);
} else {
System.out.println("No user found with ID " + userId);
}
return rowsAffected;
}
}
// Soft delete -- better practice than hard delete
public static int deactivateUser(Connection conn, int userId) throws SQLException {
String sql = "UPDATE users SET active = false WHERE id = ? AND active = true";
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, userId);
int rowsAffected = pstmt.executeUpdate();
System.out.println(rowsAffected > 0 ? "User deactivated." : "User not found or already inactive.");
return rowsAffected;
}
}
}
// Output:
// Deleted user with ID 3
// User deactivated.
Execute Method Summary
Method
Returns
Use For
executeQuery(sql)
ResultSet
SELECT statements
executeUpdate(sql)
int (rows affected)
INSERT, UPDATE, DELETE, DDL
execute(sql)
boolean (true if ResultSet)
When you do not know the SQL type at compile time
6. ResultSet in Detail
A ResultSet is a table of data returned by a query. It maintains an internal cursor pointing to the current row. The cursor starts before the first row, so you must call next() to move to the first row.
Navigating a ResultSet
public class ResultSetNavigation {
public static void main(String[] args) throws SQLException {
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "")) {
// Setup
Statement setup = conn.createStatement();
setup.executeUpdate("CREATE TABLE products (id INT, name VARCHAR(50), price DECIMAL(10,2))");
setup.executeUpdate("INSERT INTO products VALUES (1, 'Laptop', 999.99)");
setup.executeUpdate("INSERT INTO products VALUES (2, 'Mouse', 29.99)");
setup.executeUpdate("INSERT INTO products VALUES (3, 'Keyboard', 79.99)");
// Basic forward-only iteration
try (PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM products");
ResultSet rs = pstmt.executeQuery()) {
// Access by column name (preferred -- more readable)
while (rs.next()) {
String name = rs.getString("name");
double price = rs.getDouble("price");
System.out.println(name + ": $" + price);
}
}
// Access by column index (1-based, faster but fragile)
try (PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM products");
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
int id = rs.getInt(1); // column 1
String name = rs.getString(2); // column 2
double price = rs.getDouble(3); // column 3
System.out.println(id + ". " + name + ": $" + price);
}
}
}
}
}
// Output:
// Laptop: $999.99
// Mouse: $29.99
// Keyboard: $79.99
// 1. Laptop: $999.99
// 2. Mouse: $29.99
// 3. Keyboard: $79.99
Handling NULL Values
When a database column is NULL, JDBC getter methods return a default value for primitives (0 for numbers, false for boolean). Use wasNull() to check:
// If age column is NULL in the database:
int age = rs.getInt("age"); // Returns 0 (not null, because int is a primitive)
boolean wasNull = rs.wasNull(); // Returns true -- the actual value was NULL
// Better approach: use wrapper types with getObject()
Integer age = rs.getObject("age", Integer.class); // Returns null if column is NULL
if (age == null) {
System.out.println("Age not provided");
} else {
System.out.println("Age: " + age);
}
// For String, getString() already returns null for NULL columns
String email = rs.getString("email"); // Returns null if column is NULL
ResultSet Types
By default, a ResultSet is forward-only. You can request a scrollable ResultSet when creating the statement:
ResultSet Type
Scroll?
See Updates?
Description
TYPE_FORWARD_ONLY
No
No
Default. Cursor moves forward only. Most efficient.
TYPE_SCROLL_INSENSITIVE
Yes
No
Can scroll in any direction. Snapshot at query time.
TYPE_SCROLL_SENSITIVE
Yes
Yes
Reflects changes made by others. Rarely supported well.
// Create a scrollable ResultSet
try (PreparedStatement pstmt = conn.prepareStatement(
"SELECT * FROM products",
ResultSet.TYPE_SCROLL_INSENSITIVE,
ResultSet.CONCUR_READ_ONLY)) {
ResultSet rs = pstmt.executeQuery();
// Move to last row
rs.last();
System.out.println("Last product: " + rs.getString("name"));
System.out.println("Total rows: " + rs.getRow());
// Move to first row
rs.first();
System.out.println("First product: " + rs.getString("name"));
// Move to a specific row
rs.absolute(2);
System.out.println("Second product: " + rs.getString("name"));
// Move relative to current position
rs.relative(-1); // one row back
System.out.println("Back to first: " + rs.getString("name"));
}
// Output:
// Last product: Keyboard
// Total rows: 3
// First product: Laptop
// Second product: Mouse
// Back to first: Laptop
ResultSetMetaData
When you need to inspect the structure of a result set dynamically (column names, types, count):
try (PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM users");
ResultSet rs = pstmt.executeQuery()) {
ResultSetMetaData meta = rs.getMetaData();
int columnCount = meta.getColumnCount();
// Print column info
System.out.println("Columns in result set:");
for (int i = 1; i <= columnCount; i++) {
System.out.printf(" %s (%s) - Nullable: %s%n",
meta.getColumnName(i),
meta.getColumnTypeName(i),
meta.isNullable(i) == ResultSetMetaData.columnNullable ? "Yes" : "No");
}
// Print all data dynamically
while (rs.next()) {
for (int i = 1; i <= columnCount; i++) {
System.out.print(meta.getColumnName(i) + "=" + rs.getObject(i) + " | ");
}
System.out.println();
}
}
// Output:
// Columns in result set:
// ID (INTEGER) - Nullable: No
// USERNAME (VARCHAR) - Nullable: No
// EMAIL (VARCHAR) - Nullable: No
// AGE (INTEGER) - Nullable: Yes
// SALARY (DECIMAL) - Nullable: Yes
// ACTIVE (BOOLEAN) - Nullable: Yes
// CREATED_AT (TIMESTAMP) - Nullable: Yes
7. Transactions
A transaction groups multiple SQL operations into a single unit of work that either all succeed or all fail. Think of transferring money between bank accounts -- you cannot debit one account without crediting the other.
ACID Properties
Property
Meaning
Example
Atomicity
All operations succeed or all are rolled back
Transfer: debit AND credit both happen, or neither does
Consistency
Database moves from one valid state to another
Total money across accounts stays the same
Isolation
Concurrent transactions do not interfere
Two transfers at the same time produce correct results
Durability
Committed data survives system crashes
Once "transfer complete" is shown, it is saved permanently
Auto-Commit Mode
By default, JDBC runs in auto-commit mode -- every SQL statement is automatically committed as soon as it executes. This means each statement is its own transaction. For multi-statement transactions, you must disable auto-commit:
import java.sql.*;
public class TransactionExample {
public static void transferMoney(Connection conn, int fromAccountId,
int toAccountId, double amount) throws SQLException {
// Save auto-commit state to restore later
boolean originalAutoCommit = conn.getAutoCommit();
try {
// Step 1: Disable auto-commit -- starts a transaction
conn.setAutoCommit(false);
// Step 2: Debit from source account
String debitSql = "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?";
try (PreparedStatement debit = conn.prepareStatement(debitSql)) {
debit.setDouble(1, amount);
debit.setInt(2, fromAccountId);
debit.setDouble(3, amount);
int rows = debit.executeUpdate();
if (rows == 0) {
throw new SQLException("Insufficient funds or account not found: " + fromAccountId);
}
}
// Step 3: Credit to destination account
String creditSql = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
try (PreparedStatement credit = conn.prepareStatement(creditSql)) {
credit.setDouble(1, amount);
credit.setInt(2, toAccountId);
int rows = credit.executeUpdate();
if (rows == 0) {
throw new SQLException("Destination account not found: " + toAccountId);
}
}
// Step 4: Both operations succeeded -- commit
conn.commit();
System.out.printf("Transferred $%.2f from account %d to account %d%n",
amount, fromAccountId, toAccountId);
} catch (SQLException e) {
// Step 5: Something went wrong -- rollback ALL changes
conn.rollback();
System.err.println("Transfer failed, rolled back: " + e.getMessage());
throw e;
} finally {
// Restore original auto-commit state
conn.setAutoCommit(originalAutoCommit);
}
}
}
// Output (success):
// Transferred $500.00 from account 1 to account 2
//
// Output (failure):
// Transfer failed, rolled back: Insufficient funds or account not found: 1
Savepoints
Savepoints let you roll back part of a transaction without undoing everything. They act as checkpoints within a transaction:
public static void processOrderWithSavepoint(Connection conn) throws SQLException {
conn.setAutoCommit(false);
Savepoint orderSavepoint = null;
try {
// Insert the order
try (PreparedStatement pstmt = conn.prepareStatement(
"INSERT INTO orders (customer_id, total) VALUES (?, ?)")) {
pstmt.setInt(1, 42);
pstmt.setDouble(2, 299.99);
pstmt.executeUpdate();
}
// Mark a savepoint after the order is created
orderSavepoint = conn.setSavepoint("afterOrder");
// Try to insert shipping info (might fail)
try {
try (PreparedStatement pstmt = conn.prepareStatement(
"INSERT INTO shipping (order_id, address) VALUES (?, ?)")) {
pstmt.setInt(1, 1);
pstmt.setString(2, "123 Main St");
pstmt.executeUpdate();
}
} catch (SQLException e) {
// Shipping failed -- roll back to savepoint (order is still intact)
conn.rollback(orderSavepoint);
System.out.println("Shipping failed, but order preserved. Will ship later.");
}
conn.commit();
System.out.println("Order processed successfully.");
} catch (SQLException e) {
conn.rollback(); // Roll back everything
throw e;
} finally {
conn.setAutoCommit(true);
}
}
// Output:
// Order processed successfully.
8. Batch Operations
When you need to execute the same SQL statement many times with different parameters (e.g., inserting 1,000 rows), sending each statement individually is extremely slow due to network round-trips. Batch operations bundle multiple statements into a single round-trip.
Performance Comparison
Approach
10,000 Inserts
Network Round-Trips
Individual inserts
~30 seconds
10,000
Batch inserts
~0.5 seconds
1
Batch + transaction
~0.3 seconds
1
import java.sql.*;
public class BatchExample {
public static void batchInsertUsers(Connection conn, List users) throws SQLException {
String sql = "INSERT INTO users (username, email, age) VALUES (?, ?, ?)";
conn.setAutoCommit(false); // Wrap batch in a transaction for performance
try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
int batchSize = 500; // Flush every 500 rows to avoid memory issues
int count = 0;
for (String[] user : users) {
pstmt.setString(1, user[0]); // username
pstmt.setString(2, user[1]); // email
pstmt.setInt(3, Integer.parseInt(user[2])); // age
pstmt.addBatch(); // Add to batch
count++;
if (count % batchSize == 0) {
pstmt.executeBatch(); // Send batch to database
pstmt.clearBatch(); // Clear for next batch
System.out.println("Flushed " + count + " rows...");
}
}
// Execute remaining records
pstmt.executeBatch();
conn.commit();
System.out.println("Batch insert complete: " + count + " total rows.");
} catch (SQLException e) {
conn.rollback();
System.err.println("Batch failed, rolled back: " + e.getMessage());
throw e;
} finally {
conn.setAutoCommit(true);
}
}
public static void main(String[] args) throws SQLException {
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "")) {
// Create table...
List users = List.of(
new String[]{"user1", "user1@mail.com", "25"},
new String[]{"user2", "user2@mail.com", "30"},
new String[]{"user3", "user3@mail.com", "35"},
new String[]{"user4", "user4@mail.com", "28"},
new String[]{"user5", "user5@mail.com", "42"}
);
batchInsertUsers(conn, users);
}
}
}
// Output:
// Batch insert complete: 5 total rows.
Batch with Statement (Multiple Different SQL)
You can also batch different SQL statements together using Statement:
try (Statement stmt = conn.createStatement()) {
conn.setAutoCommit(false);
stmt.addBatch("INSERT INTO audit_log (action) VALUES ('User created')");
stmt.addBatch("UPDATE users SET active = true WHERE id = 1");
stmt.addBatch("DELETE FROM temp_tokens WHERE expired = true");
int[] results = stmt.executeBatch();
// results[0] = rows affected by first statement
// results[1] = rows affected by second statement
// results[2] = rows affected by third statement
for (int i = 0; i < results.length; i++) {
System.out.println("Statement " + (i + 1) + ": " + results[i] + " row(s) affected");
}
conn.commit();
}
// Output:
// Statement 1: 1 row(s) affected
// Statement 2: 1 row(s) affected
// Statement 3: 3 row(s) affected
9. Connection Pooling
Creating a database connection is expensive. It involves TCP handshake, authentication, SSL negotiation, and resource allocation. Opening a new connection for every database call and closing it afterward is extremely wasteful in a real application.
Connection pooling solves this by maintaining a pool of pre-created, reusable connections. When your code needs a connection, it borrows one from the pool. When done, it returns it to the pool instead of closing it.
Without Pooling vs With Pooling
Without Pooling:
Request 1: Open connection -> Execute query -> Close connection (200ms overhead)
Request 2: Open connection -> Execute query -> Close connection (200ms overhead)
Request 3: Open connection -> Execute query -> Close connection (200ms overhead)
With Pooling:
Startup: Create 10 connections in pool
Request 1: Borrow connection -> Execute query -> Return to pool (0ms overhead)
Request 2: Borrow connection -> Execute query -> Return to pool (0ms overhead)
Request 3: Borrow connection -> Execute query -> Return to pool (0ms overhead)
HikariCP -- The Industry Standard
HikariCP is the fastest and most widely used connection pool for Java. Spring Boot uses it as the default. Add it as a dependency:
com.zaxxerHikariCP5.1.0
Setting up HikariCP:
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
public class ConnectionPoolExample {
private static HikariDataSource dataSource;
// Initialize the pool once at application startup
public static void initPool() {
HikariConfig config = new HikariConfig();
// Required settings
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
config.setUsername("root");
config.setPassword("secret");
// Pool sizing
config.setMaximumPoolSize(10); // Max connections in pool
config.setMinimumIdle(5); // Min idle connections maintained
config.setIdleTimeout(300000); // 5 min -- close idle connections after this
config.setMaxLifetime(1800000); // 30 min -- max lifetime of a connection
// Connection validation
config.setConnectionTimeout(30000); // 30 sec -- wait for connection from pool
config.setConnectionTestQuery("SELECT 1"); // Validate connection health
// Performance settings
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
dataSource = new HikariDataSource(config);
System.out.println("Connection pool initialized.");
}
// Get a connection from the pool
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
// Shutdown the pool at application exit
public static void closePool() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
System.out.println("Connection pool closed.");
}
}
public static void main(String[] args) {
initPool();
// Use connections from the pool
try (Connection conn = getConnection()) {
// Connection is borrowed from the pool
System.out.println("Got connection from pool: " + conn.isValid(2));
// ... do work ...
} catch (SQLException e) {
e.printStackTrace();
}
// Connection is returned to the pool here, NOT closed
closePool();
}
}
// Output:
// Connection pool initialized.
// Got connection from pool: true
// Connection pool closed.
DataSource vs DriverManager
Feature
DriverManager
DataSource (with pool)
Connection creation
New connection every time
Reuses pooled connections
Performance
Slow (TCP+auth per call)
Fast (borrow/return)
Resource management
Manual
Automatic (pool manages lifecycle)
Configuration
URL + user + password
Pool size, timeouts, validation, metrics
Use case
Learning, small scripts
Production applications
Rule: Use DriverManager for learning and scripts. Use DataSource with HikariCP for any real application.
10. Stored Procedures
A stored procedure is a precompiled SQL program stored in the database. JDBC calls stored procedures using CallableStatement, which extends PreparedStatement.
First, here is a simple stored procedure in MySQL:
-- MySQL stored procedure: Get user by ID
DELIMITER //
CREATE PROCEDURE get_user_by_id(IN user_id INT)
BEGIN
SELECT id, username, email, salary FROM users WHERE id = user_id;
END //
DELIMITER ;
-- MySQL stored procedure with OUT parameter
DELIMITER //
CREATE PROCEDURE count_active_users(OUT user_count INT)
BEGIN
SELECT COUNT(*) INTO user_count FROM users WHERE active = true;
END //
DELIMITER ;
-- MySQL stored procedure with IN and OUT
DELIMITER //
CREATE PROCEDURE calculate_bonus(
IN employee_id INT,
IN bonus_percentage DECIMAL(5,2),
OUT bonus_amount DECIMAL(10,2)
)
BEGIN
SELECT salary * (bonus_percentage / 100) INTO bonus_amount
FROM users WHERE id = employee_id;
END //
DELIMITER ;
Calling these procedures from Java:
import java.sql.*;
public class StoredProcedureExample {
// Call procedure with IN parameter that returns a ResultSet
public static void getUserById(Connection conn, int userId) throws SQLException {
String sql = "{CALL get_user_by_id(?)}";
try (CallableStatement cstmt = conn.prepareCall(sql)) {
cstmt.setInt(1, userId);
try (ResultSet rs = cstmt.executeQuery()) {
if (rs.next()) {
System.out.printf("User: %s (%s) - $%.2f%n",
rs.getString("username"),
rs.getString("email"),
rs.getDouble("salary"));
}
}
}
}
// Call procedure with OUT parameter
public static int countActiveUsers(Connection conn) throws SQLException {
String sql = "{CALL count_active_users(?)}";
try (CallableStatement cstmt = conn.prepareCall(sql)) {
cstmt.registerOutParameter(1, Types.INTEGER); // Register OUT param
cstmt.execute();
int count = cstmt.getInt(1); // Get the OUT value
System.out.println("Active users: " + count);
return count;
}
}
// Call procedure with IN and OUT parameters
public static double calculateBonus(Connection conn, int employeeId,
double percentage) throws SQLException {
String sql = "{CALL calculate_bonus(?, ?, ?)}";
try (CallableStatement cstmt = conn.prepareCall(sql)) {
cstmt.setInt(1, employeeId); // IN
cstmt.setDouble(2, percentage); // IN
cstmt.registerOutParameter(3, Types.DECIMAL); // OUT
cstmt.execute();
double bonus = cstmt.getDouble(3);
System.out.printf("Bonus for employee %d at %.1f%%: $%.2f%n",
employeeId, percentage, bonus);
return bonus;
}
}
}
// Output:
// User: john_doe (john@example.com) - $75000.00
// Active users: 3
// Bonus for employee 1 at 10.0%: $7500.00
11. Common Mistakes
These are the mistakes that cause the most bugs, security vulnerabilities, and performance issues in JDBC code. Avoid every one of them.
Mistake 1: SQL Injection via String Concatenation
The single most dangerous mistake. Never concatenate user input into SQL strings:
Every Connection, Statement, and ResultSet holds database and JVM resources. Forgetting to close them causes connection leaks, which eventually crash your application:
// WRONG -- resources never closed if exception occurs
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery();
// if exception here, conn, pstmt, and rs are leaked
rs.close();
pstmt.close();
conn.close();
// RIGHT -- try-with-resources guarantees cleanup
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
// Work with rs...
} // All three auto-closed, even on exception
Mistake 3: Ignoring Transactions
With auto-commit on (the default), each statement is its own transaction. If a multi-step operation fails halfway, you end up with inconsistent data:
// WRONG -- auto-commit means each statement is independent
pstmt1.executeUpdate(); // Debits account A -- committed immediately
pstmt2.executeUpdate(); // Credits account B -- FAILS!
// Account A was debited but account B was never credited. Money vanished.
// RIGHT -- wrap related operations in a transaction
conn.setAutoCommit(false);
try {
pstmt1.executeUpdate(); // Debits account A -- NOT committed yet
pstmt2.executeUpdate(); // Credits account B -- NOT committed yet
conn.commit(); // Both committed together
} catch (SQLException e) {
conn.rollback(); // Both rolled back together
}
Mistake 4: The N+1 Query Problem
Executing a query inside a loop, generating N additional queries for N results:
// WRONG -- N+1 problem: 1 query to get orders + N queries to get users
ResultSet orders = stmt.executeQuery("SELECT * FROM orders"); // 1 query
while (orders.next()) {
int userId = orders.getInt("user_id");
// This runs once per order -- if 1000 orders, that's 1000 extra queries!
PreparedStatement userStmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
userStmt.setInt(1, userId);
ResultSet user = userStmt.executeQuery();
// ...
}
// RIGHT -- use a JOIN to get all data in one query
String sql = """
SELECT o.id AS order_id, o.total, u.username, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
""";
ResultSet rs = stmt.executeQuery(sql); // 1 query for everything
Mistake 5: Hardcoding Database Credentials
// WRONG -- credentials in source code (committed to git!)
Connection conn = DriverManager.getConnection(
"jdbc:mysql://prod-server:3306/mydb", "admin", "P@ssw0rd!");
// RIGHT -- use environment variables or config files
String url = System.getenv("DB_URL");
String user = System.getenv("DB_USER");
String password = System.getenv("DB_PASSWORD");
Connection conn = DriverManager.getConnection(url, user, password);
Mistake 6: Using getObject() Without Type Safety
// WRONG -- loses type safety, requires casting
Object value = rs.getObject("salary");
double salary = (Double) value; // ClassCastException if null or wrong type
// RIGHT -- use typed getters
double salary = rs.getDouble("salary");
if (rs.wasNull()) {
// Handle null case
}
// ALSO RIGHT -- use getObject with type parameter (Java 7+)
Double salary = rs.getObject("salary", Double.class); // Returns null if SQL NULL
12. Best Practices
These are the practices that separate production-quality JDBC code from tutorial-level code. Follow every one of them.
12.1 Always Use PreparedStatement
Even when the query has no parameters, PreparedStatement gives you precompilation benefits and establishes a consistent pattern. The only exception is DDL (CREATE TABLE, ALTER TABLE).
12.2 Always Use try-with-resources
Close resources in the reverse order of creation. With try-with-resources, this is automatic:
// Gold standard pattern for JDBC
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, value);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
// Process row
}
}
}
12.3 Use Connection Pooling in Production
Never use DriverManager.getConnection() in production. Always use a connection pool (HikariCP). If using Spring Boot, HikariCP is the default -- just configure it in application.properties.
12.4 Use Transactions for Multi-Statement Operations
Any operation that modifies multiple rows or tables should be wrapped in a transaction. Follow this pattern:
Use the DAO pattern (covered in the next section) to separate data access from business logic. Your service classes should not contain SQL strings.
12.6 Log SQL Errors Properly
try {
// database operation
} catch (SQLException e) {
// Log ALL the information -- you will need it for debugging
logger.error("Database error - Message: {} | SQLState: {} | ErrorCode: {} | SQL: {}",
e.getMessage(), e.getSQLState(), e.getErrorCode(), sql, e);
throw e; // Re-throw or wrap in a custom exception
}
12.7 Use Meaningful Column Aliases
// WRONG -- what does rs.getString(1) mean? Fragile if column order changes.
ResultSet rs = stmt.executeQuery("SELECT a, b, c FROM table1");
String x = rs.getString(1);
// RIGHT -- use aliases and access by name
String sql = """
SELECT u.username AS user_name,
o.total AS order_total,
o.created AS order_date
FROM users u
JOIN orders o ON u.id = o.user_id
""";
ResultSet rs = stmt.executeQuery(sql);
String name = rs.getString("user_name");
double total = rs.getDouble("order_total");
Best Practices Summary
Practice
Do
Don't
SQL parameters
Use PreparedStatement with ?
Concatenate strings into SQL
Resource cleanup
Use try-with-resources
Manual close in finally block
Connections
Use connection pool (HikariCP)
Create new connections per query
Transactions
Explicit setAutoCommit(false) for multi-step ops
Rely on auto-commit for related changes
Architecture
DAO pattern, SQL in data layer only
SQL strings scattered in business logic
Column access
Access by column name
Access by column index
Credentials
Environment variables or config files
Hardcode in source code
Error handling
Log SQL state, error code, and message
Catch and swallow exceptions
Queries
Use JOINs for related data
Query in a loop (N+1)
Null handling
Check wasNull() or use getObject()
Assume primitives from DB are never null
13. DAO Pattern
The Data Access Object (DAO) pattern separates the data access logic from the business logic. Your service layer should not know or care whether data comes from MySQL, PostgreSQL, a REST API, or a flat file. It just calls methods on a DAO interface.
Why Use the DAO Pattern?
Separation of concerns -- SQL stays in one place, business logic in another
Testability -- you can mock the DAO interface in unit tests
Swappability -- switch databases or use in-memory implementations without changing business logic
Single responsibility -- each DAO handles one entity (UserDAO, OrderDAO, ProductDAO)
import java.time.LocalDateTime;
public class User {
private int id;
private String username;
private String email;
private int age;
private double salary;
private boolean active;
private LocalDateTime createdAt;
// No-arg constructor (needed for DAO mapping)
public User() {}
// Constructor for creating new users (no ID yet)
public User(String username, String email, int age, double salary) {
this.username = username;
this.email = email;
this.age = age;
this.salary = salary;
this.active = true;
}
// Getters and setters
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
public double getSalary() { return salary; }
public void setSalary(double salary) { this.salary = salary; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@Override
public String toString() {
return String.format("User{id=%d, username='%s', email='%s', age=%d, salary=%.2f, active=%b}",
id, username, email, age, salary, active);
}
}
Step 2: Define the DAO Interface
import java.util.List;
import java.util.Optional;
public interface UserDAO {
// Create
User save(User user);
// Read
Optional findById(int id);
Optional findByUsername(String username);
List findAll();
List findByMinSalary(double minSalary);
// Update
boolean update(User user);
// Delete
boolean deleteById(int id);
// Count
long count();
}
Step 3: Implement the DAO with JDBC
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class UserDAOImpl implements UserDAO {
private final DataSource dataSource;
public UserDAOImpl(DataSource dataSource) {
this.dataSource = dataSource; // Injected -- no hardcoded connection info
}
@Override
public User save(User user) {
String sql = "INSERT INTO users (username, email, age, salary, active) VALUES (?, ?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
pstmt.setString(1, user.getUsername());
pstmt.setString(2, user.getEmail());
pstmt.setInt(3, user.getAge());
pstmt.setDouble(4, user.getSalary());
pstmt.setBoolean(5, user.isActive());
pstmt.executeUpdate();
try (ResultSet keys = pstmt.getGeneratedKeys()) {
if (keys.next()) {
user.setId(keys.getInt(1));
}
}
return user;
} catch (SQLException e) {
throw new RuntimeException("Failed to save user: " + user.getUsername(), e);
}
}
@Override
public Optional findById(int id) {
String sql = "SELECT * FROM users WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
}
return Optional.empty();
} catch (SQLException e) {
throw new RuntimeException("Failed to find user by ID: " + id, e);
}
}
@Override
public Optional findByUsername(String username) {
String sql = "SELECT * FROM users WHERE username = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, username);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
}
return Optional.empty();
} catch (SQLException e) {
throw new RuntimeException("Failed to find user by username: " + username, e);
}
}
@Override
public List findAll() {
String sql = "SELECT * FROM users ORDER BY id";
List users = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
users.add(mapRow(rs));
}
return users;
} catch (SQLException e) {
throw new RuntimeException("Failed to find all users", e);
}
}
@Override
public List findByMinSalary(double minSalary) {
String sql = "SELECT * FROM users WHERE salary >= ? ORDER BY salary DESC";
List users = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setDouble(1, minSalary);
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
users.add(mapRow(rs));
}
}
return users;
} catch (SQLException e) {
throw new RuntimeException("Failed to find users by salary", e);
}
}
@Override
public boolean update(User user) {
String sql = "UPDATE users SET username = ?, email = ?, age = ?, salary = ?, active = ? WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, user.getUsername());
pstmt.setString(2, user.getEmail());
pstmt.setInt(3, user.getAge());
pstmt.setDouble(4, user.getSalary());
pstmt.setBoolean(5, user.isActive());
pstmt.setInt(6, user.getId());
return pstmt.executeUpdate() > 0;
} catch (SQLException e) {
throw new RuntimeException("Failed to update user: " + user.getId(), e);
}
}
@Override
public boolean deleteById(int id) {
String sql = "DELETE FROM users WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
return pstmt.executeUpdate() > 0;
} catch (SQLException e) {
throw new RuntimeException("Failed to delete user: " + id, e);
}
}
@Override
public long count() {
String sql = "SELECT COUNT(*) FROM users";
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
rs.next();
return rs.getLong(1);
} catch (SQLException e) {
throw new RuntimeException("Failed to count users", e);
}
}
// Private helper -- maps a ResultSet row to a User object
private User mapRow(ResultSet rs) throws SQLException {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setEmail(rs.getString("email"));
user.setAge(rs.getInt("age"));
user.setSalary(rs.getDouble("salary"));
user.setActive(rs.getBoolean("active"));
Timestamp ts = rs.getTimestamp("created_at");
if (ts != null) {
user.setCreatedAt(ts.toLocalDateTime());
}
return user;
}
}
Step 4: Use the DAO in Business Logic
public class UserService {
private final UserDAO userDAO;
public UserService(UserDAO userDAO) {
this.userDAO = userDAO; // Dependency injection
}
public User registerUser(String username, String email, int age, double salary) {
// Business logic validation -- no SQL here
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (userDAO.findByUsername(username).isPresent()) {
throw new IllegalArgumentException("Username already taken: " + username);
}
User user = new User(username, email, age, salary);
return userDAO.save(user);
}
public User getUserOrThrow(int id) {
return userDAO.findById(id)
.orElseThrow(() -> new RuntimeException("User not found: " + id));
}
public List getHighEarners(double threshold) {
return userDAO.findByMinSalary(threshold);
}
}
// Usage:
// UserDAO dao = new UserDAOImpl(dataSource);
// UserService service = new UserService(dao);
// User user = service.registerUser("john_doe", "john@example.com", 28, 75000);
14. Complete Practical Example
Let us put everything together in a complete, runnable application. This example demonstrates a CRUD application for managing employees with proper resource management, transactions, batch operations, the DAO pattern, and all the best practices covered in this tutorial.
We will use the H2 in-memory database so you can run this without installing anything.
Project Structure
src/
Employee.java -- Model class
EmployeeDAO.java -- DAO interface
EmployeeDAOImpl.java -- JDBC implementation with all best practices
EmployeeApp.java -- Main application demonstrating all operations
Employee Model
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
public class Employee {
private int id;
private String firstName;
private String lastName;
private String email;
private String department;
private BigDecimal salary;
private LocalDate hireDate;
private boolean active;
private LocalDateTime createdAt;
public Employee() {}
public Employee(String firstName, String lastName, String email,
String department, BigDecimal salary, LocalDate hireDate) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.department = department;
this.salary = salary;
this.hireDate = hireDate;
this.active = true;
}
// Getters and setters
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getDepartment() { return department; }
public void setDepartment(String department) { this.department = department; }
public BigDecimal getSalary() { return salary; }
public void setSalary(BigDecimal salary) { this.salary = salary; }
public LocalDate getHireDate() { return hireDate; }
public void setHireDate(LocalDate hireDate) { this.hireDate = hireDate; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public String getFullName() {
return firstName + " " + lastName;
}
@Override
public String toString() {
return String.format("Employee{id=%d, name='%s %s', dept='%s', salary=%s, active=%b}",
id, firstName, lastName, department, salary, active);
}
}
Every method auto-closes Connection, PreparedStatement, ResultSet
5
Generated keys
save() retrieves auto-increment ID
6
Optional for nullable results
findById() returns Optional<Employee>
7
ResultSet mapping
Private mapRow() helper converts rows to objects
8
Batch operations
saveBatch() uses addBatch() + executeBatch()
9
Transactions
transferDepartment() wraps two tables in a transaction
10
Rollback on failure
saveBatch() and transferDepartment() both rollback on exception
11
BigDecimal for money
Salary uses BigDecimal, not double
12
java.time API
Uses LocalDate and LocalDateTime, not legacy java.util.Date
13
Dependency injection
DAO receives DataSource via constructor
14
Aggregation queries
count() and getAverageSalaryByDepartment()
15. Quick Reference
Topic
Key Point
JDBC
Java's standard API for database connectivity. Uses interfaces in java.sql with vendor-specific drivers.
Driver
Use Type 4 (pure Java). Auto-loaded since JDBC 4.0 -- no Class.forName() needed.
Connection
Use DataSource + HikariCP in production. Always close with try-with-resources.
Statement
For static SQL only (DDL). Never concatenate user input.
PreparedStatement
For all parameterized SQL. Prevents SQL injection. Precompiled for performance.
CallableStatement
For calling stored procedures. Register OUT parameters with registerOutParameter().
ResultSet
Forward-only cursor by default. Use column names over indices. Check wasNull() for primitives.
Transactions
setAutoCommit(false), then commit() or rollback(). Always restore auto-commit in finally.
Batch
addBatch() + executeBatch(). Combine with transactions for maximum performance.
Connection Pool
HikariCP is the standard. Set max pool size, idle timeout, and enable statement caching.
DAO Pattern
Interface + implementation. Separates SQL from business logic. Enables testability.
Resource Cleanup
Always try-with-resources. Close in reverse order: ResultSet, Statement, Connection.
Security
Always PreparedStatement. Never concatenate. Credentials in env vars, not code.
October 17, 2019
Regex
What is Regex?
Regex (short for Regular Expression) is a sequence of characters that defines a search pattern. Think of it as a mini-language specifically designed for matching, searching, extracting, and replacing text.
Here is a real-world analogy: imagine you are in a library looking for books. Instead of searching for one specific title, you tell the librarian: “Find me all books whose title starts with ‘Java’, has a number in the middle, and ends with ‘Guide’.” That description is essentially a regex — a template that matches multiple possibilities based on a pattern, not a fixed string.
In Java, regex is used everywhere:
Validation — Check if user input matches an expected format (email, phone number, password)
Search — Find all occurrences of a pattern in a large body of text
Extraction — Pull specific pieces of data out of strings (dates from logs, numbers from reports)
Replacement — Transform text by replacing matched patterns with new content
Splitting — Break strings apart at complex delimiters
Without regex, tasks like “find all email addresses in a 10,000-line log file” would require dozens of lines of manual string parsing. With regex, it takes one line.
Java Regex Classes
Java provides regex support through the java.util.regex package, which contains three core classes:
Class
Purpose
Key Methods
Pattern
A compiled representation of a regex pattern. Compiling is expensive, so you compile once and reuse.
compile(), matcher(), matches(), split()
Matcher
The engine that performs matching operations against a string using a Pattern.
matches(), find(), group(), replaceAll()
PatternSyntaxException
An unchecked exception thrown when a regex pattern has invalid syntax.
getMessage(), getPattern(), getIndex()
The basic workflow for using regex in Java follows three steps:
Compile the regex string into a Pattern object
Create a Matcher by calling pattern.matcher(inputString)
Execute a matching operation: matches(), find(), lookingAt(), etc.
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class RegexBasics {
public static void main(String[] args) {
// Step 1: Compile the pattern
Pattern pattern = Pattern.compile("Java");
// Step 2: Create a matcher for the input string
Matcher matcher = pattern.matcher("I love Java programming");
// Step 3: Execute matching operations
boolean found = matcher.find();
System.out.println("Found 'Java': " + found); // Found 'Java': true
// matches() checks if the ENTIRE string matches the pattern
boolean fullMatch = matcher.matches();
System.out.println("Entire string is 'Java': " + fullMatch); // Entire string is 'Java': false
// Reset and find the match position
matcher.reset();
if (matcher.find()) {
System.out.println("Match starts at index: " + matcher.start()); // Match starts at index: 7
System.out.println("Match ends at index: " + matcher.end()); // Match ends at index: 11
System.out.println("Matched text: " + matcher.group()); // Matched text: Java
}
}
}
There is an important distinction between three Matcher methods:
Method
What it Checks
Example Pattern: "Java"
matches()
Does the entire string match the pattern?
"Java" returns true, "Java rocks" returns false
find()
Is the pattern found anywhere in the string?
"I love Java" returns true
lookingAt()
Does the beginning of the string match the pattern?
"Java rocks" returns true, "I love Java" returns false
For quick one-off checks, you can skip the compile step and use the static Pattern.matches() method. However, this recompiles the pattern every time, so avoid it in loops or frequently called methods.
// Quick one-off match (compiles a new Pattern every call -- avoid in loops)
boolean isMatch = Pattern.matches("\\d+", "12345");
System.out.println("All digits: " + isMatch); // All digits: true
// Even quicker: String.matches() delegates to Pattern.matches()
boolean isDigits = "12345".matches("\\d+");
System.out.println("All digits: " + isDigits); // All digits: true
Basic Pattern Syntax
A regex pattern is built from two types of characters:
Literal characters — Match themselves exactly. The pattern cat matches the text “cat”.
Metacharacters — Special characters with special meaning. They are the building blocks of pattern logic.
Java has 14 metacharacters that have special meaning in regex. If you want to match these characters literally, you must escape them with a backslash (\).
Metacharacter
Meaning
To Match Literally
.
Any single character (except newline by default)
\\.
^
Start of string (or line in MULTILINE mode)
\\^
$
End of string (or line in MULTILINE mode)
\\$
*
Zero or more of preceding element
\\*
+
One or more of preceding element
\\+
?
Zero or one of preceding element
\\?
{}
Quantifier range (e.g., {2,5})
\\{\\}
[]
Character class definition
\\[\\]
()
Grouping and capturing
\\(\\)
\
Escape character
\\\\
|
Alternation (OR)
\\|
Critical Java note: In Java strings, the backslash (\) is itself an escape character. So to write the regex \d (which means “a digit”), you must write "\\d" in Java code — the first backslash escapes the second one for Java, and the resulting \d is what the regex engine sees.
import java.util.regex.*;
public class MetacharacterEscaping {
public static void main(String[] args) {
// Without escaping: . matches ANY character
System.out.println("file.txt".matches("file.txt")); // true
System.out.println("fileXtxt".matches("file.txt")); // true -- oops, . matched 'X'
// With escaping: \\. matches only a literal dot
System.out.println("file.txt".matches("file\\.txt")); // true
System.out.println("fileXtxt".matches("file\\.txt")); // false -- correct!
// Matching a literal dollar sign in a price
Pattern price = Pattern.compile("\\$\\d+\\.\\d{2}");
System.out.println(price.matcher("$19.99").matches()); // true
System.out.println(price.matcher("$5.00").matches()); // true
System.out.println(price.matcher("19.99").matches()); // false -- missing $
// Use Pattern.quote() to treat an entire string as a literal
String userInput = "price is $10.00 (USD)";
String searchTerm = "$10.00";
Pattern literal = Pattern.compile(Pattern.quote(searchTerm));
Matcher m = literal.matcher(userInput);
System.out.println(m.find()); // true -- matched "$10.00" literally
}
}
Character Classes
A character class (also called a character set) matches a single character from a defined set. You define a character class by placing characters inside square brackets [].
Custom Character Classes
Syntax
Meaning
Example
Matches
[abc]
Any one of a, b, or c
[aeiou]
Any vowel
[a-z]
Any character in range a through z
[a-zA-Z]
Any letter
[0-9]
Any digit 0 through 9
[0-9a-f]
Any hex digit
[^abc]
Any character except a, b, or c
[^0-9]
Any non-digit
[a-z&&[^aeiou]]
Intersection: a-z but not vowels
[a-z&&[^aeiou]]
Any consonant
Predefined Character Classes
Java provides shorthand notation for commonly used character classes. These save typing and improve readability.
Shorthand
Equivalent
Meaning
\d
[0-9]
Any digit
\D
[^0-9]
Any non-digit
\w
[a-zA-Z0-9_]
Any word character (letter, digit, or underscore)
\W
[^a-zA-Z0-9_]
Any non-word character
\s
[ \t\n\r\f]
Any whitespace character
\S
[^ \t\n\r\f]
Any non-whitespace character
.
(almost anything)
Any character except newline (unless DOTALL flag is set)
Remember: in Java strings, you write \\d to produce the regex \d.
import java.util.regex.*;
public class CharacterClasses {
public static void main(String[] args) {
// Custom character class: match a vowel followed by a consonant
Pattern vc = Pattern.compile("[aeiou][^aeiou\\s\\d]");
Matcher m = vc.matcher("hello world");
while (m.find()) {
System.out.println("Found: " + m.group() + " at index " + m.start());
}
// Found: el at index 1
// Found: or at index 7
// \\d matches any digit
System.out.println("abc".matches("\\d+")); // false
System.out.println("123".matches("\\d+")); // true
// \\w matches word characters (letters, digits, underscore)
System.out.println("hello_world".matches("\\w+")); // true
System.out.println("hello world".matches("\\w+")); // false -- space is not a word char
// \\s matches whitespace
System.out.println("has spaces".matches(".*\\s.*")); // true
System.out.println("nospaces".matches(".*\\s.*")); // false
// . matches any character except newline
System.out.println("a".matches(".")); // true
System.out.println("1".matches(".")); // true
System.out.println("".matches(".")); // false -- needs exactly one char
// Ranges: hex digit check
Pattern hex = Pattern.compile("[0-9a-fA-F]+");
System.out.println(hex.matcher("1a2bFF").matches()); // true
System.out.println(hex.matcher("GHIJ").matches()); // false
// Negation: match non-digits
Matcher nonDigits = Pattern.compile("[^0-9]+").matcher("abc123def");
while (nonDigits.find()) {
System.out.println("Non-digit segment: " + nonDigits.group());
}
// Non-digit segment: abc
// Non-digit segment: def
}
}
Quantifiers
Quantifiers control how many times a preceding element must occur for a match. Without quantifiers, each element in a pattern matches exactly once.
Quantifier Reference
Quantifier
Meaning
Example Pattern
Matches
Does Not Match
*
Zero or more
ab*c
“ac”, “abc”, “abbc”
“adc”
+
One or more
ab+c
“abc”, “abbc”
“ac”
?
Zero or one (optional)
colou?r
“color”, “colour”
“colouur”
{n}
Exactly n times
\\d{3}
“123”
“12”, “1234”
{n,}
At least n times
\\d{2,}
“12”, “123”, “1234”
“1”
{n,m}
Between n and m times
\\d{2,4}
“12”, “123”, “1234”
“1”, “12345”
Greedy vs Lazy Quantifiers
By default, all quantifiers are greedy — they match as much text as possible. Adding a ? after a quantifier makes it lazy (also called reluctant) — it matches as little text as possible.
Greedy
Lazy
Behavior
*
*?
Match as few as possible (zero or more)
+
+?
Match as few as possible (one or more)
?
??
Match zero if possible
{n,m}
{n,m}?
Match n times if possible
The difference matters most when your pattern has flexible parts and you need to control where the match stops.
import java.util.regex.*;
public class Quantifiers {
public static void main(String[] args) {
// Greedy vs Lazy demonstration
String html = "bold and more bold";
// Greedy: .* grabs as much as possible
Matcher greedy = Pattern.compile(".*").matcher(html);
if (greedy.find()) {
System.out.println("Greedy: " + greedy.group());
// Greedy: bold and more bold
// -- matched from first to LAST
}
// Lazy: .*? grabs as little as possible
Matcher lazy = Pattern.compile(".*?").matcher(html);
while (lazy.find()) {
System.out.println("Lazy: " + lazy.group());
}
// Lazy: bold
// Lazy: more bold
// -- matched each ... pair individually
// Exact count: match a US zip code (5 digits, optional -4 digits)
Pattern zip = Pattern.compile("\\d{5}(-\\d{4})?");
System.out.println(zip.matcher("90210").matches()); // true
System.out.println(zip.matcher("90210-1234").matches()); // true
System.out.println(zip.matcher("9021").matches()); // false
System.out.println(zip.matcher("902101234").matches()); // false
// Range: password length check (8 to 20 characters)
Pattern length = Pattern.compile(".{8,20}");
System.out.println(length.matcher("short").matches()); // false (5 chars)
System.out.println(length.matcher("justright").matches()); // true (9 chars)
System.out.println(length.matcher("a]".repeat(11)).matches()); // false (22 chars)
// Optional element: match "http" or "https"
Pattern protocol = Pattern.compile("https?://.*");
System.out.println(protocol.matcher("http://example.com").matches()); // true
System.out.println(protocol.matcher("https://example.com").matches()); // true
System.out.println(protocol.matcher("ftp://example.com").matches()); // false
}
}
Anchors and Boundaries
Anchors do not match characters — they match positions in the string. They assert that the current position in the string meets a certain condition.
Anchor
Meaning
Example
^
Start of string (or start of each line with MULTILINE flag)
^Hello matches “Hello world” but not “Say Hello”
$
End of string (or end of each line with MULTILINE flag)
world$ matches “Hello world” but not “world peace”
\b
Word boundary (between a word char and a non-word char)
\bcat\b matches “the cat sat” but not “concatenate”
\B
Non-word boundary (between two word chars or two non-word chars)
\Bcat\B matches “concatenate” but not “the cat sat”
Word boundaries (\b) are one of the most useful anchors. A word boundary exists between a word character (\w) and a non-word character (\W), or at the start/end of the string if it begins/ends with a word character.
import java.util.regex.*;
public class AnchorsAndBoundaries {
public static void main(String[] args) {
// ^ and $ -- start and end anchors
System.out.println("Hello World".matches("^Hello.*")); // true
System.out.println("Say Hello".matches("^Hello.*")); // false
// Without anchors, find() looks anywhere in the string
Matcher m1 = Pattern.compile("error").matcher("An error occurred");
System.out.println(m1.find()); // true
// With anchors, matches() checks the entire string
System.out.println("An error occurred".matches("error")); // false -- not the whole string
System.out.println("error".matches("error")); // true
// \\b word boundary -- match whole words only
String text = "The cat scattered the catalog across the category";
Matcher wordCat = Pattern.compile("\\bcat\\b").matcher(text);
int count = 0;
while (wordCat.find()) {
System.out.println("Found whole word 'cat' at index " + wordCat.start());
count++;
}
System.out.println("Total matches: " + count);
// Found whole word 'cat' at index 4
// Total matches: 1
// -- "scattered", "catalog", and "category" were correctly excluded
// Without word boundary -- matches "cat" inside other words too
Matcher anyCat = Pattern.compile("cat").matcher(text);
count = 0;
while (anyCat.find()) {
count++;
}
System.out.println("Without boundary: " + count + " matches");
// Without boundary: 4 matches
// ^ and $ with MULTILINE flag -- match each line
String multiline = "First line\nSecond line\nThird line";
Matcher lineStarts = Pattern.compile("^\\w+", Pattern.MULTILINE).matcher(multiline);
while (lineStarts.find()) {
System.out.println("Line starts with: " + lineStarts.group());
}
// Line starts with: First
// Line starts with: Second
// Line starts with: Third
}
}
Groups and Capturing
Parentheses () in a regex serve two purposes: they group parts of the pattern together (so quantifiers or alternation can apply to the whole group), and they capture the matched text (so you can retrieve it later).
Capturing Groups
Each pair of parentheses creates a capturing group, numbered left-to-right starting at 1. Group 0 always refers to the entire match.
For the pattern (\\d{3})-(\\d{3})-(\\d{4}) matching “555-123-4567”:
group(0) = “555-123-4567” (entire match)
group(1) = “555” (area code)
group(2) = “123” (prefix)
group(3) = “4567” (line number)
Named Groups
Numbered groups can be hard to read in complex patterns. Java supports named capturing groups using the syntax (?<name>...). You retrieve the value with matcher.group("name").
Non-Capturing Groups
Sometimes you need parentheses for grouping (e.g., to apply a quantifier to a group) but do not need to capture the matched text. Use (?:...) for a non-capturing group. This is slightly more efficient since the regex engine does not need to store the match.
Backreferences
A backreference refers back to a previously captured group within the same pattern. \\1 refers to the text matched by group 1, \\2 refers to group 2, and so on. This is useful for finding repeated patterns like duplicate words.
import java.util.regex.*;
public class GroupsAndCapturing {
public static void main(String[] args) {
// --- Numbered Capturing Groups ---
String phone = "Call me at 555-123-4567 or 800-555-0199";
Pattern phonePattern = Pattern.compile("(\\d{3})-(\\d{3})-(\\d{4})");
Matcher m = phonePattern.matcher(phone);
while (m.find()) {
System.out.println("Full match: " + m.group(0));
System.out.println("Area code: " + m.group(1));
System.out.println("Prefix: " + m.group(2));
System.out.println("Line number: " + m.group(3));
System.out.println();
}
// Full match: 555-123-4567
// Area code: 555
// Prefix: 123
// Line number: 4567
//
// Full match: 800-555-0199
// Area code: 800
// Prefix: 555
// Line number: 0199
// --- Named Capturing Groups ---
String dateStr = "2026-02-28";
Pattern datePattern = Pattern.compile(
"(?\\d{4})-(?\\d{2})-(?\\d{2})"
);
Matcher dm = datePattern.matcher(dateStr);
if (dm.matches()) {
System.out.println("Year: " + dm.group("year")); // Year: 2026
System.out.println("Month: " + dm.group("month")); // Month: 02
System.out.println("Day: " + dm.group("day")); // Day: 28
}
// --- Non-Capturing Groups ---
// Match "http" or "https" without capturing the "s"
Pattern url = Pattern.compile("(?:https?)://([\\w.]+)");
Matcher um = url.matcher("Visit https://example.com today");
if (um.find()) {
System.out.println("Full match: " + um.group(0)); // Full match: https://example.com
System.out.println("Domain: " + um.group(1)); // Domain: example.com
// group(1) is the domain, not "https" -- because (?:...) did not capture
}
// --- Backreferences: find duplicate words ---
String text = "This is is a test test of of duplicate words";
Pattern dupes = Pattern.compile("\\b(\\w+)\\s+\\1\\b", Pattern.CASE_INSENSITIVE);
Matcher dupeMatcher = dupes.matcher(text);
while (dupeMatcher.find()) {
System.out.println("Duplicate found: \"" + dupeMatcher.group() + "\"");
}
// Duplicate found: "is is"
// Duplicate found: "test test"
// Duplicate found: "of of"
}
}
Alternation and Lookaround
Alternation (OR)
The pipe character | acts as an OR operator. The pattern cat|dog matches either “cat” or “dog”. Alternation has the lowest precedence of any regex operator, so gray|grey matches “gray” or “grey”, not “gra” followed by “y|grey”.
To limit the scope of alternation, use parentheses: gr(a|e)y matches “gray” or “grey”.
Lookahead and Lookbehind
Lookaround assertions check if a pattern exists before or after the current position, but they do not consume characters (the match position does not advance). They are “zero-width assertions” — they assert a condition without including the matched text in the result.
Syntax
Name
Meaning
Example
(?=...)
Positive lookahead
What follows must match
\\d+(?= dollars) matches “100” in “100 dollars”
(?!...)
Negative lookahead
What follows must NOT match
\\d+(?! dollars) matches “100” in “100 euros”
(?<=...)
Positive lookbehind
What precedes must match
(?<=\\$)\\d+ matches "50" in "$50"
(?
Negative lookbehind
What precedes must NOT match
(? matches "50" in "50" but not in "$50"
Lookarounds are especially useful in password validation, where you need to check multiple conditions at the same position (e.g., must contain a digit AND a special character AND an uppercase letter).
import java.util.regex.*;
public class AlternationAndLookaround {
public static void main(String[] args) {
// --- Alternation ---
Pattern pet = Pattern.compile("cat|dog|bird");
String text = "I have a cat and a dog but no bird";
Matcher m = pet.matcher(text);
while (m.find()) {
System.out.println("Found pet: " + m.group());
}
// Found pet: cat
// Found pet: dog
// Found pet: bird
// Alternation with grouping
Pattern color = Pattern.compile("gr(a|e)y");
System.out.println(color.matcher("gray").matches()); // true
System.out.println(color.matcher("grey").matches()); // true
System.out.println(color.matcher("griy").matches()); // false
// --- Positive Lookahead: find numbers followed by "px" ---
Matcher lookahead = Pattern.compile("\\d+(?=px)").matcher("width: 100px; height: 50px; margin: 10em");
while (lookahead.find()) {
System.out.println("Pixel value: " + lookahead.group());
}
// Pixel value: 100
// Pixel value: 50
// -- "10" was excluded because it is followed by "em", not "px"
// --- Negative Lookahead: find numbers NOT followed by "px" ---
Matcher negLookahead = Pattern.compile("\\d+(?!px)").matcher("width: 100px; margin: 10em");
while (negLookahead.find()) {
System.out.println("Non-pixel: " + negLookahead.group());
}
// Non-pixel: 10
// Non-pixel: 10
// --- Positive Lookbehind: extract amounts after $ ---
Matcher lookbehind = Pattern.compile("(?<=\\$)\\d+\\.?\\d*").matcher("Price: $19.99 and $5.00");
while (lookbehind.find()) {
System.out.println("Amount: " + lookbehind.group());
}
// Amount: 19.99
// Amount: 5.00
// --- Password validation using multiple lookaheads ---
// At least 8 chars, one uppercase, one lowercase, one digit, one special char
Pattern strongPassword = Pattern.compile(
"^(?=.*[A-Z])" + // at least one uppercase
"(?=.*[a-z])" + // at least one lowercase
"(?=.*\\d)" + // at least one digit
"(?=.*[@#$%^&+=!])" + // at least one special character
".{8,}$" // at least 8 characters total
);
String[] passwords = {"Passw0rd!", "password", "SHORT1!", "MyP@ss12"};
for (String pw : passwords) {
boolean strong = strongPassword.matcher(pw).matches();
System.out.println(pw + " -> " + (strong ? "STRONG" : "WEAK"));
}
// Passw0rd! -> STRONG
// password -> WEAK
// SHORT1! -> WEAK
// MyP@ss12 -> STRONG
}
}
Common String Methods with Regex
Java's String class has several built-in methods that accept regex patterns. These are convenient for simple use cases where you do not need the full power of Pattern and Matcher.
Method
What it Does
Returns
String.matches(regex)
Tests if the entire string matches the regex
boolean
String.split(regex)
Splits the string at each match of the regex
String[]
String.split(regex, limit)
Splits with a limit on the number of parts
String[]
String.replaceAll(regex, replacement)
Replaces all matches with the replacement
String
String.replaceFirst(regex, replacement)
Replaces only the first match
String
Performance warning: Every call to these methods compiles a new Pattern internally. If you call them in a loop or frequently, compile the Pattern once yourself and use Matcher instead.
import java.util.Arrays;
public class StringRegexMethods {
public static void main(String[] args) {
// --- matches() -- checks the ENTIRE string ---
System.out.println("12345".matches("\\d+")); // true
System.out.println("123abc".matches("\\d+")); // false -- not all digits
System.out.println("hello".matches("[a-z]+")); // true
// --- split() -- break a string into parts ---
// Split on one or more whitespace characters
String sentence = "Split this string up";
String[] words = sentence.split("\\s+");
System.out.println(Arrays.toString(words));
// [Split, this, string, up]
// Split a CSV line (handles optional spaces after commas)
String csv = "Java, Python, C++, JavaScript";
String[] languages = csv.split(",\\s*");
System.out.println(Arrays.toString(languages));
// [Java, Python, C++, JavaScript]
// Split with a limit
String path = "com.example.project.Main";
String[] parts = path.split("\\.", 3); // at most 3 parts
System.out.println(Arrays.toString(parts));
// [com, example, project.Main]
// --- replaceAll() -- replace all matches ---
// Remove all non-alphanumeric characters
String dirty = "Hello, World! @2026";
String clean = dirty.replaceAll("[^a-zA-Z0-9]", "");
System.out.println(clean); // HelloWorld2026
// Normalize whitespace: replace multiple spaces/tabs with a single space
String messy = "too many spaces here";
String normalized = messy.replaceAll("\\s+", " ");
System.out.println(normalized); // too many spaces here
// --- replaceFirst() -- replace only the first match ---
String text = "error: file not found. error: permission denied.";
String result = text.replaceFirst("error", "WARNING");
System.out.println(result);
// WARNING: file not found. error: permission denied.
// Use captured groups in replacement with $1, $2, etc.
// Reformat dates from MM/DD/YYYY to YYYY-MM-DD
String date = "02/28/2026";
String reformatted = date.replaceAll("(\\d{2})/(\\d{2})/(\\d{4})", "$3-$1-$2");
System.out.println(reformatted); // 2026-02-28
}
}
Pattern Flags
Pattern flags modify how the regex engine interprets the pattern. You pass them as the second argument to Pattern.compile(), or embed them directly in the pattern using inline flag syntax.
Flag Constant
Inline
Effect
Pattern.CASE_INSENSITIVE
(?i)
Matches letters regardless of case. abc matches "ABC".
Pattern.MULTILINE
(?m)
^ and $ match start/end of each line, not just the entire string.
Pattern.DOTALL
(?s)
. matches any character including newline.
Pattern.COMMENTS
(?x)
Whitespace and comments (# to end of line) in the pattern are ignored. Great for readability.
Pattern.UNICODE_CASE
(?u)
Case-insensitive matching follows Unicode rules, not just ASCII.
Pattern.LITERAL
--
The pattern is treated as a literal string (metacharacters have no special meaning).
You can combine multiple flags using the bitwise OR operator (|).
import java.util.regex.*;
public class PatternFlags {
public static void main(String[] args) {
// --- CASE_INSENSITIVE ---
Pattern ci = Pattern.compile("java", Pattern.CASE_INSENSITIVE);
System.out.println(ci.matcher("JAVA").matches()); // true
System.out.println(ci.matcher("Java").matches()); // true
System.out.println(ci.matcher("jAvA").matches()); // true
// Same thing using inline flag (?i)
System.out.println("JAVA".matches("(?i)java")); // true
// --- MULTILINE ---
String log = "ERROR: disk full\nWARN: low memory\nERROR: timeout";
Pattern errorLines = Pattern.compile("^ERROR.*$", Pattern.MULTILINE);
Matcher m = errorLines.matcher(log);
while (m.find()) {
System.out.println(m.group());
}
// ERROR: disk full
// ERROR: timeout
// --- DOTALL ---
String html = "
\nHello\nWorld\n
";
// Without DOTALL, . does not match newlines
System.out.println(html.matches("
.*
")); // false
// With DOTALL, . matches everything including newlines
Pattern dotall = Pattern.compile("
.*
", Pattern.DOTALL);
System.out.println(dotall.matcher(html).matches()); // true
// --- COMMENTS -- write readable patterns ---
Pattern readable = Pattern.compile(
"\\d{3}" + // area code
"-" + // separator
"\\d{3}" + // prefix
"-" + // separator
"\\d{4}" // line number
);
System.out.println(readable.matcher("555-123-4567").matches()); // true
// Using COMMENTS flag with whitespace and # comments in the pattern itself
Pattern commented = Pattern.compile(
"(?x) " + // enable comments mode
"\\d{3} " + // area code
"- " + // dash separator
"\\d{3} " + // prefix
"- " + // dash separator
"\\d{4} " // line number
);
System.out.println(commented.matcher("555-123-4567").matches()); // true
// --- Combining multiple flags ---
Pattern combined = Pattern.compile(
"^error.*$",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE
);
Matcher cm = combined.matcher("Error: something\nERROR: another\ninfo: ok");
while (cm.find()) {
System.out.println("Found: " + cm.group());
}
// Found: Error: something
// Found: ERROR: another
}
}
Practical Validation Examples
One of the most common uses of regex is input validation. Below are battle-tested patterns for common formats, each broken down so you understand every part.
1. Email Validation
A simplified but practical email regex. Note that the full RFC 5322 email spec is extremely complex -- this pattern covers the vast majority of real-world addresses.
Uses lookaheads to enforce multiple rules simultaneously: minimum length, uppercase, lowercase, digit, and special character.
// Password must have:
// (?=.*[A-Z]) -- at least one uppercase letter
// (?=.*[a-z]) -- at least one lowercase letter
// (?=.*\\d) -- at least one digit
// (?=.*[@#$%^&+=!]) -- at least one special character
// .{8,20} -- between 8 and 20 characters total
String passwordRegex = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=!]).{8,20}$";
String[] passwords = {"Str0ng!Pass", "weakpassword", "SHORT1!", "NoSpecial1", "G00d@Pwd"};
for (String pw : passwords) {
System.out.println(pw + " -> " + (pw.matches(passwordRegex) ? "STRONG" : "WEAK"));
}
// Str0ng!Pass -> STRONG
// weakpassword -> WEAK (no uppercase, no digit, no special)
// SHORT1! -> WEAK (less than 8 chars)
// NoSpecial1 -> WEAK (no special character)
// G00d@Pwd -> STRONG
// Credit card: 13-19 digits, optionally separated by spaces or dashes every 4 digits
// Common formats: Visa (4xxx), Mastercard (5xxx), Amex (34xx/37xx)
String ccRegex = "^\\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{1,7}$";
String[] cards = {"4111111111111111", "4111-1111-1111-1111", "4111 1111 1111 1111", "411", "1234567890123456789012"};
for (String card : cards) {
System.out.println(card + " -> " + (card.matches(ccRegex) ? "VALID FORMAT" : "INVALID FORMAT"));
}
// 4111111111111111 -> VALID FORMAT
// 4111-1111-1111-1111 -> VALID FORMAT
// 4111 1111 1111 1111 -> VALID FORMAT
// 411 -> INVALID FORMAT
// 1234567890123456789012 -> INVALID FORMAT
// Note: this only validates the FORMAT, not the actual card number.
// Use the Luhn algorithm for checksum validation.
8. Social Security Number (SSN)
// SSN format: XXX-XX-XXXX
// (?!000|666) -- area number cannot be 000 or 666
// (?!9) -- area number cannot start with 9
// \\d{3} -- 3-digit area number
// - -- separator
// (?!00)\\d{2} -- 2-digit group number (not 00)
// - -- separator
// (?!0000)\\d{4} -- 4-digit serial number (not 0000)
String ssnRegex = "^(?!000|666)(?!9\\d{2})\\d{3}-(?!00)\\d{2}-(?!0000)\\d{4}$";
String[] ssns = {"123-45-6789", "000-12-3456", "666-12-3456", "900-12-3456", "123-00-6789", "123-45-0000"};
for (String ssn : ssns) {
System.out.println(ssn + " -> " + (ssn.matches(ssnRegex) ? "VALID" : "INVALID"));
}
// 123-45-6789 -> VALID
// 000-12-3456 -> INVALID (area 000)
// 666-12-3456 -> INVALID (area 666)
// 900-12-3456 -> INVALID (area starts with 9)
// 123-00-6789 -> INVALID (group 00)
// 123-45-0000 -> INVALID (serial 0000)
Search and Replace
Beyond validation, regex is heavily used for searching text and performing replacements. The Matcher class gives you fine-grained control over the search and replace process.
Finding All Matches with find()
The find() method scans the input for the next match. Call it in a while loop to iterate through all matches.
import java.util.regex.*;
import java.util.ArrayList;
import java.util.List;
public class SearchAndReplace {
public static void main(String[] args) {
// --- Finding all matches ---
String text = "Contact us at support@company.com or sales@company.com. " +
"Personal: john.doe@gmail.com";
Pattern emailPattern = Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
Matcher finder = emailPattern.matcher(text);
List emails = new ArrayList<>();
while (finder.find()) {
emails.add(finder.group());
System.out.println("Found email at [" + finder.start() + "-" + finder.end() + "]: " + finder.group());
}
// Found email at [17-36]: support@company.com
// Found email at [40-57]: sales@company.com
// Found email at [69-88]: john.doe@gmail.com
System.out.println("Total emails found: " + emails.size()); // Total emails found: 3
// --- Simple replaceAll ---
String censored = emailPattern.matcher(text).replaceAll("[REDACTED]");
System.out.println(censored);
// Contact us at [REDACTED] or [REDACTED]. Personal: [REDACTED]
// --- replaceFirst ---
String firstOnly = emailPattern.matcher(text).replaceFirst("[REDACTED]");
System.out.println(firstOnly);
// Contact us at [REDACTED] or sales@company.com. Personal: john.doe@gmail.com
}
}
Custom Replacement with appendReplacement / appendTail
When you need dynamic replacements (e.g., the replacement depends on the matched value), use appendReplacement() and appendTail(). This pair lets you build a result string incrementally, applying custom logic to each match.
import java.util.regex.*;
public class CustomReplacement {
public static void main(String[] args) {
// Convert all words to title case using appendReplacement
String input = "the quick brown fox jumps over the lazy dog";
Pattern wordPattern = Pattern.compile("\\b([a-z])(\\w*)");
Matcher m = wordPattern.matcher(input);
StringBuilder result = new StringBuilder();
while (m.find()) {
String titleCase = m.group(1).toUpperCase() + m.group(2);
m.appendReplacement(result, titleCase);
}
m.appendTail(result);
System.out.println(result);
// The Quick Brown Fox Jumps Over The Lazy Dog
// Mask credit card numbers: show only last 4 digits
String data = "Card: 4111-1111-1111-1111, Another: 5500-0000-0000-0004";
Pattern ccPattern = Pattern.compile("(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})");
Matcher ccMatcher = ccPattern.matcher(data);
StringBuilder masked = new StringBuilder();
while (ccMatcher.find()) {
String replacement = "****-****-****-" + ccMatcher.group(4);
ccMatcher.appendReplacement(masked, replacement);
}
ccMatcher.appendTail(masked);
System.out.println(masked);
// Card: ****-****-****-1111, Another: ****-****-****-0004
// Java 9+: Matcher.replaceAll with a Function
String prices = "Items cost $5 and $23 and $100";
Pattern pricePattern = Pattern.compile("\\$(\\d+)");
String doubled = pricePattern.matcher(prices).replaceAll(mr -> {
int amount = Integer.parseInt(mr.group(1));
return "\\$" + (amount * 2);
});
System.out.println(doubled);
// Items cost $10 and $46 and $200
}
}
Common Mistakes
Even experienced developers make regex mistakes. Here are the most frequent pitfalls and how to avoid them.
1. Forgetting to Double-Escape Backslashes in Java
This is the number one mistake for Java developers. In regex, \d means "digit." In a Java string, \d is not a valid escape sequence. You must write \\d so Java's string parser produces the single backslash that the regex engine expects.
// WRONG -- Java does not recognize \d as a string escape
// String pattern = "\d+"; // Compilation error!
// CORRECT -- double backslash to produce \d for the regex engine
String pattern = "\\d+";
// To match a literal backslash in text, you need FOUR backslashes:
// Java string: "\\\\" -> produces: \\ -> regex sees: \ (literal backslash)
String backslashPattern = "\\\\";
System.out.println("C:\\Users".matches(".*\\\\.*")); // true
2. Catastrophic Backtracking
Certain regex patterns can cause the engine to take an exponential amount of time on certain inputs. This happens when a pattern has nested quantifiers that can match the same characters in multiple ways.
// DANGEROUS -- nested quantifiers can cause catastrophic backtracking
// String bad = "(a+)+b";
// On input "aaaaaaaaaaaaaaaaaac", the engine tries every possible way
// to split the 'a's between the inner and outer groups before failing.
// This can freeze your application.
// SAFE -- flatten the nesting
String safe = "a+b";
// This matches the same thing but without the exponential backtracking risk.
// Another common danger: matching quoted strings with nested quantifiers
// DANGEROUS: "(.*)*"
// SAFE: "[^"]*" -- use negated character class instead
3. Overly Complex Patterns
If your regex is more than about 80 characters long, consider breaking the validation into multiple simpler steps. A 200-character regex that validates everything at once is nearly impossible to maintain.
// BAD -- one massive unreadable regex
// String nightmare = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=!])[a-zA-Z0-9@#$%^&+=!]{8,20}$";
// BETTER -- break into understandable steps
public static boolean isStrongPassword(String password) {
if (password == null) return false;
if (password.length() < 8 || password.length() > 20) return false;
if (!password.matches(".*[A-Z].*")) return false; // needs uppercase
if (!password.matches(".*[a-z].*")) return false; // needs lowercase
if (!password.matches(".*\\d.*")) return false; // needs digit
if (!password.matches(".*[@#$%^&+=!].*")) return false; // needs special char
return true;
}
// Easier to read, debug, and extend. Each rule is independently testable.
4. Testing Only the Happy Path
Always test your regex with edge cases: empty strings, very long strings, strings with special characters, and strings that are close to matching but should not.
// Testing an email regex -- you need ALL of these test cases
String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$";
// Happy path
assert "user@example.com".matches(emailRegex); // standard email
assert "first.last@company.co.uk".matches(emailRegex); // dots and subdomains
// Edge cases that should FAIL
assert !"".matches(emailRegex); // empty string
assert !"@example.com".matches(emailRegex); // missing local part
assert !"user@".matches(emailRegex); // missing domain
assert !"user@.com".matches(emailRegex); // domain starts with dot
assert !"user@com".matches(emailRegex); // no TLD separator
assert !"user@@example.com".matches(emailRegex); // double @
// Edge cases that should PASS
assert "a@b.co".matches(emailRegex); // minimal valid email
assert "user+tag@gmail.com".matches(emailRegex); // plus addressing
5. Using matches() When You Meant find()
String.matches() and Matcher.matches() check if the entire string matches the pattern. If you want to check if the pattern appears anywhere in the string, use Matcher.find().
String text = "Error code: 404";
// WRONG -- matches() checks the ENTIRE string
System.out.println(text.matches("\\d+")); // false -- the entire string is not digits
// CORRECT -- find() searches for the pattern anywhere
Matcher m = Pattern.compile("\\d+").matcher(text);
System.out.println(m.find()); // true
System.out.println(m.group()); // 404
// If you must use matches(), wrap the pattern with .*
System.out.println(text.matches(".*\\d+.*")); // true -- but find() is cleaner
Best Practices
Follow these guidelines to write regex that is correct, readable, and performant.
1. Compile Patterns Once
The Pattern.compile() method is expensive. If you use the same regex multiple times (in a loop, in a method called frequently, etc.), compile it once and store it as a static final field.
public class UserValidator {
// GOOD -- compiled once, reused many times
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
private static final Pattern PHONE_PATTERN =
Pattern.compile("^(\\+1[- ]?)?(\\(?\\d{3}\\)?[- ]?)?\\d{3}[- ]?\\d{4}$");
public static boolean isValidEmail(String email) {
return email != null && EMAIL_PATTERN.matcher(email).matches();
}
public static boolean isValidPhone(String phone) {
return phone != null && PHONE_PATTERN.matcher(phone).matches();
}
// BAD -- compiles a new Pattern on every call
// public static boolean isValidEmailBad(String email) {
// return email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
// }
}
2. Use Named Groups for Readability
Named groups make your code self-documenting. Instead of remembering that group(3) is the year, use group("year").
3. Use Pattern.quote() for Literal Matching
When you are searching for user-supplied text that might contain regex metacharacters, use Pattern.quote() to escape everything automatically.
4. Keep Patterns Simple
If a regex grows beyond a readable length, consider breaking the validation into multiple steps or using a combination of regex and plain Java logic.
5. Comment Complex Patterns
Use Java string concatenation with comments, or the COMMENTS flag, to make complex patterns understandable.
6. Test with Edge Cases
Always test with: empty strings, null input, maximum-length input, strings with only special characters, strings that are "almost" valid, and internationalized input (if applicable).
7. Prefer Specific Patterns Over Greedy Wildcards
Instead of .* (which matches anything), use character classes that describe what you actually expect: [^"]* instead of .* inside quotes, \\d+ instead of .+ for numbers.
Best Practices Summary
Practice
Do
Do Not
Compile patterns
static final Pattern P = Pattern.compile(...)
str.matches("...") in a loop
Escape user input
Pattern.quote(userInput)
Concatenate user input directly into regex
Name groups
(?<year>\\d{4})
(\\d{4}) then group(1)
Be specific
[^"]* between quotes
.* between quotes
Handle null
Check null before matching
Call .matches() on nullable values
Break complex logic
Multiple simple checks
One enormous regex
Test edge cases
Empty, long, special chars, near-misses
Test only the happy path
Quick Reference Table
A comprehensive reference of all regex syntax elements covered in this tutorial.
Category
Syntax
Meaning
Java String
Character Classes
[abc]
Any of a, b, or c
"[abc]"
[^abc]
Not a, b, or c
"[^abc]"
[a-z]
Range a through z
"[a-z]"
\d / \D
Digit / Non-digit
"\\d" / "\\D"
\w / \W
Word char / Non-word char
"\\w" / "\\W"
\s / \S
Whitespace / Non-whitespace
"\\s" / "\\S"
.
Any character (except newline)
"."
Quantifiers
*
Zero or more
"a*"
+
One or more
"a+"
?
Zero or one
"a?"
{n}
Exactly n
"a{3}"
{n,m}
Between n and m
"a{2,5}"
*? / +?
Lazy (minimal) match
"a*?" / "a+?"
Anchors
^
Start of string/line
"^"
$
End of string/line
"$"
\b
Word boundary
"\\b"
\B
Non-word boundary
"\\B"
Groups
(...)
Capturing group
"(abc)"
(?:...)
Non-capturing group
"(?:abc)"
(?<name>...)
Named group
"(?<name>abc)"
\1
Backreference to group 1
"\\1"
|
Alternation (OR)
"cat|dog"
Lookaround
(?=...)
Positive lookahead
"(?=abc)"
(?!...)
Negative lookahead
"(?!abc)"
(?<=...)
Positive lookbehind
"(?<=abc)"
(?
Negative lookbehind
"(?
Flags
(?i)
Case insensitive
Pattern.CASE_INSENSITIVE
(?m)
Multiline (^ $ match lines)
Pattern.MULTILINE
(?s)
Dotall (. matches newline)
Pattern.DOTALL
(?x)
Comments mode
Pattern.COMMENTS
(?u)
Unicode case
Pattern.UNICODE_CASE
--
Literal (no metacharacters)
Pattern.LITERAL
Complete Practical Example: Log Parser and Input Validator
This final example brings together everything we have learned. It is a complete, runnable program that demonstrates regex in two real-world scenarios: parsing structured log files and validating user input for a registration form.
import java.util.regex.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* Complete Regex Example: LogParser and InputValidator
*
* Demonstrates:
* - Pattern compilation and reuse (static final)
* - Named capturing groups
* - Multiple validation patterns
* - find() with while loop for extraction
* - replaceAll for data masking
* - appendReplacement for custom replacement
* - Lookaheads for password validation
* - Word boundaries
* - Greedy vs lazy matching
* - Pattern flags
*/
public class RegexDemo {
// =========================================================================
// Part 1: Log Parser -- Extract structured data from log entries
// =========================================================================
// Pre-compiled patterns (compiled once, reused across all calls)
private static final Pattern LOG_PATTERN = Pattern.compile(
"(?\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})" + // 2026-02-28 14:30:00
"\\s+\\[(?\\w+)]" + // [ERROR]
"\\s+(?[\\w.]+)" + // com.app.Service
"\\s+-\\s+(?.*)" // - The log message
);
private static final Pattern IP_PATTERN = Pattern.compile(
"\\b(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d{1,2})\\b"
);
private static final Pattern ERROR_CODE_PATTERN = Pattern.compile(
"\\b[A-Z]{2,4}-\\d{3,5}\\b" // e.g., ERR-5001, HTTP-404
);
public static void parseLogEntries(String[] logLines) {
System.out.println("=== LOG PARSER RESULTS ===");
System.out.println();
Map levelCounts = new LinkedHashMap<>();
List errorMessages = new ArrayList<>();
for (String line : logLines) {
Matcher m = LOG_PATTERN.matcher(line);
if (m.matches()) {
String timestamp = m.group("timestamp");
String level = m.group("level");
String className = m.group("class");
String message = m.group("message");
// Count log levels
levelCounts.merge(level, 1, Integer::sum);
// Collect error messages
if ("ERROR".equals(level)) {
errorMessages.add(timestamp + " | " + className + " | " + message);
}
// Extract IP addresses from the message
Matcher ipMatcher = IP_PATTERN.matcher(message);
while (ipMatcher.find()) {
System.out.println(" IP found in log: " + ipMatcher.group()
+ " (from " + className + ")");
}
// Extract error codes from the message
Matcher codeMatcher = ERROR_CODE_PATTERN.matcher(message);
while (codeMatcher.find()) {
System.out.println(" Error code found: " + codeMatcher.group()
+ " (at " + timestamp + ")");
}
}
}
System.out.println();
System.out.println("Log Level Summary:");
levelCounts.forEach((level, count) ->
System.out.println(" " + level + ": " + count));
System.out.println();
System.out.println("Error Messages:");
errorMessages.forEach(msg -> System.out.println(" " + msg));
}
// =========================================================================
// Part 2: Input Validator -- Validate form fields for user registration
// =========================================================================
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
);
private static final Pattern PHONE_PATTERN = Pattern.compile(
"^(\\+1[- ]?)?(\\(?\\d{3}\\)?[- ]?)?\\d{3}[- ]?\\d{4}$"
);
private static final Pattern PASSWORD_PATTERN = Pattern.compile(
"^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@#$%^&+=!]).{8,20}$"
);
private static final Pattern USERNAME_PATTERN = Pattern.compile(
"^[a-zA-Z][a-zA-Z0-9_]{2,19}$" // starts with letter, 3-20 chars, only alphanumeric and _
);
private static final Pattern DATE_PATTERN = Pattern.compile(
"^(?\\d{4})-(?0[1-9]|1[0-2])-(?0[1-9]|[12]\\d|3[01])$"
);
public static Map validateRegistration(
String username, String email, String password, String phone, String birthDate) {
Map errors = new LinkedHashMap<>();
// Username validation
if (username == null || username.isEmpty()) {
errors.put("username", "Username is required");
} else if (!USERNAME_PATTERN.matcher(username).matches()) {
errors.put("username", "Must start with a letter, 3-20 chars, only letters/digits/underscore");
}
// Email validation
if (email == null || email.isEmpty()) {
errors.put("email", "Email is required");
} else if (!EMAIL_PATTERN.matcher(email).matches()) {
errors.put("email", "Invalid email format");
}
// Password validation with specific feedback
if (password == null || password.isEmpty()) {
errors.put("password", "Password is required");
} else {
List passwordIssues = new ArrayList<>();
if (password.length() < 8) passwordIssues.add("at least 8 characters");
if (password.length() > 20) passwordIssues.add("at most 20 characters");
if (!password.matches(".*[A-Z].*")) passwordIssues.add("an uppercase letter");
if (!password.matches(".*[a-z].*")) passwordIssues.add("a lowercase letter");
if (!password.matches(".*\\d.*")) passwordIssues.add("a digit");
if (!password.matches(".*[@#$%^&+=!].*")) passwordIssues.add("a special character (@#$%^&+=!)");
if (!passwordIssues.isEmpty()) {
errors.put("password", "Password needs: " + String.join(", ", passwordIssues));
}
}
// Phone validation
if (phone != null && !phone.isEmpty() && !PHONE_PATTERN.matcher(phone).matches()) {
errors.put("phone", "Invalid US phone format");
}
// Birth date validation
if (birthDate != null && !birthDate.isEmpty()) {
Matcher dm = DATE_PATTERN.matcher(birthDate);
if (!dm.matches()) {
errors.put("birthDate", "Invalid date format (use YYYY-MM-DD)");
} else {
int year = Integer.parseInt(dm.group("year"));
if (year > 2026 || year < 1900) {
errors.put("birthDate", "Year must be between 1900 and 2026");
}
}
}
return errors;
}
// =========================================================================
// Part 3: Data Masking -- Redact sensitive information from text
// =========================================================================
private static final Pattern SSN_IN_TEXT = Pattern.compile(
"\\b\\d{3}-\\d{2}-\\d{4}\\b"
);
private static final Pattern CC_IN_TEXT = Pattern.compile(
"\\b(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})[- ]?(\\d{4})\\b"
);
private static final Pattern EMAIL_IN_TEXT = Pattern.compile(
"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
);
public static String maskSensitiveData(String text) {
// Mask SSNs: 123-45-6789 -> ***-**-6789
String result = SSN_IN_TEXT.matcher(text).replaceAll(mr -> {
String ssn = mr.group();
return "***-**-" + ssn.substring(ssn.length() - 4);
});
// Mask credit cards: show only last 4 digits
Matcher ccMatcher = CC_IN_TEXT.matcher(result);
StringBuilder sb = new StringBuilder();
while (ccMatcher.find()) {
ccMatcher.appendReplacement(sb, "****-****-****-" + ccMatcher.group(4));
}
ccMatcher.appendTail(sb);
result = sb.toString();
// Mask emails: user@domain.com -> u***@domain.com
Matcher emailMatcher = EMAIL_IN_TEXT.matcher(result);
sb = new StringBuilder();
while (emailMatcher.find()) {
String email = emailMatcher.group();
int atIndex = email.indexOf('@');
String masked = email.charAt(0) + "***" + email.substring(atIndex);
emailMatcher.appendReplacement(sb, Matcher.quoteReplacement(masked));
}
emailMatcher.appendTail(sb);
return sb.toString();
}
// =========================================================================
// Main -- Run all demonstrations
// =========================================================================
public static void main(String[] args) {
// --- Part 1: Parse log entries ---
String[] logLines = {
"2026-02-28 14:30:00 [INFO] com.app.UserService - User login from 192.168.1.100",
"2026-02-28 14:30:05 [ERROR] com.app.PaymentService - Payment failed: ERR-5001 for IP 10.0.0.1",
"2026-02-28 14:30:10 [WARN] com.app.AuthService - Failed login attempt from 172.16.0.50",
"2026-02-28 14:30:15 [ERROR] com.app.OrderService - Order processing failed: HTTP-500 timeout",
"2026-02-28 14:30:20 [INFO] com.app.CacheService - Cache refreshed successfully",
"2026-02-28 14:30:25 [ERROR] com.app.DatabaseService - Connection lost: DB-1001 to 192.168.1.200"
};
parseLogEntries(logLines);
System.out.println();
System.out.println("========================================");
System.out.println();
// --- Part 2: Validate registration forms ---
System.out.println("=== REGISTRATION VALIDATION ===");
System.out.println();
// Test case 1: Valid registration
Map errors1 = validateRegistration(
"john_doe", "john@example.com", "MyP@ss123", "(555) 123-4567", "1990-06-15"
);
System.out.println("Test 1 (valid): " + (errors1.isEmpty() ? "PASSED" : "FAILED: " + errors1));
// Test case 2: Multiple validation failures
Map errors2 = validateRegistration(
"2bad", "not-an-email", "weak", "12345", "2026-13-45"
);
System.out.println("Test 2 (invalid):");
errors2.forEach((field, error) -> System.out.println(" " + field + ": " + error));
// Test case 3: Specific password feedback
Map errors3 = validateRegistration(
"alice", "alice@test.com", "onlylowercase", null, null
);
System.out.println("Test 3 (weak password):");
errors3.forEach((field, error) -> System.out.println(" " + field + ": " + error));
System.out.println();
System.out.println("========================================");
System.out.println();
// --- Part 3: Mask sensitive data ---
System.out.println("=== DATA MASKING ===");
System.out.println();
String sensitiveText = "Customer SSN: 123-45-6789, CC: 4111-1111-1111-1111, " +
"Email: john.doe@gmail.com, Alt SSN: 987-65-4321";
System.out.println("Original: " + sensitiveText);
System.out.println("Masked: " + maskSensitiveData(sensitiveText));
}
}
Output
=== LOG PARSER RESULTS ===
IP found in log: 192.168.1.100 (from com.app.UserService)
IP found in log: 10.0.0.1 (from com.app.PaymentService)
Error code found: ERR-5001 (at 2026-02-28 14:30:05)
IP found in log: 172.16.0.50 (from com.app.AuthService)
Error code found: HTTP-500 (at 2026-02-28 14:30:15)
Error code found: DB-1001 (at 2026-02-28 14:30:25)
IP found in log: 192.168.1.200 (from com.app.DatabaseService)
Log Level Summary:
INFO: 2
ERROR: 3
WARN: 1
Error Messages:
2026-02-28 14:30:05 | com.app.PaymentService | Payment failed: ERR-5001 for IP 10.0.0.1
2026-02-28 14:30:15 | com.app.OrderService | Order processing failed: HTTP-500 timeout
2026-02-28 14:30:25 | com.app.DatabaseService | Connection lost: DB-1001 to 192.168.1.200
========================================
=== REGISTRATION VALIDATION ===
Test 1 (valid): PASSED
Test 2 (invalid):
username: Must start with a letter, 3-20 chars, only letters/digits/underscore
email: Invalid email format
password: Password needs: at least 8 characters, an uppercase letter, a digit, a special character (@#$%^&+=!)
phone: Invalid US phone format
birthDate: Invalid date format (use YYYY-MM-DD)
Test 3 (weak password):
password: Password needs: an uppercase letter, a digit, a special character (@#$%^&+=!)
========================================
=== DATA MASKING ===
Original: Customer SSN: 123-45-6789, CC: 4111-1111-1111-1111, Email: john.doe@gmail.com, Alt SSN: 987-65-4321
Masked: Customer SSN: ***-**-6789, CC: ****-****-****-1111, Email: j***@gmail.com, Alt SSN: ***-**-4321
Password validation gives specific feedback per rule instead of one giant regex
14
Null-safe validation
All validators check for null before applying regex
March 11, 2019
Generics
1. What are Generics?
Generics allow you to write classes, interfaces, and methods that operate on types specified by the caller, rather than hardcoded into the implementation. Introduced in Java 5 (JDK 1.5), generics bring type safety at compile time — the compiler catches type mismatches before your code ever runs, eliminating an entire category of runtime errors.
Before generics, collections stored everything as Object. You could put a String into a List, then accidentally retrieve it as an Integer, and the compiler would not complain. The error would only surface at runtime as a ClassCastException — often in production, often at the worst possible time.
The Problem Generics Solve
Consider this pre-generics code (Java 1.4 and earlier):
import java.util.ArrayList;
import java.util.List;
public class WithoutGenerics {
public static void main(String[] args) {
// Raw List -- no type information. Anything goes.
List names = new ArrayList();
names.add("Alice");
names.add("Bob");
names.add(42); // No compiler error! An Integer slipped into a "names" list.
// Later, when we retrieve elements...
for (int i = 0; i < names.size(); i++) {
String name = (String) names.get(i); // Manual cast required every time
System.out.println(name.toUpperCase());
}
// Output:
// ALICE
// BOB
// Exception in thread "main" java.lang.ClassCastException:
// java.lang.Integer cannot be cast to java.lang.String
}
}
There are three problems with the raw type approach:
No compile-time type checking -- the Integer 42 was silently added to a list that should only contain strings
Manual casting -- every retrieval requires (String) cast, which is tedious and error-prone
Runtime failure -- the ClassCastException only appears when the code runs, not when it compiles
Now here is the same code with generics:
import java.util.ArrayList;
import java.util.List;
public class WithGenerics {
public static void main(String[] args) {
// Parameterized List -- only String elements allowed
List names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
// names.add(42); // COMPILE ERROR: incompatible types: int cannot be converted to String
// No casting needed -- the compiler knows every element is a String
for (String name : names) {
System.out.println(name.toUpperCase());
}
// Output:
// ALICE
// BOB
}
}
Before vs After Generics
Aspect
Without Generics (Raw Types)
With Generics
Declaration
List names = new ArrayList();
List<String> names = new ArrayList<>();
Type safety
None -- any Object can be added
Enforced at compile time
Retrieval
Manual cast: (String) list.get(0)
No cast needed: list.get(0)
Error detection
Runtime (ClassCastException)
Compile time (compiler error)
Readability
Type intent is hidden
Type intent is self-documenting
The golden rule: Generics move type errors from runtime to compile time. A bug caught by the compiler costs you 30 seconds. A bug caught in production costs you hours -- or worse.
2. Generic Classes
A generic class is a class that declares one or more type parameters in angle brackets after its name. These type parameters act as placeholders -- they are replaced with actual types when the class is instantiated. This allows you to write a single class that works with any type while maintaining full type safety.
Basic Syntax
The general form of a generic class declaration is:
// T is a type parameter -- a placeholder for a real type
public class Box {
private T content;
public Box(T content) {
this.content = content;
}
public T getContent() {
return content;
}
public void setContent(T content) {
this.content = content;
}
@Override
public String toString() {
return "Box[" + content + "]";
}
}
// Usage -- T is replaced with actual types at instantiation
public class BoxDemo {
public static void main(String[] args) {
// Box of String
Box nameBox = new Box<>("Alice");
String name = nameBox.getContent(); // No cast needed
System.out.println(name); // Output: Alice
// Box of Integer
Box ageBox = new Box<>(30);
int age = ageBox.getContent(); // Autoboxing/unboxing works seamlessly
System.out.println(age); // Output: 30
// Box of a custom type
Box> listBox = new Box<>(List.of("A", "B", "C"));
List items = listBox.getContent();
System.out.println(items); // Output: [A, B, C]
}
}
Type Parameter Naming Conventions
By convention, type parameters use single uppercase letters. This distinguishes them from class names at a glance. Here are the standard conventions used throughout the Java ecosystem:
Letter
Convention
Example Usage
T
Type (general purpose)
Box<T>, Optional<T>
E
Element (used in collections)
List<E>, Set<E>, Queue<E>
K
Key (used in maps)
Map<K, V>
V
Value (used in maps)
Map<K, V>
N
Number
Calculator<N extends Number>
S, U, V
Second, third, fourth types
Function<T, R>, BiFunction<T, U, R>
R
Return type
Function<T, R>, Callable<R>
Multiple Type Parameters
A generic class can declare multiple type parameters, separated by commas. A classic example is a Pair class that holds two values of potentially different types:
public class Pair {
private final K key;
private final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
@Override
public String toString() {
return "(" + key + ", " + value + ")";
}
// Static factory method -- type inference handles the type parameters
public static Pair of(K key, V value) {
return new Pair<>(key, value);
}
}
public class PairDemo {
public static void main(String[] args) {
// Explicit type arguments
Pair entry = new Pair<>("age", 30);
System.out.println(entry.getKey()); // Output: age
System.out.println(entry.getValue()); // Output: 30
// Using the factory method -- types are inferred
Pair> config = Pair.of("roles", List.of("ADMIN", "USER"));
System.out.println(config); // Output: (roles, [ADMIN, USER])
// Pair of Pairs -- generics compose naturally
Pair> location = Pair.of("Office", Pair.of(37.7749, -122.4194));
System.out.println(location); // Output: (Office, (37.7749, -122.4194))
}
}
Generic Class with Bounded Type
You can constrain a type parameter so that it only accepts types that extend a particular class or implement a particular interface. This lets you call methods of the bound type inside your generic class:
// Only accepts types that are Numbers (Integer, Double, Long, etc.)
public class NumberBox {
private T number;
public NumberBox(T number) {
this.number = number;
}
public double doubleValue() {
// Because T extends Number, we can call Number's methods
return number.doubleValue();
}
public boolean isPositive() {
return number.doubleValue() > 0;
}
public T getNumber() {
return number;
}
}
public class NumberBoxDemo {
public static void main(String[] args) {
NumberBox intBox = new NumberBox<>(42);
System.out.println(intBox.doubleValue()); // Output: 42.0
System.out.println(intBox.isPositive()); // Output: true
NumberBox doubleBox = new NumberBox<>(-3.14);
System.out.println(doubleBox.isPositive()); // Output: false
// NumberBox stringBox = new NumberBox<>("hello");
// COMPILE ERROR: String does not extend Number
}
}
3. Generic Methods
A generic method is a method that declares its own type parameters, independent of any type parameters on the enclosing class. The type parameter list appears in angle brackets before the return type. Generic methods can appear in generic classes, non-generic classes, or even as static methods.
The compiler infers the type argument from the arguments you pass, so you rarely need to specify it explicitly.
public class GenericMethodDemo {
// Generic method -- is declared before the return type
public static void printArray(T[] array) {
for (T element : array) {
System.out.print(element + " ");
}
System.out.println();
}
// Generic method that returns a value
public static T getFirst(List list) {
if (list == null || list.isEmpty()) {
return null;
}
return list.get(0);
}
// Generic method with two type parameters
public static Map mapOf(K key, V value) {
Map map = new HashMap<>();
map.put(key, value);
return map;
}
// Generic swap method
public static void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// Generic max method -- requires Comparable bound
public static > T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
public static void main(String[] args) {
// printArray -- type inferred from arguments
Integer[] intArr = {1, 2, 3, 4, 5};
String[] strArr = {"Hello", "World"};
printArray(intArr); // Output: 1 2 3 4 5
printArray(strArr); // Output: Hello World
// getFirst -- type inferred from list
List names = List.of("Alice", "Bob", "Charlie");
String first = getFirst(names); // No cast needed
System.out.println("First: " + first); // Output: First: Alice
// swap
String[] colors = {"Red", "Blue", "Green"};
swap(colors, 0, 2);
printArray(colors); // Output: Green Blue Red
// max -- works with any Comparable type
System.out.println(max(10, 20)); // Output: 20
System.out.println(max("apple", "banana")); // Output: banana
System.out.println(max(3.14, 2.71)); // Output: 3.14
}
}
Generic Methods in Non-Generic Classes
A key insight is that a method can be generic even if its enclosing class is not. This is common for utility classes:
// This is NOT a generic class -- no type parameter on the class itself
public class ArrayUtils {
// But these are generic methods
public static boolean contains(T[] array, T target) {
for (T element : array) {
if (element.equals(target)) {
return true;
}
}
return false;
}
public static int indexOf(T[] array, T target) {
for (int i = 0; i < array.length; i++) {
if (array[i].equals(target)) {
return i;
}
}
return -1;
}
public static T[] reverse(T[] array) {
@SuppressWarnings("unchecked")
T[] reversed = (T[]) java.lang.reflect.Array.newInstance(
array.getClass().getComponentType(), array.length);
for (int i = 0; i < array.length; i++) {
reversed[i] = array[array.length - 1 - i];
}
return reversed;
}
public static void main(String[] args) {
String[] fruits = {"Apple", "Banana", "Cherry"};
System.out.println(contains(fruits, "Banana")); // Output: true
System.out.println(contains(fruits, "Grape")); // Output: false
System.out.println(indexOf(fruits, "Cherry")); // Output: 2
String[] reversed = reverse(fruits);
System.out.println(Arrays.toString(reversed)); // Output: [Cherry, Banana, Apple]
}
}
4. Bounded Type Parameters
Sometimes you need a type parameter that is not completely open-ended. You want to restrict it to a family of types so that you can call specific methods on it. This is where bounded type parameters come in.
Upper Bounds with extends
The extends keyword in a type parameter bound means "is a subtype of" -- this works for both classes and interfaces. When you write <T extends Number>, T can be Integer, Double, Long, or any other subclass of Number.
Why is this useful? Because inside the method or class, you can call any method defined on the bound type. Without the bound, T is effectively Object, and you can only call toString(), equals(), and hashCode().
public class BoundedTypeDemo {
// Without bound: T is Object -- we can't call .doubleValue()
// public static double sum(List numbers) {
// double total = 0;
// for (T n : numbers) {
// total += n.doubleValue(); // COMPILE ERROR: Object has no doubleValue()
// }
// return total;
// }
// With upper bound: T must be a Number -- now we can call doubleValue()
public static double sum(List numbers) {
double total = 0;
for (T n : numbers) {
total += n.doubleValue(); // OK: Number has doubleValue()
}
return total;
}
// Finding the maximum in a list -- T must be Comparable
public static > T findMax(List list) {
if (list == null || list.isEmpty()) {
throw new IllegalArgumentException("List must not be null or empty");
}
T max = list.get(0);
for (int i = 1; i < list.size(); i++) {
if (list.get(i).compareTo(max) > 0) {
max = list.get(i);
}
}
return max;
}
public static void main(String[] args) {
List integers = List.of(10, 20, 30);
List doubles = List.of(1.5, 2.5, 3.5);
System.out.println("Sum of integers: " + sum(integers)); // Output: Sum of integers: 60.0
System.out.println("Sum of doubles: " + sum(doubles)); // Output: Sum of doubles: 7.5
// sum(List.of("a", "b")); // COMPILE ERROR: String does not extend Number
List words = List.of("banana", "apple", "cherry");
System.out.println("Max word: " + findMax(words)); // Output: Max word: cherry
System.out.println("Max integer: " + findMax(integers)); // Output: Max integer: 30
}
}
Multiple Bounds
A type parameter can have multiple bounds, separated by &. The syntax is <T extends ClassA & InterfaceB & InterfaceC>. If one of the bounds is a class (not an interface), it must be listed first.
import java.io.Serializable;
public class MultipleBoundsDemo {
// T must be Comparable AND Serializable
public static & Serializable> T clampedMax(T a, T b, T ceiling) {
T larger = a.compareTo(b) >= 0 ? a : b;
return larger.compareTo(ceiling) > 0 ? ceiling : larger;
}
// A practical example: a method that requires both Number and Comparable
public static > T clamp(T value, T min, T max) {
if (value.compareTo(min) < 0) return min;
if (value.compareTo(max) > 0) return max;
return value;
}
public static void main(String[] args) {
// Integer implements both Comparable and Serializable
System.out.println(clampedMax(10, 20, 15)); // Output: 15
// clamp example
System.out.println(clamp(5, 1, 10)); // Output: 5 (within range)
System.out.println(clamp(-3, 1, 10)); // Output: 1 (below min, clamped up)
System.out.println(clamp(15, 1, 10)); // Output: 10 (above max, clamped down)
System.out.println(clamp(3.7, 1.0, 5.0)); // Output: 3.7
}
}
5. Wildcards
Wildcards (?) represent an unknown type. They are used in type arguments (not type parameters) -- that is, you use them when using a generic type, not when declaring one. Wildcards are essential for writing flexible APIs that can accept a range of parameterized types.
There are three kinds of wildcards:
Wildcard
Syntax
Meaning
Use Case
Upper bounded
? extends Type
Any subtype of Type
Reading from a structure (producer)
Lower bounded
? super Type
Any supertype of Type
Writing to a structure (consumer)
Unbounded
?
Any type at all
When the type does not matter
Upper Bounded Wildcards (? extends Type)
Use upper bounded wildcards when you want to read from a generic structure. You know the elements are at least the bound type, so you can safely read them as that type. However, you cannot write to such a structure (except null), because the compiler does not know the exact type.
public class UpperBoundedWildcardDemo {
// This method accepts List, List, List, etc.
public static double sumOfList(List extends Number> list) {
double total = 0;
for (Number n : list) { // Safe to read as Number
total += n.doubleValue();
}
return total;
}
// Without the wildcard, this would ONLY accept List -- not List!
// Remember: List is NOT a subtype of List, even though Integer IS a subtype of Number.
// This is called "invariance" and it's a critical concept to understand.
public static void main(String[] args) {
List integers = List.of(1, 2, 3);
List doubles = List.of(1.5, 2.5, 3.5);
List numbers = List.of(1, 2.5, 3L);
System.out.println(sumOfList(integers)); // Output: 6.0
System.out.println(sumOfList(doubles)); // Output: 7.5
System.out.println(sumOfList(numbers)); // Output: 6.5
// Why can't we add to a List extends Number>?
List extends Number> unknown = integers;
// unknown.add(42); // COMPILE ERROR!
// unknown.add(3.14); // COMPILE ERROR!
// The compiler doesn't know if the list is List, List, etc.
// Adding a Double to a List would break type safety.
Number first = unknown.get(0); // But READING is fine
System.out.println("First: " + first); // Output: First: 1
}
}
Lower Bounded Wildcards (? super Type)
Use lower bounded wildcards when you want to write to a generic structure. You know the structure accepts at least the bound type and all its subtypes, so you can safely add elements of that type. However, when you read from it, you can only guarantee the element is an Object.
public class LowerBoundedWildcardDemo {
// This method accepts List, List, List
The PECS Principle (Producer Extends, Consumer Super)
Joshua Bloch coined this mnemonic in Effective Java, and it is the single most important rule for using wildcards correctly:
Producer Extends: If a parameterized type produces (provides) elements of type T, use ? extends T. You are reading from it.
Consumer Super: If a parameterized type consumes (accepts) elements of type T, use ? super T. You are writing to it.
If a parameter both produces and consumes, do not use wildcards -- use an exact type.
import java.util.*;
import java.util.function.Predicate;
public class PECSDemo {
// 'src' is a PRODUCER (we read from it) -- use extends
// 'dest' is a CONSUMER (we write to it) -- use super
public static void transferFiltered(
List extends T> src, // Producer: extends
List super T> dest, // Consumer: super
Predicate super T> filter) // Consumer: super (it "consumes" T to test it)
{
for (T item : src) {
if (filter.test(item)) {
dest.add(item);
}
}
}
public static void main(String[] args) {
List source = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List evens = new ArrayList<>();
// Transfer even numbers from List to List
transferFiltered(source, evens, n -> n % 2 == 0);
System.out.println("Evens: " + evens); // Output: Evens: [2, 4, 6, 8, 10]
}
}
Unbounded Wildcards (?)
Use an unbounded wildcard when the actual type does not matter. This is appropriate when you are working with methods from Object (like toString() or size()) or when the method's logic is type-independent.
public class UnboundedWildcardDemo {
// We don't care what type the list contains -- we just want its size
public static int countElements(List> list) {
return list.size();
}
// Print any list -- we only call toString() on elements
public static void printList(List> list) {
for (Object element : list) {
System.out.print(element + " ");
}
System.out.println();
}
// Check if two lists have the same size
public static boolean sameSize(List> a, List> b) {
return a.size() == b.size();
}
public static void main(String[] args) {
List names = List.of("Alice", "Bob");
List ages = List.of(30, 25);
List scores = List.of(95.5, 87.3, 91.0);
printList(names); // Output: Alice Bob
printList(ages); // Output: 30 25
printList(scores); // Output: 95.5 87.3 91.0
System.out.println(countElements(scores)); // Output: 3
System.out.println(sameSize(names, ages)); // Output: true
}
}
Wildcard Summary
Scenario
Wildcard to Use
Can Read As
Can Write
Read elements as T
? extends T
T
Only null
Write elements of type T
? super T
Object
T and subtypes
Read and write
No wildcard (use T)
T
T
Type does not matter
?
Object
Only null
6. Type Erasure
Here is the part that trips up many Java developers: generics exist only at compile time. At runtime, the JVM has no concept of generic types. The compiler removes (erases) all type parameter information and replaces it with their bounds (or Object if unbounded). This process is called type erasure.
Type erasure was a deliberate design choice to maintain backward compatibility with pre-generics Java code. It means that List<String> and List<Integer> are the same class at runtime -- they are both just List.
What Happens During Erasure
Generic Code (Compile Time)
After Erasure (Runtime)
List<String>
List
Box<Integer>
Box
<T> T getFirst(List<T>)
Object getFirst(List)
<T extends Number> T sum(T a, T b)
Number sum(Number a, Number b)
Pair<String, Integer>
Pair (fields become Object)
Consequences of Type Erasure
Type erasure imposes several limitations that every Java developer must understand. These are not bugs -- they are inherent trade-offs of the erasure-based generics design:
public class TypeErasureDemo {
// 1. You CANNOT create instances of type parameters
public static T createInstance() {
// return new T(); // COMPILE ERROR: Type parameter T cannot be instantiated directly
// Workaround: pass a Supplier or Class
return null;
}
// Workaround using Class
public static T createInstance(Class clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
// 2. You CANNOT use instanceof with generic types
public static void checkType(List list) {
// if (list instanceof List) {} // COMPILE ERROR
// At runtime, list is just a List -- the is gone
if (list instanceof List>) { } // This is OK -- unbounded wildcard is fine
}
// 3. You CANNOT create generic arrays
public static void cannotCreateGenericArray() {
// T[] array = new T[10]; // COMPILE ERROR
// List[] arrayOfLists = new List[10]; // COMPILE ERROR
// Workaround: use Array.newInstance or collections
@SuppressWarnings("unchecked")
T[] workaround = (T[]) new Object[10]; // Works but produces unchecked warning
}
// 4. You CANNOT overload methods that differ only by type parameter
// public static void process(List list) { }
// public static void process(List list) { } // COMPILE ERROR
// After erasure, both signatures become process(List list) -- they clash
// 5. You CANNOT catch or throw generic types
// class MyException extends Exception { } // COMPILE ERROR
// The JVM needs to know the exact exception type at runtime
public static void main(String[] args) throws Exception {
// Demonstrating that generic types are the same at runtime
List strings = new ArrayList<>();
List integers = new ArrayList<>();
System.out.println(strings.getClass() == integers.getClass()); // Output: true
System.out.println(strings.getClass().getName()); // Output: java.util.ArrayList
// Both are just ArrayList at runtime -- the and are gone
// Creating instances via Class
String s = createInstance(String.class);
System.out.println("Created: " + s); // Output: Created:
}
}
Bridge Methods
When a generic class is extended with a concrete type, the compiler sometimes generates bridge methods to maintain polymorphism after type erasure. You will rarely interact with bridge methods directly, but understanding them explains some unexpected behaviors:
// Generic interface
public interface Transformer {
T transform(T input);
}
// Concrete implementation
public class UpperCaseTransformer implements Transformer {
@Override
public String transform(String input) {
return input.toUpperCase();
}
}
// After type erasure, the interface becomes:
// Object transform(Object input)
//
// But UpperCaseTransformer has:
// String transform(String input)
//
// These are different signatures! The compiler generates a "bridge method":
// public Object transform(Object input) {
// return transform((String) input); // delegates to the real method
// }
//
// This bridge method ensures that calling transformer.transform(obj)
// via the interface still dispatches to the correct implementation.
public class BridgeMethodDemo {
public static void main(String[] args) {
Transformer transformer = new UpperCaseTransformer();
System.out.println(transformer.transform("hello")); // Output: HELLO
// The bridge method is visible via reflection
for (java.lang.reflect.Method m : UpperCaseTransformer.class.getDeclaredMethods()) {
System.out.println(m.getName() + " - bridge: " + m.isBridge()
+ " - params: " + java.util.Arrays.toString(m.getParameterTypes()));
}
// Output:
// transform - bridge: false - params: [class java.lang.String]
// transform - bridge: true - params: [class java.lang.Object]
}
}
7. Generic Interfaces
Just as classes can be generic, interfaces can declare type parameters. In fact, some of the most important interfaces in the Java standard library are generic: Comparable<T>, Iterable<T>, Comparator<T>, Function<T, R>, and more.
When a class implements a generic interface, it has three choices:
Approach
Example
When to Use
Concrete type
class Name implements Comparable<Name>
The type is fixed and known
Keep generic
class Box<T> implements Container<T>
The implementing class is also generic
Raw type
class Legacy implements Comparable
Never do this -- legacy only
// Defining a generic interface
public interface Repository {
T findById(ID id);
List findAll();
T save(T entity);
void deleteById(ID id);
boolean existsById(ID id);
}
// Approach 1: Implement with concrete types
public class UserRepository implements Repository {
private final Map store = new HashMap<>();
@Override
public User findById(Long id) {
return store.get(id);
}
@Override
public List findAll() {
return new ArrayList<>(store.values());
}
@Override
public User save(User user) {
store.put(user.getId(), user);
return user;
}
@Override
public void deleteById(Long id) {
store.remove(id);
}
@Override
public boolean existsById(Long id) {
return store.containsKey(id);
}
}
// Approach 2: Keep the type parameters -- create a reusable base class
public class InMemoryRepository implements Repository {
private final Map store = new HashMap<>();
private final Function idExtractor;
public InMemoryRepository(Function idExtractor) {
this.idExtractor = idExtractor;
}
@Override
public T findById(ID id) {
return store.get(id);
}
@Override
public List findAll() {
return new ArrayList<>(store.values());
}
@Override
public T save(T entity) {
store.put(idExtractor.apply(entity), entity);
return entity;
}
@Override
public void deleteById(ID id) {
store.remove(id);
}
@Override
public boolean existsById(ID id) {
return store.containsKey(id);
}
}
Implementing Comparable<T>
Comparable<T> is arguably the most frequently implemented generic interface. It defines a natural ordering for a class. Here is a proper implementation:
public class Employee implements Comparable {
private final String name;
private final double salary;
public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public double getSalary() { return salary; }
// Natural ordering: by salary (ascending)
@Override
public int compareTo(Employee other) {
return Double.compare(this.salary, other.salary);
}
@Override
public String toString() {
return name + "($" + salary + ")";
}
}
public class ComparableDemo {
public static void main(String[] args) {
List team = new ArrayList<>(List.of(
new Employee("Alice", 95000),
new Employee("Bob", 72000),
new Employee("Charlie", 110000),
new Employee("Diana", 88000)
));
// Sort uses Comparable.compareTo()
Collections.sort(team);
System.out.println("By salary: " + team);
// Output: By salary: [Bob($72000.0), Diana($88000.0), Alice($95000.0), Charlie($110000.0)]
// Collections.min() and max() also use Comparable
System.out.println("Lowest paid: " + Collections.min(team)); // Output: Lowest paid: Bob($72000.0)
System.out.println("Highest paid: " + Collections.max(team)); // Output: Highest paid: Charlie($110000.0)
}
}
8. Generic Collections
Generics and the Collections Framework are inseparable. When generics were introduced in Java 5, the entire Collections API was retrofitted to use them. This is why you write List<String> instead of the raw List. Understanding how generics power collections is essential for everyday Java development.
The Diamond Operator (Java 7+)
Before Java 7, you had to repeat the type arguments on both sides of the assignment:
import java.util.*;
public class GenericCollectionsDemo {
public static void main(String[] args) {
// Java 5/6 -- type arguments repeated on both sides (verbose)
Map> scores1 = new HashMap>();
// Java 7+ -- diamond operator infers the type arguments
Map> scores2 = new HashMap<>(); // Much cleaner
// Java 9+ -- factory methods with implicit typing
List names = List.of("Alice", "Bob", "Charlie");
Set primes = Set.of(2, 3, 5, 7, 11);
Map ages = Map.of("Alice", 30, "Bob", 25);
// -------------------------------------------------------
// List -- ordered, allows duplicates
// -------------------------------------------------------
List languages = new ArrayList<>();
languages.add("Java");
languages.add("Python");
languages.add("Java"); // Duplicates OK
String first = languages.get(0); // No cast needed -- type safe
System.out.println("Languages: " + languages); // Output: Languages: [Java, Python, Java]
// -------------------------------------------------------
// Set -- no duplicates
// -------------------------------------------------------
Set uniqueLanguages = new LinkedHashSet<>(languages);
System.out.println("Unique: " + uniqueLanguages); // Output: Unique: [Java, Python]
// -------------------------------------------------------
// Map -- key-value pairs
// -------------------------------------------------------
Map> courseStudents = new HashMap<>();
courseStudents.put("CS101", List.of("Alice", "Bob"));
courseStudents.put("CS201", List.of("Charlie", "Diana"));
// Type safety carries through nested generics
List cs101Students = courseStudents.get("CS101"); // returns List, not Object
System.out.println("CS101: " + cs101Students); // Output: CS101: [Alice, Bob]
// -------------------------------------------------------
// Queue -- FIFO ordering
// -------------------------------------------------------
Queue taskQueue = new LinkedList<>();
taskQueue.offer("Build feature");
taskQueue.offer("Write tests");
taskQueue.offer("Deploy");
System.out.println("Next task: " + taskQueue.poll()); // Output: Next task: Build feature
// -------------------------------------------------------
// Deque -- double-ended queue (also used as a stack)
// -------------------------------------------------------
Deque stack = new ArrayDeque<>();
stack.push("First");
stack.push("Second");
stack.push("Third");
System.out.println("Stack pop: " + stack.pop()); // Output: Stack pop: Third
// -------------------------------------------------------
// PriorityQueue -- elements ordered by natural ordering or Comparator
// -------------------------------------------------------
PriorityQueue minHeap = new PriorityQueue<>();
minHeap.addAll(List.of(30, 10, 50, 20, 40));
System.out.print("Priority order: ");
while (!minHeap.isEmpty()) {
System.out.print(minHeap.poll() + " ");
}
// Output: Priority order: 10 20 30 40 50
System.out.println();
}
}
Type Safety Benefits in Practice
The real power of generic collections becomes apparent when you chain operations. Every step preserves type information, and the compiler verifies correctness throughout:
import java.util.*;
import java.util.stream.*;
public class TypeSafetyBenefitDemo {
record Student(String name, int grade, String major) {}
public static void main(String[] args) {
List students = List.of(
new Student("Alice", 92, "CS"),
new Student("Bob", 85, "Math"),
new Student("Charlie", 97, "CS"),
new Student("Diana", 88, "CS"),
new Student("Eve", 91, "Math")
);
// Every step is type-safe -- the compiler tracks types through the entire pipeline
Map> byMajor = students.stream()
.collect(Collectors.groupingBy(Student::major)); // Map>
Map avgGradeByMajor = students.stream()
.collect(Collectors.groupingBy(
Student::major, // K = String
Collectors.averagingInt(Student::grade) // V = Double
));
System.out.println("CS students: " + byMajor.get("CS"));
// Output: CS students: [Student[name=Alice, grade=92, major=CS], ...]
System.out.println("Average grades: " + avgGradeByMajor);
// Output: Average grades: {CS=92.33333333333333, Math=88.0}
// Optional -- not Optional
Optional topStudent = students.stream()
.max(Comparator.comparingInt(Student::grade));
topStudent.ifPresent(s ->
System.out.println("Top student: " + s.name() + " (" + s.grade() + ")")
);
// Output: Top student: Charlie (97)
}
}
9. Recursive Type Bounds
A recursive type bound is a type parameter that is bounded by an expression involving the type parameter itself. The most common form is <T extends Comparable<T>>, which reads: "T is a type that can compare itself to other instances of T."
This pattern appears throughout the Java standard library and is essential for writing type-safe APIs that deal with ordering, self-referential structures, or fluent builders.
The Classic: <T extends Comparable<T>>
public class RecursiveTypeBoundDemo {
// Without recursive bound -- less type safe
// public static T findMax(List list) {
// // Can't call compareTo() because T is just Object
// }
// With recursive bound -- T must be comparable to itself
public static > T findMax(List list) {
if (list.isEmpty()) throw new IllegalArgumentException("Empty list");
T max = list.get(0);
for (T item : list) {
if (item.compareTo(max) > 0) {
max = item;
}
}
return max;
}
// Sorting with recursive bounds
public static > List sorted(List list) {
List copy = new ArrayList<>(list);
Collections.sort(copy); // Works because T is Comparable
return copy;
}
// Range check with recursive bounds
public static > boolean isBetween(T value, T low, T high) {
return value.compareTo(low) >= 0 && value.compareTo(high) <= 0;
}
public static void main(String[] args) {
System.out.println(findMax(List.of(3, 1, 4, 1, 5, 9))); // Output: 9
System.out.println(findMax(List.of("banana", "apple", "cherry"))); // Output: cherry
System.out.println(sorted(List.of(5, 2, 8, 1, 9))); // Output: [1, 2, 5, 8, 9]
System.out.println(isBetween(5, 1, 10)); // Output: true
System.out.println(isBetween(15, 1, 10)); // Output: false
System.out.println(isBetween("dog", "cat", "fox")); // Output: true
}
}
Fluent Builder with Recursive Generics
Recursive type bounds enable the curiously recurring template pattern (CRTP), which is particularly useful for builder hierarchies. Without it, a subclass builder's methods would return the parent builder type, breaking method chaining.
// Base class with a self-referential builder
public abstract class Pizza {
private final String size;
private final boolean cheese;
private final boolean pepperoni;
private final boolean mushrooms;
// T extends Builder -- the recursive bound
protected abstract static class Builder> {
private String size;
private boolean cheese;
private boolean pepperoni;
private boolean mushrooms;
// Each method returns T (the concrete builder type), not Builder
@SuppressWarnings("unchecked")
protected T self() {
return (T) this;
}
public T size(String size) {
this.size = size;
return self();
}
public T cheese(boolean cheese) {
this.cheese = cheese;
return self();
}
public T pepperoni(boolean pepperoni) {
this.pepperoni = pepperoni;
return self();
}
public T mushrooms(boolean mushrooms) {
this.mushrooms = mushrooms;
return self();
}
public abstract Pizza build();
}
protected Pizza(Builder> builder) {
this.size = builder.size;
this.cheese = builder.cheese;
this.pepperoni = builder.pepperoni;
this.mushrooms = builder.mushrooms;
}
@Override
public String toString() {
return size + " pizza [cheese=" + cheese + ", pepperoni=" + pepperoni
+ ", mushrooms=" + mushrooms + "]";
}
}
// Subclass with its own builder that adds extra options
public class CalzonePizza extends Pizza {
private final boolean sauceInside;
public static class Builder extends Pizza.Builder {
private boolean sauceInside;
public Builder sauceInside(boolean sauceInside) {
this.sauceInside = sauceInside;
return self();
}
@Override
public CalzonePizza build() {
return new CalzonePizza(this);
}
@Override
protected Builder self() {
return this;
}
}
private CalzonePizza(Builder builder) {
super(builder);
this.sauceInside = builder.sauceInside;
}
}
// Usage -- method chaining works correctly across the class hierarchy
public class BuilderDemo {
public static void main(String[] args) {
CalzonePizza pizza = new CalzonePizza.Builder()
.size("Large") // returns CalzonePizza.Builder, not Pizza.Builder
.cheese(true) // still CalzonePizza.Builder
.pepperoni(true) // still CalzonePizza.Builder
.sauceInside(true) // CalzonePizza.Builder-specific method
.build();
System.out.println(pizza);
// Output: Large pizza [cheese=true, pepperoni=true, mushrooms=false]
}
}
Understanding Enum<E extends Enum<E>>
The Java Enum class uses the most famous recursive type bound in the language: Enum<E extends Enum<E>>. This ensures that enum methods like compareTo() and valueOf() work with the correct enum type, not just Enum in general.
When you write enum Color { RED, GREEN, BLUE }, the compiler generates class Color extends Enum<Color>. This means:
Color.RED.compareTo(Color.BLUE) works because Comparable<Color> is inherited
You cannot accidentally compare Color.RED to Size.LARGE -- the type system prevents it
Enum.valueOf(Color.class, "RED") returns Color, not Enum
10. Generic Utility Methods
One of the greatest strengths of generics is the ability to write reusable utility methods that work across all types while maintaining compile-time type safety. The Java standard library is full of these (see Collections, Arrays, Objects), and writing your own is a hallmark of senior-level Java development.
The java.util.Collections class is a masterclass in generic utility methods. Here are some of the most useful ones and how they leverage generics:
import java.util.*;
public class CollectionsUtilityDemo {
public static void main(String[] args) {
// -------------------------------------------------------
// Collections.sort() -- >
// -------------------------------------------------------
List names = new ArrayList<>(List.of("Charlie", "Alice", "Bob"));
Collections.sort(names);
System.out.println("Sorted: " + names); // Output: Sorted: [Alice, Bob, Charlie]
// -------------------------------------------------------
// Collections.unmodifiableList() -- List unmodifiableList(List extends T>)
// -------------------------------------------------------
List readOnly = Collections.unmodifiableList(names);
// readOnly.add("Diana"); // Throws UnsupportedOperationException
System.out.println("Unmodifiable: " + readOnly); // Output: Unmodifiable: [Alice, Bob, Charlie]
// -------------------------------------------------------
// Collections.singletonList() -- List singletonList(T o)
// -------------------------------------------------------
List single = Collections.singletonList("OnlyOne");
System.out.println("Singleton: " + single); // Output: Singleton: [OnlyOne]
// -------------------------------------------------------
// Collections.emptyList/Map/Set -- List emptyList()
// -------------------------------------------------------
List empty = Collections.emptyList();
System.out.println("Empty: " + empty); // Output: Empty: []
// -------------------------------------------------------
// Collections.synchronizedList() -- List synchronizedList(List)
// -------------------------------------------------------
List threadSafe = Collections.synchronizedList(new ArrayList<>(names));
System.out.println("Thread-safe: " + threadSafe);
// -------------------------------------------------------
// Collections.frequency() -- int frequency(Collection>, Object)
// -------------------------------------------------------
List items = List.of("a", "b", "a", "c", "a");
System.out.println("Frequency of 'a': " + Collections.frequency(items, "a"));
// Output: Frequency of 'a': 3
// -------------------------------------------------------
// Collections.min/max with Comparator
// -------------------------------------------------------
String longest = Collections.max(names, Comparator.comparingInt(String::length));
System.out.println("Longest name: " + longest); // Output: Longest name: Charlie
}
}
11. Common Mistakes
Even experienced developers fall into these traps. Understanding these mistakes will save you hours of debugging and help you write cleaner generic code.
Mistake 1: Using Raw Types
A raw type is a generic type used without any type arguments. Raw types exist only for backward compatibility with pre-Java 5 code. There is never a valid reason to use them in new code.
public class RawTypeMistake {
public static void main(String[] args) {
// BAD -- raw type. The compiler cannot help you.
List names = new ArrayList();
names.add("Alice");
names.add(42); // No error -- but this is a bug
String first = (String) names.get(0); // Manual cast -- tedious
// String second = (String) names.get(1); // ClassCastException at runtime!
// GOOD -- parameterized type. The compiler has your back.
List safeNames = new ArrayList<>();
safeNames.add("Alice");
// safeNames.add(42); // COMPILE ERROR -- bug caught immediately
String safeFirst = safeNames.get(0); // No cast needed
}
}
Mistake 2: Trying to Instantiate Type Parameters
public class InstantiationMistake {
// BAD -- cannot instantiate T directly
// public static T createBad() {
// return new T(); // COMPILE ERROR
// }
// GOOD -- use a Supplier
public static T create(java.util.function.Supplier supplier) {
return supplier.get();
}
// GOOD -- use Class with reflection
public static T create(Class clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
public static void main(String[] args) throws Exception {
// Using Supplier (preferred -- no reflection, no exceptions)
String s = create(String::new);
ArrayList list = create(ArrayList::new);
// Using Class
String s2 = create(String.class);
System.out.println("Created: '" + s2 + "'"); // Output: Created: ''
}
}
Mistake 3: Generic Array Creation
public class GenericArrayMistake {
public static void main(String[] args) {
// BAD -- cannot create generic arrays directly
// List[] arrayOfLists = new List[10]; // COMPILE ERROR
// Why? Because arrays are "reified" -- they know their component type at runtime.
// But generics are erased -- List becomes just List at runtime.
// If Java allowed this, you could break type safety:
// Hypothetically, if it were allowed:
// Object[] objArray = arrayOfLists; // Arrays are covariant, so this would compile
// objArray[0] = List.of(42); // This is a List, stored in a List[] slot
// String s = arrayOfLists[0].get(0); // ClassCastException! Integer is not String
// GOOD -- use a List of Lists instead
List> listOfLists = new ArrayList<>();
listOfLists.add(List.of("Alice", "Bob"));
listOfLists.add(List.of("Charlie"));
// GOOD -- if you truly need an array, use a workaround with unchecked cast
@SuppressWarnings("unchecked")
List[] array = (List[]) new List>[10];
array[0] = List.of("Hello");
System.out.println(array[0]); // Output: [Hello]
}
}
Mistake 4: Confusing extends in Generics vs Class Hierarchy
public class ExtendsConfusion {
public static void main(String[] args) {
// In class hierarchy: Integer IS-A Number (subtype relationship)
Number n = Integer.valueOf(42); // This works -- Integer extends Number
// But in generics: List IS NOT a List
// List numbers = new ArrayList(); // COMPILE ERROR!
// Why? Because if this were allowed:
// numbers.add(3.14); // This would be legal (Double is a Number)
// But the actual list is ArrayList -- a Double would corrupt it!
// This is called INVARIANCE: Generic types are invariant.
// List and List are NOT related, even though Integer extends Number.
// Solution 1: Use wildcards
List extends Number> readOnly = new ArrayList(); // OK for reading
// Solution 2: Use the exact type
List numbers = new ArrayList<>(); // Both Integer and Double can go in
numbers.add(42);
numbers.add(3.14);
}
}
Mistake 5: Overloading with Type Erasure Conflicts
public class ErasureConflict {
// These two methods look different at the source level...
// public void process(List strings) { }
// public void process(List integers) { }
// ...but after erasure, both become:
// public void process(List strings) { }
// public void process(List integers) { }
// COMPILE ERROR: both methods have same erasure
// Solution: use different method names
public void processStrings(List strings) {
strings.forEach(s -> System.out.println("String: " + s));
}
public void processIntegers(List integers) {
integers.forEach(i -> System.out.println("Integer: " + i));
}
// Or use a single generic method
public void process(List items, String label) {
items.forEach(item -> System.out.println(label + ": " + item));
}
}
Mistake 6: Using Generics with Primitives
public class PrimitiveMistake {
public static void main(String[] args) {
// BAD -- generics do not support primitive types
// List numbers = new ArrayList<>(); // COMPILE ERROR
// GOOD -- use wrapper classes (autoboxing handles conversion)
List numbers = new ArrayList<>();
numbers.add(42); // autoboxing: int -> Integer
int value = numbers.get(0); // auto-unboxing: Integer -> int
// Be aware of boxing overhead in performance-critical code
// For large datasets, consider specialized collections:
// - IntStream, LongStream, DoubleStream (java.util.stream)
// - int[], double[] (plain arrays)
// - Third-party: Eclipse Collections IntList, Trove TIntArrayList
// Null danger with auto-unboxing
List data = new ArrayList<>();
data.add(null); // Allowed for Integer (it's an object)
// int bad = data.get(0); // NullPointerException! null cannot unbox to int
}
}
12. Best Practices
These best practices come from years of experience in the Java ecosystem and are distilled from Effective Java by Joshua Bloch, Java language specifications, and real-world codebase reviews. Following them will make your generic code safer, cleaner, and more maintainable.
Practice 1: Always Use Parameterized Types
Never use raw types. If you do not know the type, use <?> (unbounded wildcard) rather than leaving the type argument off entirely.
// BAD
List names = new ArrayList();
Map config = new HashMap();
// GOOD
List names = new ArrayList<>();
Map config = new HashMap<>();
// If you truly don't know the type, use unbounded wildcard
List> unknownList = getListFromSomewhere();
// You can read from it (as Object), check size, iterate, etc.
Practice 2: Use Bounded Wildcards for API Flexibility
Public API methods should use wildcards in their parameters to be as flexible as possible for callers. This follows the PECS principle.
public class WildcardAPIBestPractice {
// LESS FLEXIBLE -- only accepts List, not List or List
public static double sumRigid(List numbers) {
return numbers.stream().mapToDouble(Number::doubleValue).sum();
}
// MORE FLEXIBLE -- accepts List, List, List, etc.
public static double sumFlexible(List extends Number> numbers) {
return numbers.stream().mapToDouble(Number::doubleValue).sum();
}
// LESS FLEXIBLE -- only accepts Comparator
public static String maxRigid(List list, Comparator comparator) {
return list.stream().max(comparator).orElseThrow();
}
// MORE FLEXIBLE -- accepts Comparator, Comparator, etc.
public static T maxFlexible(List extends T> list, Comparator super T> comparator) {
return list.stream().max(comparator).orElseThrow();
}
public static void main(String[] args) {
List integers = List.of(1, 2, 3);
// sumRigid(integers); // COMPILE ERROR -- List is not List
System.out.println(sumFlexible(integers)); // Output: 6.0 -- works!
}
}
Practice 3: Prefer Generic Methods Over Wildcard Types in Return Values
Use wildcards in parameters (inputs) but avoid them in return types. A method that returns List<?> forces the caller to deal with an unknown type. Instead, use a type parameter so the caller gets a concrete type.
public class ReturnTypeBestPractice {
// BAD -- caller gets List>, which is almost useless
public static List> filterBad(List> list) {
return list; // Caller can't do much with List>
}
// GOOD -- caller gets List, preserving the type information
public static List filterGood(List list, Predicate super T> predicate) {
return list.stream().filter(predicate).collect(Collectors.toList());
}
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Anna");
// filterGood returns List -- full type safety preserved
List aNames = filterGood(names, n -> n.startsWith("A"));
String first = aNames.get(0); // No cast needed
System.out.println(first); // Output: Alice
}
}
Practice 4: Use @SuppressWarnings("unchecked") Sparingly
Unchecked warnings exist for a reason -- they indicate a potential type safety hole. Only suppress them when you have carefully verified that the cast is safe, and always document why it is safe.
public class SuppressWarningsBestPractice {
// BAD -- suppressing at class or method level hides all warnings
// @SuppressWarnings("unchecked")
// public T[] toArray(List list) { ... }
// GOOD -- suppress at the narrowest possible scope with a comment
public static T[] toArray(List list, Class componentType) {
// Safe because we create the array with the correct component type
@SuppressWarnings("unchecked")
T[] array = (T[]) java.lang.reflect.Array.newInstance(componentType, list.size());
return list.toArray(array);
}
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie");
String[] nameArray = toArray(names, String.class);
System.out.println(java.util.Arrays.toString(nameArray));
// Output: [Alice, Bob, Charlie]
}
}
Practice 5: Favor Generic Methods Over Raw Types for Interoperability
When you need to work with legacy code that uses raw types, wrap the interaction in a generic method that performs a checked cast, rather than spreading raw type usage throughout your code.
public class LegacyInteropBestPractice {
// Legacy code returns raw List
@SuppressWarnings("rawtypes")
public static List getLegacyData() {
List data = new java.util.ArrayList();
data.add("Alice");
data.add("Bob");
return data;
}
// Wrapper method: isolates the raw type and unchecked cast
@SuppressWarnings("unchecked") // Safe: we know getLegacyData() returns strings
public static List getLegacyNames() {
return (List) getLegacyData();
}
// Safer alternative: validate each element
public static List checkedCast(List> raw, Class type) {
List result = new ArrayList<>();
for (Object item : raw) {
result.add(type.cast(item)); // Throws ClassCastException if wrong type
}
return result;
}
public static void main(String[] args) {
// All downstream code uses proper generics
List names = getLegacyNames();
names.forEach(name -> System.out.println(name.toUpperCase()));
// Output: ALICE
// BOB
// Extra-safe approach
List verified = checkedCast(getLegacyData(), String.class);
System.out.println("Verified: " + verified); // Output: Verified: [Alice, Bob]
}
}
Best Practices Summary
#
Practice
Rationale
1
Always use parameterized types -- never raw types
Raw types bypass compile-time type checking entirely
2
Use bounded wildcards in method parameters (PECS)
Makes APIs more flexible for callers without sacrificing type safety
3
Avoid wildcards in return types
Callers should receive concrete types, not unknowns
Let us bring everything together with a real-world example that demonstrates generic classes, generic methods, bounded type parameters, wildcards, and generic interfaces working in concert. We will build a type-safe in-memory repository -- a pattern you will encounter in Spring Boot, JPA, and virtually every data-driven Java application.
This example is designed to show how generics enable you to write a single piece of infrastructure code that works with any entity type, while the compiler ensures that you never accidentally mix up your User operations with your Product operations.
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
// ============================================================
// Step 1: Define a generic Identifiable interface
// ============================================================
// Any entity stored in our repository must have an ID
public interface Identifiable {
ID getId();
void setId(ID id);
}
// ============================================================
// Step 2: Define a generic Repository interface
// ============================================================
// T is the entity type, ID is the primary key type
// T must be Identifiable -- bounded type parameter
public interface CrudRepository, ID> {
T save(T entity);
Optional findById(ID id);
List findAll();
List findAll(Predicate super T> filter); // Wildcard: consumer of T
List findAllMapped(Function super T, ? extends R> mapper); // Generic method with wildcards
boolean existsById(ID id);
long count();
void deleteById(ID id);
void deleteAll();
}
// ============================================================
// Step 3: Implement a generic in-memory repository
// ============================================================
public class InMemoryCrudRepository>
implements CrudRepository {
private final Map store = new LinkedHashMap<>();
private final AtomicLong sequence = new AtomicLong(1);
@Override
public T save(T entity) {
if (entity.getId() == null) {
entity.setId(sequence.getAndIncrement());
}
store.put(entity.getId(), entity);
return entity;
}
@Override
public Optional findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List findAll() {
return new ArrayList<>(store.values());
}
// Wildcard in Predicate: accepts Predicate, Predicate, etc.
@Override
public List findAll(Predicate super T> filter) {
return store.values().stream()
.filter(filter)
.collect(Collectors.toList());
}
// Generic method: caller decides the return type R
@Override
public List findAllMapped(Function super T, ? extends R> mapper) {
return store.values().stream()
.map(mapper)
.collect(Collectors.toList());
}
@Override
public boolean existsById(Long id) {
return store.containsKey(id);
}
@Override
public long count() {
return store.size();
}
@Override
public void deleteById(Long id) {
store.remove(id);
}
@Override
public void deleteAll() {
store.clear();
}
}
// ============================================================
// Step 4: Define concrete entity classes
// ============================================================
public class User implements Identifiable {
private Long id;
private String name;
private String email;
private int age;
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
@Override
public Long getId() { return id; }
@Override
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public String getEmail() { return email; }
public int getAge() { return age; }
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "', email='" + email + "', age=" + age + "}";
}
}
public class Product implements Identifiable {
private Long id;
private String name;
private double price;
private String category;
public Product(String name, double price, String category) {
this.name = name;
this.price = price;
this.category = category;
}
@Override
public Long getId() { return id; }
@Override
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public double getPrice() { return price; }
public String getCategory() { return category; }
@Override
public String toString() {
return "Product{id=" + id + ", name='" + name + "', price=$" + price
+ ", category='" + category + "'}";
}
}
// ============================================================
// Step 5: Build a generic service layer
// ============================================================
public class EntityService> {
private final CrudRepository repository;
public EntityService(CrudRepository repository) {
this.repository = repository;
}
public T create(T entity) {
return repository.save(entity);
}
public Optional getById(Long id) {
return repository.findById(id);
}
public List getAll() {
return repository.findAll();
}
// Uses bounded wildcard for maximum flexibility
public List search(Predicate super T> criteria) {
return repository.findAll(criteria);
}
// Generic method with wildcard
public List extractField(Function super T, ? extends R> extractor) {
return repository.findAllMapped(extractor);
}
public boolean exists(Long id) {
return repository.existsById(id);
}
public void delete(Long id) {
repository.deleteById(id);
}
}
// ============================================================
// Step 6: Putting it all together
// ============================================================
public class RepositoryDemo {
public static void main(String[] args) {
// Create typed repositories -- the compiler ensures type safety
InMemoryCrudRepository userRepo = new InMemoryCrudRepository<>();
InMemoryCrudRepository productRepo = new InMemoryCrudRepository<>();
// Create typed services
EntityService userService = new EntityService<>(userRepo);
EntityService productService = new EntityService<>(productRepo);
// --- User operations (type safe: only User methods available) ---
userService.create(new User("Alice", "alice@example.com", 30));
userService.create(new User("Bob", "bob@example.com", 25));
userService.create(new User("Charlie", "charlie@example.com", 35));
userService.create(new User("Diana", "diana@example.com", 28));
System.out.println("=== All Users ===");
userService.getAll().forEach(System.out::println);
// Output:
// User{id=1, name='Alice', email='alice@example.com', age=30}
// User{id=2, name='Bob', email='bob@example.com', age=25}
// User{id=3, name='Charlie', email='charlie@example.com', age=35}
// User{id=4, name='Diana', email='diana@example.com', age=28}
// Search with type-safe predicate
System.out.println("\n=== Users over 28 ===");
List seniorUsers = userService.search(u -> u.getAge() > 28);
seniorUsers.forEach(System.out::println);
// Output:
// User{id=1, name='Alice', email='alice@example.com', age=30}
// User{id=3, name='Charlie', email='charlie@example.com', age=35}
// Extract specific fields -- generic method in action
List emails = userService.extractField(User::getEmail);
System.out.println("\n=== Email addresses ===");
System.out.println(emails);
// Output: [alice@example.com, bob@example.com, charlie@example.com, diana@example.com]
// --- Product operations (completely independent, type safe) ---
productService.create(new Product("Laptop", 999.99, "Electronics"));
productService.create(new Product("Coffee Mug", 12.99, "Kitchen"));
productService.create(new Product("Headphones", 149.99, "Electronics"));
System.out.println("\n=== Electronics ===");
List electronics = productService.search(p -> "Electronics".equals(p.getCategory()));
electronics.forEach(System.out::println);
// Output:
// Product{id=1, name='Laptop', price=$999.99, category='Electronics'}
// Product{id=3, name='Headphones', price=$149.99, category='Electronics'}
// Extract product names
List productNames = productService.extractField(Product::getName);
System.out.println("\n=== Product names ===");
System.out.println(productNames);
// Output: [Laptop, Coffee Mug, Headphones]
// The compiler prevents mixing types:
// userRepo.save(new Product(...)); // COMPILE ERROR: Product is not User
// productRepo.save(new User(...)); // COMPILE ERROR: User is not Product
// Find by ID -- returns Optional, not Optional
Optional found = userService.getById(1L);
found.ifPresent(u -> System.out.println("\nFound: " + u.getName()));
// Output: Found: Alice
// Delete
userService.delete(2L);
System.out.println("\n=== After deleting Bob ===");
System.out.println("User count: " + userRepo.count()); // Output: User count: 3
System.out.println("Bob exists: " + userService.exists(2L)); // Output: Bob exists: false
}
}
What This Example Demonstrates
Generic Feature
Where It Appears
Generic interface
Identifiable<ID>, CrudRepository<T, ID>
Bounded type parameter
T extends Identifiable<ID> in CrudRepository
Generic class
InMemoryCrudRepository<T>, EntityService<T>
Generic method
<R> List<R> findAllMapped(...)
Upper bounded wildcard
Function<? super T, ? extends R>
Lower bounded wildcard
Predicate<? super T>
Diamond operator
new InMemoryCrudRepository<>()
PECS principle
Predicate consumes T (super), Function produces R (extends)
Type safety
Cannot mix User and Product repositories
This is the power of generics: you write the repository logic once, and it works correctly and type-safely with User, Product, Order, or any entity you create in the future. The compiler guarantees that you cannot store a Product in a User repository, retrieve a User when you expected a Product, or pass the wrong type to a service method. All of these checks happen at compile time, with zero runtime overhead.