synchronized

synchronized是Java中的关键字,由底层C/C++实现,本文大致记录一下个人对这个关键字的见解与学习记录

1.基本概念

1.1. 锁概念

synchronized可以保证线程安全中的原子性、可见性与有序性。

该锁属于可重入锁,也属于重量级锁,在jdk1.5之前相对于AQS实现的lock来说,加锁的资源消耗要大很多,在后来的优化中,它的效率也在不断提升,编码中我们仍然优先使用这种锁进行多线程场景下的操作

它的加锁基本原理是将任何一个非空对象设置为一个监视器,对作用域内的代码进行“监视”,也称为监视器锁。该锁的作用粒度是对象,且是可重入的。可重入的最大作用是避免死锁(子类同步方法调用父类同步方法,无可重入特性则会死锁)。

1.2. 对象结构

Java对象之所以能够被设置为监视器,主要源于它的结构特点

对象结构

由图可见Java对象主要由实例数据、对象头与对其填充构成

  • 实例数据:该部分包含了对象所属类的有效信息,即类型以及父类属性等信息
  • 对象头:对象头包含两类信息,第一类是对象自身运行时的状态数据,如hashcode、Gc分代年龄、锁状态、偏向线程ID等
  • 对齐填充:这部分不是一定存在的,由于JVM的自动内存管理系统需要对象起始地址必须是8字节的整数倍而产生的这部分,因而该部分仅仅起着占位符的作用。

此外需要了解的是,32位系统下,类型指针大小为4字节,Mark Word大小为4字节,对象头一共8字节。64位系统下,类型指针大小为8字节,Mark Word大小为8字节,对象投一共16字节。64位系统开启指针压缩的情况下,存放类型指针的大小是4字节,Mark Word大小为8字节,对象头一共12字节。数组长度4字节+数组对象头8字节+数组Mark Word为4字节+对齐4字节=16字节。

:静态属性不算在对象的大小之内。

synchronized就是借助了Java对象头里存储的数据来实现的,在jdk1.6之后,还利用对象头对锁进行了进一步优化

下面是32位系统内Java对象头Mark Word部分的结构

Mark Word 32位

下面是64位系统内Java对象头Mark Word部分的结构

Mark Word 64位

1.3.锁记录(Lock Record)

锁记录是线程私有的数据结构,存在于线程栈之中,可以为偏向锁提供支持,用于存储对象Mark Word的数据

Lock Record 注释
Owner 没有任何线程拥有该监视器锁时为null,线程成功拥有该监视器锁时会保存线程的唯一标识
EntryQ 阻塞住所有获取该锁失败的线程
RcThis 阻塞在该锁上的线程个数
Nest 可重入锁的计数
HashCode 保存从对象投拷贝过来的hashcode(可能包含GC年龄)
Candidate 用来避免不必要的阻塞或锁等待,每次保证只唤醒一个阻塞线程,因为每次只有一个线程能够拥有该锁,如果每次前一个释放锁的线程唤醒所有的线程,会引起许多不必要的上下文切换。

2.基本用法

2.1. 作用在对象上

此时监视器锁是括号中的实例this对象

public void test() {
    synchronized (this) {
        System.out.println("test");
    }
}

2.2. 作用在方法上

此时监视器锁是实例对象this

public synchronized void test() {
    System.out.println("test");
}

2.3. 作用在静态方法上

此时监视器锁是class实例,相当于给整个类全局加锁

public static synchronized void test() {
    System.out.println("test");
}

3. 底层原理

synchronized在底层实现上,简单来说它的实现主要用了一个状态变量和两个队列,研究过AQS的同学们可能会对这部分有点似曾相识的感觉

synchronized主要是依赖监视器(Monitor)来实现的,在JVM中synchronized的实现都是基于进入与退出监视器来实现方法同步与代码块同步的(MonitorEnter与MonitorExit)

  • MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该监视器锁
  • MonitorExit指令:插入在同步代码块结束的位置或是异常结束的位置,JVM保证任何一个MonitorEnter指令都会有一个MonitorExit指令

监视器主要是由ObjectMonitor实现的,这里是它的数据结构:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,       // 等待中的线程数
    _recursions   = 0;       // 线程重入次数
    _object       = NULL;    // 存储该 monitor 的对象
    _owner        = NULL;    // 指向拥有该 monitor 的线程
    _WaitSet      = NULL;    // 阻塞/等待队列 双向循环链表_WaitSet 指向第一个节点
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;   // 多线程竞争锁时的单向链表,_cxq 是一个临界资源 JVM 通过 CAS 原子指令来修改_cxq 队列。每当有新来的节点入队,它的 next 指针总是指向之前队列的头节点,而_cxq 指针会指向该新入队的节点,所以是后来居上。
    FreeNext      = NULL ;
    _EntryList    = NULL ;   // 同步队列,_owner 从该双向循环链表中唤醒线程, _cxq 队列中有资格成为候选资源的线程会被移动到该队列中。
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0; // 前一个拥有此监视器的线程 ID
  }

其中有_WaitSet与_EntryList两个队列,用来保存ObjectWaiter对象列表(等待锁的线程会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,以下是在多线程环境下锁操作的简单图示:

监视器锁

  • 需要获取监视器锁的线程会先进入_cxq队列中,确定线程可以获取锁资源后会将锁放入同步队列_EntryList中等待锁释放
  • 当锁处于未占用状态后,同步队列中的队首元素会获取监视器锁(_owner指向获取锁的线程),同时_recursions++
  • 当获取锁的线程被阻塞,则会加入到阻塞队列_WaitSet中,当重新变为可运行态时会重新加入到同步队列中去

从底层源码的角度讲,synchronized锁与AQS实现的锁比较相像,除了AQS在实现锁时对阻塞队列进行了改造,使其由多个条件队列实现而成,即我们平时使用的Condition对象,体现在使用上就是AQS实现的锁可以放入不同的阻塞队列进行等待,也可以根据不同情况唤醒不同队列中的阻塞线程

4. 优化方案

在jdk1.6以后,官方对synchronized锁进行了一系列优化,其中主要包括锁升级、锁消除以及锁的粗化,使并发效率在线程竞争不激烈的情况下得到了极大地提升

4.1. 锁升级(锁膨胀)

锁升级指的是synchronized锁会按照实际情况,由初始创建时的偏向锁,随着线程竞争激烈程度的提升,逐渐升级为轻量级锁乃至重量级锁的过程,在升级的过程中,加锁的开销也在不断提升,这一过程为不可逆的,因而在线程竞争激烈的情况下如高并发场景下,并不适合于用synchronized锁进行处理。

下图为锁升级的过程中三种锁的转换流程

锁升级

4.1.1. 偏向锁

研究发现,大部分情况下,锁不但不存在多线程竞争,反而经常是同一线程多次获得,为了降低获得锁的代价,引入了偏向锁。

偏向锁是针对单线程环境下的加锁情况设计的,借助了对象头中的偏向线程ID的记录信息,标记了首次加锁的线程ID,当该线程进行加锁操作时仅仅需要将线程ID与这一字段比对,相同则成功加锁,这一操作极大减小了加锁操作的开销,且一旦加锁,单线程情况下线程并不会主动释放锁,只有竞争才可以释放锁的机制,偏向锁加锁与释放锁的过程(锁升级过程)大致如下:

  • 访问Mark Word中的偏向锁标志位,确认当前锁是否为偏向锁
  • 如果是,则检测Mark Word中的线程ID是否为当前线程的ID,如果是则直接执行同步代码,如果不是则需要通过CAS操作将Mark Word中的线程ID设置为当前线程ID,并执行同步代码
  • 如果检测当前同步对象为无锁状态,则需要先在线程栈的栈帧中建立一个锁记录,并将Mark Word中的内容拷贝至锁记录中
  • 如果CAS操作失败,则代表与其他线程存在竞争。则当到达safe point时当前占有偏向锁的线程被挂起,撤销Mark Word中的偏向锁标识,并标记当前锁为轻量级锁,之后被阻塞的线程继续执行同步代码

偏向锁除了可以减少加锁与释放锁的开销,还可以大量减少CAS带来的本地延迟(多线程间的一致性流量所带来的总线风暴),关于本地延迟与一致性流量的内容放在Jmm相关笔记中说明。

4.1.2. 轻量级锁

轻量级锁是锁升级的中间过程,在偏向锁阶段下一旦有当前线程之外的线程对锁进行抢占,则锁会升级到轻量级锁。由于线程的阻塞与唤醒需要CPU从用户态转换为内核态,线程的频繁唤醒与阻塞对CPU来说是一个不小的负担,因而引入了轻量级锁。

轻量级锁的实现原理主要是采用了CAS与自旋锁的方式,当A线程抢占到了锁资源后,B对象此时也需要锁,但A还未释放锁,此时B进入了自旋锁的状态,即B以CAS的方式循环请求锁资源,不会进入阻塞状态,执行线程B的CPU可以理解为当前情况下处于空转的状态,直到一定自旋次数后仍然没有获取锁,则会将线程置为挂起状态。

自旋锁由Jdk1.4.2引入,自旋的次数是固定的,Jdk6以后又引入了自适应自旋锁,自旋的次数并不固定,而是由前一次在同一个锁上的自旋时间以及锁拥有者的状态来决定的,当一个线程上一次自旋最终获得了锁,则下一次获得锁时自旋的次数会更多一些。

4.1.3. 重量级锁

重量级锁是锁升级的最终形态,当轻量级锁中的一个线程不断请求加锁,但是另一个线程占用锁资源迟迟没有释放,当锁自旋的状态保持一段时间后会挂起,当这类情况频繁出现的时候,锁就有可能再次升级,成为重量级锁,在重量级锁的状态下,加锁与释放锁时,操作系统需要在用户态与内核态之间切换,因而这个操作开销比较高,高并发场景下会十分影响代码的执行效率

4.2. 锁消除

在一些情况下,代码执行的操作不需要加锁,但是代码中却有加锁的操作,此时JVM会对代码进行锁消除的优化,去掉这部分的锁同步操作,这一优化的原理是基于线程内数据的逃逸分析来实现的

4.3. 锁粗化

在同一片代码区域内,执行了一系列连续加锁的操作,并且加锁操作的监视器为同一个,那么这时JVM就会对这段代码进行锁粗化的处理,简而言之就是将这一段代码中的多个锁连在一起,形成一个大范围的锁,避免了频繁加锁与释放锁的操作所造成的性能损耗


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!