一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情。
上一篇,我们讲解了Java进程与线程的基础内容,本篇我们来从原理层级上继续深入理解进程和线程。
synchronized关键字
在详细讲解synchronized关键字前,我们先来了解以下的几个问题。
线程安全问题的主要诱因
- 存在共享数据(也称为临界资源)
- 存在多个线程共同操作这些共享数据
解决问题的根本方法:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。此时,我们可以引入互斥锁。
互斥锁的特性
- 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块进行访问。互斥性也称为操作的原子性。
- 可见性:必须确保在锁被释放之前,对共享变量所做的修改对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一线程可能是在本地缓存的某个副本上继续操作,从而引起数据不一致的问题。
对于Java来说,关键字synchronized就满足了上述互斥锁的特性。它可以保证在同一时刻只有一个线程可以执行某个方法或代码块,同时synchronized也能够保证一个线程共享数据对其他线程是可见的,也就满足了互斥锁的可见性。synchronized锁的不是代码,锁的都是对象。下面,我们根据获取的锁的分类对synchronized进行讲解。
根据获取的锁的分类可分为:
-
获取对象锁
获取对象锁的两种方法:
- 同步代码块:
synchronized(this),synchronized(类的实例对象)。锁的是实例的对象。 - 同步非静态方法:
synchronized method锁的是当前对象的实例对象。
- 同步代码块:
-
获取类锁
获取类锁的两种方法:
- 同步代码块:
synchronized(类名.class),锁的是类的对象。 - 同步静态方法:
synchronized static method,锁的是当前对象的类对象。
- 同步代码块:
对象锁和类锁的总结:
- 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块。
- 类锁和对象锁互不干扰:如果一个线程调用一个实例对象的非静态
synchronized方法,而另一个线程调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁。 - 同一个类的不同对象的对象锁互不干扰。
synchronized底层实现原理
要了解synchronized的底层实现,我们首先需要对Java对象头和Monitor有一个认识。
Java对象头
在HotSpot虚拟机中,对象在内存中的布局分为三块区域,分别是:对象头,实例数据,对齐填充。一般而言,Java的锁对象是存储在对象头中的,其主要结构是由Mark Word和Class Metadata Address组成。接下来我们将详细解释。
Mark Word:默认存储对象的hashCode,分代年龄,锁类型,锁标志位等信息。考虑到空间效率,Mark Word被设计成非固定的数据结构,便于存储更多有效的数据。它会根据对象本身的状态复用自己的存储空间。
Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据。
Monitor
Monitor也称为管层、监视器锁,我们可以把它理解成一种同步机制,通常它被描述为一个对象。Monitor对象存在于每个Java对象的对象头中,在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。以下是ObjectMonitor的部分源码:
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
从源码中可以看出,ObjectMonitor有两个队列:_EntryList即我们在上一篇讲到的锁池、_WaitSet即等待池。_owner是指向持有ObjectMonitor对象的线程。当多个线程同时访问同一段同步代码时,线程会先进入到EntryList集合中,当线程获取到对象的monitor后,就会进入到object区域,并把monitor中的owner变量设置为当前线程,同时计数器count就会加一。若线程调用wait方法,将释放当前持有的monitor,owner就会被恢复成null,count也会减一,同时该线程及ObjectWaiter实例就会进入到等待池中等待被唤醒。若当前线程执行完毕,它也会释放monitor锁并复位对应变量的值,以便于其他线程进入获取monitor锁。
看完前置知识后,接下来,我们通过一个demo查看经过synchronized修饰的代码块和方法在.class字节码中有什么区别。
编写如下代码:
SynchronizedTest.java
package com.yeliheng.threads;
public class SynchronizedTest {
//同步代码块
public void syncBlock() {
synchronized (this) {
System.out.println("测试同步代码块");
}
}
//同步方法
public synchronized void syncMethod() {
System.out.println("测试同步方法");
}
}
我们通过JDK自带的javap命令来查看SynchronizedTest类的字节码信息。首先对该类进行编译。
javac SynchronizedTest.java
编译完成后,我们使用javap来获取字节码信息。
javap -verbose SynchronizedTest.class
可以看到如下结果:
从上图我们可以看出,synchronized同步语句块实现使用的是monitorenter和monitorexit指令,monitorenter指令指向同步代码块的开始位置,monitorexit则指向结束位置。当执行monitorenter时,线程将尝试获取对象的锁,若锁的计数器count为0时,则代表锁可被获取,获取后count对应加1。流程图如下:
并且由于synchronized是可重入锁,线程在之前若已经获得ObjectMonitor的持有权时,可再次在内部调用synchronized,即重入,重入后的count将再次加1。
重入:从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,线程将会处于阻塞状态,但当一个线程再次请求自己持有的对象锁的临界资源时,是允许的。这种情况叫做重入。
同理,拥有该对象锁的线程可以使用monitorexit来释放锁。在执行monitorexit指令后,锁的计数器将被减1,直至计数器为0,表明锁被释放。这时其他线程才可以尝试获得该锁。
接下来我们继续看synchronized修饰同步方法时的情况:
此处,我们可以看到,不同于修饰同步代码块,修饰同步方法时并没有monitorenter和monitorexit,而是产生了一个ACC_SYNCHRONIZED标识。这个标识用于区分一个方法是否是同步方法。被设置ACC_SYNCHRONIZED的方法也会按照上文讲述的规则进行加锁,它的本质也是对象监视器。
早期的synchronized为什么效率低下?
- 早期版本中,
synchronized属于重量级锁,依赖于操作系统的Mutex Lock实现。 - 在线程之间的切换需要从用户态转换到核心态,开销较大。
在Java6之后,synchronized关键字做了哪些优化?
虚拟机在Java6之后,对底层的锁进行了大量的优化,引入了:自适应自旋锁、自旋锁、锁消除、锁粗化、偏向锁、轻量级锁。(下文我们会一一介绍) 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
锁膨胀的方向:无锁->偏向锁->轻量级锁->重量级锁
自旋锁与自适应自旋锁
自旋锁
许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。自旋锁通过让线程执行忙循环等待锁的释放,不让出CPU。另外地,自旋锁与阻塞并不相同。自旋锁自Java4就存在,只是默认为关闭状态,到Java6后才默认开启。
缺点:若锁被其他线程长时间占用,那循环会带来更多性能上的开销。由于线程执行的时间是不好确定的,所以自适应自旋锁应运而生。
自适应自旋锁
自适应自旋锁自旋的次数不再固定,是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
锁的优化机制
锁消除
锁消除也是一种JVM自身的优化机制。在JIT编译时,对运行的上下文进行扫描,去除不可能存在竞争的锁。
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源。JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。
偏向锁
偏向锁可用于减少同一线程获取锁的代价。在大多数情况下,锁并不存在多线程竞争,而且总是由同一个线程多次获得。为了减少同一个线程获取锁的代价,便引入了偏向锁。偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变成了偏向锁结构,当该线程再次请求时,无需再做任何同步操作。即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。
注意:由于偏向锁的特性,偏向锁并不适用于锁竞争比较激烈的多线程场合。
轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。若存在同一时间访问同一把锁的情况,轻量级锁就会膨胀为重量级锁。
轻量级锁适合线程交替执行同步块的应用场景。
总结
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块或者同步方法的场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 若线程长时间抢不到锁,自旋会消耗CPU性能 | 线程交替执行同步块或同步方法的场景 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下频繁地获取和释放锁会带来巨大的性能开销 | 追求吞吐量、同步块或者同步方法执行较长的场景 |
关于JMM内存模型以及volatile关键字,我们拆分到下一篇讲解。