synchronized关键字在开发过程中可以用来解决线程安全问题中提到的原子性,可见性,以及顺序性。
以前它是重量级锁,但是后来jdk对synchronized进行了各种优化,引入的偏向锁和轻量级锁,升级了锁的存储结构,大大减少了获得锁和释放锁带来的性能消耗。
同步器
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源。这种资源可能是:对象、变量、文件等。
- 共享:资源可以由多个线程同时访问
- 可变:资源可以在其生命周期内被修改
由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问。
那么怎么解决线程并发安全问题呢? 实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
在Java中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock
同步器的本质就是加锁。加锁的目的是序列化访问临界资源,即同一时刻只能有一个线程访问临界资源。
不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。
synchronized关键字
锁分为显式锁和隐式锁,其中 synchronized 属于隐式锁,不需要手动加锁与解锁,JVM会自动加锁跟解锁。
synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
synchronized加锁方式:
- 同步实例方法,锁是当前实例对象
- 同步类方法,锁是当前类对象
- 同步代码块,锁是括号里面的对象
synchronized是基于JVM内置锁实现
- jdk6以前,synchronized关键字只表示重量级锁
- jdk6以后,增加了偏向锁和轻量级锁,所以synchronized关键字包含偏向锁、轻量级锁、重量级锁。随着竞争的激烈,锁会自动升级。
那么有个问题来了,既然synchronized加锁加在对象上,那对象是如何记录锁状态的呢? 答案是:对象头
要了解synchronized的加锁原理,需要了解下对象的内存布局、锁的状态与膨胀升级过程。
对象的内存布局
HotSpot虚拟机中,对象的内存布局可以分为三块区域:对象头、实例数据、对齐填充。
当然,这里面最主要的就是对象头。
对象头 Header | 比如hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等 |
实例数据 Instance Data | 即创建对象时,对象中成员变量,方法等 |
对齐填充Padding | 对象的大小必须是8字节的整数倍 |
对象头
HotSpot虚拟机的对象头包括两部分(数组对象的话是三种)信息
1)Mark Word
第一部分是"Mark Word",用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32Bits和64Bits。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,"Mark Word"被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
"Mark Word"在各种状态下的存储空间分配情况(方便起见以32位为例,总数据长度为32bits):
64位跟32位虚拟机,内存分配有点区别,不过原理是一样的。
2)元数据指针
也就是指向类数据的指针。类数据在方法区中(元空间)
3)数组长度
这部分只有数组对象才有。
因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
实例数据
如果对象有属性字段,则这里会有数据信息。如果对象无属性字段,则这里就不会有数据。根据字段类型的不同占不同的字节。
boolean | 1 bytes |
byte | 1 bytes |
short | 2 bytes |
char | 2 bytes |
int | 4 bytes |
float | 4 bytes |
long | 8 bytes |
double | 8 bytes |
reference(引用) | 4 bytes(32位) 8 bytes(64位) |
对齐填充
JVM要求java的对象占的内存大小应该是8bit的倍数。所以如果对象大小不是8bit的倍数的话,需要补齐。补齐的那部分内存大小就是内存填充。
锁的状态与膨胀升级过程
内置锁的状态分为四种,无锁状态、偏向锁、轻量级锁和重量级锁。
这4种状态是从【多个线程竞争同步资源的流程细节】角度区分出来的:
状态 | 描述 |
---|---|
无锁 | 不锁住资源,多个线程中只有一个线程能修改资源成功,其他线程会重试 |
偏向锁 | 同一个线程执行同步资源时自动获取资源 |
轻量级锁 | 多个线程竞争同步资源时,没有获取资源的线程会自旋等待锁释放 |
重量级锁 | 多个线程竞争同步资源时,没有获取资源的线程会阻塞等待唤醒 |
膨胀升级
当使用synchronized关键字加锁时,并不是一上来就直接加重量级锁的,这样性能太低了。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级为重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
偏向锁
偏向锁主要解决没有竞争情况下的锁性能问题。
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价(加锁会涉及到一些CAS操作,耗时)而引入偏向锁。
核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时"Mark Word"的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作(即获取锁的过程),这样就省去了大量有关锁申请的操作,从而提高了程序的性能。
- 对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁
- 对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,这个时候需要用更高级别的锁
需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时"Mark Word"的结构也变为轻量级锁的结构。
有一个经验数据:对绝大部分的锁,在整个同步周期内都不存在竞争。这个也就是轻量级锁能够提升程序性能的依据。轻量级锁就适用于竞争不激烈的场景。
轻量级锁的目标是:减少无实际竞争情况下,使用重量级锁产生的性能消耗(包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等)。
加锁过程:
- 线程尝试使用CAS将对象头中的"MarkWord"替换为指向锁记录的指针
- CAS替换成功说明当前线程已获得该锁
- 如果CAS替换失败则说明当前时间锁对象已被某个线程占有,那么此时会通过自旋的方式去获取锁。如果在自旋一定次数后仍然没有获得锁,那么轻量级锁将会升级成重量级锁
自旋锁
1)未使用自旋锁的线程状态:运行 -> 阻塞 -> 运行
那些处于等待队列中的线程均处于阻塞状态,阻塞操作由操作系统完成,线程被阻塞后便进入内核Linux调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
缓解上述问题的办法便是自旋。自旋锁的目标是降低线程切换的成本。
2)使用自旋锁线程状态:运行 -》 自旋(不会放弃CPU使用权) -》 运行
当线程暂时无法获得锁时,不直接进入阻塞,而是进行自旋(可以理解为java中的for(;;)循环),自旋状态下,线程不会放弃CPU的使用权。自旋时间不会太长,在这个过程中如果获得了锁,那就正常进入运行状态,这样就节省了线程阻塞再唤醒的时间。如果自旋一段时间后还是无法获得锁,线程就会停止自旋进入阻塞状态。
基本思路就是:先自旋,不成功再阻塞,尽量降低阻塞的可能性。这对那些执行时间很短的代码块来说有很显著的性能提高。
3)自旋的问题
当然,自旋也不都是好事,也有它的缺点。
首先,自旋是消耗CPU的,所以如果自旋时间过长,对CPU来说压力比较大;时间过短又达不到延迟阻塞的目的。
其次,线程在进入等待队列时首先进行自旋尝试获得锁,如果不成功再进入等待队列。这对那些已经在等待队列中的线程来说,显得不公平。
重量级锁
基本思想是:锁竞争失败后,线程会阻塞,被阻塞的线程不会消耗cpu(所以说比自旋消耗的cpu少);释放锁后,唤醒阻塞的线程。
阻塞或者唤醒一个线程时,都需要操作系统来操作,这就需要用户态与内核态的转换,这是很消耗性能的!
重量级锁通过对象内部的监视器(Monitor)实现。
监视器Monitor
Monitor的本质是依赖于底层操作系统的Mutex Lock实现。每个同步对象都有一个自己的 Monitor(监视器锁),这也是为什么加锁的时候要加在对象上。
加锁过程如下图所示:
重量级锁时,synchronized关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令,分别在同步块逻辑代码的起始位置与结束位置。
- monitorenter 对于 JMM 8大原子操作中的 lock
- monitorexit 对于 JMM 8大原子操作中的 unlock
Monitor中有三个比较重要的的部分:Owner、EntrySet、WaitSet
Owner:保存持有锁的线程。
EntrySet:保存处于阻塞状态的线程
WaitSet:保存处于等待状态的线程
- 同一时刻只能有一个线程持有锁
- 当线程需要访问受保护的数据,但是没有获得锁时,会进入EntrySet,等待当前持有锁的线程把锁释放
- WaitSet中的线程需要等待被唤醒(notify()、notifyAll()),才能有资格获取锁
注意:当一个线程在WaitSet中被唤醒后,并不一定会立刻获取Monitor,它需要和其他线程(EntrySet中的线程,以及WaitSet中其他被唤醒的线程)去竞争。
锁粗化&锁消除
锁粗化
看下面这段代码:
public StringBuffer sb = new StringBuffer();
public void test() {
// do something
synchronized (this) {
sb.append("aaa");
}
synchronized (this) {
sb.append("bbb");
}
synchronized (this) {
sb.append("ccc");
}
// do something
}
这段代码中,虽然写了三次加锁,但实际上 JVM 会自动将它们合并,最终只加锁一次。这就是锁粗化。
锁消除
看下面这段代码:
public void test() {
synchronized (new Object()) {
// do something
}
}
这段代码中,不会进行加锁。因为JVM会进行对象逃逸分析。new Object() 是局部变量,是线程私有的,JVM 可以判断出这个加锁没有意义,所以 JVM 不会对这个同步块进行加锁。这个就是锁消除。