【Java并发编程】Synchronized关键字实现原理
它的主要特性是同步锁、非公平锁、阻塞锁、可以保证线程安全(可见性、原子性、有序性)
JDK1.6之后对Synchronized有优化,有个锁升级过程
1、Synchronized之保障线程安全
多线程情况下保障线程安全的方法有很多,一般都是通过加锁去竞争同一个资源,来达到互斥的效果,那么Synchronized是如何保障线程安全的呢
1.1、原子性
它的主要含义是要么全部成功要么全部失败,不允许部分成功部分失败,多线程中原子性是指一个或者多个操作在CPU中执行的过程中出现了被中断的情况
原子性产生的原因主要是有两个:
- CPU时间切换
CPU处于空闲状态就会把时间片分配给其他线程进行处理,有两个线程对变量进行修改,会有一个A、线程先得到CPU的执行权,它将变量加载到寄存器后CPU切换为另一个B线程执行,B线程同样加载变量到寄存器,最后把结果写回内存,这时候两个变量值可能会一致 - 程序本身执行不具备原子性
这个可以用常见的i++来说明,i++本身不具备原子性,因为它分为了三个操作,先获取值,加一,赋值。这里每一步都是原子性,可是组合在一起就不具备原子性
解决原子性的办法有两个
- 通过一个互斥条件来达到同时一刻只有一个线程执行
- 使操作具有原子性,不允许执行过程被中断
为了保证原子性可以在方法上加上Synchronized
关键字
1.2、可见性
1.2.1、为什么会存在可见性问题?
1.2.1.1、高速缓存
它的本质是因为,CPU是计算机的核心,它在做运算的时候无法避免从内存中读取数据和指令,即使存储在磁盘的数据也要加载到内存中CPU才能访问,CPU与内存之间无法避免IO操作。CPU向内存发起读取操作,需要等待内存返回结果,此时CPU处于等待状态,如果等待返回之后CPU再执行其他指令会浪费CPU资源。因此在硬件、操作系统、编译器都做了不少优化,正因为这些优化导致出现了可见性问题
例如加入了CPU高速缓存,高速的缓存的作用就是CPU在读取数据的时候会先从高速缓存中读取,如果高速缓存中没有就会从内存中读取
高速缓存又分为三部分:L1、L2、L3
每块CPU里有多个内核,而每个内核都维护了自己的缓存
L1和2属于CPU核内私有缓存,L3属于共享缓存,三块缓存从存储的数据大小排序来说L3>L2>L1,从访问速度来说L1>L2>L3
访问数据从L1中开始查找,然后是L2,最后访问L3如果还没有命中就会从内存中加载数据,加载数据会从L3到L2最后到L1
1.2.2、缓存一致性问题
虽然有高速缓存提高了访问速度,但是一个CPU有多核,每一个线程可能运行在不同的CPU核内,如果多个线程同时访问数据,那么同一份数据就有可能被缓存到多个CPU核内,就会存在缓存一致性问题:两个线程同时加载一块数据到CPU高速缓存中时,如何保证一个数据被修改后在其他缓存中的值也能保持一致,而不是获取到的初始值。CPU解决这个问题使用到了
1.2.2.1、总线锁
操作系统使用总线锁可以解决这个缓存一致性问题,它的原理就是在CPU与内存传输的通道上加了一个LOCK信号,这个信号确保同一时刻只有当前CPU才能访问共享内存,使得其他处理器对内存的操作请求都会被阻塞,但是这样又会使CPU的使用效率下降。
1.2.2.2、缓存锁
为了CPU使用效率下降解决这个问题,引入了缓存锁,当数据已经存在高速缓存中的某个CPU核内私有区域,不使用总线锁而使用缓存一致性解决问题
1.2.2.3、缓存一致性
缓存锁就是通过缓存一致性协议来保证一致性的,不同的CPU支持的缓存一致性协议不同,比较常见就是MSI
、MESI
、MOSI
、MESIF
,最常用的就是MESI
(Modify Exclusive Shared Invalid),它表示四种状态:
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
这四种状态会基于缓存行的状态而变化, 不同的状态会有不同的监听任务
- 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回内存
- 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I
- 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S
监听过程使基于嗅探协议完成的,该协议要求每个CPU都可以监听到总线上的数据事件变化并作出反应,这个缓存一致性原理就是
- 首先CPU0发送一个指令从主内存中读取x变量,然后加载到了高速缓存中,这时候缓存的状态为E
- 如果CPU1这时候也要读取x变量的值,就会检测到本地含有该缓存发生冲突,CPU0会通过嗅探协议里面的Read Response消息响应给CPU1,这时候x变量存在于CPU0和CPU1中,缓存的状态变成了S
- CPU0拿到x变量的值以后进行修改为x=20,写入主内存中,这时候缓存的状态变为了E,缓存行变为共享状态,同时还需要发送一个Invalidate消息给其他缓存,其他缓存CPU1收到后缓存状态变为Invaild,CPU1里面的x变量值缓存失效,需要从主内存中重新获取值
这个就是基于缓存一致性保证缓存的一致性原理
synchronized
就是基于该原理,对进入同一个锁(监视器)的线程保证可见性,在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性
1.2.3、Happens-Before
在JMM内存模型中还定义了一个Happens-Before
模型用来保证可见性,这个模型主要描述的就是两个指令操作之间的关系,如果 A happens-before B,意思就是A发生在B之前,那么A的结果对B可见,它主要有如下常见6种规则。
- 程序顺序规则:一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作
- 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C
- Volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
- start规则:这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
我们只需要理解Happens-Before规则,既可以编写线程安全的程序了
1.3、有序性
CPU为了提升性能会对编译器、处理器以及代码指令重排序,这种排序在单线程下没问题结果不会受到影响,但如果是多线程操作下就不一定了,可能出现脏数据
就拿最容易复原的指令重排序如何影响程序执行的结果
int x=0; int y=0; void handleA(){ int x=10; int y=20; } void handleB(){ if(y==20){ assert(x==10); } }
指令重排序就是程序的执行顺序和代码的编写顺序不一定一致,两个线程同时执行可能会出现在handleB方法里面y== 20的情况,但是x==10断言失败,出现这种情况的原因是因为在执行handleA
方法的时候,因为x、y没有依赖关系,有可能先赋值y=20,这时候刚好handleB()
方法判断成功,而x这时候还没有赋值,导致断言失败,这就是多线程环境下的重排序问题,也会导致可见性问题
1.3.1、as-if-serial语义
它表示所有的程序指令都可以因为优化而被重排序,但是要保证在单线程环境下,重排序之后的运行结果和程序代码本身的执行结果一致,CPU指令重排序、Java编译器都需要保证在单线程环境下as-if-serial语义是正确的。存在依赖关系的不会被排序
int x=10; //1 int y=20; //2 int c=x+y; //3
按照正常执行顺序就是1、2、3,经过重排序之后可能是2、1、3,但绝对不会是3、2、1,因为as-if-serial语义可以保证排序后和之前结果一致
synchronized
能够保证有序性的是因为单线程独占CPU,根据as-if-serial语义,无论编译器和处理器怎么优化或指令重排,单线程下的运行结果一定是正确的。
2、Synchronized原理
Synchronized有两种加锁方式:修饰方法、代码块,这两种方式实现的底层有些不同,但同样的是monitor和对象头是实现Synchronized的关键
2.1、Synchronized修饰方法
public class Teacher { public static int i = 0; public static synchronized void syncLock() { i++; } }
通过Java- v Teacher.class
反编译之后
发现这个方法有三个标识,其中比较醒目的就是ACC_SYNCHRONIZED
,它是用来标记当前方法为同步方法
2.2、Synchronized修饰代码块
public class Teacher { public static int i = 0; public static void main(String[] args) { synchronized (Teacher.class){ } } }
通过Java- v Teacher.class
反编译之后
发现会在同步块的前后分别形成monitorenter
和monitorexit
这两个指令包裹起来,后面还多一个monitorexit,这个作用是防止代码块里面有异常无法释放锁,所有会用第二个monitorexit指令来保证释放,这两个指令都是属于Monitor
对象,底层是C ++的ObjectMonitor实现,也是依赖于操作系统的mutex lock
来实现的。
倘若线程获取不到锁,通过一定次数的自旋最后阻塞升级为重量级锁,未抢占到锁的线程进入等待队列,那么这里又是如何实现的呢,参考ObjectMonitor的实现
2.3、ObjectMonitor源码
ObjectMonitor::ObjectMonitor() { _header = NULL; _count = 0; _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
里面主要参数是:
WaitSet
:阻塞后等待唤醒的队列,为双向循环链表EntryList
:准备获取锁的线程队列owner
:标识拥有锁的线程count
:线程重入次数
1、首先线程会进入EntryList,然后尝试获取Monitor对象,获取成功后把owner标记为当前线程,然后count次数+1,执行完毕后会释放Monitor对象,并把owner设置为null,然后count减1,只有当count等于0才能够获取到锁
2、假如线程进入EntryList后获取Monitor失败,就会进入WaitSet的尾部节点中,等待Monitor对象释放后,会根据操作唤醒一个或全部线程进入EntryList中,处于EntryList中的线程都会抢占锁
Monitor
对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),它也被称之为“监视器锁”,这就是为什么任意对象都可以作为锁的原因
3、Synchronized锁对象
3.1、Java对象内存布局
一个对象初始化之后会被存储在堆内存中,一个对象又分为三部分:对象头、实例数据、对其填充
对象头
其中对象头又分为三部分
Mark Word
:记录了对象和锁相关的信息,主要包含了GC分代年龄、锁的状态标记、HashCode、epoch等等信息Klass Pointer
:代表指向类的指针,通过指针来找到具体的实例Length
:表示数组的长度,只有数组对象才会有这个属性值
实例数据
实例数据表示一个类里面所有的成员的变量
public class Student{ int age=0; boolean state=false; }
例如这些成员变量就存储在实例数据里面,实例数据占用的空间是由成员变量的类型决定的比如int占4个字节
对齐填充
对齐填充没有什么实际含义,主要是使得当前实例变量占用空间是8的倍数,这样做的目的是为了减少CPU访问内存的频率,为什么会频繁的访问内存?这个问题涉及到了高速缓存中的缓存行,CPU每次从内存加载8个字节的的数据到缓存行中,也意味着高速缓存存储的是连续的数据,每个缓存行大小是64位,意思如果是一个8个字节的变量,需要存储8个才能把该缓存行占满
但是存在这样一种情况,两个线程同时去读取该缓存上不同的值Long2、Long8,就会使同时缓存该缓存行,为了保证缓存一致性就会使一部分缓存失效,导致一个线程需要重新去获取,重新加载到缓存行,如果线程访问频繁就会使缓存反复失效,形成伪共享问题,为了减少CPU访问内存的频率,那么必须要变量不在于同一缓存行中,使用对其填充使两个变量分开,在一个变量前后填充7个填充变量,就可以使两个值分布于不同缓存行,比如在Long2前后填充七个
之所以要做前后填充是为了使无论Long2处于什么位置都可以保证它处于不同的缓存行,避免出现伪共享问题
还有一种作用,假如需要读取Long类型的数据的时候,它分布在两个缓存行中,如果没有对其填充需要读取缓存行A和缓存行B才可以获得真正的数据
使用对其填充之后,在缓存行B中可以直接读取到全部数据,减少了CPU访问次数
在对齐填充的布局中,虽然做了无效填充,但是访问内存次数少了,本质上来说是一种时间换空间的设计方式。
一个类对象在JVM中对象存储的布局
public class Student(){ private String name; public static void main(String[] args) { Student stu=new Student(); System.out.println(ClassLayout.parseInstance(stu).toPrintable()); } }
使用ClassLayout查看对象布局
OFFSET
:偏移地址SIZE
:占用内存大小TYPE DESCRIPTION
:类型描述value
:内存中存储的值
对象字段代表为:
- 对象头:
TYPE DESCRIPTION
中(object header)
类型的,总共3个占用12字节的内存,前两行SIZE加起来为8字节的代表Mark Word
,第三行4字节的代表类型指针Klass Pointer,不压缩会占用8个字节 - 对齐填充:
TYPE DESCRIPTION
中((loss due to the next object alignment))
类型的,本身4个字节,填充了12个字节总共16字节,主要为了保证是8的倍数 - 实例数据:
Instance size
,总共16个字节
3.2、Java锁结构信息
Java锁包含在对象头里面,Mark Word中记录了对象和锁的信息,锁的标记和相关信息都存储在里面
锁状态 | 偏向锁标记 | 锁标记 |
无锁 | 0 | 01 |
偏向锁 | 1 | 01 |
轻量级锁 | 00 | |
重量级锁 | 10 | |
GC标记 | 11 |
Mark Word使用2bit来存储锁的标记,也就是两位数最多只能存储4个数:00、01、10、11。而锁的状态有五种,超出一种就多使用了1个bit的偏向锁来表达。