Java 并发 - 关键字 synchronized
# Java 并发 - 关键字 synchronized
- Synchronized 可以作用在哪里? 分别通过对象锁和类锁进行举例。
- Synchronized 本质上是通过什么保证线程安全的? 分三个方面回答:加锁和释放锁的原理,可重入原理,保证可见性原理。
- Synchronized 由什么样的缺陷? Java Lock 是怎么弥补这些缺陷的。
- Synchronized 和 Lock的对比,和选择?
- Synchronized 在使用时有何注意事项?
- Synchronized 修饰的方法在抛出异常时,会释放锁吗?
- 多个线程等待同一个 Synchronized 锁的时候,JVM 如何选择下一个获取锁的线程?
- Synchronized 使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
- 我想更加灵活的控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
- 什么是锁的升级和降级? 什么是 JVM 里的偏斜锁、轻量级锁、重量级锁?
- 不同的 JDK 中对 Synchronized 有何优化?
# 前言
原子性问题的源头是线程切换,如果我们能够保证对共享变量的修改是互斥的,那么就都能保证原子性了。这里的互斥指的是:同一时刻只有一个线程执行,
当谈到互斥,一般都会联想到”锁“,对于锁有两个非常非常重要的点:我们锁的是什么?我们保护的又是什么?下图为锁模型:
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间特地用一条线做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里我们认为已经正确加锁了。
# 1. Java 语言提供的锁技术:synchronized
说到锁,我们通常会谈到 synchronized 这个关键字。它翻译成中文就是“同步”的意思。
首先需要明确的一点是:Java 多线程的锁都是基于对象的,Java 中的每一个对象都可以作为一个锁。
还有一点需要注意的是,我们常听到的类锁其实也是对象锁。
Java 类只有一个 Class 对象(可以有多个实例对象,多个实例共享这个 Class 对象),而 Class 对象也是特殊的Java 对象。所以我们常说的类锁,其实就是 Class 对象的锁。
在使用 sychronized 关键字时需要把握如下注意点:
- 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
- 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是 *.class 以及 synchronized 修饰的是 static 方法的时候,所有对象公用同一把锁
- synchronized 修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
- 锁对象不能为空,因为锁的信息都保存在对象头里。也不能用可变对象做锁。
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用 Lock 也不要用 synchronized 关键字,用 java.util.concurrent 包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用 synchronized 关键,因为代码量少,避免出错
- synchronized 实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。
# 2. synchronized 使用方式
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
可以发现上面代码中没有加锁 lock() 和解锁 unlock(),其实这两个操作都是有的,只是这两个操作是被 Java 默默加上的,Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。
那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象是什么?Java 的一条隐式规则:
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
- 当修饰非静态方法的时候,锁定的是当前实例对象 this。
对于上面的例子,synchronized 修饰静态方法相当于:
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
2
3
4
5
6
修饰非静态方法,相当于:
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
2
3
4
5
6
具体例子如下:
# 2.1 对象锁
包括方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)
代码块形式:手动指定锁定对象,也可是是this,也可以是自定义的锁
示例1:
public class SynchronizedObjectLock implements Runnable { static SynchronizedObjectLock instance = new SynchronizedObjectLock(); @Override public void run() { // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行 synchronized (this) { System.out.println("我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "结束"); } } public static void main(String[] args) { Thread t1 = new Thread(instance); Thread t2 = new Thread(instance); t1.start(); t2.start(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24输出结果:
我是线程Thread-0 Thread-0结束 我是线程Thread-1 Thread-1结束
1
2
3
4实例2:
public class SynchronizedObjectLock implements Runnable { static SynchronizedObjectLock instance = new SynchronizedObjectLock(); // 创建2把锁 Object block1 = new Object(); Object block2 = new Object(); @Override public void run() { // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行 synchronized (block1) { System.out.println("block1锁,我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("block1锁,"+Thread.currentThread().getName() + "结束"); } synchronized (block2) { System.out.println("block2锁,我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("block2锁,"+Thread.currentThread().getName() + "结束"); } } public static void main(String[] args) { Thread t1 = new Thread(instance); Thread t2 = new Thread(instance); t1.start(); t2.start(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37输出结果:
block1锁,我是线程Thread-0 block1锁,Thread-0结束 block2锁,我是线程Thread-0 // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把 block1锁,我是线程Thread-1 block2锁,Thread-0结束 block1锁,Thread-1结束 block2锁,我是线程Thread-1 block2锁,Thread-1结束
1
2
3
4
5
6
7
8
9方法锁形式:synchronized 修饰普通方法,锁对象默认为 this
public class SynchronizedObjectLock implements Runnable { static SynchronizedObjectLock instance = new SynchronizedObjectLock(); @Override public void run() { method(); } public synchronized void method() { System.out.println("我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "结束"); } public static void main(String[] args) { Thread t1 = new Thread(instance); Thread t2 = new Thread(instance); t1.start(); t2.start(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25输出结果:
我是线程Thread-0 Thread-0结束 我是线程Thread-1 Thread-1结束
1
2
3
4
# 2.2 类锁
指 synchronized 修饰静态的方法或指定锁对象为 Class 对象
synchronized 修饰静态方法
实例1:
public class SynchronizedObjectLock implements Runnable { static SynchronizedObjectLock instance1 = new SynchronizedObjectLock(); static SynchronizedObjectLock instance2 = new SynchronizedObjectLock(); @Override public void run() { method(); } // synchronized用在普通方法上,默认的锁就是this,当前实例 public synchronized void method() { System.out.println("我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "结束"); } public static void main(String[] args) { // t1和t2对应的this是两个不同的实例,所以代码不会串行 Thread t1 = new Thread(instance1); Thread t2 = new Thread(instance2); t1.start(); t2.start(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28输出结果:
我是线程Thread-0 我是线程Thread-1 Thread-1结束 Thread-0结束
1
2
3
4实例2:
public class SynchronizedObjectLock implements Runnable { static SynchronizedObjectLock instance1 = new SynchronizedObjectLock(); static SynchronizedObjectLock instance2 = new SynchronizedObjectLock(); @Override public void run() { method(); } // synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把 public static synchronized void method() { System.out.println("我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "结束"); } public static void main(String[] args) { Thread t1 = new Thread(instance1); Thread t2 = new Thread(instance2); t1.start(); t2.start(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27输出结果:
我是线程Thread-0 Thread-0结束 我是线程Thread-1 Thread-1结束
1
2
3
4synchronized 指定锁对象为 Class 对象
public class SynchronizedObjectLock implements Runnable { static SynchronizedObjectLock instance1 = new SynchronizedObjectLock(); static SynchronizedObjectLock instance2 = new SynchronizedObjectLock(); @Override public void run() { // 所有线程需要的锁都是同一把 synchronized(SynchronizedObjectLock.class){ System.out.println("我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "结束"); } } public static void main(String[] args) { Thread t1 = new Thread(instance1); Thread t2 = new Thread(instance2); t1.start(); t2.start(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25输出结果:
我是线程Thread-0 Thread-0结束 我是线程Thread-1 Thread-1结束
1
2
3
4
# 3. synchronized 原理
# 3.1 加锁和释放锁的原理
深入JVM看字节码,创建如下的代码:
public class SynchronizedDemo {
Object object = new Object();
public void method1() {
synchronized (object) {
}
method2();
}
private static void method2() {
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
使用 javac 命令进行编译生成 .class 文件
javac SynchronizedDemo.java
使用 javap 命令反编译查看 .class 文件的信息
javap -verbose SynchronizedDemo.class
结果如下:
其中monitorenter
和mnitorexit
指令,会让对象在执行,使其锁计数器加 1 或者减 1。每一个对象在同一时间只与一个 monitor(锁)相关联,而一个 monitor 在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的 monitor 锁的所有权的时候,monitorenter
指令会发生如下 3 种情况之一:
- monitor 计数器为 0,意味着目前还没有被获得,那这个线程就会立刻获得,然后把锁计数器 +1,一旦 +1,别的线程再想获取,就需要等待
- 如果这个 monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加
- 这把锁已经被别的线程获取了,等待锁释放
monitorexit
指令:释放对于 monitor 的所有权,释放过程很简单,就是将 monitor 的计数器减 1,如果减完以后,计数器不是 0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成 0,则代表当前线程不再拥有该 monitor 的所有权,即释放锁。
下图为对象,对象监视器,同步队列以及执行线程状态之间的关系:
该图可以看出,任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败,该线程就进入同步状态,线程状态变为 BLOCKED,当 Object 的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
# 3.2 可重入原理
可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant 或 re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。
可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
看如下的例子:
public class SynchronizedDemo {
public static void main(String[] args) {
SynchronizedDemo demo = new SynchronizedDemo();
demo.method1();
}
private synchronized void method1() {
System.out.println(Thread.currentThread().getId() + ": method1()");
method2();
}
private synchronized void method2() {
System.out.println(Thread.currentThread().getId()+ ": method2()");
method3();
}
private synchronized void method3() {
System.out.println(Thread.currentThread().getId()+ ": method3()");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
结合前文中加锁和释放锁的原理,不难理解:
- 执行 monitorenter 获取锁
- (monitor 计数器 = 0,可获取锁)
- 执行 method1() 方法,monitor 计数器 +1 -> 1 (获取到锁)
- 执行 method2() 方法,monitor 计数器 +1 -> 2
- 执行 method3() 方法,monitor 计数器 +1 -> 3
- 执行monitorexit命令
- method3() 方法执行完,monitor 计数器 -1 -> 2
- method2() 方法执行完,monitor 计数器 -1 -> 1
- method2() 方法执行完,monitor 计数器 -1 -> 0 (释放了锁)
- (monitor计数器=0,锁被释放了)
这就是 synchronized 的重入性,即在同一锁程中,每个对象拥有一个 monitor 计数器,当线程获取该对象锁后,monitor 计数器就会加一,释放锁后就会将 monitor 计数器减一,线程不需要再次获取同一把锁。
# 3.3 保证可见性原理
synchronized 的 happens-before 规则,即监视器锁规则:对同一个监视器的解锁 happens-before 于对该监视器的加锁。看下面代码:
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
2
3
4
5
6
7
8
9
10
11
该代码的 happens-before 关系如图所示:
在图中每一个箭头连接的两个节点就代表之间的 happens-before 关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程 A 释放锁 happens-before 线程 B 加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来 happens-befor 关系,通过传递性规则进一步推导的 happens-before 关系。现在来重点关注 2 happens-before 5,通过这个关系我们可以得出什么?
根据 happens-before 的定义中的一条:如果 A happens-before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。线程 A 先对共享变量 a 进行加一,由 2 happens-before 5 关系可知线程 A 的执行结果对线程 B 可见即线程 B 所读取到的 a 的值为 1。
# 4. Synchronized 与 Lock
synchronized 的缺陷
- 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock 可以中断和设置超时
- 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
- 无法知道是否成功获得锁,相对而言,Lock 可以拿到状态,如果成功获取锁,....,如果获取失败,.....
Lock 解决相应问题
Lock 类这里不做过多解释,主要看里面的 4 个方法:
lock()
: 加锁unlock()
: 解锁tryLock()
: 尝试获取锁,返回一个 boolean 值tryLock(long,TimeUtil)
: 尝试获取锁,可以设置超时
synchronized 加锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition 与 Lock 的结合
解决了这个问题。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock 的 lockInterruptibly() 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像synchronized 那样产生死锁了。
# 5. 参考
- http://concurrent.redspider.group/article/02/9.html
- Java锁优化--JVM锁降级 (opens new window)
- 死磕Synchronized底层实现 (opens new window)
- 《Java并发编程的艺术》
- https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html