Since the specification does not define a limit, it’s implementation specific. There doesn’t even have to be a limit at all, but JVMs are often optimized for high performance, considering the ordinary use cases rather than focusing on support for extreme cases.
As said in this answer, there’s a fundamental difference between an object’s intrinsic monitor and a ReentrantLock
, as you can acquire the latter in a loop, which makes it necessary to specify that there’s limit.
Determining the actual limit of a particular JVM implementation, like the widely used HotSpot?JVM, has the problem that there are several factors which can affect the result, even in the same environment.
- The JVM may eliminate locks when it can prove that the object is purely local, i.e. it is impossible that a different thread ever synchronizes on it
- The JVM may merge adjacent and nested synchronized blocks when they use the same object, which may apply after inlining, so these blocks do not need to appear nested or close to each other in source code
- The JVM may have different implementations, selected based on the shape of the object’s class (some classes are more likely to be used as synchronization key) and the history of a particular acquisition (e.g. use biased locking, or use optimistic or pessimistic approaches, depending on how often the lock was contended)
To experiment with the actual implementation, I used the ASM library to generate bytecode which acquires an object’s monitor in a loop, an action, ordinary Java code can not do
package locking;
import static org.objectweb.asm.Opcodes.*;
import java.util.function.Consumer;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
public class GenerateViaASM {
public static int COUNT;
static Object LOCK = new Object();
public static void main(String[] args) throws ReflectiveOperationException {
Consumer s = toClass(getCodeSimple()).asSubclass(Consumer.class)
.getConstructor().newInstance();
try {
s.accept(LOCK);
} catch(Throwable t) {
t.printStackTrace();
}
System.out.println("acquired "+COUNT+" locks");
}
static Class<?> toClass(byte[] code) {
return new ClassLoader(GenerateViaASM.class.getClassLoader()) {
Class<?> get(byte[] b) { return defineClass(null, b, 0, b.length); }
}.get(code);
}
static byte[] getCodeSimple() {
ClassWriter cw = new ClassWriter(0);
cw.visit(49, ACC_PUBLIC, "Test", null, "java/lang/Object",
new String[] { "java/util/function/Consumer" });
MethodVisitor con = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
con.visitCode();
con.visitVarInsn(ALOAD, 0);
con.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
con.visitInsn(RETURN);
con.visitMaxs(1, 1);
con.visitEnd();
MethodVisitor method = cw.visitMethod(
ACC_PUBLIC, "accept", "(Ljava/lang/Object;)V", null, null);
method.visitCode();
method.visitInsn(ICONST_0);
method.visitVarInsn(ISTORE, 0);
Label start = new Label();
method.visitLabel(start);
method.visitVarInsn(ALOAD, 1);
method.visitInsn(MONITORENTER);
method.visitIincInsn(0, +1);
method.visitVarInsn(ILOAD, 0);
method.visitFieldInsn(PUTSTATIC, "locking/GenerateViaASM", "COUNT", "I");
method.visitJumpInsn(GOTO, start);
method.visitMaxs(1, 2);
method.visitEnd();
cw.visitEnd();
return cw.toByteArray();
}
}
On my machine, it printed
java.lang.IllegalMonitorStateException
at Test.accept(Unknown Source)
at locking.GenerateViaASM.main(GenerateViaASM.java:23)
acquired 62470 locks
in one run, but different numbers in the same order of magnitude in other runs. The limit we’ve hit here, is not a counter, but the stack size. E.g. re-running this program in the same environment, but with the -Xss10m
option, gave ten times the number of lock acquisitions.
So the reason why this number is not the same in every run, is the same as elaborated in Why is the max recursion depth I can reach non-deterministic? The reason why we don’t get a StackOverflowError
is that the HotSpot?JVM enforces structured locking, which means that a method must release the monitor exactly as often as it has acquired it. This even applies to the exceptional case and as our generated code does not make any attempt to release the monitor, the StackOverflowError
gets shadowed by an IllegalMonitorStateException
.
Ordinary Java code with nested synchronized
blocks can never get anywhere near 60,000 acquisitions in one method, as the bytecode is limited to 65536 bytes and it takes up to 30 bytes for a javac
compiled synchronized
block. But the same monitor can get acquired in nested method invocations.
For exploring the limits with ordinary Java code, expanding the code of your question is not so hard. You just have to give up indenting it:
public class MaxSynchronized {
static final Object LOCK = new Object(); // potentially visible to other threads
static int COUNT = 0;
public static void main(String[] args) {
try {
testNested(LOCK);
} catch(Throwable t) {
System.out.println(t+" at depth "+COUNT);
}
}
private static void testNested(Object o) {
// copy as often as you like
synchronized(o) { synchronized(o) { synchronized(o) { synchronized(o) {
synchronized(o) { synchronized(o) { synchronized(o) { synchronized(o) {
synchronized(o) { synchronized(o) { synchronized(o) { synchronized(o) {
synchronized(o) { synchronized(o) { synchronized(o) { synchronized(o) {
COUNT ++;
testNested(o);
// copy as often as you copied the synchronized... line
} } } }
} } } }
} } } }
} } } }
}
}
The method will invoke itself to have a number of nested acquisitions matching the number of nested invocation times the number of nested synchronized
blocks within method.
When you run it with the small number of synchronized
blocks as above, you’ll get a StackOverflowError
after a large number of invocations, which changes from run to run and is affected by the presence of options like -Xcomp
or -Xint
, indicating that it subject to the indeterministic stack size mentioned above.
But when you raise the number of nested synchronized
blocks significantly, the number of nested invocations becomes smaller and stable. On my environment, it produced a StackOverflowError
after 30 nested calls when having 1,000 nested synchronized
blocks and 15 nested calls when having 2,000 nested synchronized
blocks, which is pretty consistent, indicating that the method invocation overhead has become irrelevant.
This implies more than 30,000 acquisitions, roughly half the number achieved with the ASM generated code, which is reasonable considering that the javac
generated code will ensure a matching number of acquisitions and releases, introducing a synthetic local variable holding the reference of the object that must be released for each synchronized
block. This additional variable reduces the available stack size. It’s also the reason why we now see the StackOverflowError
and no IllegalMonitorStateException
, as this code correctly does structured locking.
Like with the other example, running with larger stack size raises the reported number, scaling linearly. Extrapolating the results implies that it would need a stack size of several?GB to acquire the monitor Integer.MAX_VALUE
times. Whether there is a limiting counter or not, becomes irrelevant under these circumstances.
Of course, these code examples are so far away from real life application code, that it should not be surprising that not much optimizations happened here. For real life application code, lock elimination and lock coarsening may happen with a much higher likelihood. Further, real life code will do actual operations needing stack space on their own, rendering the stack requirements of synchronization negligible, so there’s no practical limit.