参考链接
文章目录
- 一 基本使用
- 1 三个作用
- 2 三种用法
- 二 同步原理
- 1 监视器 Monitor
- 2 synchronized 用于同步代码块
- 3 synchronized 用于同步方法
- 3 Mark Word
- 4 对象头的 Mark Word 和线程的 Lock Record
- 三 锁的优化
- 1 自旋锁
- 2 锁消除
- 3 锁粗化
- 4 偏向锁
- 5 轻量级锁、重量级锁以及三种锁的对比*
一 基本使用
1 三个作用
-
原子性:确保线程互斥的访问同步代码,即同一时间只有一个线程会进入同步代码块
-
可见性:保证共享变量的修改能够及时可见,依赖于 JMM 对一个变量 unlock 操作之前,必须要同步到主内存中;对一个变量进行 lock 操作,则将会清空工作内存(线程私有)中此变量的值,重新从主内存中 load 或 assign 初始化变量值
-
有序性:虽然进行了重排序,但保证只有一个线程会进入同步代码块,单线程下的指令重排是安全的
2 三种用法
- 锁定实例方法时,监视器锁(monitor)便是对象实例(this)
- 锁定静态方法时,监视器锁(monitor)便是对象的 Class 实例,即锁定了这个类的所有实例
- 锁定对象实例时,监视器锁(monitor)便是括号括起来的对象实例
二 同步原理
数据的同步依赖锁,锁的同步如何实现?
synchronized
在软件层面依赖 JVM 实现锁的同步JUC.Lock
在硬件层面依赖特殊的 CPU 指令
1 监视器 Monitor
- 每个对象都对应一个监视器锁(monitor),用于实现重量级锁
synchronized
在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步- 每个 Java 对象的对象头的 Mark Word 中都存放着对应 Monitor 对象的引用,所以任意对象都可以作为锁
- Monitor 对象包含两个队列
_EntryList
和_WaitSet
,分别存放未获取到 Monitor 对象的线程,以及曾经获取到并且再次等待 Monitor 对象的线程- 当多个线程同时访问一段同步代码时首先会进入
_EntryList
集合,当线程获取到对象的 monitor 后,进入_Owner
区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count++ - 若线程调用
wait()
方法,将释放当前持有的 monitor,owner 变量恢复为 null,count–,同时该线程进入_WaitSet
中等待被唤醒 - 若当前线程执行完毕,释放 monitor 并复位 count,以便其他线程进入获取 monitor
- 当多个线程同时访问一段同步代码时首先会进入
2 synchronized 用于同步代码块
反编译后可以得到由 monitorenter
、monitorexit
两种指令实现
-
monitorenter
:当monitor被占用时就会处于锁定状态,线程执行monitorenter
指令时尝试获取对象的 monitor 的所有权,过程如下:- 如果 monitor 的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为 monitor 的所有者
- 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加1 (可重入性)
- 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为0,重新尝试获取 monitor 的所有权
-
monitorexit
:执行monitorexit
的线程必须是 monitor 的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出 monitor,不再是这个 monitor 的所有者。monitorexit
插入在方法结束处和异常处,JVM保证每个monitorenter
必须有对应的monitorexit
3 synchronized 用于同步方法
- 方法的同步并没有通过指令
monitorenter
和monitorexit
来完成,两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成 - 相对于非同步方法,同步方法的常量池中多了
ACC_SYNCHRONIZED
标示符 - 当方法调用时,调用指令将会检查方法的
ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor 。在方法执行期间,其他任何线程都无法再获得同一个monitor 对象
3 Mark Word
Class Pointer
(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例Mark Word
(标记字段):用于存储对象自身的运行时数据,是实现轻量级锁和偏向锁的关键。- 每个 Java 对象的对象头的 Mark Word 中都存放着对应 Monitor 对象的引用,所以任意对象都可以作为锁。为了在很小的内存中尽可能存储更多的数据,它的结构会随着程序的运行发生变化
4 对象头的 Mark Word 和线程的 Lock Record
- 在线程进入同步代码块的时候,如果此同步对象没有被锁定,则虚拟机首先在当前线程的栈中创建称为“锁记录(Lock Record)”的空间,这个空间是线程私有的,用于存储锁对象的 Mark Word 的拷贝
- 每一个被锁住的对象的 Mark Word 都会和获得这个锁的线程的 Lock Record 关联(对象头的 Mark Word 中的 Lock Word 指向 Lock Record 的起始地址)
- Lock Record 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用
三 锁的优化
1 自旋锁
- 自旋锁:当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态
- 使用自旋锁的理由:CPU 在用户态和核心态的切换需要消耗资源;大多情况下锁状态的持续时间很短
- 自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间,所以要规定适当的自旋次数
- 适应性自旋锁:线程如果自旋成功了,那么下次自旋的次数会更加多;反之会减少自旋次数甚至取消
2 锁消除
- 在有些情况下,JVM 检测到不可能存在共享数据竞争,这时会对这些同步锁进行锁消除
- 在运行这段代码时,JVM 可以明显检测到变量 vector 没有逃逸出方法,所以可以将 vector 内部的加锁操作消除
public void vectorTest(){Vector<String> vector = new Vector<String>();for(int i = 0 ; i < 10 ; i++){vector.add(i + "");}System.out.println(vector);
}
3 锁粗化
- 如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,锁粗化将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
- 上述例子 vector 每次 add 都需要加锁,JVM检测到对同一个对象连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到循环之外
4 偏向锁
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级
- 偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下一定会转化为轻量级锁或者重量级锁
- 引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径
- 轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能
5 轻量级锁、重量级锁以及三种锁的对比*
- 如果是单线程使用,偏向锁的代价最小,仅仅在内存中比较对象头即可,无需 CAS
- 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁
- 如果其他线程通过一定次数的 CAS 尝试没有成功,则进入重量级锁