博主主页:爪哇贡尘拾Miraitow
创作时间:2022年2月18日 15:41
内容介绍: Synchronized详解
参考资料:黑马程序员JUC
⏳简言以励:列位看官,且将新火试新茶,诗酒趁年华
内容较多有问题希望能够不吝赐教
欢迎点赞 收藏 ⭐留言
(1)原子性
:所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
。被synchronized
修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
(2)可见性
:可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。 synchronized
和volatile
(后面文章会讲到)都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存
当中(也就是主内存,而不是线程的副本
),保证资源变量的可见性。
(3)有序性
:有序性值程序执行的顺序按照代码先后执行。 synchronized
和volatile
都具有有序性,Java允许编译器和处理器对指令进行重排(JIT
),但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性
。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
补充:
volatile可以保证可见性和顺序性,这些都很好理解,那么它为什么不能保证原子性呢?
首先需要了解的是,Java中只有对基本类型变量的赋值和读取是原子操作,如i = 1的赋值操作,但是像j =i或者i++这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取i的值,再将i的值赋值给j,两个原子操作加起来就不是原子操作了。 所以,如果一个变量被volatile修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。
以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象
类对象:
数组对象
其中 Mark Word 结构为
对象头包含两部分:运行时元数据(Mark Word
)和类型指针 (Klass Word
)
1、运行时元数据:
哈希值
(HashCode),可以看作是堆中对象的地址GC分代年龄
(年龄计数器) (用于新生代from/to区晋升老年代的标准, 阈值为15,之所以为15是因为占用四个字节,最大为15)锁状态标志
(用于JDK1.6对synchronized的优化 -> 轻量级锁(00))线程持有的锁
用Thread.holdsLock(lockObj) 获取判断是否当前线程持有锁偏向线程ID
(用于JDK1.6对synchronized的优化 -> 偏向锁)偏向时间戳
类型指针
2. 指向类元数据InstanceKlass,确定该对象所属的类型。指向的其实是方法区中存放的类元信息
多线程同时访问临界区:
使用重量级锁
JDK6对Synchronized的优先状态:
偏向锁–>轻量级锁–>重量级锁
Monitor被翻译为监视器
或者说管程
每个java对象都可以关联一个Monitor,如果使用synchronized
给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针
每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁 |
简单描述:
Monitor
中的Owner
为null
Thread-2
执行synchronized(obj){}
代码时就会将Monitor
的所有者Owner 设置为 Thread-2
,上锁成功,Monitor中同一时刻只能有一个Owner
Thread-2
占据锁时,如果线程Thread-3,Thread-4,Thread-5
也来执行synchronized(obj){}代码
,就会进入EntryList
中变成BLOCKED状态
Thread-2
执行完同步代码块的内容,然后唤醒 EntryList
中等待的线程来竞争锁
,竞争时是非公平
的WaitSet
中的条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析注意:synchronized 必须是进入同一个对象的 monitor 才有上述的效果不加 synchronized 的对象不会关联监视器,不遵从以上规则
详细描述:
当Thread2
访问到synchronized(obj)
中的共享资源
的时候
synchronized
中的锁对象中对象头的MarkWord
去尝试指向操作系统的Monitor对象
. 让锁对象中的MarkWord和Monitor对象相关联
. 如果关联成功
, 将obj对象头中的MarkWord
的对象状态从01(无锁)
改为10(重量级)
。Monitor
没有和其他的obj的MarkWord相关联
, 所以Thread2
就成为了该Monitor的Owner(所有者)
。Thread1
执行synchronized(obj)代码
, 它首先会看看能不能执行该临界区的代码; 它会检查obj是否关联了Montior
, 此时已经有关联了, 它就会去看看该Montior有没有所有者(Owner)
, 发现有所有者了(Thread2
); Thread1也会和该Monitor关联, 该线程就会进入到它的EntryList(阻塞队列)
;Thread2
执行完临界区代码后, Monitor的Owner(所有者)就空出来了
. 此时就会通知Monitor中的EntryList阻塞队列中的线程
, 这些线程通过竞争, 成为新的所有者
static final Object lock=new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
反编译后的部分字节码
0 getstatic #2
# 取得lock的引用(synchronized开始了)
3 dup
# 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
4 astore_1
# 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
5 monitorenter
# 将lock对象的Mark Word置为指向Monitor指针
6 getstatic #3
9 iconst_1
10 iadd
11 putstatic #3
14 aload_1
# 从局部变量表中取得lock的引用,放入操作数栈栈顶
15 monitorexit
# 将lock对象的Mark Word重置,唤醒EntryList
16 goto 24 (+8)
# 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return
注意:方法级别的 synchronized 不会在字节码指令中有所体现
具体原理可以看我之前记录的文章
synchronized底层原理
在JDK1.6以后引入偏向锁和轻量级锁在JVM层面实现加锁的逻辑,不依赖底层的操作系统,就不会产生切换的消耗(
之前的直接加入重量级锁,就会导致后来的线程进入阻塞,所以会导致上下文切换,涉及到操作系统从用户态转到核心态
),所以,Markword对的状态记录有四种
分别是:无锁
,偏向锁
,轻量级锁
,重量级锁
通过锁记录的方式, 场景 :
多个线程交替进入临界区
轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的)
,那么可以使用轻量级锁
来进行优化。轻量级锁对使用者是透明的
,即语法仍然是 synchronized
eg:
线程A来操作临界区的资源, 给资源加锁,到执行完临界区代码,释放锁的过程, 没有线程来竞争, 此时就可以使用轻量级锁
; 如果这期间有线程来竞争的话, 就会升级为重量级锁(synchronized)
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
(Lock Record)
对象,每个线程都会包括一个锁记录的结构
,锁记录内部可以储存对象的Mark Word
和对象引用reference
2. 让锁记录中的Object reference
指向对象,并且尝试用cas(compare and sweep)
替换Object对象的Mark Word ,将Mark Word 的值存入锁记录
中
① 如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态00,如下所示
锁记录
, 记录了锁对象的锁状态标志
; 锁对象的对象头
中存储了锁记录的地址和状态
, 标志哪个线程获得了锁栈帧中存储了对象的对象头中的锁状态标志,年龄计数器,哈希值等
; 对象的对象头中就存储了栈帧中锁记录的地址和状态00
, 这样的话对象就知道了是哪个线程锁住自己。②如果cas失败,有两种情况(①锁膨胀,②重入锁失败)
①锁膨胀:
如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段,此时对象Object对象头中已经存储了别的线程的锁记录地址 00,指向了其他线程
;②重入锁失败:
如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条 Lock Record
作为重入的计数,在上面代码中,临界区中又调用了method2, method2
中又进行了一次synchronized加锁操作, 此时就会在虚拟机栈中再开辟一个method2
方法对应的栈帧(栈顶),
该栈帧中又会存在一个独立的Lock Record
, 此时它发现对象的对象头中指向的就是自己线程中栈帧的锁记录
; 加锁也就失败了.
这种现象就叫做 锁重入
; 线程中有多少个锁记录, 就能表明该线程对这个对象加了几次锁 (锁重入计数
)当线程退出synchronized代码块
的时候,如果获取的是取值为null
的锁记录 ,表示有锁重入
,这时重置锁记录,表示重入计数减一
当线程退出synchronized代码块
的时候,如果获取的锁记录取值不为 null
,那么使用cas
将Mark Word的值恢复给对象
轻量级锁进行了锁膨胀
或已经升级为重量级锁
,进入重量级锁解锁流程
如果在尝试加轻量级锁的过程中,cas
操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁
,这是就要进行锁膨胀,将轻量级锁变成重量级锁
。
轻量级锁没有阻塞队列的概念
, 即为对象申请Monitor锁
,让Object指向重量级锁地址
(01
),然后自己进入Monitor 的EntryList 变成BLOCKED状态
因为对象的对象头中存储的是重量级锁的地址,状态变为10了之前的是00, 肯定恢复失败
),那么会进入重量级锁的解锁过程,即按照Monitor的地址
找到Monitor对象
,将Owner设置为null
,唤醒EntryList 中的Thread-1线程
重量级锁竞争的时候,还可以使用自旋来进行优化(不立即加入Monitor的阻塞队列EntryList中,先自旋,我们可以想一下,如果立马到阻塞状态,务必会照成上下文切换,会耗费资源,倒不如我先试着自旋以下,如果成功,我们也顶多占用一点cpu
),如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁
2. 自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
自旋会占用 CPU 时间
,单核 CPU
自旋就是浪费
,多核 CPU
自旋才能发挥优势
。旋锁是自适应的
,比如对象刚刚的一次自旋操作成功
过,那么认为这次自旋成功的可能性会高
,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能
在轻量级的锁
中,我们可以发现,如果同一个线程对同一个对象进行重入锁
时,也需要执行CAS操作
,这是有点耗时
,那么java6开始引入了偏向锁
,只有第一次使用CAS
时将对象的Mark Word头
设置为入锁线程
ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了
一个对象的创建过程
如果开启了偏向锁(默认是开启的
),那么对象刚创建之后,Mark Word 最后三位的值101
,并且这是它的Thread,epoch,age都是0
,在加锁的时候进行设置这些的值.
偏向锁默认是延迟的,不会在程序启动的时候立刻生效
,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0
来禁用延迟
注意:处于偏向锁的对象解锁后
,线程id 仍存储于对象头
中
实验Test18.java,加上虚拟机参数XX:BiasedLockingStartupDelay=0
进行测试
输出结果如下,三次输出的状态码都为101
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
测试禁用
:如果没有开启偏向锁
,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0
,hashcode是第一次用到hashcode
时才赋值的。在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking
禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized
状态变回001
-XX:-UseBiasedLocking
输出结果如下,最开始状态为001,然后加轻量级锁变成00,最后恢复成001
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
测试 hashCode
:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode
的值了
测试代码时,使用虚拟机参数 -XX:BiasedLockingStartupDelay=0
确保我们的程序最开始使用了偏向锁!但是结果显示程序还是使用了轻量级锁。
这里我们是偏向锁撤销变成轻量级锁的过程
,那么就得满足轻量级锁的使用条件,就是没有线程对同一个对象进行锁竞争,我们使用wait
和 notify
来辅助实现
会使对象的锁变成重量级锁,因为wait/notify方法之后重量级锁才支持
如果对象被多个线程
访问,但是没有竞争,这时候偏向了线程一
的对象又有机会重新偏向线程二
,即可以不用升级为轻量级锁
,其实要实现重新偏向是要有条件的,就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。
相当高的
,同步的后果是降低并发性
和性能
。JIT编译器
可以借助逃逸分析
来判断同步块所使用的锁对象是否只能够被一个线程访问
而没有被发布到其他线程。JIT编译器
在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。例如下面的代码,根本起不到锁的作用:
public void f() {
Object gql = new Object();
synchronized(gql) {
System.out.println(gql);
}
}
代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f()方法中(在栈中,不共享
),并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(gql);
}
字节码文件中并没有进行优化,加锁和释放锁的操作依然存在,同步省略操作是在解释运行时发生的