文章目录
一、synchronized 同步机制
synchronized 同步机制是 Java 第一个多线程同步访问共享对象(数据)的机制。显然刚开始的时候Java 同步机制不是很好,因此在 jdk 1.5 提出了一些并发工具类帮助开发者实现比synchronized同步机制更好的并发控制。随着 jdk 版本的不断更新发布,synchronized 关键的性能已经做了很多的优化,在多数情况下,使用 synchronized 关键字能够满足并发控制。
二、synchronized 关键字
通过 synchronized 关键字标记 synchronized 代码块。Java 中 synchronized 代码块是在某个对象上做同步(这个对象我们称为锁),所有作用在某个对象的同步代码块同时只能被一个执行,其他线程尝试执行同步代码块时必须等待正在执行同步代码块的线程退出之后才能进入同步代码块。
synchronized 关键字三种用法:
- 作用于实例方法(普通方法)
- 作用于静态方法
- 作用于代码块
建议: 在需要使用synchronized 关键字时,首先考虑作用于代码块,其次是普通方法,最后是静态方法(代码块 > 普通方法 > 静态方法)。
2.1 作用于普通方法
通过使用 synchronized 关键字声明一个同步的普通方法。
示例:
public class MyCounter {
private int count = 0;
public synchronized void add(int value){
this.count += value;
}
}
示例中通过使用 声明 add() 方法,告诉 JVM 这个 add() 方法是同步的。
synchronized 同步实例方法:给当前实例对象加锁,当某个线程进入执行synchronized实例方法时需要获取当前实例对象的锁。因此当synchronized作用于普通方法时,每个实例的同步方法只作用于自己的实例对象。
只有一个线程可以执行实例同步方法。如果存在多个线程,那么只有一个线程能够同时执行实例同步方法,其他线程必须等待正在执行同步方法的线程执行完毕同步方法,获取到实例锁之后才能执行同步方法。
2.2 作用于静态方法
通过使用 synchronized 关键字声明一个同步的静态方法,与普通方法不同的是,静态方法需要通过 static 关键字声明。
public static MyStaticCounter{
private static int count = 0;
public static synchronized void add(int value){
count += value;
}
}
示例中通过使用 synchronized 声明 add() 方法,告诉 JVM 这个 add() 方法是同步的。
synchronized 同步静态方法:给当前类的 class 对象加锁,当某个线程进入synchronized静态方法时需要获取当前类的class对象的锁。
因为JVM中每个类只有一个class对象,因此只有一个线程能够同时执行静态同步方法。
如果一个类声明了多个静态同步方法,只有一个线程可以同时执行这些方法。示例:
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;
}
}
由于静态同步方法的锁作用于当前类的class对象,所以当多个线程需要执行同一个类中声明的不同的静态同步方法时,同一时间只有一个线程能拿到类的class对象锁,因此只有一个线程能在给定的时间内执行某个静态方法。
上例中声明了2个静态同步方法,分别是add()方法和subtract()方法,当某个线程执行add()方法时,其他线程只能等待正在执行add()方法执行完毕,并释放类的class对象锁后,才能获取到锁并执行对应的静态同步方法。
2.3 作用于代码块
通常情况下,我们不需要同步整个方法,可以在方法内部声明同步代码块,当多个线程执行到同步代码时,使线程的变得同步执行。如下所示:
synchronized (object) {
}
说明:object 表示接收某个对象作为锁,可以是任意对象,也可以是class对象。
示例:
public void add(int value){
synchronized(this){
this.count += value;
}
}
上例中通过使用synchronized标记这段代码块是同步的,当多个线程需要执行这段代码时,线程会变得同步执行。
synchronized代码块接收一个对象作为同步锁,只有获取到同步锁才能执行同步代码,这个对象可以是我们自定义的任意对象,包括class对象。通常包括下面三种情况:
- this 对象
- class 对象
- 其他自定义对象
2.3.1 this对象作为synchronized同步代码块的锁
当以this对象(表示当前实例对象)作为同步代码块的锁时,只有获取到当前实例对象锁的线程才能执行相应的同步代码。
示例:
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);
}
}
}
上例中:如果某个线程正在执行log2()方法中的同步代码块,那么会阻塞其他线程执行log1()方法和log2()方法中的同步代码块(log1方法是普通同步方法,也是以当前实例对象作为锁)。
以this为对象锁的同步代码块,和普通同步方法的锁对象相同,都是某个实例对象作为同步锁。这种情况下,我们要考虑多线程竞争锁对象时造成的不必要的损耗。
2.3.2 class对象作为synchronized同步代码块的锁
当以class对象作为同步代码块的锁时,只有获取到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);
}
}
}
上例中:当某个线程正在执行log1()方法中的同步代码块或者log2()方法时,会阻塞其他线程执行这两个方法中的任意一个方法。
以class为对象锁的同步代码块,和静态同步方法的锁对象相同,都是某个类的class对象作为同步锁。这种情况下,我们也要考虑多线程竞争锁对象时造成的不必要的损耗。
2.3.3 其他对象作为synchronized同步代码块的锁
可以指定任意对象作为同步代码块的对象锁,通常情况下我们使用共享资源对象作为指定的对象锁。备注:如果存在多个共享资源,那么一定要注意锁的顺序,有效防止死锁。
public class MyClass {
private String lock = "lock";
public static void log2(String msg1, String msg2) {
synchronized (lock) {
log.writeln(msg1);
log.writeln(msg2);
}
}
}
以任意自定义对象作为锁时,似乎要比以this对象和class对象作为synchronized同步锁要灵活的多,不过也要根据场景来使用不同的对象作为同步锁。
2.3.4 Lambda 表达式中的同步代码块
我们可以在Lambda表达式中声明synchronized同步代码块,其使用方式跟我们上面提到的知识点是一样的。
示例:
public class MyClass {
public static void main(String[] args) {
String lock = "lock";
Consumer<String> consumer = s -> {
synchronized (lock) {
//do something...
}
}
}
}
三、synchronized 数据可见性
上一篇博客我们讲《Java Happens Before Guarantee》 时提到过 volatile 关键字和 synchronize 关键字的数据可见性保证。
这里我们简单回忆一下:没有使用 volatile 关键字或 synchronized 关键字时,当某个线程改变了共享资源的值时对其他线程不可见,原因是不会保证存储于CPU寄存器或者CPU高速缓存的数据重新写回主内存中。
synchronized 数据可见性:通过使用synchronized关键字声明同步方法或者同步代码块时,某个线程进入synchronized代码块时,会从主内存中读取最新的数据保存至CPU高速缓存或者CPU寄存器中;当某个线程退出synchronized代码块时,会将CPU寄存器中最新的数据刷回到CPU高速缓存中,再将CPU高速缓存中的最新数据刷新到主内存中,以此保证数据可见性。
四、synchronized 和指令重排
上一篇博客中提到过: synchronized 在一定程度上限制了指令重排。
JVM 和 CPU 为了让程序更快的执行,允许指令重排,通过指令重排让指令并行执行。(前提条件是并行执行的指令没有依赖关系,也就是重排的指令之间不存在依赖关系)
指令重排在多线程中可能会产生一些问题,比如synchronized同步代码块中写一个变量的代码被重排到synchronized外面。为了防止这种问题产生,synchronized 关键字限制了一些指令重排的发生(与volatile关键字限制指令重排相似)。
最终的结果是:你可以确定你的代码正确的工作 ———— synchronized限制了指令重排的发生。
五、什么对象用来做同步对象锁?
正如我们前面多次提到的一样,synchronized 关键字必须作用于某个对象上做同步(通常情况下我们称这个对象为同步对象锁)。
你可以使用任意对象作为同步对象锁,但是不建议你使用某些String类型的对象和原始基本数据类型的包装类对象作为同步对象锁。因为编译器会优化它们,最终的结果是当你使用他们在不同的同步代码块中作为对象锁时,你以为你使用了不同的对象作为对象锁,实际上只使用了同一个对象作为对象锁。
出于安全考虑,使用 this 对象或者 new Object() 作为同步对象锁,这种情况下不会被Java编译器或JVM缓存。
六、synchronized 代码块的限制和替代品
Java 中的 synchronized代码块存在一些限制,比如同一时间只允许一个线程进入同步代码块。如果多个线程只读取共享数据,不更新共享数据,这是线程安全的,这种情况下我们可以使用 Read / Write Lock 代替 synchronized 代码块。
你想要多个线程进入同步代码块还是只是一个线程进入同步代码块?我们可以通过使用 Semaphore(计数信号量:限流的作用) 来实现指定数量的线程进入同步代码块(这个点说的同步代码块不是指synchronized代码块),后面的博客我们来讲解这种实现方式。
synchronized代码块不会保证等待线程按顺序执行同步代码块。如果我们需要线程按顺序执行同步代码块,可以通过实现 Fairness(公平锁) 来达到这个目的。
如果只有一个线程修改共享变量,其他的线程只读取共享变量的值,我们可以使用 volatile 关键字,不需要任何同步代码块。
七、synchronized 性能消耗
当线程进入和退出 synchronized 代码块时存在一些性能消耗。随着 JDK 版本的发布,性能的消耗变得越来越小,但是使用synchronized代码块还是不可避免地需要付出一些代价。
如果在循环内不断地进入和退出同步代码块,无疑会让放大性能的损耗。
尽量避免大的同步代码块,换句话说,只把真正需要的同步操作的代码放进同步代码块,避免其他线程执行不需要同步操作的代码时造成阻塞,同时也能提高执行代码的性能。
八、synchronized 锁重入
锁重入:当一个线程进入某个同步代码块时,线程持有同步代码块的同步锁,若同步代码块还调用了具有相同同步锁的同步代码块,线程不需要再次获取锁,可以直接进入相同同步锁的其他同步代码块。
synchronized 支持锁重入。
备注:设计多重同步代码块时,需要合理的设计,错误的实现容易导致死锁产生,保持同步代码的锁的顺序一致是一种设计方案。
九、集群中的 synchronized 代码块
请记住:synchronized 代码块只针对于同一个 JVM 中不同的线程能起到同步作用,如果你拥有相同的 Java 应用程序在多个 JVM 上面运行 ———— 集群,可能导致每个JVM都有一个线程在同时访问共享资源。
如果你需要所有的JVM同步访问共享资源,可以使用其他的同步机制,而不是使用 synchronized 代码块。