在 java 语言中,保证线程安全性的主要手段是加锁,而 Java 中的锁主要有两种:synchronized 和 Lock,我们今天重点来看一下 synchronized 的几种用法:
-
对象锁
- 方法锁(默认锁对象为this,当前实例对象)
- 同步代码块锁(自己指定锁对象)
-
类锁
- 修饰静态方法
- 锁对象为Class对象
synchronized的使用
在开始前,让我们先记住使用synchronized是需要注意的几点:
- 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
- 每个实例都对应有自己的一把锁(this),不同实例之间互不影响(锁对象是Class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁);
- synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁;
对象锁
方法锁形式:synchronized修饰普通方法,锁对象默认为this
public class ObjectSynchronizedUsage implements Runnable {
@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) {
ObjectSynchronizedUsage instance = new ObjectSynchronizedUsage();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
输出结果:
Thread-0开始
Thread-0结束
Thread-1开始
Thread-1结束
代码块形式:自己指定锁对象(this/自定义锁对象)
- 示例1
public class ObjectSynchronizedUsage implements Runnable {
@Override
public void run() {
//锁为this的同步代码快形式(本质与方法锁一样),两个线程使用同一把锁(this),Thread-1必须等到Thread-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) {
ObjectSynchronizedUsage instance = new ObjectSynchronizedUsage();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
输出结果:
Thread-0开始
Thread-0结束
Thread-1开始
Thread-1结束
- 示例2
public class ObjectSynchronizedUsage implements Runnable {
// 创建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) {
ObjectSynchronizedUsage instance = new ObjectSynchronizedUsage();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
输出结果:
block1锁:Thread-0开始
block1锁:Thread-0结束
block2锁:Thread-0开始 // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
block1锁:Thread-1开始
block2锁:Thread-0结束
block1锁:Thread-1结束
block2锁:Thread-1开始
block2锁:Thread-1结束
类锁
synchronized修饰静态方法
示例1:
public class ClassSynchronizedUsage implements Runnable {
@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) {
ClassSynchronizedUsage instance1 = new ClassSynchronizedUsage();
ClassSynchronizedUsage instance2 = new ClassSynchronizedUsage();
// t1和t2对应的this是两个不同的实例,所以代码不会串行
Thread t1 = new Thread(instance1);
Thread t2 = new Thread(instance2);
t1.start();
t2.start();
}
}
输出结果:
Thread-0开始
Thread-1开始
Thread-1结束
Thread-0结束
- 示例2
public class ClassSynchronizedUsage implements Runnable {
...
// 将示例1中的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() + "结束");
}
...
}
输出结果:
Thread-0开始
Thread-0结束
Thread-1开始
Thread-1结束
synchronized原理
加锁和释放锁的原理
我们从字节码的角度来看下synchronized实现细节。
从上一节我知道synchronized既可以作用于方法,也可以作用于代码块。但在实现上是有区别的。 比如如下代码,使用 synchronized 作用于代码块:
public class SynchronizedTest{
private int number;
public void test1(){
int i = 1;
synchronized(this){
number = i + 1;
}
}
}
使用javap命令反编译查看字节码:
> javap -verbose SynchronizedTest.class
得到如下信息:
可以看到,编译而成的字节码中会包含
monitorenter
和monitorexit
这两个字节码指令。
你可能已经发现了,上面的字节码中有1个monitorenter
和2个monitorexit
,这是因为虚拟机需要保证当异常发生时也能释放锁。因此2个monitorexit
一个是代码正常执行结束后释放锁,一个是在代码执行异常时释放锁。
在看下synchronized 修饰方法有哪些区别:
public class SynchronizedTest{
public synchronized void test1(){
int i = 0;
i = i + 1;
}
}
使用javap查看上面方法编译后的字节码如下:
从图中可以看出,被synchronized修饰的方法在被编译为字节码后,在方法的flags属性中会被标记为ACC_SYNCHRONIZED标志。当虚拟机访问一个被标记为ACC_SYNCHRONIZED的方法时,会自动在方法的开始和结束(或异常)位置添加
monitorenter
和monitorexit
指令。
关于monitorenter
和monitorexit
,可以理解为一把具体的锁。在这个锁中保存着两个比较重要的属性:计数器和指针。
- 计数器:记录当前线程一共访问几次这把锁;
- 指针:指向持有这把锁的线程。
用一张图表示:
我们来看下
monitorenter
和monitorexit
具体是如何工作的:
-
monitorenter
和monitorexit
指令,会让对象在执行时,使其锁计数器加1或者减1。
[图片上传中...(WechatIMG197.jpg-b015ef-1696404823622-0)] - 每一个对象在同一个时间只与一个monitor(锁)关联,而一个monitor在同一个时间只能被一个线程获得。
- 一个对象在尝试获取与这个对象关联的 monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:
- monitor计数器为0,表示当前还未被获得,此时这个线程就会立刻获得然后把锁计数器+1,一但+1,其他线程再想获取,就需要等待;
- 若这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加;
- 这把锁已经被其他线程获取了,等待锁释放。
-
monitorexit
指令:释放对monitor的所有权。释放过程就是将monitor的计数器-1,如果减完以后,计数器不为0,则表示刚才是重入进来的,当前线程还继续持有这把锁的所有;如果计数器为0,则代表当前线程不再拥有monitor所有权,即锁被释放。
下图表现了Object,Monitor,SynchronizedQueue以及Thread状态之间的关系:
从上图可以看出任意线程对Object的访问,首先要获得Object的monitor,如果获取失败,该线程就会进入同步队列中,该线程状态变为BLOCKED。当monitor持有者释放后,在同步队列中的线程才会有机会重新获取monitor,才能继续执行。
synchronized是可重入锁吗,及其实现原理?
其实在上一节中已经无意透漏出了答案——synchronized是可重入锁。
什么是可重入,可重入锁?
可重入:若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另一端代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调单个线程执行时重新进入同一个子程序仍然是安全的。(来源于维基百科)
可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
示例:
public class ChildProgram extends SuperProgram {
public static void main(String[] args) {
ChildProgram childProgram = new ChildProgram();
childProgram.childSomeSthing();
}
public synchronized void childSomeSthing (){
superDoSomeSthing();
System.out.println("child do Something");
}
@Override
public synchronized void superDoSomeSthing() {
System.out.println("child do Something");
super.superDoSomeSthing();
}
}
public class SuperProgram {
public synchronized void superDoSomeSthing (){
System.out.println("super,doing something");
}
}
执行结果:
child do Something
super,doing something
child do Something
可以看到调用的三个方法均得到了执行。我们知道synchronized修饰普通方法时,使用的是对象锁,也就是ChildProgram对象。三个方法的锁都是ChildProgram对象。我们在子类中执行childSomeSthing方法时,获取了ChildProgram对象锁,然后在childSomeString时调用了重写父类的superDoSomeSthing方法,该方法的锁也是ChildProgram对象锁,然后在其中调用父类的superDoSomeSthing方法,该方法的锁也是ChildProgram对象锁。一个锁多次请求,而且都成功了,所以synchronized是可重入锁。
synchronized可重入锁的实现原理:
结合上一节中加锁和释放锁原理,不难理解:
- 执行monitorenter获取锁:
- (monitor计数器=0,可获取锁)
- 执行childSomeSthing()方法:monitor计数器+1 -> 1(获取到锁)
- 执行子类中重写的superDoSomeSthing()方法:monitor计数器+1 -> 2
- 执行父类中superDoSomeSthing()方法:monitor计数器+1 -> 3
- 执行monitorexit命令:
- 父类中superDoSomeSthing()方法执行完成:monitor计数器-1 -> 2
- 子类中重写的superDoSomeSthing()方法执行完成:monitor计数器-1 -> 1
- childSomeSthing()方法执行完成:monitor计数器-1 -> 0(释放了锁)
- (monitor计数器=0,锁被释放了)
这就是synchronized的重入性,及在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会+1,释放锁后就会将monitor计数器-1,线程不需要再次获取同一把锁。
保证可见性的原理:synchronized的happens-before关系
synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。为进一步了解synchronized的并发语义,通过示例代码分析这条happens-before规则,示例代码如下:
public class MonitorTest{
private int a = 0;
public synchronized void writer(){//1
a++;//2
}//3
public synchronized void reader(){//4
int i = a;//5
}//6
}
在并发时,第5步操作中读取到的变量a是多少?这就需要通过happens-before规则来进行分析,示例代码的happens-before关系如下图所示:
上图中每一个箭头连接的两个节点就代表之间的happens-before关系:
- 黑色的是通过程序顺序规则推到出来。
- 红色的为监视器规则推导而出:线程A释放锁happens-before线程B加锁。
- 蓝色的则是通过传递性规则进一步推导的happens-before关系。
最终得到结论就是操作2 happens-before 5,通过这个关系我们可以得出以下:
根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见。那么在上面的代码中,线程A先对共享变量a进行+1,由2 happens-before 5关系可知线程A的执行结果对线程B可见及线程B所读取到的 a的值为1。