synchronized原理

@[TOC](synchronized 原理)

synchronized

场景

银行办理业务的时候,叫号:


多台叫号机(客户端)连接一个服务器Server,如果并发量比较大的时候会出现,可能会出现如下几种情况:
1. 重号
2. 乱号(跳号)
3. 超过最大值

我们用Java程序对可能出现的重号现象进行模拟。

场景1

首先,我们完成一个叫号机TicketTest类,类中定义每次叫到的号index和每天最多能叫号的个数MAX。
假设最大值MAX为3。
然后模拟并发,继承Thread类,重写run方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.leehao;

/**
* @author lihao
*/
public class TicketTest extends Thread {
private int index = 1;
private static final int MAX = 3;
@Override
public void run() {
while (index <= MAX) {
System.out.println(Thread.currentThread().getName() + "的编号为:" + index++);
}
}
}
JAVA

然后,我们编写启动类进行线程的调用,假设有4个并发在执行:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
TicketTest t1 = new TicketTest();
TicketTest t2 = new TicketTest();
TicketTest t3 = new TicketTest();
TicketTest t4 = new TicketTest();
t1.start();
t2.start();
t3.start();
t4.start();
}
JAVA

执行后,发现4个线程中,每一个线程都从1开始叫号,直至最大号(3):

1
2
3
4
5
6
7
8
9
10
11
12
Thread-0的编号为:1
Thread-0的编号为:2
Thread-0的编号为:3
Thread-1的编号为:1
Thread-2的编号为:1
Thread-1的编号为:2
Thread-2的编号为:2
Thread-1的编号为:3
Thread-2的编号为:3
Thread-3的编号为:1
Thread-3的编号为:2
Thread-3的编号为:3
APACHE

显然,由于变量数据index为非全局变量,每个线程中的index都是独立的,所以都是从1开始直至Max,导致叫号机的结果重号了。

场景2

我们在场景1的基础上稍作改动,将叫号的index设置为index全局共享,那么会不会继续出现重号的现象呢?

1
private static  int index = 1;
JAVA

程序多次运行后的执行结果如下:
第一次运行

1
2
3
4
5
Thread-0的编号为:1
Thread-2的编号为:1
Thread-1的编号为:1
Thread-2的编号为:2
Thread-1的编号为:3
APACHE

第二次运行

1
2
3
Thread-0的编号为:1
Thread-0的编号为:2
Thread-0的编号为:3
APACHE

第三次运行

1
2
3
4
5
Thread-0的编号为:1
Thread-0的编号为:2
Thread-1的编号为:1
Thread-0的编号为:3
Thread-2的编号为:2
APACHE

如果为了效果明显,可以将MAX的值加大。
从上述结果可以看出,在多线程并发运行下,虽然数据为全局变量,但大部分的执行结果仍然会出现跳号、重号、超过最大值等情况。这是因为多线程并发执行的情况下,各个线程之间对共享资源进行读写的时候,读写没有进行控制,数据不一致,导致存在读脏数据,重复度等现象。
假如有2个线程,把线程的执行操作分为3步:拿到index、输出打印、自增加1,我们做如下示例:
1. Thread0 拿到index=1
2. Thread1 拿到 index=1
3. Thread1 输出 index=1
4. Thread0 输出 index=1 (此时重号了)
5. Thread1 自增 inedx=2
6. Thread0 自增 index=3
7. Thread1 拿到 index=3
8. Thread0 拿到 index=3
9. Thread0 输出 index=3
10. Thread0 自增 index=4
11. Thread1 输出index=4(超过最大值了)

:由于并发,输出到控制台的先后顺序是无法预测的,所以上述的2个线程输出顺序是随机的。

场景3

我们在场景2的基础上稍作改动,将run()方法中while循环里的代码片段采用synchronized进行加锁,保证该代码片段的原子性(要么全部执行,要么都不执行)和可见性(其他线程可以知晓当前线程的执行情况,来判断是否进行资源的抢占)。
其中:synchronized (this)中的this表征在该对象内,对该代码片段进行加锁。

1
2
3
4
5
6
7
8
@Override
public void run() {
while (index <= MAX) {
synchronized (this) {
System.out.println(Thread.currentThread().getName() + "的编号为:" + index++);
}
}
}
JAVA

当MAX=3的时候,结果如下:
第一次运行

1
2
3
Thread-1的编号为:1
Thread-2的编号为:3
Thread-0的编号为:2
APACHE

第二次运行

1
2
3
Thread-0的编号为:1
Thread-0的编号为:2
Thread-0的编号为:3
APACHE

第三次运行

1
2
3
Thread-0的编号为:1
Thread-0的编号为:3
Thread-1的编号为:2
APACHE

当MAX=10的时候,运行结果如下:

1
2
3
4
5
6
7
8
9
10
Thread-1的编号为:2
Thread-2的编号为:3
Thread-0的编号为:1
Thread-0的编号为:6
Thread-2的编号为:5
Thread-1的编号为:4
Thread-2的编号为:8
Thread-2的编号为:10
Thread-0的编号为:7
Thread-1的编号为:9
APACHE
1
2
3
4
5
6
7
8
9
10
Thread-0的编号为:2
Thread-0的编号为:3
Thread-0的编号为:4
Thread-2的编号为:5
Thread-1的编号为:1
Thread-1的编号为:8
Thread-1的编号为:9
Thread-1的编号为:10
Thread-2的编号为:7
Thread-0的编号为:6
APACHE

我们可发现,无论并发多少,运行多少次,都不会出现之前的重号、超出最大号的现象。

synchronized的用法

根据修饰对象分类

同步方法

同步静态方法

对静态方法进行synchronized同步,我们可以在静态方法的修饰符中加入synchronized即可。
我们加入方法执行的开始时间和方法执行的结束时间,来明确每个线程的执行情况。
为了和不采用synchronized进行对比,我们分别进行测试。
1)不引入synchronized

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
package com.leehao;

import java.text.SimpleDateFormat;
import java.util.Date;

/**
* @author lihao
*/
public class SynchronizedDemo001 {
public static void methodA() {
try {
System.out.println(Thread.currentThread().getName()+"开始时间:"+new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName()+"结束时间:"+new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(SynchronizedDemo001::methodA).start();
}
}
}
JAVA

运行后结果:

1
2
3
4
5
6
Thread-0开始时间:20-03-21 17:26:27
Thread-2开始时间:20-03-21 17:26:27
Thread-1开始时间:20-03-21 17:26:27
Thread-0结束时间:20-03-21 17:26:29
Thread-2结束时间:20-03-21 17:26:29
Thread-1结束时间:20-03-21 17:26:29
APACHE

可以看出,线程0、1、2同时在17:26:27开始执行该方法,2秒后17:26:29执行结束。该方法在并发执行的过程中,并没有被独占,数据未同步。
2)引入synchronized
只需在方法的定义中加入修饰符synchronized

1
public  synchronized static void methodA() {
JAVA

运行后结果:

1
2
3
4
5
6
Thread-0开始时间:20-03-21 17:30:10
Thread-0结束时间:20-03-21 17:30:12
Thread-2开始时间:20-03-21 17:30:12
Thread-2结束时间:20-03-21 17:30:14
Thread-1开始时间:20-03-21 17:30:14
Thread-1结束时间:20-03-21 17:30:16
APACHE

可以发现,线程0首先执行,线程1和线程2处于等待状态;线程0执行完成后,线程2抢占到资源进行执行,线程1处于等待状态;线程2执行完成后,线程1拿到资源进行执行。整个并发过程中,该方法处于独占,即同时只有一个线程拥有该方法的使用权,这样就保证了数据的一致性。

同步非静态方法

同步非静态方法的功能和使用方法和上述的同步静态方法是一样的。具体代码如下:

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
package com.leehao;

import java.text.SimpleDateFormat;
import java.util.Date;

/**
* @author lihao
*/
public class SynchronizedDemo001 {

public synchronized void methodStaticB() {
try {
System.out.println(Thread.currentThread().getName() + "开始时间:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + "结束时间:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}


public static void main(String[] args) {
SynchronizedDemo001 demo001 = new SynchronizedDemo001();
for (int i = 0; i < 3; i++) {
new Thread(demo001::methodStaticB).start();
}
}
}
JAVA

同步代码块

代码块的同步同上述的使用方式和功能是一致的,在需要同步的代码段上加入synchronized () {)
根据获取的锁分类,可分为如下2类:

获取对象锁

synchronized(this|object) {}
修饰非静态方法
在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。
如下所示:

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
package com.leehao;

import java.text.SimpleDateFormat;
import java.util.Date;

/**
* @author lihao
*/
public class SynchronizedDemo001 {

public void methodStatic() {
//代码段同步
//有Class对象的所有的对象都共同使用这一个锁
synchronized (this) {
try {
System.out.println(Thread.currentThread().getName() + "开始时间:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + "结束时间:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

public static void main(String[] args) {
SynchronizedDemo001 demo001 = new SynchronizedDemo001();
for (int i = 0; i < 3; i++) {
new Thread(demo001::methodStatic).start();
}
}
}
JAVA

获取类锁

synchronized(类.class) {}
修饰静态方法
在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。
如下所示:

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
package com.leehao;

import java.text.SimpleDateFormat;
import java.util.Date;

/**
* @author lihao
*/
public class SynchronizedDemo001 {

public static void methodStatic() {
//代码段同步
//有Class对象的所有的对象都共同使用这一个锁
synchronized (SynchronizedDemo001.class) {
try {
System.out.println(Thread.currentThread().getName() + "开始时间:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + "结束时间:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(SynchronizedDemo001::methodStatic).start();
}
}
}
JAVA

Java对象 monitor

在 Java 中,每个对象都会有一个 monitor 对象,监视器。
1)某一线程占有这个对象的时候,先查看monitor 的计数器是不是0,如果是0表示还没有线程占有,这个时候线程占有这个对象,并且对这个对象的monitor+1;如果不为0,表示这个线程已经被其他线程占有,这个线程等待。当线程释放占有权的时候,monitor-1;
同一线程可以对同一对象进行多次加锁。

synchronized原理分析

线程堆栈分析

Jconsole,是Java的线程堆栈监视器,程序位于Java的安装目录中。
上述示例,我们把sleep的时间改为2分钟,运行Java程序后,再运行Jconsole来查看Jconsole中的堆栈监控信息。
线程0:

线程1:

线程2:


线程0执行完成后:

1
2
3
Thread-0开始时间:20-03-21 19:44:27
Thread-0结束时间:20-03-21 19:48:27
Thread-2开始时间:20-03-21 19:48:27
APACHE

线程2开始执行,此时线程0执行完成释放,线程2拿到资源,处于TIMED_WAITING:


线程1处于阻塞状态:

切换到VM概要之中,可以查看到程序运行的进程号PID(此处为:11820)

采用另外一个强大的工具Jstack,可以查看线程运行的信息,用法 jstack pid

目前,线程0和线程2已执行完成,只剩下线程1,正在执行,所以线程1的状态为TIMED_WAITING。

从上述堆栈分析可得知,synchronized确实达到了线程之间的排他性(互斥)。

JVM指令分析synchronized的实现

我们将上述程序编译后的class文件进行反编译,此处的反编译不是编译为Java文件,而是反编译为JVM
指令,具体的操作是采用Java自带的Javap程序,使用方式为:javap -vclassName。

代码块加锁的指令分析


回车后,其中部分指定如下:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public static void methodStatic();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=5, locals=3, args_size=0
0: ldc #2 // class com/leehao/SynchronizedDemo001
2: dup
3: astore_0
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: new #4 // class java/lang/StringBuilder
11: dup
12: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
15: invokestatic #6 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
18: invokevirtual #7 // Method java/lang/Thread.getName:()Ljava/lang/String;
21: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: ldc #9 // String 开始时间:
26: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
29: new #10 // class java/text/SimpleDateFormat
32: dup
33: ldc #11 // String yy-MM-dd HH:mm:ss
35: invokespecial #12 // Method java/text/SimpleDateFormat."<init>":(Ljava/lang/String;)V
38: new #13 // class java/util/Date
41: dup
42: invokespecial #14 // Method java/util/Date."<init>":()V
45: invokevirtual #15 // Method java/text/SimpleDateFormat.format:(Ljava/util/Date;)Ljava/lang/String;
48: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
51: invokevirtual #16 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
54: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
57: ldc2_w #18 // long 2000l
60: invokestatic #20 // Method java/lang/Thread.sleep:(J)V
63: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
66: new #4 // class java/lang/StringBuilder
69: dup
70: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
73: invokestatic #6 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
76: invokevirtual #7 // Method java/lang/Thread.getName:()Ljava/lang/String;
79: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
82: ldc #21 // String 结束时间:
84: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
87: new #10 // class java/text/SimpleDateFormat
90: dup
91: ldc #11 // String yy-MM-dd HH:mm:ss
93: invokespecial #12 // Method java/text/SimpleDateFormat."<init>":(Ljava/lang/String;)V
96: new #13 // class java/util/Date
99: dup
100: invokespecial #14 // Method java/util/Date."<init>":()V
103: invokevirtual #15 // Method java/text/SimpleDateFormat.format:(Ljava/util/Date;)Ljava/lang/String;
106: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
109: invokevirtual #16 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
112: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
115: goto 123
118: astore_1
119: aload_1
120: invokevirtual #23 // Method java/lang/InterruptedException.printStackTrace:()V
123: aload_0
124: monitorexit
125: goto 133
128: astore_2
129: aload_0
130: monitorexit
131: aload_2
132: athrow
133: return
YAML

注意上述反编译后的如下2个指令:

  • Monitorenter为互斥入门(1个)
  • Monitorexit为互斥出口(2个)

上述4为互斥入口,表征该方法的加锁开始。
124和130互斥出口,为什么会有2个互斥出口呢,一个为程序正常运行的互斥出口,另外一个则为程序异常的时候互斥出口,所以一般入口为1个,出口为2个。
在互斥入口和互斥出口的中间部分则为lock加锁的指令。

以上是代码块的加锁,monitorenter、monitorexit配合使用。

方法加锁的指令分析

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
public static synchronized void methodStatic();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=5, locals=1, args_size=0
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: invokestatic #5 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
13: invokevirtual #6 // Method java/lang/Thread.getName:()Ljava/lang/String;
16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: ldc #8 // String 开始时间:
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: new #9 // class java/text/SimpleDateFormat
27: dup
28: ldc #10 // String yy-MM-dd HH:mm:ss
30: invokespecial #11 // Method java/text/SimpleDateFormat."<init>":(Ljava/lang/String;)V
33: new #12 // class java/util/Date
36: dup
37: invokespecial #13 // Method java/util/Date."<init>":()V
40: invokevirtual #14 // Method java/text/SimpleDateFormat.format:(Ljava/util/Date;)Ljava/lang/String;
...
...
...
...
LASSO

其中,对方法的加锁,在方法的头部有个flag标识为,如果标记为ACC_SYNCHRONIZED,那么表征该方法为加锁方法。

synchronized的优化

在JDK1.6以前,SYNCHRONIZED 为重量级锁,独占资源后,其他资源一直等待直至释放,导致其他程序的等待时间特别长。
在1.6之后,Java虚拟机对SYNCHRONIZED 进行了优化,加入了:偏向锁、 轻量级锁来进行锁的高效利用。
一个对象实例包含:对象头、实例变量、填充数据,而其中对象头是加锁的基础。
对象头:2个字=4个字节=32位:


其中:

  • 无锁状态:没有加锁
  • 偏向锁:在对象第一次被某一线程占有的时候,是否偏向锁置1,锁表01,写入线程号,当其他的线程访问的时候,去竞争,竞争结果有2种:成功或失败。
    1. 如果成功,那么大部分情况下被第一次占有它的线程获取的次数最多。
    2. 如果竞争失败,那么会升级锁,升级到轻量级锁。
  • CAS算法 campany and set(CAS)
    无锁状态时间非常接近,竞争不激烈的时候适用
    如果CAS竞争失败,那么升级到轻量级锁,锁标志位为00
  • 轻量级锁:线程有交替适用,互斥性不是很强,
  • 重量级锁:是一种强互斥,各个线程之间等待时间长,锁标志位为10
  • 自旋锁:如果竞争失败的时候,不是马上转化级别(升级到高一级的锁:偏向锁->轻量级锁->重量级锁的顺序),而是执行几次空循环继续获取锁(延时)
  • 锁消除:JIT在编译的时候,把不必要的锁给去掉,比如某些代码端或者方法,没有必要去加锁,那么就会把锁给去掉,如下代码段:
    1
    2
    3
    synchronized (this){
    int i = 10;
    }
    JAVA

int i = 10;本身具有原子性,不需要进行加锁操作。虽然我们在代码中加入了锁,但是在JIT编译的过程中,会识别到不必要的加锁情况,并给去掉。


synchronized原理
https://leehoward.cn/2020/04/04/synchronized原理/
作者
lihao
发布于
2020年4月4日
许可协议