@[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;
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;
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;
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;
public class SynchronizedDemo001 {
public void methodStatic() { 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;
public class SynchronizedDemo001 {
public static void methodStatic() { 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:
线程0
线程1:
线程1
线程2:
线程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:
线程2
线程1处于阻塞状态:
线程1
切换到VM概要之中,可以查看到程序运行的进程号PID(此处为:11820)
VM
采用另外一个强大的工具Jstack,可以查看线程运行的信息,用法 jstack pid
jstack
目前,线程0和线程2已执行完成,只剩下线程1,正在执行,所以线程1的状态为TIMED_WAITING。
在这里插入图片描述
从上述堆栈分析可得知,synchronized确实达到了线程之间的排他性(互斥)。
JVM指令分析synchronized的实现
我们将上述程序编译后的class文件进行反编译,此处的反编译不是编译为Java文件,而是反编译为JVM
指令,具体的操作是采用Java自带的Javap程序,使用方式为:javap -v
className。
代码块加锁的指令分析
javap
回车后,其中部分指定如下:
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: dup 3: astore_0 4: monitorenter 5: getstatic 8: new 11: dup 12: invokespecial 15: invokestatic 18: invokevirtual 21: invokevirtual 24: ldc 26: invokevirtual 29: new 32: dup 33: ldc 35: invokespecial 38: new 41: dup 42: invokespecial 45: invokevirtual 48: invokevirtual 51: invokevirtual 54: invokevirtual 57: ldc2_w 60: invokestatic 63: getstatic 66: new 69: dup 70: invokespecial 73: invokestatic 76: invokevirtual 79: invokevirtual 82: ldc 84: invokevirtual 87: new 90: dup 91: ldc 93: invokespecial 96: new 99: dup 100: invokespecial 103: invokevirtual 106: invokevirtual 109: invokevirtual 112: invokevirtual 115: goto 123 118: astore_1 119: aload_1 120: invokevirtual 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 3: new #3 6: dup 7: invokespecial #4 10: invokestatic #5 13: invokevirtual #6 16: invokevirtual #7 19: ldc #8 21: invokevirtual #7 24: new #9 27: dup 28: ldc #10 30: invokespecial #11 33: new #12 36: dup 37: invokespecial #13 40: invokevirtual #14 ... ... ... ...
LASSO
|
其中,对方法的加锁,在方法的头部有个flag标识为,如果标记为ACC_SYNCHRONIZED
,那么表征该方法为加锁方法。
synchronized的优化
在JDK1.6以前,SYNCHRONIZED 为重量级锁,独占资源后,其他资源一直等待直至释放,导致其他程序的等待时间特别长。
在1.6之后,Java虚拟机对SYNCHRONIZED 进行了优化,加入了:偏向锁、 轻量级锁来进行锁的高效利用。
一个对象实例包含:对象头、实例变量、填充数据,而其中对象头是加锁的基础。
对象头:2个字=4个字节=32位:
对象头
其中:
- 无锁状态:没有加锁
- 偏向锁:在对象第一次被某一线程占有的时候,是否偏向锁置1,锁表01,写入线程号,当其他的线程访问的时候,去竞争,竞争结果有2种:成功或失败。
- 如果成功,那么大部分情况下被第一次占有它的线程获取的次数最多。
- 如果竞争失败,那么会升级锁,升级到轻量级锁。
- CAS算法 campany and set(CAS)
无锁状态时间非常接近,竞争不激烈的时候适用
如果CAS竞争失败,那么升级到轻量级锁,锁标志位为00
- 轻量级锁:线程有交替适用,互斥性不是很强,
- 重量级锁:是一种强互斥,各个线程之间等待时间长,锁标志位为10
- 自旋锁:如果竞争失败的时候,不是马上转化级别(升级到高一级的锁:偏向锁->轻量级锁->重量级锁的顺序),而是执行几次空循环继续获取锁(延时)
- 锁消除:JIT在编译的时候,把不必要的锁给去掉,比如某些代码端或者方法,没有必要去加锁,那么就会把锁给去掉,如下代码段:
1 2 3
| synchronized (this){ int i = 10; }
JAVA
|
int i = 10;
本身具有原子性,不需要进行加锁操作。虽然我们在代码中加入了锁,但是在JIT编译的过程中,会识别到不必要的加锁情况,并给去掉。