Java同步块
Java同步块将方法或者代码块标记为已同步。 Java中的同步块一次只能执行一个线程(取决于我们如何使用它)。因此,可以使用Java同步块来避免争用条件。
Java并发实用程序
"同步"机制是Java第一种用于同步对多个线程共享的对象的访问的机制。不过,"同步"机制不是很先进。这就是为什么Java 5拥有一整套并发实用程序类来帮助开发人员实现比" synchronized"更细粒度的并发控制的原因。
Java同步关键字
Java中的同步块用synchronized
关键字标记。 Java中的同步块在某些对象上同步。在同一个对象上同步的所有同步块只能其中一个内部执行一个线程。尝试进入同步块的所有其他线程将被阻止,直到同步块内的线程退出该块为止。
" synchronized"关键字可用于标记四种不同类型的块:
- 实例方法
- 静态方法
- 实例方法中的代码块
- 静态方法中的代码块
这些块在不同的对象上同步。我们需要哪种类型的同步块取决于具体情况。这些同步块中的每一个将在下面更详细地说明。
同步实例方法
这是一个同步实例方法:
public class MyCounter { private int count = 0; public synchronized void add(int value){ this.count += value; } }
注意在add()方法声明中使用了synchronized关键字。这告诉Java该方法是同步的。
Java中的同步实例方法在拥有该方法的实例(对象)上同步。因此,每个实例在不同的对象(拥有实例)上具有同步的同步方法。
每个实例只能在一个同步实例方法中执行一个线程。如果存在多个实例,则每个实例可以一次在一个同步实例方法内执行一个线程。每个实例一个线程。
在同一对象(实例)的所有同步实例方法中都是如此。因此,在下面的示例中,两个同步方法中的任何一个都只能执行一个线程。每个实例总共一个线程:
public class MyCounter { private int count = 0; public synchronized void add(int value){ this.count += value; } public synchronized void subtract(int value){ this.count -= value; } }
同步静态方法
静态方法被标记为已同步,就像使用synchronized
关键字的实例方法一样。这是一个Java同步静态方法示例:
public static MyStaticCounter{ private static int count = 0; public static synchronized void add(int value){ count += value; } }
同样在这里synchronized
关键字告诉Javaadd()
方法是同步的。
同步的静态方法在同步的静态方法所属的类的类对象上同步。由于每个类的Java VM中仅存在一个类对象,因此在同一类中的静态同步方法内只能执行一个线程。
如果一个类包含多个静态同步方法,则只能在一个方法中同时执行一个线程。看下面这个静态同步方法的例子:
public static MyStaticCounter{ private static int count = 0; public static synchronized void add(int value){ count += value; } public static synchronized void subtract(int value){ count -= value; } }
在任何给定时间,只有一个线程可以在两个" add()"和" subtract()"方法中的任何一个内执行。如果线程A正在执行add()
,那么线程B将无法执行add()
或者subtract()
,直到线程A退出了add()
。
如果静态同步方法位于不同的类中,则一个线程可以在每个类的静态同步方法中执行。每个类一个线程,无论它调用哪种静态同步方法。
实例方法中的同步块
我们不必同步整个方法。有时最好只同步一部分方法。方法内部的Java同步块使这成为可能。
这是未同步的Java方法内的Java代码的同步块:
public void add(int value){ synchronized(this){ this.count += value; } }
本示例使用Java同步块构造将代码块标记为已同步。现在,将像执行同步方法一样执行此代码。
请注意,Java同步块构造如何将对象放在括号中。在示例中,使用了" this",这是调用add方法的实例。同步结构在括号中使用的对象称为监视对象。据说该代码已在监视对象上同步。同步实例方法将其所属的对象用作监视对象。
在同一监视对象上同步的Java代码块内只能执行一个线程。
以下两个示例都在调用它们的实例上同步。因此,它们在同步方面是等效的:
public class MyClass { public synchronized void log1(String msg1, String msg2){ log.writeln(msg1); log.writeln(msg2); } public void log2(String msg1, String msg2){ synchronized(this){ log.writeln(msg1); log.writeln(msg2); } } }
因此,在此示例中,只有一个线程可以在两个同步块中的任何一个内部执行。
如果第二个同步块在与" this"不同的对象上进行了同步,则每次可以在每个方法中执行一个线程。
静态方法中的同步块
同步块也可以在静态方法中使用。这是上一节中与静态方法相同的两个示例。这些方法在该方法所属的类的class对象上同步:
public class MyClass { public static synchronized void log1(String msg1, String msg2){ log.writeln(msg1); log.writeln(msg2); } public static void log2(String msg1, String msg2){ synchronized(MyClass.class){ log.writeln(msg1); log.writeln(msg2); } } }
在这两个方法中的任何一个中只能同时执行一个线程。
如果第二个同步块在与MyClass.class不同的对象上进行了同步,那么一个线程可以在每个方法中同时执行。
Lambda表达式中的同步块
甚至可以在Java Lambda表达式以及匿名类中使用同步块。
这是一个Java lambda表达式的示例,其中包含一个同步块。请注意,同步块在包含lambda表达式的类的类对象上同步。如果这样做更有意义(考虑到特定的用例),也可以在另一个对象上进行同步,但是在本示例中,使用class对象是可以的。
import java.util.function.Consumer; public class SynchronizedExample { public static void main(String[] args) { Consumer<String> func = (String param) -> { synchronized(SynchronizedExample.class) { System.out.println( Thread.currentThread().getName() + " step 1: " + param); try { Thread.sleep( (long) (Math.random() * 1000)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println( Thread.currentThread().getName() + " step 2: " + param); } }; Thread thread1 = new Thread(() -> { func.accept("Parameter"); }, "Thread 1"); Thread thread2 = new Thread(() -> { func.accept("Parameter"); }, "Thread 2"); thread1.start(); thread2.start(); } }
Java同步示例
这是一个示例,该示例启动2个线程,并使它们两个都在Counter的同一实例上调用add方法。一次仅一个线程将能够在同一实例上调用add方法,因为该方法在它所属的实例上是同步的。
public class Example { public static void main(String[] args){ Counter counter = new Counter(); Thread threadA = new CounterThread(counter); Thread threadB = new CounterThread(counter); threadA.start(); threadB.start(); } }
这是上面示例中使用的两个类,Counter
和CounterThread
。
public class Counter{ long count = 0; public synchronized void add(long value){ this.count += value; } }
public class CounterThread extends Thread{ protected Counter counter = null; public CounterThread(Counter counter){ this.counter = counter; } public void run() { for(int i=0; i<10; i++){ counter.add(i); } } }
创建两个线程。相同的Counter
实例在其构造函数中传递给它们两个。 Counter.add()
方法在实例上同步,因为add方法是实例方法,并标记为已同步。因此,只有一个线程可以一次调用add()方法。另一个线程将等到第一个线程离开add()方法,然后才能执行该方法本身。
如果两个线程引用了两个单独的" Counter"实例,则同时调用add()方法不会有问题。调用本来是对不同的对象的,所以调用的方法也将在不同的对象(拥有该方法的对象)上同步。因此,通话不会阻塞。看起来是这样的:
public class Example { public static void main(String[] args){ Counter counterA = new Counter(); Counter counterB = new Counter(); Thread threadA = new CounterThread(counterA); Thread threadB = new CounterThread(counterB); threadA.start(); threadB.start(); } }
请注意,线程A和线程B这两个线程不再引用相同的计数器实例。 " counterA"和" counterB"的" add"方法在它们两个拥有的实例上同步。因此,在counterA
上调用add()
不会阻止对counterB
上的add()
的调用。
同步和数据可见性
如果不使用" synchronized"关键字(或者Java volatile关键字),则无法保证当一个线程更改与其他线程共享的变量的值时(例如,通过一个对象,所有线程都可以访问),其他线程可以看到更改后的值。无法保证何时将一个线程保留在CPU寄存器中的变量"提交"到主存储器,也无法保证何时其他线程从主存储器"刷新" CPU寄存器中的变量。
synchronized
关键字改变了这一点。当线程进入同步块时,它将刷新该线程可见的所有变量的值。当线程退出同步块时,对该线程可见的变量的所有更改都将提交给主内存。这类似于volatile关键字的工作方式。
同步和指令重新排序
允许Java编译器和Java虚拟机对代码中的指令进行重新排序,以使它们更快地执行,通常是通过使重新排序的指令由CPU并行执行来实现。
指令重新排序可能会导致多个线程同时执行的代码出现问题。例如,如果对发生在同步块内部的变量的写操作被重新排序为发生在同步块外部。
为了解决此问题,Java同步关键字对同步块之前,内部和之后的指令重新排序设置了一些限制。这类似于volatile关键字所设置的限制。
最终结果是,可以确保代码正确运行,不会发生指令重新排序,最终导致代码的行为与编写的代码不同。
要同步哪些对象
如本Java同步教程中多次提到的,同步块必须在某个对象上同步。实际上,我们可以选择任何要同步的对象,但是建议我们不要在String对象或者任何原始类型包装器对象上进行同步,因为编译器可能会优化这些对象,以便在不同位置使用相同的实例。我们以为我们正在使用其他实例的代码。看这个例子:
synchronized("Hey") { //do something in here. }
如果我们有多个在字面量String值" Hey"上同步的同步块,则编译器实际上可能在幕后使用相同的String对象。结果是,这两个同步块随后都在同一对象上同步。那可能不是我们想要的行为。
使用原始类型包装器对象也是如此。看这个例子:
synchronized(Integer.valueOf(1)) { //do something in here. }
如果多次调用Integer.valueOf(1),它实际上可能为相同的输入参数值返回相同的包装对象实例。这意味着,如果要同步同一原始包装对象上的多个块(例如,多次使用Integer.valueOf(1)作为监视对象),则冒着这些同步块都在同一对象上同步的风险。那也可能不是我们想要的行为。
为了安全起见,请在this
或者new Object()
上进行同步。 Java编译器,Java VM或者Java库不会在内部对其进行缓存或者重用。
同步块限制和替代
Java中的同步块有几个限制。例如,Java中的同步块仅一次允许一个线程进入。但是,如果两个线程只想读取一个共享值而不更新它,该怎么办?这可能是安全的。作为同步块的替代方法,我们可以使用读/写锁来保护代码,该锁比同步块具有更高级的锁定语义。 Java实际上附带了我们可以使用的内置ReadWriteLock类。
如果要允许N个线程进入一个同步块,而不仅仅是一个线程,该怎么办?我们可以使用信号量来实现该行为。 Java实际上带有可使用的内置Java Semaphore类。
同步块不能保证以等待的顺序向等待进入它们的线程授予对同步块的访问权限。如果我们需要保证尝试进入同步块的线程能够以其请求访问的确切顺序进行访问,那该怎么办?我们需要自己实现"公平"。
如果我们只有一个线程写入共享变量,而其他线程仅读取该变量怎么办?这样一来,我们就可以只使用一个volatile变量而无需任何同步。
同步块性能开销
与进入和退出Java中的同步块相关的性能开销很小。随着Jave的发展,这种性能开销已经下降,但是仍然要付出很小的代价。
如果我们在一个紧密的循环内多次进入和退出同步块,则通常要担心进入和退出同步块的性能开销。
另外,请尽量不要使同步块大于必需的块。换句话说,仅同步真正需要同步的操作,以避免阻止其他线程执行不必同步的操作。同步块中只有绝对必要的指令。那应该增加代码的并行性。
同步块重入
一旦线程进入了同步块,就称该线程"锁定"了同步块同步到的监视对象。如果线程调用另一个方法,该方法在内部包含同步块的情况下回调第一个方法,则持有锁的线程可以重新进入同步块。它不仅仅因为线程(本身)持有该锁而被阻止。仅当有其他线程持有该锁时。看这个例子:
public class MyClass { List<String> elements = new ArrayList<String>(); public void count() { if(elements.size() == 0) { return 0; } synchronized(this) { elements.remove(); return 1 + count(); } } }
暂时忘了,上述计算列表元素的方法根本没有任何意义。只需关注count()
方法内的同步块内部如何递归调用count()
方法即可。因此,线程调用count()最终可能多次进入同一同步块。这是允许的。这个有可能。
但是请记住,如果我们不仔细设计代码,则线程进入多个同步块的设计可能导致嵌套的监视器锁定。
集群设置中的同步块
请记住,同步块仅阻止同一Java VM中的线程进入该代码块。如果我们在集群中的多个Java VM上运行了相同的Java应用程序,则每个Java VM中的一个线程可能会同时输入该同步块。
如果需要在集群中的所有Java VM之间进行同步,则将需要使用其他同步机制,而不仅仅是同步块。