文章目录
synchronized核心特点
synchronized是java内置的关键字,是java为了保证线程安全的一种加锁方式,悲观锁
保证变量的可见性、原子性、禁止指令重排(volatile不保证原子性)
synchronized是独占锁,可重入锁,非公平锁
synchronized无需程序员手动释放锁(ReenTrantLock锁如果忘了手动释放会造成死锁)
synchronized不可中断,不可设置超时时间(ReenTrantLock都支持)
一共三种工作方式:修饰实例方法(同一对象锁),修饰静态方法(同一类的锁),修饰代码块(手动指定锁)
和ReenTrantLock的区别
本质区别
synchronized是java内置的关键字,而ReenTrantLock是一个类
所以二者的释放方式不同:
synchronized自动释放对锁的占用
而ReenTrantLock则必须要程序员手动释放锁
,如果没有主动释放锁,就有可能出现死锁现象
。
jdk先设计的sync,由于sync很多功能不支持,性能很差(jdk5优化之前),所以jdk又设计了ReenTrantLock,那么ReenTrantLock相较synchronized的优势有哪些呢?如下:
- synchronized只能非公平,ReenTrantLock默认非公平,但可以设置为公平锁
- ReenTrantLock可响应中断、可以设置超时时间、支持多个条件变量用来实现
分组唤醒
线程,这些synchronized都不支持。
如何选择呢?
- 在资源竞争不激烈的情况下,Synchronized使用方便,自动释放锁,可以选择使用。
- 但是在资源竞争很激烈的情况下,Synchronized的性能会下降很严重,推荐选择ReetrantLock
synchronized的3种使用方式
-
修饰实例方法,作用于当前对象,进入同步代码前需要
先获取对象的锁
-
修饰静态方法,作用于类的Class对象,进入修饰的静态方法前需要先获取
类的Class对象的锁
-
修饰代码块,需要指定加锁对象,在进入同步代码块前需要先获取
指定对象的锁
深入理解synchronized的使用
看下面的例子
t1线程对counter执行+1操作
t2线程对counter执行-1操作
文字理解:
图示理解:
为了加深理解,请思考下面的问题
1、如果把 synchronized(obj)
放在 for 循环的外面,如何理解?
for循环里面:counter++/counter- -的四步是原子的。
for循环外面:for循环的5000次*counter的四步=20000步是原子的。
2、如果 t1 synchronized(obj1) 而 t2 synchronized(obj2)
会怎样运作?
两个线程获得的是不同的对象的锁,无法保证同步了。
深入理解synchronized的原理
想要深入理解synchronized的原理,必须先搞清楚对象在内存中是如何布局的?
java对象内存布局
在HotSpot虚拟机中,对象在内存中的布局可以分为3块区域:
-
对象头: HotSpot虚拟机的对象头主要包括两部分数据:标记字段(Mark Word)、类型指针(Class Pointer)。如果是数组的话,还包括数组长度,不是数组就没这部分。
-
实例数据: 包括了对象的所有成员变量,大小由各变量的类型决定,包括基本类型和引用类型,这是对象真正存储的有效信息;
-
对齐填充: 由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据的存在主要是为了字节对齐。
如果现在使用 synchronized
给对象上锁之后,该对象的对象头中的Mark Word
中就会保存一个指向 Monitor 对象的指针。如下图:
在HotSpot虚拟机中,Monitor对象是基于C++的ObjectMonitor类实现的,其主要成员包括:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
_EntryList:存放处于等待锁block状态的线程队列
_count:约为_WaitSet 和 _EntryList 的节点数之和
_cxq: 多个线程争抢锁,会先存入这个单向链表
_recursions: 记录重入次数
加sync锁之后的多线程运行原理如下:
比如现在t2线程拿到moniter锁,所以Owner指针指向t2 ,其他线程要么waiting要么blocked
当t2调用 wait 方法时会释放锁,随后进入 WaitSet
变为 WAITING
状态
然后在EntryList上block的线程就会抢锁,假如时t3抢到,owner就是指向t3
WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入 EntryList
重新竞争
以上介绍的是sync最开始设计的加锁方式,可以看到这种锁很重,我们叫它重量级锁,性能很低,jdk官方为了优化sync的性能,在jdk5时引入了偏向锁和轻量级锁,我们一起来看看。
偏向锁
什么是偏向锁?
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
为什么会出现偏向锁?
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
偏向锁实现原理
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储 锁偏向的那个线程的线程ID。在线程进入和退出同步块
时不再通过CAS操作
来加锁和解锁,而是检测Mark Word
里是否存储着指向当前线程的偏向锁。
偏向锁什么时候撤销?
线程不会主动释放偏向锁,只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
轻量级锁(自旋锁)
当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁
轻量级锁是指当线程没有获取到锁时不直接阻塞,而是会通过自旋
的形式再次尝试获取锁,在尝试一定次数之后仍没有获取到锁才进入阻塞状态。
优点:
减少线程阻塞的次数和时间从而提高性能。但是注意,自旋获取锁也是消耗性能的,所以需要设置一定的自旋上限。
轻量级锁适用场景:
如果一个对象有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁升级为重量级锁:
当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。
为什么这么设计?
一般来说,同步代码块内的代码应该很快就执行结束,这时候线程B 自旋一段时间是很容易拿到锁的,但是如果没拿到,自旋其实很耗CPU的,不能一直自旋下去,一直自旋下去就是死循环,因此就需要转成重量级锁。至于具体自旋多少次需要根据具体业务判断。
总结Synchronized的工作原理
Synchronized在jdk1.6版本之前,是通过重量级锁的方式来实现线程之间锁的竞争。
之所以称它为重量级锁,是因为它的底层底层依赖操作系统的Mutex Lock来实现互斥功能。
Mutex是系统方法,由于权限隔离的关系,应用程序调用系统方法时需要切换到内核态来执行。
这里涉及到用户态向内核态的切换,这个切换会带来性能的损耗。
在jdk1.6版本及之后,synchronized增加了锁升级的机制,来平衡数据安全性和性能。
简单来说,就是线程去访问synchronized同步代码块的时候,synchronized根据线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向锁和轻量级锁的机制。
-
偏向锁,就是直接把当前锁偏向于某个线程,简单来说就是通过CAS修改偏向锁标记,这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。
-
轻量级锁,也叫自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自旋锁优点在于它避免避免了用户态到内核态的切换带来的性能开销。
Synchronized引入了锁升级的机制之后,如果有线程去竞争锁:
首先,synchronized会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。如果竞争锁失败,说明当前锁已经偏向了其他线程。
需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源,如果在轻量级锁状态下还是没有竞争到锁,就只能升级到重量级锁
在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线程状态是Blocked。
处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。
总的来说, Synchronized的锁升级的设计思想,在我看来本质上是一种性能和安全性的平衡,如何在保证线程安全的前提下最大化提升性能