一个空的同步块会一直等待,直到没有其他人在使用该监视器。
这可能是您想要的,但由于您没有保护同步块中的后续代码,没有任何东西阻止其他人在运行后续代码时修改您正在等待的内容。这几乎永远都不是您想要的。
在第一部分和第二部分中,我将解释一个空的 synchronized
块如何“实现正确的线程处理”。在第三部分中,我将解释它为什么可以是一个“更好的解决方案”。最后,在第四部分中,我将通过示例展示如何使用它仍然是“微妙且难以正确使用”的。
在什么情况下,一个空的 synchronized
块会使线程正确处理?
考虑以下示例。
import static java.lang.System.exit;
class Example { // Incorrect, might never exit
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) {
toExit = true; }})
.start();
new Thread( () -> { // B
for( ;; ) {
if( toExit ) exit( 0 ); }})
.start(); }
boolean toExit; }
toExit
的更改隔离起来,从而将其隐藏在B之外,导致B一直循环。可以通过引入空的synchronized
块来纠正它,如下所示。import static java.lang.System.exit;
class Example { // Corrected
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) {
toExit = true;
synchronized( o ) {} }}) // Force exposure of the change
.start();
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {} // Seek exposed changes
if( toExit ) exit( 0 ); }})
.start(); }
static final Object o = new Object();
boolean toExit; }
空的 synchronized
块如何使代码正确?
Java 内存模型保证‘对于监视器 m 的解锁操作与此后所有对 m 的加锁操作进行同步,并因此§17.4.4形成发生-之前关系(happens-before relation)。因此,在 A 的 synchronized
块末尾解锁对象 o
会先于 B 的 synchronized
块头部的加锁操作。由于 A 的变量写入在解锁前,B 的变量读取在加锁后,保证也扩展到了写和读操作:写操作在读操作之前发生。
现在,‘[if] one action happens-before another, then the first is visible to and ordered before the second’ (§17.4.5)。正是这种可见性保证使得代码在内存模型方面的正确。
一个空的synchronized
块可能比其他解决方案更好吗?
另一种选择是一个非-空的synchronized
块。一个非空的synchronized
块有两个作用:a)它提供了前面一节中描述的排序和可见性保证,有效地强制所有同步于相同监视器的线程之间的内存变化暴露;b)它使块内代码在这些线程中实际上是原子性的;该代码的执行不会与其他块同步的代码的执行交错。
一个空的 synchronized
块只执行 (a) 操作。在需要执行 (a) 操作且 (b) 操作代价较高时,空的 synchronized
块可能是更好的解决方案。
另一种选择是将 volatile
修饰符附加到特定变量的声明上,从而强制暴露其更改。空的 synchronized
块应用于所有变量,而不是任何特定变量。在需要公开广泛变量变化的情况下,空的 synchronized
块可能是更好的解决方案。
此外,volatile
修饰符强制暴露每个单独写入变量的操作,在所有线程中暴露每个操作。空的 synchronized
块在曝光的时间(仅当块被执行时)和范围(仅限于同步到相同监视器的线程)方面有所不同。在需要更狭窄的时间和范围聚焦可能有显着的成本效益的情况下,出于这个原因,空的 synchronized
块可能是更好的解决方案。
并发编程很难。因此,空的synchronized
块“微妙且难以正确使用”也就不足为奇了。一种误用它们的方式(Holger提到)如下面的示例所示。
import static java.lang.System.exit;
class Example { // Incorrect, might exit with a value of 0 instead of 1
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) {
exitValue = 1;
toExit = true;
synchronized( o ) {} }})
.start();
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
if( toExit ) exit( exitValue ); }})
.start(); }
static final Object o = new Object();
int exitValue;
boolean toExit; }
if( toExit ) exit( exitValue )
”假设两个变量之间存在同步,但代码并未保证。假设B在A写入toExit
和exitValue
后读取它们,但在两个synchronized
语句(先是A的,然后是B的)执行之前。那么B看到的可能是第一个变量的更新值(true
)以及第二个变量的未更新值(零),导致它退出时出现错误的值。import static java.lang.System.exit;
class Example { // Corrected
public static void main( String[] _args ) { new Example().enter(); }
void enter() {
new Thread( () -> { // A
for( ;; ) if( !state.toExit() ) {
state = new State( /*toExit*/true, /*exitValue*/1 );
synchronized( o ) {} }})
.start();
new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
State state = this.state; /* Local cache. It might seem
unnecessary when `state` is known to change once only,
but see § Subtleties in the text. */
if( state.toExit ) exit( state.exitValue ); }})
.start(); }
static final Object o = new Object();
static record State( boolean toExit, int exitValue ) {}
State state = new State( /*toExit*/false, /*exitValue*/0 ); }
state
的新值时,它将看到在State
的声明中隐式为final的toExit
和exitValue
这两个最终字段的完全初始化值。 "一个只能在完全初始化对象之后看到对象引用的线程保证会看到该对象最终字段的正确初始化值"(§17.5)。state
变量的本地缓存(如上面的示例)可能看起来是不必要的,因为已知state
只会更改一次。当它具有其原始值时,“if( state.toExit ) exit( state.exitValue )
”语句将短路并仅读取一次;否则它将具有其最终值,并保证在两次读取之间不会更改。但正如Holger指出的那样,没有这样的保证。new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
// State state = this.state;
//// not required when it’s known to change once only
if( state.toExit ) exit( state.exitValue ); }})
.start(); }
if(state.toExit)exit(state.exitValue)
”位于同步块之外,并且state
是非易失性变量,因此以下转换是有效的。new Thread( () -> { // B
for( ;; ) {
synchronized( o ) {}
// State state = this.state;
//// not required when it’s known to change once only
State s = state;
if( state.toExit ) exit( s.exitValue ); }})
.start(); }
state
(到 s
)可能会产生其原始值,而下一次读取则产生其最终值,导致程序意外退出并返回0而不是1。volatile
、AtomicInteger
或VarHandle
)。但在涉及多个变量的任何情况下,这都将被破坏。 - Holgerif(state.toExit)exit(state.exitValue);
在这里不能保证有效,因为这是两个没有线程间语义的状态读取。如果没有正确的同步,读取和写入可能会被认为是无序的,因此,在if
条件内部的第一次读取可能会感知到比exit(…)
调用语句内部的读取更新的值。您必须首先将引用读入本地变量中(而且这又是一个案例,其中替代方案volatile
、AtomicInteger
或VarHandle
比空的synchronized
块更好)。 - Holgerif( state.toExit )
可能会看到 state
的新值,然后 exit( state.exitValue );
看到 state
的旧值。为了防止无序读取,您必须将变量声明为 volatile
,但由于整个示例都是关于避免这种情况,因此必须使用局部变量。 - Holger过去规范意味着会发生某些内存屏障操作。然而,现在规范已经改变,原始规范从未被正确实现。它可以用于等待另一个线程释放锁,但协调其他线程已经获取锁可能会很棘手。
- 线程获取对象this的监视器锁(假设监视器未锁定,否则线程将等待,直到监视器被解锁)。
- 线程刷新其所有变量的内存,即它的所有变量都从“主”内存中有效读取(JVM可以使用脏集来优化此过程,以便只刷新“脏”变量,但在概念上这是相同的。请参见Java语言规范的第17.9节)。
- 执行代码块(在本例中将返回值设置为i3的当前值,该值可能刚刚从“主”内存中重置)。
- (任何对变量的更改现在通常都会写入“主”内存,但对于geti3(),我们没有更改。)
- 线程释放对象this的监视器锁。
如果想深入了解Java的内存模型,可以观看Google“编程语言高级主题”系列中的这个视频: http://www.youtube.com/watch?v=1FX4zco0ziY
它提供了一个非常好的概述,介绍了编译器可以对你的代码进行哪些操作(通常是理论上的,但有时也会在实践中出现)。对于任何认真的Java程序员来说,这都是必不可少的知识!
synchronized
等待工人完成修改实例,自此以后 - 就不需要进一步的同步,因此所有读取都在synchronized
代码之外完成。我认为synchronized
是比手动管理锁实例更清晰的解决方案。 - Pijusn