Lock coarsening and elimination are sophisticated JVM optimizations that reduce the overhead of synchronization in Java applications. These techniques help improve performance by minimizing the cost of lock operations while maintaining thread safety.
Understanding Synchronization Overhead
Before diving into optimizations, let's understand why synchronization is expensive:
- Context switching between threads
- Memory barriers that flush CPU caches
- Lock acquisition/release overhead
- Thread contention when multiple threads compete for the same lock
Lock Coarsening
What is Lock Coarsening?
Lock coarsening is a JVM optimization that combines multiple adjacent lock operations on the same object into a single, larger lock operation. This reduces the overhead of frequent lock acquisition and release.
How Lock Coarsening Works
Without Coarsening:
public void processData(List<String> data) {
for (String item : data) {
synchronized (this) { // Multiple lock/unlock operations
// process item
}
}
}
With Coarsening (Conceptual):
public void processData(List<String> data) {
synchronized (this) { // Single lock/unlock operation
for (String item : data) {
// process item
}
}
}
Example 1: Demonstrating Lock Coarsening
public class LockCoarseningDemo {
private int counter = 0;
private final Object lock = new Object();
// Method that might benefit from lock coarsening
public void incrementMultipleTimes(int iterations) {
for (int i = 0; i < iterations; i++) {
synchronized (lock) {
counter++; // Multiple synchronized blocks close together
}
}
}
// Equivalent manual coarsening
public void incrementMultipleTimesCoarsened(int iterations) {
synchronized (lock) {
for (int i = 0; i < iterations; i++) {
counter++; // Single synchronized block
}
}
}
}
Example 2: Real-World Coarsening Scenario
public class StringBufferCoarsening {
// StringBuffer is synchronized - good candidate for coarsening
public String buildString(List<String> parts) {
StringBuffer sb = new StringBuffer();
// Multiple append calls - each is synchronized internally
for (String part : parts) {
sb.append(part); // Each append has synchronized block
sb.append(","); // Another synchronized block
}
// JVM may coarsen these adjacent synchronized operations
return sb.toString();
}
// Manual optimization - explicit coarsening
public String buildStringOptimized(List<String> parts) {
StringBuffer sb = new StringBuffer();
// Manual coarsening by using local StringBuilder
StringBuilder temp = new StringBuilder();
for (String part : parts) {
temp.append(part);
temp.append(",");
}
// Single synchronized operation
sb.append(temp.toString());
return sb.toString();
}
}
Lock Elimination
What is Lock Elimination?
Lock elimination is a JVM optimization that completely removes unnecessary lock operations when the JVM can prove that the lock is not needed for thread safety.
Lock Elimination Scenarios
- Lock on Local Objects: When a lock is acquired on an object that cannot be accessed by other threads
- Escape Analysis: When the JVM determines an object doesn't escape the current thread
- Uncontended Locks: When locks are never contended by multiple threads
Example 3: Lock Elimination Candidates
public class LockEliminationDemo {
// Candidate for lock elimination - local StringBuffer
public String localStringBuffer() {
StringBuffer sb = new StringBuffer(); // Local object
sb.append("Hello");
sb.append(" World");
// The synchronized blocks in append() can be eliminated
// because 'sb' never escapes this method
return sb.toString();
}
// NOT a candidate - object escapes
public StringBuffer escapingStringBuffer() {
StringBuffer sb = new StringBuffer();
sb.append("Hello");
// 'sb' escapes through return - lock cannot be eliminated
return sb;
}
// Candidate for lock elimination
public int localLockElimination() {
Object localLock = new Object(); // Local object
synchronized (localLock) {
// This lock can be eliminated because localLock
// cannot be accessed by other threads
return 42;
}
}
}
JVM Escape Analysis
Escape analysis is the mechanism that enables lock elimination by determining whether an object "escapes" the current thread.
Escape Analysis Categories:
- No Escape: Object doesn't escape the method
- Method Escape: Object escapes to calling methods but not other threads
- Thread Escape: Object escapes to other threads (requires synchronization)
Example 4: Escape Analysis in Action
public class EscapeAnalysisDemo {
private static class Point {
private int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public synchronized void move(int dx, int dy) {
x += dx;
y += dy;
}
public synchronized String toString() {
return "(" + x + ", " + y + ")";
}
}
// No escape - good candidate for lock elimination
public String pointNoEscape() {
Point p = new Point(10, 20); // Local object
p.move(5, 5);
return p.toString(); // Returns String, not Point
}
// Method escape - might allow some optimizations
public Point pointMethodEscape() {
Point p = new Point(10, 20);
p.move(5, 5);
return p; // Point escapes to caller
}
// Thread escape - no optimization possible
public void pointThreadEscape() {
Point p = new Point(10, 20);
new Thread(() -> {
p.move(5, 5); // Accessed by another thread
}).start();
}
}
Advanced Examples and Benchmarks
Example 5: Benchmarking Lock Optimizations
import java.util.concurrent.TimeUnit;
public class LockOptimizationBenchmark {
private static final int ITERATIONS = 10_000_000;
// Non-optimized version - fine-grained locking
public static long fineGrainedLocking() {
final Object lock = new Object();
long sum = 0;
long start = System.nanoTime();
for (int i = 0; i < ITERATIONS; i++) {
synchronized (lock) {
sum += i; // Lock acquired/released each iteration
}
}
long end = System.nanoTime();
System.out.println("Fine-grained result: " + sum);
return TimeUnit.NANOSECONDS.toMillis(end - start);
}
// Coarsened version - coarse-grained locking
public static long coarseGrainedLocking() {
final Object lock = new Object();
long sum = 0;
long start = System.nanoTime();
synchronized (lock) {
for (int i = 0; i < ITERATIONS; i++) {
sum += i; // Single lock for all iterations
}
}
long end = System.nanoTime();
System.out.println("Coarse-grained result: " + sum);
return TimeUnit.NANOSECONDS.toMillis(end - start);
}
// Lock elimination candidate
public static long potentialLockElimination() {
long sum = 0;
long start = System.nanoTime();
for (int i = 0; i < ITERATIONS; i++) {
Object localLock = new Object();
synchronized (localLock) {
sum += i; // Lock on local object - may be eliminated
}
}
long end = System.nanoTime();
System.out.println("Lock elimination candidate result: " + sum);
return TimeUnit.NANOSECONDS.toMillis(end - start);
}
public static void main(String[] args) {
// Warm up JVM
for (int i = 0; i < 1000; i++) {
potentialLockElimination();
}
System.out.println("=== Lock Optimization Benchmark ===");
long time1 = fineGrainedLocking();
long time2 = coarseGrainedLocking();
long time3 = potentialLockElimination();
System.out.println("Fine-grained locking: " + time1 + "ms");
System.out.println("Coarse-grained locking: " + time2 + "ms");
System.out.println("Lock elimination candidate: " + time3 + "ms");
System.out.println("Coarsening benefit: " + (time1 - time2) + "ms");
System.out.println("Elimination benefit: " + (time1 - time3) + "ms");
}
}
Example 6: StringBuffer vs StringBuilder Performance
public class StringBufferVsBuilder {
private static final int ITERATIONS = 100_000;
public static long stringBufferTest() {
long start = System.currentTimeMillis();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < ITERATIONS; i++) {
sb.append("test"); // Synchronized - but may benefit from coarsening/elimination
sb.append(i);
}
long end = System.currentTimeMillis();
System.out.println("StringBuffer length: " + sb.length());
return end - start;
}
public static long stringBuilderTest() {
long start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ITERATIONS; i++) {
sb.append("test"); // Not synchronized
sb.append(i);
}
long end = System.currentTimeMillis();
System.out.println("StringBuilder length: " + sb.length());
return end - start;
}
public static void main(String[] args) {
// Warm up
for (int i = 0; i < 100; i++) {
stringBufferTest();
stringBuilderTest();
}
System.out.println("=== StringBuffer vs StringBuilder ===");
long bufferTime = stringBufferTest();
long builderTime = stringBuilderTest();
System.out.println("StringBuffer time: " + bufferTime + "ms");
System.out.println("StringBuilder time: " + builderTime + "ms");
System.out.println("Difference: " + (bufferTime - builderTime) + "ms");
// With modern JVMs, the difference might be small due to lock optimizations
}
}
JVM Flags for Lock Optimization
Monitoring and Controlling Lock Optimizations
# Enable/disable specific optimizations -XX:+DoEscapeAnalysis # Enable escape analysis (default: true) -XX:+EliminateLocks # Enable lock elimination (default: true) -XX:+EliminateNestedLocks # Enable nested lock elimination # Diagnostic flags -XX:+PrintEscapeAnalysis # Print escape analysis results -XX:+PrintEliminateLocks # Print lock elimination information -XX:+PrintAssembly # Print assembly code (requires hsdis) # Lock profiling -XX:+UseLockProfiling # Enable lock profiling -XX:+PrintBiasedLocking # Print biased locking information
Example 7: Testing with Different JVM Flags
public class JVMOptimizationTest {
// This method benefits from both coarsening and elimination
public static void optimizedMethod() {
List<String> results = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
StringBuffer sb = new StringBuffer(); // Local object
synchronized (sb) { // Lock on local object
sb.append("Result: ");
sb.append(i);
results.add(sb.toString());
}
}
}
public static void main(String[] args) throws Exception {
System.out.println("Testing lock optimizations...");
System.out.println("Escape Analysis: " +
System.getProperty("java.vm.version"));
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
optimizedMethod();
}
long end = System.nanoTime();
System.out.println("Time: " +
TimeUnit.NANOSECONDS.toMillis(end - start) + "ms");
}
}
Best Practices for Leveraging Lock Optimizations
1. Write Clear Code First
// Write clear, correct code first
public class ClearCode {
private final Object lock = new Object();
private int value;
// Clear but potentially optimizable
public void increment() {
synchronized (lock) {
value++;
}
}
// Let the JVM handle optimizations
public int getValue() {
synchronized (lock) {
return value;
}
}
}
2. Avoid Premature Optimization
public class AvoidPrematureOptimization {
// Don't do this - harder to read
public String overlyOptimized(String[] parts) {
StringBuilder sb = new StringBuilder(parts.length * 10);
synchronized (this) {
for (String part : parts) {
sb.append(part);
}
}
return sb.toString();
}
// Better - let JVM optimize
public String clearAndOptimizable(String[] parts) {
StringBuffer sb = new StringBuffer();
for (String part : parts) {
sb.append(part); // JVM may coarsen/eliminate locks
}
return sb.toString();
}
}
3. Understand When Manual Optimization is Needed
public class ManualOptimization {
private final Collection<Data> dataStore = new ArrayList<>();
// Good case for manual coarsening
public void addAllData(Collection<Data> newData) {
// Manual coarsening - single lock for bulk operation
synchronized (dataStore) {
dataStore.addAll(newData);
}
}
// vs fine-grained (may still be optimized by JVM)
public void addAllDataFineGrained(Collection<Data> newData) {
for (Data data : newData) {
synchronized (dataStore) {
dataStore.add(data);
}
}
}
}
Limitations and Considerations
When Optimizations May Not Apply:
- Volatile Accesses: Limit some optimizations
- Native Methods: May have unknown side effects
- Complex Control Flow: Harder for JVM to analyze
- Object Pooling: Reused objects may not benefit from elimination
- Heavy Contention: Coarsening might increase contention
Example 8: Scenarios Where Optimizations Fail
public class OptimizationLimitations {
private static volatile boolean flag = false;
// Volatile access may prevent some optimizations
public void volatilePreventsOptimization() {
Object lock = new Object();
synchronized (lock) {
if (flag) { // Volatile access in synchronized block
System.out.println("Flag is true");
}
}
}
// Complex control flow
public void complexControlFlow(Object param) {
Object lock = new Object();
synchronized (lock) {
try {
if (param == null) return;
if (param.toString().length() > 10) return;
// Complex flow makes analysis harder
} finally {
System.out.println("Finally");
}
}
}
}
Conclusion
Lock Coarsening and Elimination are powerful JVM optimizations that:
Lock Coarsening:
- Combines adjacent lock operations on the same object
- Reduces lock acquisition/release overhead
- Most effective in loops and frequently called synchronized methods
Lock Elimination:
- Removes unnecessary lock operations entirely
- Relies on escape analysis to prove locks are not needed
- Most effective with local objects and non-escaping references
Key Takeaways:
- Trust the JVM: Modern JVMs are excellent at these optimizations
- Write Clear Code: Focus on correctness first, let JVM optimize
- Profile Before Optimizing: Use actual performance measurements
- Understand the Limits: Some scenarios prevent optimizations
These optimizations demonstrate why micro-optimizations at the code level are often unnecessary and can even be counterproductive. The JVM's runtime optimizations frequently outperform manual tweaks while maintaining code clarity and maintainability.
Remember: The effectiveness of these optimizations depends on the JVM implementation (HotSpot, OpenJ9, etc.), version, and runtime conditions. Always profile your specific application to understand actual performance characteristics.