synchronized锁

1. 实现原理

  synchronized 可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

Java中每一个对象都可以作为锁, 这是 synchronized 实现同步的基础:

  1. 普通同步方法, 锁是当前实例对象
  2. 静态同步方法,锁是当前类的 class 对象
  3. 同步方法块,锁是括号里面的对象
public class SynchronizedTest {

    public synchronized void test1() {

    }

    public void test2() {
        synchronized(this) {

        }
    }
    
}

javap -p -v .\test.class
在这里插入图片描述

从上面可以看出:1)同步代码块是使用 monitorentermonitorexit 指令实现的;2)同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED 实现

  • 同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM 需要保证每一个 monitorenter 都有一个 monitorexit 与之相对应。任何对象都有一个 Monitor 与之相关联,当且一个 Monitor 被持有之后,他将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。



2. Java 对象头、Monitor

Java 对象头和 Monitor 是实现 synchronized 的基础

2.1. 对象头

对象内存结构:
转载: https://www.jianshu.com/p/dff4bd49d25a

synchronized 用的锁是存在Java对象头里的。Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)

  • Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述 Mark Word 。
  • Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

在这里插入图片描述
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、偏向状态、线程持有的锁、偏向线程 ID、偏向时间戳等等。



2.2. Monitor

什么是 Monitor ? 我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

互斥: 一个 Monitor 锁在同一时刻只能被一个线程占用,其他线程无法占用。
信号机制( signal ): 占用 Monitor 锁失败的线程会暂时放弃竞争并等待某个谓词成真(条件变量),但该条件成立后,当前线程会通过释放锁通知正在等待这个条件变量的其他线程,让其可以重新竞争锁。

与一切皆对象一样,所有的 Java 对象是天生的 Monitor ,每一个 Java 对象都有成为Monitor 的潜质,因为在 Java 的设计中 ,每一个 Java 对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁




3. 锁优化

我们知道 synchronized 是重量级锁,效率不怎么滴,同时这个观念也一直存在我们脑海里,不过在 JDK 1.6 中对 synchronize 的实现进行了各种优化,使得它显得不是那么重了,那么 JVM 采用了那些优化手段呢?

  简单来说,在 JVM 中 monitorentermonitorexit 字节码依赖于底层的操作系统的Mutex Lock(互斥锁) 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。然而,在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境),如果每次都调用 Mutex Lock 那么将严重的影响程序的性能

因此,JDK 1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销



3.1. 自旋锁

由来
  线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

定义
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。

在这里插入图片描述

怎么等待呢?执行一段无意义的循环即可(自旋)。

自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。

所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 -XX:+UseSpinning 开开启。
在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整。

如果通过参数 -XX:PreBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为 10 ,但是系统很多线程都是等你刚刚退出的时候,就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是 JDK 1.6 引入自适应的自旋锁,让虚拟机会变得越来越聪明。



3.1.1. 适用性自旋锁

JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。



3.2. 锁消除

由来

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制。但是,在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。

定义

锁消除的依据是逃逸分析的数据支持。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐性的加锁操作。比如 StringBuffer 的 #append(…) 方法,Vector 的 add(…) 方法:

public void vectorTest(){
    Vector<String> vector = new Vector<String>();
    for (int i = 0 ; i < 10 ; i++){
    	vector.add(i + "");
    }
    System.out.println(vector);
}

在运行这段代码时,JVM 可以明显检测到变量 vector 没有逃逸出方法 #vectorTest() 之外,所以 JVM 可以大胆地将 vector 内部的加锁操作消除。



3.3. 锁粗化

由来

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小:仅在共享数据的实际作用域中才进行同步。这样做的目的,是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的,楼主 也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。

定义

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

如上面实例:vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。



3.4. 锁的升级

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。它们会随着竞争的激烈而逐渐升级。注意,锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率



3.4.1 重量级锁

重量级锁通过对象内部的监视器(Monitor)实现。

其中,Monitor 的本质是,依赖于底层操作系统的 Mutex Lock 实现。操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本非常高。



3.4.2 轻量级锁

引入轻量级锁的主要目的,是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

当关闭偏向锁功能或者多个线程竞争偏向锁,导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
在这里插入图片描述

释放锁

轻量级锁的释放也是通过 CAS 操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在 Displaced Mark Word 中 数据。
  2. 使用 CAS 操作将取出的数据替换锁对象的 Mark Word 中。如果成功,则说明释放锁成功;否则,执行(3)。
  3. 如果 CAS 操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。


3.4.3. 偏向锁

引入偏向锁主要目的是:为了在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。

上面提到了轻量级锁的加锁解锁操作,是需要依赖多次 CAS 原子指令的。那么偏向锁是如何来减少不必要的 CAS 操作呢?
  偏向的"偏",就是偏心的"偏",它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的ThreadId,以后该线程进入和退出同步块时只需要检查是否为偏向状态、偏向锁标志位以及ThreadId即可
在这里插入图片描述

撤销偏向锁
偏向锁的放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。

偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态。
  2. 撤销偏向锁,恢复到无锁状态( 01 )或者轻量级锁的状态。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值