Synchronized

synchronized的作用

在并发编程中存在线程安全问题,主要原因有:

  1. 存在共享数据;
  2. 多线程共同操作共享数据;
    关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,实现线程安全。

synchronized使用方式

synchronized可以是有在代码块和方法中,根据使用的位置不同,可以有这些使用场景:

分类具体分类被锁的对象代码示例
方法实例方法类的实例对象public synchronized void method(){
..............
}
静态方法类对象public static synchronized void method(){
........................
}
代码块实例对象类的实例对象synchronized(this){
...................
}
class对象类对象synchronized(Demo.class){
......................
}
任意实例对象Object实例对象ObjectString lock = "";
synchronized(lock){
.................
}

synchronized导致的死锁

死锁是两个或更多线程阻塞着等待其他处于死锁状态的线程所持有的锁。
死锁通常发生在多个线程同时但以不同的顺序请求同一组锁的时候。
例如:如果线程1锁住了A,然后尝试对B进行加锁,同时线程2已经锁住了B,接着尝试对A进行加锁,这时死锁就发生了。
线程1永远得不到B,线程2也永远得不到A,并且他们永远也不会知道发生了这样的情况。为了得到彼此的对象锁(A和B),他们将永远的阻塞下去。这种情况就是一个死锁。

synchronized的特性

在jdk1.5之前synchronized是一个重量级的锁,随着java se1.6对synchronized进行的各种优化后,synchronized并不会显得那么重了。
synchronized的作用特性:

  1. 原子性:确保线程互斥的访问同步代码
  2. 可见性:保证共享变量的修改能够及时可见,其实是通过java内存模型中的“对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assing操作初始化变量值。” 来保证的;
  3. 有序性:有效解决重排序问题,即“一个unlock操作现行发生(happen-before)于后面对同一个锁的lock操作”从语法上讲,synchronized可以把任何一个非null对象作为“锁”,在HotSpot JVM实现中,琐有个专门的名字:对象监视器(Object Monitor)。

synchronized的重进入

synchronized内置锁是一种对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
其可重入最大的作用是避免死锁,如:
子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁。

synchronized的原理

synchronized同步代码块

monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitorenter进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果该线程已经占有该monitor,只是重新进入,则monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
    monitorexit:执行monitorexit的线程必须是对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以去尝试获取这个monitor的所有权。

synchronize同步方法

当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
两种同步方法本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

synchronize的实现

Java对象头

在这里插入图片描述
实例数据:存放类的属性数据信息,包括父类的属性信息;
对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅 仅是为了字节对齐。
对象头:Java对象头一般占有2个机器码,但是如果是数组对象则需要3个,因为虚拟机可以通过对象元数据确定对象大小,但是无法从数组的元数据中确认数组的大小,所以另需一块记录数组长度。
synchronize用的锁就是存在Java对象头里的,Hotspot虚拟机的对象头包括两部分数据:
Mark Word(标记字段)
Class Pointer(类型指针)
其中Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Mark Word用于存储对象自身的运行时数据,它是实现轻量锁和偏向锁的关键。
Java对象头具体结构描述如下:
在这里插入图片描述
在这里插入图片描述
对象头信息是与对象自身定义的数据无关的额外的存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,可能变化为存储以下几种数据:
32位
在这里插入图片描述64位
在这里插入图片描述
对象头的最后两位存储了锁的标志位。
01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码。
随着锁级别的不同,对象头里会存储不同的内容。
偏向锁存储的是当前占用此线程的线程ID;
轻量级锁则存储指向线程栈中锁记录的指针。
可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较);
也可能是对象头里的线程ID(偏向锁时将线程的ID和对象头里处于存储的线程ID比较)。

Mark Word&Lock Record

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位为01 ,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录”(Lock Record)的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝只管重要。
Lock Word时线程私有的数据结构,每一个线程都有一个可用的Lock Word列表,同时还有一个可用的全局列表。
每一个被锁住的对象Mark Word都会和一个Lock Record关联,同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。
Lock Record的内部结构:

Lock Record描述
Owner初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
EntryQ关联系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程
RcThis标识blocked或waiting在该monitor record上的所有线程的个数;
Nest用来实现重入锁的计数;
HashCord保存从对象头拷贝过来的HashCord值(可能还包括GC age);
Candidate用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

Monitor

监视器(Monitor)
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。
Synchronized在JVM里的实现都是基于进入和推出Monitor对象来实现同步方法和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获取该对象的锁;
MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;
什么时Monitor,可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的Java对象是天生Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中,每一个Java对象都带了一把看不见的锁,它叫做内部锁或者Monitor锁。
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下:
在这里插入图片描述
ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_Owner指向拥有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:
首先会进入_EntryList集合,当线程获取到对象的monitor后,进入_Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒;
若当前线程执行完毕,释放monitor(锁)并复位count值,以便其他线程进入获取monitor(锁);
同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储指针的指向),Synchronized锁 便是通过这种方式获取锁的,这也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
监视器Monitor有两种同步方式:互斥和协作。
多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只有一个线程在访问。
一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写数据向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。
JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程释放它持有的监视器,直到其他线程通知它才会有执行的机会。
一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。
如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。
Object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总会有一个线程执行。

synchronized的优化

从JDK5引入了现代操作系统新增的CAS原子操作(JDK5中并没有对synchronized关键字优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能)
从JDK6开始,就对synchronized的实现机制进行了较大的调整,包括使用JDK5引进的CAS自旋之外,还增加了:
自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。
由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上自关键字还有优化的空间。

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,会给系统的并发性能带来很大的压力。
同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了一段很短的时间频繁的阻塞和唤醒线程是非常不值得的。所以引入自旋锁,何谓自旋锁:
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已经被其他线程占用,就一直循环检测锁是否被释放,而不是立刻进入线程挂起或睡眠状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它能避免线程切换带来的开销,但是它占用了CPU处理器的时间。
如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。
所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

适应性自旋锁

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

锁消除

如果方法中不存在并发性问题,JVM可以大胆的将加锁操作消除。

锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能的小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能的缩小,如果存在锁竞争,那么等待的线程也能尽快拿到锁。
在大多数情况下,上述的观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗化就是将将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。这样就可以避免多个小范围的锁竞争消耗性能。
例如:在for循环中对一个对象连续加锁、解锁操作,JVM检测到后就会合并成一个更大范围的加锁、解锁操作,即加锁、解锁操作就会移到for循环外。

synchronized的四种状态

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。但锁的升级时单向的,也就是说只能从低到高升级,不会出现锁的降级。

偏向锁

偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下,则一定会转化为轻量级锁或重量级锁。
引入偏向锁的目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS指令。

偏向锁减少不必要的CAS指令机制

在无竞争下,现在几乎所有的锁都是可以重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是设置个变量,如果发现为true则无需再走各种加锁/解锁流程。

CAS引起本地延迟原因

这要从SMP(堆成多处理器)架构说起,SMP结构如下:
在这里插入图片描述
所有的CPU会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构称为“对称多处理器”。
列如:
Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失),则会通过总线从主存中加载改地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

偏向锁处理流程

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要话费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识位以及线程ID即可,处理流程如下:

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若是可偏向状态,则检测线程ID是否为当前线程ID,如果是则执行步骤5,否则执行步骤3;
  3. 如果检测到线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行步骤4;
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块。

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动释放偏向锁,需要其他线程来竞争。
偏向锁的撤销需要等待全局安全点(这个时间点上没有正在执行的代码),其步骤如下:

  1. 暂停拥有偏向锁的线程;
  2. 判断所对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其他线程竞争。是,则挂起持有锁的当前线程,并将指向线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式。

此处将当前线程挂起再恢复的过程中并没有发生锁的转移,仍然在当前线程手中,只是穿插了个“将对象头中的线程ID变更为指向锁记录地址的指针”这么个事儿。
在这里插入图片描述

轻量级锁

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

轻量级锁处理流程

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

  1. 在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标记位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储所对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word。此时线程堆栈与对象头的状态如图所示。
    在这里插入图片描述
  2. 拷贝对象头中Mark Word复制到锁记录(Lock Record)中;
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock Record里的Owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5;
  4. 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,此时线程堆栈与对象头状态如图所示
    在这里插入图片描述
  5. 如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Record是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行,若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

在这里插入图片描述

重量级锁

Synchronized是通过对象内部的一个叫做监视器锁(Monitor)来实现的。但是监视器锁本质有事依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖操作系统Mutex Lock 所实现的锁我们称之为“重量级锁”。

锁的优缺点

各种锁并不是相互代替的,而是在不同的场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,开销逐渐加大。

  1. 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较一下对象头就可以了;
  2. 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
  3. 如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;

在第3种情况下,进入同步代码块就要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是要靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了。
所有使用哪种技术,一定要看其所处的环境及场景,在绝大多数的情况下,偏向锁是有效的,这是基于HotSpot 作者发现的“大多数锁只会由同一线程并发申请”的经验规律。

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距如果线程间存在所竞争,会带来额外的锁撤销的消耗·适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程使用自旋会消耗CPU追求响应时间 同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量 同步块执行时间较长
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值