目录
一、引言
二、synchronized的使用
三、Synchronized如何保证线程安全
四、锁优化
Java对象结构
64位虚拟机Mark Word组成
无锁、偏向锁、轻量级锁、重量级锁
五、Synchronized与ReentrantLock
在Java中线程同步使用synchronized关键字,每个java对象都有一把隐含的锁,称为Java内置锁或者对象锁。
package com.xiaojie.syn;
/**
* @author xiaojie
* @version 1.0
* @description:synchronized代码示例
* @date 2022/1/1 20:49
*/
public class Test {
private static volatile int num;
Object obj = new Object();
//同步方法
public synchronized void add() {
num++;
}
//同步代码块
public void add1() {
// synchronized (this){ //当前实例对象作为锁
// num++;
// } 或者
synchronized (obj) { //Object 实例对象作为锁
num++;
}
}
//静态代码
public static synchronized void add2() {
//锁是当前类的class字节码,
num++;
}
}
小结:
),而修饰一般方法的同步锁为对象锁。synchronized (Test.class) { ...... }
将上面代码反汇编 javap -p -v Test.class
指令中多了monitorenter 和monitorexit 两条指令。
monitorenter描述如下
每个对象都与一个监视器相关联。当且仅当监视器有所有者时,监视器才被锁定。执行monitorenter的线程 尝试获得与objectref关联的监视器的所有权,如下所示:
如果与objectref关联的监视器的条目计数 为零,则线程进入监视器并将其条目计数设置为 1。该线程然后是监视器的所有者。
如果线程已经拥有与objectref关联的监视器 ,它会重新进入监视器,增加其条目计数。
如果另一个线程已经拥有与objectref关联的监视器 ,则线程会阻塞,直到监视器的条目计数为 零,然后再次尝试获得所有权。
monitorexit描述如下
执行monitorexit的线程必须是与objectref引用的实例关联的监视器的所有者 。
该线程递减与objectref关联的监视器的条目计数。如果结果条目计数的值为零,则线程退出监视器并且不再是其所有者
意思就是每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。 当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权,如果获取到了那么获取到的锁的计数器就会加1,如果释放了锁那么就会相应的计数减1,一直减到0之后,表示释放锁,其他线程才能去和这个monitor关联,然后执行方法。
有个问题上面只有一个monitorenter,为什么反汇编之后是两个monitorexit?
public void test() {
synchronized (obj) {
try {
num++;
int i = 1 / 0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
如上代码,如果在执行方法异常时,该线程一直持有锁,然后一直不释放,就会发生死锁,所以JVM在发生异常时也会执行monitorexit操作来释放锁,所以第二个monitorexit是在程序发生异常时才会执行的指令。
锁的介绍请参考:JUC并发编程——锁_熟透的蜗牛的博客-CSDN博客
下面具体补充一下锁是如何从偏向锁——轻量级锁——重量级锁。
一个JAVA对象一般包含对象头、对象体、填充对齐。
对象头
对象头包括三个字段 Mark Word(标志字,一般存储GC标记,哈希码、锁状态等)、Class Pointer (类对象指针,用于存放方法区class对象地址,虚拟机通过这个指针确定这个对象是哪个类的实例)、Array Length(数组长度 ,此字段当对象是一个java数组时,必须有这个字段,其他类型对象此字段是不存在的)。
对象体
对象体包含对象的实例变量(成员变量),用于成员属性,包括父类成员属性值,这部分内存按照4字节对齐
填充对齐
也叫对齐字节,其作用是用来保证JAVA对象所占内存字节数为8的整倍数。当对象头本身不是8的整倍数时,需要填充数据来保证8字节的对齐。
使用ClassLayout.parseInstance(mayiktLock).toPrintable(),方法可以查看对象内存布局,需要引入jol-core的maven依赖本文不详细介绍,有感兴趣可以自行了解。
lock锁标记位,占两个二进制位,01表示无锁、00轻量级锁、10重量级锁、11 GC标识、01偏向线程id标识为可偏向。
biased 对象是否启用偏向锁标志,只占一个二进制位,1标识启用偏向锁、0标识没有启动偏向锁。
age 分代年龄占4个二进制位,这也就解释了为什么JVM参数设置垃圾回收年龄-XX:MaxTenuringThreshold最大值为15。
identity_hashcode 31位的对象标识哈希码,当对象被锁定时,该值会从移动到Monitor监视器中。
thread 持有偏向锁的线程id。
epoch 偏向时间戳。
无锁:JAVA对面刚创建此时还没有线程来竞争,此时偏向锁的状态是0,锁状态是01。
偏向锁:一段代码只被一个线程调用,那么该线程就会自动获取锁,降低获取锁的代价。在无竞争的情况下,之前获取锁的线程再次获取锁时会判断偏向锁的线程id是否是自己,如果是自己那么该线程不用获取锁就可以直接进入同步代码块。如果不是自己,那么当前线程就会采用CAS操作将Mark Word 中线程id设置为自己的线程id,如果CAS操作成功,那么获取偏向锁成功。反之表示有其他线程在竞争,则挂起抢锁线程,撤销占锁线程的偏向锁,将偏向锁升级为轻量级锁。
偏向锁在Java 1.6之后是默认启用的,但在应用程序启动几秒钟之后才激活,可以使用-XX:BiasedLockingStartupDelay=0
参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过XX:-UseBiasedLocking=false
参数关闭偏向锁。
轻量级锁:轻量级锁的目的是尽可能不动用操作系统层面的互斥锁,因为其性能比较差,线程的阻塞和唤醒需要CPU从用户态转化为核心态,频繁的阻塞和唤醒很消耗CPU性能。在代码进入同步代码块时,如果此对象没有被锁定(标记位为01状态),虚拟机首先在当前线程的栈帧建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前Mark Word的拷贝,然后虚拟机使用CAS操作尝试将对象的Mark Word 更新为指向当前线程的Lock Record的指针,如果操作成功了,那么这个线程就有了这个对象的锁,并且将Mark Word 的标记位更改为00,表示这个对象处于轻量级锁定状态。如果更新失败了虚拟机会首先检查是否是当前线程拥有了这个对象的锁,如果是就进入同步代码,如果不是,那就说明锁被其他线程占用了。如果有两个以上的线程(争抢同步锁的线程很多)争夺同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标记位变为10,后面等待的线程就要进入阻塞状态。
重量级锁:
JVM中每个对象都会有一个监视器,监视器和对象一起创建、销毁。在hotspot中监视器是由C++中ObjectMonitor实现的。该对象中有以下几个属性需要知道
Cxq: 竞争队列,所有请求锁的线程首先会被放在这个竞争队列中。注意:他并不是一个真正的队列,只是一个虚拟队列,是由Node及next的指针逻辑构成,并不具有队列的完整数据结构。
EntryList: 在线程释放锁时,JVM会从Cxq中迁移线程到EntryList,EntryList存放着可以参加竞争锁的线程。
WaitSet:如果一个线程被Object.wait(),那么这个线程就被转移到了WaitSet队列中,直到调用notify()或者notifyAll(),该线程会重新进入EnrtyList中。
总结:synchronized执行流程
名称 | Synchronized | ReentrantLock |
锁实现 | JVM 内置锁(关键字) | JDK接口 |
性能 | JDK1.6锁优化性能大致相同 | 大致相同 |
等待可中断 | 不可中断 | 可中断 |
公平 | 非公平 | 公平、非公平 |
锁绑定条件 | 方法、代码块 | 一个 ReentrantLock 可以同时绑定多个 Condition 对象 |
《JAVA高并发核心编程(卷2):多线程、锁、JMM、JUC、高并发设计》-尼恩编著
Java 并发 - 理论基础 | Java 全栈知识体系