并发编程 -- Synchronized浅析


前言

在并发编程中,有一个关键字搞java的人应该都知道,那就是Synchronized,但是你真的了解这个关键字吗,下面我们一起来走进Synchronized吧


一、Synchronized是什么?

Synchronized是java为了解决并发安全而提供的一个关键字.在介绍Synchronized之前先介绍几个概念:

1.1 临界区

此概念来源于<Java并发编程实战>,理解如下:

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    1. 多个线程读共享资源其实也没有问题
    2. 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

1.2 竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量
    synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换

1.3 互斥和同步

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

1.4 原子性 一致性 有序性

Synchronized保证了代码执行的原子性,有序性,一致性,那么什么是这三个特性是什么意思呢?(这三个特性的理解可以参考<深入理解Java虚拟机的12.3.5章节>)

1.4.1 原子性

原子操作: 不可被中断的一个或一系列操作
关于原子性,我的理解是:

  • 对于一段存在竞态条件的代码, 当并发执行时(即对于一个CPU而言), 由于CPU是以分配时间片来执行线程的,导致线程A的一段指令在执行过程中被切换到了线程B,此时便打破了原子性. 而Synchronized并不是说让线程A一直持有着CPU不做线程的上下文切换,而是当A的时间片用完了,当CPU尝试切换到B时,发现由于锁的存在,线程B或其他线程无法执行这段代码,故还是线程A继续来执行这段代码,这样便保证了这段有线程安全问题的代码是在一个线程中执行完成的,即保证了这段代码的原子性
  • 当这段代码并行执行时, 即被多个CPU同时进行操作,这样的操作便不是原子的.此时保证原子性就是保证这一时刻只有一个CPU来执行这段代码

1.4.2 一致性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改.这部分的理解可以看Volatile的讲解

1.4.3 有序性

同见Volatile

二、在哪里加锁?

Synchronized的加锁是锁住哪个对象?

  • 普通同步方法,锁住的是当前实例对象,即this,即谁调用的该方法便锁的是谁
  • 静态同步方法,锁住的是当前类的Class对象
  • 同步代码块锁住的是Synchronized(x)中的X,注: X要是一个对象

这部分的理解可以搜素"线程八锁"来看题理解

三、工作原理

Synchronized的加锁和解锁是基于Moniter对象来操作的,这也是我们印象中的重量级锁,这部分原理放在重量级锁中来讲解.
Synchronized是经常使用的,这么重肯定不行啊,于是从jdk6后便对Synchronized做了很大的优化来提供它的性能,即锁升级.
要了解锁升级就要先了解一个概念,对象头

1. 对象头

关于对象头的了解,可以参照<深入理解Java虚拟机>这本书的2.3.2对象的内存布局

  • 在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)。
  • HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据,如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部 分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它 为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的 最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效 率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根 据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态 下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年 龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标 记、可偏向)
    在这里插入图片描述
  • 对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针 来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话 说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论。此外,如果对 象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的 信息推断出数组的大小。

1.1 面试题

  • 一个Object对象在内存中占多少个字节
    答: 16个字节(默认64位虚拟机).在64位虚拟机中,一个Mark Word占64个bit, 类型指针占32个bit, 还有32个bit的padding, 故加起来是16个字节

1.2 总结

Synchronized的加锁和解锁与被加锁对象的对象头有关.即通过CAS修改对象头中的内容,和根绝对象头中的某些标志位来优化加锁

2 锁升级

从jdk6开始,为了减少获得锁和释放锁带来的性能损耗,引入了偏向锁和轻量级锁.
锁一共有四种状态,从低到高分别是: 无锁 > 偏向锁 > 轻量级锁 > 重量级锁,但并不是说Synchronized就是按照这个来一步步升级的,Synchronized首先使用的偏向锁

2.1 偏向锁

当总是由一个线程来获取锁时,便可以使用偏向锁来优化
偏向锁,它的意思是这个锁会偏向于第一个获得它的线 程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需 要再进行同步。

2.1.1 加锁流程
  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块
2.1.2 锁的撤销
  • 当其它线程尝试竞争偏向锁时,持有偏向锁的线程会释放锁.偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正 在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,
    • 如果线程不处于活动状态,则将对象头设置成无锁状态
    • 如果线程仍然活着,拥有偏向锁的栈 会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他 线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程
  • 这里我理解的解锁是: 因为当前模式是偏向锁,即有如果是偏向的线程来抢锁,那么直接执行代码块,即没有什么加锁解锁的流程, 但是如果有其他线程来竞争锁了,此时就涉及到偏向锁的释放了
  • 调用对象 hashCode : 而当一个对象当前正处于偏向锁状态,又收到需要 计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁
2.1.3 批量重偏向
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象
    的 Thread ID
  • 当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至
    加锁线程
2.1.4 批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

2.1.5 偏向锁的目的

为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,偏向锁可以减少不必要的CAS操作

2.1.6 总结
  • 偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,
    • 启用参数-XX:+UseBiased Locking,
    • 关闭偏向锁参数-XX:-UseBiased Locking
    • 关闭延迟参数:-XX:BiasedLockingStartupDelay=0
  • 偏向锁和无锁的锁标志位(2bit)都为01, 只有Mark Word中的偏向锁标志(1bit)为1表示偏向锁,为0表示无锁

2.2 轻量级锁

当偏向锁出现线程竞争时会升级为轻量级锁

2.2.1 加锁

轻量级锁的加锁过程是JVM会在当前线程的栈帧中创建锁记录(Lock Record),然后将对象头中的Mark Word复制到锁记录中,接着尝试使用CAS将Mark Word更新为指向锁记录的指针

  • 如果成功,则获得锁
  • 如果失败,则表示其他线程持有了锁,当前线程会尝试使用自旋在获取锁(在进入重量级锁之前尝试拯救下自己),而且是自适应自旋
2.2.2 解锁

轻量级锁的解锁时,会使用CAS操作尝试将锁记录中的Mark Word替换回到对象头中,

  • 如果成功,那么解锁成功,表示没有竞争发生
  • 如果失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程

2.4 自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。 何谓自旋锁? 所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。 自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。 自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整; 如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

2.4 适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

2.5 重量级锁

何为重量级锁,什么才叫重呢?
当其它线程抢不到锁,处于阻塞状态时,这便是重,因为线程由Runnable变为Blocking会有内核态和用户态的切换,这是很重的操作

2.5.1 Monitor

什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。 与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。 Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的,其主要数据结构如下:

 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 ;
  }

ObjectMonitor中有几个关键属性:

_owner:指向持有ObjectMonitor对象的线程

_WaitSet:存放处于wait状态的线程队列

_EntryList:存放处于等待锁block状态的线程队列

_recursions:锁的重入次数

_count:用来记录该线程获取锁的次数

2.5.1 加锁流程
  • 任何java对象都可以关联一个Monitor对象,当对一个对象加重量级锁后,JVM便会将Mark Word中的内容复制一份到Monitor对象中,然后将Mark Word更新为指向Monitor对象的指针
  • 当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_recursions加1。即获得对象锁,其他线程会进去EntryList中Blocked
  • 当线程A执行完代码块中的内容后,唤醒EntryList中等待的线程来竞争锁,竞争的时是非公平的
  • 若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_recursions自减1,同时该线程进入_WaitSet集合中等待被唤醒。
  • 若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值